diff --git a/tailbone/templates/ordering/create.mako b/tailbone/templates/ordering/create.mako
new file mode 100644
index 00000000..76c09a7a
--- /dev/null
+++ b/tailbone/templates/ordering/create.mako
@@ -0,0 +1,81 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/newbatch/create.mako" />
+
+<%def name="extra_javascript()">
+ ${parent.extra_javascript()}
+ ${self.func_show_mode()}
+
+%def>
+
+<%def name="func_show_mode()">
+
+%def>
+
+${parent.body()}
diff --git a/tailbone/templates/purchases/batches/order_form.mako b/tailbone/templates/ordering/order_form.mako
similarity index 97%
rename from tailbone/templates/purchases/batches/order_form.mako
rename to tailbone/templates/ordering/order_form.mako
index f2fb6d5c..d8a2485b 100644
--- a/tailbone/templates/purchases/batches/order_form.mako
+++ b/tailbone/templates/ordering/order_form.mako
@@ -1,4 +1,4 @@
-## -*- coding: utf-8 -*-
+## -*- coding: utf-8; -*-
<%inherit file="/base.mako" />
<%def name="title()">Purchase Order Form%def>
@@ -96,7 +96,7 @@
<%def name="context_menu_items()">
-
${h.link_to("Back to Purchase Batch", url('purchases.batch.view', uuid=batch.uuid))}
+ ${h.link_to("Back to {}".format(model_title), url('ordering.view', uuid=batch.uuid))}
%def>
@@ -146,7 +146,7 @@
${self.order_form_grid()}
-${h.form(url('purchases.batch.order_form_update', uuid=batch.uuid), id='item-update-form', style='display: none;')}
+${h.form(url('ordering.order_form_update', uuid=batch.uuid), id='item-update-form', style='display: none;')}
${h.csrf_token(request)}
${h.hidden('product_uuid')}
${h.hidden('cases_ordered')}
diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako
new file mode 100644
index 00000000..45672de4
--- /dev/null
+++ b/tailbone/templates/ordering/view.mako
@@ -0,0 +1,36 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/newbatch/view.mako" />
+
+<%def name="extra_javascript()">
+ ${parent.extra_javascript()}
+
+%def>
+
+<%def name="extra_styles()">
+ ${parent.extra_styles()}
+ ${h.stylesheet_link(request.static_url('tailbone:static/css/purchases.css'))}
+%def>
+
+<%def name="leading_buttons()">
+ % if not batch.complete and not batch.executed and request.has_perm('ordering.order_form'):
+
+ % endif
+%def>
+
+${parent.body()}
diff --git a/tailbone/views/purchasing/__init__.py b/tailbone/views/purchasing/__init__.py
new file mode 100644
index 00000000..52b6a4e0
--- /dev/null
+++ b/tailbone/views/purchasing/__init__.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2017 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
+# more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Rattail. If not, see .
+#
+################################################################################
+"""
+Views for purchasing batches
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from .batch import PurchasingBatchView
+
+
+def includeme(config):
+ config.include('tailbone.views.purchasing.ordering')
diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
new file mode 100644
index 00000000..03988727
--- /dev/null
+++ b/tailbone/views/purchasing/batch.py
@@ -0,0 +1,532 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2017 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
+# more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Rattail. If not, see .
+#
+################################################################################
+"""
+Base views for purchasing batches
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+# from sqlalchemy import orm
+
+from rattail.db import model, api
+# from rattail.gpc import GPC
+from rattail.time import localtime
+
+import formalchemy as fa
+from pyramid import httpexceptions
+
+from tailbone import forms, newgrids as grids
+from tailbone.views.batch import BatchMasterView
+
+
+class PurchasingBatchView(BatchMasterView):
+ """
+ Master view for purchase order batches.
+ """
+ model_class = model.PurchaseBatch
+ model_row_class = model.PurchaseBatchRow
+ default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
+ rows_creatable = True
+ rows_editable = True
+ edit_with_rows = False
+
+ @property
+ def batch_mode(self):
+ raise NotImplementedError("Please define `batch_mode` for your purchasing batch view")
+
+ def query(self, session):
+ return session.query(model.PurchaseBatch)\
+ .filter(model.PurchaseBatch.mode == self.batch_mode)
+
+ def _preconfigure_grid(self, g):
+ super(PurchasingBatchView, self)._preconfigure_grid(g)
+ del g.filters['mode']
+
+ g.joiners['vendor'] = lambda q: q.join(model.Vendor)
+ g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name,
+ default_active=True, default_verb='contains')
+ g.sorters['vendor'] = g.make_sorter(model.Vendor.name)
+
+ g.joiners['department'] = lambda q: q.join(model.Department)
+ g.filters['department'] = g.make_filter('department', model.Department.name)
+ g.sorters['department'] = g.make_sorter(model.Department.name)
+
+ g.joiners['buyer'] = lambda q: q.join(model.Employee).join(model.Person)
+ g.filters['buyer'] = g.make_filter('buyer', model.Person.display_name,
+ default_active=True, default_verb='contains')
+ g.sorters['buyer'] = g.make_sorter(model.Person.display_name)
+
+ if self.request.has_perm('{}.execute'.format(self.get_permission_prefix())):
+ g.filters['complete'].default_active = True
+ g.filters['complete'].default_verb = 'is_true'
+
+ g.date_ordered.set(label="Ordered")
+ g.date_received.set(label="Received")
+
+ def configure_grid(self, g):
+ g.configure(
+ include=[
+ g.id,
+ g.vendor,
+ g.department,
+ g.buyer,
+ g.date_ordered,
+ g.created,
+ g.created_by,
+ g.executed,
+ ],
+ readonly=True)
+
+# def make_form(self, batch, **kwargs):
+# if self.creating:
+# kwargs.setdefault('id', 'new-purchase-form')
+# form = super(PurchasingBatchView, self).make_form(batch, **kwargs)
+# return form
+
+ def _preconfigure_fieldset(self, fs):
+ super(PurchasingBatchView, self)._preconfigure_fieldset(fs)
+ fs.mode.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.PURCHASE_BATCH_MODE))
+ fs.store.set(renderer=forms.renderers.StoreFieldRenderer)
+ fs.purchase.set(renderer=forms.renderers.PurchaseFieldRenderer, options=[])
+ fs.vendor.set(renderer=forms.renderers.VendorFieldRenderer,
+ attrs={'selected': 'vendor_selected',
+ 'cleared': 'vendor_cleared'})
+ fs.department.set(renderer=forms.renderers.DepartmentFieldRenderer,
+ options=self.get_department_options())
+ fs.buyer.set(renderer=forms.renderers.EmployeeFieldRenderer)
+ fs.po_number.set(label="PO Number")
+ fs.po_total.set(label="PO Total", readonly=True, renderer=forms.renderers.CurrencyFieldRenderer)
+ fs.invoice_total.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer)
+ fs.notes.set(renderer=fa.TextAreaFieldRenderer, size=(80, 10))
+
+ fs.append(fa.Field('vendor_email', readonly=True,
+ value=lambda b: b.vendor.email.address if b.vendor.email else None))
+ fs.append(fa.Field('vendor_fax', readonly=True,
+ value=self.get_vendor_fax_number))
+ fs.append(fa.Field('vendor_contact', readonly=True,
+ value=lambda b: b.vendor.contact or None))
+ fs.append(fa.Field('vendor_phone', readonly=True,
+ value=self.get_vendor_phone_number))
+
+ def get_department_options(self):
+ departments = self.Session.query(model.Department).order_by(model.Department.number)
+ return [('{} {}'.format(d.number, d.name), d.uuid) for d in departments]
+
+ def get_vendor_phone_number(self, batch):
+ for phone in batch.vendor.phones:
+ if phone.type == 'Voice':
+ return phone.number
+
+ def get_vendor_fax_number(self, batch):
+ for phone in batch.vendor.phones:
+ if phone.type == 'Fax':
+ return phone.number
+
+ def configure_fieldset(self, fs):
+ fs.configure(
+ include=[
+ fs.id,
+ fs.store,
+ fs.buyer,
+ fs.vendor,
+ fs.department,
+ fs.purchase,
+ fs.vendor_email,
+ fs.vendor_fax,
+ fs.vendor_contact,
+ fs.vendor_phone,
+ fs.date_ordered,
+ fs.date_received,
+ fs.po_number,
+ fs.po_total,
+ fs.invoice_date,
+ fs.invoice_number,
+ fs.invoice_total,
+ fs.notes,
+ fs.created,
+ fs.created_by,
+ fs.complete,
+ fs.executed,
+ fs.executed_by,
+ ])
+
+ if self.creating:
+ del fs.po_total
+ del fs.invoice_total
+ del fs.complete
+ del fs.vendor_email
+ del fs.vendor_fax
+ del fs.vendor_phone
+ del fs.vendor_contact
+
+ # default store may be configured
+ store = self.rattail_config.get('rattail', 'store')
+ if store:
+ store = api.get_store(self.Session(), store)
+ if store:
+ fs.model.store = store
+
+ # default buyer is current user
+ if self.request.method != 'POST':
+ buyer = self.request.user.employee
+ if buyer:
+ fs.model.buyer = buyer
+
+ # TODO: something tells me this isn't quite safe..
+ # all dates have today as default
+ today = localtime(self.rattail_config).date()
+ fs.model.date_ordered = today
+ fs.model.date_received = today
+
+ elif self.editing:
+ fs.store.set(readonly=True)
+ fs.vendor.set(readonly=True)
+ fs.department.set(readonly=True)
+ fs.purchase.set(readonly=True)
+
+ def eligible_purchases(self, vendor_uuid=None, mode=None):
+ if not vendor_uuid:
+ vendor_uuid = self.request.GET.get('vendor_uuid')
+ vendor = self.Session.query(model.Vendor).get(vendor_uuid) if vendor_uuid else None
+ if not vendor:
+ return {'error': "Must specify a vendor."}
+
+ if mode is None:
+ mode = self.request.GET.get('mode')
+ mode = int(mode) if mode and mode.isdigit() else None
+ if not mode or mode not in self.enum.PURCHASE_BATCH_MODE:
+ return {'error': "Unknown mode: {}".format(mode)}
+
+ purchases = self.Session.query(model.Purchase)\
+ .filter(model.Purchase.vendor == vendor)
+ if mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
+ purchases = purchases.filter(model.Purchase.status == self.enum.PURCHASE_STATUS_ORDERED)\
+ .order_by(model.Purchase.date_ordered, model.Purchase.created)
+ elif mode == self.enum.PURCHASE_BATCH_MODE_COSTING:
+ purchases = purchases.filter(model.Purchase.status == self.enum.PURCHASE_STATUS_RECEIVED)\
+ .order_by(model.Purchase.date_received, model.Purchase.created)
+
+ return {'purchases': [{'key': p.uuid,
+ 'department_uuid': p.department_uuid or '',
+ 'display': self.render_eligible_purchase(p)}
+ for p in purchases]}
+
+ def render_eligible_purchase(self, purchase):
+ if purchase.status == self.enum.PURCHASE_STATUS_ORDERED:
+ date = purchase.date_ordered
+ total = purchase.po_total
+ elif purchase.status == self.enum.PURCHASE_STATUS_RECEIVED:
+ date = purchase.date_received
+ total = purchase.invoice_total
+ return '{} for ${:0,.2f} ({})'.format(date, total, purchase.department or purchase.buyer)
+
+ def get_batch_kwargs(self, batch, mobile=False):
+ kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
+ kwargs['mode'] = self.batch_mode
+ if batch.store:
+ kwargs['store'] = batch.store
+ elif batch.store_uuid:
+ kwargs['store_uuid'] = batch.store_uuid
+ if batch.vendor:
+ kwargs['vendor'] = batch.vendor
+ elif batch.vendor_uuid:
+ kwargs['vendor_uuid'] = batch.vendor_uuid
+ if batch.department:
+ kwargs['department'] = batch.department
+ elif batch.department_uuid:
+ kwargs['department_uuid'] = batch.department_uuid
+ if batch.buyer:
+ kwargs['buyer'] = batch.buyer
+ elif batch.buyer_uuid:
+ kwargs['buyer_uuid'] = batch.buyer_uuid
+ kwargs['po_number'] = batch.po_number
+
+ # TODO: should these always get set?
+ if self.batch_mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
+ kwargs['date_ordered'] = batch.date_ordered
+ elif self.batch_mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
+ kwargs['date_ordered'] = batch.date_ordered
+ kwargs['date_received'] = batch.date_received
+ kwargs['invoice_number'] = batch.invoice_number
+ elif self.batch_mode == self.enum.PURCHASE_BATCH_MODE_COSTING:
+ kwargs['invoice_date'] = batch.invoice_date
+ kwargs['invoice_number'] = batch.invoice_number
+
+ if self.batch_mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
+ self.enum.PURCHASE_BATCH_MODE_COSTING):
+ if batch.purchase_uuid:
+ purchase = self.Session.query(model.Purchase).get(batch.purchase_uuid)
+ assert purchase
+ kwargs['purchase'] = purchase
+ kwargs['buyer'] = purchase.buyer
+ kwargs['buyer_uuid'] = purchase.buyer_uuid
+ kwargs['date_ordered'] = purchase.date_ordered
+ kwargs['po_total'] = purchase.po_total
+
+ return kwargs
+
+# def template_kwargs_view(self, **kwargs):
+# kwargs = super(PurchasingBatchView, self).template_kwargs_view(**kwargs)
+# vendor = kwargs['batch'].vendor
+# kwargs['vendor_cost_count'] = Session.query(model.ProductCost)\
+# .filter(model.ProductCost.vendor == vendor)\
+# .count()
+# kwargs['vendor_cost_threshold'] = self.rattail_config.getint(
+# 'tailbone', 'purchases.order_form.vendor_cost_warning_threshold', default=699)
+# return kwargs
+
+ def template_kwargs_create(self, **kwargs):
+ kwargs['purchases_field'] = 'purchase_uuid'
+ return kwargs
+
+# def get_row_data(self, batch):
+# query = super(PurchasingBatchView, self).get_row_data(batch)
+# return query.options(orm.joinedload(model.PurchaseBatchRow.credits))
+
+ def _preconfigure_row_grid(self, g):
+ super(PurchasingBatchView, self)._preconfigure_row_grid(g)
+
+ g.filters['upc'].label = "UPC"
+ g.filters['brand_name'].label = "Brand"
+
+ g.upc.set(label="UPC")
+ g.brand_name.set(label="Brand")
+ g.cases_ordered.set(label="Cases Ord.", renderer=forms.renderers.QuantityFieldRenderer)
+ g.units_ordered.set(label="Units Ord.", renderer=forms.renderers.QuantityFieldRenderer)
+ g.cases_received.set(label="Cases Rec.", renderer=forms.renderers.QuantityFieldRenderer)
+ g.units_received.set(label="Units Rec.", renderer=forms.renderers.QuantityFieldRenderer)
+ g.po_total.set(label="Total", renderer=forms.renderers.CurrencyFieldRenderer)
+ g.invoice_total.set(label="Total", renderer=forms.renderers.CurrencyFieldRenderer)
+ g.append(fa.Field('has_credits', type=fa.types.Boolean, label="Credits?",
+ value=lambda row: bool(row.credits)))
+
+ def configure_row_grid(self, g):
+ batch = self.get_instance()
+
+ g.configure(
+ include=[
+ g.sequence,
+ g.upc,
+ # g.item_id,
+ g.brand_name,
+ g.description,
+ g.size,
+ g.cases_ordered,
+ g.units_ordered,
+ g.cases_received,
+ g.units_received,
+ g.po_total,
+ g.invoice_total,
+ g.has_credits,
+ g.status_code,
+ ],
+ readonly=True)
+
+ if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
+ del g.cases_received
+ del g.units_received
+ del g.has_credits
+ del g.invoice_total
+ elif batch.mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
+ self.enum.PURCHASE_BATCH_MODE_COSTING):
+ del g.po_total
+
+ def make_row_grid_tools(self, batch):
+ return self.make_default_row_grid_tools(batch)
+
+ def row_grid_row_attrs(self, row, i):
+ attrs = {}
+ if row.status_code == row.STATUS_PRODUCT_NOT_FOUND:
+ attrs['class_'] = 'warning'
+ elif row.status_code in (row.STATUS_INCOMPLETE,
+ row.STATUS_ORDERED_RECEIVED_DIFFER):
+ attrs['class_'] = 'notice'
+ return attrs
+
+ def _preconfigure_row_fieldset(self, fs):
+ super(PurchasingBatchView, self)._preconfigure_row_fieldset(fs)
+ fs.upc.set(label="UPC")
+ fs.brand_name.set(label="Brand")
+ fs.case_quantity.set(renderer=forms.renderers.QuantityFieldRenderer, readonly=True)
+ fs.cases_ordered.set(renderer=forms.renderers.QuantityFieldRenderer)
+ fs.units_ordered.set(renderer=forms.renderers.QuantityFieldRenderer)
+ fs.cases_received.set(renderer=forms.renderers.QuantityFieldRenderer)
+ fs.units_received.set(renderer=forms.renderers.QuantityFieldRenderer)
+ fs.cases_damaged.set(renderer=forms.renderers.QuantityFieldRenderer)
+ fs.units_damaged.set(renderer=forms.renderers.QuantityFieldRenderer)
+ fs.cases_expired.set(renderer=forms.renderers.QuantityFieldRenderer)
+ fs.units_expired.set(renderer=forms.renderers.QuantityFieldRenderer)
+ fs.cases_mispick.set(renderer=forms.renderers.QuantityFieldRenderer)
+ fs.units_mispick.set(renderer=forms.renderers.QuantityFieldRenderer)
+ fs.po_line_number.set(label="PO Line Number")
+ fs.po_unit_cost.set(label="PO Unit Cost", renderer=forms.renderers.CurrencyFieldRenderer)
+ fs.po_total.set(label="PO Total", renderer=forms.renderers.CurrencyFieldRenderer)
+ fs.invoice_unit_cost.set(renderer=forms.renderers.CurrencyFieldRenderer)
+ fs.invoice_total.set(renderer=forms.renderers.CurrencyFieldRenderer)
+ fs.credits.set(readonly=True)
+ # fs.append(fa.Field('item_lookup', label="Item Lookup Code", required=True,
+ # validate=self.item_lookup))
+
+# def item_lookup(self, value, field=None):
+# """
+# Try to locate a single product using ``value`` as a lookup code.
+# """
+# batch = self.get_instance()
+# product = api.get_product_by_vendor_code(Session(), value, vendor=batch.vendor)
+# if product:
+# return product.uuid
+# if value.isdigit():
+# product = api.get_product_by_upc(Session(), GPC(value))
+# if not product:
+# product = api.get_product_by_upc(Session(), GPC(value, calc_check_digit='upc'))
+# if product:
+# if not product.cost_for_vendor(batch.vendor):
+# raise fa.ValidationError("Product {} exists but has no cost for vendor {}".format(
+# product.upc.pretty(), batch.vendor))
+# return product.uuid
+# raise fa.ValidationError("Product not found")
+
+ def configure_row_fieldset(self, fs):
+ try:
+ batch = self.get_instance()
+ except httpexceptions.HTTPNotFound:
+ batch = self.get_row_instance().batch
+
+ fs.configure(
+ include=[
+ # fs.item_lookup,
+ fs.upc,
+ fs.product,
+ fs.brand_name,
+ fs.description,
+ fs.size,
+ fs.case_quantity,
+ fs.cases_ordered,
+ fs.units_ordered,
+ fs.cases_received,
+ fs.units_received,
+ fs.cases_damaged,
+ fs.units_damaged,
+ fs.cases_expired,
+ fs.units_expired,
+ fs.cases_mispick,
+ fs.units_mispick,
+ fs.po_line_number,
+ fs.po_unit_cost,
+ fs.po_total,
+ fs.invoice_line_number,
+ fs.invoice_unit_cost,
+ fs.invoice_total,
+ fs.status_code,
+ fs.credits,
+ ])
+
+ if self.creating:
+ del fs.upc
+ del fs.product
+ del fs.po_total
+ del fs.invoice_total
+ if self.batch_mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
+ del fs.cases_received
+ del fs.units_received
+ elif self.batch_mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
+ del fs.cases_ordered
+ del fs.units_ordered
+
+ elif self.editing:
+ # del fs.item_lookup
+ fs.upc.set(readonly=True)
+ fs.product.set(readonly=True)
+ del fs.po_total
+ del fs.invoice_total
+ del fs.status_code
+
+ elif self.viewing:
+ # del fs.item_lookup
+ if fs.model.product:
+ del (fs.brand_name,
+ fs.description,
+ fs.size)
+ else:
+ del fs.product
+
+# def before_create_row(self, form):
+# row = form.fieldset.model
+# batch = self.get_instance()
+# batch.add_row(row)
+# # TODO: this seems heavy-handed but works..
+# row.product_uuid = self.item_lookup(form.fieldset.item_lookup.value)
+
+# def after_create_row(self, row):
+# self.handler.refresh_row(row)
+
+# def after_edit_row(self, row):
+# batch = row.batch
+
+# # first undo any totals previously in effect for the row
+# if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING and row.po_total:
+# batch.po_total -= row.po_total
+# elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING and row.invoice_total:
+# batch.invoice_total -= row.invoice_total
+
+# self.handler.refresh_row(row)
+
+# def redirect_after_create_row(self, row):
+# self.request.session.flash("Added item: {} {}".format(row.upc.pretty(), row.product))
+# return self.redirect(self.request.current_route_url())
+
+# def delete_row(self):
+# """
+# Update the PO total in addition to marking row as removed.
+# """
+# row = self.Session.query(self.model_row_class).get(self.request.matchdict['uuid'])
+# if not row:
+# raise httpexceptions.HTTPNotFound()
+# if row.po_total:
+# row.batch.po_total -= row.po_total
+# if row.invoice_total:
+# row.batch.invoice_total -= row.invoice_total
+# row.removed = True
+# return self.redirect(self.get_action_url('view', row.batch))
+
+# def get_execute_success_url(self, batch, result, **kwargs):
+# # if batch execution yielded a Purchase, redirect to it
+# if isinstance(result, model.Purchase):
+# return self.request.route_url('purchases.view', uuid=result.uuid)
+
+# # otherwise just view batch again
+# return self.get_action_url('view', batch)
+
+
+ @classmethod
+ def _purchasing_defaults(cls, config):
+ route_prefix = cls.get_route_prefix()
+ url_prefix = cls.get_url_prefix()
+ permission_prefix = cls.get_permission_prefix()
+ model_key = cls.get_model_key()
+ model_title = cls.get_model_title()
+
+ # eligible purchases (AJAX)
+ config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(url_prefix))
+ config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix),
+ renderer='json', permission='{}.view'.format(permission_prefix))
diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py
new file mode 100644
index 00000000..e2f145bd
--- /dev/null
+++ b/tailbone/views/purchasing/ordering.py
@@ -0,0 +1,249 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2017 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
+# more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Rattail. If not, see .
+#
+################################################################################
+"""
+Views for 'ordering' (purchasing) batches
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from sqlalchemy import orm
+
+from rattail.db import model, api
+from rattail.core import Object
+from rattail.time import localtime
+
+from tailbone import forms
+from tailbone.views.purchasing import PurchasingBatchView
+
+
+class OrderingBatchView(PurchasingBatchView):
+ """
+ Master view for purchase order batches.
+ """
+ route_prefix = 'ordering'
+ url_prefix = '/ordering'
+ model_title = "Ordering Batch"
+ model_title_plural = "Ordering Batches"
+ rows_creatable = False
+ rows_editable = False
+
+ order_form_header_columns = [
+ "UPC",
+ "Brand",
+ "Description",
+ "Case",
+ "Vend. Code",
+ "Pref.",
+ "Unit Cost",
+ ]
+
+ @property
+ def batch_mode(self):
+ return self.enum.PURCHASE_BATCH_MODE_ORDERING
+
+ def order_form(self):
+ """
+ View for editing batch row data as an order form.
+ """
+ batch = self.get_instance()
+ if batch.executed:
+ return self.redirect(self.get_action_url('view', batch))
+
+ # organize existing batch rows by product
+ order_items = {}
+ for row in batch.data_rows:
+ if not row.removed:
+ order_items[row.product_uuid] = row
+
+ # organize vendor catalog costs by dept / subdept
+ departments = {}
+ costs = self.get_order_form_costs(batch.vendor)
+ costs = self.sort_order_form_costs(costs)
+ for cost in costs:
+
+ department = cost.product.department
+ if department:
+ departments.setdefault(department.uuid, department)
+ else:
+ if None not in departments:
+ department = Object(name=None, number=None)
+ departments[None] = department
+ department = departments[None]
+
+ subdepartments = getattr(department, '_order_subdepartments', None)
+ if subdepartments is None:
+ subdepartments = department._order_subdepartments = {}
+
+ subdepartment = cost.product.subdepartment
+ if subdepartment:
+ subdepartments.setdefault(subdepartment.uuid, subdepartment)
+ else:
+ if None not in subdepartments:
+ subdepartment = Object(name=None, number=None)
+ subdepartments[None] = subdepartment
+ subdepartment = subdepartments[None]
+
+ subdept_costs = getattr(subdepartment, '_order_costs', None)
+ if subdept_costs is None:
+ subdept_costs = subdepartment._order_costs = []
+ subdept_costs.append(cost)
+ cost._batchrow = order_items.get(cost.product_uuid)
+
+ # do anything else needed to satisfy template display requirements etc.
+ self.decorate_order_form_cost(cost)
+
+ # fetch recent purchase history, sort/pad for template convenience
+ history = self.get_order_form_history(batch, costs, 6)
+ for i in range(6 - len(history)):
+ history.append(None)
+ history = list(reversed(history))
+
+ title = self.get_instance_title(batch)
+ return self.render_to_response('order_form', {
+ 'batch': batch,
+ 'instance': batch,
+ 'instance_title': title,
+ 'index_title': "{}: {}".format(self.get_model_title(), title),
+ 'index_url': self.get_action_url('view', batch),
+ 'vendor': batch.vendor,
+ 'departments': departments,
+ 'history': history,
+ 'get_upc': lambda p: p.upc.pretty() if p.upc else '',
+ 'header_columns': self.order_form_header_columns,
+ 'ignore_cases': self.handler.ignore_cases,
+ })
+
+ def get_order_form_history(self, batch, costs, count):
+
+ # fetch last 6 purchases for this vendor, organize line items by product
+ history = []
+ purchases = self.Session.query(model.Purchase)\
+ .filter(model.Purchase.vendor == batch.vendor)\
+ .filter(model.Purchase.status >= self.enum.PURCHASE_STATUS_ORDERED)\
+ .order_by(model.Purchase.date_ordered.desc(), model.Purchase.created.desc())\
+ .options(orm.joinedload(model.Purchase.items))
+ for purchase in purchases[:count]:
+ items = {}
+ for item in purchase.items:
+ items[item.product_uuid] = item
+ history.append({'purchase': purchase, 'items': items})
+
+ return history
+
+ def get_order_form_costs(self, vendor):
+ return self.Session.query(model.ProductCost)\
+ .join(model.Product)\
+ .outerjoin(model.Brand)\
+ .filter(model.ProductCost.vendor == vendor)\
+ .options(orm.joinedload(model.ProductCost.product)\
+ .joinedload(model.Product.department))\
+ .options(orm.joinedload(model.ProductCost.product)\
+ .joinedload(model.Product.subdepartment))
+
+ def sort_order_form_costs(self, costs):
+ return costs.order_by(model.Brand.name,
+ model.Product.description,
+ model.Product.size)
+
+ def decorate_order_form_cost(self, cost):
+ pass
+
+ def order_form_update(self):
+ """
+ Handles AJAX requests to update current batch, from Order Form view.
+ """
+ batch = self.get_instance()
+
+ cases_ordered = self.request.POST.get('cases_ordered', '0')
+ if not cases_ordered or not cases_ordered.isdigit():
+ return {'error': "Invalid value for cases ordered: {}".format(cases_ordered)}
+ cases_ordered = int(cases_ordered)
+
+ units_ordered = self.request.POST.get('units_ordered', '0')
+ if not units_ordered or not units_ordered.isdigit():
+ return {'error': "Invalid value for units ordered: {}".format(units_ordered)}
+ units_ordered = int(units_ordered)
+
+ uuid = self.request.POST.get('product_uuid')
+ product = self.Session.query(model.Product).get(uuid) if uuid else None
+ if not product:
+ return {'error': "Product not found"}
+
+ row = None
+ rows = [r for r in batch.data_rows if r.product_uuid == uuid]
+ if rows:
+ assert len(rows) == 1
+ row = rows[0]
+ if row.po_total and not row.removed:
+ batch.po_total -= row.po_total
+ if cases_ordered or units_ordered:
+ row.cases_ordered = cases_ordered or None
+ row.units_ordered = units_ordered or None
+ row.removed = False
+ self.handler.refresh_row(row)
+ else:
+ row.removed = True
+
+ elif cases_ordered or units_ordered:
+ row = model.PurchaseBatchRow()
+ row.sequence = max([0] + [r.sequence for r in batch.data_rows]) + 1
+ row.product = product
+ batch.data_rows.append(row)
+ row.cases_ordered = cases_ordered or None
+ row.units_ordered = units_ordered or None
+ self.handler.refresh_row(row)
+
+ return {
+ 'row_cases_ordered': '' if not row or row.removed else int(row.cases_ordered or 0),
+ 'row_units_ordered': '' if not row or row.removed else int(row.units_ordered or 0),
+ 'row_po_total': '' if not row or row.removed else '${:0,.2f}'.format(row.po_total),
+ 'batch_po_total': '${:0,.2f}'.format(batch.po_total or 0),
+ }
+
+
+ @classmethod
+ def defaults(cls, config):
+ route_prefix = cls.get_route_prefix()
+ url_prefix = cls.get_url_prefix()
+ permission_prefix = cls.get_permission_prefix()
+ model_key = cls.get_model_key()
+ model_title = cls.get_model_title()
+
+ # defaults
+ cls._purchasing_defaults(config)
+ cls._batch_defaults(config)
+ cls._defaults(config)
+
+ # ordering form
+ config.add_tailbone_permission(permission_prefix, '{}.order_form'.format(permission_prefix),
+ "Edit new {} in Order Form mode".format(model_title))
+ config.add_route('{}.order_form'.format(route_prefix), '{}/{{{}}}/order-form'.format(url_prefix, model_key))
+ config.add_view(cls, attr='order_form', route_name='{}.order_form'.format(route_prefix),
+ permission='{}.order_form'.format(permission_prefix))
+ config.add_route('{}.order_form_update'.format(route_prefix), '{}/{{{}}}/order-form/update'.format(url_prefix, model_key))
+ config.add_view(cls, attr='order_form_update', route_name='{}.order_form_update'.format(route_prefix),
+ renderer='json', permission='{}.order_form'.format(permission_prefix))
+
+
+def includeme(config):
+ OrderingBatchView.defaults(config)