Overhaul desktop views for receiving, for efficiency

still could use even more i'm sure, but this takes advantage of buefy
to add dialogs etc. from the "view receiving batch row" page.  this
batch no longer allows direct edit of rows but that's hopefully for
the better.
This commit is contained in:
Lance Edgar 2021-12-13 17:53:14 -06:00
parent 2f676774e9
commit 340a177a29
14 changed files with 1014 additions and 157 deletions

View file

@ -115,7 +115,9 @@ class BatchMasterView(MasterView):
def __init__(self, request):
super(BatchMasterView, self).__init__(request)
self.handler = self.get_handler()
self.batch_handler = self.get_handler()
# TODO: deprecate / remove this (?)
self.handler = self.batch_handler
@classmethod
def get_handler_factory(cls, rattail_config):
@ -1149,18 +1151,27 @@ class BatchMasterView(MasterView):
"""
Batch rows are editable only until batch is complete or executed.
"""
if not (self.rows_editable or self.rows_editable_but_not_directly):
return False
batch = self.get_parent(row)
return self.rows_editable and not batch.executed and not batch.complete
if batch.complete or batch.executed:
return False
return True
def row_deletable(self, row):
"""
Batch rows are deletable only until batch is complete or executed.
"""
if self.rows_deletable:
batch = self.get_parent(row)
if not batch.executed and not batch.complete:
return True
return False
if not self.rows_deletable:
return False
batch = self.get_parent(row)
if batch.complete or batch.executed:
return False
return True
def template_kwargs_view_row(self, **kwargs):
kwargs['batch_model_title'] = kwargs['parent_model_title']

View file

