diff --git a/rattail/batch/handlers.py b/rattail/batch/handlers.py index ca42da47..5521e1a1 100644 --- a/rattail/batch/handlers.py +++ b/rattail/batch/handlers.py @@ -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) diff --git a/rattail/batch/purchase.py b/rattail/batch/purchase.py index a1ad426b..11cbae2a 100644 --- a/rattail/batch/purchase.py +++ b/rattail/batch/purchase.py @@ -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,8 +621,12 @@ class PurchaseBatchHandler(BatchHandler): return purchase elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: - with session.no_autoflush: - return self.receive_purchase(batch, progress=progress) + 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) elif batch.mode == self.enum.PURCHASE_BATCH_MODE_COSTING: # TODO: finish this... @@ -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)) diff --git a/rattail/db/alembic/versions/6551a4d9ff25_add_receiving_truck_dump_batch.py b/rattail/db/alembic/versions/6551a4d9ff25_add_receiving_truck_dump_batch.py new file mode 100644 index 00000000..a34f1d09 --- /dev/null +++ b/rattail/db/alembic/versions/6551a4d9ff25_add_receiving_truck_dump_batch.py @@ -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') diff --git a/rattail/db/alembic/versions/92a49edc45c3_add_purchase_batch_invoice_file.py b/rattail/db/alembic/versions/92a49edc45c3_add_purchase_batch_invoice_file.py new file mode 100644 index 00000000..eae2ae39 --- /dev/null +++ b/rattail/db/alembic/versions/92a49edc45c3_add_purchase_batch_invoice_file.py @@ -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') diff --git a/rattail/db/alembic/versions/977a76f259bd_add_purchase_shipped_counts.py b/rattail/db/alembic/versions/977a76f259bd_add_purchase_shipped_counts.py new file mode 100644 index 00000000..7e1a9f05 --- /dev/null +++ b/rattail/db/alembic/versions/977a76f259bd_add_purchase_shipped_counts.py @@ -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') diff --git a/rattail/db/alembic/versions/b5aa18867ab3_add_purchase_batch_row_claim.py b/rattail/db/alembic/versions/b5aa18867ab3_add_purchase_batch_row_claim.py new file mode 100644 index 00000000..4cbfd126 --- /dev/null +++ b/rattail/db/alembic/versions/b5aa18867ab3_add_purchase_batch_row_claim.py @@ -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') diff --git a/rattail/db/model/__init__.py b/rattail/db/model/__init__.py index d0abce21..cd3f46a9 100644 --- a/rattail/db/model/__init__.py +++ b/rattail/db/model/__init__.py @@ -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 diff --git a/rattail/db/model/batch/purchase.py b/rattail/db/model/batch/purchase.py index 24abdfd1..8090aae8 100644 --- a/rattail/db/model/batch/purchase.py +++ b/rattail/db/model/batch/purchase.py @@ -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): """ diff --git a/rattail/db/model/purchase.py b/rattail/db/model/purchase.py index c6f111fa..ee449bae 100644 --- a/rattail/db/model/purchase.py +++ b/rattail/db/model/purchase.py @@ -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. """)