From ebb395a11e1563a6731ea7b3a3892bae77c8885d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 10 Dec 2016 09:05:21 -0600 Subject: [PATCH] Add `PurchaseCredit` and friends to schema Also allow missing cost when refreshing purchase batch row --- rattail/batch/purchase.py | 35 ++- .../193c2dcdd14d_add_purchase_credits.py | 135 ++++++++++++ rattail/db/model/__init__.py | 5 +- rattail/db/model/batch/purchase.py | 55 ++++- rattail/db/model/purchase.py | 207 ++++++++++++++++++ 5 files changed, 420 insertions(+), 17 deletions(-) create mode 100644 rattail/db/alembic/versions/193c2dcdd14d_add_purchase_credits.py diff --git a/rattail/batch/purchase.py b/rattail/batch/purchase.py index 77231e1d..1b014800 100644 --- a/rattail/batch/purchase.py +++ b/rattail/batch/purchase.py @@ -84,8 +84,7 @@ class PurchaseBatchHandler(BatchHandler): """ batch = row.batch product = row.product - cost = row.product.cost_for_vendor(batch.vendor) - assert cost + cost = row.product.cost_for_vendor(batch.vendor) or row.product.cost row.upc = product.upc row.brand_name = unicode(product.brand or '') row.description = product.description @@ -96,18 +95,25 @@ class PurchaseBatchHandler(BatchHandler): else: row.department_number = None row.department_name = None - row.vendor_code = cost.code - row.case_quantity = cost.case_size + row.vendor_code = cost.code if cost else None + row.case_quantity = cost.case_size if cost else None if batch.mode == self.enum.PURCHASE_BATCH_MODE_NEW: - row.po_unit_cost = cost.unit_cost - row.po_total = row.po_unit_cost * self.get_units_ordered(row) - batch.po_total = (batch.po_total or 0) + row.po_total + if cost: + row.po_unit_cost = cost.unit_cost + row.po_total = row.po_unit_cost * self.get_units_ordered(row) + batch.po_total = (batch.po_total or 0) + row.po_total + else: + row.po_unit_cost = None + row.po_total = None elif batch.mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING, self.enum.PURCHASE_BATCH_MODE_COSTING): - row.invoice_unit_cost = cost.unit_cost - row.invoice_total = row.invoice_unit_cost * self.get_units_shipped(row) - batch.invoice_total = (batch.invoice_total or 0) + row.invoice_total + row.invoice_unit_cost = (cost.unit_cost if cost else None) or row.po_unit_cost + if row.invoice_unit_cost: + row.invoice_total = row.invoice_unit_cost * self.get_units_accounted_for(row) + batch.invoice_total = (batch.invoice_total or 0) + row.invoice_total + else: + row.invoice_total = None if batch.mode == self.enum.PURCHASE_BATCH_MODE_NEW: row.status_code = row.STATUS_OK @@ -116,10 +122,11 @@ class PurchaseBatchHandler(BatchHandler): self.enum.PURCHASE_BATCH_MODE_COSTING): if (row.cases_received is None and row.units_received is None and row.cases_damaged is None and row.units_damaged is None and - row.cases_expired is None and row.units_expired is None): + row.cases_expired is None and row.units_expired is None and + row.cases_mispick is None and row.units_mispick is None): row.status_code = row.STATUS_INCOMPLETE else: - if self.get_units_ordered() != self.get_units_shipped(row): + if self.get_units_ordered(row) != self.get_units_accounted_for(row): row.status_code = row.STATUS_ORDERED_RECEIVED_DIFFER else: row.status_code = row.STATUS_OK @@ -133,6 +140,10 @@ class PurchaseBatchHandler(BatchHandler): units_expired = (row.units_expired or 0) + row.case_quantity * (row.cases_expired or 0) return units_received + units_damaged + units_expired + def get_units_accounted_for(self, row): + units_mispick = (row.units_mispick or 0) + row.case_quantity * (row.cases_mispick or 0) + return self.get_units_shipped(row) + units_mispick + def execute(self, batch, user, progress=None): """ Default behavior for executing a purchase batch will create a new diff --git a/rattail/db/alembic/versions/193c2dcdd14d_add_purchase_credits.py b/rattail/db/alembic/versions/193c2dcdd14d_add_purchase_credits.py new file mode 100644 index 00000000..0324269a --- /dev/null +++ b/rattail/db/alembic/versions/193c2dcdd14d_add_purchase_credits.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +"""add purchase credits + +Revision ID: 193c2dcdd14d +Revises: 123af5f6a0bc +Create Date: 2016-12-09 17:03:39.001046 + +""" + +from __future__ import unicode_literals + +# revision identifiers, used by Alembic. +revision = '193c2dcdd14d' +down_revision = u'123af5f6a0bc' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +import rattail.db.types +from sqlalchemy.dialects import postgresql + + +batch_id_seq = sa.Sequence('batch_id_seq') + + +def upgrade(): + + # purchase_batch_row + op.add_column('purchase_batch_row', sa.Column('cases_mispick', sa.Numeric(precision=10, scale=4), nullable=True)) + op.add_column('purchase_batch_row', sa.Column('units_mispick', sa.Numeric(precision=10, scale=4), nullable=True)) + + # purchase_item + op.add_column('purchase_item', sa.Column('cases_mispick', sa.Numeric(precision=10, scale=4), nullable=True)) + op.add_column('purchase_item', sa.Column('units_mispick', sa.Numeric(precision=10, scale=4), nullable=True)) + + # purchase_credit + op.create_table('purchase_credit', + sa.Column('uuid', sa.String(length=32), nullable=False), + sa.Column('store_uuid', sa.String(length=32), nullable=False), + sa.Column('vendor_uuid', sa.String(length=32), nullable=False), + sa.Column('date_ordered', sa.Date(), nullable=True), + sa.Column('date_shipped', sa.Date(), nullable=True), + sa.Column('date_received', sa.Date(), nullable=True), + sa.Column('invoice_number', sa.String(length=20), nullable=True), + sa.Column('invoice_date', sa.Date(), nullable=True), + sa.Column('credit_type', sa.String(length=20), nullable=False), + sa.Column('product_uuid', sa.String(length=32), nullable=True), + sa.Column('upc', rattail.db.types.GPCType(), nullable=True), + sa.Column('brand_name', sa.String(length=100), nullable=True), + sa.Column('description', sa.String(length=60), nullable=False), + sa.Column('size', sa.String(length=255), nullable=True), + sa.Column('department_number', sa.Integer(), nullable=True), + sa.Column('department_name', sa.String(length=30), nullable=True), + sa.Column('case_quantity', sa.Numeric(precision=8, scale=2), nullable=True), + sa.Column('cases_shorted', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('units_shorted', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('expiration_date', sa.Date(), nullable=True), + sa.Column('invoice_line_number', sa.Integer(), nullable=True), + sa.Column('invoice_case_cost', sa.Numeric(precision=7, scale=3), nullable=True), + sa.Column('invoice_unit_cost', sa.Numeric(precision=7, scale=3), nullable=True), + sa.Column('invoice_total', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('purchase_uuid', sa.String(length=32), nullable=True), + sa.Column('item_uuid', sa.String(length=32), nullable=True), + sa.Column('status', sa.Integer(), nullable=True), + sa.Column('mispick_brand_name', sa.String(length=100), nullable=True), + sa.Column('mispick_description', sa.String(length=60), nullable=True), + sa.Column('mispick_product_uuid', sa.String(length=32), nullable=True), + sa.Column('mispick_size', sa.String(length=255), nullable=True), + sa.Column('mispick_upc', rattail.db.types.GPCType(), nullable=True), + sa.ForeignKeyConstraint(['item_uuid'], [u'purchase_item.uuid'], name=u'purchase_credit_fk_item'), + sa.ForeignKeyConstraint(['product_uuid'], [u'product.uuid'], name=u'purchase_credit_fk_product'), + sa.ForeignKeyConstraint(['purchase_uuid'], [u'purchase.uuid'], name=u'purchase_credit_fk_purchase'), + sa.ForeignKeyConstraint(['store_uuid'], [u'store.uuid'], name=u'purchase_credit_fk_store'), + sa.ForeignKeyConstraint(['vendor_uuid'], [u'vendor.uuid'], name=u'purchase_credit_fk_vendor'), + sa.ForeignKeyConstraint(['mispick_product_uuid'], [u'product.uuid'], name=u'purchase_credit_fk_mispick_product'), + sa.PrimaryKeyConstraint('uuid') + ) + + # purchase_batch_credit + op.create_table('purchase_batch_credit', + sa.Column('uuid', sa.String(length=32), nullable=False), + sa.Column('row_uuid', sa.String(length=32), nullable=True), + sa.Column('store_uuid', sa.String(length=32), nullable=False), + sa.Column('vendor_uuid', sa.String(length=32), nullable=False), + sa.Column('date_ordered', sa.Date(), nullable=True), + sa.Column('date_shipped', sa.Date(), nullable=True), + sa.Column('date_received', sa.Date(), nullable=True), + sa.Column('invoice_number', sa.String(length=20), nullable=True), + sa.Column('invoice_date', sa.Date(), nullable=True), + sa.Column('credit_type', sa.String(length=20), nullable=False), + sa.Column('product_uuid', sa.String(length=32), nullable=True), + sa.Column('upc', rattail.db.types.GPCType(), nullable=True), + sa.Column('brand_name', sa.String(length=100), nullable=True), + sa.Column('description', sa.String(length=60), nullable=False), + sa.Column('size', sa.String(length=255), nullable=True), + sa.Column('department_number', sa.Integer(), nullable=True), + sa.Column('department_name', sa.String(length=30), nullable=True), + sa.Column('case_quantity', sa.Numeric(precision=8, scale=2), nullable=True), + sa.Column('cases_shorted', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('units_shorted', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('expiration_date', sa.Date(), nullable=True), + sa.Column('invoice_line_number', sa.Integer(), nullable=True), + sa.Column('invoice_case_cost', sa.Numeric(precision=7, scale=3), nullable=True), + sa.Column('invoice_unit_cost', sa.Numeric(precision=7, scale=3), nullable=True), + sa.Column('invoice_total', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('mispick_brand_name', sa.String(length=100), nullable=True), + sa.Column('mispick_description', sa.String(length=60), nullable=True), + sa.Column('mispick_product_uuid', sa.String(length=32), nullable=True), + sa.Column('mispick_size', sa.String(length=255), nullable=True), + sa.Column('mispick_upc', rattail.db.types.GPCType(), nullable=True), + sa.ForeignKeyConstraint(['product_uuid'], [u'product.uuid'], name=u'purchase_batch_credit_fk_product'), + sa.ForeignKeyConstraint(['row_uuid'], [u'purchase_batch_row.uuid'], name=u'purchase_batch_credit_fk_row'), + sa.ForeignKeyConstraint(['store_uuid'], [u'store.uuid'], name=u'purchase_batch_credit_fk_store'), + sa.ForeignKeyConstraint(['vendor_uuid'], [u'vendor.uuid'], name=u'purchase_batch_credit_fk_vendor'), + sa.ForeignKeyConstraint(['mispick_product_uuid'], [u'product.uuid'], name=u'purchase_batch_credit_fk_mispick_product'), + sa.PrimaryKeyConstraint('uuid') + ) + + +def downgrade(): + + # purchase_batch_credit + op.drop_table('purchase_batch_credit') + + # purchase_credit + op.drop_table('purchase_credit') + + # purchase_item + op.drop_column('purchase_item', 'units_mispick') + op.drop_column('purchase_item', 'cases_mispick') + + # purchase_batch_row + op.drop_column('purchase_batch_row', 'units_mispick') + op.drop_column('purchase_batch_row', 'cases_mispick') diff --git a/rattail/db/model/__init__.py b/rattail/db/model/__init__.py index 68b888fd..e4c28ade 100644 --- a/rattail/db/model/__init__.py +++ b/rattail/db/model/__init__.py @@ -40,7 +40,8 @@ from .shifts import ScheduledShift, WorkedShift from .vendors import Vendor, VendorPhoneNumber, VendorEmailAddress, VendorContact from .products import Brand, Tax, Product, ProductCode, ProductCost, ProductPrice -from .purchase import PurchaseBase, PurchaseItemBase, Purchase, PurchaseItem +from .purchase import (PurchaseBase, PurchaseItemBase, PurchaseCreditBase, + Purchase, PurchaseItem, PurchaseCredit) from .messages import Message, MessageRecipient @@ -55,6 +56,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 +from .batch.purchase import PurchaseBatch, PurchaseBatchRow, 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 db743f8c..de5bee56 100644 --- a/rattail/db/model/batch/purchase.py +++ b/rattail/db/model/batch/purchase.py @@ -30,9 +30,10 @@ import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.declarative import declared_attr -from rattail.db.model import (Base, PurchaseBase, PurchaseItemBase, - Purchase, PurchaseItem, - BatchMixin, BatchRowMixin) +from rattail.db.model import (Base, uuid_column, BatchMixin, BatchRowMixin, + PurchaseBase, PurchaseItemBase, PurchaseCreditBase, + Purchase, PurchaseItem) +from rattail.util import pretty_quantity class PurchaseBatch(BatchMixin, PurchaseBase, Base): @@ -113,3 +114,51 @@ class PurchaseBatchRow(BatchRowMixin, PurchaseItemBase, Base): Reference to the purchase item with which the batch row is associated. May be null, e.g. in the case of a "new purchase" batch. """) + + +class PurchaseBatchCredit(PurchaseCreditBase, Base): + """ + Represents a working copy of purchase credit tied to a batch row. + """ + __tablename__ = 'purchase_batch_credit' + + @declared_attr + def __table_args__(cls): + return cls.__purchasecredit_table_args__() + ( + sa.ForeignKeyConstraint(['row_uuid'], ['purchase_batch_row.uuid'], name='purchase_batch_credit_fk_row'), + ) + + uuid = uuid_column() + + row_uuid = sa.Column(sa.String(length=32), nullable=True) + + row = orm.relationship( + PurchaseBatchRow, + doc=""" + Reference to the batch row with which the credit is associated. + """, + backref=orm.backref( + 'credits', + doc=""" + List of :class:`PurchaseBatchCredit` instances for the row. + """)) + + def __unicode__(self): + if self.cases_shorted is not None and self.units_shorted is not None: + qty = "{} cases, {} units".format( + pretty_quantity(self.cases_shorted), + pretty_quantity(self.units_shorted)) + elif self.cases_shorted is not None: + qty = "{} cases".format(pretty_quantity(self.cases_shorted)) + elif self.units_shorted is not None: + qty = "{} units".format(pretty_quantity(self.units_shorted)) + else: + qty = "??" + qty += " {}".format(self.credit_type) + if self.credit_type == 'expired' and self.expiration_date: + qty += " ({})".format(self.expiration_date) + if self.credit_type == 'mispick' and self.mispick_product: + qty += " ({})".format(self.mispick_product) + if self.invoice_total: + return "{} = ${:0.2f}".format(qty, self.invoice_total) + return qty diff --git a/rattail/db/model/purchase.py b/rattail/db/model/purchase.py index ef0f0213..fc17bb4e 100644 --- a/rattail/db/model/purchase.py +++ b/rattail/db/model/purchase.py @@ -251,6 +251,172 @@ class PurchaseItemBase(object): Number of units of product which were shipped expired. """) + cases_mispick = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc=""" + Number of cases of product for which mispick was shipped. + """) + + units_mispick = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc=""" + Number of units of product for which mispick was shipped. + """) + + +class PurchaseCreditBase(object): + """ + Base class for purchase credits. + """ + + @declared_attr + def __table_args__(cls): + return cls.__purchasecredit_table_args__() + + @classmethod + def __purchasecredit_table_args__(cls): + return ( + sa.ForeignKeyConstraint(['store_uuid'], ['store.uuid'], name='{}_fk_store'.format(cls.__tablename__)), + sa.ForeignKeyConstraint(['vendor_uuid'], ['vendor.uuid'], name='{}_fk_vendor'.format(cls.__tablename__)), + sa.ForeignKeyConstraint(['product_uuid'], ['product.uuid'], name='{}_fk_product'.format(cls.__tablename__)), + sa.ForeignKeyConstraint(['mispick_product_uuid'], ['product.uuid'], name='{}_fk_mispick_product'.format(cls.__tablename__)), + ) + + store_uuid = sa.Column(sa.String(length=32), nullable=False) + + @declared_attr + def store(cls): + return orm.relationship( + Store, + doc=""" + Reference to the :class:`Store` for which the purchase was made. + """) + + vendor_uuid = sa.Column(sa.String(length=32), nullable=False) + + @declared_attr + def vendor(cls): + return orm.relationship( + Vendor, + doc=""" + Reference to the :class:`Vendor` to which the purchase was made. + """) + + date_ordered = sa.Column(sa.Date(), nullable=True, doc=""" + Date on which the purchase order was first submitted to the vendor. + """) + + date_shipped = sa.Column(sa.Date(), nullable=True, doc=""" + Date on which the order was shipped from the vendor. + """) + + date_received = sa.Column(sa.Date(), nullable=True, doc=""" + Date on which the order was received at the store. + """) + + invoice_number = sa.Column(sa.String(length=20), nullable=True, doc=""" + Invoice number, e.g. for cross-reference with another system. + """) + + invoice_date = sa.Column(sa.Date(), nullable=True, doc=""" + Invoice date, if applicable. + """) + + credit_type = sa.Column(sa.String(length=20), nullable=False, doc=""" + Type of the credit, i.e. damaged/expired/mispick + """) + + product_uuid = sa.Column(sa.String(length=32), nullable=True) + + @declared_attr + def product(cls): + return orm.relationship( + Product, + foreign_keys=[cls.product_uuid], + doc=""" + Reference to the :class:`Product` with which the credit is associated. + """) + + upc = sa.Column(GPCType(), nullable=True, doc=""" + Product UPC for the credit item. + """) + + brand_name = sa.Column(sa.String(length=100), nullable=True, doc=""" + Brand name for the credit item. + """) + + description = sa.Column(sa.String(length=60), nullable=False, default='', doc=""" + Product description for the credit item. + """) + + size = sa.Column(sa.String(length=255), nullable=True, doc=""" + Product size for the credit item. + """) + + department_number = sa.Column(sa.Integer(), nullable=True, doc=""" + Number of the department to which the product belongs. + """) + + department_name = sa.Column(sa.String(length=30), nullable=True, doc=""" + Name of the department to which the product belongs. + """) + + case_quantity = sa.Column(sa.Numeric(precision=8, scale=2), nullable=True, doc=""" + Number of units in a single case of product. + """) + + cases_shorted = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc=""" + Number of cases of product which were ordered but not received. + """) + + units_shorted = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc=""" + Number of cases of product which were ordered but not received. + """) + + expiration_date = sa.Column(sa.Date(), nullable=True, doc=""" + Expiration date marked on expired product, if applicable. + """) + + invoice_line_number = sa.Column(sa.Integer(), nullable=True, doc=""" + Line number from the invoice if known, for cross-reference. + """) + + invoice_case_cost = sa.Column(sa.Numeric(precision=7, scale=3), nullable=True, doc=""" + Actual cost per case of product, per invoice. + """) + + invoice_unit_cost = sa.Column(sa.Numeric(precision=7, scale=3), nullable=True, doc=""" + Actual cost per single unit of product, per invoice. + """) + + invoice_total = sa.Column(sa.Numeric(precision=7, scale=2), nullable=True, doc=""" + Actual total cost for line item, per invoice. + """) + + mispick_product_uuid = sa.Column(sa.String(length=32), nullable=True) + + @declared_attr + def mispick_product(cls): + return orm.relationship( + Product, + foreign_keys=[cls.mispick_product_uuid], + doc=""" + Reference to the :class:`Product` which was shipped in place of the + one which was ordered. + """) + + mispick_upc = sa.Column(GPCType(), nullable=True, doc=""" + Product UPC for the mispick item. + """) + + mispick_brand_name = sa.Column(sa.String(length=100), nullable=True, doc=""" + Brand name for the mispick item. + """) + + mispick_description = sa.Column(sa.String(length=60), nullable=True, default='', doc=""" + Product description for the mispick item. + """) + + mispick_size = sa.Column(sa.String(length=255), nullable=True, doc=""" + Product size for the mispick item. + """) + class Purchase(PurchaseBase, Base): """ @@ -319,3 +485,44 @@ class PurchaseItem(PurchaseItemBase, Base): Numeric code used to signify current status for the line item, e.g. for highlighting rows when invoice cost differed from expected/PO cost (?) """) + + +class PurchaseCredit(PurchaseCreditBase, Base): + """ + Represents a purchase credit item. + """ + __tablename__ = 'purchase_credit' + + @declared_attr + def __table_args__(cls): + return cls.__purchasecredit_table_args__() + ( + sa.ForeignKeyConstraint(['purchase_uuid'], ['purchase.uuid'], name='purchase_credit_fk_purchase'), + sa.ForeignKeyConstraint(['item_uuid'], ['purchase_item.uuid'], name='purchase_credit_fk_item'), + ) + + uuid = uuid_column() + + purchase_uuid = sa.Column(sa.String(length=32), nullable=True) + + purchase = orm.relationship( + Purchase, + doc=""" + Reference to the :class:`Purchase` to which the credit applies. + """, + backref=orm.backref( + 'credits', + doc=""" + List of :class:`PurchaseCredit` instances for the purchase. + """)) + + item_uuid = sa.Column(sa.String(length=32), nullable=True) + + item = orm.relationship( + PurchaseItem, + doc=""" + Reference to the purchase item with which the credit is associated. + """) + + status = sa.Column(sa.Integer(), nullable=True, doc=""" + Numeric code used to signify current status for the credit. + """)