Add "most of" support for truck dump receiving

This commit is contained in:
Lance Edgar 2018-05-18 15:44:02 -05:00
parent d3662fe55d
commit 225a0ba380
9 changed files with 747 additions and 31 deletions

View file

@ -310,6 +310,16 @@ class BatchHandler(object):
Update the batch status, as needed... 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): def executable(self, batch):
""" """
This method should return a boolean indicating whether or not execution This method should return a boolean indicating whether or not execution
@ -321,7 +331,11 @@ class BatchHandler(object):
""" """
if batch is None: if batch is None:
return True 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): def auto_executable(self, batch):
""" """
@ -352,29 +366,57 @@ class BatchHandler(object):
batch.executed_by = kwargs['user'] batch.executed_by = kwargs['user']
return True 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. 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.created_by = created_by
newbatch.rowcount = 0 newbatch.rowcount = 0
rowclass = newbatch.row_class for name in batch_mapper.columns.keys():
mapper = orm.class_mapper(rowclass) if name not in ('uuid', 'id', 'created', 'created_by_uuid', 'executed', 'executed_by_uuid'):
setattr(newbatch, name, getattr(oldbatch, name))
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
session = orm.object_session(oldbatch) session = orm.object_session(oldbatch)
session.add(newbatch) session.add(newbatch)
session.flush() 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 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): def cache_model(self, session, model, **kwargs):
return cache_model(session, model, **kwargs) return cache_model(session, model, **kwargs)

View file

@ -26,12 +26,18 @@ Handler for purchase order batches
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
import logging
import six import six
from sqlalchemy import orm from sqlalchemy import orm
from rattail.db import model, api from rattail.db import model, api
from rattail.batch import BatchHandler from rattail.batch import BatchHandler
from rattail.time import make_utc from rattail.time import make_utc
from rattail.vendors.invoices import require_invoice_parser
log = logging.getLogger(__name__)
class PurchaseBatchHandler(BatchHandler): class PurchaseBatchHandler(BatchHandler):
@ -75,6 +81,285 @@ class PurchaseBatchHandler(BatchHandler):
session.flush() session.flush()
self.refresh_batch_status(batch) 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): def refresh(self, batch, progress=None):
if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
batch.po_total = 0 batch.po_total = 0
@ -84,8 +369,14 @@ class PurchaseBatchHandler(BatchHandler):
return super(PurchaseBatchHandler, self).refresh(batch, progress=progress) return super(PurchaseBatchHandler, self).refresh(batch, progress=progress)
def refresh_batch_status(self, batch): 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 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: else:
batch.status_code = batch.STATUS_OK batch.status_code = batch.STATUS_OK
@ -138,7 +429,16 @@ class PurchaseBatchHandler(BatchHandler):
row.status_code = row.STATUS_INCOMPLETE row.status_code = row.STATUS_INCOMPLETE
else: else:
if batch.truck_dump: 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 else: # not truck_dump
if self.get_units_ordered(row) != self.get_units_accounted_for(row): if self.get_units_ordered(row) != self.get_units_accounted_for(row):
row.status_code = row.STATUS_ORDERED_RECEIVED_DIFFER row.status_code = row.STATUS_ORDERED_RECEIVED_DIFFER
@ -192,20 +492,78 @@ class PurchaseBatchHandler(BatchHandler):
if cost: if cost:
return cost.unit_cost return cost.unit_cost
def get_units_ordered(self, row): def get_units(self, cases, units, case_quantity):
return (row.units_ordered or 0) + (row.case_quantity or 1) * (row.cases_ordered or 0) case_quantity = case_quantity or 1
return (units or 0) + case_quantity * (cases or 0)
def get_units_received(self, row): def get_units_ordered(self, row, case_quantity=None):
return (row.units_received or 0) + (row.case_quantity or 1) * (row.cases_received or 0) 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): # TODO: we now have shipped quantities...should return sum of those instead?
units_damaged = (row.units_damaged or 0) + (row.case_quantity or 1) * (row.cases_damaged or 0) def get_units_shipped(self, row, case_quantity=None):
units_expired = (row.units_expired or 0) + (row.case_quantity or 1) * (row.cases_expired or 0) 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 return self.get_units_received(row) + units_damaged + units_expired
def get_units_accounted_for(self, row): def get_units_received(self, row, case_quantity=None):
units_mispick = (row.units_mispick or 0) + (row.case_quantity or 1) * (row.cases_mispick or 0) case_quantity = case_quantity or row.case_quantity or 1
return self.get_units_shipped(row) + units_mispick 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): def update_order_counts(self, purchase, progress=None):
@ -234,6 +592,22 @@ class PurchaseBatchHandler(BatchHandler):
self.progress_loop(update, purchase.items, progress, self.progress_loop(update, purchase.items, progress,
message="Updating inventory counts") 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): def execute(self, batch, user, progress=None):
""" """
Default behavior for executing a purchase batch will create a new Default behavior for executing a purchase batch will create a new
@ -247,6 +621,10 @@ class PurchaseBatchHandler(BatchHandler):
return purchase return purchase
elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: 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: with session.no_autoflush:
return self.receive_purchase(batch, progress=progress) return self.receive_purchase(batch, progress=progress)
@ -261,6 +639,14 @@ class PurchaseBatchHandler(BatchHandler):
assert False 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): def make_credits(self, batch, progress=None):
session = orm.object_session(batch) session = orm.object_session(batch)
mapper = orm.class_mapper(model.PurchaseBatchCredit) mapper = orm.class_mapper(model.PurchaseBatchCredit)
@ -369,3 +755,20 @@ class PurchaseBatchHandler(BatchHandler):
purchase.status = self.enum.PURCHASE_STATUS_RECEIVED purchase.status = self.enum.PURCHASE_STATUS_RECEIVED
return purchase 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))