@ -166,6 +166,7 @@ class MasterView(View):
rows_viewable = True
rows_creatable = False
rows_editable = False
rows_editable_but_not_directly = False
rows_deletable = False
rows_deletable_speedbump = True
rows_bulk_deletable = False
@ -3852,6 +3853,7 @@ class MasterView(View):
return self.render_to_response('edit_row', {
'instance': row,
'row_parent': parent,
'parent_model_title': self.get_model_title(),
'parent_title': self.get_instance_title(parent),
'parent_url': self.get_action_url('view', parent),
'parent_instance': parent,
@ -3884,6 +3886,8 @@ class MasterView(View):
considered "deletable". Returns ``True`` by default; override as
necessary.
"""
if not self.rows_deletable:
return False
return True
def delete_row_object(self, row):
@ -4099,6 +4103,7 @@ class MasterView(View):
config_title = cls.get_config_title()
if cls.has_rows:
row_model_title = cls.get_row_model_title()
row_model_title_plural = cls.get_row_model_title_plural()
config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False)
@ -4386,9 +4391,10 @@ class MasterView(View):
# edit row
if cls.has_rows:
if cls.rows_editable:
if cls.rows_editable or cls.rows_editable_but_not_directly:
config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix),
"Edit individual {} rows".format(model_title))
"Edit individual {}".format(row_model_title_plural))
if cls.rows_editable:
config.add_route('{}.edit_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/edit'.format(url_prefix))
config.add_view(cls, attr='edit_row', route_name='{}.edit_row'.format(route_prefix),
permission='{}.edit_row'.format(permission_prefix))
@ -4397,7 +4403,7 @@ class MasterView(View):
if cls.has_rows:
if cls.rows_deletable:
config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix),
"Delete individual {} rows".format(model_title))
"Delete individual {}".format(row_model_title_plural))
config.add_route('{}.delete_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix))
config.add_view(cls, attr='delete_row', route_name='{}.delete_row'.format(route_prefix),
permission='{}.delete_row'.format(permission_prefix))

View file

@ -99,8 +99,10 @@ class PurchasingBatchView(BatchMasterView):
'upc': "UPC",
'item_id': "Item ID",
'brand_name': "Brand",
'case_quantity': "Case Size",
'po_line_number': "PO Line Number",
'po_unit_cost': "PO Unit Cost",
'po_case_size': "PO Case Size",
'po_total': "PO Total",
}
@ -144,6 +146,9 @@ class PurchasingBatchView(BatchMasterView):
'mispick',
'cases_mispick',
'units_mispick',
'missing',
'cases_missing',
'units_missing',
'po_line_number',
'po_unit_cost',
'po_total',
@ -710,8 +715,11 @@ class PurchasingBatchView(BatchMasterView):
f.set_renderer('damaged', self.render_row_quantity)
f.set_renderer('expired', self.render_row_quantity)
f.set_renderer('mispick', self.render_row_quantity)
f.set_renderer('missing', self.render_row_quantity)
f.set_type('case_quantity', 'quantity')
f.set_type('po_case_size', 'quantity')
f.set_type('invoice_case_size', 'quantity')
f.set_type('cases_ordered', 'quantity')
f.set_type('units_ordered', 'quantity')
f.set_type('cases_shipped', 'quantity')
@ -724,6 +732,8 @@ class PurchasingBatchView(BatchMasterView):
f.set_type('units_expired', 'quantity')
f.set_type('cases_mispick', 'quantity')
f.set_type('units_mispick', 'quantity')
f.set_type('cases_missing', 'quantity')
f.set_type('units_missing', 'quantity')
# currency fields
# nb. we only show "total" fields as currency, but not case or
@ -746,7 +756,8 @@ class PurchasingBatchView(BatchMasterView):
# credits
f.set_readonly('credits')
f.set_renderer('credits', self.render_row_credits)
if self.viewing:
f.set_renderer('credits', self.render_row_credits)
if self.creating:
f.remove_fields(
@ -786,36 +797,58 @@ class PurchasingBatchView(BatchMasterView):
app = self.get_rattail_app()
cases = getattr(row, 'cases_{}'.format(field))
units = getattr(row, 'units_{}'.format(field))
if cases and units:
return "{} cases + {} units".format(app.render_quantity(cases),
app.render_quantity(units))
if cases and not units:
return "{} cases".format(app.render_quantity(cases))
if units and not cases:
return "{} units".format(app.render_quantity(units))
def render_row_credits(self, row, field):
if not row.credits:
return ""
return app.render_cases_units(cases, units)
def make_row_credits_grid(self, row):
use_buefy = self.get_use_buefy()
route_prefix = self.get_route_prefix()
columns = [
'credit_type',
'cases_shorted',
'units_shorted',
'credit_total',
]
g = grids.Grid(
factory = self.get_grid_factory()
g = factory(
key='{}.row_credits'.format(route_prefix),
data=row.credits,
columns=columns,
labels={'credit_type': "Type",
'cases_shorted': "Cases",
'units_shorted': "Units"})
data=[] if use_buefy else row.credits,
columns=[
'credit_type',
# 'cases_shorted',
# 'units_shorted',
'shorted',
'credit_total',
'expiration_date',
# 'mispick_upc',
# 'mispick_brand_name',
# 'mispick_description',
# 'mispick_size',
],
labels={
'credit_type': "Type",
'cases_shorted': "Cases",
'units_shorted': "Units",
'shorted': "Quantity",
'credit_total': "Total",
'mispick_upc': "Mispick UPC",
'mispick_brand_name': "MP Brand",
'mispick_description': "MP Description",
'mispick_size': "MP Size",
})
g.set_type('cases_shorted', 'quantity')
g.set_type('units_shorted', 'quantity')
g.set_type('credit_total', 'currency')
return HTML.literal(g.render_grid())
return g
def render_row_credits(self, row, field):
use_buefy = self.get_use_buefy()
if not use_buefy and not row.credits:
return
g = self.make_row_credits_grid(row)
if use_buefy:
return HTML.literal(
g.render_buefy_table_element(data_prop='rowData.credits'))
else:
return HTML.literal(g.render_grid())
# def item_lookup(self, value, field=None):
# """

View file

@ -51,6 +51,21 @@ from tailbone.views.purchasing import PurchasingBatchView
log = logging.getLogger(__name__)
POSSIBLE_RECEIVING_MODES = [
'received',
'damaged',
'expired',
# 'mispick',
'missing',
]
POSSIBLE_CREDIT_TYPES = [
'damaged',
'expired',
# 'mispick',
'missing',
]
class ReceivingBatchView(PurchasingBatchView):
"""
@ -63,7 +78,9 @@ class ReceivingBatchView(PurchasingBatchView):
index_title = "Receiving"
downloadable = True
bulk_deletable = True
rows_editable = True
rows_editable = False
rows_editable_but_not_directly = True
rows_deletable = True
default_uom_is_case = True
@ -181,13 +198,18 @@ class ReceivingBatchView(PurchasingBatchView):
'mispick',
'cases_mispick',
'units_mispick',
'missing',
'cases_missing',
'units_missing',
'catalog_unit_cost',
'po_line_number',
'po_unit_cost',
'po_case_size',
'po_total',
'invoice_line_number',
'invoice_unit_cost',
'invoice_cost_confirmed',
'invoice_case_size',
'invoice_total',
'invoice_total_calculated',
'status_code',
@ -322,17 +344,14 @@ class ReceivingBatchView(PurchasingBatchView):
return self.render_to_response('create', context)
def row_deletable(self, row):
# first run it through the normal logic, if that doesn't like
# it then we won't either
if not super(ReceivingBatchView, self).row_deletable(row):
return False
batch = row.batch
# don't allow if master view has disabled that entirely
if not self.rows_deletable:
return False
# can never delete rows for complete/executed batches
# TODO: not so sure about the 'complete' part though..?
if batch.executed or batch.complete:
return False
# can always delete rows from truck dump parent
if batch.is_truck_dump_parent():
return True
@ -362,7 +381,7 @@ class ReceivingBatchView(PurchasingBatchView):
super(ReceivingBatchView, self).configure_form(f)
model = self.model
batch = f.model_instance
allow_truck_dump = self.handler.allow_truck_dump_receiving()
allow_truck_dump = self.batch_handler.allow_truck_dump_receiving()
workflow = self.request.matchdict.get('workflow_key')
route_prefix = self.get_route_prefix()
use_buefy = self.get_use_buefy()
@ -472,9 +491,9 @@ class ReceivingBatchView(PurchasingBatchView):
and self.purchase_order_fieldname == 'purchase'):
if use_buefy:
f.replace('purchase', 'purchase_uuid')
purchases = self.handler.get_eligible_purchases(
purchases = self.batch_handler.get_eligible_purchases(
vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING)
values = [(p.uuid, self.handler.render_eligible_purchase(p))
values = [(p.uuid, self.batch_handler.render_eligible_purchase(p))
for p in purchases]
f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values))
f.set_label('purchase_uuid', "Purchase Order")
@ -497,12 +516,11 @@ class ReceivingBatchView(PurchasingBatchView):
f.remove('invoice_total_calculated')
# hide all invoice fields if batch does not have invoice file
if not self.creating and not self.handler.has_invoice_file(batch):
if not self.creating and not self.batch_handler.has_invoice_file(batch):
f.remove('invoice_file',
'invoice_date',
'invoice_number',
'invoice_total',
'invoice_total_calculated')
'invoice_total')
# receiving_complete
if self.creating:
@ -517,9 +535,12 @@ class ReceivingBatchView(PurchasingBatchView):
'invoice_parser_key')
elif workflow == 'from_invoice':
f.remove('truck_dump_batch_uuid')
f.set_required('invoice_file')
f.set_required('invoice_parser_key')
f.remove('truck_dump_batch_uuid',
'po_number',
'invoice_date',
'invoice_number')
elif workflow == 'from_po':
f.remove('truck_dump_batch_uuid',
@ -531,9 +552,13 @@ class ReceivingBatchView(PurchasingBatchView):
'invoice_number')
elif workflow == 'from_po_with_invoice':
f.remove('truck_dump_batch_uuid')
f.set_required('invoice_file')
f.set_required('invoice_parser_key')
f.remove('truck_dump_batch_uuid',
'date_ordered',
'po_number',
'invoice_date',
'invoice_number')
elif workflow == 'truck_dump_children_first':
f.remove('truck_dump_batch_uuid',
@ -614,16 +639,92 @@ class ReceivingBatchView(PurchasingBatchView):
raise NotImplementedError
return kwargs
def make_po_vs_invoice_breakdown(self, batch):
"""
Returns a simple breakdown as list of 2-tuples, each of which
has the display title as first member, and number of rows as
second member.
"""
grouped = {}
labels = OrderedDict([
('both', "Found in both PO and Invoice"),
('po_not_invoice', "Found in PO but not Invoice"),
('invoice_not_po', "Found in Invoice but not PO"),
('neither', "Not found in PO nor Invoice"),
])
for row in batch.active_rows():
if row.po_line_number and not row.invoice_line_number:
grouped.setdefault('po_not_invoice', []).append(row)
elif row.invoice_line_number and not row.po_line_number:
grouped.setdefault('invoice_not_po', []).append(row)
elif row.po_line_number and row.invoice_line_number:
grouped.setdefault('both', []).append(row)
else:
grouped.setdefault('neither', []).append(row)
breakdown = []
for key, label in labels.items():
if key in grouped:
breakdown.append({
'title': label,
'count': len(grouped[key]),
})
return breakdown
def template_kwargs_view(self, **kwargs):
kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs)
batch = kwargs['instance']
if self.handler.has_purchase_order(batch) and self.handler.has_invoice_file(batch):
breakdown = self.make_po_vs_invoice_breakdown(batch)
factory = self.get_grid_factory()
kwargs['po_vs_invoice_breakdown_grid'] = factory(
'batch_po_vs_invoice_breakdown',
data=breakdown,
columns=['title', 'count'])
return kwargs
def get_context_credits(self, row):
app = self.get_rattail_app()
credits_data = []
for credit in row.credits:
credits_data.append({
'uuid': credit.uuid,
'credit_type': credit.credit_type,
'expiration_date': six.text_type(credit.expiration_date) if credit.expiration_date else None,
'cases_shorted': app.render_quantity(credit.cases_shorted),
'units_shorted': app.render_quantity(credit.units_shorted),
'shorted': app.render_cases_units(credit.cases_shorted,
credit.units_shorted),
'credit_total': app.render_currency(credit.credit_total),
'mispick_upc': '-',
'mispick_brand_name': '-',
'mispick_description': '-',
'mispick_size': '-',
})
return credits_data
def template_kwargs_view_row(self, **kwargs):
kwargs = super(ReceivingBatchView, self).template_kwargs_view_row(**kwargs)
use_buefy = self.get_use_buefy()
app = self.get_rattail_app()
handler = app.get_products_handler()
products_handler = app.get_products_handler()
row = kwargs['instance']
if row.product:
kwargs['image_url'] = handler.get_image_url(row.product)
kwargs['image_url'] = products_handler.get_image_url(row.product)
elif row.upc:
kwargs['image_url'] = handler.get_image_url(upc=row.upc)
kwargs['image_url'] = products_handler.get_image_url(upc=row.upc)
if use_buefy:
kwargs['row_context'] = self.get_context_row(row)
kwargs['possible_receiving_modes'] = POSSIBLE_RECEIVING_MODES
kwargs['possible_credit_types'] = POSSIBLE_CREDIT_TYPES
return kwargs
@ -849,6 +950,24 @@ class ReceivingBatchView(PurchasingBatchView):
if row.product and row.product.is_pack_item():
return self.get_row_action_url('transform_unit', row)
def make_row_credits_grid(self, row):
# first make grid like normal
g = super(ReceivingBatchView, self).make_row_credits_grid(row)
if (self.get_use_buefy()
and self.has_perm('edit_row')
and self.row_editable(row)):
# add the Un-Declare action
g.main_actions.append(self.make_action(
'remove', label="Un-Declare",
url='#', icon='trash',
link_class='has-text-danger',
click_handler='removeCreditInit(props.row)'))
return g
def vuejs_convert_quantity(self, cstruct):
result = dict(cstruct)
if result['cases'] is colander.null:
@ -872,6 +991,55 @@ class ReceivingBatchView(PurchasingBatchView):
self.viewing = True
use_buefy = self.get_use_buefy()
row = self.get_row_instance()
# things are a bit different now w/ buefy support..
if use_buefy:
# don't even bother showing this page if that's all the
# request was about
if self.request.method == 'GET':
return self.redirect(self.get_row_action_url('view', row))
# make sure edit is allowed
if not (self.has_perm('edit_row') and self.row_editable(row)):
raise self.forbidden()
# check for JSON POST, which is submitted via AJAX from
# the "view row" page
if self.request.method == 'POST' and not self.request.POST:
data = self.request.json_body
kwargs = dict(data)
# TODO: for some reason quantities can come through as strings?
cases = kwargs['quantity']['cases']
if cases is not None:
if cases == '':
cases = None
else:
cases = decimal.Decimal(cases)
kwargs['cases'] = cases
units = kwargs['quantity']['units']
if units is not None:
if units == '':
units = None
else:
units = decimal.Decimal(units)
kwargs['units'] = units
del kwargs['quantity']
# handler takes care of the receiving logic for us
try:
self.batch_handler.receive_row(row, **kwargs)
except Exception as error:
return self.json_response({'error': six.text_type(error)})
self.Session.flush()
self.Session.refresh(row)
return self.json_response({
'ok': True,
'row': self.get_context_row(row)})
batch = row.batch
permission_prefix = self.get_permission_prefix()
possible_modes = [
@ -1024,11 +1192,59 @@ class ReceivingBatchView(PurchasingBatchView):
"""
use_buefy = self.get_use_buefy()
row = self.get_row_instance()
# things are a bit different now w/ buefy support..
if use_buefy:
# don't even bother showing this page if that's all the
# request was about
if self.request.method == 'GET':
return self.redirect(self.get_row_action_url('view', row))
# make sure edit is allowed
if not (self.has_perm('edit_row') and self.row_editable(row)):
raise self.forbidden()
# check for JSON POST, which is submitted via AJAX from
# the "view row" page
if self.request.method == 'POST' and not self.request.POST:
data = self.request.json_body
kwargs = dict(data)
# TODO: for some reason quantities can come through as strings?
if kwargs['cases'] is not None:
if kwargs['cases'] == '':
kwargs['cases'] = None
else:
kwargs['cases'] = decimal.Decimal(kwargs['cases'])
if kwargs['units'] is not None:
if kwargs['units'] == '':
kwargs['units'] = None
else:
kwargs['units'] = decimal.Decimal(kwargs['units'])
try:
result = self.handler.can_declare_credit(row, **kwargs)
except Exception as error:
return self.json_response({'error': six.text_type(error)})
else:
if result:
self.handler.declare_credit(row, **kwargs)
else:
return self.json_response({
'error': "Handler says you can't declare that credit; "
"not sure why"})
self.Session.flush()
self.Session.refresh(row)
return self.json_response({
'ok': True,
'row': self.get_context_row(row)})
batch = row.batch
possible_credit_types = [
'damaged',
'expired',
]
context = {
'row': row,
'batch': batch,
@ -1044,9 +1260,10 @@ class ReceivingBatchView(PurchasingBatchView):
schema = DeclareCreditForm()
form = forms.Form(schema=schema, request=self.request,
use_buefy=use_buefy)
form.cancel_url = self.get_row_action_url('view', row)
# credit_type
values = [(m, m) for m in possible_credit_types]
values = [(m, m) for m in POSSIBLE_CREDIT_TYPES]
if use_buefy:
widget = dfwidget.SelectWidget(values=values)
else:
@ -1085,6 +1302,54 @@ class ReceivingBatchView(PurchasingBatchView):
context['parent_title'] = self.get_instance_title(batch)
return self.render_to_response('declare_credit', context)
def undeclare_credit(self):
"""
View for un-declaring a credit, i.e. moving the credit amounts
back into the "received" tally.
"""
model = self.model
row = self.get_row_instance()
data = self.request.json_body
# make sure edit is allowed
if not (self.has_perm('edit_row') and self.row_editable(row)):
raise self.forbidden()
# figure out which credit to un-declare
credit = None
uuid = data.get('uuid')
if uuid:
credit = self.Session.query(model.PurchaseBatchCredit).get(uuid)
if not credit:
return {'error': "Credit not found"}
# un-declare it
self.batch_handler.undeclare_credit(row, credit)
self.Session.flush()
self.Session.refresh(row)
return {'ok': True,
'row': self.get_context_row(row)}
def get_context_row(self, row):
app = self.get_rattail_app()
return {
'sequence': row.sequence,
'case_quantity': float(row.case_quantity) if row.case_quantity is not None else None,
'ordered': self.render_row_quantity(row, 'ordered'),
'shipped': self.render_row_quantity(row, 'shipped'),
'received': self.render_row_quantity(row, 'received'),
'cases_received': float(row.cases_received) if row.cases_received is not None else None,
'units_received': float(row.units_received) if row.units_received is not None else None,
'damaged': self.render_row_quantity(row, 'damaged'),
'expired': self.render_row_quantity(row, 'expired'),
'mispick': self.render_row_quantity(row, 'mispick'),
'missing': self.render_row_quantity(row, 'missing'),
'credits': self.get_context_credits(row),
'invoice_total_calculated': app.render_currency(row.invoice_total_calculated),
'status': row.STATUS[row.status_code],
}
def transform_unit_row(self):
"""
View which transforms the given row, which is assumed to associate with
@ -1593,6 +1858,14 @@ class ReceivingBatchView(PurchasingBatchView):
config.add_view(cls, attr='declare_credit', route_name='{}.declare_credit'.format(route_prefix),
permission='{}.edit_row'.format(permission_prefix))
# un-declare credit
config.add_route('{}.undeclare_credit'.format(route_prefix),
'{}/rows/{{row_uuid}}/undeclare-credit'.format(instance_url_prefix))
config.add_view(cls, attr='undeclare_credit',
route_name='{}.undeclare_credit'.format(route_prefix),
permission='{}.edit_row'.format(permission_prefix),
renderer='json')
# update row cost
config.add_route('{}.update_row_cost'.format(route_prefix), '{}/update-row-cost'.format(instance_url_prefix))
config.add_view(cls, attr='update_row_cost', route_name='{}.update_row_cost'.format(route_prefix),
@ -1649,12 +1922,8 @@ class NewReceivingBatch(colander.Schema):
class ReceiveRowForm(colander.MappingSchema):
mode = colander.SchemaNode(colander.String(),
validator=colander.OneOf([
'received',
'damaged',
'expired',
# 'mispick',
]))
validator=colander.OneOf(
POSSIBLE_RECEIVING_MODES))
quantity = forms.types.ProductQuantity()
@ -1677,11 +1946,8 @@ class ReceiveRowForm(colander.MappingSchema):
class DeclareCreditForm(colander.MappingSchema):
credit_type = colander.SchemaNode(colander.String(),
validator=colander.OneOf([
'damaged',
'expired',
# 'mispick',
]))
validator=colander.OneOf(
POSSIBLE_CREDIT_TYPES))
quantity = forms.types.ProductQuantity()