diff --git a/rattail/app.py b/rattail/app.py index 12095172f9d24c1b41346f59fdc89544f2d5c7a4..0566bc5c0413019b75ea61ab2ebe0ee083c202a7 100644 --- a/rattail/app.py +++ b/rattail/app.py @@ -529,6 +529,26 @@ class AppHandler(object): return format_phone_number(number) + def make_gpc(self, value, **kwargs): + """ + Convenience method; shortcut to + :meth:`rattail.products.ProductsHandler.make_upc()`. + """ + products_handler = self.get_products_handler() + return products_handler.make_gpc(value, **kwargs) + + def render_gpc(self, value, **kwargs): + """ + Returns a human-friendly display string for the given "upc" value. + + :param value: Should be a :class:`~rattail.gpc.GPC` instance. + """ + if value: + return value.pretty() + + # TODO: decide which one of these to stick with + render_upc = render_gpc + def render_currency(self, value, scale=2, **kwargs): """ Must return a human-friendly display string for the given currency diff --git a/rattail/batch/custorder.py b/rattail/batch/custorder.py index 4b1c50d960013362c131f1ebc814bef81b9d93ab..447fbce49258e0371ab00bc2ac188056527426ff 100644 --- a/rattail/batch/custorder.py +++ b/rattail/batch/custorder.py @@ -31,7 +31,6 @@ import decimal import six import sqlalchemy as sa -from sqlalchemy import orm from rattail.db import model from rattail.batch import BatchHandler @@ -84,16 +83,26 @@ class CustomerOrderBatchHandler(BatchHandler): """ return self.config.getbool('rattail.custorders', 'new_orders.allow_contact_info_choice', - default=True) + default=False) - def should_restrict_contact_info(self): + def allow_contact_info_creation(self): """ - Returns a boolean indicating whether contact info should be - "restricted" - i.e. user can only choose from existing contact - info and cannot override by e.g. entering a new phone number. + Returns a boolean indicating whether the user is allowed to + enter *new* contact info for the customer. This setting + should only be honored if :meth:`allow_contact_info_choice()` + also returns true. """ return self.config.getbool('rattail.custorders', - 'new_orders.restrict_contact_info', + 'new_orders.allow_contact_info_create', + default=False) + + def allow_unknown_product(self): + """ + Returns a boolean indicating whether "unknown" products are + allowed on new orders. + """ + return self.config.getbool('rattail.custorders', + 'allow_unknown_product', default=False) def product_price_may_be_questionable(self): @@ -460,12 +469,15 @@ class CustomerOrderBatchHandler(BatchHandler): info = { 'uuid': product.uuid, 'upc': six.text_type(product.upc), + 'item_id': product.item_id, + 'scancode': product.scancode, 'upc_pretty': product.upc.pretty(), 'brand_name': product.brand.name if product.brand else None, 'description': product.description, 'size': product.size, 'full_description': product.full_description, 'case_quantity': self.app.render_quantity(self.get_case_size_for_product(product)), + 'unit_price': float(product.regular_price.price) if product.regular_price and product.regular_price.price is not None else None, 'unit_price_display': products.render_price(product.regular_price), 'department_name': product.department.name if product.department else None, 'vendor_name': vendor.name if vendor else None, @@ -510,6 +522,28 @@ class CustomerOrderBatchHandler(BatchHandler): return info + def uom_choices_for_row(self, row): + """ + Return a list of UOM choices for the given batch row. + """ + if row.product: + return self.uom_choices_for_product(row.product) + + choices = [] + + # Each + unit_name = self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_EACH] + choices.append({'key': self.enum.UNIT_OF_MEASURE_EACH, + 'value': unit_name}) + + # Case + case_text = self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE] + if case_text: + choices.append({'key': self.enum.UNIT_OF_MEASURE_CASE, + 'value': case_text}) + + return choices + def uom_choices_for_product(self, product): """ Return a list of UOM choices for the given product. @@ -575,35 +609,135 @@ class CustomerOrderBatchHandler(BatchHandler): self.add_row(batch, row) return row + def add_pending_product(self, batch, pending_info, + order_quantity, order_uom, + **kwargs): + """ + Add a new row to the batch, for the given "pending" product + and order quantity. + + :param batch: Reference to the current order batch. + + :param pending_info: Dict of information about a new pending product. + + :param order_quantity: Quantity of the item to be added to the order. + + :param order_uom: Unit of measure for the order quantity. + """ + session = self.app.get_session(batch) + + # make a new pending product + pending_product = self.make_pending_product(**pending_info) + session.add(pending_product) + session.flush() + + # TODO: seems like make_pending_product() should do this? + if pending_product.department and not pending_product.department_name: + pending_product.department_name = pending_product.department.name + + # make a new row, w/ pending product + row = self.make_row() + row.pending_product = pending_product + row.order_quantity = order_quantity + row.order_uom = order_uom + self.add_row(batch, row) + return row + + def make_pending_product(self, **kwargs): + products_handler = self.app.get_products_handler() + if 'status_code' not in kwargs: + kwargs['status_code'] = self.enum.PENDING_PRODUCT_STATUS_PENDING + return products_handler.make_pending_product(**kwargs) + + def update_pending_product(self, row, data): + """ + Update the pending product data for the given batch row. + """ + # create pending product if needed + pending = row.pending_product + if not pending: + pending = self.make_pending_product(**data) + + simple_fields = [ + 'scancode', + 'item_id', + 'item_type', + 'department_name', + 'department_uuid', + 'brand_name', + 'brand_uuid', + 'description', + 'size', + 'vendor_name', + 'vendor_uuid', + 'vendor_item_code', + 'special_order', + 'notes', + ] + + decimal_fields = [ + 'unit_cost', + 'case_size', + 'case_cost', + 'regular_price_amount', + ] + + # update pending product info + if 'upc' in data: + pending.upc = self.app.make_gpc(data['upc']) if data['upc'] else None + for field in simple_fields: + if field in data: + setattr(pending, field, data[field]) + for field in decimal_fields: + if field in data: + value = data[field] + value = decimal.Decimal(value) if value is not None else None + setattr(pending, field, value) + + # refresh the row per new pending product info + self.refresh_row(row) + def refresh_row(self, row): if not row.product: if row.item_entry: - session = orm.object_session(row) - # TODO: should do more than just query for uuid here - product = session.query(model.Product).get(row.item_entry) + session = self.app.get_session(row) + product = self.locate_product_for_entry(session, row.item_entry) if product: row.product = product - if not row.product: + if not row.product and not row.pending_product: row.status_code = row.STATUS_PRODUCT_NOT_FOUND return product = row.product - row.product_upc = product.upc - row.product_brand = six.text_type(product.brand or "") - row.product_description = product.description - row.product_size = product.size - row.product_weighed = product.weighed - row.case_quantity = self.get_case_size_for_product(product) + if product: - department = product.department - row.department_number = department.number if department else None - row.department_name = department.name if department else None + row.product_upc = product.upc + row.product_item_id = product.item_id + row.product_scancode = product.scancode + row.product_brand = six.text_type(product.brand or "") + row.product_description = product.description + row.product_size = product.size + row.product_weighed = product.weighed + row.case_quantity = self.get_case_size_for_product(product) - cost = product.cost - row.product_unit_cost = cost.unit_cost if cost else None + department = product.department + row.department_number = department.number if department else None + row.department_name = department.name if department else None - regprice = product.regular_price - row.unit_price = regprice.price if regprice else None + cost = product.cost + row.product_unit_cost = cost.unit_cost if cost else None + + regprice = product.regular_price + row.unit_regular_price = regprice.price if regprice else None + + curprice = product.current_price + row.unit_sale_price = curprice.price if curprice else None + row.sale_ends = curprice.ends if curprice else None + + row.unit_price = row.unit_sale_price or row.unit_regular_price + + else: + self.refresh_row_from_pending_product(row) # we need to know if total price is updated old_total = row.total_price @@ -625,7 +759,47 @@ class CustomerOrderBatchHandler(BatchHandler): + (row.total_price or 0) - (old_total or 0)) - row.status_code = row.STATUS_OK + if not row.product and row.pending_product: + row.status_code = row.STATUS_PENDING_PRODUCT + else: + row.status_code = row.STATUS_OK + + def refresh_row_from_pending_product(self, row): + """ + Refresh basic row attributes from its pending product. + """ + pending = row.pending_product + if not pending: + return + + row.product_upc = pending.upc + row.product_item_id = pending.item_id + row.product_scancode = pending.scancode + row.product_brand = six.text_type(pending.brand or pending.brand_name or '') + row.product_description = pending.description + row.product_size = pending.size + # TODO: is this even important? pending does not have it + # row.product_weighed = pending.weighed + row.case_quantity = pending.case_size + + if pending.department: + row.department = pending.department + row.department_number = pending.department.number + row.department_name = pending.department.name + elif pending.department_name: + row.department = None + row.department_number = None + row.department_name = pending.department_name + else: + row.department = None + row.department_number = None + row.department_name = None + + row.product_unit_cost = pending.unit_cost + row.unit_regular_price = pending.regular_price_amount + row.unit_price = row.unit_regular_price + row.unit_sale_price = None + row.sale_ends = None def remove_row(self, row): batch = row.batch @@ -638,6 +812,27 @@ class CustomerOrderBatchHandler(BatchHandler): self.refresh_batch_status(batch) + def delete_extra_data(self, batch, progress=None, **kwargs): + + # do the normal stuff (e.g. delete input files) + super(CustomerOrderBatchHandler, self).delete_extra_data( + batch, progress=progress, **kwargs) + + session = self.app.get_session(batch) + + # delete pending customer if present + pending = batch.pending_customer + if pending: + batch.pending_customer = None + session.delete(pending) + + # delete any pending products if present + for row in batch.data_rows: + pending = row.pending_product + if pending: + row.pending_product = None + session.delete(pending) + def execute(self, batch, user=None, progress=None, **kwargs): """ Default behavior here will create and return a new rattail @@ -713,6 +908,7 @@ class CustomerOrderBatchHandler(BatchHandler): row_fields = [ 'product', + 'pending_product', 'product_upc', 'product_brand', 'product_description', @@ -747,7 +943,7 @@ class CustomerOrderBatchHandler(BatchHandler): self.progress_loop(convert, batch.active_rows(), progress, message="Converting batch rows to order items") - session = orm.object_session(batch) + session = self.app.get_session(batch) session.add(order) session.flush() diff --git a/rattail/db/alembic/versions/08cc2ef12c18_add_more_custorder_stuff.py b/rattail/db/alembic/versions/08cc2ef12c18_add_more_custorder_stuff.py new file mode 100644 index 0000000000000000000000000000000000000000..594ea4e04bff57ee6e33be567ea71addfdbd1c5e --- /dev/null +++ b/rattail/db/alembic/versions/08cc2ef12c18_add_more_custorder_stuff.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8; -*- +"""add more custorder stuff + +Revision ID: 08cc2ef12c18 +Revises: 3f3523703313 +Create Date: 2021-12-21 17:00:11.841243 + +""" + +from __future__ import unicode_literals + +# revision identifiers, used by Alembic. +revision = '08cc2ef12c18' +down_revision = '3f3523703313' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +import rattail.db.types + + + +def upgrade(): + + # pending_product + op.add_column('pending_product', sa.Column('vendor_name', sa.String(length=50), nullable=True)) + op.add_column('pending_product', sa.Column('vendor_uuid', sa.String(length=32), nullable=True)) + op.add_column('pending_product', sa.Column('vendor_item_code', sa.String(length=20), nullable=True)) + op.add_column('pending_product', sa.Column('unit_cost', sa.Numeric(precision=10, scale=5), nullable=True)) + op.create_foreign_key('pending_product_fk_vendor', 'pending_product', 'vendor', ['vendor_uuid'], ['uuid']) + + # batch_custorder_row + op.add_column('batch_custorder_row', sa.Column('pending_product_uuid', sa.String(length=32), nullable=True)) + op.create_foreign_key('batch_custorder_row_fk_pending_product', 'batch_custorder_row', 'pending_product', ['pending_product_uuid'], ['uuid']) + op.add_column('batch_custorder_row', sa.Column('unit_regular_price', sa.Numeric(precision=8, scale=3), nullable=True)) + op.add_column('batch_custorder_row', sa.Column('unit_sale_price', sa.Numeric(precision=8, scale=3), nullable=True)) + op.add_column('batch_custorder_row', sa.Column('sale_ends', sa.DateTime(), nullable=True)) + op.add_column('batch_custorder_row', sa.Column('product_scancode', sa.String(length=14), nullable=True)) + op.add_column('batch_custorder_row', sa.Column('product_item_id', sa.String(length=50), nullable=True)) + + # custorder_item + op.add_column('custorder_item', sa.Column('pending_product_uuid', sa.String(length=32), nullable=True)) + op.create_foreign_key('custorder_item_fk_pending_product', 'custorder_item', 'pending_product', ['pending_product_uuid'], ['uuid']) + op.add_column('custorder_item', sa.Column('unit_regular_price', sa.Numeric(precision=8, scale=3), nullable=True)) + op.add_column('custorder_item', sa.Column('unit_sale_price', sa.Numeric(precision=8, scale=3), nullable=True)) + op.add_column('custorder_item', sa.Column('sale_ends', sa.DateTime(), nullable=True)) + op.add_column('custorder_item', sa.Column('product_scancode', sa.String(length=14), nullable=True)) + op.add_column('custorder_item', sa.Column('product_item_id', sa.String(length=50), nullable=True)) + + +def downgrade(): + + # custorder_item + # op.drop_column('custorder_item', 'product_item_id') + # op.drop_column('custorder_item', 'product_scancode') + op.drop_column('custorder_item', 'sale_ends') + op.drop_column('custorder_item', 'unit_sale_price') + op.drop_column('custorder_item', 'unit_regular_price') + op.drop_constraint('custorder_item_fk_pending_product', 'custorder_item', type_='foreignkey') + op.drop_column('custorder_item', 'pending_product_uuid') + + # batch_custorder_row + # op.drop_column('batch_custorder_row', 'product_item_id') + # op.drop_column('batch_custorder_row', 'product_scancode') + op.drop_column('batch_custorder_row', 'sale_ends') + op.drop_column('batch_custorder_row', 'unit_sale_price') + op.drop_column('batch_custorder_row', 'unit_regular_price') + op.drop_constraint('batch_custorder_row_fk_pending_product', 'batch_custorder_row', type_='foreignkey') + op.drop_column('batch_custorder_row', 'pending_product_uuid') + + # pending_product + op.drop_constraint('pending_product_fk_vendor', 'pending_product', type_='foreignkey') + op.drop_column('pending_product', 'unit_cost') + op.drop_column('pending_product', 'vendor_item_code') + op.drop_column('pending_product', 'vendor_uuid') + op.drop_column('pending_product', 'vendor_name') diff --git a/rattail/db/model/batch/custorder.py b/rattail/db/model/batch/custorder.py index 8a4366843a6a751ffaca6d7812588c1238a5e0b0..d74877004e13c41539a9e06b4920bbe6fd50da53 100644 --- a/rattail/db/model/batch/custorder.py +++ b/rattail/db/model/batch/custorder.py @@ -94,11 +94,13 @@ class CustomerOrderBatchRow(BatchRowMixin, CustomerOrderItemBase, Base): STATUS_OK = 1 STATUS_PRODUCT_NOT_FOUND = 2 # STATUS_PRICE_NEEDS_CONFIRMATION = 3 + STATUS_PENDING_PRODUCT = 4 STATUS = { STATUS_OK : "ok", STATUS_PRODUCT_NOT_FOUND : "product not found", # STATUS_PRICE_NEEDS_CONFIRMATION : "price needs to be confirmed", + STATUS_PENDING_PRODUCT : "has pending product", } item_entry = sa.Column(sa.String(length=32), nullable=True, doc=""" diff --git a/rattail/db/model/custorders.py b/rattail/db/model/custorders.py index f1e302ca7715794caada4e03e85f6d23929cce84..9195294e7f7984e1e090de5f417fed70e0fa9c23 100644 --- a/rattail/db/model/custorders.py +++ b/rattail/db/model/custorders.py @@ -35,7 +35,8 @@ from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.declarative import declared_attr from rattail.db.model import Base, uuid_column -from rattail.db.model import Store, Customer, PendingCustomer, Person, Product, User, Note +from rattail.db.model import (Store, Customer, PendingCustomer, Person, + Product, PendingProduct, User, Note) from rattail.db.types import GPCType @@ -184,6 +185,8 @@ class CustomerOrderItemBase(object): return ( sa.ForeignKeyConstraint(['product_uuid'], ['product.uuid'], name='{}_fk_product'.format(table_name)), + sa.ForeignKeyConstraint(['pending_product_uuid'], ['pending_product.uuid'], + name='{}_fk_pending_product'.format(table_name)), ) product_uuid = sa.Column(sa.String(length=32), nullable=True) @@ -196,10 +199,29 @@ class CustomerOrderItemBase(object): Reference to the master product record for the line item. """) + pending_product_uuid = sa.Column(sa.String(length=32), nullable=True) + + @declared_attr + def pending_product(cls): + return orm.relationship( + PendingProduct, + doc=""" + Reference to the *pending* product record for the order + item, if applicable. + """) + product_upc = sa.Column(GPCType(), nullable=True, doc=""" UPC for the product associated with the row. """) + product_scancode = sa.Column(sa.String(length=14), nullable=True, doc=""" + Scancode for the product, if applicable. + """) + + product_item_id = sa.Column(sa.String(length=50), nullable=True, doc=""" + Item ID for the product, if applicable. + """) + product_brand = sa.Column(sa.String(length=100), nullable=True, doc=""" Brand name for the product being ordered. This should be a cache of the relevant :attr:`Brand.name`. @@ -273,6 +295,19 @@ class CustomerOrderItemBase(object): the relevant :attr:`ProductPrice.price`. """) + unit_regular_price = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc=""" + Regular price for the item unit. Note that if a sale price is in + effect, then this may differ from :attr:`unit_price`. + """) + + unit_sale_price = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc=""" + Sale price for the item unit, if applicable. + """) + + sale_ends = sa.Column(sa.DateTime(), nullable=True, doc=""" + End date/time for the sale in effect, if any. + """) + discount_percent = sa.Column(sa.Numeric(precision=5, scale=3), nullable=False, default=0, doc=""" Discount percentage which will be applied to the product's price as part of calculating the :attr:`total_price` for the item. diff --git a/rattail/db/model/products.py b/rattail/db/model/products.py index 050f1398453d23a3a897500ec9b990fb03bb769e..e32a0d2e519148d4add7628b3b1bcf1595f558c6 100644 --- a/rattail/db/model/products.py +++ b/rattail/db/model/products.py @@ -990,6 +990,7 @@ class PendingProduct(Base): sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'], name='pending_product_fk_user'), sa.ForeignKeyConstraint(['department_uuid'], ['department.uuid'], name='pending_product_fk_department'), sa.ForeignKeyConstraint(['brand_uuid'], ['brand.uuid'], name='pending_product_fk_brand'), + sa.ForeignKeyConstraint(['vendor_uuid'], ['vendor.uuid'], name='pending_product_fk_vendor'), ) uuid = uuid_column() @@ -1023,7 +1024,15 @@ class PendingProduct(Base): description = sa.Column(sa.String(length=255), nullable=True) size = sa.Column(sa.String(length=30), nullable=True) + + vendor_name = sa.Column(sa.String(length=50), nullable=True) + vendor_uuid = sa.Column(sa.String(length=32), nullable=True) + vendor = orm.relationship(Vendor) + + vendor_item_code = sa.Column(sa.String(length=20), nullable=True) + unit_cost = sa.Column(sa.Numeric(precision=10, scale=5), nullable=True) case_size = sa.Column(sa.Numeric(precision=9, scale=4), nullable=True) + regular_price_amount = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True) special_order = sa.Column(sa.Boolean(), nullable=True) notes = sa.Column(sa.Text(), nullable=True) diff --git a/rattail/products.py b/rattail/products.py index 2f0683b8815bfba0af112a8fe1f54213df47f11e..d449b49d5d434382dc6d535888e4cec466c53158 100644 --- a/rattail/products.py +++ b/rattail/products.py @@ -46,6 +46,52 @@ class ProductsHandler(GenericHandler): particular product can be deleted, etc. """ + def make_gpc(self, value, **kwargs): + """ + Try to convert the given value to a :class:`~rattail.gpc.GPC` + instance, and return the result. + """ + return GPC(value) + + def make_full_description(self, product=None, + brand_name=None, description=None, size=None, + **kwargs): + """ + Return a "full" description for the given product, or + attributes thereof. + + :param product: Optional, but can be a reference to either a + :class:`~rattail.db.model.products.Product` or + :class:`~rattail.db.model.products.PendingProduct` + instance. + + :param brand_name: Optional; brand name as string. If not + provided then will be obtained from ``product`` param. + + :param description: Optional; description as string. If not + provided then will be obtained from ``product`` param. + + :param size: Optional; size as string. If not provided then + will be obtained from ``product`` param. + """ + from rattail.db.util import make_full_description + + model = self.model + + if brand_name is None and product: + if product.brand: + brand_name = product.brand.name + elif isinstance(product, model.PendingProduct): + brand_name = product.brand_name + + if description is None and product: + description = product.description + + if size is None and product: + size = product.size + + return make_full_description(brand_name, description, size) + def find_products_by_key(self, session, value, **kwargs): """ Locate any products where the "key" matches the given value. @@ -252,6 +298,17 @@ class ProductsHandler(GenericHandler): self.app.render_currency(price.pack_price), price.pack_multiple) + def make_pending_product(self, **kwargs): + """ + Create and return a new + :class:`~rattail.db.model.products.PendingProduct` instance, + per the given kwargs. + """ + model = self.model + kwargs.setdefault('status_code', self.enum.PENDING_PRODUCT_STATUS_PENDING) + pending = model.PendingProduct(**kwargs) + return pending + def get_uom_sil_codes(self, session, uppercase=False, **kwargs): """ This should return a dict, keys of which are UOM abbreviation strings, diff --git a/rattail/templates/mail/new_email_requested.html.mako b/rattail/templates/mail/new_email_requested.html.mako index 27fde7a05d35785664e20212210d0a433f3107f7..4b52de7e091a41668f4702df51b15eb6946189be 100644 --- a/rattail/templates/mail/new_email_requested.html.mako +++ b/rattail/templates/mail/new_email_requested.html.mako @@ -13,7 +13,7 @@ Contact: ${contact_id} ${contact}