From 91bb38573bdcb4e6efa413712db9b8776d407f17 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Feb 2018 21:53:39 -0600 Subject: [PATCH] Add desktop support for creating inventory batches with a workflow form of sorts --- tailbone/forms/core.py | 7 +- tailbone/forms/types.py | 25 ++ .../batch/inventory/desktop_form.mako | 258 ++++++++++++++++++ tailbone/views/inventory.py | 154 ++++++++++- 4 files changed, 433 insertions(+), 11 deletions(-) create mode 100644 tailbone/templates/batch/inventory/desktop_form.mako diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 6a4dc21d..82b21fd4 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -585,11 +585,14 @@ class Form(object): else: raise ValueError("unknown type for '{}' field: {}".format(key, type_)) - def set_enum(self, key, enum): + def set_enum(self, key, enum, empty=None): if enum: self.enums[key] = enum self.set_type(key, 'enum') - self.set_widget(key, dfwidget.SelectWidget(values=list(enum.items()))) + values = list(enum.items()) + if empty: + values.insert(0, empty) + self.set_widget(key, dfwidget.SelectWidget(values=values)) else: self.enums.pop(key, None) diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py index d45b957e..68168915 100644 --- a/tailbone/forms/types.py +++ b/tailbone/forms/types.py @@ -26,9 +26,12 @@ Form Schema Types from __future__ import unicode_literals, absolute_import +import re + import six from rattail.db import model +from rattail.gpc import GPC import colander @@ -60,6 +63,28 @@ class JQueryTime(colander.Time): return colander.timeparse(cstruct, formats[0]) +class GPCType(colander.SchemaType): + """ + Schema type for product GPC data. + """ + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + return six.text_type(appstruct) + + def deserialize(self, node, cstruct): + if not cstruct: + return None + digits = re.sub(r'\D', '', cstruct) + if not digits: + return None + try: + return GPC(digits) + except Exception as err: + raise colander.Invalid(node, six.text_type(err)) + + class ModelType(colander.SchemaType): """ Custom schema type for scalar ORM relationship fields. diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako new file mode 100644 index 00000000..5d09e896 --- /dev/null +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -0,0 +1,258 @@ +## -*- coding: utf-8; -*- +<%inherit file="/base.mako" /> + +<%def name="title()">Inventory Form + +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} + + + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to Inventory Batch", url('batch.inventory.view', uuid=batch.uuid))}
  • + + + + + +
    + ${h.form(form.action_url, id='inventory-form')} + ${h.csrf_token(request)} + ${h.hidden('mode')} + +
    + +
    + ${h.hidden('product')} +
    ${h.text('upc', autocomplete='off')}
    +
    +

    please ENTER a scancode

    +
    +
    please confirm UPC and provide more details
    +
    +
    +
    + + + +
    + +
    ${h.text('cases', autocomplete='off')}
    +
    + +
    + +
    ${h.text('units', autocomplete='off')}
    +
    + +
    + + +
    + + ${h.end_form()} +
    diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index e7da7f9a..a118938b 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -27,11 +27,13 @@ Views for inventory batches from __future__ import unicode_literals, absolute_import import re +import logging import six from rattail import pod from rattail.db import model, api +from rattail.db.util import make_full_description from rattail.time import localtime from rattail.gpc import GPC from rattail.util import pretty_quantity @@ -45,6 +47,9 @@ from tailbone.views import MasterView from tailbone.views.batch import BatchMasterView +log = logging.getLogger(__name__) + + class InventoryAdjustmentReasonsView(MasterView): """ Master view for inventory adjustment reasons. @@ -84,7 +89,7 @@ class InventoryBatchView(BatchMasterView): route_prefix = 'batch.inventory' url_prefix = '/batch/inventory' index_title = "Inventory" - creatable = False + rows_creatable = True results_executable = True mobile_creatable = True mobile_rows_creatable = True @@ -108,6 +113,7 @@ class InventoryBatchView(BatchMasterView): form_fields = [ 'id', 'description', + 'notes', 'created', 'created_by', 'handheld_batches', @@ -225,8 +231,15 @@ class InventoryBatchView(BatchMasterView): f.set_type('total_cost', 'currency') # handheld_batches - f.set_readonly('handheld_batches') - f.set_renderer('handheld_batches', self.render_handheld_batches) + if self.creating: + f.remove_field('handheld_batches') + else: + f.set_readonly('handheld_batches') + f.set_renderer('handheld_batches', self.render_handheld_batches) + + # complete + if self.creating: + f.remove_field('complete') def render_handheld_batches(self, inventory_batch, field): items = '' @@ -250,7 +263,7 @@ class InventoryBatchView(BatchMasterView): return super(InventoryBatchView, self).save_edit_row_form(form) def delete_row(self): - row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['uuid']) + row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['row_uuid']) if not row: raise self.notfound() batch = row.batch @@ -258,6 +271,99 @@ class InventoryBatchView(BatchMasterView): batch.total_cost -= row.total_cost return super(InventoryBatchView, self).delete_row() + def create_row(self): + """ + Desktop workflow view for adding items to inventory batch. + """ + batch = self.get_instance() + if batch.executed: + return self.redirect(self.get_action_url('view', batch)) + + form = forms.Form(schema=DesktopForm(), request=self.request) + if form.validate(newstyle=True): + + mode = form.validated['mode'] + product = self.Session.merge(form.validated['product']) + row = model.InventoryBatchRow() + row.product = product + row.upc = form.validated['upc'] + row.brand_name = form.validated['brand_name'] + row.description = form.validated['description'] + row.size = form.validated['size'] + row.case_quantity = form.validated['case_quantity'] + + cases = form.validated['cases'] + units = form.validated['units'] + if mode == 'add': + row.cases = cases + row.units = units + else: + assert mode == 'subtract' + row.cases = (0 - cases) if cases else None + row.units = (0 - units) if units else None + + self.handler.add_row(batch, row) + description = make_full_description(form.validated['brand_name'], + form.validated['description'], + form.validated['size']) + self.request.session.flash("({}) {} cases, {} units: {} {}".format( + form.validated['mode'], form.validated['cases'] or 0, form.validated['units'] or 0, + form.validated['upc'].pretty(), description)) + return self.redirect(self.request.current_route_url()) + + title = self.get_instance_title(batch) + return self.render_to_response('desktop_form', { + 'batch': batch, + 'instance': batch, + 'instance_title': title, + 'index_title': "{}: {}".format(self.get_model_title(), title), + 'index_url': self.get_action_url('view', batch), + 'form': form, + 'dform': form.make_deform_form(), + }) + + def desktop_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() + if batch.executed: + return { + 'error': "Current batch has already been executed", + 'redirect': self.get_action_url('view', batch), + } + data = {} + 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') + product = api.get_product_by_upc(self.Session(), provided) + if not product: + product = api.get_product_by_upc(self.Session(), checked) + if product and (not product.deleted or self.request.has_perm('products.view_deleted')): + data['uuid'] = product.uuid + data['upc'] = six.text_type(product.upc) + data['upc_pretty'] = product.upc.pretty() + data['full_description'] = product.full_description + data['brand_name'] = six.text_type(product.brand or '') + data['description'] = product.description + data['size'] = product.size + data['case_quantity'] = 1 # default + data['cost_found'] = False + data['image_url'] = pod.get_image_url(self.rattail_config, product.upc) + + result = {'product': data or None, 'upc': None} + if not data and upc: + upc = GPC(upc) + result['upc'] = unicode(upc) + result['upc_pretty'] = upc.pretty() + result['image_url'] = pod.get_image_url(self.rattail_config, upc) + return result + def configure_mobile_form(self, f): super(InventoryBatchView, self).configure_mobile_form(f) batch = f.model_instance @@ -466,17 +572,22 @@ class InventoryBatchView(BatchMasterView): url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() - # mobile - make new row from UPC - config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix), - permission='{}.create_row'.format(permission_prefix)) - # extra perms for creating batches per "mode" config.add_tailbone_permission(permission_prefix, '{}.create.replace'.format(permission_prefix), "Create new {} with 'replace' mode".format(model_title)) config.add_tailbone_permission(permission_prefix, '{}.create.zero'.format(permission_prefix), "Create new {} with 'zero' mode".format(model_title)) + # row UPC lookup, for desktop + config.add_route('{}.desktop_lookup'.format(route_prefix), '{}/{{{}}}/desktop-form/lookup'.format(url_prefix, model_key)) + config.add_view(cls, attr='desktop_lookup', route_name='{}.desktop_lookup'.format(route_prefix), + renderer='json', permission='{}.create_row'.format(permission_prefix)) + + # mobile - make new row from UPC + config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key)) + config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix), + permission='{}.create_row'.format(permission_prefix)) + class InventoryBatchRowType(forms.types.ObjectType): model_class = model.InventoryBatchRow @@ -497,6 +608,31 @@ class InventoryForm(colander.MappingSchema): units = colander.SchemaNode(colander.Decimal(), missing=colander.null) +class DesktopForm(colander.Schema): + + mode = colander.SchemaNode(colander.String(), + validator=colander.OneOf(['add', + 'subtract'])) + + product = colander.SchemaNode(forms.types.ProductType()) + + upc = colander.SchemaNode(forms.types.GPCType()) + + brand_name = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) + + size = colander.SchemaNode(colander.String()) + + case_quantity = colander.SchemaNode(colander.Decimal()) + + cases = colander.SchemaNode(colander.Decimal(), + missing=None) + + units = colander.SchemaNode(colander.Decimal(), + missing=None) + + def includeme(config): InventoryAdjustmentReasonsView.defaults(config) InventoryBatchView.defaults(config)