tailbone/tailbone/views/purchasing/receiving.py
Lance Edgar 801c56f06e More tweaks for receiving batch workflows
now first step requires choice of vendor and workflow.  supports receiving from
PO at least for native use case.
2021-01-31 12:10:44 -06:00

1594 lines
66 KiB
Python

# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Views for 'receiving' (purchasing) batches
"""
from __future__ import unicode_literals, absolute_import
import re
import decimal
import logging
import six
import humanize
import sqlalchemy as sa
from rattail import pod
from rattail.db import model, Session as RattailSession
from rattail.time import localtime, make_utc
from rattail.util import pretty_quantity, prettify, OrderedDict, simple_error
from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser
from rattail.threads import Thread
import colander
from deform import widget as dfwidget
from pyramid import httpexceptions
from webhelpers2.html import tags, HTML
from tailbone import forms, grids
from tailbone.views.purchasing import PurchasingBatchView
log = logging.getLogger(__name__)
class ReceivingBatchView(PurchasingBatchView):
"""
Master view for receiving batches
"""
route_prefix = 'receiving'
url_prefix = '/receiving'
model_title = "Receiving Batch"
model_title_plural = "Receiving Batches"
index_title = "Receiving"
downloadable = True
bulk_deletable = True
rows_editable = True
default_uom_is_case = True
purchase_order_fieldname = 'purchase'
labels = {
'truck_dump_batch': "Truck Dump Parent",
'invoice_parser_key': "Invoice Parser",
}
grid_columns = [
'id',
'vendor',
'truck_dump',
'description',
'department',
'buyer',
'date_ordered',
'created',
'created_by',
'rowcount',
'invoice_total_calculated',
'status_code',
'executed',
]
form_fields = [
'id',
'batch_type', # TODO: ideally would get rid of this one
'store',
'vendor',
'receiving_workflow',
'description',
'truck_dump',
'truck_dump_children_first',
'truck_dump_children',
'truck_dump_ready',
'truck_dump_batch',
'invoice_file',
'invoice_parser_key',
'department',
'purchase',
'vendor_email',
'vendor_fax',
'vendor_contact',
'vendor_phone',
'date_ordered',
'date_received',
'po_number',
'po_total',
'invoice_date',
'invoice_number',
'invoice_total',
'invoice_total_calculated',
'notes',
'created',
'created_by',
'status_code',
'truck_dump_status',
'rowcount',
'order_quantities_known',
'receiving_complete',
'complete',
'executed',
'executed_by',
]
row_grid_columns = [
'sequence',
'upc',
# 'item_id',
'vendor_code',
'brand_name',
'description',
'size',
'department_name',
'cases_shipped',
'units_shipped',
'cases_received',
'units_received',
'catalog_unit_cost',
'invoice_unit_cost',
'invoice_total_calculated',
'credits',
'status_code',
'truck_dump_status',
]
row_form_fields = [
'item_entry',
'upc',
'item_id',
'vendor_code',
'product',
'brand_name',
'description',
'size',
'case_quantity',
'cases_ordered',
'units_ordered',
'cases_shipped',
'units_shipped',
'cases_received',
'units_received',
'cases_damaged',
'units_damaged',
'cases_expired',
'units_expired',
'cases_mispick',
'units_mispick',
'po_line_number',
'po_unit_cost',
'po_total',
'invoice_line_number',
'invoice_unit_cost',
'invoice_cost_confirmed',
'invoice_total',
'invoice_total_calculated',
'status_code',
'truck_dump_status',
'claims',
'credits',
]
# convenience list of all quantity attributes involved for a truck dump claim
claim_keys = [
'cases_received',
'units_received',
'cases_damaged',
'units_damaged',
'cases_expired',
'units_expired',
]
@property
def batch_mode(self):
return self.enum.PURCHASE_BATCH_MODE_RECEIVING
def create(self, form=None, **kwargs):
"""
Custom view for creating a new receiving batch. We split the process
into two steps, 1) choose and 2) create. This is because the specific
form details for creating a batch will depend on which "type" of batch
creation is to be done, and it's much easier to keep conditional logic
for that in the server instead of client-side etc.
"""
route_prefix = self.get_route_prefix()
workflows = self.handler.supported_receiving_workflows()
valid_workflows = [workflow['workflow_key']
for workflow in workflows]
# if user has already identified their desired workflow, then we can
# just farm out to the default logic. we will of course configure our
# form differently, based on workflow, but this create() method at
# least will not need customization for that.
if self.request.matched_route.name.endswith('create_workflow'):
# however we do have one more thing to check - the workflow
# requested must of course be valid!
workflow_key = self.request.matchdict['workflow_key']
if workflow_key not in valid_workflows:
self.request.session.flash(
"Not a supported workflow: {}".format(workflow_key),
'error')
raise self.redirect(self.request.route_url('{}.create'.format(route_prefix)))
# okay now do the normal thing, per workflow
return super(ReceivingBatchView, self).create(**kwargs)
# on the other hand, if caller provided a form, that means we are in
# the middle of some other custom workflow, e.g. "add child to truck
# dump parent" or some such. in which case we also defer to the normal
# logic, so as to not interfere with that.
if form:
return super(ReceivingBatchView, self).create(form=form, **kwargs)
# okay, at this point we need the user to select a vendor and workflow
self.creating = True
use_buefy = self.get_use_buefy()
context = {}
# form to accept user choice of vendor/workflow
schema = NewReceivingBatch().bind(valid_workflows=valid_workflows)
form = forms.Form(schema=schema, request=self.request,
use_buefy=use_buefy)
# configure vendor field
use_autocomplete = self.rattail_config.getbool(
'rattail', 'vendor.use_autocomplete', default=True)
if use_autocomplete:
vendor_display = ""
if self.request.method == 'POST':
if self.request.POST.get('vendor'):
vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor'])
if vendor:
vendor_display = six.text_type(vendor)
vendors_url = self.request.route_url('vendors.autocomplete')
form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
field_display=vendor_display, service_url=vendors_url))
else:
vendors = self.Session.query(model.Vendor)\
.order_by(model.Vendor.id)
vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name))
for vendor in vendors]
if use_buefy:
form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
else:
form.set_widget('vendor', forms.widgets.JQuerySelectWidget(values=vendor_values))
# configure workflow field
values = [(workflow['workflow_key'], workflow['display'])
for workflow in workflows]
if use_buefy:
form.set_widget('workflow',
dfwidget.SelectWidget(values=values))
else:
form.set_widget('workflow',
forms.widgets.JQuerySelectWidget(values=values))
form.submit_label = "Continue"
form.cancel_url = self.get_index_url()
# if form validates, that means user has chosen a creation type, so we
# just redirect to the appropriate "new batch of type X" page
if form.validate(newstyle=True):
workflow_key = form.validated['workflow']
vendor_uuid = form.validated['vendor']
url = self.request.route_url('{}.create_workflow'.format(route_prefix),
workflow_key=workflow_key,
vendor_uuid=vendor_uuid)
raise self.redirect(url)
context['form'] = form
if hasattr(form, 'make_deform_form'):
context['dform'] = form.make_deform_form()
return self.render_to_response('create', context)
def row_deletable(self, row):
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
# can always delete rows from truck dump child
elif batch.is_truck_dump_child():
return True
else: # okay, normal batch
if batch.order_quantities_known:
return False
else: # allow delete if receiving rom scratch
return True
# cannot delete row by default
return False
def get_instance_title(self, batch):
title = super(ReceivingBatchView, self).get_instance_title(batch)
if batch.is_truck_dump_parent():
title = "{} (TRUCK DUMP PARENT)".format(title)
elif batch.is_truck_dump_child():
title = "{} (TRUCK DUMP CHILD)".format(title)
return title
def configure_form(self, f):
super(ReceivingBatchView, self).configure_form(f)
model = self.model
batch = f.model_instance
allow_truck_dump = self.handler.allow_truck_dump_receiving()
workflow = self.request.matchdict.get('workflow_key')
route_prefix = self.get_route_prefix()
use_buefy = self.get_use_buefy()
# tweak some things if we are in "step 2" of creating new batch
if self.creating and workflow:
# display vendor but do not allow changing
vendor = self.Session.query(model.Vendor).get(
self.request.matchdict['vendor_uuid'])
assert vendor
f.set_readonly('vendor_uuid')
f.set_default('vendor_uuid', six.text_type(vendor))
# cancel should take us back to choosing a workflow
f.cancel_url = self.request.route_url('{}.create'.format(route_prefix))
# receiving_workflow
if self.creating and workflow:
f.set_readonly('receiving_workflow')
f.set_renderer('receiving_workflow', self.render_receiving_workflow)
else:
f.remove('receiving_workflow')
# batch_type
if self.creating:
f.set_widget('batch_type', dfwidget.HiddenWidget())
f.set_default('batch_type', workflow)
f.set_hidden('batch_type')
else:
f.remove_field('batch_type')
# truck_dump*
if allow_truck_dump:
# truck_dump
if self.creating or not batch.is_truck_dump_parent():
f.remove_field('truck_dump')
else:
f.set_readonly('truck_dump')
# truck_dump_children_first
if self.creating or not batch.is_truck_dump_parent():
f.remove_field('truck_dump_children_first')
# truck_dump_children
if self.viewing and batch.is_truck_dump_parent():
f.set_renderer('truck_dump_children', self.render_truck_dump_children)
else:
f.remove_field('truck_dump_children')
# truck_dump_ready
if self.creating or not batch.is_truck_dump_parent():
f.remove_field('truck_dump_ready')
# truck_dump_status
if self.creating or not batch.is_truck_dump_parent():
f.remove_field('truck_dump_status')
else:
f.set_readonly('truck_dump_status')
f.set_enum('truck_dump_status', model.PurchaseBatch.STATUS)
# truck_dump_batch
if self.creating:
f.replace('truck_dump_batch', 'truck_dump_batch_uuid')
batches = self.Session.query(model.PurchaseBatch)\
.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\
.filter(model.PurchaseBatch.truck_dump == True)\
.filter(model.PurchaseBatch.complete == True)\
.filter(model.PurchaseBatch.executed == None)\
.order_by(model.PurchaseBatch.id)
batch_values = [(b.uuid, "({}) {}, {}".format(b.id_str, b.date_received, b.vendor))
for b in batches]
batch_values.insert(0, ('', "(please choose)"))
f.set_widget('truck_dump_batch_uuid', forms.widgets.JQuerySelectWidget(values=batch_values))
f.set_label('truck_dump_batch_uuid', "Truck Dump Parent")
elif batch.is_truck_dump_child():
f.set_readonly('truck_dump_batch')
f.set_renderer('truck_dump_batch', self.render_truck_dump_batch)
else:
f.remove_field('truck_dump_batch')
# truck_dump_vendor
if self.creating:
f.set_label('truck_dump_vendor', "Vendor")
f.set_readonly('truck_dump_vendor')
f.set_renderer('truck_dump_vendor', self.render_truck_dump_vendor)
else:
f.remove_fields('truck_dump',
'truck_dump_children_first',
'truck_dump_children',
'truck_dump_ready',
'truck_dump_status',
'truck_dump_batch')
# invoice_file
if self.creating:
f.set_type('invoice_file', 'file', required=False)
else:
f.set_readonly('invoice_file')
f.set_renderer('invoice_file', self.render_downloadable_file)
# invoice_parser_key
if self.creating:
parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display)
parser_values = [(p.key, p.display) for p in parsers]
parser_values.insert(0, ('', "(please choose)"))
if use_buefy:
f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values))
else:
f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values))
else:
f.remove_field('invoice_parser_key')
# store
if self.creating:
store = self.rattail_config.get_store(self.Session())
f.set_default('store_uuid', store.uuid)
# TODO: seems like set_hidden() should also set HiddenWidget
f.set_hidden('store_uuid')
f.set_widget('store_uuid', dfwidget.HiddenWidget())
# purchase
if (self.creating and workflow == 'from_po'
and self.purchase_order_fieldname == 'purchase'):
if use_buefy:
f.replace('purchase', 'purchase_uuid')
purchases = self.handler.get_eligible_purchases(
vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING)
values = [(p.uuid, self.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")
f.set_required('purchase_uuid')
else:
f.remove_field('purchase')
# department
if self.creating:
f.remove_field('department_uuid')
# order_quantities_known
if not self.editing:
f.remove_field('order_quantities_known')
# invoice totals
f.set_label('invoice_total', "Invoice Total (Orig.)")
f.set_label('invoice_total_calculated', "Invoice Total (Calc.)")
if self.creating:
f.remove('invoice_total_calculated')
# receiving_complete
if self.creating:
f.remove('receiving_complete')
# now that all fields are setup, some final tweaks based on workflow
if self.creating and workflow:
if workflow == 'from_scratch':
f.remove('truck_dump_batch_uuid',
'invoice_file',
'invoice_parser_key')
elif workflow == 'from_invoice':
f.remove('truck_dump_batch_uuid')
f.set_required('invoice_file')
f.set_required('invoice_parser_key')
elif workflow == 'from_po':
f.remove('truck_dump_batch_uuid',
'invoice_file',
'invoice_parser_key')
elif workflow == 'truck_dump_children_first':
f.remove('truck_dump_batch_uuid',
'invoice_file',
'invoice_parser_key',
'date_ordered',
'po_number',
'invoice_date',
'invoice_number')
elif workflow == 'truck_dump_children_last':
f.remove('truck_dump_batch_uuid',
'invoice_file',
'invoice_parser_key',
'date_ordered',
'po_number',
'invoice_date',
'invoice_number')
def render_receiving_workflow(self, batch, field):
key = self.request.matchdict['workflow_key']
info = self.handler.receiving_workflow_info(key)
if info:
return info['display']
def template_kwargs_create(self, **kwargs):
kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs)
if self.handler.allow_truck_dump_receiving():
vmap = {}
batches = self.Session.query(model.PurchaseBatch)\
.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\
.filter(model.PurchaseBatch.truck_dump == True)\
.filter(model.PurchaseBatch.complete == True)
for batch in batches:
vmap[batch.uuid] = batch.vendor_uuid
kwargs['batch_vendor_map'] = vmap
return kwargs
def get_batch_kwargs(self, batch, **kwargs):
kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, **kwargs)
batch_type = self.request.POST['batch_type']
# must pull vendor from URL if it was not in form data
if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
if 'vendor_uuid' in self.request.matchdict:
kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
if batch_type == 'from_scratch':
kwargs.pop('truck_dump_batch', None)
kwargs.pop('truck_dump_batch_uuid', None)
elif batch_type == 'from_invoice':
pass
elif batch_type == 'from_po':
# TODO: how to best handle this field? this doesn't seem flexible
kwargs['purchase_key'] = batch.purchase_uuid
elif batch_type == 'truck_dump_children_first':
kwargs['truck_dump'] = True
kwargs['truck_dump_children_first'] = True
kwargs['order_quantities_known'] = True
# TODO: this makes sense in some cases, but all?
# (should just omit that field when not relevant)
kwargs['date_ordered'] = None
elif batch_type == 'truck_dump_children_last':
kwargs['truck_dump'] = True
kwargs['truck_dump_ready'] = True
# TODO: this makes sense in some cases, but all?
# (should just omit that field when not relevant)
kwargs['date_ordered'] = None
elif batch_type.startswith('truck_dump_child'):
truck_dump = self.get_instance()
kwargs['store'] = truck_dump.store
kwargs['vendor'] = truck_dump.vendor
kwargs['truck_dump_batch'] = truck_dump
else:
raise NotImplementedError
return kwargs
def department_for_purchase(self, purchase):
pass
def delete_instance(self, batch):
"""
Delete all data (files etc.) for the batch.
"""
truck_dump = batch.truck_dump_batch
if batch.is_truck_dump_parent():
for child in batch.truck_dump_children:
self.delete_instance(child)
super(ReceivingBatchView, self).delete_instance(batch)
if truck_dump:
self.handler.refresh(truck_dump)
def render_truck_dump_batch(self, batch, field):
truck_dump = batch.truck_dump_batch
if not truck_dump:
return ""
text = "({}) {}".format(truck_dump.id_str, truck_dump.description or '')
url = self.request.route_url('receiving.view', uuid=truck_dump.uuid)
return tags.link_to(text, url)
def render_truck_dump_vendor(self, batch, field):
truck_dump = self.get_instance()
vendor = truck_dump.vendor
text = "({}) {}".format(vendor.id, vendor.name)
url = self.request.route_url('vendors.view', uuid=vendor.uuid)
return tags.link_to(text, url)
def render_truck_dump_children(self, batch, field):
contents = []
children = batch.truck_dump_children
if children:
items = []
for child in children:
text = "({}) {}".format(child.id_str, child.description or '')
url = self.request.route_url('receiving.view', uuid=child.uuid)
items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
contents.append(HTML.tag('ul', c=items))
if not batch.executed and (batch.complete or batch.truck_dump_children_first):
buttons = self.make_truck_dump_child_buttons(batch)
if buttons:
buttons = HTML.literal(' ').join(buttons)
contents.append(HTML.tag('div', class_='buttons', c=[buttons]))
if not contents:
return ""
return HTML.tag('div', c=contents)
def make_truck_dump_child_buttons(self, batch):
return [
tags.link_to("Add from Invoice File", self.get_action_url('add_child_from_invoice', batch), class_='button autodisable'),
]
def add_child_from_invoice(self):
"""
View for adding a child batch to a truck dump, from invoice file.
"""
batch = self.get_instance()
if not batch.is_truck_dump_parent():
self.request.session.flash("Batch is not a truck dump: {}".format(batch))
return self.redirect(self.get_action_url('view', batch))
if batch.executed:
self.request.session.flash("Batch has already been executed: {}".format(batch))
return self.redirect(self.get_action_url('view', batch))
if not batch.complete and not batch.truck_dump_children_first:
self.request.session.flash("Batch is not marked as complete: {}".format(batch))
return self.redirect(self.get_action_url('view', batch))
self.creating = True
form = self.make_child_from_invoice_form(self.get_model_class())
return self.create(form=form)
def make_child_from_invoice_form(self, instance, **kwargs):
"""
Creates a new form for the given model class/instance
"""
kwargs['configure'] = self.configure_child_from_invoice_form
return self.make_form(instance=instance, **kwargs)
def configure_child_from_invoice_form(self, f):
assert self.creating
truck_dump = self.get_instance()
self.configure_form(f)
# cancel should go back to truck dump parent
f.cancel_url = self.get_action_url('view', truck_dump)
f.set_fields([
'batch_type',
'truck_dump_parent',
'truck_dump_vendor',
'invoice_file',
'invoice_parser_key',
'invoice_number',
'description',
'notes',
])
# batch_type
f.set_widget('batch_type', forms.widgets.ReadonlyWidget())
f.set_default('batch_type', 'truck_dump_child_from_invoice')
f.set_hidden('batch_type', False)
# truck_dump_batch_uuid
f.set_readonly('truck_dump_parent')
f.set_renderer('truck_dump_parent', self.render_truck_dump_parent)
# invoice_parser_key
f.set_required('invoice_parser_key')
def render_truck_dump_parent(self, batch, field):
truck_dump = self.get_instance()
text = six.text_type(truck_dump)
url = self.request.route_url('receiving.view', uuid=truck_dump.uuid)
return tags.link_to(text, url)
@staticmethod
@colander.deferred
def validate_purchase(node, kw):
session = kw['session']
def validate(node, value):
purchase = session.query(model.Purchase).get(value)
if not purchase:
raise colander.Invalid(node, "Purchase not found")
return purchase.uuid
return validate
def assign_purchase_order(self, batch, po_form):
"""
Assign the original purchase order to the given batch. Default
behavior assumes a Rattail Purchase object is what we're after.
"""
purchase = self.handler.assign_purchase_order(
batch, po_form.validated[self.purchase_order_fieldname],
session=self.Session())
department = self.department_for_purchase(purchase)
if department:
batch.department_uuid = department.uuid
def configure_row_grid(self, g):
super(ReceivingBatchView, self).configure_row_grid(g)
g.set_label('department_name', "Department")
# vendor_code
g.filters['vendor_code'].default_active = True
g.filters['vendor_code'].default_verb = 'contains'
# catalog_unit_cost
g.set_renderer('catalog_unit_cost', self.render_row_grid_cost)
g.set_label('catalog_unit_cost', "Catalog Cost")
g.filters['catalog_unit_cost'].label = "Catalog Unit Cost"
# invoice_unit_cost
g.set_renderer('invoice_unit_cost', self.render_row_grid_cost)
g.set_label('invoice_unit_cost', "Invoice Cost")
g.filters['invoice_unit_cost'].label = "Invoice Unit Cost"
# credits
# note that sorting by credits involves a subquery with group by clause.
# seems likely there may be a better way? but this seems to work fine
Credits = self.Session.query(model.PurchaseBatchCredit.row_uuid,
sa.func.count().label('credit_count'))\
.group_by(model.PurchaseBatchCredit.row_uuid)\
.subquery()
g.set_joiner('credits', lambda q: q.outerjoin(Credits))
g.sorters['credits'] = lambda q, d: q.order_by(getattr(Credits.c.credit_count, d)())
# hide 'ordered' columns for truck dump parent, if its "children first"
# flag is set, since that batch type is only concerned with receiving
batch = self.get_instance()
if batch.is_truck_dump_parent() and not batch.truck_dump_children_first:
g.hide_column('cases_ordered')
g.hide_column('units_ordered')
# add "Transform to Unit" action, if appropriate
if batch.is_truck_dump_parent():
permission_prefix = self.get_permission_prefix()
if self.request.has_perm('{}.edit_row'.format(permission_prefix)):
transform = grids.GridAction('transform',
icon='shuffle',
label="Transform to Unit",
url=self.transform_unit_url)
g.more_actions.append(transform)
if g.main_actions and g.main_actions[-1].key == 'delete':
delete = g.main_actions.pop()
g.more_actions.append(delete)
# truck_dump_status
if not batch.is_truck_dump_parent():
g.hide_column('truck_dump_status')
else:
g.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS)
def row_grid_extra_class(self, row, i):
css_class = super(ReceivingBatchView, self).row_grid_extra_class(row, i)
if row.catalog_cost_confirmed:
css_class = '{} catalog_cost_confirmed'.format(css_class or '')
if row.invoice_cost_confirmed:
css_class = '{} invoice_cost_confirmed'.format(css_class or '')
return css_class
def render_row_grid_cost(self, row, field):
cost = getattr(row, field)
if cost is None:
return ""
return "{:0,.3f}".format(cost)
def transform_unit_url(self, row, i):
# grid action is shown only when we return a URL here
if self.row_editable(row):
if row.batch.is_truck_dump_parent():
if row.product and row.product.is_pack_item():
return self.get_row_action_url('transform_unit', row)
def receive_row(self, **kwargs):
"""
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.viewing = True
use_buefy = self.get_use_buefy()
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,
}
schema = ReceiveRowForm().bind(session=self.Session())
form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy)
form.cancel_url = self.get_row_action_url('view', row)
mode_values = [(mode, mode) for mode in possible_modes]
if use_buefy:
form.set_widget('mode', dfwidget.SelectWidget(values=mode_values))
else:
form.set_widget('mode', forms.widgets.JQuerySelectWidget(values=mode_values))
form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True,
one_amount_only=True))
form.set_type('expiration_date', 'date_jquery')
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
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)
context['parent_title'] = self.get_instance_title(batch)
return self.render_to_response('receive_row', context)
def declare_credit(self):
"""
View for declaring a credit, i.e. converting some "received" or similar
quantity, to a credit of some sort.
"""
row = self.get_row_instance()
batch = row.batch
possible_credit_types = [
'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(),
}
schema = DeclareCreditForm()
form = forms.Form(schema=schema, request=self.request)
form.set_widget('credit_type', forms.widgets.JQuerySelectWidget(
values=[(m, m) for m in possible_credit_types]))
form.set_widget('quantity', forms.widgets.CasesUnitsWidget(
amount_required=True, one_amount_only=True))
form.set_type('expiration_date', 'date_jquery')
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']
try:
result = self.handler.can_declare_credit(row, **kwargs)
except Exception as error:
self.request.session.flash("Handler says you can't declare that credit: {}".format(error), 'error')
else:
if result:
self.handler.declare_credit(row, **kwargs)
return self.redirect(self.get_row_action_url('view', row))
self.request.session.flash("Handler says you can't declare that credit; not sure why", 'error')
context['form'] = form
context['dform'] = form.make_deform_form()
context['parent_url'] = self.get_action_url('view', batch)
context['parent_title'] = self.get_instance_title(batch)
return self.render_to_response('declare_credit', context)
def transform_unit_row(self):
"""
View which transforms the given row, which is assumed to associate with
a "pack" item, such that it instead associates with the "unit" item,
with quantities adjusted accordingly.
"""
batch = self.get_instance()
row_uuid = self.request.params.get('row_uuid')
row = self.Session.query(model.PurchaseBatchRow).get(row_uuid) if row_uuid else None
if row and row.batch is batch and not row.removed:
pass # we're good
else:
if self.request.method == 'POST':
raise self.notfound()
return {'error': "Row not found."}
def normalize(product):
data = {
'upc': product.upc,
'item_id': product.item_id,
'description': product.description,
'size': product.size,
'case_quantity': None,
'cases_received': row.cases_received,
}
cost = product.cost_for_vendor(batch.vendor)
if cost:
data['case_quantity'] = cost.case_size
return data
if self.request.method == 'POST':
self.handler.transform_pack_to_unit(row)
self.request.session.flash("Transformed pack to unit item for: {}".format(row.product))
return self.redirect(self.get_action_url('view', batch))
pack_data = normalize(row.product)
pack_data['units_received'] = row.units_received
unit_data = normalize(row.product.unit)
unit_data['units_received'] = None
if row.units_received:
unit_data['units_received'] = row.units_received * row.product.pack_size
diff = self.make_diff(pack_data, unit_data, monospace=True)
return self.render_to_response('transform_unit_row', {
'batch': batch,
'row': row,
'diff': diff,
})
def configure_row_form(self, f):
super(ReceivingBatchView, self).configure_row_form(f)
batch = self.get_instance()
# 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_label('invoice_total', "Invoice Total (Orig.)")
f.set_label('invoice_total_calculated', "Invoice Total (Calc.)")
# claims
f.set_readonly('claims')
if batch.is_truck_dump_parent():
f.set_renderer('claims', self.render_parent_row_claims)
f.set_helptext('claims', "Parent row is claimed by these child rows.")
elif batch.is_truck_dump_child():
f.set_renderer('claims', self.render_child_row_claims)
f.set_helptext('claims', "Child row makes claims against these parent rows.")
else:
f.remove_field('claims')
# truck_dump_status
if self.creating or not batch.is_truck_dump_parent():
f.remove_field('truck_dump_status')
else:
f.set_readonly('truck_dump_status')
f.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS)
# misc. labels
f.set_label('vendor_code', "Vendor Item Code")
def render_parent_row_claims(self, row, field):
items = []
for claim in row.claims:
child_row = claim.claiming_row
child_batch = child_row.batch
text = child_batch.id_str
if child_batch.description:
text = "{} ({})".format(text, child_batch.description)
text = "{}, row {}".format(text, child_row.sequence)
url = self.get_row_action_url('view', child_row)
items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
return HTML.tag('ul', c=items)
def render_child_row_claims(self, row, field):
items = []
for claim in row.truck_dump_claims:
parent_row = claim.claimed_row
parent_batch = parent_row.batch
text = parent_batch.id_str
if parent_batch.description:
text = "{} ({})".format(text, parent_batch.description)
text = "{}, row {}".format(text, parent_row.sequence)
url = self.get_row_action_url('view', parent_row)
items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
return HTML.tag('ul', c=items)
def validate_row_form(self, form):
# if normal validation fails, stop there
if not super(ReceivingBatchView, self).validate_row_form(form):
return False
# if user is editing row from truck dump child, then we must further
# validate the form to ensure whatever new amounts they've requested
# would in fact fall within the bounds of what is available from the
# truck dump parent batch...
if self.editing:
batch = self.get_instance()
if batch.is_truck_dump_child():
old_row = self.get_row_instance()
case_quantity = old_row.case_quantity
# get all "existing" (old) claim amounts
old_claims = {}
for claim in old_row.truck_dump_claims:
for key in self.claim_keys:
amount = getattr(claim, key)
if amount is not None:
old_claims[key] = old_claims.get(key, 0) + amount
# get all "proposed" (new) claim amounts
new_claims = {}
for key in self.claim_keys:
amount = form.validated[key]
if amount is not colander.null and amount is not None:
# do not allow user to request a negative claim amount
if amount < 0:
self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error')
return False
new_claims[key] = amount
# figure out what changes are actually being requested
claim_diff = {}
for key in new_claims:
if key not in old_claims:
claim_diff[key] = new_claims[key]
elif new_claims[key] != old_claims[key]:
claim_diff[key] = new_claims[key] - old_claims[key]
# do not allow user to request a negative claim amount
if claim_diff[key] < (0 - old_claims[key]):
self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error')
return False
for key in old_claims:
if key not in new_claims:
claim_diff[key] = 0 - old_claims[key]
# find all rows from truck dump parent which "may" pertain to child row
# TODO: perhaps would need to do a more "loose" match on UPC also?
if not old_row.product_uuid:
raise NotImplementedError("Don't (yet) know how to handle edit for row with no product")
parent_rows = [row for row in batch.truck_dump_batch.active_rows()
if row.product_uuid == old_row.product_uuid]
# NOTE: "confirmed" are the proper amounts which exist in the
# parent batch. "claimed" are the amounts claimed by this row.
# get existing "confirmed" and "claimed" amounts for all
# (possibly related) truck dump parent rows
confirmed = {}
claimed = {}
for parent_row in parent_rows:
for key in self.claim_keys:
amount = getattr(parent_row, key)
if amount is not None:
confirmed[key] = confirmed.get(key, 0) + amount
for claim in parent_row.claims:
for key in self.claim_keys:
amount = getattr(claim, key)
if amount is not None:
claimed[key] = claimed.get(key, 0) + amount
# now to see if user's request is possible, given what is
# available...
# first we must (pretend to) "relinquish" any claims which are
# to be reduced or eliminated, according to our diff
for key, amount in claim_diff.items():
if amount < 0:
amount = abs(amount) # make positive, for more readable math
if key not in claimed or claimed[key] < amount:
self.request.session.flash("Cannot relinquish more claims than the "
"parent batch has to offer.", 'error')
return False
claimed[key] -= amount
# next we must determine if any "new" requests would increase
# the claim(s) beyond what is available
for key, amount in claim_diff.items():
if amount > 0:
claimed[key] = claimed.get(key, 0) + amount
if key not in confirmed or confirmed[key] < claimed[key]:
self.request.session.flash("Cannot request to claim more product than "
"is available in Truck Dump Parent batch", 'error')
return False
# looks like the claim diff is all good, so let's attach that
# to the form now and then pick this up again in save()
form._claim_diff = claim_diff
# all validation went ok
return True
def save_edit_row_form(self, form):
batch = self.get_instance()
row = self.objectify(form)
# editing a row for truck dump child batch can be complicated...
if batch.is_truck_dump_child():
# grab the claim diff which we attached to the form during validation
claim_diff = form._claim_diff
# first we must "relinquish" any claims which are to be reduced or
# eliminated, according to our diff
for key, amount in claim_diff.items():
if amount < 0:
amount = abs(amount) # make positive, for more readable math
# we'd prefer to find an exact match, i.e. there was a 1CS
# claim and our diff said to reduce by 1CS
matches = [claim for claim in row.truck_dump_claims
if getattr(claim, key) == amount]
if matches:
claim = matches[0]
setattr(claim, key, None)
else:
# but if no exact match(es) then we'll just whittle
# away at whatever (smallest) claims we do find
possible = [claim for claim in row.truck_dump_claims
if getattr(claim, key) is not None]
for claim in sorted(possible, key=lambda claim: getattr(claim, key)):
previous = getattr(claim, key)
if previous:
if previous >= amount:
if (previous - amount):
setattr(claim, key, previous - amount)
else:
setattr(claim, key, None)
amount = 0
break
else:
setattr(claim, key, None)
amount -= previous
if amount:
raise NotImplementedError("Had leftover amount when \"relinquishing\" claim(s)")
# next we must stake all new claim(s) as requested, per our diff
for key, amount in claim_diff.items():
if amount > 0:
# if possible, we'd prefer to add to an existing claim
# which already has an amount for this key
existing = [claim for claim in row.truck_dump_claims
if getattr(claim, key) is not None]
if existing:
claim = existing[0]
setattr(claim, key, getattr(claim, key) + amount)
# next we'd prefer to add to an existing claim, of any kind
elif row.truck_dump_claims:
claim = row.truck_dump_claims[0]
setattr(claim, key, (getattr(claim, key) or 0) + amount)
else:
# otherwise we must create a new claim...
# find all rows from truck dump parent which "may" pertain to child row
# TODO: perhaps would need to do a more "loose" match on UPC also?
if not row.product_uuid:
raise NotImplementedError("Don't (yet) know how to handle edit for row with no product")
parent_rows = [parent_row for parent_row in batch.truck_dump_batch.active_rows()
if parent_row.product_uuid == row.product_uuid]
# remove any parent rows which are fully claimed
# TODO: should perhaps leverage actual amounts for this, instead
parent_rows = [parent_row for parent_row in parent_rows
if parent_row.status_code != parent_row.STATUS_TRUCKDUMP_CLAIMED]
# try to find a parent row which is exact match on claim amount
matches = [parent_row for parent_row in parent_rows
if getattr(parent_row, key) == amount]
if matches:
# make the claim against first matching parent row
claim = model.PurchaseBatchRowClaim()
claim.claimed_row = parent_rows[0]
setattr(claim, key, amount)
row.truck_dump_claims.append(claim)
else:
# but if no exact match(es) then we'll just whittle
# away at whatever (smallest) parent rows we do find
for parent_row in sorted(parent_rows, lambda prow: getattr(prow, key)):
available = getattr(parent_row, key) - sum([getattr(claim, key) for claim in parent_row.claims])
if available:
if available >= amount:
# make claim against this parent row, making it fully claimed
claim = model.PurchaseBatchRowClaim()
claim.claimed_row = parent_row
setattr(claim, key, amount)
row.truck_dump_claims.append(claim)
amount = 0
break
else:
# make partial claim against this parent row
claim = model.PurchaseBatchRowClaim()
claim.claimed_row = parent_row
setattr(claim, key, available)
row.truck_dump_claims.append(claim)
amount -= available
if amount:
raise NotImplementedError("Had leftover amount when \"staking\" claim(s)")
# now we must be sure to refresh all truck dump parent batch rows
# which were affected. but along with that we also should purge
# any empty claims, i.e. those which were fully relinquished
pending_refresh = set()
for claim in list(row.truck_dump_claims):
parent_row = claim.claimed_row
if claim.is_empty():
row.truck_dump_claims.remove(claim)
self.Session.flush()
pending_refresh.add(parent_row)
for parent_row in pending_refresh:
self.handler.refresh_row(parent_row)
self.handler.refresh_batch_status(batch.truck_dump_batch)
self.after_edit_row(row)
self.Session.flush()
return row
def redirect_after_edit_row(self, row, **kwargs):
return self.redirect(self.get_row_action_url('view', row))
def update_row_cost(self):
"""
AJAX view for updating the invoice (actual) unit cost for a row.
"""
batch = self.get_instance()
data = dict(self.request.POST)
# validate row
uuid = data.get('row_uuid')
row = self.Session.query(model.PurchaseBatchRow).get(uuid) if uuid else None
if not row or row.batch is not batch:
return {'error': "Row not found"}
# validate/normalize cost value(s)
for field in ('catalog_unit_cost', 'invoice_unit_cost'):
if field in data:
cost = data[field]
if cost == '':
return {'error': "You must specify a cost"}
try:
cost = decimal.Decimal(six.text_type(cost))
except decimal.InvalidOperation:
return {'error': "Cost is not valid!"}
else:
data[field] = cost
# okay, update our row
self.handler.update_row_cost(row, **data)
return {
'row': {
'catalog_unit_cost': '{:0.3f}'.format(row.catalog_unit_cost),
'catalog_cost_confirmed': row.catalog_cost_confirmed,
'invoice_unit_cost': '{:0.3f}'.format(row.invoice_unit_cost),
'invoice_cost_confirmed': row.invoice_cost_confirmed,
'invoice_total_calculated': '{:0.2f}'.format(row.invoice_total_calculated),
},
'batch': {
'invoice_total_calculated': '{:0.2f}'.format(batch.invoice_total_calculated),
},
}
def save_quick_row_form(self, form):
batch = self.get_instance()
entry = form.validated['quick_entry']
row = self.handler.quick_entry(self.Session(), batch, entry)
return row
def get_row_image_url(self, row):
if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
return pod.get_image_url(self.rattail_config, row.upc)
def auto_receive(self):
"""
View which can "auto-receive" all items in the batch. Meant only as a
convenience for developers.
"""
batch = self.get_instance()
key = '{}.receive_all'.format(self.get_grid_key())
progress = self.make_progress(key)
kwargs = {'progress': progress}
thread = Thread(target=self.auto_receive_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs)
thread.start()
return self.render_progress(progress, {
'instance': batch,
'cancel_url': self.get_action_url('view', batch),
'cancel_msg': "Auto-receive was canceled",
})
def auto_receive_thread(self, uuid, user_uuid, progress=None):
"""
Thread target for receiving all items on the given batch.
"""
session = RattailSession()
batch = session.query(model.PurchaseBatch).get(uuid)
user = session.query(model.User).get(user_uuid)
try:
self.handler.auto_receive_all_items(batch, progress=progress)
# if anything goes wrong, rollback and log the error etc.
except Exception as error:
session.rollback()
log.exception("auto-receive failed for: %s".format(batch))
session.close()
if progress:
progress.session.load()
progress.session['error'] = True
progress.session['error_msg'] = "Auto-receive failed: {}".format(
simple_error(error))
progress.session.save()
# if no error, check result flag (false means user canceled)
else:
session.commit()
session.refresh(batch)
success_url = self.get_action_url('view', batch)
session.close()
if progress:
progress.session.load()
progress.session['complete'] = True
progress.session['success_url'] = success_url
progress.session.save()
@classmethod
def defaults(cls, config):
cls._receiving_defaults(config)
cls._purchasing_defaults(config)
cls._batch_defaults(config)
cls._defaults(config)
@classmethod
def _receiving_defaults(cls, config):
rattail_config = config.registry.settings.get('rattail_config')
route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
instance_url_prefix = cls.get_instance_url_prefix()
model_key = cls.get_model_key()
permission_prefix = cls.get_permission_prefix()
# new receiving batch using workflow X
config.add_route('{}.create_workflow'.format(route_prefix), '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix))
config.add_view(cls, attr='create', route_name='{}.create_workflow'.format(route_prefix),
permission='{}.create'.format(permission_prefix))
# row-level receiving
config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix))
config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix),
permission='{}.edit_row'.format(permission_prefix))
# declare credit for row
config.add_route('{}.declare_credit'.format(route_prefix), '{}/rows/{{row_uuid}}/declare-credit'.format(instance_url_prefix))
config.add_view(cls, attr='declare_credit', route_name='{}.declare_credit'.format(route_prefix),
permission='{}.edit_row'.format(permission_prefix))
# 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),
permission='{}.edit_row'.format(permission_prefix),
renderer='json')
# add TD child batch, from invoice file
config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/add-child-from-invoice'.format(instance_url_prefix))
config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix),
permission='{}.create'.format(permission_prefix))
# transform TD parent row from "pack" to "unit" item
config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/transform-unit'.format(instance_url_prefix))
config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix),
permission='{}.edit_row'.format(permission_prefix), renderer='json')
# auto-receive all items
if not rattail_config.production():
config.add_route('{}.auto_receive'.format(route_prefix), '{}/auto-receive'.format(instance_url_prefix),
request_method='POST')
config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix),
permission='admin')
@colander.deferred
def valid_workflow(node, kw):
"""
Deferred validator for ``workflow`` field, for new batches.
"""
valid_workflows = kw['valid_workflows']
def validate(node, value):
# we just need to provide possible values, and let stock validator
# handle the rest
oneof = colander.OneOf(valid_workflows)
return oneof(node, value)
return validate
class NewReceivingBatch(colander.Schema):
"""
Schema for choosing which "type" of new receiving batch should be created.
"""
vendor = colander.SchemaNode(colander.String(),
label="Vendor")
workflow = colander.SchemaNode(colander.String(),
validator=valid_workflow)
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 DeclareCreditForm(colander.MappingSchema):
credit_type = colander.SchemaNode(colander.String(),
validator=colander.OneOf([
'damaged',
'expired',
# 'mispick',
]))
quantity = forms.types.ProductQuantity()
expiration_date = colander.SchemaNode(colander.Date(),
widget=dfwidget.TextInputWidget(),
missing=colander.null)
def includeme(config):
ReceivingBatchView.defaults(config)