
this logic needs to be customized for a client, so breaking it out from the tailbone view code
997 lines
44 KiB
Python
997 lines
44 KiB
Python
# -*- coding: utf-8; -*-
|
|
################################################################################
|
|
#
|
|
# Rattail -- Retail Software Framework
|
|
# Copyright © 2010-2018 Lance Edgar
|
|
#
|
|
# This file is part of Rattail.
|
|
#
|
|
# Rattail is free software: you can redistribute it and/or modify it under the
|
|
# terms of the GNU General Public License as published by the Free Software
|
|
# Foundation, either version 3 of the License, or (at your option) any later
|
|
# version.
|
|
#
|
|
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License along with
|
|
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
################################################################################
|
|
"""
|
|
Handler for purchase order batches
|
|
"""
|
|
|
|
from __future__ import unicode_literals, absolute_import
|
|
|
|
import logging
|
|
|
|
import six
|
|
from sqlalchemy import orm
|
|
|
|
from rattail.db import model, api
|
|
from rattail.gpc import GPC
|
|
from rattail.batch import BatchHandler
|
|
from rattail.time import make_utc
|
|
from rattail.vendors.invoices import require_invoice_parser
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class PurchaseBatchHandler(BatchHandler):
|
|
"""
|
|
Handler for purchase order batches.
|
|
"""
|
|
batch_model_class = model.PurchaseBatch
|
|
|
|
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=True)
|
|
|
|
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=True)
|
|
|
|
def should_populate(self, batch):
|
|
# TODO: this probably should change soon, for now this works..
|
|
return batch.purchase and batch.mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
|
|
self.enum.PURCHASE_BATCH_MODE_COSTING)
|
|
|
|
def populate(self, batch, progress=None):
|
|
assert batch.purchase and batch.mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
|
|
self.enum.PURCHASE_BATCH_MODE_COSTING)
|
|
batch.order_quantities_known = True
|
|
|
|
def append(item, i):
|
|
row = model.PurchaseBatchRow()
|
|
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
|
|
row.cases_ordered = item.cases_ordered
|
|
row.units_ordered = item.units_ordered
|
|
row.cases_received = item.cases_received
|
|
row.units_received = item.units_received
|
|
row.po_unit_cost = item.po_unit_cost
|
|
row.po_total = item.po_total
|
|
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, batch.purchase.items, progress,
|
|
message="Adding initial rows to batch")
|
|
|
|
# TODO: should(n't) this be handled elsewhere?
|
|
session = orm.object_session(batch)
|
|
session.flush()
|
|
self.refresh_batch_status(batch)
|
|
|
|
def populate_from_truck_dump_invoice(self, batch, progress=None):
|
|
parser = require_invoice_parser(batch.invoice_parser_key)
|
|
|
|
session = orm.object_session(batch)
|
|
parser.session = session
|
|
|
|
parser.vendor = api.get_vendor(session, parser.vendor_key)
|
|
if parser.vendor is not batch.vendor:
|
|
raise RuntimeError("Parser is for vendor '{}' but batch is for: {}".format(
|
|
parser.vendor_key, batch.vendor))
|
|
|
|
path = batch.filepath(self.config, batch.invoice_file)
|
|
batch.invoice_date = parser.parse_invoice_date(path)
|
|
batch.order_quantities_known = True
|
|
|
|
def append(invoice_row, i):
|
|
row = model.PurchaseBatchRow()
|
|
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.case_quantity = invoice_row.case_quantity
|
|
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.invoice_unit_cost = invoice_row.unit_cost
|
|
row.invoice_total = invoice_row.total_cost
|
|
row.invoice_case_cost = invoice_row.case_cost
|
|
self.add_row(batch, row)
|
|
|
|
self.progress_loop(append, list(parser.parse_rows(path)), progress,
|
|
message="Adding initial rows to batch")
|
|
|
|
self.make_truck_dump_claims_for_child_batch(batch, progress=progress)
|
|
self.refresh_batch_status(batch.truck_dump_batch)
|
|
|
|
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 = orm.object_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")
|
|
|
|
# first pass looks only for exact product and quantity match
|
|
def first_pass(child_product, i):
|
|
uuid, child_product_rows = child_product
|
|
if uuid not in truck_dump_by_product:
|
|
return
|
|
|
|
# inspect truck dump to find exact match on child 'ordered' count
|
|
index = 0
|
|
truck_dump_product_rows = truck_dump_by_product[uuid]
|
|
for truck_dump_row in list(truck_dump_product_rows):
|
|
matched_child_rows = None
|
|
|
|
# Note: A possibility we do not address here is one where the
|
|
# truck dump quantity would match against a certain aggregation
|
|
# of child rows but not *all* child rows. E.g. if the child
|
|
# contained 3 rows but only 2 of them should (combined) match
|
|
# the truck dump row. As of this writing this is a
|
|
# hypothetical edge case but it will probably happen at some
|
|
# point. We can maybe assess the situation then.
|
|
|
|
available = self.get_units_available(truck_dump_row)
|
|
|
|
# first look at each child row individually (first match wins)
|
|
for child_row in child_product_rows:
|
|
ordered = self.get_units_ordered(child_row)
|
|
if ordered == available:
|
|
matched_child_rows = [child_row]
|
|
break
|
|
|
|
# if no "exact" match, fall back to arbitrary matching where we can
|
|
if not matched_child_rows:
|
|
matched_child_rows = child_product_rows
|
|
|
|
# did we find a match?
|
|
claims = False
|
|
if matched_child_rows:
|
|
|
|
# make some truck dump claim(s)
|
|
claims = self.make_truck_dump_claims(truck_dump_row, matched_child_rows)
|
|
|
|
if claims:
|
|
|
|
# remove truck dump row from working set
|
|
truck_dump_product_rows.pop(index)
|
|
if not truck_dump_product_rows:
|
|
del truck_dump_by_product[uuid]
|
|
|
|
# filter working set of child batch rows, removing any
|
|
# which contributed to the match
|
|
remaining = []
|
|
for child_row in child_product_rows:
|
|
matched = False
|
|
for match_row in matched_child_rows:
|
|
if match_row is child_row:
|
|
matched = True
|
|
break
|
|
if not matched:
|
|
remaining.append(child_row)
|
|
child_product_rows = remaining
|
|
|
|
# if no match, shift index so future list pops work
|
|
else:
|
|
index += 1
|
|
|
|
self.progress_loop(first_pass, list(child_by_product.items()), progress,
|
|
message="Claiming parent rows for child") # (pass #1)
|
|
|
|
# TODO: second pass to look for inexact and UPC matches, yes?
|
|
|
|
def make_truck_dump_claims(self, truck_dump_row, child_rows):
|
|
|
|
avail_received = self.get_units_received(truck_dump_row) - self.get_units_claimed_received(truck_dump_row)
|
|
avail_damaged = self.get_units_damaged(truck_dump_row) - self.get_units_claimed_damaged(truck_dump_row)
|
|
avail_expired = self.get_units_expired(truck_dump_row) - self.get_units_claimed_expired(truck_dump_row)
|
|
|
|
claims = []
|
|
for child_row in child_rows:
|
|
|
|
ordered = self.get_units_ordered(child_row)
|
|
|
|
# TODO: must look into why this can happen...
|
|
# assert ordered, "child row has no ordered count: {}".format(child_row)
|
|
if not ordered:
|
|
continue
|
|
|
|
# TODO: should we ever use case quantity from truck dump instead?
|
|
case_quantity = child_row.case_quantity
|
|
|
|
claim = model.PurchaseBatchRowClaim()
|
|
claim.claiming_row = child_row
|
|
truck_dump_row.claims.append(claim)
|
|
|
|
# if "received" can account for all we ordered, use only that
|
|
if ordered <= avail_received:
|
|
child_row.cases_received = claim.cases_received = child_row.cases_ordered
|
|
child_row.units_received = claim.units_received = child_row.units_ordered
|
|
self.refresh_row(child_row)
|
|
|
|
# if "damaged" can account for all we ordered, use only that
|
|
elif ordered <= avail_damaged:
|
|
|
|
matched = False
|
|
for credit in truck_dump_row.credits:
|
|
if credit.credit_type == 'damaged':
|
|
shorted = self.get_units_shorted(credit)
|
|
if shorted == ordered:
|
|
child_row.cases_damaged = claim.cases_damaged = credit.cases_shorted
|
|
child_row.units_damaged = claim.units_damaged = credit.units_shorted
|
|
self.clone_truck_dump_credit(credit, child_row)
|
|
self.refresh_row(child_row)
|
|
matched = True
|
|
break
|
|
|
|
assert matched, "could not find matching 'damaged' credit to clone for {}".format(truck_dump_row)
|
|
|
|
# if "expired" can account for all we ordered, use only that
|
|
elif ordered <= avail_expired:
|
|
|
|
matched = False
|
|
for credit in truck_dump_row.credits:
|
|
if credit.credit_type == 'expired':
|
|
shorted = self.get_units_shorted(credit)
|
|
if shorted == ordered:
|
|
child_row.cases_expired = claim.cases_expired = credit.cases_shorted
|
|
child_row.units_expired = claim.units_expired = credit.units_shorted
|
|
self.clone_truck_dump_credit(credit, child_row)
|
|
self.refresh_row(child_row)
|
|
matched = True
|
|
break
|
|
|
|
assert matched, "could not find matching 'expired' credit to clone for {}".format(truck_dump_row)
|
|
|
|
else: # things are a bit trickier in this scenario
|
|
|
|
if ordered and avail_received:
|
|
cases, units = self.calc_best_fit(avail_received, case_quantity)
|
|
total = self.get_units(cases, units, case_quantity)
|
|
assert total == avail_received, "total units doesn't match avail_received for {}".format(truck_dump_row)
|
|
child_row.cases_received = claim.cases_received = cases or None
|
|
child_row.units_received = claim.units_received = units or None
|
|
self.refresh_row(child_row)
|
|
avail_received -= total
|
|
ordered -= total
|
|
|
|
if ordered and avail_damaged:
|
|
assert ordered >= avail_damaged
|
|
possible_credits = [credit for credit in truck_dump_row.credits
|
|
if credit.credit_type == 'damaged']
|
|
possible_shorted = sum([self.get_units_shorted(credit)
|
|
for credit in possible_credits])
|
|
if possible_shorted == avail_damaged:
|
|
cases = sum([credit.cases_shorted or 0
|
|
for credit in possible_credits])
|
|
units = sum([credit.units_shorted or 0
|
|
for credit in possible_credits])
|
|
child_row.cases_damaged = claim.cases_damaged = cases or None
|
|
child_row.units_damaged = claim.units_damaged = units or None
|
|
for credit in possible_credits:
|
|
self.clone_truck_dump_credit(credit, child_row)
|
|
self.refresh_row(child_row)
|
|
ordered -= avail_damaged
|
|
avail_damaged = 0
|
|
else:
|
|
raise NotImplementedError
|
|
|
|
if ordered and avail_expired:
|
|
possible_credits = [credit for credit in truck_dump_row.credits
|
|
if credit.credit_type == 'expired']
|
|
possible_shorted = sum([self.get_units_shorted(credit)
|
|
for credit in possible_credits])
|
|
if possible_shorted != avail_expired:
|
|
raise NotImplementedError("need logic to handle 'expired' credit quantity mismatch?")
|
|
cases, units = self.calc_best_fit(avail_expired, case_quantity)
|
|
total = self.get_units(cases, units, case_quantity)
|
|
if total != avail_expired:
|
|
raise ValueError("total units doesn't match avail_expired for {}".format(truck_dump_row))
|
|
child_row.cases_expired = claim.cases_expired = cases or None
|
|
child_row.units_expired = claim.units_expired = units or None
|
|
for credit in possible_credits:
|
|
self.clone_truck_dump_credit(credit, child_row)
|
|
self.refresh_row(child_row)
|
|
avail_expired -= total
|
|
ordered -= total
|
|
|
|
self.refresh_row(truck_dump_row)
|
|
claims.append(claim)
|
|
|
|
return claims
|
|
|
|
def clone_truck_dump_credit(self, truck_dump_credit, child_row):
|
|
"""
|
|
Clone a credit record from a truck dump, onto the given child row.
|
|
"""
|
|
child_batch = child_row.batch
|
|
child_credit = model.PurchaseBatchCredit()
|
|
self.copy_credit_attributes(truck_dump_credit, child_credit)
|
|
child_credit.date_ordered = child_batch.date_ordered
|
|
child_credit.date_shipped = child_batch.date_shipped
|
|
child_credit.date_received = child_batch.date_received
|
|
child_credit.invoice_date = child_batch.invoice_date
|
|
child_credit.invoice_number = child_batch.invoice_number
|
|
child_credit.invoice_line_number = child_row.invoice_line_number
|
|
child_credit.invoice_case_cost = child_row.invoice_case_cost
|
|
child_credit.invoice_unit_cost = child_row.invoice_unit_cost
|
|
child_credit.invoice_total = child_row.invoice_total
|
|
child_row.credits.append(child_credit)
|
|
return child_credit
|
|
|
|
# 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
|
|
|
|
def refresh(self, batch, progress=None):
|
|
|
|
# reset PO total for ordering batch
|
|
if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
|
|
batch.po_total = 0
|
|
|
|
# reset invoice total for receiving and costing batch
|
|
elif batch.mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
|
|
self.enum.PURCHASE_BATCH_MODE_COSTING):
|
|
batch.invoice_total = 0
|
|
|
|
# refresh all rows etc. per usual
|
|
result = super(PurchaseBatchHandler, self).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 = orm.object_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
|
|
for child in batch.truck_dump_children:
|
|
child_rows = [child_row for child_row in child.active_rows()
|
|
if child_row.product_uuid == row.product_uuid]
|
|
self.make_truck_dump_claims(row, child_rows)
|
|
if row.status_code not in (row.STATUS_TRUCKDUMP_UNCLAIMED,
|
|
row.STATUS_TRUCKDUMP_PARTCLAIMED):
|
|
break
|
|
|
|
# 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
|
|
|
|
def refresh_batch_status(self, batch):
|
|
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
|
|
|
|
# truck dump parent batch status should reflect how much is (un)claimed
|
|
elif batch.is_truck_dump_parent():
|
|
|
|
# empty batch is considered "ok"
|
|
if not rows:
|
|
batch.status_code = batch.STATUS_OK
|
|
|
|
# batch is "claimed" only if all rows are
|
|
elif all([row.status_code == row.STATUS_TRUCKDUMP_CLAIMED for row in rows]):
|
|
batch.status_code = batch.STATUS_TRUCKDUMP_CLAIMED
|
|
|
|
# otherwise just call it "unclaimed"
|
|
else:
|
|
batch.status_code = batch.STATUS_TRUCKDUMP_UNCLAIMED
|
|
|
|
# for now anything else is considered ok
|
|
else:
|
|
batch.status_code = batch.STATUS_OK
|
|
|
|
def locate_product_for_entry(self, session, entry, lookup_by_code=True):
|
|
"""
|
|
Try to locate the product represented by the given "entry" - which is
|
|
assumed to be a "raw" string, e.g. as obtained from scanner or other
|
|
user input, or from a vendor-supplied spreadsheet etc. (In other words
|
|
this method is not told "what" sort of entry it is being given.)
|
|
|
|
:param lookup_by_code: If set to ``False``, then the method will
|
|
attempt a lookup *only* on the product key field (either ``upc`` or
|
|
``item_id`` depending on config). If set to ``True`` (the default)
|
|
then the method will also attempt a lookup in the ``ProductCode``
|
|
table, aka. alternate codes.
|
|
"""
|
|
# try to locate product by uuid before other, more specific key
|
|
product = session.query(model.Product).get(entry)
|
|
if product:
|
|
return product
|
|
|
|
product_key = self.config.product_key()
|
|
if product_key == 'upc':
|
|
|
|
# we first assume the user entry *does* include check digit
|
|
provided = GPC(entry, calc_check_digit=False)
|
|
product = api.get_product_by_upc(session, provided)
|
|
if product:
|
|
return product
|
|
|
|
# but we can also calculate a check digit and try that
|
|
checked = GPC(entry, calc_check_digit='upc')
|
|
product = api.get_product_by_upc(session, checked)
|
|
if product:
|
|
return product
|
|
|
|
elif product_key == 'item_id':
|
|
|
|
# try to locate product by item_id
|
|
product = api.get_product_by_item_id(session, entry)
|
|
if product:
|
|
return product
|
|
|
|
# if we made it this far, lookup by product key failed.
|
|
|
|
# okay then, let's maybe attempt lookup by "alternate" code
|
|
if lookup_by_code:
|
|
product = api.get_product_by_code(session, entry)
|
|
if product:
|
|
return product
|
|
|
|
def locate_product(self, row):
|
|
"""
|
|
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.
|
|
"""
|
|
session = orm.object_session(row)
|
|
product_key = self.config.product_key()
|
|
|
|
if product_key == 'upc':
|
|
if row.upc:
|
|
product = api.get_product_by_upc(session, row.upc)
|
|
if product:
|
|
return product
|
|
|
|
elif product_key == 'item_id':
|
|
if row.item_id:
|
|
product = api.get_product_by_item_id(session, row.item_id)
|
|
if product:
|
|
return product
|
|
|
|
# product key didn't work, but vendor item code just might
|
|
if row.vendor_code:
|
|
product = api.get_product_by_vendor_code(session, row.vendor_code, vendor=row.batch.vendor)
|
|
if product:
|
|
return product
|
|
|
|
# before giving up, let's do a lookup on alt codes too
|
|
if row.item_entry:
|
|
product = api.get_product_by_code(session, row.item_entry)
|
|
if product:
|
|
return product
|
|
|
|
def refresh_row(self, row, initial=False):
|
|
"""
|
|
Refreshing a row will A) assume that ``row.product`` is already set to
|
|
a valid 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
|
|
product = row.product
|
|
if not product:
|
|
product = self.locate_product(row)
|
|
if not product:
|
|
# TODO: should we do more stuff here..?
|
|
row.status_code = row.STATUS_PRODUCT_NOT_FOUND
|
|
return
|
|
row.product = product
|
|
|
|
cost = row.product.cost_for_vendor(batch.vendor)
|
|
row.upc = product.upc
|
|
row.item_id = product.item_id
|
|
row.brand_name = six.text_type(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
|
|
|
|
case_quantity_differs = False
|
|
if cost and cost.case_size:
|
|
if not row.case_quantity:
|
|
row.case_quantity = cost.case_size
|
|
elif row.case_quantity != cost.case_size:
|
|
if batch.is_truck_dump_parent():
|
|
# always overwrite case quantity for truck dump, since its
|
|
# value should only come from master cost (right?)
|
|
row.case_quantity = cost.case_size
|
|
else:
|
|
case_quantity_differs = True
|
|
|
|
self.refresh_totals(row, cost, initial)
|
|
|
|
if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
|
|
row.status_code = row.STATUS_OK
|
|
|
|
elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
|
|
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):
|
|
row.status_code = row.STATUS_INCOMPLETE
|
|
else:
|
|
if batch.is_truck_dump_parent():
|
|
confirmed = self.get_units_confirmed(row)
|
|
claimed = self.get_units_claimed(row)
|
|
if claimed == confirmed:
|
|
row.status_code = row.STATUS_TRUCKDUMP_CLAIMED
|
|
elif not claimed:
|
|
row.status_code = row.STATUS_TRUCKDUMP_UNCLAIMED
|
|
elif claimed < confirmed:
|
|
row.status_code = row.STATUS_TRUCKDUMP_PARTCLAIMED
|
|
elif claimed > confirmed:
|
|
row.status_code = row.STATUS_TRUCKDUMP_OVERCLAIMED
|
|
else:
|
|
raise NotImplementedError
|
|
else: # not truck_dump
|
|
if case_quantity_differs:
|
|
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))
|
|
elif batch.order_quantities_known and (
|
|
self.get_units_ordered(row) != self.get_units_accounted_for(row)):
|
|
row.status_code = row.STATUS_ORDERED_RECEIVED_DIFFER
|
|
else:
|
|
row.status_code = row.STATUS_OK
|
|
|
|
else:
|
|
raise NotImplementedError("can't refresh row for batch of mode: {}".format(
|
|
self.enum.PURHASE_BATCH_MODE.get(batch.mode, "unknown ({})".format(batch.mode))))
|
|
|
|
def remove_row(self, row):
|
|
"""
|
|
When removing a row from purchase batch, (maybe) must also update some
|
|
totals for the batch.
|
|
"""
|
|
batch = row.batch
|
|
|
|
if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
|
|
if row.po_total:
|
|
batch.po_total -= row.po_total
|
|
|
|
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
|
|
if row.invoice_total:
|
|
batch.invoice_total -= row.invoice_total
|
|
|
|
super(PurchaseBatchHandler, self).remove_row(row)
|
|
|
|
def refresh_totals(self, row, cost, initial):
|
|
batch = row.batch
|
|
|
|
if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
|
|
row.po_unit_cost = self.get_unit_cost(row.product, batch.vendor)
|
|
if row.po_unit_cost:
|
|
row.po_total = row.po_unit_cost * self.get_units_ordered(row)
|
|
batch.po_total = (batch.po_total or 0) + row.po_total
|
|
else:
|
|
row.po_total = None
|
|
|
|
elif batch.mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
|
|
self.enum.PURCHASE_BATCH_MODE_COSTING):
|
|
row.invoice_unit_cost = (cost.unit_cost if cost else None) or row.po_unit_cost
|
|
if row.invoice_unit_cost:
|
|
row.invoice_total = row.invoice_unit_cost * self.get_units_accounted_for(row)
|
|
batch.invoice_total = (batch.invoice_total or 0) + row.invoice_total
|
|
else:
|
|
row.invoice_total = None
|
|
|
|
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):
|
|
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)
|
|
|
|
# TODO: we now have shipped quantities...should return sum of those instead?
|
|
def get_units_shipped(self, row, case_quantity=None):
|
|
case_quantity = case_quantity or row.case_quantity or 1
|
|
units_damaged = (row.units_damaged or 0) + case_quantity * (row.cases_damaged or 0)
|
|
units_expired = (row.units_expired or 0) + case_quantity * (row.cases_expired or 0)
|
|
return self.get_units_received(row) + units_damaged + units_expired
|
|
|
|
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_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)
|
|
return confirmed + mispick
|
|
|
|
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 update_order_counts(self, purchase, progress=None):
|
|
|
|
def update(item, i):
|
|
if item.product:
|
|
inventory = item.product.inventory or model.ProductInventory(product=item.product)
|
|
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):
|
|
|
|
def update(item, i):
|
|
if item.product:
|
|
inventory = item.product.inventory or model.ProductInventory(product=item.product)
|
|
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:
|
|
raise RuntimeError("Received {} units for {} but it only had {} on order".format(
|
|
count, item.product, inventory.on_order or 0))
|
|
inventory.on_order -= count
|
|
inventory.on_hand = (inventory.on_hand or 0) + count
|
|
|
|
self.progress_loop(update, purchase.items, progress,
|
|
message="Updating inventory counts")
|
|
|
|
def why_not_execute(self, batch):
|
|
"""
|
|
This method should return a string indicating the reason why the given
|
|
batch should not be considered executable. By default it returns
|
|
``None`` which means the batch *is* to be considered executable.
|
|
|
|
Note that it is assumed the batch has not already been executed, since
|
|
execution is globally prevented for such batches.
|
|
"""
|
|
# not all receiving batches are executable
|
|
if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
|
|
|
|
if batch.is_truck_dump_parent() and batch.status_code != 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 execute(self, batch, user, progress=None):
|
|
"""
|
|
Default behavior for executing a purchase batch will create a new
|
|
purchase, by invoking :meth:`make_purchase()`.
|
|
"""
|
|
session = orm.object_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 self.allow_truck_dump 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
|
|
|
|
def execute_truck_dump(self, batch, user, progress=None):
|
|
now = 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):
|
|
session = orm.object_session(batch)
|
|
mapper = orm.class_mapper(model.PurchaseBatchCredit)
|
|
|
|
def copy(row, i):
|
|
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
|
|
session.add(credit)
|
|
|
|
return self.progress_loop(copy, batch.active_rows(), progress,
|
|
message="Creating purchase credits")
|
|
|
|
def make_purchase(self, batch, user, progress=None):
|
|
"""
|
|
Effectively clones the given batch, creating a new Purchase in the
|
|
Rattail system.
|
|
"""
|
|
session = orm.object_session(batch)
|
|
purchase = model.Purchase()
|
|
|
|
# TODO: should be smarter and only copy certain fields here
|
|
skip_fields = [
|
|
'date_received',
|
|
]
|
|
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))
|
|
|
|
def clone(row, i):
|
|
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))
|
|
purchase.items.append(item)
|
|
|
|
with session.no_autoflush:
|
|
self.progress_loop(clone, batch.active_rows(), progress,
|
|
message="Creating purchase items")
|
|
|
|
purchase.created = make_utc()
|
|
purchase.created_by = user
|
|
purchase.status = self.enum.PURCHASE_STATUS_ORDERED
|
|
session.add(purchase)
|
|
batch.purchase = purchase
|
|
return purchase
|
|
|
|
def receive_purchase(self, batch, progress=None):
|
|
"""
|
|
Update the purchase for the given batch, to indicate received status.
|
|
"""
|
|
session = orm.object_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
|
|
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
|
|
|
|
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(PurchaseBatchHandler, self).clone_row(oldrow)
|
|
|
|
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):
|
|
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))
|