Add "most of" support for truck dump receiving
This commit is contained in:
parent
d3662fe55d
commit
225a0ba380
|
@ -310,6 +310,16 @@ class BatchHandler(object):
|
|||
Update the batch status, as needed...
|
||||
"""
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def executable(self, batch):
|
||||
"""
|
||||
This method should return a boolean indicating whether or not execution
|
||||
|
@ -321,7 +331,11 @@ class BatchHandler(object):
|
|||
"""
|
||||
if batch is None:
|
||||
return True
|
||||
return not bool(batch.executed)
|
||||
if batch.executed:
|
||||
return False
|
||||
if self.why_not_execute(batch):
|
||||
return False
|
||||
return True
|
||||
|
||||
def auto_executable(self, batch):
|
||||
"""
|
||||
|
@ -352,29 +366,57 @@ class BatchHandler(object):
|
|||
batch.executed_by = kwargs['user']
|
||||
return True
|
||||
|
||||
def clone(self, oldbatch, created_by):
|
||||
def delete(self, batch, progress=None, **kwargs):
|
||||
"""
|
||||
Delete all data for the batch, including any related (e.g. row)
|
||||
records, as well as files on disk etc. This method should *not* delete
|
||||
the batch itself however.
|
||||
"""
|
||||
if hasattr(batch, 'delete_data'):
|
||||
batch.delete_data(self.config)
|
||||
if hasattr(batch, 'data_rows'):
|
||||
del batch.data_rows[:]
|
||||
|
||||
def clone(self, oldbatch, created_by, progress=None):
|
||||
"""
|
||||
Clone the given batch as a new batch, and return the new batch.
|
||||
"""
|
||||
newbatch = self.batch_model_class()
|
||||
batch_class = self.batch_model_class
|
||||
batch_mapper = orm.class_mapper(batch_class)
|
||||
|
||||
newbatch = batch_class()
|
||||
newbatch.created_by = created_by
|
||||
newbatch.rowcount = 0
|
||||
rowclass = newbatch.row_class
|
||||
mapper = orm.class_mapper(rowclass)
|
||||
|
||||
for oldrow in oldbatch.data_rows:
|
||||
newrow = rowclass()
|
||||
for name in mapper.columns.keys():
|
||||
if name not in ('uuid', 'batch_uuid'):
|
||||
setattr(newrow, name, getattr(oldrow, name))
|
||||
newbatch.data_rows.append(newrow)
|
||||
newbatch.rowcount += 1
|
||||
for name in batch_mapper.columns.keys():
|
||||
if name not in ('uuid', 'id', 'created', 'created_by_uuid', 'executed', 'executed_by_uuid'):
|
||||
setattr(newbatch, name, getattr(oldbatch, name))
|
||||
|
||||
session = orm.object_session(oldbatch)
|
||||
session.add(newbatch)
|
||||
session.flush()
|
||||
|
||||
row_class = newbatch.row_class
|
||||
row_mapper = orm.class_mapper(row_class)
|
||||
|
||||
def clone_row(oldrow, i):
|
||||
newrow = self.clone_row(oldrow)
|
||||
self.add_row(newbatch, newrow)
|
||||
|
||||
self.progress_loop(clone_row, oldbatch.data_rows, progress,
|
||||
message="Cloning data rows for new batch")
|
||||
|
||||
self.refresh_batch_status(newbatch)
|
||||
return newbatch
|
||||
|
||||
def clone_row(self, oldrow):
|
||||
row_class = self.batch_model_class.row_class
|
||||
row_mapper = orm.class_mapper(row_class)
|
||||
newrow = row_class()
|
||||
for name in row_mapper.columns.keys():
|
||||
if name not in ('uuid', 'batch_uuid'):
|
||||
setattr(newrow, name, getattr(oldrow, name))
|
||||
return newrow
|
||||
|
||||
def cache_model(self, session, model, **kwargs):
|
||||
return cache_model(session, model, **kwargs)
|
||||
|
||||
|
|
|
@ -26,12 +26,18 @@ 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.batch import BatchHandler
|
||||
from rattail.time import make_utc
|
||||
from rattail.vendors.invoices import require_invoice_parser
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PurchaseBatchHandler(BatchHandler):
|
||||
|
@ -75,6 +81,285 @@ class PurchaseBatchHandler(BatchHandler):
|
|||
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)
|
||||
|
||||
def append(invoice_row, i):
|
||||
row = model.PurchaseBatchRow()
|
||||
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 = {}
|
||||
for row in truck_dump_rows:
|
||||
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)
|
||||
|
||||
# organize child batch by product and UPC
|
||||
child_by_product = {}
|
||||
child_by_upc = {}
|
||||
for row in child_rows:
|
||||
if row.product:
|
||||
child_by_product.setdefault(row.product.uuid, []).append(row)
|
||||
if row.upc:
|
||||
child_by_upc.setdefault(row.upc, []).append(row)
|
||||
|
||||
# first pass looks only for exact product and quantity match
|
||||
for uuid, child_product_rows in six.iteritems(child_by_product):
|
||||
if uuid not in truck_dump_by_product:
|
||||
continue
|
||||
|
||||
# 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
|
||||
|
||||
# maybe also look at the aggregate child counts
|
||||
if not matched_child_rows:
|
||||
ordered = sum([self.get_units_ordered(row)
|
||||
for row in child_product_rows])
|
||||
if ordered == available:
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# TODO: should we ever use case quantity from truck dump instead?
|
||||
|
||||
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
|
||||
|
||||
# TODO: need to add support for credits to this one
|
||||
if ordered and avail_expired:
|
||||
cases, units = self.calc_best_fit(avail_expired, case_quantity)
|
||||
total = self.get_units(cases, units, case_quantity)
|
||||
assert total == avail_expired, "total units doesn't match avail_expired for {}".format(truck_dump_row)
|
||||
child_row.cases_expired = claim.cases_expired = cases or None
|
||||
child_row.cases_expired = claim.units_expired = units or None
|
||||
self.refresh_row(child_row)
|
||||
avail_expired -= total
|
||||
ordered -= total
|
||||
|
||||
if ordered:
|
||||
raise NotImplementedError("TODO: can't yet handle match with mixture of received/damaged/expired (?)")
|
||||
|
||||
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
|
||||
return 0, units
|
||||
|
||||
def refresh(self, batch, progress=None):
|
||||
if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
|
||||
batch.po_total = 0
|
||||
|
@ -84,8 +369,14 @@ class PurchaseBatchHandler(BatchHandler):
|
|||
return super(PurchaseBatchHandler, self).refresh(batch, progress=progress)
|
||||
|
||||
def refresh_batch_status(self, batch):
|
||||
if any([not row.product_uuid for row in batch.active_rows()]):
|
||||
rows = batch.active_rows()
|
||||
if any([not row.product_uuid for row in rows]):
|
||||
batch.status_code = batch.STATUS_UNKNOWN_PRODUCT
|
||||
elif batch.truck_dump:
|
||||
if any([row.status_code == row.STATUS_TRUCKDUMP_UNCLAIMED for row in rows]):
|
||||
batch.status_code = batch.STATUS_TRUCKDUMP_UNCLAIMED
|
||||
else:
|
||||
batch.status_code = batch.STATUS_TRUCKDUMP_CLAIMED
|
||||
else:
|
||||
batch.status_code = batch.STATUS_OK
|
||||
|
||||
|
@ -138,7 +429,16 @@ class PurchaseBatchHandler(BatchHandler):
|
|||
row.status_code = row.STATUS_INCOMPLETE
|
||||
else:
|
||||
if batch.truck_dump:
|
||||
row.status_code = row.STATUS_OK
|
||||
confirmed = self.get_units_confirmed(row)
|
||||
claimed = self.get_units_claimed(row)
|
||||
if claimed == confirmed:
|
||||
row.status_code = row.STATUS_TRUCKDUMP_CLAIMED
|
||||
elif claimed < confirmed:
|
||||
row.status_code = row.STATUS_TRUCKDUMP_UNCLAIMED
|
||||
elif claimed > confirmed:
|
||||
row.status_code = row.STATUS_TRUCKDUMP_OVERCLAIMED
|
||||
else:
|
||||
raise NotImplementedError
|
||||
else: # not truck_dump
|
||||
if self.get_units_ordered(row) != self.get_units_accounted_for(row):
|
||||
row.status_code = row.STATUS_ORDERED_RECEIVED_DIFFER
|
||||
|
@ -192,20 +492,78 @@ class PurchaseBatchHandler(BatchHandler):
|
|||
if cost:
|
||||
return cost.unit_cost
|
||||
|
||||
def get_units_ordered(self, row):
|
||||
return (row.units_ordered or 0) + (row.case_quantity or 1) * (row.cases_ordered or 0)
|
||||
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_received(self, row):
|
||||
return (row.units_received or 0) + (row.case_quantity or 1) * (row.cases_received 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):
|
||||
units_damaged = (row.units_damaged or 0) + (row.case_quantity or 1) * (row.cases_damaged or 0)
|
||||
units_expired = (row.units_expired or 0) + (row.case_quantity or 1) * (row.cases_expired or 0)
|
||||
# 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_accounted_for(self, row):
|
||||
units_mispick = (row.units_mispick or 0) + (row.case_quantity or 1) * (row.cases_mispick or 0)
|
||||
return self.get_units_shipped(row) + units_mispick
|
||||
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):
|
||||
return sum([self.get_units_confirmed(claim, case_quantity=row.case_quantity)
|
||||
for claim in row.claims])
|
||||
|
||||
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):
|
||||
|
||||
|
@ -234,6 +592,22 @@ class PurchaseBatchHandler(BatchHandler):
|
|||
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.truck_dump_batch:
|
||||
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
|
||||
|
@ -247,6 +621,10 @@ class PurchaseBatchHandler(BatchHandler):
|
|||
return purchase
|
||||
|
||||
elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
|
||||
if self.allow_truck_dump and batch.truck_dump:
|
||||
self.execute_truck_dump(batch, user, progress=progress)
|
||||
return True
|
||||
else:
|
||||
with session.no_autoflush:
|
||||
return self.receive_purchase(batch, progress=progress)
|
||||
|
||||
|
@ -261,6 +639,14 @@ class PurchaseBatchHandler(BatchHandler):
|
|||
|
||||
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)
|
||||
|
@ -369,3 +755,20 @@ class PurchaseBatchHandler(BatchHandler):
|
|||
|
||||
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))
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""add receiving.truck_dump_batch
|
||||
|
||||
Revision ID: 6551a4d9ff25
|
||||
Revises: c37154504ae1
|
||||
Create Date: 2018-05-16 10:40:14.855471
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6551a4d9ff25'
|
||||
down_revision = u'c37154504ae1'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import rattail.db.types
|
||||
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
# purchase_batch
|
||||
op.add_column('purchase_batch', sa.Column('truck_dump_batch_uuid', sa.String(length=32), nullable=True))
|
||||
op.create_foreign_key(u'purchase_batch_fk_truck_dump_batch', 'purchase_batch', 'purchase_batch', ['truck_dump_batch_uuid'], ['uuid'], use_alter=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
# purchase_batch
|
||||
op.drop_constraint(u'purchase_batch_fk_truck_dump_batch', 'purchase_batch', type_='foreignkey')
|
||||
op.drop_column('purchase_batch', 'truck_dump_batch_uuid')
|
|
@ -0,0 +1,35 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""add purchase_batch.invoice_file
|
||||
|
||||
Revision ID: 92a49edc45c3
|
||||
Revises: 6551a4d9ff25
|
||||
Create Date: 2018-05-16 13:46:31.940108
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '92a49edc45c3'
|
||||
down_revision = u'6551a4d9ff25'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import rattail.db.types
|
||||
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
# purchase_batch
|
||||
op.add_column('purchase_batch', sa.Column('invoice_file', sa.String(length=255), nullable=True))
|
||||
op.add_column('purchase_batch', sa.Column('invoice_parser_key', sa.String(length=100), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
# purchase_batch
|
||||
op.drop_column('purchase_batch', 'invoice_parser_key')
|
||||
op.drop_column('purchase_batch', 'invoice_file')
|
|
@ -0,0 +1,43 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""add purchase shipped counts
|
||||
|
||||
Revision ID: 977a76f259bd
|
||||
Revises: 92a49edc45c3
|
||||
Create Date: 2018-05-16 16:06:13.691091
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '977a76f259bd'
|
||||
down_revision = u'92a49edc45c3'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import rattail.db.types
|
||||
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
# purchase_batch_row
|
||||
op.add_column('purchase_batch_row', sa.Column('cases_shipped', sa.Numeric(precision=10, scale=4), nullable=True))
|
||||
op.add_column('purchase_batch_row', sa.Column('units_shipped', sa.Numeric(precision=10, scale=4), nullable=True))
|
||||
|
||||
# purchase_item
|
||||
op.add_column('purchase_item', sa.Column('cases_shipped', sa.Numeric(precision=10, scale=4), nullable=True))
|
||||
op.add_column('purchase_item', sa.Column('units_shipped', sa.Numeric(precision=10, scale=4), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
# purchase_item
|
||||
op.drop_column('purchase_item', 'units_shipped')
|
||||
op.drop_column('purchase_item', 'cases_shipped')
|
||||
|
||||
# purchase_batch_row
|
||||
op.drop_column('purchase_batch_row', 'units_shipped')
|
||||
op.drop_column('purchase_batch_row', 'cases_shipped')
|
|
@ -0,0 +1,46 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""add purchase_batch_row_claim
|
||||
|
||||
Revision ID: b5aa18867ab3
|
||||
Revises: 977a76f259bd
|
||||
Create Date: 2018-05-17 11:43:14.639019
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b5aa18867ab3'
|
||||
down_revision = u'977a76f259bd'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import rattail.db.types
|
||||
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
# purchase_batch_row_claim
|
||||
op.create_table('purchase_batch_row_claim',
|
||||
sa.Column('uuid', sa.String(length=32), nullable=False),
|
||||
sa.Column('claiming_row_uuid', sa.String(length=32), nullable=False),
|
||||
sa.Column('claimed_row_uuid', sa.String(length=32), nullable=False),
|
||||
sa.Column('cases_received', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||
sa.Column('units_received', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||
sa.Column('cases_damaged', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||
sa.Column('units_damaged', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||
sa.Column('cases_expired', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||
sa.Column('units_expired', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||
sa.ForeignKeyConstraint(['claimed_row_uuid'], [u'purchase_batch_row.uuid'], name=u'purchase_batch_row_claim_fk_claimed_row'),
|
||||
sa.ForeignKeyConstraint(['claiming_row_uuid'], [u'purchase_batch_row.uuid'], name=u'purchase_batch_row_claim_fk_claiming_row'),
|
||||
sa.PrimaryKeyConstraint('uuid')
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
# purchase_batch_row_claim
|
||||
op.drop_table('purchase_batch_row_claim')
|
|
@ -65,6 +65,6 @@ from .batch.handheld import HandheldBatch, HandheldBatchRow
|
|||
from .batch.inventory import InventoryBatch, InventoryBatchRow
|
||||
from .batch.labels import LabelBatch, LabelBatchRow
|
||||
from .batch.pricing import PricingBatch, PricingBatchRow
|
||||
from .batch.purchase import PurchaseBatch, PurchaseBatchRow, PurchaseBatchCredit
|
||||
from .batch.purchase import PurchaseBatch, PurchaseBatchRow, PurchaseBatchRowClaim, PurchaseBatchCredit
|
||||
from .batch.vendorcatalog import VendorCatalog, VendorCatalogRow
|
||||
from .batch.vendorinvoice import VendorInvoice, VendorInvoiceRow
|
||||
|
|
|
@ -34,6 +34,7 @@ from sqlalchemy.ext.declarative import declared_attr
|
|||
from rattail.db.model import (Base, uuid_column, BatchMixin, BatchRowMixin,
|
||||
PurchaseBase, PurchaseItemBase, PurchaseCreditBase,
|
||||
Purchase, PurchaseItem)
|
||||
from rattail.db.model.batch import filename_column
|
||||
from rattail.util import pretty_quantity
|
||||
|
||||
|
||||
|
@ -51,14 +52,19 @@ class PurchaseBatch(BatchMixin, PurchaseBase, Base):
|
|||
def __table_args__(cls):
|
||||
return cls.__batch_table_args__() + cls.__purchase_table_args__() + (
|
||||
sa.ForeignKeyConstraint(['purchase_uuid'], ['purchase.uuid'], name='purchase_batch_fk_purchase'),
|
||||
sa.ForeignKeyConstraint(['truck_dump_batch_uuid'], ['purchase_batch.uuid'], name='purchase_batch_fk_truck_dump_batch', use_alter=True),
|
||||
)
|
||||
|
||||
STATUS_OK = 1
|
||||
STATUS_UNKNOWN_PRODUCT = 2
|
||||
STATUS_TRUCKDUMP_UNCLAIMED = 3
|
||||
STATUS_TRUCKDUMP_CLAIMED = 4
|
||||
|
||||
STATUS = {
|
||||
STATUS_OK: "ok",
|
||||
STATUS_UNKNOWN_PRODUCT: "has unknown product(s)",
|
||||
STATUS_OK : "ok",
|
||||
STATUS_UNKNOWN_PRODUCT : "has unknown product(s)",
|
||||
STATUS_TRUCKDUMP_UNCLAIMED : "not yet fully claimed",
|
||||
STATUS_TRUCKDUMP_CLAIMED : "fully claimed by child(ren)",
|
||||
}
|
||||
|
||||
purchase_uuid = sa.Column(sa.String(length=32), nullable=True)
|
||||
|
@ -80,12 +86,35 @@ class PurchaseBatch(BatchMixin, PurchaseBase, Base):
|
|||
Numeric "mode" for the purchase batch, to indicate new/receiving etc.
|
||||
""")
|
||||
|
||||
truck_dump = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||
invoice_file = filename_column(doc="Base name for the associated invoice file, if any.")
|
||||
|
||||
invoice_parser_key = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||
The key of the parser used to read the contents of the invoice file.
|
||||
""")
|
||||
|
||||
truck_dump = sa.Column(sa.Boolean(), nullable=True, default=False, doc="""
|
||||
Flag indicating whether a "receiving" batch is of the "truck dump"
|
||||
persuasion, i.e. it does not correspond to a single purchase order but
|
||||
rather is assumed to represent multiple orders.
|
||||
""")
|
||||
|
||||
truck_dump_batch_uuid = sa.Column(sa.String(length=32), nullable=True)
|
||||
truck_dump_batch = orm.relationship(
|
||||
'PurchaseBatch',
|
||||
remote_side='PurchaseBatch.uuid',
|
||||
doc="""
|
||||
Reference to the "truck dump" receiving batch, for which the current
|
||||
batch represents a single invoice which partially "consumes" the truck
|
||||
dump.
|
||||
""",
|
||||
backref=orm.backref(
|
||||
'truck_dump_children',
|
||||
order_by='PurchaseBatch.id',
|
||||
doc="""
|
||||
List of batches which are "children" of the current batch, which is
|
||||
assumed to be a truck dump.
|
||||
"""))
|
||||
|
||||
|
||||
class PurchaseBatchRow(BatchRowMixin, PurchaseItemBase, Base):
|
||||
"""
|
||||
|
@ -106,6 +135,9 @@ class PurchaseBatchRow(BatchRowMixin, PurchaseItemBase, Base):
|
|||
STATUS_CASE_QUANTITY_UNKNOWN = 4
|
||||
STATUS_INCOMPLETE = 5
|
||||
STATUS_ORDERED_RECEIVED_DIFFER = 6
|
||||
STATUS_TRUCKDUMP_UNCLAIMED = 7
|
||||
STATUS_TRUCKDUMP_CLAIMED = 8
|
||||
STATUS_TRUCKDUMP_OVERCLAIMED = 9
|
||||
|
||||
STATUS = {
|
||||
STATUS_OK : "ok",
|
||||
|
@ -114,6 +146,9 @@ class PurchaseBatchRow(BatchRowMixin, PurchaseItemBase, Base):
|
|||
STATUS_CASE_QUANTITY_UNKNOWN : "case quantity not known",
|
||||
STATUS_INCOMPLETE : "incomplete",
|
||||
STATUS_ORDERED_RECEIVED_DIFFER : "ordered / received differ",
|
||||
STATUS_TRUCKDUMP_UNCLAIMED : "not yet fully claimed",
|
||||
STATUS_TRUCKDUMP_CLAIMED : "fully claimed by child(ren)",
|
||||
STATUS_TRUCKDUMP_OVERCLAIMED : "OVER claimed by child(ren)",
|
||||
}
|
||||
|
||||
item_uuid = sa.Column(sa.String(length=32), nullable=True)
|
||||
|
@ -126,6 +161,75 @@ class PurchaseBatchRow(BatchRowMixin, PurchaseItemBase, Base):
|
|||
""")
|
||||
|
||||
|
||||
class PurchaseBatchRowClaim(Base):
|
||||
"""
|
||||
Represents the connection between a row(s) from a truck dump batch, and the
|
||||
corresponding "child" batch row which claims it, as well as the claimed
|
||||
quantities etc.
|
||||
"""
|
||||
__tablename__ = 'purchase_batch_row_claim'
|
||||
__table_args__ = (
|
||||
sa.ForeignKeyConstraint(['claiming_row_uuid'], ['purchase_batch_row.uuid'], name='purchase_batch_row_claim_fk_claiming_row'),
|
||||
sa.ForeignKeyConstraint(['claimed_row_uuid'], ['purchase_batch_row.uuid'], name='purchase_batch_row_claim_fk_claimed_row'),
|
||||
)
|
||||
|
||||
uuid = uuid_column()
|
||||
|
||||
claiming_row_uuid = sa.Column(sa.String(length=32), nullable=False)
|
||||
claiming_row = orm.relationship(
|
||||
PurchaseBatchRow,
|
||||
foreign_keys='PurchaseBatchRowClaim.claiming_row_uuid',
|
||||
doc="""
|
||||
Reference to the "child" row which is claiming some row from a truck
|
||||
dump batch.
|
||||
""",
|
||||
backref=orm.backref(
|
||||
'truck_dump_claims',
|
||||
cascade='all, delete-orphan',
|
||||
doc="""
|
||||
List of claims which this "child" row makes against rows within a
|
||||
truck dump batch.
|
||||
"""))
|
||||
|
||||
claimed_row_uuid = sa.Column(sa.String(length=32), nullable=False)
|
||||
claimed_row = orm.relationship(
|
||||
PurchaseBatchRow,
|
||||
foreign_keys='PurchaseBatchRowClaim.claimed_row_uuid',
|
||||
doc="""
|
||||
Reference to the truck dump batch row which is claimed by the "child" row.
|
||||
""",
|
||||
backref=orm.backref(
|
||||
'claims',
|
||||
# cascade='all, delete-orphan',
|
||||
doc="""
|
||||
List of claims made by "child" rows against this truck dump batch row.
|
||||
"""))
|
||||
|
||||
cases_received = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||
Number of cases of product which were ultimately received, and are involved in the claim.
|
||||
""")
|
||||
|
||||
units_received = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||
Number of units of product which were ultimately received, and are involved in the claim.
|
||||
""")
|
||||
|
||||
cases_damaged = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||
Number of cases of product which were shipped damaged, and are involved in the claim.
|
||||
""")
|
||||
|
||||
units_damaged = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||
Number of units of product which were shipped damaged, and are involved in the claim.
|
||||
""")
|
||||
|
||||
cases_expired = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||
Number of cases of product which were shipped expired, and are involved in the claim.
|
||||
""")
|
||||
|
||||
units_expired = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||
Number of units of product which were shipped expired, and are involved in the claim.
|
||||
""")
|
||||
|
||||
|
||||
@six.python_2_unicode_compatible
|
||||
class PurchaseBatchCredit(PurchaseCreditBase, Base):
|
||||
"""
|
||||
|
|
|
@ -235,6 +235,14 @@ class PurchaseItemBase(object):
|
|||
Expected total cost for line item, as of initial order placement.
|
||||
""")
|
||||
|
||||
cases_shipped = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||
Number of cases of product which were supposedly shipped by/from the vendor.
|
||||
""")
|
||||
|
||||
units_shipped = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||
Number of units of product which were supposedly shipped by/from the vendor.
|
||||
""")
|
||||
|
||||
cases_received = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||
Number of cases of product which were ultimately received.
|
||||
""")
|
||||
|
|
Loading…
Reference in a new issue