# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 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/>.
#
################################################################################
"""
Handler for "purchasing" batches
"""
import decimal
import logging
import os
import shutil
from sqlalchemy import orm
from rattail.db.model import PurchaseBatch
from rattail.batch import BatchHandler
from rattail.util import simple_error
from rattail.vendors.invoices import require_invoice_parser, iter_invoice_parsers
from rattail.exceptions import BatchAlreadyExecuted
log = logging.getLogger(__name__)
[docs]
class PurchaseBatchHandler(BatchHandler):
"""
Handler for all "purchasing" batches, regardless of "mode". The handler
must inspect the
:attr:`~rattail.db.model.batch.purchase.PurchaseBatch.mode` attribute of
each batch it deals with, in order to determine which logic to apply.
Possible mode values are:
* ``rattail.enum.PURCHASE_BATCH_MODE_ORDERING``
* ``rattail.enum.PURCHASE_BATCH_MODE_RECEIVING``
* ``rattail.enum.PURCHASE_BATCH_MODE_COSTING``
"""
batch_model_class = PurchaseBatch
penny = decimal.Decimal('0.01')
# employ versioning workarounds
populate_with_versioning = False
refresh_with_versioning = False
def __init__(self, config, **kwargs):
super().__init__(config, **kwargs)
self.products_handler = self.app.get_products_handler()
def allow_versioning(self, action):
if action == 'auto_receive':
return False
return super().allow_versioning(action)
def allow_cases(self):
"""
Must return boolean indicating whether "cases" should be generally
allowed, for sake of quantity input etc.
"""
return self.config.getbool('rattail.batch', 'purchase.allow_cases',
default=False)
def allow_receiving_from_scratch(self, **kwargs):
"""
Return boolean indicating whether receiving "from scratch" is allowed.
"""
return self.config.getbool('rattail.batch',
'purchase.allow_receiving_from_scratch',
default=False)
def allow_receiving_from_invoice(self, **kwargs):
"""
Return boolean indicating whether receiving "from invoice" is allowed.
"""
return self.config.getbool('rattail.batch',
'purchase.allow_receiving_from_invoice',
default=False)
def allow_receiving_from_multi_invoice(self, **kwargs):
"""
Return boolean indicating whether receiving "from multiple
invoices" is allowed.
"""
return self.config.getbool('rattail.batch',
'purchase.allow_receiving_from_multi_invoice',
default=False)
def allow_receiving_from_purchase_order(self, **kwargs):
"""
Return boolean indicating whether receiving "from PO" is allowed.
"""
return self.config.getbool('rattail.batch',
'purchase.allow_receiving_from_purchase_order',
default=False)
def allow_receiving_from_purchase_order_with_invoice(self, **kwargs):
"""
Return boolean indicating whether receiving "from PO with
invoice" is allowed.
"""
return self.config.getbool('rattail.batch',
'purchase.allow_receiving_from_purchase_order_with_invoice',
default=False)
def allow_truck_dump_receiving(self, **kwargs):
"""
Return boolean indicating whether "truck dump receiving" is allowed.
"""
return self.config.getbool('rattail.batch',
'purchase.allow_truck_dump_receiving',
default=False)
def auto_missing_credits(self, **kwargs):
"""
Return boolean indicating whether missing/DNR credits should
be auto-generated for items not accounted for.
"""
return self.config.getbool('rattail.batch',
'purchase.receiving.auto_missing_credits',
default=False)
def possible_receiving_workflows(self, **kwargs):
"""
Returns a list representing all "receiving workflows" which are
*possible* when making a new batch. Note that possible != supported.
See :meth:`supported_receiving_workflows()` for the latter.
Each element of the list will be a dict with 3 items; keys of which
are: ``'workflow_key'``, ``'display'`` and ``'description'``.
Maybe these "creation type key" strings should be defined within the
``enum`` module, but for now they're sort of just magical hard-coded
values I guess:
* ``'from_scratch'``
* ``'from_invoice'``
* ``'from_multi_invoice'``
* ``'from_po'``
* ``'from_po_with_invoice'``
* ``'truck_dump_children_first'``
* ``'truck_dump_children_last'``
"""
return [
{
'workflow_key': 'from_scratch',
'display': "From Scratch",
'description': "Create a new empty batch and start scanning!",
},
{
'workflow_key': 'from_invoice',
'display': "From Single Invoice",
'description': "Upload a single invoice file, and receive \"against\" it.",
},
{
'workflow_key': 'from_multi_invoice',
'display': "From Multiple (Combined) Invoices",
'description': "Upload multiple invoice files, and receive \"against\" the combination.",
},
{
'workflow_key': 'from_po',
'display': "From Purchase Order",
'description': "Select an existing PO, and receive \"against\" it.",
},
{
'workflow_key': 'from_po_with_invoice',
'display': "From Purchase Order, with Invoice File",
'description': "Select an existing PO, and provide its invoice file.",
},
{
'workflow_key': 'truck_dump_children_first',
'display': "Truck Dump (invoices FIRST)",
'description': "Upload all invoice files and then receive against the whole lot at once.",
},
{
'workflow_key': 'truck_dump_children_last',
'display': "Truck Dump (invoices LAST)",
'description': "Create a new empty truck dump batch, and start scanning; upload invoices later.",
},
]
def supported_receiving_workflows(self, **kwargs):
"""
Returns a list representing which "creation types" are *supported* by
the app, when making a new receiving batch. Elements of this list will
be of the same type as returned by :meth:`possible_receiving_workflows()`.
"""
possible = self.possible_receiving_workflows()
possible = dict([(item['workflow_key'], item)
for item in possible])
workflows = []
if self.allow_receiving_from_scratch():
workflows.append(possible['from_scratch'])
if self.allow_receiving_from_invoice():
workflows.append(possible['from_invoice'])
if self.allow_receiving_from_multi_invoice():
workflows.append(possible['from_multi_invoice'])
if self.allow_receiving_from_purchase_order():
workflows.append(possible['from_po'])
if self.allow_receiving_from_purchase_order_with_invoice():
workflows.append(possible['from_po_with_invoice'])
if self.allow_truck_dump_receiving():
workflows.append(possible['truck_dump_children_first'])
workflows.append(possible['truck_dump_children_last'])
return workflows
def receiving_workflow_info(self, workflow_key):
"""
Returns the info dict for the given "creation type" key. The dict will
be one of those as returned by :meth:`possible_receiving_workflows()`.
"""
for typ in self.possible_receiving_workflows():
if typ['workflow_key'] == workflow_key:
return typ
def possible_costing_workflows(self, **kwargs):
"""
Returns a list representing all "costing workflows" which are
*possible* when making a new batch. Note that possible != supported.
See :meth:`supported_costing_workflows()` for the latter.
Each element of the list will be a dict with 3 items; keys of which
are: ``'workflow_key'``, ``'display'`` and ``'description'``.
Maybe these "creation type key" strings should be defined within the
``enum`` module, but for now they're sort of just magical hard-coded
values I guess:
* ``'invoice_only'``
* ``'invoice_with_po'``
"""
return [
{
'workflow_key': 'invoice_only',
'display': "Invoice Only",
'description': "Create a new batch from invoice file only.",
},
{
'workflow_key': 'invoice_with_po',
'display': "Invoice with PO",
'description': "Select an existing PO, and provide its invoice file.",
},
]
def supported_costing_workflows(self, **kwargs):
"""
Returns a list representing which "creation types" are *supported* by
the app, when making a new costing batch. Elements of this list will
be of the same type as returned by :meth:`possible_costing_workflows()`.
"""
# TODO: for now we only support 'invoice_with_po' just to keep it simple
return [workflow for workflow in self.possible_costing_workflows()
if workflow['workflow_key'] == 'invoice_with_po']
def costing_workflow_info(self, workflow_key):
"""
Returns the info dict for the given "creation type" key. The
dict will be one of those as returned by
:meth:`possible_costing_workflows()`.
"""
for typ in self.possible_costing_workflows():
if typ['workflow_key'] == workflow_key:
return typ
def allow_expired_credits(self):
"""
Must return boolean indicating whether "expired" credits should be
tracked. In practice, this should either en- or dis-able various UI
elements which involves expired product.
"""
return self.config.getbool('rattail.batch', 'purchase.allow_expired_credits',
default=False)
def allow_receiving_edit_catalog_unit_cost(self):
"""
Returns boolean indicating whether the Catalog Unit Cost field
should allow edits, on the row level, for receiving mode.
This is the global setting, and does not consider current user
or batch.
"""
return self.config.getbool('rattail.batch', 'purchase.receiving.allow_edit_catalog_unit_cost',
default=False)
def allow_receiving_edit_invoice_unit_cost(self):
"""
Returns boolean indicating whether the Invoice Unit Cost field
should allow edits, on the row level, for receiving mode.
This is the global setting, and does not consider current user
or batch.
"""
return self.config.getbool('rattail.batch', 'purchase.receiving.allow_edit_invoice_unit_cost',
default=False)
def make_basic_batch(self, session, **kwargs):
"""
Create a "bare minimum" batch object.
Here we'll set the default store, if none is provided.
"""
# try to assign store for new batch
if 'store_uuid' not in kwargs and 'store' not in kwargs:
store = self.config.get_store(session)
if store:
kwargs['store'] = store
# remove some kwargs which are not meant for the primary batch
# constructor; they will be dealt with within init_batch()
kwargs.pop('receiving_workflow', None)
kwargs.pop('purchase_key', None)
# continue with usual logic
batch = super().make_basic_batch(session, **kwargs)
return batch
[docs]
def init_batch(self, batch, progress=None, **kwargs):
"""
If this is a receiving batch, try to assign the original PO for it, by
invoking :meth:`assign_purchase_order()`.
"""
purchase_key = kwargs.get('purchase_key')
if purchase_key:
self.assign_purchase_order(batch, purchase_key,
session=kwargs.get('session'))
receiving_workflow = kwargs.get('receiving_workflow')
if receiving_workflow:
batch.set_param('receiving_workflow', receiving_workflow)
def set_input_file(self, batch, path, attr=None, **kwargs):
"""
Custom logic for setting batch input file, to allow for
receiving from multiple invoice files.
"""
# special logic for receiving from multiple invoice files
if (batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING
and batch.get_param('receiving_workflow') == 'from_multi_invoice'
and attr == 'invoice_files'):
# store file as normal, but instead of setting filename
# attr directly on batch, store that in the params
datadir = self.make_datadir(batch)
filename = os.path.basename(path)
shutil.copyfile(path, os.path.join(datadir, filename))
files = batch.get_param('invoice_files') or []
files.append(filename)
batch.set_param('invoice_files', files)
return
# otherwise, do normal logic
super().set_input_file(batch, path, attr=attr, **kwargs)
[docs]
def assign_purchase_order(self, batch, purchase_key, session=None):
"""
This is mostly to assign the original PO for a new receiving batch.
The purchase order is located (in whatever system) and will be
returned. The batch will be updated to include a reference to
the PO. Note that which batch attribute is used to store the
reference may vary depending on the system where the PO lives.
:returns: The PO object if found, or ``None``.
"""
model = self.model
if not session:
session = self.app.get_session(batch)
purchase = self.get_purchase_order(session, purchase_key)
if isinstance(purchase, model.Purchase):
# TODO: is there a reason we previously did this?
#batch.purchase_uuid = purchase.uuid
batch.purchase = purchase
else:
field = self.get_purchase_order_fieldname()
setattr(batch, field, purchase_key)
return purchase
[docs]
def should_populate(self, batch):
"""
Must populate when e.g. making new receiving batch from PO or invoice,
but otherwise not, e.g. receiving from scratch.
"""
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
workflow = batch.get_param('receiving_workflow')
if workflow in ('from_invoice',
'from_multi_invoice',
'from_po',
'from_po_with_invoice'):
return True
if batch.mode == self.enum.PURCHASE_BATCH_MODE_COSTING:
if self.has_purchase_order(batch):
return True
if self.has_invoice_file(batch):
return True
return False
[docs]
def get_purchase_order(self, session, purchase_key, **kwargs):
"""
Retrieve the PO object represented by ``purchase_key``. The default
logic assumes the key is a UUID, and will try to locate the
corresponding :class:`~rattail.db.model.purchase.Purchase` instance.
"""
model = self.model
return session.get(model.Purchase, purchase_key)
def get_purchase_key(self, purchase, **kwargs):
"""
Returns the "key" for the given purchase order object.
Default logic here assumes that ``purchase`` is a Rattail
Purchase object, so you may need to override.
"""
return purchase.uuid
def get_purchase_department(self, purchase, **kwargs):
"""
Returns the :class:`~rattail.db.model.org.Department` record
to which the purchase should be attributed, if
applicable/known.
"""
return getattr(purchase, 'department', None)
def normalize_eligible_purchase(self, purchase):
department = self.get_purchase_department(purchase)
return {
'key': self.get_purchase_key(purchase),
'department_uuid': department.uuid if department else None,
'display': self.render_eligible_purchase(purchase),
}
def get_purchase_order_fieldname(self):
return 'purchase'
def get_eligible_purchases(self, vendor, mode):
"""
This method should return a list of "eligible purchases" for the given
vendor and batch mode. Generally speaking, this list will be presented
to the user so they can choose which PO to receive against, etc.
Note that the only "real" requirement for the return value, is that it
be a sequence of some type, e.g. list. The actual purchase objects can
be of whatever type your handler and other app logic require.
"""
session = self.app.get_session(vendor)
model = self.model
purchases = session.query(model.Purchase)\
.filter(model.Purchase.vendor == vendor)
if mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
purchases = purchases.filter(model.Purchase.status == self.enum.PURCHASE_STATUS_ORDERED)\
.order_by(model.Purchase.date_ordered.desc(),
model.Purchase.created.desc())
elif mode == self.enum.PURCHASE_BATCH_MODE_COSTING:
purchases = purchases.filter(model.Purchase.status == self.enum.PURCHASE_STATUS_RECEIVED)\
.order_by(model.Purchase.date_received, model.Purchase.created)
return purchases.all()
def get_eligible_purchase_key(self, purchase):
return purchase.uuid
def render_eligible_purchase(self, purchase):
"""
Render the purchase as a simple string, e.g. for use within a dropdown
when user must select one from a list.
"""
if purchase.status == self.enum.PURCHASE_STATUS_ORDERED:
date = purchase.date_ordered
total = purchase.po_total
elif purchase.status == self.enum.PURCHASE_STATUS_RECEIVED:
date = purchase.date_received
total = purchase.invoice_total
distinction = purchase.department or purchase.buyer
return "({}) {} for ${:0,.2f} ({})".format(purchase.id_str,
date, total or 0, distinction)
[docs]
def populate(self, batch, progress=None):
"""
Fill the batch with initial data, e.g. from data file or existing PO.
A receiving batch which is populated from PO/file will also have its
:attr:`~rattail.db.model.batch.purchase.PurchaseBatch.order_quantities_known`
attribute set to ``True``.
If the batch is a "truck dump child" and does not yet have a receiving
date, it is given the same one as the parent batch.
"""
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
if batch.is_truck_dump_parent():
pass # TODO?
elif batch.is_truck_dump_child():
# copy receiving date from parent
if not batch.date_received:
batch.date_received = batch.truck_dump_batch.date_received
if batch.invoice_file:
self.populate_from_truck_dump_invoice(batch, progress=progress)
return
else: # "normal" (no truck dump)
# assume receiving date is today, if none specified
# TODO: probably should not do this here, instead
# assume caller has set it already, if/as needed
if not batch.date_received:
batch.date_received = self.app.today()
workflow = batch.get_param('receiving_workflow')
if workflow == 'from_invoice':
self.populate_from_invoice(batch, progress=progress)
return
if workflow == 'from_multi_invoice':
self.populate_from_multiple_invoices(batch, progress=progress)
return
if workflow == 'from_po':
self.populate_from_purchase_order(batch, progress=progress)
return
if workflow == 'from_po_with_invoice':
self.populate_from_invoice_with_po(batch, progress=progress)
return
if batch.mode == self.enum.PURCHASE_BATCH_MODE_COSTING:
if self.has_purchase_order(batch) and self.has_invoice_file(batch):
self.populate_from_invoice_with_po(batch, progress=progress)
return
if self.has_invoice_file(batch):
self.populate_from_invoice(batch, progress=progress)
return
raise NotImplementedError("Don't know how to populate batch: {}".format(batch))
def has_purchase_order(self, batch, **kwargs):
"""
Should inspect the batch and return ``True`` if there is a
"purchase order" associated with it, or ``False`` if not.
"""
return bool(batch.purchase)
def has_invoice_file(self, batch, **kwargs):
"""
Should inspect the batch and return ``True`` if there is an
"invoice file" associated with it, or ``False`` if not.
"""
if batch.invoice_file:
return True
if batch.get_param('invoice_files'):
return True
return False
def get_supported_invoice_parsers(self, vendor=None, **kwargs):
"""
Should return a list of invoice parsers which are *supported*
and should be exposed to the user.
:param vendor: Vendor for whom the parser is needed, if known.
If you specify a vendor then parsers which indicate support
for a *different* vendor will be excluded from the list.
"""
parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display)
result = []
if vendor:
session = self.app.get_session(vendor)
for Parser in parsers:
if Parser.vendor_key:
# parser declares a vendor, so only add if it's a match
parser = Parser(self.config)
pvendor = parser.get_vendor(session)
if pvendor and pvendor is vendor:
result.append(parser)
else: # parser is vendor-neutral; always add
result.append(parser)
else: # no vendor specified, so show all parsers
result = parsers
return result
def require_invoice_parser(self, batch):
"""
Returns the appropriate invoice parser for the given batch, or
raises an error if one can't be found.
"""
return require_invoice_parser(self.config, batch.invoice_parser_key)
def get_vendor_for_parser(self, batch, parser):
"""
Returns the "configured" vendor for the given invoice parser.
"""
session = self.app.get_session(batch)
vendor_handler = self.app.get_vendor_handler()
vendor = vendor_handler.get_vendor(session, parser.vendor_key)
if vendor is not batch.vendor:
raise RuntimeError("Parser is for vendor '{}' "
"but batch is for: {}".format(
parser.vendor_key, batch.vendor))
return vendor
def populate_from_invoice(self, batch, progress=None):
"""
Populate a batch from vendor invoice file.
"""
session = self.app.get_session(batch)
parser = self.require_invoice_parser(batch)
parser.session = session
parser.vendor = self.get_vendor_for_parser(batch, parser)
path = batch.filepath(self.config, batch.invoice_file)
try:
batch.invoice_date = parser.parse_invoice_date(path)
except:
log.warning("failed to parse invoice date from file: %s", path,
exc_info=True)
try:
batch.invoice_number = parser.parse_invoice_number(path)
except:
log.warning("failed to parse invoice number from file: %s", path,
exc_info=True)
batch.order_quantities_known = True
def append(invrow, i):
row = self.make_row_from_invoice(batch, invrow)
self.add_row(batch, row)
try:
lines = list(parser.parse_rows(path))
except Exception as error:
log.warning("failed to parse invoice lines from file: %s", path,
exc_info=True)
raise RuntimeError("Failed to parse invoice: {}".format(
simple_error(error)))
self.progress_loop(append, lines, progress,
message="Adding initial rows to batch")
# pretty sure batch.invoice_total is set by way of adding rows
# to the batch, so technically "calculated" but should be a
# simple sum of amounts taken from invoice parser. at any
# rate some parsers can grab the total directly; if so we
# prefer that
total = parser.parse_invoice_total(path)
if total is not None:
batch.invoice_total = total
else:
batch.invoice_total = sum([row.invoice_total or 0
for row in batch.active_rows()])
self.refresh_batch_status(batch)
def populate_from_multiple_invoices(self, batch, progress=None):
"""
Populate a batch from multiple invoice files.
"""
session = self.app.get_session(batch)
parser = self.require_invoice_parser(batch)
parser.session = session
parser.vendor = self.get_vendor_for_parser(batch, parser)
batch.order_quantities_known = True
def append(invrow, i):
# first make a typical row from invoice
thisrow = self.make_row_from_invoice(batch, invrow)
thisrow.invoice_number = parser._invoice_number
thisrow.invoice_date = parser._invoice_date
# TODO: should aggregation be configurable?
aggregate = True
if aggregate:
rows = [row for row in batch.active_rows()
if row.item_entry == thisrow.item_entry]
if rows:
row = rows[0]
sum_fields = [
'cases_ordered',
'units_ordered',
'cases_shipped',
'units_shipped',
'invoice_total',
]
for field in sum_fields:
value = getattr(thisrow, field)
if value:
setattr(row, field,
(getattr(row, field) or 0) + value)
# do not add `thisrow` since it was merged into `row`
thisrow = None
if thisrow:
self.add_row(batch, thisrow)
invoices_total = None
for filename in batch.get_param('invoice_files', []):
path = self.get_filepath(batch, filename=filename)
# nb. invoice number/date will be assigned to each row of the invoice
parser._invoice_number = parser.parse_invoice_number(path)
parser._invoice_date = parser.parse_invoice_date(path)
self.progress_loop(append, list(parser.parse_rows(path)), progress,
message="Adding invoice file: {}".format(filename))
total = parser.parse_invoice_total(path)
if total is not None:
invoices_total = (invoices_total or 0) + total
# some invoice parsers can't determine an overall total; in
# that case just sum the total from line items
if invoices_total:
batch.invoice_total = invoices_total
else:
batch.invoice_total = sum([row.invoice_total
for row in batch.active_rows()])
self.refresh_batch_status(batch)
def populate_from_purchase_order(self, batch, progress=None):
"""
Populate a batch from existing purchase order.
"""
assert batch.mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
self.enum.PURCHASE_BATCH_MODE_COSTING)
purchase = batch.purchase
assert purchase
session = self.app.get_session(batch)
batch.order_quantities_known = True
batch.date_ordered = purchase.date_ordered
batch.po_number = purchase.po_number
# maybe copy total from PO
if not batch.po_total:
batch.po_total = purchase.po_total
# maybe copy receiving date from parent
if batch.is_truck_dump_child() and not batch.date_received:
batch.date_received = batch.truck_dump_batch.date_received
def append(item, i):
row = self.make_row_from_po_item( batch, item)
# TODO: some of this probably should be moved into
# make_row_for_po_item() ..?
# since we're *receiving* from a PO (i.e. not an invoice), we can
# assume "shipped" quantities were same as "ordered" quantities
# TODO: does this still seem like a good idea..?
if row.cases_ordered:
row.cases_shipped = row.cases_ordered
if row.units_ordered:
row.units_shipped = row.units_ordered
row.po_unit_cost = item.po_unit_cost
row.po_total = item.po_total
if not row.po_total and row.po_unit_cost:
units = self.get_units_shipped(row)
if units:
row.po_total = units * row.po_unit_cost
if batch.mode == self.enum.PURCHASE_BATCH_MODE_COSTING:
row.invoice_unit_cost = item.invoice_unit_cost
row.invoice_total = item.invoice_total
self.add_row(batch, row)
self.progress_loop(append, purchase.items, progress,
message="Adding initial rows to batch")
session.flush()
self.refresh_batch_status(batch)
def populate_from_invoice_with_po(self, batch, progress=None):
"""
Populate a batch from existing SMS PO *and* invoice file.
"""
# first we populate from PO, "per usual"
self.populate_from_purchase_order(batch, progress=progress)
# next we "overlay" the invoice onto the batch
session = self.app.get_session(batch)
parser = self.require_invoice_parser(batch)
parser.session = session
parser.vendor = self.get_vendor_for_parser(batch, parser)
path = batch.filepath(self.config, batch.invoice_file)
batch.invoice_date = parser.parse_invoice_date(path)
batch.invoice_number = parser.parse_invoice_number(path)
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
self.assume_shipped_is_received = self.config.getbool(
'rattail.batch', 'purchase.receiving.assume_shipped_is_received')
def overlay(invrow, i):
row = self.find_batch_row_for_invoice_row(batch, invrow)
if row:
self.update_batch_row_from_invoice_row(row, invrow)
self.refresh_row(row)
else:
row = self.make_row_from_invoice(batch, invrow)
# if we are in "receiving" mode then we *may* go ahead and
# assume that the shipped quantity, is what we are receiving
# as well. if config says so that is.
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
if self.assume_shipped_is_received:
row.cases_received = row.cases_shipped
row.units_received = row.units_shipped
self.add_row(batch, row)
self.progress_loop(overlay, list(parser.parse_rows(path)), progress,
message="Overlaying invoice data onto PO")
# set the "original" invoice total
total = parser.parse_invoice_total(path)
if total is not None:
batch.invoice_total = total
else:
batch.invoice_total = sum([row.invoice_total or 0
for row in batch.active_rows()])
# finally update batch status, in case of new products not found etc.
self.refresh_batch_status(batch)
def update_batch_row_from_invoice_row(self, row, invrow):
"""
Update an existing batch row, with a line item row parsed from
an invoice.
"""
batch = row.batch
row.invoice_line_number = invrow.line_number
# here we want the "best" case quantity we can get. we will
# use the one from invoice if present; otherwise keep using
# what the row already had on file
case_quantity = row.case_quantity
# nb. we always replace the batch row's case quantity from the
# invoice row, although if that's empty, then refresh_row()
# may apply case quantity from product cost record
row.case_quantity = invrow.case_quantity
if row.case_quantity:
case_quantity = row.case_quantity
row.cases_shipped = invrow.shipped_cases
row.units_shipped = invrow.shipped_units
# if we are in "receiving" mode then we *may* go ahead and
# assume that the shipped quantity, is what we are receiving
# as well. if config says so that is.
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
if self.assume_shipped_is_received:
row.cases_received = row.cases_shipped
row.units_received = row.units_shipped
row.invoice_unit_cost = invrow.unit_cost
row.invoice_case_cost = invrow.case_cost
# now about that case quantity..we may need to use it to
# calculate unit or case cost
if case_quantity:
case_quantity = decimal.Decimal(str(case_quantity))
if row.invoice_unit_cost and not row.invoice_case_cost:
row.invoice_case_cost = row.invoice_unit_cost * case_quantity
elif row.invoice_case_cost and not row.invoice_unit_cost:
row.invoice_unit_cost = row.invoice_case_cost / case_quantity
row.invoice_total = invrow.total_cost
row.out_of_stock = invrow.out_of_stock
def find_batch_row_for_invoice_row(self, batch, invrow):
"""
For a given invoice row (line item), locate and return the
"matching" batch row.
"""
session = self.app.get_session(batch)
# first try to locate the rattail product, and match on that
product = self.locate_product_for_entry(session, invrow.item_entry,
lookup_vendor_code=True,
vendor=batch.vendor)
if product:
prod_matches = [row for row in batch.data_rows
if row.product is product]
if prod_matches:
# if only one match, use that
if len(prod_matches) == 1:
return prod_matches[0]
# but if more than one, try to match on quantity too
invrow_units = self.get_units_ordered(invrow)
qty_matches = [row for row in prod_matches
if self.get_units_ordered(row) == invrow_units]
if qty_matches:
if len(qty_matches) > 1:
log.warning("%s rows of batch %s matched both product "
"and quantity for invoice item: %s",
len(qty_matches), batch.uuid, invrow.item_entry)
return qty_matches[0]
# hm no quantity match..
log.warning("%s rows of batch %s matched product but none "
"matched quantity for invoice item: %s",
len(prod_matches), batch.uuid, invrow.item_entry)
return prod_matches[0]
def populate_from_truck_dump_invoice(self, batch, progress=None):
"""
Logic for populating a "truck dump child" batch, from a vendor invoice
data file.
"""
child_batch = batch
parent_batch = child_batch.truck_dump_batch
session = self.app.get_session(child_batch)
parser = self.require_invoice_parser(batch)
parser.session = session
vendor_handler = self.app.get_vendor_handler()
parser.vendor = vendor_handler.get_vendor(session, parser.vendor_key)
if parser.vendor is not child_batch.vendor:
raise RuntimeError("Parser is for vendor '{}' but batch is for: {}".format(
parser.vendor_key, child_batch.vendor))
path = child_batch.filepath(self.config, child_batch.invoice_file)
child_batch.invoice_date = parser.parse_invoice_date(path)
child_batch.order_quantities_known = True
def append(invoice_row, i):
row = self.make_row_from_invoice(child_batch, invoice_row)
self.add_row(child_batch, row)
self.progress_loop(append, list(parser.parse_rows(path)), progress,
message="Adding initial rows to batch")
if parent_batch.truck_dump_children_first:
# children first, so should add rows to parent batch now
session.flush()
def append(child_row, i):
if not child_row.out_of_stock:
# if row for this product already exists in parent, must aggregate
parent_row = self.locate_parent_row_for_child(parent_batch, child_row)
if parent_row:
# confirm 'case_quantity' matches
if parent_row.case_quantity != child_row.case_quantity:
raise ValueError("differing 'case_quantity' for item {}: {}".format(
child_row.item_entry, child_row.description))
# confirm 'out_of_stock' matches
if parent_row.out_of_stock != child_row.out_of_stock:
raise ValueError("differing 'out_of_stock' for item {}: {}".format(
cihld_row.item_entry, child_row.description))
# confirm 'invoice_unit_cost' matches
if parent_row.invoice_unit_cost != child_row.invoice_unit_cost:
raise ValueError("differing 'invoice_unit_cost' for item {}: {}".format(
cihld_row.item_entry, child_row.description))
# confirm 'invoice_case_cost' matches
if parent_row.invoice_case_cost != child_row.invoice_case_cost:
raise ValueError("differing 'invoice_case_cost' for item {}: {}".format(
cihld_row.item_entry, child_row.description))
# add 'ordered' quantities
if child_row.cases_ordered:
parent_row.cases_ordered = (parent_row.cases_ordered or 0) + child_row.cases_ordered
if child_row.units_ordered:
parent_row.units_ordered = (parent_row.units_ordered or 0) + child_row.units_ordered
# add 'shipped' quantities
if child_row.cases_shipped:
parent_row.cases_shipped = (parent_row.cases_shipped or 0) + child_row.cases_shipped
if child_row.units_shipped:
parent_row.units_shipped = (parent_row.units_shipped or 0) + child_row.units_shipped
# add 'invoice_total' quantities
if child_row.invoice_total:
parent_row.invoice_total = (parent_row.invoice_total or 0) + child_row.invoice_total
parent_batch.invoice_total = (parent_batch.invoice_total or 0) + child_row.invoice_total
if child_row.invoice_total_calculated:
parent_row.invoice_total_calculated = (parent_row.invoice_total_calculated or 0) + child_row.invoice_total_calculated
parent_batch.invoice_total_calculated = (parent_batch.invoice_total_calculated or 0) + child_row.invoice_total_calculated
else: # new product; simply add new row to parent
parent_row = self.make_parent_row_from_child(child_row)
self.add_row(parent_batch, parent_row)
self.progress_loop(append, child_batch.active_rows(), progress,
message="Adding rows to parent batch")
else: # children last, so should make parent claims now
self.make_truck_dump_claims_for_child_batch(child_batch, progress=progress)
self.refresh_batch_status(parent_batch)
def locate_parent_row_for_child(self, parent_batch, child_row):
"""
Locate a row within parent batch, which "matches" given row from child
batch. May return ``None`` if no match found.
"""
if child_row.product_uuid:
rows = [row for row in parent_batch.active_rows()
if row.product_uuid == child_row.product_uuid]
if rows:
return rows[0]
elif child_row.item_entry:
rows = [row for row in parent_batch.active_rows()
if row.product_uuid is None
and row.item_entry == child_row.item_entry]
if rows:
return rows[0]
def make_row_from_po_item(self, batch, item):
"""
Create a new batch row, from the given ``po_item`` object,
which is assumed to be a purchase order line item of some
sort. Default implementation specifically assumes it is a
`PurchaseItem` instance, but subclasses may assume something
else.
"""
row = self.make_row()
row.po_line_number = item.sequence
product = item.product
row.item = item
row.product = product
if product:
row.upc = product.upc
row.item_id = product.item_id
else:
row.upc = item.upc
row.item_id = item.item_id
# we set the case size according to PO, but we also set the
# "official" case size for the row, to match. later on when
# the row is refreshed that may be updated from product record.
row.po_case_size = item.case_quantity
row.case_quantity = row.po_case_size
row.cases_ordered = item.cases_ordered
row.units_ordered = item.units_ordered
# TODO: previous logic did not do this..?
row.cases_shipped = item.cases_shipped
row.units_shipped = item.units_shipped
row.cases_received = item.cases_received
row.units_received = item.units_received
return row
def make_row_from_invoice(self, batch, invoice_row):
"""
Create a new batch row, from the given ``invoice_row`` object, which is
assumed to be a
:class:`~rattail.db.model.batch.vendorinvoice.VendorInvoiceBatchRow`
object as obtained from an invoice parser.
"""
row = self.make_row()
row.invoice_number = batch.invoice_number
row.invoice_date = batch.invoice_date
if invoice_row.line_number:
row.invoice_line_number = invoice_row.line_number
row.item_entry = invoice_row.item_entry
row.upc = invoice_row.upc
row.vendor_code = invoice_row.vendor_code
row.brand_name = invoice_row.brand_name
row.description = invoice_row.description
row.size = invoice_row.size
row.invoice_case_size = invoice_row.case_quantity
# TODO: not sure if/why this was needed? it can cause issues
# if invoice has incorrect case size
#row.case_quantity = row.invoice_case_size
row.cases_ordered = invoice_row.ordered_cases
row.units_ordered = invoice_row.ordered_units
row.cases_shipped = invoice_row.shipped_cases
row.units_shipped = invoice_row.shipped_units
row.out_of_stock = invoice_row.out_of_stock
row.invoice_unit_cost = invoice_row.unit_cost
row.invoice_total = invoice_row.total_cost
row.invoice_case_cost = invoice_row.case_cost
return row
def make_parent_row_from_child(self, child_row):
row = self.make_row()
row.item_entry = child_row.item_entry
row.upc = child_row.upc
row.vendor_code = child_row.vendor_code
row.brand_name = child_row.brand_name
row.description = child_row.description
row.size = child_row.size
row.case_quantity = child_row.case_quantity
row.cases_ordered = child_row.cases_ordered
row.units_ordered = child_row.units_ordered
row.cases_shipped = child_row.cases_shipped
row.units_shipped = child_row.units_shipped
row.out_of_stock = child_row.out_of_stock
row.invoice_unit_cost = child_row.invoice_unit_cost
row.invoice_total = child_row.invoice_total
row.invoice_case_cost = child_row.invoice_case_cost
return row
def make_truck_dump_claims_for_child_batch(self, batch, progress=None):
"""
Make all "claims" against a truck dump, for the given child batch.
This assumes no claims exist for the child batch at time of calling,
and that the truck dump batch is complete and not yet executed.
"""
session = self.app.get_session(batch)
truck_dump_rows = batch.truck_dump_batch.active_rows()
child_rows = batch.active_rows()
# organize truck dump by product and UPC
truck_dump_by_product = {}
truck_dump_by_upc = {}
def organize_parent(row, i):
if row.product:
truck_dump_by_product.setdefault(row.product.uuid, []).append(row)
if row.upc:
truck_dump_by_upc.setdefault(row.upc, []).append(row)
self.progress_loop(organize_parent, truck_dump_rows, progress,
message="Organizing truck dump parent rows")
# organize child batch by product and UPC
child_by_product = {}
child_by_upc = {}
def organize_child(row, i):
if row.product:
child_by_product.setdefault(row.product.uuid, []).append(row)
if row.upc:
child_by_upc.setdefault(row.upc, []).append(row)
self.progress_loop(organize_child, child_rows, progress,
message="Organizing truck dump child rows")
# okay then, let's go through all our organized rows, and make claims
def make_claims(child_product, i):
uuid, child_product_rows = child_product
if uuid in truck_dump_by_product:
truck_dump_product_rows = truck_dump_by_product[uuid]
for truck_dump_row in truck_dump_product_rows:
self.make_truck_dump_claims(truck_dump_row, child_product_rows)
self.progress_loop(make_claims, child_by_product.items(), progress,
count=len(child_by_product),
message="Claiming parent rows for child") # (pass #1)
def make_truck_dump_claims(self, truck_dump_row, child_rows):
model = self.model
# first we go through the truck dump parent row, and calculate all
# "present", and "claimed" vs. "pending" product quantities
# cases_received
cases_received = truck_dump_row.cases_received or 0
cases_received_claimed = sum([claim.cases_received or 0
for claim in truck_dump_row.claims])
cases_received_pending = cases_received - cases_received_claimed
# units_received
units_received = truck_dump_row.units_received or 0
units_received_claimed = sum([claim.units_received or 0
for claim in truck_dump_row.claims])
units_received_pending = units_received - units_received_claimed
# cases_damaged
cases_damaged = truck_dump_row.cases_damaged or 0
cases_damaged_claimed = sum([claim.cases_damaged or 0
for claim in truck_dump_row.claims])
cases_damaged_pending = cases_damaged - cases_damaged_claimed
# units_damaged
units_damaged = truck_dump_row.units_damaged or 0
units_damaged_claimed = sum([claim.units_damaged or 0
for claim in truck_dump_row.claims])
units_damaged_pending = units_damaged - units_damaged_claimed
# cases_expired
cases_expired = truck_dump_row.cases_expired or 0
cases_expired_claimed = sum([claim.cases_expired or 0
for claim in truck_dump_row.claims])
cases_expired_pending = cases_expired - cases_expired_claimed
# units_expired
units_expired = truck_dump_row.units_expired or 0
units_expired_claimed = sum([claim.units_expired or 0
for claim in truck_dump_row.claims])
units_expired_pending = units_expired - units_expired_claimed
# TODO: should be calculating mispicks here too, right?
def make_claim(child_row):
c = model.PurchaseBatchRowClaim()
c.claiming_row = child_row
truck_dump_row.claims.append(c)
return c
for child_row in child_rows:
# stop now if everything in this parent row is accounted for
if not (cases_received_pending or units_received_pending
or cases_damaged_pending or units_damaged_pending
or cases_expired_pending or units_expired_pending):
break
# for each child row we also calculate all "present", and "claimed"
# vs. "pending" product quantities
# cases_shipped
cases_shipped = child_row.cases_shipped or 0
cases_shipped_claimed = sum([(claim.cases_received or 0)
+ (claim.cases_damaged or 0)
+ (claim.cases_expired or 0)
for claim in child_row.truck_dump_claims])
cases_shipped_pending = cases_shipped - cases_shipped_claimed
# units_shipped
units_shipped = child_row.units_shipped or 0
units_shipped_claimed = sum([(claim.units_received or 0)
+ (claim.units_damaged or 0)
+ (claim.units_expired or 0)
for claim in child_row.truck_dump_claims])
units_shipped_pending = units_shipped - units_shipped_claimed
# skip this child row if everything in it is accounted for
if not (cases_shipped_pending or units_shipped_pending):
continue
# there should only be one claim for this parent/child combo
claim = None
# let's cache this
case_quantity = child_row.case_quantity
# make case claims
if cases_shipped_pending and cases_received_pending:
claim = claim or make_claim(child_row)
if cases_received_pending >= cases_shipped_pending:
claim.cases_received = (claim.cases_received or 0) + cases_shipped_pending
child_row.cases_received = (child_row.cases_received or 0) + cases_shipped_pending
cases_received_pending -= cases_shipped_pending
cases_shipped_pending = 0
else: # shipped > received
claim.cases_received = (claim.cases_received or 0) + cases_received_pending
child_row.cases_received = (child_row.cases_received or 0) + cases_received_pending
cases_shipped_pending -= cases_received_pending
cases_received_pending = 0
self.refresh_row(child_row)
if cases_shipped_pending and cases_damaged_pending:
claim = claim or make_claim(child_row)
if cases_damaged_pending >= cases_shipped_pending:
claim.cases_damaged = (claim.cases_damaged or 0) + cases_shipped_pending
child_row.cases_damaged = (child_row.cases_damaged or 0) + cases_shipped_pending
cases_damaged_pending -= cases_shipped_pending
cases_shipped_pending = 0
else: # shipped > damaged
claim.cases_damaged = (claim.cases_damaged or 0) + cases_damaged_pending
child_row.cases_damaged = (child_row.cases_damaged or 0) + cases_damaged_pending
cases_shipped_pending -= cases_damaged_pending
cases_damaged_pending = 0
self.refresh_row(child_row)
if cases_shipped_pending and cases_expired_pending:
claim = claim or make_claim(child_row)
if cases_expired_pending >= cases_shipped_pending:
claim.cases_expired = (claim.cases_expired or 0) + cases_shipped_pending
child_row.cases_expired = (child_row.cases_expired or 0) + cases_shipped_pending
cases_expired_pending -= cases_shipped_pending
cases_shipped_pending = 0
else: # shipped > expired
claim.cases_expired = (claim.cases_expired or 0) + cases_expired_pending
child_row.cases_expired = (child_row.cases_expired or 0) + cases_expired_pending
cases_shipped_pending -= cases_expired_pending
cases_expired_pending = 0
self.refresh_row(child_row)
# make unit claims
if units_shipped_pending and units_received_pending:
claim = claim or make_claim(child_row)
if units_received_pending >= units_shipped_pending:
claim.units_received = (claim.units_received or 0) + units_shipped_pending
child_row.units_received = (child_row.units_received or 0) + units_shipped_pending
units_received_pending -= units_shipped_pending
units_shipped_pending = 0
else: # shipped > received
claim.units_received = (claim.units_received or 0) + units_received_pending
child_row.units_received = (child_row.units_received or 0) + units_received_pending
units_shipped_pending -= units_received_pending
units_received_pending = 0
self.refresh_row(child_row)
if units_shipped_pending and units_damaged_pending:
claim = claim or make_claim(child_row)
if units_damaged_pending >= units_shipped_pending:
claim.units_damaged = (claim.units_damaged or 0) + units_shipped_pending
child_row.units_damaged = (child_row.units_damaged or 0) + units_shipped_pending
units_damaged_pending -= units_shipped_pending
units_shipped_pending = 0
else: # shipped > damaged
claim.units_damaged = (claim.units_damaged or 0) + units_damaged_pending
child_row.units_damaged = (child_row.units_damaged or 0) + units_damaged_pending
units_shipped_pending -= units_damaged_pending
units_damaged_pending = 0
self.refresh_row(child_row)
if units_shipped_pending and units_expired_pending:
claim = claim or make_claim(child_row)
if units_expired_pending >= units_shipped_pending:
claim.units_expired = (claim.units_expired or 0) + units_shipped_pending
child_row.units_expired = (child_row.units_expired or 0) + units_shipped_pending
units_expired_pending -= units_shipped_pending
units_shipped_pending = 0
else: # shipped > expired
claim.units_expired = (claim.units_expired or 0) + units_expired_pending
child_row.units_expired = (child_row.units_expired or 0) + units_expired_pending
units_shipped_pending -= units_expired_pending
units_expired_pending = 0
self.refresh_row(child_row)
# claim units from parent, as cases for child. note that this
# crosses the case/unit boundary, but is considered "safe" because
# we assume the child row has correct case quantity even if parent
# row has a different one.
if cases_shipped_pending and units_received_pending:
received = units_received_pending // case_quantity
if received:
claim = claim or make_claim(child_row)
if received >= cases_shipped_pending:
claim.cases_received = (claim.cases_received or 0) + cases_shipped_pending
child_row.cases_received = (child_row.units_received or 0) + cases_shipped_pending
units_received_pending -= (cases_shipped_pending * case_quantity)
cases_shipped_pending = 0
else: # shipped > received
claim.cases_received = (claim.cases_received or 0) + received
child_row.cases_received = (child_row.units_received or 0) + received
cases_shipped_pending -= received
units_received_pending -= (received * case_quantity)
self.refresh_row(child_row)
if cases_shipped_pending and units_damaged_pending:
damaged = units_damaged_pending // case_quantity
if damaged:
claim = claim or make_claim(child_row)
if damaged >= cases_shipped_pending:
claim.cases_damaged = (claim.cases_damaged or 0) + cases_shipped_pending
child_row.cases_damaged = (child_row.units_damaged or 0) + cases_shipped_pending
units_damaged_pending -= (cases_shipped_pending * case_quantity)
cases_shipped_pending = 0
else: # shipped > damaged
claim.cases_damaged = (claim.cases_damaged or 0) + damaged
child_row.cases_damaged = (child_row.units_damaged or 0) + damaged
cases_shipped_pending -= damaged
units_damaged_pending -= (damaged * case_quantity)
self.refresh_row(child_row)
if cases_shipped_pending and units_expired_pending:
expired = units_expired_pending // case_quantity
if expired:
claim = claim or make_claim(child_row)
if expired >= cases_shipped_pending:
claim.cases_expired = (claim.cases_expired or 0) + cases_shipped_pending
child_row.cases_expired = (child_row.units_expired or 0) + cases_shipped_pending
units_expired_pending -= (cases_shipped_pending * case_quantity)
cases_shipped_pending = 0
else: # shipped > expired
claim.cases_expired = (claim.cases_expired or 0) + expired
child_row.cases_expired = (child_row.units_expired or 0) + expired
cases_shipped_pending -= expired
units_expired_pending -= (expired * case_quantity)
self.refresh_row(child_row)
# if necessary, try to claim cases from parent, as units for child.
# this also crosses the case/unit boundary but is considered safe
# only if the case quantity matches between child and parent rows.
# (otherwise who knows what could go wrong.)
if case_quantity == truck_dump_row.case_quantity:
if units_shipped_pending and cases_received_pending:
received = cases_received_pending * case_quantity
claim = claim or make_claim(child_row)
if received >= units_shipped_pending:
claim.units_received = (claim.units_received or 0) + units_shipped_pending
child_row.units_received = (child_row.units_received or 0) + units_shipped_pending
leftover = received % units_shipped_pending
if leftover == 0:
cases_received_pending -= (received // units_shipped_pending)
else:
cases_received_pending -= (received // units_shipped_pending) - 1
units_received_pending += leftover
units_shipped_pending = 0
else: # shipped > received
claim.units_received = (claim.units_received or 0) + received
child_row.units_received = (child_row.units_received or 0) + received
units_shipped_pending -= received
cases_received_pending = 0
self.refresh_row(child_row)
if units_shipped_pending and cases_damaged_pending:
damaged = cases_damaged_pending * case_quantity
claim = claim or make_claim(child_row)
if damaged >= units_shipped_pending:
claim.units_damaged = (claim.units_damaged or 0) + units_shipped_pending
child_row.units_damaged = (child_row.units_damaged or 0) + units_shipped_pending
leftover = damaged % units_shipped_pending
if leftover == 0:
cases_damaged_pending -= (damaged // units_shipped_pending)
else:
cases_damaged_pending -= (damaged // units_shipped_pending) - 1
units_damaged_pending += leftover
units_shipped_pending = 0
else: # shipped > damaged
claim.units_damaged = (claim.units_damaged or 0) + damaged
child_row.units_damaged = (child_row.units_damaged or 0) + damaged
units_shipped_pending -= damaged
cases_damaged_pending = 0
self.refresh_row(child_row)
if units_shipped_pending and cases_expired_pending:
expired = cases_expired_pending * case_quantity
claim = claim or make_claim(child_row)
if expired >= units_shipped_pending:
claim.units_expired = (claim.units_expired or 0) + units_shipped_pending
child_row.units_expired = (child_row.units_expired or 0) + units_shipped_pending
leftover = expired % units_shipped_pending
if leftover == 0:
cases_expired_pending -= (expired // units_shipped_pending)
else:
cases_expired_pending -= (expired // units_shipped_pending) - 1
units_expired_pending += leftover
units_shipped_pending = 0
else: # shipped > expired
claim.units_expired = (claim.units_expired or 0) + expired
child_row.units_expired = (child_row.units_expired or 0) + expired
units_shipped_pending -= expired
cases_expired_pending = 0
self.refresh_row(child_row)
# refresh the parent row, to reflect any new claim(s) made
self.refresh_row(truck_dump_row)
# TODO: surely this should live elsewhere
def calc_best_fit(self, units, case_quantity):
case_quantity = case_quantity or 1
if case_quantity == 1:
return 0, units
cases = units // case_quantity
if cases:
return cases, units - (cases * case_quantity)
return 0, units
[docs]
def refresh(self, batch, progress=None):
"""
Supplements the default logic as follows:
First, the default refresh logic runs. And if the batch is *not* part
of a truck dump, nothing more happens. But if it *is* truck dump...
Basically whether the given batch is a truck dump parent, or truck dump
child batch, the goal here is the same. We must locate any rows in the
given batch which are not yet "fully claimed / complete" with regard to
the "other" (parent/child) batch.
For any rows which are not yet fully resolved between parent and child,
an attempt is then made to add new row "claims" where possible, to
eliminate the gap.
The status of the given batch is then updated. If the batch is a truck
dump child then the status of its parent batch will also be updated.
If the given batch is truck dump parent, then status for *each* of its
children will also be updated. See :meth:`refresh_batch_status()` for
more on that.
"""
# refresh all rows etc. per usual
result = super().refresh(batch, progress=progress)
if result:
# here begins some extra magic for truck dump receiving batches
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
session = self.app.get_session(batch)
session.flush()
if batch.is_truck_dump_parent():
# will try to establish new claims against the parent
# batch, where possible
unclaimed = [row for row in batch.active_rows()
if row.status_code in (row.STATUS_TRUCKDUMP_UNCLAIMED,
row.STATUS_TRUCKDUMP_PARTCLAIMED)]
for row in unclaimed:
if row.product_uuid: # only support rows with product for now
self.make_truck_dump_claims_for_parent_row(row)
# all rows should be refreshed now, but batch status still needs it
self.refresh_batch_status(batch)
for child in batch.truck_dump_children:
self.refresh_batch_status(child)
elif batch.is_truck_dump_child():
# will try to establish claims against the parent batch,
# for each "incomplete" row (i.e. those with unclaimed
# order quantities)
incomplete = [row for row in batch.active_rows()
if row.status_code in (row.STATUS_INCOMPLETE,
row.STATUS_ORDERED_RECEIVED_DIFFER)]
for row in incomplete:
if row.product_uuid: # only support rows with product for now
parent_rows = [parent_row for parent_row in batch.truck_dump_batch.active_rows()
if parent_row.product_uuid == row.product_uuid]
for parent_row in parent_rows:
self.make_truck_dump_claims(parent_row, [row])
if row.status_code not in (row.STATUS_INCOMPLETE,
row.STATUS_ORDERED_RECEIVED_DIFFER):
break
# all rows should be refreshed now, but batch status still needs it
self.refresh_batch_status(batch.truck_dump_batch)
self.refresh_batch_status(batch)
return result
[docs]
def refresh_batch_status(self, batch):
"""
Logic for updating the status attribute(s) for the given batch.
This primarily tries to see if there are any "unknown" items in the batch,
and set status accordingly if some are found.
But it also is responsible for setting "truck dump" status, for a truck
dump parent batch, based on whether or not its children have fully
claimed all of its items etc.
"""
rows = batch.active_rows()
# "unknown product" is the most egregious status; we'll "prefer" it
# over all others in order to bring it to user's attention
if any([row.status_code == row.STATUS_PRODUCT_NOT_FOUND for row in rows]):
batch.status_code = batch.STATUS_UNKNOWN_PRODUCT
# for now anything else is considered ok
else:
batch.status_code = batch.STATUS_OK
# truck dump parent batch gets status to reflect how much is (un)claimed
if batch.is_truck_dump_parent():
# batch is "claimed" only if all rows are "settled" so to speak
if all([row.truck_dump_status == row.STATUS_TRUCKDUMP_CLAIMED
for row in rows]):
batch.truck_dump_status = batch.STATUS_TRUCKDUMP_CLAIMED
# otherwise just call it "unclaimed"
else:
batch.truck_dump_status = batch.STATUS_TRUCKDUMP_UNCLAIMED
def locate_product(self, row, batch=None, session=None, vendor=None):
"""
Try to locate the product represented by the given row. Default
behavior here, is to do a simple lookup on either ``Product.upc`` or
``Product.item_id``, depending on which is configured as your product
key field.
"""
if not batch:
batch = row.batch
if not session:
session = self.app.get_session(row)
# nb. lookup should find products even if not for sale, at
# least for receiving. may need to expand this later..
kw = {}
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
kw['include_not_for_sale'] = True
if row.item_entry:
product = self.locate_product_for_entry(session, row.item_entry, **kw)
if product:
return product
# TODO: is the rest of this really needed? maybe so if there are
# some handlers out there assuming its behavior..
product_key = self.app.get_product_key_field()
if product_key == 'upc':
if row.upc:
product = self.products_handler.locate_product_for_gpc(
session, row.upc)
if product:
return product
elif product_key == 'item_id':
if row.item_id:
product = self.locate_product_for_entry(session, row.item_id,
product_key='item_id')
if product:
return product
# product key didn't work, but vendor item code just might
if row.vendor_code:
product = self.locate_product_for_entry(session, row.vendor_code,
lookup_fields=['vendor_code'],
vendor=vendor or row.batch.vendor)
if product:
return product
# before giving up, let's do a lookup on alt codes too
if row.item_entry:
product = self.locate_product_for_entry(session, row.item_entry,
lookup_fields=['alt_code'])
if product:
return product
def transform_pack_to_unit(self, row):
"""
Transform the given row, which is assumed to associate with a "pack"
item, such that it associates with the "unit" item instead.
"""
if not row.product:
return
if not row.product.is_pack_item():
return
assert row.batch.is_truck_dump_parent()
# remove any existing claims for this (parent) row
if row.claims:
session = self.app.get_session(row)
del row.claims[:]
# set temporary status for the row, if needed. this is to help
# with claiming logic below
if row.status_code in (row.STATUS_TRUCKDUMP_PARTCLAIMED,
row.STATUS_TRUCKDUMP_CLAIMED,
row.STATUS_TRUCKDUMP_OVERCLAIMED):
row.status_code = row.STATUS_TRUCKDUMP_UNCLAIMED
session.flush()
session.refresh(row)
# pretty sure this is the only status we're expecting at this point...
assert row.status_code == row.STATUS_TRUCKDUMP_UNCLAIMED
# replace the row's product association
pack = row.product
unit = pack.unit
row.product = unit
row.item_id = unit.item_id
row.upc = unit.upc
# set new case quantity, per preferred cost
cost = unit.cost_for_vendor(row.batch.vendor)
row.case_quantity = (cost.case_size or 1) if cost else 1
# must recalculate "units received" since those were for the pack item
if row.units_received:
row.units_received *= pack.pack_size
# try to establish "claims" between parent and child(ren)
self.make_truck_dump_claims_for_parent_row(row)
# refresh the row itself, so product attributes will be updated
self.refresh_row(row)
# refresh status for the batch(es) proper, just in case this changed things
self.refresh_batch_status(row.batch)
for child in row.batch.truck_dump_children:
self.refresh_batch_status(child)
def make_truck_dump_claims_for_parent_row(self, row):
"""
Try to establish all "truck dump claims" between parent and children,
for the given parent row.
"""
for child in row.batch.truck_dump_children:
child_rows = [child_row for child_row in child.active_rows()
if child_row.product_uuid == row.product.uuid]
if child_rows:
self.make_truck_dump_claims(row, child_rows)
if row.status_code not in (row.STATUS_TRUCKDUMP_UNCLAIMED,
row.STATUS_TRUCKDUMP_PARTCLAIMED):
break
def quick_entry(self, session, batch, entry):
"""
Quick entry is assumed to be a UPC scan or similar user input. Product
lookup will be based on this.
If the product cannot be found, an error is raised.
If the batch already contains a row matching the relevant product, that
row will be returned.
Otherwise, a new row will be added to the batch, and returned.
"""
model = self.model
# first try to locate the product based on quick entry
product = self.quick_locate_product(session, batch, entry)
# then try to locate existing row(s) which match product/entry
rows = self.quick_locate_rows(batch, entry, product)
if rows:
# if aggregating, just re-use matching row
prefer_existing = True # TODO: make configurable
if prefer_existing:
if len(rows) > 1:
log.warning("found multiple row matches for '%s' in batch %s: %s",
entry, batch.uuid, batch)
return rows[0]
else: # borrow product from matching row, but make new row
other_row = rows[0]
row = self.make_row()
row.item_entry = entry
row.product = other_row.product
self.add_row(batch, row)
session.flush()
self.refresh_batch_status(batch)
return row
# matching row(s) not found; add new row if product was identified
# TODO: probably should be smarter about how we handle deleted?
if product and not product.deleted:
row = self.make_row()
row.item_entry = entry
row.product = product
self.add_row(batch, row)
session.flush()
self.refresh_batch_status(batch)
return row
# at this point a true product could not be found, but we can still add
# a new row with just the quick entry, stored in product "key" field
key = self.app.get_product_key_field()
if key == 'upc':
# check for "bad" upc
if len(entry) > 14:
return
if not entry.isdigit():
return
provided = self.app.make_gpc(entry, calc_check_digit=False)
checked = self.app.make_gpc(entry, calc_check_digit='upc')
# product not in system, but presumably sane upc, so add to batch anyway
row = self.make_row()
row.item_entry = entry
add_check_digit = True # TODO: make this dynamic, of course
if add_check_digit:
row.upc = checked
else:
row.upc = provided
row.item_id = entry
row.description = "(unknown product)"
self.add_row(batch, row)
session.flush()
self.refresh_batch_status(batch)
return row
elif key == 'item_id':
# check for "too long" item_id
if len(entry) > self.app.maxlen(model.PurchaseBatchRow.item_id):
return
# product not in system, but presumably sane item_id, so add to batch anyway
row = self.make_row()
row.item_entry = entry
row.item_id = entry
row.description = "(unknown product)"
self.add_row(batch, row)
session.flush()
self.refresh_batch_status(batch)
return row
else:
raise NotImplementedError("don't know how to handle product key: {}".format(key))
def quick_locate_product(self, session, batch, entry):
"""
Slightly customized logic for locating a product from quick entry.
This will attempt a "normal" product lookup first, except it will skip
the "alternate code" lookup logic for this phase. Effectively then,
the lookup would work on either UUID or product "key" field only.
If product not yet found, logic will then try to locate a cost record
based on the entry and whichever vendor is associated with the batch.
If product still not found, the "alternate code" lookup will be tried.
"""
# first attempt lookup on product key (only)
product = self.products_handler.locate_product_for_entry(session, entry)
if product:
return product
# now we'll attempt lookup by vendor item code
product = self.products_handler.locate_product_for_vendor_code(session, entry,
vendor=batch.vendor)
if product:
return product
# okay then, let's attempt lookup by "alternate" code
product = self.products_handler.locate_product_for_alt_code(session, entry)
if product:
return product
def quick_locate_rows(self, batch, entry, product):
"""
Locate and return all rows in the given batch, which match the given
product and/or quick entry.
This logic will prefer rows which match on product UUID, and if any of
these are found then that's all that will be returned.
Otherwise, it will attempt to match the quick entry, against the
product "key" field for each row.
"""
active_rows = batch.active_rows()
rows = []
# try to locate rows by product uuid match before other key
if product:
rows = [row for row in active_rows
if row.product_uuid == product.uuid]
if rows:
return rows
key = self.app.get_product_key_field()
if key == 'upc':
if entry.isdigit():
# we prefer "exact" UPC matches, i.e. those which assumed the
# entry already contained the check digit.
provided = self.app.make_gpc(entry, calc_check_digit=False)
rows = [row for row in 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 = self.app.make_gpc(entry, calc_check_digit='upc')
rows = [row for row in active_rows
if row.upc == checked]
return rows
elif key == 'item_id':
rows = [row for row in active_rows
if row.item_id == entry]
return rows
[docs]
def after_add_row(self, batch, row):
"""
Implements an event hook with the following logic:
If the batch is for "receiving" then various invoice totals are updated,
both for the row and batch. In particular:
* :attr:`rattail.db.model.batch.purchase.PurchaseBatch.invoice_total`
* :attr:`rattail.db.model.batch.purchase.PurchaseBatch.invoice_total_calculated`
* :attr:`rattail.db.model.batch.purchase.PurchaseBatchRow.invoice_total_calculated`
"""
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
# update "original" invoice total for batch
if row.invoice_total is not None:
batch.invoice_total = (batch.invoice_total or 0) + row.invoice_total
# update "calculated" invoice totals for row, batch
if row.invoice_unit_cost is None:
row.invoice_total_calculated = None
else:
row.invoice_total_calculated = row.invoice_unit_cost * self.get_units_accounted_for(row)
if row.invoice_total_calculated is not None:
batch.invoice_total_calculated = (batch.invoice_total_calculated or 0) + row.invoice_total_calculated
[docs]
def refresh_row(self, row, **kwargs):
"""
Refreshing a row will A) assume that ``row.product`` is already set to
a valid product, or else will attempt to locate the product, and B)
update various other fields on the row (description, size, etc.) to
reflect the current product data. It also will adjust the batch PO
total per the row PO total.
"""
batch = row.batch
# always reset status first thing
row.status_code = None
row.status_text = None
# first identify the product, or else we have nothing more to do
product = row.product
if not product:
product = self.locate_product(row)
if product:
row.product = product
else:
row.status_code = row.STATUS_PRODUCT_NOT_FOUND
return
# update various (cached) product attributes for the row
cost = product.cost_for_vendor(batch.vendor)
row.upc = product.upc
row.item_id = product.item_id
row.brand_name = str(product.brand or '')
row.description = product.description
row.size = product.size
if product.department:
row.department_number = product.department.number
row.department_name = product.department.name
else:
row.department_number = None
row.department_name = None
row.vendor_code = cost.code if cost else None
if not row.catalog_cost_confirmed:
row.catalog_unit_cost = cost.unit_cost if cost else None
# figure out the effective case quantity, and whether it differs with
# what we previously had on file
case_quantity_differs = False
if cost and cost.case_size:
if not row.case_quantity:
self.assign_case_size_from_cost(row, cost)
elif row.case_quantity != cost.case_size:
if batch.is_truck_dump_parent():
if batch.truck_dump_children_first:
# supposedly our case quantity came from a truck dump
# child row, which we assume to be authoritative
case_quantity_differs = True
else:
# truck dump has no children yet, which means we have
# no special authority for case quantity; therefore
# should treat master cost record as authority
self.assign_case_size_from_cost(row, cost)
else:
case_quantity_differs = True
elif not row.case_quantity:
row.case_quantity = 1
# determine PO / invoice unit cost if necessary
if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING and row.po_unit_cost is None:
row.po_unit_cost = self.get_unit_cost(row.product, batch.vendor)
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING and row.invoice_unit_cost is None:
if row.invoice_case_cost is not None and row.case_quantity:
row.invoice_unit_cost = (row.invoice_case_cost / row.case_quantity)\
.quantize(decimal.Decimal('0.00001'))
else:
row.invoice_unit_cost = row.po_unit_cost or (
cost.unit_cost if cost else None)
# all that's left should be setting status for the row...and that logic
# will primarily depend on the 'mode' for this purchase batch
self.set_row_status(row, case_quantity_differs=case_quantity_differs)
def receiving_should_autofix_invoice_case_vs_unit(self):
if not hasattr(self, '_receiving_should_autofix_invoice_case_vs_unit'):
self._receiving_should_autofix_invoice_case_vs_unit = self.config.getbool(
'rattail.batch',
'purchase.receiving.should_autofix_invoice_case_vs_unit',
default=False)
return self._receiving_should_autofix_invoice_case_vs_unit
def assign_case_size_from_cost(self, row, cost, **kwargs):
"""
Assign the case size value for the given row, based on the
value from the given cost record.
Note that this does not check to see if the operation is
warranted; it assumes you already checked for that and your
calling this method implies the operation is warranted.
The assumed situation is where a batch was populated from e.g.
an invoice file, which did not specify case sizes. In that
case it's possible for the invoice parsing to have been "off"
and misinterpret unit data for case data, or vice versa.
Therefore if config dictates, this method will also try to
detect such unit/case confusion issues, using the new case
size value to help determine what's what. If it determines a
mistake was made, it will try to rectify it.
"""
batch = row.batch
# we might auto-fix things when receiving invoice
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
if self.has_invoice_file(batch):
if self.receiving_should_autofix_invoice_case_vs_unit():
# only auto-fix when item is really in cases but
# the invoice thought it was in units
if row.units_shipped and not row.cases_shipped:
# only auto-fix when catalog case cost is
# closer than unit cost, to the invoice cost
if (cost.case_cost and cost.unit_cost
and row.invoice_unit_cost
and (abs(cost.case_cost - row.invoice_unit_cost)
< abs(cost.unit_cost - row.invoice_unit_cost))):
# we think invoice had it wrong, so swap
log.debug("swapping unit => case for row #%s: %s",
row.sequence, row)
row.cases_shipped = row.units_shipped
row.units_shipped = None
row.invoice_case_cost = row.invoice_unit_cost
row.invoice_unit_cost = row.invoice_case_cost / cost.case_size
if not self.has_purchase_order(batch):
row.cases_ordered = row.units_ordered
row.units_ordered = None
# and finally, set effective case size for row
row.case_quantity = cost.case_size
def set_row_status(self, row, case_quantity_differs=None):
batch = row.batch
if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
row.status_code = row.STATUS_OK
return
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
# first check to see if we have *any* confirmed items
if not (row.cases_received or row.units_received or
row.cases_damaged or row.units_damaged or
row.cases_expired or row.units_expired or
row.cases_mispick or row.units_mispick or
row.cases_missing or row.units_missing):
# no, we do not have any confirmed items...
def set_status():
# so, nothing confirmed, but if we also know that
# nothing was ordered/shipped, then we're "all good"
if batch.order_quantities_known:
if self.has_invoice_file(batch):
if not row.cases_shipped and not row.units_shipped:
row.status_code = row.STATUS_NOT_SHIPPED
return
if not row.cases_ordered and not row.units_ordered:
row.status_code = row.STATUS_OK
return
# TODO: is this right? should out of stock just be a filter for
# the user to specify, or should it affect status?
if row.out_of_stock:
row.status_code = row.STATUS_OUT_OF_STOCK
else:
row.status_code = row.STATUS_INCOMPLETE
set_status()
# truck dump parent rows are also given status for that, which
# reflects claimed vs. pending, i.e. child reconciliation
if batch.is_truck_dump_parent():
row.truck_dump_status = row.STATUS_TRUCKDUMP_CLAIMED
return
else: # we do have some confirmed items
# TODO: this whole block was copied from above, need
# to refactor somehow surely
if case_quantity_differs is None:
# figure out the effective case quantity, and whether it differs with
# what we previously had on file
case_quantity_differs = False
product = row.product
cost = product.cost_for_vendor(batch.vendor)
if cost and cost.case_size:
if row.case_quantity and row.case_quantity != cost.case_size:
if batch.is_truck_dump_parent():
if batch.truck_dump_children_first:
# supposedly our case quantity came from a truck dump
# child row, which we assume to be authoritative
case_quantity_differs = True
else:
case_quantity_differs = True
# primary status code for row should ideally reflect ordered
# vs. received, although there are some exceptions
# TODO: this used to prefer "case qty differs" and now i'm not
# sure what the priority should be..perhaps config should say?
if batch.order_quantities_known and (
self.get_units_shipped(row) != self.get_units_accounted_for(row)):
row.status_code = row.STATUS_ORDERED_RECEIVED_DIFFER
elif case_quantity_differs:
cost = row.product.cost_for_vendor(batch.vendor)
row.status_code = row.STATUS_CASE_QUANTITY_DIFFERS
row.status_text = "batch has {} but master cost has {}".format(
repr(row.case_quantity), repr(cost.case_size))
# the canonical "case size" for the row will always
# match the master cost record on file, but the
# invoice may have a different value. there will be
# false alarms e.g. for 6-pack soda, so we only flag
# these if *totals* also do not match, *and* if the
# case UOM is being received
elif (row.invoice_case_size is not None
and row.invoice_case_size != row.case_quantity
and row.invoice_total is not None
and row.invoice_total_calculated != row.invoice_total
and (row.cases_ordered or row.cases_shipped)):
row.status_code = row.STATUS_CASE_QUANTITY_DIFFERS
row.status_text = f"master has {row.case_quantity} but invoice has {row.invoice_case_size}"
# TODO: is this right? should out of stock just be a filter for
# the user to specify, or should it affect status?
elif row.out_of_stock:
row.status_code = row.STATUS_OUT_OF_STOCK
else:
row.status_code = row.STATUS_OK
# truck dump parent rows are also given status for that, which
# reflects claimed vs. pending, i.e. child reconciliation
if batch.is_truck_dump_parent():
confirmed = self.get_units_confirmed(row)
claimed = self.get_units_claimed(row)
if claimed == confirmed:
row.truck_dump_status = row.STATUS_TRUCKDUMP_CLAIMED
elif not claimed:
row.truck_dump_status = row.STATUS_TRUCKDUMP_UNCLAIMED
elif claimed < confirmed:
row.truck_dump_status = row.STATUS_TRUCKDUMP_PARTCLAIMED
elif claimed > confirmed:
row.truck_dump_status = row.STATUS_TRUCKDUMP_OVERCLAIMED
else:
raise NotImplementedError
return
if batch.mode == self.enum.PURCHASE_BATCH_MODE_COSTING:
if row.po_line_number and not row.invoice_line_number:
row.status_code = row.STATUS_ON_PO_NOT_INVOICE
elif row.invoice_line_number and not row.po_line_number:
row.status_code = row.STATUS_ON_INVOICE_NOT_PO
elif not self.get_units_received(row):
row.status_code = row.STATUS_DID_NOT_RECEIVE
elif (row.invoice_unit_cost and row.catalog_unit_cost
and (row.invoice_unit_cost - row.catalog_unit_cost) > self.penny):
row.status_code = row.STATUS_COST_INCREASE
else:
row.status_code = row.STATUS_OK
return
raise NotImplementedError("can't refresh row for batch of mode: {}".format(
self.enum.PURCHASE_BATCH_MODE.get(batch.mode, "unknown ({})".format(batch.mode))))
def can_declare_credit(self, row, credit_type='received', cases=None, units=None, **kwargs):
"""
This method should be used to validate a potential declaration of
credit, i.e. call this before calling :meth:`declare_credit()`. See
the latter for call signature documentation, as they are the same.
This method will use similar logic to confirm the proposed credit is
valid, i.e. there is sufficient "received" quantity in place for it.
"""
# make sure we have cases *or* units
if not (cases or units):
raise ValueError("Must provide amount for cases *or* units.")
if cases and units:
raise ValueError("Must provide amount for cases *or* units (but not both).")
if cases and cases < 0:
raise ValueError("Must provide *positive* amount for cases.")
if units and units < 0:
raise ValueError("Must provide *positive* amount for units.")
# make sure we have a (non-executed) receiving batch
if row.batch.mode != self.enum.PURCHASE_BATCH_MODE_RECEIVING:
raise NotImplementedError("receive_row() is only for receiving batches")
if row.batch.executed:
raise NotImplementedError("receive_row() is only for *non-executed* batches")
if cases:
if row.cases_received and row.cases_received >= cases:
return True
if row.units_received:
units = cases * row.case_quantity
if row.units_received >= units:
return True
if units:
if row.units_received and row.units_received >= units:
return True
if row.cases_received:
cases = units // row.case_quantity
if units % row.case_quantity:
cases += 1
if row.cases_received >= cases:
return True
raise ValueError("Credit amount cannot be more than the \"Received\" amount.")
def declare_credit(self, row, credit_type='received', cases=None, units=None, **kwargs):
"""
This method is similar in nature to :meth:`receive_row()`, although its
goal is different. Whereas ``receive_row()`` is concerned with "adding
confirmed quantities" to the row, ``declare_credit()`` will instead
"convert" some quantity which was previously "received" into one of the
possible credit types.
In other words if you have "received" 2 CS of a given product, but then
while stocking it you discover 3 EA are damaged, then you would use
this method to declare a credit like so::
handler.declare_credit(row, credit_type='damaged', units=3)
The received quantity for the row would go down by 3 EA, and its
damaged quantity would go up by 3 EA. The logic is able to handle
"splitting" a case as necessary to accomplish this.
Note that each call must specify *either* a (non-empty) ``cases`` or
``units`` value, but *not* both!
:param rattail.db.model.batch.purchase.PurchaseBatchRow row: Batch row
which is to be updated with the given receiving data. The row must
exist, i.e. this method will not create a new row for you.
:param str credit_type: Must be one of the credit types which are
"supported" according to the handler. Possible types include:
* ``'damaged'``
* ``'expired'``
* ``'mispick'``
* ``'missing'``
:param decimal.Decimal cases: Case quantity for the credit, if applicable.
:param decimal.Decimal units: Unit quantity for the credit, if applicable.
:param datetime.date expiration_date: Expiration date for the credit,
if applicable. Only used if ``credit_type='expired'``.
"""
# make sure we have cases *or* units
if not (cases or units):
raise ValueError("Must provide amount for cases *or* units.")
if cases and units:
raise ValueError("Must provide amount for cases *or* units (but not both).")
if cases and cases < 0:
raise ValueError("Must provide *positive* amount for cases.")
if units and units < 0:
raise ValueError("Must provide *positive* amount for units.")
# make sure we have a (non-executed) receiving batch
if row.batch.mode != self.enum.PURCHASE_BATCH_MODE_RECEIVING:
raise NotImplementedError("receive_row() is only for receiving batches")
if row.batch.executed:
raise NotImplementedError("receive_row() is only for *non-executed* batches")
if cases:
if row.cases_received and row.cases_received >= cases:
self.receive_row(row, mode='received', cases=-cases)
self.receive_row(row, mode=credit_type, cases=cases, **kwargs)
return
if row.units_received:
units = cases * row.case_quantity
if row.units_received >= units:
self.receive_row(row, mode='received', units=-units)
self.receive_row(row, mode=credit_type, units=units, **kwargs)
return
if units:
if row.units_received and row.units_received >= units:
self.receive_row(row, mode='received', units=-units)
self.receive_row(row, mode=credit_type, units=units, **kwargs)
return
if row.cases_received:
cases = units // row.case_quantity
if units % row.case_quantity:
cases += 1
if row.cases_received >= cases:
self.receive_row(row, mode='received', cases=-cases)
if (cases * row.case_quantity) > units:
self.receive_row(row, mode='received', units=cases * row.case_quantity - units)
self.receive_row(row, mode=credit_type, units=units, **kwargs)
return
raise ValueError("credit amount must be <= 'received' amount for the row")
def undeclare_credit(self, row, credit, **kwargs):
"""
This does the opposite of :meth:`declare_credit()` basically.
Here you must provide a credit that has previously been
declared, and this method will move the case/unit amounts for
the credit back into the "received" tally for the row. It
then will remove the credit; that will no longer exist.
"""
if credit.cases_shorted:
self.receive_row(row, mode=credit.credit_type,
cases=-credit.cases_shorted,
update_credits=False)
self.receive_row(row, mode='received',
cases=credit.cases_shorted)
if credit.units_shorted:
self.receive_row(row, mode=credit.credit_type,
units=-credit.units_shorted,
update_credits=False)
self.receive_row(row, mode='received',
units=credit.units_shorted)
session = self.app.get_session(row)
session.delete(credit)
[docs]
def receive_row(self, row, mode='received', cases=None, units=None,
update_credits=True, **kwargs):
"""
This method is arguably the workhorse of the whole process. Callers
should invoke it as they receive input from the user during the
receiving workflow.
Each call to this method must include the row to be updated, as well as
the details of the update. These details should reflect "changes"
which are to be made, as opposed to "final values" for the row. In
other words if a row already has ``cases_received == 1`` and the user
is receiving a second case, this method should be called like so::
handler.receive_row(row, mode='received', cases=1)
The row will be updated such that ``cases_received == 2``; the main
point here is that the caller should *not* specify ``cases=2`` because
it is the handler's job to "apply changes" from the caller. (If the
caller speficies ``cases=2`` then the row would end up with
``cases_received == 3``.)
For "undo" type adjustments, caller can just send a negative amount,
and the handler will apply the changes as expected::
handler.receive_row(row, mode='received', cases=-1)
Note that each call must specify *either* a (non-empty) ``cases`` or
``units`` value, but *not* both! If you need to adjust both then you
must make two separate calls.
:param ~rattail.db.model.batch.purchase.PurchaseBatchRow row: Batch row
which is to be updated with the given receiving data. The row must
exist, i.e. this method will not create a new row for you.
:param str mode: Must be one of the receiving modes which are
"supported" according to the handler. Possible modes include:
* ``'received'``
* ``'damaged'``
* ``'expired'``
* ``'mispick'``
* ``'missing'``
:param ~decimal.Decimal cases: Case quantity for the update, if applicable.
:param ~decimal.Decimal units: Unit quantity for the update, if applicable.
:param ~datetime.date expiration_date: Expiration date for the update,
if applicable. Only used if ``mode='expired'``.
This method exists mostly to consolidate the various logical steps which
must be taken for each new receiving input from the user. Under the hood
it delegates to a few other methods:
* :meth:`receiving_update_row_attrs()`
* :meth:`receiving_update_row_credits()`
* :meth:`receiving_update_row_children()`
"""
# make sure we have cases *or* units
if not (cases or units):
raise ValueError("Must provide amount for cases *or* units.")
if cases and units:
raise ValueError("Must provide amount for cases *or* units (but not both).")
# make sure we have a (non-executed) receiving batch
if row.batch.mode != self.enum.PURCHASE_BATCH_MODE_RECEIVING:
raise NotImplementedError("receive_row() is only for receiving batches")
if row.batch.executed:
raise NotImplementedError("receive_row() is only for *non-executed* batches")
# update the given row
self.receiving_update_row_attrs(row, mode, cases, units)
# update the given row's credits
if update_credits:
self.receiving_update_row_credits(row, mode, cases, units, **kwargs)
# update the given row's "children" (if this is truck dump parent)
self.receiving_update_row_children(row, mode, cases, units, **kwargs)
[docs]
def receiving_update_row_attrs(self, row, mode, cases, units):
"""
Apply a receiving update to the row's attributes.
Note that this should not be called directly; it is invoked as part of
:meth:`receive_row()`.
"""
batch = row.batch
# 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)
# refresh row status etc.
self.refresh_row(row)
# update calculated invoice totals if normal received amounts
if mode == 'received':
# TODO: should round invoice amount to 2 places here?
invoice_amount = 0
if cases:
invoice_amount += cases * (row.case_quantity or 0) * (row.invoice_unit_cost or 0)
if units:
invoice_amount += units * (row.invoice_unit_cost or 0)
row.invoice_total_calculated = (row.invoice_total_calculated or 0) + invoice_amount
batch.invoice_total_calculated = (batch.invoice_total_calculated or 0) + invoice_amount
[docs]
def receiving_update_row_credits(self, row, mode, cases, units, **kwargs):
"""
Apply a receiving update to the row's credits, if applicable.
Note that this should not be called directly; it is invoked as part of
:meth:`receive_row()`.
"""
model = self.model
batch = row.batch
# only certain modes should involve credits
if mode not in ('damaged', 'expired', 'mispick', 'missing'):
return
# TODO: need to add mispick support obviously
if mode == 'mispick':
raise NotImplementedError("mispick credits not yet supported")
# TODO: must account for negative values here! i.e. remove credit in
# some scenarios, perhaps using `kwargs` to find the match?
if (cases and cases > 0) or (units and units > 0):
positive = True
else:
positive = False
raise NotImplementedError("TODO: add support for negative values when updating credits")
# always make new credit; never aggregate
credit = model.PurchaseBatchCredit()
self.populate_credit(credit, row)
credit.credit_type = mode
credit.cases_shorted = cases or None
credit.units_shorted = units or None
# calculate credit total
# TODO: should this leverage case cost if present?
credit_units = self.get_units(credit.cases_shorted,
credit.units_shorted,
credit.case_quantity)
credit.credit_total = credit_units * (credit.invoice_unit_cost or 0)
# apply other attributes to credit, per caller kwargs
credit.product_discarded = kwargs.get('discarded')
if mode == 'expired':
credit.expiration_date = kwargs.get('expiration_date')
elif mode == 'mispick' and kwargs.get('mispick_product'):
mispick_product = kwargs['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
# attach credit to row
row.credits.append(credit)
[docs]
def update_row_cost(self, row, **kwargs):
"""
Update the cost value(s) for the given row, and calculate new totals
accordingly. This will handle updating the row as well as the batch,
as necessary.
Note that thus far, it is assumed the given row is for a "receiving"
batch, and the logic does not provide special handling for truck dump.
The only cost kwargs supported are:
* ``catalog_unit_cost``
* ``invoice_unit_cost``
"""
session = self.app.get_session(row)
batch = row.batch
# cache original values for comparison
old_invoice_total_calculated = row.invoice_total_calculated
# TODO: this used to *not* have a fallback of zero; i'm not
# clear in which circumstances it might be None but apparently
# that is possible.. so we must fallback to avoid error and
# log a warning that we'll hopefully see (?) to learn more
if old_invoice_total_calculated is None:
log.warning("invoice_total_calculated is null for row %s of batch %s: row",
row.uuid, batch.id_str, row)
old_invoice_total_calculated = 0
# update catalog_unit_cost for row
if 'catalog_unit_cost' in kwargs:
row.catalog_unit_cost = kwargs['catalog_unit_cost']
row.catalog_cost_confirmed = True
# update invoice_unit_cost for row, and totals for row/batch
if 'invoice_unit_cost' in kwargs:
row.invoice_unit_cost = kwargs['invoice_unit_cost']
row.invoice_cost_confirmed = True
row.invoice_total_calculated = (
row.invoice_unit_cost * self.get_units_accounted_for(row))
batch.invoice_total_calculated += (
row.invoice_total_calculated - old_invoice_total_calculated)
session.flush()
self.refresh_row(row)
[docs]
def update_row_quantity(self, row, **kwargs):
"""
Update quantity value(s) for the given row, and calculate new totals
accordingly. This will handle updating the row as well as the batch,
as necessary. Which kwargs this method accepts, and which values are
updated, will depend on the batch mode.
*Ordering Mode*
Possible kwargs are:
* ``cases_ordered``
* ``units_ordered``
Logic will figure out the "diff" between the given quantites, and the
row's existing values at the time. It then invokes :meth:`order_row()`
with the diff values.
"""
batch = row.batch
if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
cases_ordered = kwargs.get('cases_ordered')
if cases_ordered is not None:
if isinstance(cases_ordered, str):
cases_ordered = decimal.Decimal(cases_ordered or '0')
cases_diff = cases_ordered - (row.cases_ordered or 0)
if cases_diff:
self.order_row(row, cases=cases_diff)
units_ordered = kwargs.get('units_ordered')
if units_ordered is not None:
if isinstance(units_ordered, str):
units_ordered = decimal.Decimal(units_ordered or '0')
units_diff = units_ordered - (row.units_ordered or 0)
if units_diff:
self.order_row(row, units=units_diff)
[docs]
def order_row(self, row, cases=None, units=None, **kwargs):
"""
This method is conceptually similar to :meth:`receive_row()` and, while
the latter is more "necessary" than this one is, this method tries to
match its style just for consistency. Callers may or may not need this
method directly, but are welcome to use it.
Each call to this method must include the row to be updated, as well as
the details of the update. These details should reflect "changes"
which are to be made, as opposed to "final values" for the row. In
other words if a row already has ``cases_ordered == 1`` and the user
is ordering a second case, this method should be called like so::
handler.order_row(row, cases=1)
The row will be updated such that ``cases_ordered == 2``; the main
point here is that the caller should *not* specify ``cases=2`` because
it is the handler's job to "apply changes" from the caller. (If the
caller speficies ``cases=2`` then the row would end up with
``cases_ordered == 3``.)
See also :meth:`update_row_quantity()` which allows the caller to
specify the final values instead.
For "undo" type adjustments, caller can just send a negative amount,
and the handler will apply the changes as expected::
handler.order_row(row, cases=-1)
Note that each call must specify *either* a (non-empty) ``cases`` or
``units`` value, but *not* both! If you need to adjust both then you
must make two separate calls.
:param ~rattail.db.model.batch.purchase.PurchaseBatchRow row: Batch row
which is to be updated with the given order data. The row must
exist, i.e. this method will not create a new row for you.
:param ~decimal.Decimal cases: Case quantity for the update, if applicable.
:param ~decimal.Decimal units: Unit quantity for the update, if applicable.
"""
# make sure we have cases *or* units
if not (cases or units):
raise ValueError("must provide amount for cases *or* units")
if cases and units:
raise ValueError("must provide amount for cases *or* units (but not both)")
batch = row.batch
# make sure we have a (non-executed) ordering batch
if batch.mode != self.enum.PURCHASE_BATCH_MODE_ORDERING:
raise NotImplementedError("order_row() is only for ordering batches")
if batch.executed:
raise BatchAlreadyExecuted(batch)
# add values as-is to existing case/unit amounts.
# TODO: what if this gives us negative values?
if cases:
row.cases_ordered = (row.cases_ordered or 0) + cases
if units:
row.units_ordered = (row.units_ordered or 0) + units
# TODO: pretty sure this isn't needed?
# # refresh row status etc.
# self.refresh_row(row)
# update calculated PO totals
po_amount = 0
if cases:
po_amount += cases * (row.case_quantity or 1) * (row.po_unit_cost or 0)
if units:
po_amount += units * (row.po_unit_cost or 0)
row.po_total_calculated = (row.po_total_calculated or 0) + po_amount
batch.po_total_calculated = (batch.po_total_calculated or 0) + po_amount
def get_order_form_costs(self, session, vendor):
"""
This method should return an iterable sequence of "cost" records, each
of which corresponds to a single (unique) product, and which should be
displayed on an "order form" for a buyer. Note that this need not sort
the list, as that is done in :meth:`sort_order_form_costs()`.
Default logic will return
:class:`~rattail.db.model.product.ProductCost` records. It should
ideally be possible to return any "other" type of cost records, but
probably other code will need some tweaks to allow for that.
Default logic also will try to filter out certain "undesirable"
products, e.g. those which are discontinued, deleted etc.
"""
model = self.model
# note that the joinedload() usage is for efficiency when displaying
# the final record within the order form worksheet
return session.query(model.ProductCost)\
.join(model.Product)\
.outerjoin(model.Brand)\
.filter(model.ProductCost.vendor == vendor)\
.filter(model.Product.deleted == False)\
.filter(model.Product.discontinued == False)\
.filter(model.Product.not_for_sale == False)\
.options(orm.joinedload(model.ProductCost.product)\
.joinedload(model.Product.department))\
.options(orm.joinedload(model.ProductCost.product)\
.joinedload(model.Product.subdepartment))
def sort_order_form_costs(self, costs):
"""
Sort the given sequence of "cost" records, for display on the Order
Form worksheet.
"""
model = self.model
return costs.order_by(model.Brand.name,
model.Product.description,
model.Product.size)
def decorate_order_form_costs(self, batch, costs):
"""
Decorate the given sequence of "cost" records, to add any additional
context data needed for display on Order Form worksheet.
"""
def get_order_form_history(self, batch, costs, count):
"""
This method should return relevant purchase history, for display within
the Order Form worksheet.
:param batch: Reference to the current batch, for which the Order Form
worksheet is to be displayed.
:param costs: Sequence of "cost" records for the current worksheet.
:param count: Number of recent purchases for which history should be
returned. May depend on the caller generating the worksheet, but
default number for this is probably 6.
"""
model = self.model
session = self.app.get_session(batch)
# fetch last X purchases for this vendor, organize line items by product
history = []
purchases = session.query(model.Purchase)\
.filter(model.Purchase.vendor == batch.vendor)\
.filter(model.Purchase.status >= self.enum.PURCHASE_STATUS_ORDERED)\
.order_by(model.Purchase.date_ordered.desc(),
model.Purchase.created.desc())\
.options(orm.joinedload(model.Purchase.items))
for purchase in purchases[:count]:
items = {}
for item in purchase.items:
items[item.product_uuid] = item
history.append({'purchase': purchase, 'items': items})
return history
def populate_credit(self, credit, row):
"""
Populate all basic attributes for the given credit, from the given row.
"""
batch = row.batch
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 = row.invoice_number or batch.invoice_number
credit.invoice_date = row.invoice_date or 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.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_calculated
[docs]
def receiving_update_row_children(self, row, mode, cases, units, **kwargs):
"""
Apply a receiving update to the row's "children", if applicable.
Note that this should not be called directly; it is invoked as part of
:meth:`receive_row()`.
This logic only applies to a "truck dump parent" row, since that is the
only type which can have "children". Also this logic is assumed only
to apply if using the "children first" workflow. If these criteria are
not met then nothing is done.
This method is ultimately responsible for updating "everything"
(relevant) about the children of the given parent row. This includes
updating the child row(s) as well as the "claim" records used for
reconciliation, as well as any child credit(s). However most of the
heavy lifting is done by :meth:`receiving_update_row_child()`.
"""
batch = row.batch
# updating row children is only applicable for truck dump parent, and
# even then only if "children first" workflow
if not batch.is_truck_dump_parent():
return
# TODO: maybe should just check for `batch.truck_dump_children` instead?
if not batch.truck_dump_children_first:
return
# apply changes to child row(s) until we exhaust update quantities
while cases or units:
# find the "best match" child per current quantities, or quit if we
# can no longer find any child match at all
child_row = self.receiving_find_best_child_row(row, mode, cases, units)
if not child_row:
break
# apply update to child, which should reduce our quantities
before = cases, units
cases, units = self.receiving_update_row_child(row, child_row, mode, cases, units, **kwargs)
if (cases, units) == before:
raise RuntimeError("infinite loop detected; aborting")
# refresh parent row status
self.refresh_row(row)
[docs]
def receiving_update_row_child(self, parent_row, child_row, mode, cases, units, **kwargs):
"""
Update the given child row attributes, as well as the "claim" record
which ties it to the parent, as well as any credit(s) which may apply.
Ideally the child row can accommodate the "full" case/unit amounts
given, but if not then it must do as much as it can. Note that the
child row should have been located via :meth:`receiving_find_best_child_row()`
and therefore should be able to accommodate *something* at least.
This method returns a 2-tuple of ``(cases, units)`` which reflect the
amounts it was *not* able to claim (or relinquish, if incoming amounts
are negative). In other words these are the "leftovers" which still
need to be dealt with somehow.
"""
model = self.model
# were we given positive or negative values for the update?
if (cases and cases > 0) or (units and units > 0):
positive = True
else:
positive = False
##############################
def update(cases, units):
# update child claim
claim = get_claim()
if cases:
setattr(claim, 'cases_{}'.format(mode),
(getattr(claim, 'cases_{}'.format(mode)) or 0) + cases)
if units:
setattr(claim, 'units_{}'.format(mode),
(getattr(claim, 'units_{}'.format(mode)) or 0) + units)
# remove claim if now empty (should only happen if negative values?)
if claim.is_empty():
parent_row.claims.remove(claim)
# update child row
self.receiving_update_row_attrs(child_row, mode, cases, units)
if cases:
child_row.cases_shipped_claimed += cases
child_row.cases_shipped_pending -= cases
if units:
child_row.units_shipped_claimed += units
child_row.units_shipped_pending -= units
# update child credit, if applicable
self.receiving_update_row_credits(child_row, mode, cases, units, **kwargs)
def get_claim():
claims = [claim for claim in parent_row.claims
if claim.claiming_row is child_row]
if claims:
if len(claims) > 1:
raise ValueError("child row has too many claims on parent!")
return claims[0]
claim = model.PurchaseBatchRowClaim()
claim.claiming_row = child_row
parent_row.claims.append(claim)
return claim
##############################
# first we try to accommodate the full "as-is" amounts, if possible
if positive:
if cases and units:
if child_row.cases_shipped_pending >= cases and child_row.units_shipped_pending >= units:
update(cases, units)
return 0, 0
elif cases:
if child_row.cases_shipped_pending >= cases:
update(cases, 0)
return 0, 0
else: # units
if child_row.units_shipped_pending >= units:
update(0, units)
return 0, 0
else: # negative
if cases and units:
if child_row.cases_shipped_claimed >= -cases and child_row.units_shipped_claimed >= -units:
update(cases, units)
return 0, 0
elif cases:
if child_row.cases_shipped_claimed >= -cases:
update(cases, 0)
return 0, 0
else: # units
if child_row.units_shipped_claimed >= -units:
update(0, units)
return 0, 0
# next we try a couple more variations on that theme, aiming for "as
# much as possible, as simply as possible"
if cases and units:
if positive:
if child_row.cases_shipped_pending >= cases:
update(cases, 0)
return 0, units
if child_row.units_shipped_pending >= units:
update(0, units)
return cases, 0
else: # negative
if child_row.cases_shipped_claimed >= -cases:
update(cases, 0)
return 0, units
if child_row.units_shipped_claimed >= -units:
update(0, units)
return cases, 0
# okay then, try to (simply) use up any "child" quantities
if positive:
if cases and units and (child_row.cases_shipped_pending
and child_row.units_shipped_pending):
pending = (child_row.cases_shipped_pending,
child_row.units_shipped_pending)
update(pending[0], pending[1])
return cases - pending[0], units - pending[1]
if cases and child_row.cases_shipped_pending:
pending = child_row.cases_shipped_pending
update(pending, 0)
return cases - pending, 0
if units and child_row.units_shipped_pending:
pending = child_row.units_shipped_pending
update(0, pending)
return 0, units - pending
else: # negative
if cases and units and (child_row.cases_shipped_claimed
and child_row.units_shipped_claimed):
claimed = (child_row.cases_shipped_claimed,
child_row.units_shipped_claimed)
update(-claimed[0], -claimed[1])
return cases + claimed[0], units + claimed[1]
if cases and child_row.cases_shipped_claimed:
claimed = child_row.cases_shipped_claimed
update(-claimed, 0)
return cases + claimed, 0
if units and child_row.units_shipped_claimed:
claimed = child_row.units_shipped_claimed
update(0, -claimed)
return 0, units + claimed
# looks like we're gonna have to split some cases, one way or another
if parent_row.case_quantity != child_row.case_quantity:
raise NotImplementedError("cannot split case when parent/child disagree about size")
if positive:
if cases and child_row.units_shipped_pending:
if child_row.units_shipped_pending >= parent_row.case_quantity:
unit_cases = child_row.units_shipped_pending // parent_row.case_quantity
if unit_cases >= cases:
update(0, cases * parent_row.case_quantity)
return 0, units
else: # unit_cases < cases
update(0, unit_cases * parent_row.case_quantity)
return cases - unit_cases, units
else: # units_pending < case_size
pending = child_row.units_shipped_pending
update(0, pending)
return (cases - 1,
(units or 0) + parent_row.case_quantity - pending)
if units and child_row.cases_shipped_pending:
if units >= parent_row.case_quantity:
unit_cases = units // parent_row.case_quantity
if unit_cases <= child_row.cases_shipped_pending:
update(unit_cases, 0)
return 0, units - (unit_cases * parent_row.case_quantity)
else: # unit_cases > cases_pending
pending = child_row.cases_shipped_pending
update(pending, 0)
return 0, units - (pending * parent_row.case_quantity)
else: # units < case_size
update(0, units)
return 0, 0
else: # negative
if cases and child_row.units_shipped_claimed:
if child_row.units_shipped_claimed >= parent_row.case_quantity:
unit_cases = child_row.units_shipped_claimed // parent_row.case_quantity
if unit_cases >= -cases:
update(0, cases * parent_row.case_quantity)
return 0, units
else: # unit_cases < -cases
update(0, -unit_cases * parent_row.case_quantity)
return cases + unit_cases, units
else: # units_claimed < case_size
claimed = child_row.units_shipped_claimed
update(0, -claimed)
return (cases + 1,
(units or 0) - parent_row.case_quantity + claimed)
if units and child_row.cases_shipped_claimed:
if -units >= parent_row.case_quantity:
unit_cases = -units // parent_row.case_quantity
if unit_cases <= child_row.cases_shipped_claimed:
update(-unit_cases, 0)
return 0, units + (unit_cases * parent_row.case_quantity)
else: # unit_cases > cases_claimed
claimed = child_row.cases_shipped_claimed
update(-claimed, 0)
return 0, units + (claimed * parent_row.case_quantity)
else: # -units < case_size
update(0, units)
return 0, 0
# TODO: this should theoretically never happen; should log/raise error?
log.warning("unable to claim/relinquish any case/unit amounts for child row: %s", child_row)
return cases, units
[docs]
def receiving_find_best_child_row(self, row, mode, cases, units):
"""
Locate and return the "best match" child row, for the given parent row
and receiving update details. The idea here is that the parent row
will represent the "receiving" side of things, whereas the child row
will be the "ordering" side.
For instance if the update is for say, "received 2 CS" and there are
two child rows, one of which is for 1 CS and the other 2 CS, the latter
will be returned. This logic is capable of "splitting" a case where
necessary, in order to find a partial match etc.
"""
parent_row = row
parent_batch = parent_row.batch
if not (cases or units):
raise ValueError("must provide amount for cases and/or units")
if cases and units and (
(cases > 0 and units < 0) or (cases < 0 and units > 0)):
raise NotImplementedError("not sure how to handle mixed pos/neg for case/unit amounts")
# were we given positive or negative values for the update?
if (cases and cases > 0) or (units and units > 0):
positive = True
else:
positive = False
# first we collect all potential child rows
all_child_rows = []
for child_batch in parent_batch.truck_dump_children:
# match on exact product if possible, otherwise must match on upc etc.
if parent_row.product:
child_rows = [child_row for child_row in child_batch.active_rows()
if child_row.product_uuid == parent_row.product.uuid]
else:
# note that we only want to match child rows which have *no* product ref
# TODO: should consult config to determine which product key to match on
child_rows = [child_row for child_row in child_batch.active_rows()
if not child_row.product_uuid and child_row.upc == parent_row.upc]
for child_row in child_rows:
# for each child row we also calculate "claimed" vs. "pending" amounts
# cases_ordered
child_row.cases_shipped_claimed = sum([(claim.cases_received or 0)
+ (claim.cases_damaged or 0)
+ (claim.cases_expired or 0)
for claim in child_row.truck_dump_claims])
child_row.cases_shipped_pending = (child_row.cases_ordered or 0) - child_row.cases_shipped_claimed
# units_ordered
child_row.units_shipped_claimed = sum([(claim.units_received or 0)
+ (claim.units_damaged or 0)
+ (claim.units_expired or 0)
for claim in child_row.truck_dump_claims])
child_row.units_shipped_pending = (child_row.units_ordered or 0) - child_row.units_shipped_claimed
# maybe account for split cases
if child_row.units_shipped_pending < 0:
split_cases = -child_row.units_shipped_pending // child_row.case_quantity
if -child_row.units_shipped_pending % child_row.case_quantity:
split_cases += 1
if split_cases > child_row.cases_shipped_pending:
raise ValueError("too many cases have been split?")
child_row.cases_shipped_pending -= split_cases
child_row.units_shipped_pending += split_cases * child_row.case_quantity
all_child_rows.append(child_row)
def sortkey(row):
if positive:
return self.get_units(row.cases_shipped_pending,
row.units_shipped_pending,
row.case_quantity)
else: # negative
return self.get_units(row.cases_shipped_claimed,
row.units_shipped_claimed,
row.case_quantity)
# sort child rows such that smallest (relevant) quantities come first;
# idea being we would prefer the "least common denominator" to match
all_child_rows.sort(key=sortkey)
# first try to find an exact match
for child_row in all_child_rows:
if cases and units:
if positive:
if child_row.cases_shipped_pending == cases and child_row.units_shipped_pending == units:
return child_row
else: # negative
if child_row.cases_shipped_claimed == cases and child_row.units_shipped_claimed == units:
return child_row
elif cases:
if positive:
if child_row.cases_shipped_pending == cases:
return child_row
else: # negative
if child_row.cases_shipped_claimed == cases:
return child_row
else: # units
if positive:
if child_row.units_shipped_pending == units:
return child_row
else: # negative
if child_row.units_shipped_claimed == units:
return child_row
# next we try to find the "first" (smallest) match which satisfies, but
# which does so *without* having to split up any cases
for child_row in all_child_rows:
if cases and units:
if positive:
if child_row.cases_shipped_pending >= cases and child_row.units_shipped_pending >= units:
return child_row
else: # negative
if child_row.cases_shipped_claimed >= -cases and child_row.units_shipped_claimed >= -units:
return child_row
elif cases:
if positive:
if child_row.cases_shipped_pending >= cases:
return child_row
else: # negative
if child_row.cases_shipped_claimed >= -cases:
return child_row
else: # units
if positive:
if child_row.units_shipped_pending >= units:
return child_row
else: # negative
if child_row.units_shipped_claimed >= -units:
return child_row
# okay, we're getting desperate now; let's start splitting cases and
# may the first possible match (which fully satisfies) win...
incoming_units = self.get_units(cases, units, parent_row.case_quantity)
for child_row in all_child_rows:
if positive:
pending_units = self.get_units(child_row.cases_shipped_pending,
child_row.units_shipped_pending,
child_row.case_quantity)
if pending_units >= incoming_units:
return child_row
else: # negative
claimed_units = self.get_units(child_row.cases_shipped_claimed,
child_row.units_shipped_claimed,
child_row.case_quantity)
if claimed_units >= -incoming_units:
return child_row
# and now we're even more desperate. at this point no child row can
# fully (by itself) accommodate the update at hand, which means we must
# look for the first child which can accommodate anything at all, and
# settle for the partial match. note that we traverse the child row
# list *backwards* here, hoping for the "biggest" match
for child_row in reversed(all_child_rows):
if positive:
if child_row.cases_shipped_pending or child_row.units_shipped_pending:
return child_row
else: # negative
if child_row.cases_shipped_claimed or child_row.units_shipped_claimed:
return child_row
def is_row_deletable(self, row, **kwargs):
"""
Check whether deleting the given row should be allowed.
The logic for this depends on the receiving workflow.
"""
batch = row.batch
# logic below is only concerned with *receiving* - so just use
# normal logic for other modes
if batch.mode != self.enum.PURCHASE_BATCH_MODE_RECEIVING:
return super().is_row_deletable(row, **kwargs)
# RECEIVING LOGIC...
# can always delete rows from truck dump parent
if batch.is_truck_dump_parent():
return True
# can always delete rows from truck dump child
if batch.is_truck_dump_child():
return True
# okay, normal receiving batch..
# when the order quantities are known, this implies the
# item came from invoice or PO, so should not be deleted
if batch.order_quantities_known:
# but even for this kind of batch, some rows may have
# been added manually, and those can be deleted. we
# just check for empty order quantities to determine.
if not self.get_units_ordered(row):
return True
return False
# allow delete if receiving from scratch
return True
[docs]
def remove_row(self, row):
"""
Overrides the default logic as follows:
In all cases, the row is deleted outright from the batch, instead of
simply marking its ``removed`` flag. Then
:meth:`refresh_batch_status()` is invoked.
However, *before* those things happen, we may do some other steps based
on the batch mode:
*Ordering Mode*
If the row has a
:attr:`~rattail.db.model.batch.purchase.PurchaseBatchRow.po_total_calculated`
amount, then the batch's
:attr:`~rattail.db.model.batch.purchase.PurchaseBatch.po_total_calculated`
is decreased by that amount.
*Receiving Mode*
If the row has a
:attr:`~rattail.db.model.batch.purchase.PurchaseBatchRow.invoice_total_calculated`
amount, then the batch's
:attr:`~rattail.db.model.batch.purchase.PurchaseBatch.invoice_total_calculated`
is decreased by that amount.
"""
session = self.app.get_session(row)
batch = row.batch
if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
if row.po_total_calculated:
batch.po_total_calculated -= row.po_total_calculated
elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
if row.invoice_total_calculated:
batch.invoice_total_calculated -= row.invoice_total_calculated
session.delete(row)
session.flush()
self.refresh_batch_status(batch)
def get_unit_cost(self, product, vendor):
"""
Must return the PO unit cost for the given product, from the given vendor.
"""
cost = product.cost_for_vendor(vendor) or product.cost
if cost:
return cost.unit_cost
def get_units(self, cases, units, case_quantity):
"""
Calculate the full unit count, per the given info.
This merely "converts" the given cases to units, and adds the
given units.
For example::
handler.get_units(1, 2, 12)
# => 14
handler.get_units(None, 3, 6)
# => 3
handler.get_units(2, None, None)
# => 2
:param cases: Number of cases, or ``None``.
:param units: Number of units, or ``None``.
:param case_quantity: Number of units in a case; if empty then
1 is assumed.
:returns: Total number of units.
"""
case_quantity = case_quantity or 1
return (units or 0) + case_quantity * (cases or 0)
def get_units_ordered(self, row, case_quantity=None):
case_quantity = case_quantity or row.case_quantity or 1
return self.get_units(row.cases_ordered, row.units_ordered, case_quantity)
def get_units_shipped(self, row, case_quantity=None):
case_quantity = case_quantity or row.case_quantity or 1
return self.get_units(row.cases_shipped, row.units_shipped, case_quantity)
def get_units_received(self, row, case_quantity=None):
case_quantity = case_quantity or row.case_quantity or 1
return self.get_units(row.cases_received, row.units_received, case_quantity)
def get_units_damaged(self, row, case_quantity=None):
case_quantity = case_quantity or row.case_quantity or 1
return self.get_units(row.cases_damaged, row.units_damaged, case_quantity)
def get_units_expired(self, row, case_quantity=None):
case_quantity = case_quantity or row.case_quantity or 1
return self.get_units(row.cases_expired, row.units_expired, case_quantity)
def get_units_confirmed(self, row, case_quantity=None):
received = self.get_units_received(row, case_quantity=case_quantity)
damaged = self.get_units_damaged(row, case_quantity=case_quantity)
expired = self.get_units_expired(row, case_quantity=case_quantity)
return received + damaged + expired
def get_units_mispick(self, row, case_quantity=None):
case_quantity = case_quantity or row.case_quantity or 1
return self.get_units(row.cases_mispick, row.units_mispick, case_quantity)
def get_units_missing(self, row, case_quantity=None):
case_quantity = case_quantity or row.case_quantity or 1
return self.get_units(row.cases_missing, row.units_missing, case_quantity)
def get_unconfirmed_counts(self, row):
"""
Return a 2-tuple with the number of cases and units which were
ordered/shipped but not yet confirmed during receiving.
This just provides a "what's left" tally for the row, which
informs the default options when receiving it.
"""
cases = ((row.cases_shipped or 0)
- (row.cases_received or 0)
- (row.cases_damaged or 0)
- (row.cases_expired or 0)
- (row.cases_mispick or 0)
- (row.cases_missing or 0))
units = ((row.units_shipped or 0)
- (row.units_received or 0)
- (row.units_damaged or 0)
- (row.units_expired or 0)
- (row.units_mispick or 0)
- (row.units_missing or 0))
return cases, units
def get_units_accounted_for(self, row, case_quantity=None):
confirmed = self.get_units_confirmed(row, case_quantity=case_quantity)
mispick = self.get_units_mispick(row, case_quantity=case_quantity)
missing = self.get_units_missing(row, case_quantity=case_quantity)
return confirmed + mispick + missing
def get_units_shorted(self, obj, case_quantity=None):
case_quantity = case_quantity or obj.case_quantity or 1
if hasattr(obj, 'cases_shorted'):
# obj is really a credit
return self.get_units(obj.cases_shorted, obj.units_shorted, case_quantity)
else:
# obj is a row, so sum the credits
return sum([self.get_units(credit.cases_shorted, credit.units_shorted, case_quantity)
for credit in obj.credits])
def get_units_claimed(self, row, case_quantity=None):
"""
Returns the total number of units which are "claimed" by child rows,
for the given truck dump parent row.
"""
claimed = 0
for claim in row.claims:
# prefer child row's notion of case quantity, over parent row
case_qty = case_quantity or claim.claiming_row.case_quantity or row.case_quantity
claimed += self.get_units_confirmed(claim, case_quantity=case_qty)
return claimed
def get_units_claimed_received(self, row, case_quantity=None):
return sum([self.get_units_received(claim, case_quantity=row.case_quantity)
for claim in row.claims])
def get_units_claimed_damaged(self, row, case_quantity=None):
return sum([self.get_units_damaged(claim, case_quantity=row.case_quantity)
for claim in row.claims])
def get_units_claimed_expired(self, row, case_quantity=None):
return sum([self.get_units_expired(claim, case_quantity=row.case_quantity)
for claim in row.claims])
def get_units_available(self, row, case_quantity=None):
confirmed = self.get_units_confirmed(row, case_quantity=case_quantity)
claimed = self.get_units_claimed(row, case_quantity=case_quantity)
return confirmed - claimed
def can_auto_receive(self, batch, **kwargs):
"""
Returns boolean indicating whether the given batch is eligible
for the "auto receive" function,
i.e. :meth:`auto_receive_all_items()`.
"""
# can't auto-receive if already executed
if batch.executed:
return False
# don't allow auto-receive for "complete" batches
if batch.complete:
return False
# special logic for truck dump
if batch.is_truck_dump_related():
# auto-receive not allowed for truck dump child
if not batch.is_truck_dump_parent():
return False
# auto-receive not allowed for truck dump parent, unless
# child batches are added first
if not batch.truck_dump_children_first:
return False
# only auto-receive once per batch
if batch.get_param('auto_received'):
return False
return True
def auto_receive_all_items(self, batch, progress=None, **kwargs):
"""
Automatically "receive" all items for the given batch.
This only makes sense in the context of a receiving batch.
For each row, calculate the difference between the "shipped"
and "received" quantities. Then adjust "received" by that
difference, so it matches "shipped" quantity.
"""
def receive(row, i):
# only do this for rows which have product identified
if row.product:
# auto-receive whatever is left
cases, units = self.calculate_pending(row, unconfirmed='shipped')
if cases:
self.receive_row(row, mode='received', cases=cases)
if units:
self.receive_row(row, mode='received', units=units)
self.progress_loop(receive, batch.active_rows(), progress,
message="Marking all items as \"received\"")
batch.set_param('auto_received', True)
self.refresh(batch, progress=progress)
# nb. this is needed for `rattail auto-receive` cmd
return True
def confirm_all_receiving_costs(self, batch, progress=None, **kwargs):
"""
Automatically confirm all catalog and invoice costs for the
given batch.
This only makes sense in the context of a receiving batch.
"""
def confirm(row, i):
if row.catalog_unit_cost is not None and not row.catalog_cost_confirmed:
row.catalog_cost_confirmed = True
if row.invoice_unit_cost is not None and not row.invoice_cost_confirmed:
row.invoice_cost_confirmed = True
self.progress_loop(confirm, batch.active_rows(), progress,
message="Confirming all receiving costs")
batch.set_param('confirmed_all_costs', True)
self.refresh(batch, progress=progress)
[docs]
def update_order_counts(self, purchase, progress=None):
"""
Update the "on order" counts for all items on the given purchase.
Obviously this assumes that the purchase was just "ordered" from the
vendor.
"""
session = self.app.get_session(purchase)
model = self.model
def update(item, i):
if item.product:
inventory = item.product.inventory
if not inventory:
inventory = model.ProductInventory(product=item.product)
session.add(inventory)
inventory.on_order = (inventory.on_order or 0) + (item.units_ordered or 0) + (
(item.cases_ordered or 0) * (item.case_quantity or 1))
self.progress_loop(update, purchase.items, progress,
message="Updating inventory counts")
def update_receiving_inventory(self, purchase, consume_on_order=True, progress=None):
"""
Increase on-hand counts and (by default) decrease on-order
counts, as part of final "receiving" for the given purchase.
"""
session = self.app.get_session(purchase)
model = self.model
def update(item, i):
if item.product:
inventory = item.product.inventory
if not inventory:
inventory = model.ProductInventory(product=item.product)
session.add(inventory)
count = (item.units_received or 0) + (item.cases_received or 0) * (item.case_quantity or 1)
if count:
if consume_on_order:
if (inventory.on_order or 0) < count:
log.error("received %s units for %s but it only had %s on order",
count, item.product, inventory.on_order or 0)
inventory.on_order = 0
else:
inventory.on_order -= count
inventory.on_hand = (inventory.on_hand or 0) + count
self.progress_loop(update, purchase.items, progress,
message="Updating inventory counts")
[docs]
def why_not_execute(self, batch, **kwargs):
"""
This makes the following checks, but only for "receiving" batches:
If it is a truck dump parent batch, then its
:attr:`~rattail.db.model.batch.purchase.PurchaseBatch.truck_dump_status`
must be "claimed" or else execution is not allowed. This is for
simplicity, to require the truck dump parent and child batches to be
"fully" on the same page, and nothing accidentally left behind.
If it is a truck dump child, then execution is "never" allowed. (At
least, that's what we want to tell the user, so they're forced to
execute the parent batch. Technically the handler *does* know how to
execute a child batch; see :meth:`execute()` and
:meth:`execute_truck_dump()` for more info.)
"""
# not all receiving batches are executable
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
if batch.is_truck_dump_parent() and batch.truck_dump_status != batch.STATUS_TRUCKDUMP_CLAIMED:
return ("Can't execute a Truck Dump (parent) batch until "
"it has been fully claimed by children")
if batch.is_truck_dump_child():
return ("Can't directly execute batch which is child of a truck dump "
"(must execute truck dump instead)")
def describe_execution(self, batch, html_allowed=False, **kwargs):
if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
return ("A new \"Purchase\" is created, from the contents of this "
"batch. Also, the \"On Order\" counts for each product "
"will be updated accordingly. You will be redirected to "
"the new Purchase.")
[docs]
def execute(self, batch, user, progress=None):
"""
Performs execution logic for the batch, as follows:
*Ordering Mode*
A new purchase is created via :meth:`make_purchase()`, and then
:meth:`update_order_counts()` is invoked to keep the numbers straight.
The new purchase is then returned.
*Receiving Mode*
If the batch does not yet have a receiving date, that is set to the
current date.
If the batch is a truck dump parent, then :meth:`execute_truck_dump()`
is invoked.
Otherwise, either a "traditional" receiving, or truck dump child batch
is assumed, and :meth:`receive_purchase()` is invoked.
*Costing Mode*
This assumes an original purchase is attached to the batch. The
invoice date for that purchase is updated according to the value in the
batch, and the status for the purchase is set to "costed". The
purchase object is returned.
.. note::
Execution for a "costing" batch has yet to be fully implemented.
"""
session = self.app.get_session(batch)
if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
purchase = self.make_purchase(batch, user, progress=progress)
self.update_order_counts(purchase, progress=progress)
return purchase
elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
if not batch.date_received:
batch.date_received = self.app.today()
if self.allow_truck_dump_receiving() and batch.is_truck_dump_parent():
self.execute_truck_dump(batch, user, progress=progress)
return True
else:
with session.no_autoflush:
return self.receive_purchase(batch, progress=progress)
elif batch.mode == self.enum.PURCHASE_BATCH_MODE_COSTING:
# TODO: finish this...
# with session.no_autoflush:
# return self.cost_purchase(batch, progress=progress)
purchase = batch.purchase
purchase.invoice_date = batch.invoice_date
purchase.status = self.enum.PURCHASE_STATUS_COSTED
return purchase
assert False
[docs]
def execute_truck_dump(self, batch, user, progress=None):
"""
Fully executes a truck dump parent batch. In reality nothing is done
with the data from the parent; instead, each truck dump child batch is
simply executed in sequence.
"""
now = self.app.make_utc()
for child in batch.truck_dump_children:
if not self.execute(child, user, progress=progress):
raise RuntimeError("Failed to execute child batch: {}".format(child))
child.executed = now
child.executed_by = user
def make_credits(self, batch, progress=None):
"""
Make all final credit records for the given batch. Meant to be called
as part of the batch execution process.
"""
model = self.model
session = self.app.get_session(batch)
mapper = orm.class_mapper(model.PurchaseBatchCredit)
date_received = batch.date_received
if not date_received:
date_received = self.app.today()
auto_missing_credits = self.auto_missing_credits()
def add_credits(row, i):
# basically "clone" existing credits from batch row
for batch_credit in row.credits:
credit = model.PurchaseCredit()
for prop in mapper.iterate_properties:
if isinstance(prop, orm.ColumnProperty) and hasattr(credit, prop.key):
setattr(credit, prop.key, getattr(batch_credit, prop.key))
credit.status = self.enum.PURCHASE_CREDIT_STATUS_NEW
if not credit.date_received:
credit.date_received = date_received
session.add(credit)
# maybe create "missing" credits for items not accounted for
if auto_missing_credits and row.product_uuid and not row.out_of_stock:
cases, units = self.calculate_pending(row)
if cases or units:
credit = model.PurchaseCredit()
self.populate_credit(credit, row)
credit.credit_type = 'missing'
credit.cases_shorted = cases or None
credit.units_shorted = units or None
# calculate credit total
# TODO: should this leverage case cost if present?
credit_units = self.get_units(credit.cases_shorted,
credit.units_shorted,
credit.case_quantity)
credit.credit_total = credit_units * (credit.invoice_unit_cost or 0)
credit.status = self.enum.PURCHASE_CREDIT_STATUS_NEW
if not credit.date_received:
credit.date_received = date_received
session.add(credit)
return self.progress_loop(add_credits, batch.active_rows(), progress,
message="Creating purchase credits")
def calculate_pending(self, row, unconfirmed='ordered'):
"""
Calculate the "pending" case and unit amounts for the given row. This
essentially is the difference between "ordered" and "confirmed",
e.g. if a row has ``cases_ordered == 2`` and ``cases_received == 1``
then it is considered to have "1 pending case".
Note that this method *is* aware of the "split cases" problem, and will
adjust the pending amounts if any split cases are detected.
:param row: The row for which pending amounts should be calculated.
:param unconfirmed: The type of quantities which are to be
considered as "unconfirmed" for the sake of pending
calculation. The default for this is ``'ordered'``
although it probably should be ``'shipped'`` instead.
:returns: A 2-tuple of ``(cases, units)`` pending amounts.
"""
# calculate remaining cases, units
cases_confirmed = ((row.cases_received or 0)
+ (row.cases_damaged or 0)
+ (row.cases_expired or 0))
cases_unconfirmed = getattr(row, 'cases_{}'.format(unconfirmed)) or 0
cases_pending = cases_unconfirmed - cases_confirmed
units_confirmed = ((row.units_received or 0)
+ (row.units_damaged or 0)
+ (row.units_expired or 0))
units_unconfirmed = getattr(row, 'units_{}'.format(unconfirmed)) or 0
units_pending = units_unconfirmed - units_confirmed
# maybe account for split cases
if units_pending < 0:
# TODO: should this check involve case_quantity? or just cases_pending?
if row.case_quantity == 1 and cases_pending == 0:
# in this scenario, we have "confirmed" more units than were
# "ordered" but it is not the result of a split case, so will
# consider 0 units pending
units_pending = 0
else:
split_cases = -units_pending // row.case_quantity
if -units_pending % row.case_quantity:
split_cases += 1
if split_cases > cases_pending:
raise ValueError("too many cases have been split?")
cases_pending -= split_cases
units_pending += split_cases * row.case_quantity
return cases_pending, units_pending
[docs]
def make_purchase(self, batch, user,
ordered_only=False, progress=None):
"""
Effectively clones the given batch, creating a new Purchase in the
Rattail system.
:param ordered_only: If true, only include rows which have an
effective "ordered" quantity. If false (the default) then
*all* rows will be cloned regardless of ordered quantity.
"""
model = self.model
session = self.app.get_session(batch)
purchase = model.Purchase()
# TODO: should be smarter and only copy certain fields here
skip_fields = [
'id',
'date_received',
'po_total',
]
for prop in orm.object_mapper(batch).iterate_properties:
if prop.key in skip_fields:
continue
if hasattr(purchase, prop.key):
setattr(purchase, prop.key, getattr(batch, prop.key))
purchase.po_total = batch.po_total_calculated
def clone(row, i):
if ordered_only and not self.get_units_ordered(row):
return
item = model.PurchaseItem()
# TODO: should be smarter and only copy certain fields here
for prop in orm.object_mapper(row).iterate_properties:
if hasattr(item, prop.key):
setattr(item, prop.key, getattr(row, prop.key))
item.po_total = row.po_total_calculated
purchase.items.append(item)
with session.no_autoflush:
self.progress_loop(clone, batch.active_rows(), progress,
message="Creating purchase items")
purchase.created = self.app.make_utc()
purchase.created_by = user
purchase.status = self.enum.PURCHASE_STATUS_ORDERED
session.add(purchase)
batch.purchase = purchase
return purchase
[docs]
def receive_purchase(self, batch, progress=None):
"""
Update the purchase for the given batch, to indicate received status.
"""
model = self.model
session = self.app.get_session(batch)
purchase = batch.purchase
if not purchase:
batch.purchase = purchase = model.Purchase()
# TODO: should be smarter and only copy certain fields here
skip_fields = [
'uuid',
'date_received',
]
with session.no_autoflush:
for prop in orm.object_mapper(batch).iterate_properties:
if prop.key in skip_fields:
continue
if hasattr(purchase, prop.key):
setattr(purchase, prop.key, getattr(batch, prop.key))
purchase.invoice_number = batch.invoice_number
purchase.invoice_date = batch.invoice_date
purchase.invoice_total = batch.invoice_total_calculated
purchase.date_received = batch.date_received
# determine which fields we'll copy when creating new purchase item
copy_fields = []
for prop in orm.class_mapper(model.PurchaseItem).iterate_properties:
if hasattr(model.PurchaseBatchRow, prop.key):
copy_fields.append(prop.key)
def update(row, i):
item = row.item
if not item:
row.item = item = model.PurchaseItem()
for field in copy_fields:
setattr(item, field, getattr(row, field))
purchase.items.append(item)
item.cases_received = row.cases_received
item.units_received = row.units_received
item.cases_damaged = row.cases_damaged
item.units_damaged = row.units_damaged
item.cases_expired = row.cases_expired
item.units_expired = row.units_expired
item.invoice_line_number = row.invoice_line_number
item.invoice_case_cost = row.invoice_case_cost
item.invoice_unit_cost = row.invoice_unit_cost
item.invoice_total = row.invoice_total_calculated
with session.no_autoflush:
self.progress_loop(update, batch.active_rows(), progress,
message="Updating purchase line items")
purchase.status = self.enum.PURCHASE_STATUS_RECEIVED
return purchase
def clone_row(self, oldrow):
newrow = super().clone_row(oldrow)
model = self.model
# nb. all (er, most) columns are copied by default, but we
# don't want the 'confirmed' fields to be copied..so must
# reset those here
newrow.catalog_cost_confirmed = None
newrow.invoice_cost_confirmed = None
# also clone any "credits" for the row
for oldcredit in oldrow.credits:
newcredit = model.PurchaseBatchCredit()
self.copy_credit_attributes(oldcredit, newcredit)
newrow.credits.append(newcredit)
return newrow
def copy_credit_attributes(self, source_credit, target_credit):
model = self.model
mapper = orm.class_mapper(model.PurchaseBatchCredit)
for prop in mapper.iterate_properties:
if prop.key not in ('uuid', 'row_uuid'):
if isinstance(prop, orm.ColumnProperty):
setattr(target_credit, prop.key, getattr(source_credit, prop.key))