diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py index 14e5b1a3..e24af4c3 100644 --- a/tailbone/forms/__init__.py +++ b/tailbone/forms/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# Copyright © 2010-2016 Lance Edgar # # This file is part of Rattail. # @@ -24,6 +24,8 @@ Forms """ +from __future__ import unicode_literals, absolute_import + from formencode import Schema from .core import Form, Field, FieldSet, GenericFieldSet diff --git a/tailbone/templates/purchases/batches/receive_form.mako b/tailbone/templates/purchases/batches/receive_form.mako new file mode 100644 index 00000000..0a76c390 --- /dev/null +++ b/tailbone/templates/purchases/batches/receive_form.mako @@ -0,0 +1,147 @@ +## -*- coding: utf-8 -*- +<%inherit file="/base.mako" /> + +<%def name="title()">Receiving Form (${batch.vendor}) + +<%def name="head_tags()"> + ${parent.head_tags()} + ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} + + + + + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to Purchase Batch", url('purchases.batch.view', uuid=batch.uuid))}
  • + + + + + +
    + ${form.begin(id='receiving-form')} + ${h.hidden('mode')} + +
    + +
    + ${h.hidden('product')} +
    ${h.text('product-textbox', autocomplete='off')}
    +
    +

     

    +
    +
    warning: product not found on current purchase
    +
    +
    +
    + +
    + +
    ${h.text('cases')}
    +
    + +
    + +
    ${h.text('units')}
    +
    + +
    + + + + +
    + + ${form.end()} +
    diff --git a/tailbone/templates/purchases/batches/view.mako b/tailbone/templates/purchases/batches/view.mako index 3115a3b1..62fab912 100644 --- a/tailbone/templates/purchases/batches/view.mako +++ b/tailbone/templates/purchases/batches/view.mako @@ -18,13 +18,20 @@ location.href = '${url('purchases.batch.order_form', uuid=batch.uuid)}'; }); + $('#receive-form').click(function() { + $(this).button('disable').button('option', 'label', "Working, please wait..."); + location.href = '${url('purchases.batch.receiving_form', uuid=batch.uuid)}'; + }); + }); <%def name="leading_buttons()"> % if batch.mode == enum.PURCHASE_BATCH_MODE_NEW and not batch.complete and not batch.executed and request.has_perm('purchases.batch.order_form'): - + + % elif batch.mode == enum.PURCHASE_BATCH_MODE_RECEIVING and not batch.complete and not batch.executed and request.has_perm('purchases.batch.receiving_form'): + % endif diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 14a762b1..34cd6d7e 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -335,6 +335,46 @@ class ProductsView(MasterView): 'instance_title': self.get_instance_title(instance), 'form': form}) + def search(self): + """ + Locate a product(s) by UPC. + + Eventually this should be more generic, or at least offer more fields for + search. For now it operates only on the ``Product.upc`` field. + """ + data = None + upc = self.request.GET.get('upc', '').strip() + upc = re.sub(r'\D', '', upc) + if upc: + product = api.get_product_by_upc(Session(), upc) + if not product: + # Try again, assuming caller did not include check digit. + upc = GPC(upc, calc_check_digit='upc') + product = api.get_product_by_upc(Session(), upc) + if product and (not product.deleted or self.request.has_perm('products.view_deleted')): + data = { + 'uuid': product.uuid, + 'upc': unicode(product.upc), + 'upc_pretty': product.upc.pretty(), + 'full_description': product.full_description, + 'image_url': pod.get_image_url(self.rattail_config, product.upc), + } + uuid = self.request.GET.get('with_vendor_cost') + if uuid: + vendor = Session.query(model.Vendor).get(uuid) + if not vendor: + return {'error': "Vendor not found"} + cost = product.cost_for_vendor(vendor) + if cost: + data['cost_found'] = True + if int(cost.case_size) == cost.case_size: + data['cost_case_size'] = int(cost.case_size) + else: + data['cost_case_size'] = '{:0.4f}'.format(cost.case_size) + else: + data['cost_found'] = False + return {'product': data} + def get_supported_batches(self): return { 'labels': 'rattail.batch.labels:LabelBatchHandler', @@ -479,6 +519,11 @@ class ProductsView(MasterView): config.add_view(cls, attr='make_batch', route_name='products.create_batch', renderer='/products/batch.mako', permission='batches.create') + # search (by upc) + config.add_route('products.search', '/products/search') + config.add_view(cls, attr='search', route_name='products.search', + renderer='json', permission='products.view') + cls._defaults(config) @@ -535,34 +580,6 @@ class ProductsAutocomplete(AutocompleteView): return product.full_description -def products_search(request): - """ - Locate a product(s) by UPC. - - Eventually this should be more generic, or at least offer more fields for - search. For now it operates only on the ``Product.upc`` field. - """ - product = None - upc = request.GET.get('upc', '').strip() - upc = re.sub(r'\D', '', upc) - if upc: - product = api.get_product_by_upc(Session(), upc) - if not product: - # Try again, assuming caller did not include check digit. - upc = GPC(upc, calc_check_digit='upc') - product = api.get_product_by_upc(Session(), upc) - if product: - if product.deleted and not request.has_perm('products.view_deleted'): - product = None - else: - product = { - 'uuid': product.uuid, - 'upc': unicode(product.upc or ''), - 'full_description': product.full_description, - } - return {'product': product} - - def print_labels(request): profile = request.params.get('profile') profile = Session.query(model.LabelProfile).get(profile) if profile else None @@ -600,9 +617,5 @@ def includeme(config): config.add_view(print_labels, route_name='products.print_labels', renderer='json', permission='products.print_labels') - config.add_route('products.search', '/products/search') - config.add_view(products_search, route_name='products.search', - renderer='json', permission='products.list') - ProductsView.defaults(config) version_defaults(config, ProductVersionView, 'product') diff --git a/tailbone/views/purchases/batch.py b/tailbone/views/purchases/batch.py index ea0cf1b2..eed1a745 100644 --- a/tailbone/views/purchases/batch.py +++ b/tailbone/views/purchases/batch.py @@ -26,9 +26,12 @@ Views for purchase order batches from __future__ import unicode_literals, absolute_import +import re +import logging + from sqlalchemy import orm -from rattail import enum +from rattail import enum, pod from rattail.db import model, api from rattail.gpc import GPC from rattail.time import localtime @@ -36,6 +39,7 @@ from rattail.core import Object from rattail.util import OrderedDict import formalchemy as fa +import formencode as fe from pyramid import httpexceptions from tailbone import forms @@ -43,6 +47,21 @@ from tailbone.db import Session from tailbone.views.batch import BatchMasterView +log = logging.getLogger(__name__) + + +class ReceivingForm(forms.Schema): + allow_extra_fields = True + filter_extra_fields = True + mode = fe.validators.OneOf([ + 'received', + # 'damaged', 'expired', 'mispick', + ]) + product = forms.validators.ValidProduct() + cases = fe.validators.Int() + units = fe.validators.Int() + + class PurchaseBatchView(BatchMasterView): """ Master view for purchase order batches. @@ -578,6 +597,95 @@ class PurchaseBatchView(BatchMasterView): 'batch_po_total': '${:0,.2f}'.format(batch.po_total), } + def receiving_form(self): + """ + Workflow view for receiving items on a purchase batch. + """ + batch = self.get_instance() + if batch.executed: + return self.redirect(self.get_action_url('view', batch)) + + form = forms.SimpleForm(self.request, schema=ReceivingForm) + if form.validate(): + assert form.data['mode'] == 'received' # TODO + + product = form.data['product'] + rows = [row for row in batch.active_rows() if row.product is product] + if rows: + if len(rows) > 1: + log.warning("found {} matching rows in batch {} for product: {}".format( + len(rows), batch.id_str, product.upc.pretty())) + row = rows[0] + else: + row = model.PurchaseBatchRow() + row.product = product + + if form.data['cases']: + row.cases_received = (row.cases_received or 0) + form.data['cases'] + if form.data['units']: + row.units_received = (row.units_received or 0) + form.data['units'] + + if not row.uuid: + batch.add_row(row) + self.handler.refresh_row(row) + + self.request.session.flash("({}) {} cases, {} units: {} {}".format( + form.data['mode'], form.data['cases'] or 0, form.data['units'] or 0, + product.upc.pretty(), product)) + return self.redirect(self.request.current_route_url()) + + title = self.get_instance_title(batch) + return self.render_to_response('receive_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, + 'form': forms.FormRenderer(form), + }) + + def receiving_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 = None + upc = self.request.GET.get('upc', '').strip() + upc = re.sub(r'\D', '', upc) + if upc: + product = api.get_product_by_upc(Session(), upc) + if not product: + # Try again, assuming caller did not include check digit. + upc = GPC(upc, calc_check_digit='upc') + product = api.get_product_by_upc(Session(), upc) + if product and (not product.deleted or self.request.has_perm('products.view_deleted')): + data = { + 'uuid': product.uuid, + 'upc': unicode(product.upc), + 'upc_pretty': product.upc.pretty(), + 'full_description': product.full_description, + 'image_url': pod.get_image_url(self.rattail_config, product.upc), + } + cost = product.cost_for_vendor(batch.vendor) + if cost: + data['cost_found'] = True + if int(cost.case_size) == cost.case_size: + data['cost_case_size'] = int(cost.case_size) + else: + data['cost_case_size'] = '{:0.4f}'.format(cost.case_size) + else: + data['cost_found'] = False + data['found_in_batch'] = product in [row.product for row in batch.active_rows()] + + return {'product': data} + @classmethod def defaults(cls, config): route_prefix = cls.get_route_prefix() @@ -595,7 +703,7 @@ class PurchaseBatchView(BatchMasterView): cls._batch_defaults(config) cls._defaults(config) - # order form + # 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)) @@ -605,6 +713,16 @@ class PurchaseBatchView(BatchMasterView): config.add_view(cls, attr='order_form_update', route_name='{}.order_form_update'.format(route_prefix), renderer='json', permission='{}.order_form'.format(permission_prefix)) + # receiving form, lookup + config.add_tailbone_permission(permission_prefix, '{}.receiving_form'.format(permission_prefix), + "Edit 'receiving' {} in Receiving Form mode".format(model_title)) + config.add_route('{}.receiving_form'.format(route_prefix), '{}/{{{}}}/receiving-form'.format(url_prefix, model_key)) + config.add_view(cls, attr='receiving_form', route_name='{}.receiving_form'.format(route_prefix), + permission='{}.receiving_form'.format(permission_prefix)) + config.add_route('{}.receiving_lookup'.format(route_prefix), '{}/{{{}}}/receiving-form/lookup'.format(url_prefix, model_key)) + config.add_view(cls, attr='receiving_lookup', route_name='{}.receiving_lookup'.format(route_prefix), + renderer='json', permission='{}.receiving_form'.format(permission_prefix)) + def includeme(config): PurchaseBatchView.defaults(config)