View file

@ -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')

View file

@ -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')

View 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')

View file

@ -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')

View file

@ -65,6 +65,6 @@ from .batch.handheld import HandheldBatch, HandheldBatchRow
from .batch.inventory import InventoryBatch, InventoryBatchRow from .batch.inventory import InventoryBatch, InventoryBatchRow
from .batch.labels import LabelBatch, LabelBatchRow from .batch.labels import LabelBatch, LabelBatchRow
from .batch.pricing import PricingBatch, PricingBatchRow 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.vendorcatalog import VendorCatalog, VendorCatalogRow
from .batch.vendorinvoice import VendorInvoice, VendorInvoiceRow from .batch.vendorinvoice import VendorInvoice, VendorInvoiceRow

View file

@ -34,6 +34,7 @@ from sqlalchemy.ext.declarative import declared_attr
from rattail.db.model import (Base, uuid_column, BatchMixin, BatchRowMixin, from rattail.db.model import (Base, uuid_column, BatchMixin, BatchRowMixin,
PurchaseBase, PurchaseItemBase, PurchaseCreditBase, PurchaseBase, PurchaseItemBase, PurchaseCreditBase,
Purchase, PurchaseItem) Purchase, PurchaseItem)
from rattail.db.model.batch import filename_column
from rattail.util import pretty_quantity from rattail.util import pretty_quantity
@ -51,14 +52,19 @@ class PurchaseBatch(BatchMixin, PurchaseBase, Base):
def __table_args__(cls): def __table_args__(cls):
return cls.__batch_table_args__() + cls.__purchase_table_args__() + ( return cls.__batch_table_args__() + cls.__purchase_table_args__() + (
sa.ForeignKeyConstraint(['purchase_uuid'], ['purchase.uuid'], name='purchase_batch_fk_purchase'), 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_OK = 1
STATUS_UNKNOWN_PRODUCT = 2 STATUS_UNKNOWN_PRODUCT = 2
STATUS_TRUCKDUMP_UNCLAIMED = 3
STATUS_TRUCKDUMP_CLAIMED = 4
STATUS = { STATUS = {
STATUS_OK: "ok", STATUS_OK : "ok",
STATUS_UNKNOWN_PRODUCT: "has unknown product(s)", 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) 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. 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" Flag indicating whether a "receiving" batch is of the "truck dump"
persuasion, i.e. it does not correspond to a single purchase order but persuasion, i.e. it does not correspond to a single purchase order but
rather is assumed to represent multiple orders. 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): class PurchaseBatchRow(BatchRowMixin, PurchaseItemBase, Base):
""" """
@ -106,6 +135,9 @@ class PurchaseBatchRow(BatchRowMixin, PurchaseItemBase, Base):
STATUS_CASE_QUANTITY_UNKNOWN = 4 STATUS_CASE_QUANTITY_UNKNOWN = 4
STATUS_INCOMPLETE = 5 STATUS_INCOMPLETE = 5
STATUS_ORDERED_RECEIVED_DIFFER = 6 STATUS_ORDERED_RECEIVED_DIFFER = 6
STATUS_TRUCKDUMP_UNCLAIMED = 7
STATUS_TRUCKDUMP_CLAIMED = 8
STATUS_TRUCKDUMP_OVERCLAIMED = 9
STATUS = { STATUS = {
STATUS_OK : "ok", STATUS_OK : "ok",
@ -114,6 +146,9 @@ class PurchaseBatchRow(BatchRowMixin, PurchaseItemBase, Base):
STATUS_CASE_QUANTITY_UNKNOWN : "case quantity not known", STATUS_CASE_QUANTITY_UNKNOWN : "case quantity not known",
STATUS_INCOMPLETE : "incomplete", STATUS_INCOMPLETE : "incomplete",
STATUS_ORDERED_RECEIVED_DIFFER : "ordered / received differ", 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) 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 @six.python_2_unicode_compatible
class PurchaseBatchCredit(PurchaseCreditBase, Base): class PurchaseBatchCredit(PurchaseCreditBase, Base):
""" """

View file

@ -235,6 +235,14 @@ class PurchaseItemBase(object):
Expected total cost for line item, as of initial order placement. 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=""" cases_received = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
Number of cases of product which were ultimately received. Number of cases of product which were ultimately received.
""") """)