d145ce5f6d
the latter was apparently causing session flush and would create the "dummy" batch in addition to the "real" one...
1048 lines
39 KiB
Python
1048 lines
39 KiB
Python
# -*- coding: utf-8; -*-
|
|
################################################################################
|
|
#
|
|
# Rattail -- Retail Software Framework
|
|
# Copyright © 2010-2018 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 logging
|
|
|
|
import six
|
|
import sqlalchemy as sa
|
|
|
|
from rattail import pod
|
|
from rattail.db import model, api
|
|
from rattail.db.util import maxlen
|
|
from rattail.gpc import GPC
|
|
from rattail.time import localtime
|
|
from rattail.util import pretty_quantity, prettify, OrderedDict
|
|
from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser
|
|
|
|
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 MobileItemStatusFilter(grids.filters.MobileFilter):
|
|
|
|
value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all']
|
|
|
|
def filter_equal(self, query, value):
|
|
|
|
# NOTE: this is only relevant for truck dump or "from scratch"
|
|
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))\
|
|
.filter(~model.PurchaseBatchRow.status_code.in_((
|
|
model.PurchaseBatchRow.STATUS_OK,
|
|
model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND)))
|
|
|
|
if value == 'invalid':
|
|
return query.filter(model.PurchaseBatchRow.status_code.in_((
|
|
model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
|
|
model.PurchaseBatchRow.STATUS_COST_NOT_FOUND,
|
|
model.PurchaseBatchRow.STATUS_CASE_QUANTITY_UNKNOWN,
|
|
model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS,
|
|
)))
|
|
|
|
if value == 'unexpected':
|
|
return query.filter(sa.and_(
|
|
sa.or_(
|
|
model.PurchaseBatchRow.cases_ordered == None,
|
|
model.PurchaseBatchRow.cases_ordered == 0),
|
|
sa.or_(
|
|
model.PurchaseBatchRow.units_ordered == None,
|
|
model.PurchaseBatchRow.units_ordered == 0)))
|
|
|
|
if value == 'damaged':
|
|
return query.filter(sa.or_(
|
|
model.PurchaseBatchRow.cases_damaged != 0,
|
|
model.PurchaseBatchRow.units_damaged != 0))
|
|
|
|
if value == 'expired':
|
|
return query.filter(sa.or_(
|
|
model.PurchaseBatchRow.cases_expired != 0,
|
|
model.PurchaseBatchRow.units_expired != 0))
|
|
|
|
return query
|
|
|
|
def iter_choices(self):
|
|
for value in self.value_choices:
|
|
yield value, prettify(value)
|
|
|
|
|
|
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
|
|
rows_editable = True
|
|
mobile_creatable = True
|
|
mobile_rows_filterable = True
|
|
mobile_rows_creatable = True
|
|
mobile_rows_quickable = True
|
|
mobile_rows_deletable = True
|
|
|
|
allow_from_po = False
|
|
allow_from_scratch = True
|
|
allow_truck_dump = False
|
|
|
|
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',
|
|
'department',
|
|
'buyer',
|
|
'date_ordered',
|
|
'created',
|
|
'created_by',
|
|
'rowcount',
|
|
'status_code',
|
|
'executed',
|
|
]
|
|
|
|
form_fields = [
|
|
'id',
|
|
'batch_type',
|
|
'description',
|
|
'store',
|
|
'vendor',
|
|
'truck_dump',
|
|
'truck_dump_children',
|
|
'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',
|
|
'notes',
|
|
'created',
|
|
'created_by',
|
|
'status_code',
|
|
'rowcount',
|
|
'order_quantities_known',
|
|
'complete',
|
|
'executed',
|
|
'executed_by',
|
|
]
|
|
|
|
mobile_form_fields = [
|
|
'vendor',
|
|
'truck_dump',
|
|
'department',
|
|
]
|
|
|
|
row_grid_columns = [
|
|
'sequence',
|
|
'upc',
|
|
# 'item_id',
|
|
'brand_name',
|
|
'description',
|
|
'size',
|
|
'department_name',
|
|
'cases_ordered',
|
|
'units_ordered',
|
|
'cases_received',
|
|
'units_received',
|
|
# 'po_total',
|
|
'invoice_total',
|
|
'credits',
|
|
'status_code',
|
|
]
|
|
|
|
row_form_fields = [
|
|
'upc',
|
|
'item_id',
|
|
'product',
|
|
'brand_name',
|
|
'description',
|
|
'size',
|
|
'case_quantity',
|
|
'cases_ordered',
|
|
'units_ordered',
|
|
'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_total',
|
|
'status_code',
|
|
'credits',
|
|
]
|
|
|
|
@property
|
|
def batch_mode(self):
|
|
return self.enum.PURCHASE_BATCH_MODE_RECEIVING
|
|
|
|
def row_editable(self, row):
|
|
batch = row.batch
|
|
if batch.truck_dump_batch:
|
|
return False
|
|
return True
|
|
|
|
def row_deletable(self, row):
|
|
batch = row.batch
|
|
if batch.truck_dump:
|
|
return True
|
|
return False
|
|
|
|
def get_instance_title(self, batch):
|
|
title = super(ReceivingBatchView, self).get_instance_title(batch)
|
|
if batch.truck_dump:
|
|
title = "{} (TRUCK DUMP PARENT)".format(title)
|
|
elif batch.truck_dump_batch:
|
|
title = "{} (TRUCK DUMP CHILD)".format(title)
|
|
return title
|
|
|
|
def configure_form(self, f):
|
|
super(ReceivingBatchView, self).configure_form(f)
|
|
batch = f.model_instance
|
|
|
|
# batch_type
|
|
if self.creating:
|
|
f.set_enum('batch_type', OrderedDict([
|
|
('from_scratch', "New from Scratch"),
|
|
]))
|
|
else:
|
|
f.remove_field('batch_type')
|
|
|
|
# truck_dump*
|
|
if self.allow_truck_dump:
|
|
|
|
# truck_dump
|
|
if self.creating:
|
|
f.remove_field('truck_dump')
|
|
elif batch.truck_dump_batch:
|
|
f.remove_field('truck_dump')
|
|
else:
|
|
f.set_readonly('truck_dump')
|
|
|
|
# truck_dump_children
|
|
if self.viewing:
|
|
if batch.truck_dump:
|
|
f.set_renderer('truck_dump_children', self.render_truck_dump_children)
|
|
else:
|
|
f.remove_field('truck_dump_children')
|
|
else:
|
|
f.remove_field('truck_dump_children')
|
|
|
|
# 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.truck_dump:
|
|
f.remove_field('truck_dump_batch')
|
|
elif batch.truck_dump_batch:
|
|
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',
|
|
'truck_dump_batch')
|
|
|
|
# invoice_file
|
|
if self.creating:
|
|
f.set_type('invoice_file', 'file')
|
|
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)"))
|
|
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_widget('store_uuid', forms.widgets.ReadonlyWidget())
|
|
f.set_default('store_uuid', store.uuid)
|
|
f.set_hidden('store_uuid')
|
|
|
|
# purchase
|
|
if self.creating:
|
|
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')
|
|
|
|
def template_kwargs_create(self, **kwargs):
|
|
kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs)
|
|
if self.allow_truck_dump:
|
|
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, mobile=False):
|
|
kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
|
|
if not mobile:
|
|
batch_type = self.request.POST['batch_type']
|
|
if batch_type == 'from_scratch':
|
|
kwargs.pop('truck_dump_batch', None)
|
|
kwargs.pop('truck_dump_batch_uuid', 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.truck_dump:
|
|
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 = six.text_type(truck_dump)
|
|
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 = six.text_type(child)
|
|
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 batch.complete and not batch.executed:
|
|
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.truck_dump:
|
|
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:
|
|
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)
|
|
|
|
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')
|
|
|
|
# truck_dump_batch_uuid
|
|
f.set_readonly('truck_dump_parent')
|
|
f.set_renderer('truck_dump_parent', self.render_truck_dump_parent)
|
|
|
|
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)
|
|
|
|
def render_mobile_listitem(self, batch, i):
|
|
title = "({}) {} for ${:0,.2f} - {}, {}".format(
|
|
batch.id_str,
|
|
batch.vendor,
|
|
batch.po_total or 0,
|
|
batch.department,
|
|
batch.created_by)
|
|
return title
|
|
|
|
def make_mobile_row_filters(self):
|
|
"""
|
|
Returns a set of filters for the mobile row grid.
|
|
"""
|
|
batch = self.get_instance()
|
|
filters = grids.filters.GridFilterSet()
|
|
|
|
# visible filter options will depend on whether batch came from purchase
|
|
if batch.order_quantities_known:
|
|
value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'invalid', 'all']
|
|
default_status = 'incomplete'
|
|
else:
|
|
value_choices = ['received', 'damaged', 'expired', 'invalid', 'all']
|
|
default_status = 'all'
|
|
|
|
# remove 'expired' filter option if not relevant
|
|
if 'expired' in value_choices and not self.handler.allow_expired_credits():
|
|
value_choices.remove('expired')
|
|
|
|
filters['status'] = MobileItemStatusFilter('status',
|
|
value_choices=value_choices,
|
|
default_value=default_status)
|
|
return filters
|
|
|
|
def get_purchase(self, uuid):
|
|
return self.Session.query(model.Purchase).get(uuid)
|
|
|
|
def mobile_create(self):
|
|
"""
|
|
Mobile view for creating a new receiving batch
|
|
"""
|
|
mode = self.batch_mode
|
|
data = {'mode': mode}
|
|
phase = 1
|
|
|
|
schema = MobileNewReceivingBatch().bind(session=self.Session())
|
|
form = forms.Form(schema=schema, request=self.request)
|
|
if form.validate(newstyle=True):
|
|
phase = form.validated['phase']
|
|
|
|
if form.validated['workflow'] == 'from_scratch':
|
|
if not self.allow_from_scratch:
|
|
raise NotImplementedError("Requested workflow not supported: from_scratch")
|
|
batch = self.model_class()
|
|
batch.store = self.rattail_config.get_store(self.Session())
|
|
batch.mode = mode
|
|
batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor'])
|
|
batch.created_by = self.request.user
|
|
batch.date_received = localtime(self.rattail_config).date()
|
|
kwargs = self.get_batch_kwargs(batch, mobile=True)
|
|
batch = self.handler.make_batch(self.Session(), **kwargs)
|
|
return self.redirect(self.get_action_url('view', batch, mobile=True))
|
|
|
|
elif 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.query(model.Vendor).get(form.validated['vendor'])
|
|
batch.created_by = self.request.user
|
|
batch.date_received = localtime(self.rattail_config).date()
|
|
kwargs = self.get_batch_kwargs(batch, mobile=True)
|
|
batch = self.handler.make_batch(self.Session(), **kwargs)
|
|
return self.redirect(self.get_action_url('view', batch, mobile=True))
|
|
|
|
elif form.validated['workflow'] == 'from_po':
|
|
if not self.allow_from_po:
|
|
raise NotImplementedError("Requested workflow not supported: from_po")
|
|
|
|
vendor = self.Session.query(model.Vendor).get(form.validated['vendor'])
|
|
data['vendor'] = vendor
|
|
|
|
schema = self.make_mobile_receiving_from_po_schema()
|
|
po_form = forms.Form(schema=schema, request=self.request)
|
|
if phase == 2:
|
|
if po_form.validate(newstyle=True):
|
|
batch = self.model_class()
|
|
batch.store = self.rattail_config.get_store(self.Session())
|
|
batch.mode = mode
|
|
batch.vendor = vendor
|
|
batch.buyer = self.request.user.employee
|
|
batch.created_by = self.request.user
|
|
batch.date_received = localtime(self.rattail_config).date()
|
|
self.assign_purchase_order(batch, po_form)
|
|
kwargs = self.get_batch_kwargs(batch, mobile=True)
|
|
batch = self.handler.make_batch(self.Session(), **kwargs)
|
|
if self.handler.should_populate(batch):
|
|
self.handler.populate(batch)
|
|
return self.redirect(self.get_action_url('view', batch, mobile=True))
|
|
|
|
else:
|
|
phase = 2
|
|
|
|
else:
|
|
raise NotImplementedError("Requested workflow not supported: {}".format(form.validated['workflow']))
|
|
|
|
data['form'] = form
|
|
data['dform'] = form.make_deform_form()
|
|
data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize()
|
|
data['phase'] = phase
|
|
if phase == 2:
|
|
purchases = self.eligible_purchases(vendor.uuid, mode=mode)
|
|
data['purchases'] = [(p['key'], p['display']) for p in purchases['purchases']]
|
|
data['purchase_order_fieldname'] = self.purchase_order_fieldname
|
|
return self.render_to_response('create', data, mobile=True)
|
|
|
|
def make_mobile_receiving_from_po_schema(self):
|
|
schema = colander.MappingSchema()
|
|
schema.add(colander.SchemaNode(colander.String(),
|
|
name=self.purchase_order_fieldname,
|
|
validator=self.validate_purchase))
|
|
return schema.bind(session=self.Session())
|
|
|
|
@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.get_purchase(po_form.validated[self.purchase_order_fieldname])
|
|
if isinstance(purchase, model.Purchase):
|
|
batch.purchase_uuid = purchase.uuid
|
|
|
|
department = self.department_for_purchase(purchase)
|
|
if department:
|
|
batch.department_uuid = department.uuid
|
|
|
|
def configure_mobile_form(self, f):
|
|
super(ReceivingBatchView, self).configure_mobile_form(f)
|
|
batch = f.model_instance
|
|
|
|
# truck_dump
|
|
if not self.creating:
|
|
if not batch.truck_dump:
|
|
f.remove_field('truck_dump')
|
|
|
|
# department
|
|
if not self.creating:
|
|
if batch.truck_dump:
|
|
f.remove_field('department')
|
|
|
|
def configure_row_grid(self, g):
|
|
super(ReceivingBatchView, self).configure_row_grid(g)
|
|
g.set_label('department_name', "Department")
|
|
|
|
def configure_row_form(self, f):
|
|
super(ReceivingBatchView, self).configure_row_form(f)
|
|
f.set_readonly('cases_ordered')
|
|
f.set_readonly('units_ordered')
|
|
f.set_readonly('po_unit_cost')
|
|
f.set_readonly('po_total')
|
|
f.set_readonly('invoice_total')
|
|
|
|
def render_mobile_row_listitem(self, row, i):
|
|
key = self.render_product_key_value(row)
|
|
description = row.product.full_description if row.product else row.description
|
|
return "({}) {}".format(key, description)
|
|
|
|
def should_aggregate_products(self, batch):
|
|
"""
|
|
Must return a boolean indicating whether rows should be aggregated by
|
|
product for the given batch.
|
|
"""
|
|
return True
|
|
|
|
def quick_locate_rows(self, batch, entry):
|
|
rows = []
|
|
|
|
# try to locate rows by product uuid match before other key
|
|
product = self.Session.query(model.Product).get(entry)
|
|
if product:
|
|
rows = [row for row in batch.active_rows()
|
|
if row.product_uuid == product.uuid]
|
|
if rows:
|
|
return rows
|
|
|
|
key = self.rattail_config.product_key()
|
|
if key == 'upc':
|
|
|
|
# we prefer "exact" UPC matches, i.e. those which assumed the entry
|
|
# already contained the check digit.
|
|
provided = GPC(entry, calc_check_digit=False)
|
|
rows = [row for row in batch.active_rows()
|
|
if row.upc == provided]
|
|
if rows:
|
|
return rows
|
|
|
|
# if no "exact" UPC matches, we'll settle for those (UPC matches)
|
|
# which assume the entry lacked a check digit.
|
|
checked = GPC(entry, calc_check_digit='upc')
|
|
rows = [row for row in batch.active_rows()
|
|
if row.upc == checked]
|
|
return rows
|
|
|
|
elif key == 'item_id':
|
|
rows = [row for row in batch.active_rows()
|
|
if row.item_id == entry]
|
|
return rows
|
|
|
|
def save_quick_row_form(self, form):
|
|
batch = self.get_instance()
|
|
entry = form.validated['quick_row_entry']
|
|
|
|
# maybe try to locate existing row first
|
|
rows = self.quick_locate_rows(batch, entry)
|
|
if rows:
|
|
|
|
# if aggregating, just re-use matching row
|
|
prefer_existing = self.should_aggregate_products(batch)
|
|
if prefer_existing:
|
|
if len(rows) > 1:
|
|
log.warning("found multiple row matches for '%s' in batch %s: %s",
|
|
entry, batch.id_str, batch)
|
|
return rows[0]
|
|
|
|
else: # borrow product from matching row, but make new row
|
|
other_row = rows[0]
|
|
row = model.PurchaseBatchRow()
|
|
row.product = other_row.product
|
|
self.handler.add_row(batch, row)
|
|
self.Session.flush()
|
|
return row
|
|
|
|
# try to locate product by uuid before other, more specific key
|
|
product = self.Session.query(model.Product).get(entry)
|
|
if product and not product.deleted:
|
|
row = model.PurchaseBatchRow()
|
|
row.product = product
|
|
self.handler.add_row(batch, row)
|
|
self.Session.flush()
|
|
return row
|
|
|
|
key = self.rattail_config.product_key()
|
|
if key == 'upc':
|
|
|
|
# try to locate product by upc
|
|
provided = GPC(entry, calc_check_digit=False)
|
|
checked = GPC(entry, calc_check_digit='upc')
|
|
product = api.get_product_by_upc(self.Session(), provided)
|
|
if not product:
|
|
product = api.get_product_by_upc(self.Session(), checked)
|
|
if product:
|
|
row = model.PurchaseBatchRow()
|
|
row.product = product
|
|
self.handler.add_row(batch, row)
|
|
self.Session.flush()
|
|
return row
|
|
|
|
# check for "bad" upc
|
|
if len(entry) > 14:
|
|
return
|
|
|
|
# product not in system, but presumably sane upc, so add to batch anyway
|
|
row = model.PurchaseBatchRow()
|
|
row.upc = provided # TODO: why not checked? how to know?
|
|
row.item_id = entry
|
|
row.description = "(unknown product)"
|
|
self.handler.add_row(batch, row)
|
|
self.Session.flush()
|
|
return row
|
|
|
|
elif key == 'item_id':
|
|
|
|
# try to locate product by item_id
|
|
product = api.get_product_by_item_id(self.Session(), entry)
|
|
if product:
|
|
row = model.PurchaseBatchRow()
|
|
row.product = product
|
|
self.handler.add_row(batch, row)
|
|
self.Session.flush()
|
|
return row
|
|
|
|
# check for "too long" item_id
|
|
if len(entry) > maxlen(model.PurchaseBatchRow.item_id):
|
|
return
|
|
|
|
# product not in system, but presumably sane item_id, so add to batch anyway
|
|
row = model.PurchaseBatchRow()
|
|
row.item_id = entry
|
|
row.description = "(unknown product)"
|
|
self.handler.add_row(batch, row)
|
|
self.Session.flush()
|
|
return row
|
|
|
|
else:
|
|
raise NotImplementedError("don't know how to handle product key: {}".format(key))
|
|
|
|
def redirect_after_quick_row(self, row, mobile=False):
|
|
if mobile:
|
|
return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
|
|
return super(ReceivingBatchView, self).redirect_after_quick_row(row, mobile=mobile)
|
|
|
|
def mobile_view_row(self):
|
|
"""
|
|
Mobile view for receiving batch row items. Note that this also handles
|
|
updating a row.
|
|
"""
|
|
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,
|
|
'parent_instance': batch,
|
|
'instance': row,
|
|
'instance_title': self.get_row_instance_title(row),
|
|
'parent_model_title': self.get_model_title(),
|
|
'product_image_url': pod.get_image_url(self.rattail_config, row.upc),
|
|
'form': form,
|
|
'allow_expired': self.handler.allow_expired_credits(),
|
|
'allow_cases': self.handler.allow_cases(),
|
|
}
|
|
|
|
if self.request.has_perm('{}.create_row'.format(permission_prefix)):
|
|
schema = MobileReceivingForm().bind(session=self.Session())
|
|
update_form = forms.Form(schema=schema, request=self.request)
|
|
if update_form.validate(newstyle=True):
|
|
row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row'])
|
|
mode = update_form.validated['mode']
|
|
cases = update_form.validated['cases']
|
|
units = update_form.validated['units']
|
|
|
|
# add values as-is to existing case/unit amounts. note
|
|
# that this can sometimes give us negative values! e.g. if
|
|
# user scans 1 CS and then subtracts 2 EA, then we would
|
|
# have 1 / -2 for our counts. but we consider that to be
|
|
# expected, and other logic must allow for the possibility
|
|
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)
|
|
)
|
|
|
|
# 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)
|
|
|
|
# keep track of last-used uom, although we just track
|
|
# whether or not it was 'CS' since the unit_uom can vary
|
|
sticky_case = None
|
|
if not update_form.validated['quick_receive']:
|
|
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_action_url('view', batch, mobile=True))
|
|
|
|
# unit_uom can vary by product
|
|
context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
|
|
|
|
# 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 = 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']
|
|
|
|
if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
|
|
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)
|
|
|
|
def attach_credit(self, row, credit_type, cases, units, expiration_date=None, discarded=None, mispick_product=None):
|
|
batch = row.batch
|
|
credit = model.PurchaseBatchCredit()
|
|
credit.credit_type = credit_type
|
|
credit.store = batch.store
|
|
credit.vendor = batch.vendor
|
|
credit.date_ordered = batch.date_ordered
|
|
credit.date_shipped = batch.date_shipped
|
|
credit.date_received = batch.date_received
|
|
credit.invoice_number = batch.invoice_number
|
|
credit.invoice_date = batch.invoice_date
|
|
credit.product = row.product
|
|
credit.upc = row.upc
|
|
credit.vendor_item_code = row.vendor_code
|
|
credit.brand_name = row.brand_name
|
|
credit.description = row.description
|
|
credit.size = row.size
|
|
credit.department_number = row.department_number
|
|
credit.department_name = row.department_name
|
|
credit.case_quantity = row.case_quantity
|
|
credit.cases_shorted = cases
|
|
credit.units_shorted = units
|
|
credit.invoice_line_number = row.invoice_line_number
|
|
credit.invoice_case_cost = row.invoice_case_cost
|
|
credit.invoice_unit_cost = row.invoice_unit_cost
|
|
credit.invoice_total = row.invoice_total
|
|
|
|
# calculate credit total
|
|
# TODO: should this leverage case cost if present?
|
|
credit_units = self.handler.get_units(credit.cases_shorted,
|
|
credit.units_shorted,
|
|
credit.case_quantity)
|
|
credit.credit_total = credit_units * (credit.invoice_unit_cost or 0)
|
|
|
|
credit.product_discarded = discarded
|
|
if credit_type == 'expired':
|
|
credit.expiration_date = expiration_date
|
|
elif credit_type == 'mispick' and mispick_product:
|
|
credit.mispick_product = mispick_product
|
|
credit.mispick_upc = mispick_product.upc
|
|
if mispick_product.brand:
|
|
credit.mispick_brand_name = mispick_product.brand.name
|
|
credit.mispick_description = mispick_product.description
|
|
credit.mispick_size = mispick_product.size
|
|
row.credits.append(credit)
|
|
return credit
|
|
|
|
@classmethod
|
|
def _receiving_defaults(cls, config):
|
|
route_prefix = cls.get_route_prefix()
|
|
url_prefix = cls.get_url_prefix()
|
|
model_key = cls.get_model_key()
|
|
permission_prefix = cls.get_permission_prefix()
|
|
|
|
if cls.allow_truck_dump:
|
|
config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/{{{}}}/add-child-from-invoice'.format(url_prefix, model_key))
|
|
config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix),
|
|
permission='{}.create'.format(permission_prefix))
|
|
|
|
@classmethod
|
|
def defaults(cls, config):
|
|
cls._receiving_defaults(config)
|
|
cls._purchasing_defaults(config)
|
|
cls._batch_defaults(config)
|
|
cls._defaults(config)
|
|
|
|
|
|
# TODO: this is a stopgap measure to fix an obvious bug, which exists when the
|
|
# session is not provided by the view at runtime (i.e. when it was instead
|
|
# being provided by the type instance, which was created upon app startup).
|
|
@colander.deferred
|
|
def valid_vendor(node, kw):
|
|
session = kw['session']
|
|
def validate(node, value):
|
|
vendor = session.query(model.Vendor).get(value)
|
|
if not vendor:
|
|
raise colander.Invalid(node, "Vendor not found")
|
|
return vendor.uuid
|
|
return validate
|
|
|
|
|
|
class MobileNewReceivingBatch(colander.MappingSchema):
|
|
|
|
vendor = colander.SchemaNode(colander.String(),
|
|
validator=valid_vendor)
|
|
|
|
workflow = colander.SchemaNode(colander.String(),
|
|
validator=colander.OneOf([
|
|
'from_po',
|
|
'from_scratch',
|
|
'truck_dump',
|
|
]))
|
|
|
|
phase = colander.SchemaNode(colander.Int())
|
|
|
|
|
|
class MobileNewReceivingFromPO(colander.MappingSchema):
|
|
|
|
purchase = colander.SchemaNode(colander.String())
|
|
|
|
|
|
# TODO: this is a stopgap measure to fix an obvious bug, which exists when the
|
|
# session is not provided by the view at runtime (i.e. when it was instead
|
|
# being provided by the type instance, which was created upon app startup).
|
|
@colander.deferred
|
|
def valid_purchase_batch_row(node, kw):
|
|
session = kw['session']
|
|
def validate(node, value):
|
|
row = session.query(model.PurchaseBatchRow).get(value)
|
|
if not row:
|
|
raise colander.Invalid(node, "Batch row not found")
|
|
if row.batch.executed:
|
|
raise colander.Invalid(node, "Batch has already been executed")
|
|
return row.uuid
|
|
return validate
|
|
|
|
|
|
class MobileReceivingForm(colander.MappingSchema):
|
|
|
|
row = colander.SchemaNode(colander.String(),
|
|
validator=valid_purchase_batch_row)
|
|
|
|
mode = colander.SchemaNode(colander.String(),
|
|
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(),
|
|
widget=dfwidget.TextInputWidget(),
|
|
missing=colander.null)
|
|
|
|
quick_receive = colander.SchemaNode(colander.Boolean())
|
|
|
|
|
|
def includeme(config):
|
|
ReceivingBatchView.defaults(config)
|