diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js index fb392425..6f94c2f6 100644 --- a/tailbone/static/js/tailbone.mobile.js +++ b/tailbone/static/js/tailbone.mobile.js @@ -39,12 +39,14 @@ $(document).on('pagecreate', function() { /** * Automatically set focus to certain fields, on various pages + * TODO: this should accept selector params instead of hard-coding..? */ function setfocus() { var el = null; var queries = [ '#username', '#new-purchasing-batch-vendor-text', + // '.receiving-upc-search', ]; $.each(queries, function(i, query) { el = $(query); @@ -92,3 +94,60 @@ $(document).on('click', 'form[name="new-purchasing-batch"] [data-role="listview" $(document).on('click', '#datasync-restart', function() { $(this).button('disable'); }); + + + +// handle Enter press for receiving UPC lookup +$(document).on('keydown', '.receiving-upc-search', function(event) { + if (event.which == 13) { + $.mobile.navigate($(this).data('url') + '?upc=' + $(this).val()); + } +}); + + +// handle numeric buttons for receiving +// $(document).on('click', '#receiving-quantity-keypad-thingy .ui-btn', function() { +$(document).on('click', '#receiving-quantity-keypad-thingy .keypad-button', function() { + var quantity = $('.receiving-quantity'); + var value = quantity.text(); + var key = $(this).text(); + if (key == 'Del') { + if (value.length == 1) { + quantity.text('0'); + } else { + quantity.text(value.substring(0, value.length - 1)); + } + } else if (key == '.') { + if (value.indexOf('.') == -1) { + quantity.text(value + '.'); + } + } else { + if (value == '0') { + quantity.text(key); + } else { + quantity.text(value + key); + } + } +}); + + +// handle receiving action buttons +$(document).on('click', '.receiving-actions button', function() { + var action = $(this).data('action'); + var form = $('form.receiving-update'); + var uom = form.find('[name="receiving-uom"]').val(); + var qty = form.find('.receiving-quantity').text(); + if (action == 'add' || action == 'subtract') { + if (qty != '0') { + if (action == 'subtract') { + qty = '-' + qty; + } + if (uom == 'CS') { + form.find('[name="cases"]').val(qty); + } else { // units + form.find('[name="units"]').val(qty); + } + form.submit(); + } + } +}); diff --git a/tailbone/templates/mobile/master/view_row.mako b/tailbone/templates/mobile/master/view_row.mako new file mode 100644 index 00000000..74df7e9e --- /dev/null +++ b/tailbone/templates/mobile/master/view_row.mako @@ -0,0 +1,4 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/master/view.mako" /> + +${parent.body()} diff --git a/tailbone/templates/mobile/receiving/create.mako b/tailbone/templates/mobile/receiving/create.mako new file mode 100644 index 00000000..a82ee3aa --- /dev/null +++ b/tailbone/templates/mobile/receiving/create.mako @@ -0,0 +1,48 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/base.mako" /> + +<%def name="title()">${h.link_to("Receiving", url('mobile.receiving'))} » New Batch + +${h.form(request.current_route_url(), class_='ui-filterable', name='new-purchasing-batch')} +${h.csrf_token(request)} + +% if vendor is Undefined: + +
+
+ ${h.hidden('vendor')} + ${h.text('new-purchasing-batch-vendor-text', placeholder="Vendor name", autocomplete='off', **{'data-type': 'search'})} + + +
+
+ +
+ ${h.submit('submit', "Find purchase orders")} + ## + +% else: ## vendor is known + +
+
+ ${h.hidden('vendor', value=vendor.uuid)} + ${vendor} +
+
+ + % if purchases: + ${h.hidden('purchase')} + + % else: +

(no eligible purchases found)

+ % endif + + ## ${h.link_to("Receive from scratch for {}".format(vendor), '#', class_='ui-btn ui-corner-all')} + +% endif + +${h.end_form()} diff --git a/tailbone/templates/mobile/receiving/index.mako b/tailbone/templates/mobile/receiving/index.mako new file mode 100644 index 00000000..098a0078 --- /dev/null +++ b/tailbone/templates/mobile/receiving/index.mako @@ -0,0 +1,18 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/master/index.mako" /> + +<%def name="title()">Receiving + +% if request.has_perm('receiving.create'): + ${h.link_to("New Receiving Batch", url('mobile.receiving.create'), class_='ui-btn ui-corner-all')} +% endif + +
+ ${h.radio('receiving-filter', value='pending', label="Pending", checked=True)} + ${h.radio('receiving-filter', value='complete', label="Complete", disabled='disabled')} + ${h.radio('receiving-filter', value='executed', label="Executed", disabled='disabled')} + ${h.radio('receiving-filter', value='all', label="All", disabled='disabled')} +
+

+ +${parent.body()} diff --git a/tailbone/templates/mobile/receiving/view.mako b/tailbone/templates/mobile/receiving/view.mako new file mode 100644 index 00000000..e37a46e3 --- /dev/null +++ b/tailbone/templates/mobile/receiving/view.mako @@ -0,0 +1,25 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/master/view.mako" /> + +<%def name="title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${instance.id_str} + +${form.render()|n} +
+ +${h.text('upc-search', class_='receiving-upc-search', placeholder="Enter UPC", autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.receiving.lookup', uuid=batch.uuid)})} +
+ +
+ ## ${h.radio('receiving-row-filter', value='missing', label="Missing", disabled='disabled')} + ${h.radio('receiving-row-filter', value='incomplete', label="Incomplete", disabled='disabled')} + ${h.radio('receiving-row-filter', value='damaged', label="Damaged", disabled='disabled')} + ${h.radio('receiving-row-filter', value='expired', label="Expired", disabled='disabled')} + ${h.radio('receiving-row-filter', value='all', label="All", checked=True)} +
+

+ + diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako new file mode 100644 index 00000000..dd7440c5 --- /dev/null +++ b/tailbone/templates/mobile/receiving/view_row.mako @@ -0,0 +1,107 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/master/view_row.mako" /> + +## TODO: this is broken for actual page (header) title +<%def name="title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(instance.batch.id_str, url('mobile.receiving.view', uuid=instance.batch_uuid))} » ${row.upc.pretty()} + +<% unit_uom = 'LB' if row.product.weighed else 'EA' %> + +
+
+

${instance.brand_name}

+

${instance.description} ${instance.size}

+

${h.pretty_quantity(row.case_quantity)} ${unit_uom} per CS

+
+
+ ${h.image(product_image_url, "product image")} +
+
+ + + + + + + + + + + + + + + + + + + + +
ordered${h.pretty_quantity(row.cases_ordered or 0)} / ${h.pretty_quantity(row.units_ordered or 0)}
received${h.pretty_quantity(row.cases_received or 0)} / ${h.pretty_quantity(row.units_received or 0)}
damaged${h.pretty_quantity(row.cases_damaged or 0)} / ${h.pretty_quantity(row.units_damaged or 0)}
expired${h.pretty_quantity(row.cases_expired or 0)} / ${h.pretty_quantity(row.units_expired or 0)}
+ + + + + + + + + + + + + + + + + + + + + + + + +
${h.link_to("7", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("8", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("9", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}
${h.link_to("4", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("5", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("6", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}
${h.link_to("1", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("2", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("3", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}
${h.link_to("0", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to(".", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("Del", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}
+ +${h.form(request.current_route_url(), class_='receiving-update')} +${h.csrf_token(request)} +${h.hidden('row', value=row.uuid)} +${h.hidden('cases')} +${h.hidden('units')} + +<% + uom = 'CS' + if row.units_ordered and not row.cases_ordered: + uom = 'EA' +%> +
+ + + ${h.radio('receiving-uom', value='CS', checked=uom == 'CS', label="CS")} + ${h.radio('receiving-uom', value=unit_uom, checked=uom == unit_uom, label=unit_uom)} +
+ + + + + + + + + + +
+
+ ${h.radio('mode', value='received', label="received", checked=True)} + ${h.radio('mode', value='damaged', label="damaged")} + ${h.radio('mode', value='expired', label="expired")} +
+
+
+ + + +
+
+ +${h.end_form()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2112462b..dfa715a5 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -60,6 +60,7 @@ class MasterView(View): downloadable = False supports_mobile = False + mobile_creatable = False listing = False creating = False @@ -87,6 +88,8 @@ class MasterView(View): rows_deletable_speedbump = False rows_bulk_deletable = False + mobile_rows_viewable = False + @property def Session(self): """ @@ -238,6 +241,24 @@ class MasterView(View): return ListItemRenderer + def mobile_row_listitem_renderer(self): + """ + Must return a FormAlchemy field renderer callable for the mobile row + grid's list item field. + """ + master = self + + class ListItemRenderer(fa.FieldRenderer): + def render_readonly(self, **kwargs): + return master.render_mobile_row_listitem(self.raw_value, **kwargs) + + return ListItemRenderer + + def render_mobile_row_listitem(self, row, **kwargs): + if row is None: + return '' + return tags.link_to(row, '#') + def create(self): """ View for creating a new model record. @@ -316,6 +337,9 @@ class MasterView(View): # 'instance_deletable': self.deletable_instance(instance), 'form': form, } + if self.has_rows: + context['model_row_class'] = self.model_row_class + context['grid'] = self.make_mobile_row_grid(instance=instance) return self.render_to_response('view', context, mobile=True) def make_mobile_form(self, instance, **kwargs): @@ -331,6 +355,25 @@ class MasterView(View): form.readonly = self.viewing return form + def preconfigure_mobile_row_fieldset(self, fieldset): + self._preconfigure_row_fieldset(fieldset) + + def configure_mobile_row_fieldset(self, fieldset): + self.configure_row_fieldset(fieldset) + + def make_mobile_row_form(self, row, **kwargs): + """ + Make a form for use with mobile CRUD views, for the given row object. + """ + fieldset = self.make_fieldset(row) + self.preconfigure_mobile_row_fieldset(fieldset) + self.configure_mobile_row_fieldset(fieldset) + kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) + factory = kwargs.pop('factory', forms.AlchemyForm) + form = factory(self.request, fieldset, **kwargs) + form.readonly = self.viewing + return form + def preconfigure_mobile_fieldset(self, fieldset): self._preconfigure_fieldset(fieldset) @@ -340,6 +383,66 @@ class MasterView(View): """ self.configure_fieldset(fieldset) + def get_mobile_row_data(self, parent): + return self.get_row_data(parent) + + def make_mobile_row_grid_kwargs(self, **kwargs): + defaults = { + 'pageable': True, + 'sortable': True, + } + defaults.update(kwargs) + return defaults + + def make_mobile_row_grid(self, **kwargs): + """ + Make a new (configured) rows grid instance for mobile. + """ + parent = kwargs.pop('instance', self.get_instance()) + kwargs['instance'] = parent + kwargs['data'] = self.get_mobile_row_data(parent) + kwargs['key'] = 'mobile.{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()]) + kwargs.setdefault('request', self.request) + kwargs.setdefault('model_class', self.model_row_class) + kwargs = self.make_mobile_row_grid_kwargs(**kwargs) + factory = self.get_grid_factory() + grid = factory(**kwargs) + self.configure_mobile_row_grid(grid) + grid.load_settings() + return grid + + def mobile_row_listitem_field(self): + """ + Must return a FormAlchemy field to be appended to row grid, or ``None`` + if none is desired. + """ + return fa.Field('listitem', value=lambda obj: obj, + renderer=self.mobile_row_listitem_renderer()) + + def configure_mobile_row_grid(self, grid): + listitem = self.mobile_row_listitem_field() + if listitem: + grid.append(listitem) + grid.configure(include=[grid.listitem]) + else: + grid.configure() + + def mobile_view_row(self): + """ + Mobile view for row items + """ + self.viewing = True + row = self.get_row_instance() + form = self.make_mobile_row_form(row) + context = { + 'row': row, + 'instance': row, + 'instance_title': self.get_row_instance_title(row), + 'parent_model_title': self.get_model_title(), + 'form': form, + } + return self.render_to_response('view_row', context, mobile=True) + def make_default_row_grid_tools(self, obj): if self.rows_creatable: link = tags.link_to("Create a new {}".format(self.get_row_model_title()), @@ -1476,12 +1579,17 @@ class MasterView(View): permission='{}.list'.format(permission_prefix)) # create + if cls.creatable or cls.mobile_creatable: + config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix), + "Create new {}".format(model_title)) if cls.creatable: - config.add_route('{0}.create'.format(route_prefix), '{0}/new'.format(url_prefix)) - config.add_view(cls, attr='create', route_name='{0}.create'.format(route_prefix), - permission='{0}.create'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{0}.create'.format(permission_prefix), - "Create new {0}".format(model_title)) + config.add_route('{}.create'.format(route_prefix), '{}/new'.format(url_prefix)) + config.add_view(cls, attr='create', route_name='{}.create'.format(route_prefix), + permission='{}.create'.format(permission_prefix)) + if cls.mobile_creatable: + config.add_route('mobile.{}.create'.format(route_prefix), '/mobile{}/new'.format(url_prefix)) + config.add_view(cls, attr='mobile_create', route_name='mobile.{}.create'.format(route_prefix), + permission='{}.create'.format(permission_prefix)) # bulk delete if cls.bulk_deletable: @@ -1556,10 +1664,15 @@ class MasterView(View): "Create new {} rows".format(model_title)) # view row - if cls.has_rows and cls.rows_viewable: - config.add_route('{}.view'.format(row_route_prefix), '{}/{{uuid}}'.format(row_url_prefix)) - config.add_view(cls, attr='view_row', route_name='{}.view'.format(row_route_prefix), - permission='{}.view'.format(permission_prefix)) + if cls.has_rows: + if cls.rows_viewable: + config.add_route('{}.view'.format(row_route_prefix), '{}/{{uuid}}'.format(row_url_prefix)) + config.add_view(cls, attr='view_row', route_name='{}.view'.format(row_route_prefix), + permission='{}.view'.format(permission_prefix)) + if cls.mobile_rows_viewable: + config.add_route('mobile.{}.view'.format(row_route_prefix), '/mobile{}/{{uuid}}'.format(row_url_prefix)) + config.add_view(cls, attr='mobile_view_row', route_name='mobile.{}.view'.format(row_route_prefix), + permission='{}.view'.format(permission_prefix)) # edit row if cls.has_rows and cls.rows_editable: diff --git a/tailbone/views/purchasing/__init__.py b/tailbone/views/purchasing/__init__.py index 52b6a4e0..069c38bc 100644 --- a/tailbone/views/purchasing/__init__.py +++ b/tailbone/views/purchasing/__init__.py @@ -31,3 +31,4 @@ from .batch import PurchasingBatchView def includeme(config): config.include('tailbone.views.purchasing.ordering') + config.include('tailbone.views.purchasing.receiving') diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 03988727..64d359c3 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -46,9 +46,6 @@ class PurchasingBatchView(BatchMasterView): 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): @@ -530,3 +527,9 @@ class PurchasingBatchView(BatchMasterView): 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)) + + @classmethod + def defaults(cls, config): + cls._purchasing_defaults(config) + cls._batch_defaults(config) + cls._defaults(config) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index e2f145bd..7b3e402d 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -44,8 +44,6 @@ class OrderingBatchView(PurchasingBatchView): url_prefix = '/ordering' model_title = "Ordering Batch" model_title_plural = "Ordering Batches" - rows_creatable = False - rows_editable = False order_form_header_columns = [ "UPC", diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py new file mode 100644 index 00000000..6b172840 --- /dev/null +++ b/tailbone/views/purchasing/receiving.py @@ -0,0 +1,322 @@ +# -*- 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 'receiving' (purchasing) batches +""" + +from __future__ import unicode_literals, absolute_import + +import re + +import sqlalchemy as sa + +from rattail import pod +from rattail.db import model +from rattail.gpc import GPC +from rattail.util import pretty_quantity + +import formalchemy as fa +import formencode as fe +from webhelpers.html import tags + +from tailbone import forms +from tailbone.views.purchasing import PurchasingBatchView + + +class ReceivingBatchView(PurchasingBatchView): + """ + Master view for receiving batches + """ + route_prefix = 'receiving' + url_prefix = '/receiving' + model_title = "Receiving Batch" + model_title_plural = "Receiving Batches" + creatable = False + rows_deletable = False + supports_mobile = True + mobile_creatable = True + mobile_rows_viewable = True + + @property + def batch_mode(self): + return self.enum.PURCHASE_BATCH_MODE_RECEIVING + + def mobile_create(self): + """ + Mobile view for creating a new receiving batch + """ + mode = self.batch_mode + data = {'mode': mode} + + vendor = None + if self.request.method == 'POST' and self.request.POST.get('vendor'): + vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) + if vendor: + data['vendor'] = vendor + + if self.request.POST.get('purchase'): + purchase = self.get_purchase(self.request.POST['purchase']) + if purchase: + + batch = self.model_class() + batch.mode = mode + batch.vendor = vendor + batch.store = self.rattail_config.get_store(self.Session()) + batch.buyer = self.request.user.employee + batch.created_by = self.request.user + kwargs = self.get_batch_kwargs(batch, mobile=True) + batch = self.handler.make_batch(self.Session(), **kwargs) + if self.handler.should_populate(batch): + self.handler.populate(batch) + return self.redirect(self.request.route_url('mobile.receiving.view', uuid=batch.uuid)) + + data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize() + if vendor: + purchases = self.eligible_purchases(vendor.uuid, mode=mode) + data['purchases'] = [(p['key'], p['display']) for p in purchases['purchases']] + return self.render_to_response('create', data, mobile=True) + + def get_batch_kwargs(self, batch, mobile=False): + kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile) + if mobile: + + purchase = self.get_purchase(self.request.POST['purchase']) + kwargs['sms_transaction_number'] = purchase.F1032 + + numbers = [d.F03 for d in purchase.details] + if numbers: + number = max(set(numbers), key=numbers.count) + kwargs['department'] = self.Session.query(model.Department)\ + .filter(model.Department.number == number)\ + .one() + + else: + kwargs['sms_transaction_number'] = batch.sms_transaction_number + return kwargs + + def get_mobile_data(self, session=None): + # TODO: this hard-codes list view to show Pending only + return super(ReceivingBatchView, self).get_mobile_data(session=session)\ + .filter(model.PurchaseBatch.executed == None)\ + .filter(sa.or_( + model.PurchaseBatch.complete == None, + model.PurchaseBatch.complete == False)) + + def configure_mobile_grid(self, g): + super(ReceivingBatchView, self).configure_mobile_grid(g) + g.listitem.set(renderer=ReceivingBatchRenderer) + + def configure_mobile_fieldset(self, fs): + fs.configure(include=[ + fs.vendor.with_renderer(fa.TextFieldRenderer), + fs.department.with_renderer(fa.TextFieldRenderer), + ]) + + def get_mobile_row_data(self, batch): + return super(ReceivingBatchView, self).get_mobile_row_data(batch)\ + .order_by(model.PurchaseBatchRow.sequence) + + def render_mobile_row_listitem(self, row, **kwargs): + if row is None: + return '' + title = "({}) {}".format(row.upc.pretty(), row.product.full_description) + url = self.request.route_url('mobile.receiving.rows.view', uuid=row.uuid) + return tags.link_to(title, url) + + def mobile_lookup(self): + """ + Try to locate a product by UPC, and validate it in the context of + current batch, returning some data for client JS. + """ + batch = self.get_instance() + upc = self.request.GET.get('upc', '').strip() + upc = re.sub(r'\D', '', upc) + if upc: + + # first try to locate existing batch row by UPC match + provided = GPC(upc, calc_check_digit=False) + checked = GPC(upc, calc_check_digit='upc') + rows = self.Session.query(model.PurchaseBatchRow)\ + .filter(model.PurchaseBatchRow.batch == batch)\ + .filter(model.PurchaseBatchRow.upc.in_((provided, checked)))\ + .all() + + if rows: + if len(rows) > 1: + log.warning("found multiple UPC matches for {} in batch {}: {}".format( + upc, batch.id_str, batch)) + row = rows[0] + return self.redirect(self.request.route_url('mobile.{}.view'.format(self.get_row_route_prefix()), uuid=row.uuid)) + + # TODO: how to handle product not found in system / purchase ? + raise NotImplementedError + + def mobile_view_row(self): + """ + Mobile view for receiving batch row items. Note that this also handles + updating a row. + """ + self.viewing = True + row = self.get_row_instance() + form = self.make_mobile_row_form(row) + context = { + 'row': row, + 'instance': row, + 'instance_title': self.get_row_instance_title(row), + 'parent_model_title': self.get_model_title(), + 'product_image_url': pod.get_image_url(self.rattail_config, row.upc), + 'form': form, + } + + if self.request.has_perm('{}.edit'.format(self.get_row_permission_prefix())): + update_form = forms.SimpleForm(self.request, schema=ReceivingForm) + if update_form.validate(): + row = update_form.data['row'] + mode = update_form.data['mode'] + cases = update_form.data['cases'] + units = update_form.data['units'] + if cases: + setattr(row, 'cases_{}'.format(mode), + (getattr(row, 'cases_{}'.format(mode)) or 0) + cases) + if units: + setattr(row, 'units_{}'.format(mode), + (getattr(row, 'units_{}'.format(mode)) or 0) + units) + + # if mode in ('damaged', 'expired', 'mispick'): + if mode in ('damaged', 'expired'): + self.attach_credit(row, mode, cases, units, + # expiration_date=update_form.data['expiration_date'], + # discarded=update_form.data['trash'], + # mispick_product=shipped_product) + ) + + # first undo any totals previously in effect for the row, then refresh + if row.invoice_total: + row.batch.invoice_total -= row.invoice_total + self.handler.refresh_row(row) + + return self.redirect(self.request.route_url('mobile.{}.view'.format(self.get_route_prefix()), uuid=row.batch_uuid)) + + return self.render_to_response('view_row', context, mobile=True) + + def attach_credit(self, row, credit_type, cases, units, expiration_date=None, discarded=None, mispick_product=None): + batch = row.batch + credit = model.PurchaseBatchCredit() + credit.credit_type = credit_type + credit.store = batch.store + credit.vendor = batch.vendor + credit.date_ordered = batch.date_ordered + credit.date_shipped = batch.date_shipped + credit.date_received = batch.date_received + credit.invoice_number = batch.invoice_number + credit.invoice_date = batch.invoice_date + credit.product = row.product + credit.upc = row.upc + credit.brand_name = row.brand_name + credit.description = row.description + credit.size = row.size + credit.department_number = row.department_number + credit.department_name = row.department_name + credit.case_quantity = row.case_quantity + credit.cases_shorted = cases + credit.units_shorted = units + credit.invoice_line_number = row.invoice_line_number + credit.invoice_case_cost = row.invoice_case_cost + credit.invoice_unit_cost = row.invoice_unit_cost + credit.invoice_total = row.invoice_total + credit.product_discarded = discarded + if credit_type == 'expired': + credit.expiration_date = expiration_date + elif credit_type == 'mispick' and mispick_product: + credit.mispick_product = mispick_product + credit.mispick_upc = mispick_product.upc + if mispick_product.brand: + credit.mispick_brand_name = mispick_product.brand.name + credit.mispick_description = mispick_product.description + credit.mispick_size = mispick_product.size + row.credits.append(credit) + return credit + + @classmethod + def defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + model_key = cls.get_model_key() + row_permission_prefix = cls.get_row_permission_prefix() + + # mobile lookup + config.add_route('mobile.{}.lookup'.format(route_prefix), '/mobile{}/{{{}}}/lookup'.format(url_prefix, model_key)) + config.add_view(cls, attr='mobile_lookup', route_name='mobile.{}.lookup'.format(route_prefix), + renderer='json', permission='{}.view'.format(row_permission_prefix)) + + cls._purchasing_defaults(config) + cls._batch_defaults(config) + cls._defaults(config) + + +class ReceivingBatchRenderer(fa.FieldRenderer): + + def render_readonly(self, **kwargs): + batch = self.raw_value + title = "({}) {} for ${:0,.2f} - {}, {}".format( + batch.id_str, + batch.vendor, + batch.po_total or 0, + batch.department, + batch.created_by) + url = self.request.route_url('mobile.receiving.view', uuid=batch.uuid) + return tags.link_to(title, url) + + +class ValidBatchRow(forms.validators.ModelValidator): + model_class = model.PurchaseBatchRow + + def _to_python(self, value, state): + row = super(ValidBatchRow, self)._to_python(value, state) + if row.batch.executed: + raise fe.Invalid("Batch has already been executed", value, state) + return row + + +class ReceivingForm(forms.Schema): + allow_extra_fields = True + filter_extra_fields = True + row = ValidBatchRow() + mode = fe.validators.OneOf(['received', 'damaged', 'expired', + # 'mispick', + ]) + # product = forms.validators.ValidProduct() + # upc = forms.validators.ValidGPC() + # brand_name = fe.validators.String() + # description = fe.validators.String() + # size = fe.validators.String() + # case_quantity = fe.validators.Number() + cases = fe.validators.Number() + units = fe.validators.Number() + # expiration_date = fe.validators.DateValidator() + # trash = fe.validators.Bool() + # ordered_product = forms.validators.ValidProduct() + + +def includeme(config): + ReceivingBatchView.defaults(config)