Add basic "receive row" desktop view for receiving batches
not terribly polished yet, but works
This commit is contained in:
parent
7fab472fc4
commit
c869516449
|
@ -98,6 +98,16 @@ class GPCType(colander.SchemaType):
|
|||
raise colander.Invalid(node, six.text_type(err))
|
||||
|
||||
|
||||
class ProductQuantity(colander.MappingSchema):
|
||||
"""
|
||||
Combo schema type for product cases and units; useful for inventory,
|
||||
ordering, receiving etc. Meant to be used with the ``CasesUnitsWidget``.
|
||||
"""
|
||||
cases = colander.SchemaNode(colander.Decimal(), missing=colander.null)
|
||||
|
||||
units = colander.SchemaNode(colander.Decimal(), missing=colander.null)
|
||||
|
||||
|
||||
class ModelType(colander.SchemaType):
|
||||
"""
|
||||
Custom schema type for scalar ORM relationship fields.
|
||||
|
|
|
@ -36,6 +36,8 @@ import colander
|
|||
from deform import widget as dfwidget
|
||||
from webhelpers2.html import tags, HTML
|
||||
|
||||
from tailbone.forms.types import ProductQuantity
|
||||
|
||||
|
||||
class ReadonlyWidget(dfwidget.HiddenWidget):
|
||||
|
||||
|
@ -88,6 +90,46 @@ class PercentInputWidget(dfwidget.TextInputWidget):
|
|||
return six.text_type(value)
|
||||
|
||||
|
||||
class CasesUnitsWidget(dfwidget.Widget):
|
||||
"""
|
||||
Widget for collecting case and/or unit quantities. Most useful when you
|
||||
need to ensure user provides cases *or* units but not both.
|
||||
"""
|
||||
template = 'cases_units'
|
||||
amount_required = False
|
||||
one_amount_only = False
|
||||
|
||||
def serialize(self, field, cstruct, **kw):
|
||||
if cstruct in (colander.null, None):
|
||||
cstruct = ''
|
||||
readonly = kw.get('readonly', self.readonly)
|
||||
kw['cases'] = cstruct['cases'] or ''
|
||||
kw['units'] = cstruct['units'] or ''
|
||||
template = readonly and self.readonly_template or self.template
|
||||
values = self.get_template_values(field, cstruct, kw)
|
||||
return field.renderer(template, **values)
|
||||
|
||||
def deserialize(self, field, pstruct):
|
||||
if pstruct is colander.null:
|
||||
return colander.null
|
||||
|
||||
schema = ProductQuantity()
|
||||
try:
|
||||
validated = schema.deserialize(pstruct)
|
||||
except colander.Invalid as exc:
|
||||
raise colander.Invalid(field.schema, "Invalid pstruct: %s" % exc)
|
||||
|
||||
if self.amount_required and not (validated['cases'] or validated['units']):
|
||||
raise colander.Invalid(field.schema, "Must provide case or unit amount",
|
||||
value=validated)
|
||||
|
||||
if self.amount_required and self.one_amount_only and validated['cases'] and validated['units']:
|
||||
raise colander.Invalid(field.schema, "Must provide case *or* unit amount, "
|
||||
"but *not* both", value=validated)
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
class PlainSelectWidget(dfwidget.SelectWidget):
|
||||
template = 'select_plain'
|
||||
|
||||
|
|
29
tailbone/templates/deform/cases_units.pt
Normal file
29
tailbone/templates/deform/cases_units.pt
Normal file
|
@ -0,0 +1,29 @@
|
|||
<!--! -*- mode: html; -*- -->
|
||||
<div tal:define="oid oid|field.oid;
|
||||
css_class css_class|field.widget.css_class;
|
||||
style style|field.widget.style;"
|
||||
i18n:domain="deform"
|
||||
tal:omit-tag="">
|
||||
${field.start_mapping()}
|
||||
<div>
|
||||
<input type="text" name="cases" value="${cases}"
|
||||
tal:attributes="style style;
|
||||
class string: form-control ${css_class or ''};
|
||||
cases_attributes|field.widget.cases_attributes|{};"
|
||||
placeholder="cases"
|
||||
autocomplete="off"
|
||||
id="${oid}-cases"/>
|
||||
Cases
|
||||
</div>
|
||||
<div>
|
||||
<input type="text" name="units" value="${units}"
|
||||
tal:attributes="class string: form-control ${css_class or ''};
|
||||
style style;
|
||||
units_attributes|field.widget.units_attributes|{};"
|
||||
placeholder="units"
|
||||
autocomplete="off"
|
||||
id="${oid}-units"/>
|
||||
Units
|
||||
</div>
|
||||
${field.end_mapping()}
|
||||
</div>
|
|
@ -20,14 +20,23 @@
|
|||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="object_helpers()"></%def>
|
||||
|
||||
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
|
||||
<div class="form-wrapper">
|
||||
${form.render()|n}
|
||||
</div><!-- form-wrapper -->
|
||||
|
||||
<div style="display: flex;">
|
||||
<div class="object-helpers">
|
||||
${self.object_helpers()}
|
||||
</div>
|
||||
|
||||
<ul id="context-menu">
|
||||
${self.context_menu_items()}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
49
tailbone/templates/receiving/receive_row.mako
Normal file
49
tailbone/templates/receiving/receive_row.mako
Normal file
|
@ -0,0 +1,49 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/base.mako" />
|
||||
|
||||
<%def name="title()">Receive for Row #${row.sequence}</%def>
|
||||
|
||||
<%def name="context_menu_items()">
|
||||
% if master.rows_viewable and request.has_perm('{}.view'.format(permission_prefix)):
|
||||
<li>${h.link_to("View this {}".format(row_model_title), row_action_url('view', row))}</li>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="extra_javascript()">
|
||||
${parent.extra_javascript()}
|
||||
<script type="text/javascript">
|
||||
|
||||
function toggleFields(mode) {
|
||||
if (mode === undefined) {
|
||||
mode = $('select[name="mode"]').val();
|
||||
}
|
||||
if (mode == 'expired') {
|
||||
$('.field-wrapper.expiration_date').show();
|
||||
} else {
|
||||
$('.field-wrapper.expiration_date').hide();
|
||||
}
|
||||
}
|
||||
|
||||
$(function() {
|
||||
|
||||
toggleFields();
|
||||
|
||||
$('select[name="mode"]').on('selectmenuchange', function(event, ui) {
|
||||
toggleFields(ui.item.value);
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
|
||||
<div class="form-wrapper">
|
||||
${form.render()|n}
|
||||
</div><!-- form-wrapper -->
|
||||
|
||||
<ul id="context-menu">
|
||||
${self.context_menu_items()}
|
||||
</ul>
|
||||
|
||||
</div>
|
16
tailbone/templates/receiving/view_row.mako
Normal file
16
tailbone/templates/receiving/view_row.mako
Normal file
|
@ -0,0 +1,16 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/master/view_row.mako" />
|
||||
|
||||
<%def name="object_helpers()">
|
||||
${parent.object_helpers()}
|
||||
% if not batch.executed and not batch.is_truck_dump_child():
|
||||
<div class="object-helper">
|
||||
<h3>Receiving Tools</h3>
|
||||
<div class="object-helper-content">
|
||||
${h.link_to("Receive Product", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')}
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
|
@ -1111,6 +1111,9 @@ class BatchMasterView(MasterView):
|
|||
|
||||
def template_kwargs_view_row(self, **kwargs):
|
||||
kwargs['batch_model_title'] = kwargs['parent_model_title']
|
||||
# TODO: should these be set somewhere else?
|
||||
kwargs['row'] = kwargs['instance']
|
||||
kwargs['batch'] = kwargs['row'].batch
|
||||
return kwargs
|
||||
|
||||
def get_parent(self, row):
|
||||
|
|
|
@ -800,6 +800,159 @@ class ReceivingBatchView(PurchasingBatchView):
|
|||
if row.product and row.product.is_pack_item():
|
||||
return self.get_row_action_url('transform_unit', row)
|
||||
|
||||
def receive_row(self, mobile=False):
|
||||
"""
|
||||
Primary desktop view for row-level receiving.
|
||||
"""
|
||||
# TODO: this code was largely copied from mobile_receive_row() but it
|
||||
# tries to pave the way for shared logic, i.e. where the latter would
|
||||
# simply invoke this method and return the result. however we're not
|
||||
# there yet...for now it's only tested for desktop
|
||||
self.mobile = mobile
|
||||
self.viewing = True
|
||||
row = self.get_row_instance()
|
||||
batch = row.batch
|
||||
permission_prefix = self.get_permission_prefix()
|
||||
possible_modes = [
|
||||
'received',
|
||||
'damaged',
|
||||
'expired',
|
||||
]
|
||||
context = {
|
||||
'row': row,
|
||||
'batch': batch,
|
||||
'parent_instance': batch,
|
||||
'instance': row,
|
||||
'instance_title': self.get_row_instance_title(row),
|
||||
'parent_model_title': self.get_model_title(),
|
||||
'product_image_url': self.get_row_image_url(row),
|
||||
'allow_expired': self.handler.allow_expired_credits(),
|
||||
'allow_cases': self.handler.allow_cases(),
|
||||
'quick_receive': False,
|
||||
'quick_receive_all': False,
|
||||
}
|
||||
|
||||
if mobile:
|
||||
context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
|
||||
default=True)
|
||||
if batch.order_quantities_known:
|
||||
context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
|
||||
default=False)
|
||||
|
||||
schema = ReceiveRowForm().bind(session=self.Session())
|
||||
form = forms.Form(schema=schema, request=self.request)
|
||||
form.set_widget('mode', forms.widgets.JQuerySelectWidget(values=[(m, m) for m in possible_modes]))
|
||||
form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True,
|
||||
one_amount_only=True))
|
||||
form.set_type('expiration_date', 'date_jquery')
|
||||
|
||||
if not mobile:
|
||||
form.remove_field('quick_receive')
|
||||
|
||||
if form.validate(newstyle=True):
|
||||
|
||||
# handler takes care of the row receiving logic for us
|
||||
kwargs = dict(form.validated)
|
||||
kwargs['cases'] = kwargs['quantity']['cases']
|
||||
kwargs['units'] = kwargs['quantity']['units']
|
||||
del kwargs['quantity']
|
||||
self.handler.receive_row(row, **kwargs)
|
||||
|
||||
# keep track of last-used uom, although we just track
|
||||
# whether or not it was 'CS' since the unit_uom can vary
|
||||
# TODO: should this be done for desktop too somehow?
|
||||
sticky_case = None
|
||||
if mobile and not form.validated['quick_receive']:
|
||||
cases = form.validated['cases']
|
||||
units = form.validated['units']
|
||||
if cases and not units:
|
||||
sticky_case = True
|
||||
elif units and not cases:
|
||||
sticky_case = False
|
||||
if sticky_case is not None:
|
||||
self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
|
||||
|
||||
if mobile:
|
||||
return self.redirect(self.get_action_url('view', batch, mobile=True))
|
||||
else:
|
||||
return self.redirect(self.get_row_action_url('view', row))
|
||||
|
||||
# unit_uom can vary by product
|
||||
context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
|
||||
|
||||
if context['quick_receive'] and context['quick_receive_all']:
|
||||
if context['allow_cases']:
|
||||
context['quick_receive_uom'] = 'CS'
|
||||
raise NotImplementedError("TODO: add CS support for quick_receive_all")
|
||||
else:
|
||||
context['quick_receive_uom'] = context['unit_uom']
|
||||
accounted_for = self.handler.get_units_accounted_for(row)
|
||||
remainder = self.handler.get_units_ordered(row) - accounted_for
|
||||
|
||||
if accounted_for:
|
||||
# some product accounted for; button should receive "remainder" only
|
||||
if remainder:
|
||||
remainder = pretty_quantity(remainder)
|
||||
context['quick_receive_quantity'] = remainder
|
||||
context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
|
||||
else:
|
||||
# unless there is no remainder, in which case disable it
|
||||
context['quick_receive'] = False
|
||||
|
||||
else: # nothing yet accounted for, button should receive "all"
|
||||
if not remainder:
|
||||
raise ValueError("why is remainder empty?")
|
||||
remainder = pretty_quantity(remainder)
|
||||
context['quick_receive_quantity'] = remainder
|
||||
context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
|
||||
|
||||
# effective uom can vary in a few ways...the basic default is 'CS' if
|
||||
# self.default_uom_is_case is true, otherwise whatever unit_uom is.
|
||||
sticky_case = None
|
||||
if mobile:
|
||||
# TODO: should do this for desktop also, but rename the session variable
|
||||
sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
|
||||
if sticky_case is None:
|
||||
context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
|
||||
elif sticky_case:
|
||||
context['uom'] = 'CS'
|
||||
else:
|
||||
context['uom'] = context['unit_uom']
|
||||
if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
|
||||
context['uom'] = context['unit_uom']
|
||||
|
||||
# TODO: should do this for desktop in addition to mobile?
|
||||
if mobile and batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
|
||||
warn = True
|
||||
if batch.is_truck_dump_parent() and row.product:
|
||||
uuids = [child.uuid for child in batch.truck_dump_children]
|
||||
if uuids:
|
||||
count = self.Session.query(model.PurchaseBatchRow)\
|
||||
.filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
|
||||
.filter(model.PurchaseBatchRow.product == row.product)\
|
||||
.count()
|
||||
if count:
|
||||
warn = False
|
||||
if warn:
|
||||
self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
|
||||
|
||||
# TODO: should do this for desktop in addition to mobile?
|
||||
if mobile:
|
||||
# maybe alert user if they've already received some of this product
|
||||
alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received',
|
||||
default=False)
|
||||
if alert_received:
|
||||
if self.handler.get_units_confirmed(row):
|
||||
msg = "You have already received some of this product; last update was {}.".format(
|
||||
humanize.naturaltime(make_utc() - row.modified))
|
||||
self.request.session.flash(msg, 'receiving-warning')
|
||||
|
||||
context['form'] = form
|
||||
context['dform'] = form.make_deform_form()
|
||||
context['parent_url'] = self.get_action_url('view', batch, mobile=mobile)
|
||||
context['parent_title'] = self.get_instance_title(batch)
|
||||
return self.render_to_response('receive_row', context, mobile=mobile)
|
||||
|
||||
def transform_unit_row(self):
|
||||
"""
|
||||
View which transforms the given row, which is assumed to associate with
|
||||
|
@ -853,15 +1006,16 @@ class ReceivingBatchView(PurchasingBatchView):
|
|||
super(ReceivingBatchView, self).configure_row_form(f)
|
||||
batch = self.get_instance()
|
||||
|
||||
f.set_readonly('cases_ordered')
|
||||
f.set_readonly('units_ordered')
|
||||
f.set_readonly('po_unit_cost')
|
||||
f.set_readonly('po_total')
|
||||
# allow input for certain fields only; all others are readonly
|
||||
mutable = [
|
||||
'invoice_unit_cost',
|
||||
]
|
||||
for name in f.fields:
|
||||
if name not in mutable:
|
||||
f.set_readonly(name)
|
||||
|
||||
# invoice totals
|
||||
f.set_readonly('invoice_total')
|
||||
f.set_label('invoice_total', "Invoice Total (Orig.)")
|
||||
f.set_readonly('invoice_total_calculated')
|
||||
f.set_label('invoice_total_calculated', "Invoice Total (Calc.)")
|
||||
|
||||
# claims
|
||||
|
@ -1629,6 +1783,9 @@ class ReceivingBatchView(PurchasingBatchView):
|
|||
permission_prefix = cls.get_permission_prefix()
|
||||
|
||||
# row-level receiving
|
||||
config.add_route('{}.receive_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix))
|
||||
config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix),
|
||||
permission='{}.edit_row'.format(permission_prefix))
|
||||
config.add_route('mobile.{}.receive_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix))
|
||||
config.add_view(cls, attr='mobile_receive_row', route_name='mobile.{}.receive_row'.format(route_prefix),
|
||||
permission='{}.edit_row'.format(permission_prefix))
|
||||
|
@ -1711,6 +1868,25 @@ def valid_purchase_batch_row(node, kw):
|
|||
return validate
|
||||
|
||||
|
||||
class ReceiveRowForm(colander.MappingSchema):
|
||||
|
||||
mode = colander.SchemaNode(colander.String(),
|
||||
validator=colander.OneOf([
|
||||
'received',
|
||||
'damaged',
|
||||
'expired',
|
||||
# 'mispick',
|
||||
]))
|
||||
|
||||
quantity = forms.types.ProductQuantity()
|
||||
|
||||
expiration_date = colander.SchemaNode(colander.Date(),
|
||||
widget=dfwidget.TextInputWidget(),
|
||||
missing=colander.null)
|
||||
|
||||
quick_receive = colander.SchemaNode(colander.Boolean())
|
||||
|
||||
|
||||
class MobileReceivingForm(colander.MappingSchema):
|
||||
|
||||
row = colander.SchemaNode(colander.String(),
|
||||
|
|
Loading…
Reference in a new issue