diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py index 68168915..de7e117c 100644 --- a/tailbone/forms/types.py +++ b/tailbone/forms/types.py @@ -153,6 +153,13 @@ class EmployeeType(ModelType): model_class = model.Employee +class VendorType(ModelType): + """ + Custom schema type for vendor relationship field. + """ + model_class = model.Vendor + + class ProductType(ModelType): """ Custom schema type for product relationship field. diff --git a/tailbone/static/js/jquery.ui.tailbone.mobile.js b/tailbone/static/js/jquery.ui.tailbone.mobile.js index 33a2a7be..79eecb9a 100644 --- a/tailbone/static/js/jquery.ui.tailbone.mobile.js +++ b/tailbone/static/js/jquery.ui.tailbone.mobile.js @@ -56,10 +56,12 @@ // when user clicks autocomplete result, hide search etc. this.ul.on('click', 'li', function() { var $li = $(this); + var uuid = $li.data('uuid'); that.search.hide(); - that.hidden_field.val($li.data('uuid')); + that.hidden_field.val(uuid); that.button.text($li.text()).show(); that.ul.hide(); + that.element.trigger('autocompleteitemselected', uuid); }); // when user clicks "change" button, show search etc. @@ -69,6 +71,7 @@ that.hidden_field.val(''); that.search.show(); that.text_field.focus(); + that.element.trigger('autocompleteitemcleared'); }); } diff --git a/tailbone/static/js/tailbone.mobile.receiving.js b/tailbone/static/js/tailbone.mobile.receiving.js new file mode 100644 index 00000000..45341a55 --- /dev/null +++ b/tailbone/static/js/tailbone.mobile.receiving.js @@ -0,0 +1,34 @@ + +/************************************************************ + * + * tailbone.mobile.receiving.js + * + * Global logic for mobile receiving feature + * + ************************************************************/ + + +// TODO: this is really just for receiving; should change form name? +$(document).on('autocompleteitemselected', 'form[name="new-purchasing-batch"] .vendor', function(event, uuid) { + $('#new-receiving-types').show(); +}); + + +// TODO: this is really just for receiving; should change form name? +$(document).on('autocompleteitemcleared', 'form[name="new-purchasing-batch"] .vendor', function(event) { + $('#new-receiving-types').hide(); +}); + + +$(document).on('click', 'form[name="new-purchasing-batch"] #receive-truck-dump', function() { + var form = $(this).parents('form'); + form.find('input[name="workflow"]').val('truck_dump'); + form.submit(); +}); + + +$(document).on('click', 'form.receiving-update #delete-receiving-row', function() { + var form = $(this).parents('form'); + form.find('input[name="delete_row"]').val('true'); + form.submit(); +}); diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 86710cf0..31f4c88e 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -75,7 +75,7 @@ ${rows_grid|n} -% if not batch.executed: +% if master.handler.executable(batch) and not batch.executed: diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako index 06b27910..8bf656bb 100644 --- a/tailbone/templates/mobile/base.mako +++ b/tailbone/templates/mobile/base.mako @@ -10,6 +10,7 @@ ${h.javascript_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js')} ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.mobile.js'))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.js'))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.receiving.js'))} ${self.extra_javascript()} ## since jquery mobile will "utterly cache" the first page which is loaded diff --git a/tailbone/templates/mobile/receiving/create.mako b/tailbone/templates/mobile/receiving/create.mako index b8c3d3e1..2d23a29d 100644 --- a/tailbone/templates/mobile/receiving/create.mako +++ b/tailbone/templates/mobile/receiving/create.mako @@ -20,8 +20,25 @@ ${h.csrf_token(request)}
- ${h.submit('submit', "Find purchase orders")} - ## + + % else: ## vendor is known diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako index 4bc1fe34..3d6aafdc 100644 --- a/tailbone/templates/mobile/receiving/view_row.mako +++ b/tailbone/templates/mobile/receiving/view_row.mako @@ -2,9 +2,9 @@ <%inherit file="/mobile/master/view_row.mako" /> <%namespace file="/mobile/keypad.mako" import="keypad" /> -<%def name="title()">Receiving » ${instance.batch.id_str} » ${row.upc.pretty()} +<%def name="title()">Receiving » ${batch.id_str} » ${row.upc.pretty()} -<%def name="page_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()} +<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(batch.id_str, url('mobile.receiving.view', uuid=batch.uuid))} » ${row.upc.pretty()} <% unit_uom = 'LB' if row.product and row.product.weighed else 'EA' @@ -20,7 +20,7 @@ % if instance.product:

${instance.brand_name or ""}

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

-

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

+

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

% else:

${instance.description}

% endif @@ -32,10 +32,12 @@ - - - - + % if not batch.truck_dump: + + + + + % endif @@ -57,7 +59,7 @@ % endfor % endif -% if not instance.batch.executed and not instance.batch.complete: +% if not batch.executed and not batch.complete: ${h.form(request.current_route_url(), class_='receiving-update')} ${h.csrf_token(request)} @@ -98,5 +100,10 @@
ordered${h.pretty_quantity(row.cases_ordered or 0)} / ${h.pretty_quantity(row.units_ordered or 0)}
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)}
+ ${h.hidden('delete_row', value='false')} + % if request.has_perm('{}.delete_row'.format(permission_prefix)): + + % endif + ${h.end_form()} % endif diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 0f172928..fc5d1e37 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -512,6 +512,7 @@ class PurchasingBatchView(BatchMasterView): def get_batch_kwargs(self, batch, mobile=False): kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, mobile=mobile) kwargs['mode'] = self.batch_mode + kwargs['truck_dump'] = batch.truck_dump if batch.store: kwargs['store'] = batch.store elif batch.store_uuid: diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 6d5038ec..039b32ed 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -36,6 +36,8 @@ from rattail.gpc import GPC from rattail.util import pretty_quantity, prettify import colander +from deform import widget as dfwidget +from pyramid import httpexceptions from webhelpers2.html import tags from tailbone import forms, grids @@ -48,6 +50,12 @@ class MobileItemStatusFilter(grids.filters.MobileFilter): def filter_equal(self, query, value): + # NOTE: this is only relevant for truck dump + if value == 'received': + return query.filter(sa.or_( + model.PurchaseBatchRow.cases_received != 0, + model.PurchaseBatchRow.units_received != 0)) + # TODO: is this accurate (enough) ? if value == 'incomplete': return query.filter(sa.or_(model.PurchaseBatchRow.cases_ordered != 0, model.PurchaseBatchRow.units_ordered != 0))\ @@ -95,10 +103,29 @@ class ReceivingBatchView(PurchasingBatchView): mobile_rows_filterable = True mobile_rows_creatable = True + allow_from_po = False + allow_from_scratch = True + allow_truck_dump = False + + grid_columns = [ + 'id', + 'vendor', + 'truck_dump', + 'department', + 'buyer', + 'date_ordered', + 'created', + 'created_by', + 'rowcount', + 'status_code', + 'executed', + ] + form_fields = [ 'id', 'store', 'vendor', + 'truck_dump', 'department', 'purchase', 'vendor_email', @@ -123,6 +150,7 @@ class ReceivingBatchView(PurchasingBatchView): mobile_form_fields = [ 'vendor', + 'truck_dump', 'department', ] @@ -175,6 +203,13 @@ class ReceivingBatchView(PurchasingBatchView): def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING + def configure_form(self, f): + super(ReceivingBatchView, self).configure_form(f) + + # truck_dump + if self.editing: + f.set_readonly('truck_dump') + def render_mobile_listitem(self, batch, i): title = "({}) {} for ${:0,.2f} - {}, {}".format( batch.id_str, @@ -188,8 +223,17 @@ class ReceivingBatchView(PurchasingBatchView): """ Returns a set of filters for the mobile row grid. """ + batch = self.get_instance() filters = grids.filters.GridFilterSet() - filters['status'] = MobileItemStatusFilter('status', default_value='incomplete') + if batch.truck_dump: + value_choices = ['received', 'damaged', 'expired', 'all'] + default_status = 'all' + else: + value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all'] + default_status = 'incomplete' + filters['status'] = MobileItemStatusFilter('status', + value_choices=value_choices, + default_value=default_status) return filters def mobile_create(self): @@ -199,6 +243,25 @@ class ReceivingBatchView(PurchasingBatchView): mode = self.batch_mode data = {'mode': mode} + form = forms.Form(schema=MobileNewReceivingBatch(), request=self.request) + if form.validate(newstyle=True): + + if form.validated['workflow'] == 'truck_dump': + if not self.allow_truck_dump: + raise NotImplementedError("Requested workflow not supported: truck_dump") + batch = self.model_class() + batch.store = self.rattail_config.get_store(self.Session()) + batch.mode = mode + batch.truck_dump = True + batch.vendor = self.Session.merge(form.validated['vendor']) + batch.created_by = self.request.user + kwargs = self.get_batch_kwargs(batch, mobile=True) + batch = self.handler.make_batch(self.Session(), **kwargs) + return self.redirect(self.request.route_url('mobile.receiving.view', uuid=batch.uuid)) + + else: + raise NotImplementedError("Requested workflow not supported: {}".format(form.validated['workflow'])) + vendor = None if self.request.method == 'POST' and self.request.POST.get('vendor'): vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) @@ -227,28 +290,19 @@ class ReceivingBatchView(PurchasingBatchView): 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']) - 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() - - return kwargs - def configure_mobile_form(self, f): super(ReceivingBatchView, self).configure_mobile_form(f) + batch = f.model_instance - # vendor - # fs.vendor.with_renderer(fa.TextFieldRenderer), + # truck_dump + if not self.creating: + if not batch.truck_dump: + f.remove_field('truck_dump') # department - # fs.department.with_renderer(fa.TextFieldRenderer), + if not self.creating: + if batch.truck_dump: + f.remove_field('department') def configure_row_form(self, f): super(ReceivingBatchView, self).configure_row_form(f) @@ -302,8 +356,7 @@ class ReceivingBatchView(PurchasingBatchView): if product: row = model.PurchaseBatchRow() row.product = product - batch.add_row(row) - self.handler.refresh_row(row) + self.handler.add_row(batch, row) # check for "bad" upc elif len(upc) > 14: @@ -329,9 +382,12 @@ class ReceivingBatchView(PurchasingBatchView): """ self.viewing = True row = self.get_row_instance() + batch = row.batch + permission_prefix = self.get_permission_prefix() form = self.make_mobile_row_form(row) context = { 'row': row, + 'batch': batch, 'instance': row, 'instance_title': self.get_row_instance_title(row), 'parent_model_title': self.get_model_title(), @@ -339,36 +395,45 @@ class ReceivingBatchView(PurchasingBatchView): 'form': form, } - if self.request.has_perm('{}.create_row'.format(self.get_permission_prefix())): - update_form = forms.Form(schema=ReceivingForm(), request=self.request) + if self.request.has_perm('{}.create_row'.format(permission_prefix)): + update_form = forms.Form(schema=MobileReceivingForm(), request=self.request) if update_form.validate(newstyle=True): row = self.Session.merge(update_form.validated['row']) - mode = update_form.validated['mode'] - cases = update_form.validated['cases'] - units = update_form.validated['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.validated['expiration_date'], - # discarded=update_form.data['trash'], - # mispick_product=shipped_product) - ) + # TODO: surely this (delete_row) should be split out to a separate view + if update_form.validated['delete_row']: + if not self.request.has_perm('{}.delete_row'.format(permission_prefix)): + raise httpexceptions.HTTPForbidden() + self.handler.remove_row(row) + return self.redirect(self.get_action_url('view', batch, mobile=True)) - # 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) + else: # not delete_row + mode = update_form.validated['mode'] + cases = update_form.validated['cases'] + units = update_form.validated['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) - return self.redirect(self.request.route_url('mobile.{}.view'.format(self.get_route_prefix()), uuid=row.batch_uuid)) + # if mode in ('damaged', 'expired', 'mispick'): + if mode in ('damaged', 'expired'): + self.attach_credit(row, mode, cases, units, + expiration_date=update_form.validated['expiration_date'], + # discarded=update_form.data['trash'], + # mispick_product=shipped_product) + ) - if not row.cases_ordered and not row.units_ordered: + # first undo any totals previously in effect for the row, then refresh + if row.invoice_total: + batch.invoice_total -= row.invoice_total + self.handler.refresh_row(row) + + return self.redirect(self.get_action_url('view', batch, mobile=True)) + + if not row.cases_ordered and not row.units_ordered and not batch.truck_dump: self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning') return self.render_to_response('view_row', context, mobile=True) @@ -438,22 +503,39 @@ class PurchaseBatchRowType(forms.types.ObjectType): return row -class ReceivingForm(colander.MappingSchema): +class MobileNewReceivingBatch(colander.MappingSchema): + + vendor = colander.SchemaNode(forms.types.VendorType()) + + workflow = colander.SchemaNode(colander.String(), + validator=colander.OneOf([ + 'from_po', + 'from_scratch', + 'truck_dump', + ])) + + +class MobileReceivingForm(colander.MappingSchema): row = colander.SchemaNode(PurchaseBatchRowType()) mode = colander.SchemaNode(colander.String(), - validator=colander.OneOf(['received', - 'damaged', - 'expired', - # 'mispick', + validator=colander.OneOf([ + 'received', + 'damaged', + 'expired', + # 'mispick', ])) cases = colander.SchemaNode(colander.Decimal(), missing=None) units = colander.SchemaNode(colander.Decimal(), missing=None) - expiration_date = colander.SchemaNode(colander.Date(), missing=colander.null) + expiration_date = colander.SchemaNode(colander.Date(), + widget=dfwidget.TextInputWidget(), + missing=colander.null) + + delete_row = colander.SchemaNode(colander.Boolean()) def includeme(config):