From a4ad23c7fab3877eeb34c010783efef66f0c1559 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 9 Jan 2025 12:13:58 -0600 Subject: [PATCH] feat: add basic support for local customer, product lookups also convert pending to local (where relevant) when executing batch --- docs/glossary.rst | 62 +- src/sideshow/batch/neworder.py | 605 ++++++++++---- .../7a6df83afbd4_initial_order_tables.py | 83 +- src/sideshow/db/model/__init__.py | 6 +- src/sideshow/db/model/batch/neworder.py | 56 +- src/sideshow/db/model/customers.py | 87 +- src/sideshow/db/model/orders.py | 62 +- src/sideshow/db/model/products.py | 112 ++- src/sideshow/web/forms/schema.py | 55 +- src/sideshow/web/menus.py | 48 +- .../web/templates/orders/configure.mako | 6 +- src/sideshow/web/templates/orders/create.mako | 343 ++++---- src/sideshow/web/views/batch/neworder.py | 18 +- src/sideshow/web/views/customers.py | 162 +++- src/sideshow/web/views/orders.py | 489 ++++++++--- src/sideshow/web/views/products.py | 192 ++++- tests/batch/test_neworder.py | 771 ++++++++++++++---- tests/db/model/test_orders.py | 20 +- tests/web/forms/test_schema.py | 50 ++ tests/web/test_menus.py | 2 +- tests/web/views/test_customers.py | 108 +++ tests/web/views/test_orders.py | 566 +++++++++++-- tests/web/views/test_products.py | 98 +++ 23 files changed, 3205 insertions(+), 796 deletions(-) diff --git a/docs/glossary.rst b/docs/glossary.rst index d639262..f4cf836 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -5,6 +5,46 @@ Glossary .. glossary:: :sorted: + external customer + A customer account from an external system. Sideshow can be + configured to lookup customer data from external system(s) when + creating an :term:`order`. + + See also :term:`local customer` and :term:`pending customer`. + + external product + A product record from an external system. Sideshow can be + configured to lookup customer data from external system(s) when + creating an :term:`order`. + + See also :term:`local product` and :term:`pending product`. + + local customer + A customer account in the :term:`app database`. By default, + Sideshow will use its native "Local Customers" table for lookup + when creating an :term:`order`. + + The data model for this is + :class:`~sideshow.db.model.customers.LocalCustomer`. + + See also :term:`external customer` and :term:`pending customer`. + + local product + A product record in the :term:`app database`. By default, + Sideshow will use its native "Local Products" table for lookup + when creating an :term:`order`. + + The data model for this is + :class:`~sideshow.db.model.products.LocalProduct`. + + See also :term:`external product` and :term:`pending product`. + + new order batch + When user is creating a new order, under the hood a :term:`batch` + is employed to keep track of user input. When user ultimately + "submits" the order, the batch is executed which creates a true + :term:`order`. + order This is the central focus of the app; it refers to a customer case/special order which is tracked over time, from placement to @@ -20,17 +60,19 @@ Glossary sibling items. pending customer - Generally refers to a "new / unknown" customer, e.g. for whom a - new order is being created. This allows the order lifecycle to - get going before the customer has a proper account in the system. + A "temporary" customer record used when creating an :term:`order` + for new/unknown customer. - See :class:`~sideshow.db.model.customers.PendingCustomer` for the - data model. + The data model for this is + :class:`~sideshow.db.model.customers.PendingCustomer`. + + See also :term:`external customer` and :term:`pending customer`. pending product - Generally refers to a "new / unknown" product, e.g. for which a - new order is being created. This allows the order lifecycle to - get going before the product has a true record in the system. + A "temporary" product record used when creating an :term:`order` + for new/unknown product. - See :class:`~sideshow.db.model.products.PendingProduct` for the - data model. + The data model for this is + :class:`~sideshow.db.model.products.PendingProduct`. + + See also :term:`external product` and :term:`pending product`. diff --git a/src/sideshow/batch/neworder.py b/src/sideshow/batch/neworder.py index 786a1e0..353af9c 100644 --- a/src/sideshow/batch/neworder.py +++ b/src/sideshow/batch/neworder.py @@ -27,6 +27,8 @@ New Order Batch Handler import datetime import decimal +import sqlalchemy as sa + from wuttjamaican.batch import BatchHandler from sideshow.db.model import NewOrderBatch @@ -34,7 +36,8 @@ from sideshow.db.model import NewOrderBatch class NewOrderBatchHandler(BatchHandler): """ - The :term:`batch handler` for New Order Batches. + The :term:`batch handler` for :term:`new order batches `. This is responsible for business logic around the creation of new :term:`orders `. A @@ -44,202 +47,333 @@ class NewOrderBatchHandler(BatchHandler): """ model_class = NewOrderBatch - def allow_unknown_product(self): + def use_local_customers(self): """ - Returns a boolean indicating whether "unknown" (pending) - products are allowed when creating a new order. + Returns boolean indicating whether :term:`local customer` + accounts should be used. This is true by default, but may be + false for :term:`external customer` lookups. """ - return self.config.get_bool('sideshow.orders.allow_unknown_product', + return self.config.get_bool('sideshow.orders.use_local_customers', default=True) - def set_pending_customer(self, batch, data): + def use_local_products(self): """ - Set (add or update) pending customer info for the batch. - - This will clear the - :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id` - and set the - :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`, - creating a new record if needed. It then updates the pending - customer record per the given ``data``. - - :param batch: - :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` - to be updated. - - :param data: Dict of field data for the - :class:`~sideshow.db.model.customers.PendingCustomer` - record. + Returns boolean indicating whether :term:`local product` + records should be used. This is true by default, but may be + false for :term:`external product` lookups. """ - model = self.app.model - enum = self.app.enum + return self.config.get_bool('sideshow.orders.use_local_products', + default=True) - # remove customer account if set - batch.customer_id = None - - # create pending customer if needed - pending = batch.pending_customer - if not pending: - kw = dict(data) - kw.setdefault('status', enum.PendingCustomerStatus.PENDING) - pending = model.PendingCustomer(**kw) - batch.pending_customer = pending - - # update pending customer - if 'first_name' in data: - pending.first_name = data['first_name'] - if 'last_name' in data: - pending.last_name = data['last_name'] - if 'full_name' in data: - pending.full_name = data['full_name'] - elif 'first_name' in data or 'last_name' in data: - pending.full_name = self.app.make_full_name(data.get('first_name'), - data.get('last_name')) - if 'phone_number' in data: - pending.phone_number = data['phone_number'] - if 'email_address' in data: - pending.email_address = data['email_address'] - - # update batch per pending customer - batch.customer_name = pending.full_name - batch.phone_number = pending.phone_number - batch.email_address = pending.email_address - - def add_pending_product(self, batch, pending_info, - order_qty, order_uom): + def allow_unknown_products(self): """ - Add a new row to the batch, for the given "pending" product - and order quantity. + Returns boolean indicating whether :term:`pending products + ` are allowed when creating an order. - See also :meth:`set_pending_product()` to update an existing row. + This is true by default, so user can enter new/unknown product + when creating an order. This can be disabled, to force user + to choose existing local/external product. + """ + return self.config.get_bool('sideshow.orders.allow_unknown_products', + default=True) + + def set_customer(self, batch, customer_info, user=None): + """ + Set/update customer info for the batch. + + This will first set one of the following: + + * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id` + * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.local_customer` + * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer` + + Note that a new + :class:`~sideshow.db.model.customers.PendingCustomer` record + is created if necessary. + + And then it will update these accordingly: + + * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_name` + * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.phone_number` + * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.email_address` + + Note that ``customer_info`` may be ``None``, which will cause + all the above to be set to ``None`` also. :param batch: :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to - which the row should be added. + update. - :param pending_info: Dict of kwargs to use when constructing a - new :class:`~sideshow.db.model.products.PendingProduct`. + :param customer_info: Customer ID string, or dict of + :class:`~sideshow.db.model.customers.PendingCustomer` data, + or ``None`` to clear the customer info. - :param order_qty: Quantity of the product to be added to the - order. - - :param order_uom: UOM for the order quantity; must be a code - from :data:`~sideshow.enum.ORDER_UOM`. - - :returns: - :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow` - which was added to the batch. + :param user: + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + is performing the action. This is used to set + :attr:`~sideshow.db.model.customers.PendingCustomer.created_by` + on the pending customer, if applicable. If not specified, + the batch creator is assumed. """ - if not self.allow_unknown_product(): - raise TypeError("unknown/pending product not allowed for new orders") - model = self.app.model enum = self.app.enum session = self.app.get_session(batch) + use_local = self.use_local_customers() + + # set customer info + if isinstance(customer_info, str): + if use_local: + + # local_customer + customer = session.get(model.LocalCustomer, customer_info) + if not customer: + raise ValueError("local customer not found") + batch.local_customer = customer + batch.customer_name = customer.full_name + batch.phone_number = customer.phone_number + batch.email_address = customer.email_address + + else: # external customer_id + #batch.customer_id = customer_info + raise NotImplementedError + + elif customer_info: + + # pending_customer + batch.customer_id = None + batch.local_customer = None + customer = batch.pending_customer + if not customer: + customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING, + created_by=user or batch.created_by) + session.add(customer) + batch.pending_customer = customer + fields = [ + 'full_name', + 'first_name', + 'last_name', + 'phone_number', + 'email_address', + ] + for key in fields: + setattr(customer, key, customer_info.get(key)) + if 'full_name' not in customer_info: + customer.full_name = self.app.make_full_name(customer.first_name, + customer.last_name) + batch.customer_name = customer.full_name + batch.phone_number = customer.phone_number + batch.email_address = customer.email_address + + else: + + # null + batch.customer_id = None + batch.local_customer = None + batch.customer_name = None + batch.phone_number = None + batch.email_address = None - # make new pending product - kw = dict(pending_info) - kw.setdefault('status', enum.PendingProductStatus.PENDING) - product = model.PendingProduct(**kw) - session.add(product) session.flush() - # nb. this may convert float to decimal etc. - session.refresh(product) - # make/add new row, w/ pending product - row = self.make_row(pending_product=product, - order_qty=order_qty, order_uom=order_uom) + def add_item(self, batch, product_info, order_qty, order_uom, user=None): + """ + Add a new item/row to the batch, for given product and quantity. + + See also :meth:`update_item()`. + + :param batch: + :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to + update. + + :param product_info: Product ID string, or dict of + :class:`~sideshow.db.model.products.PendingProduct` data. + + :param order_qty: + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty` + value for the new row. + + :param order_uom: + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom` + value for the new row. + + :param user: + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + is performing the action. This is used to set + :attr:`~sideshow.db.model.products.PendingProduct.created_by` + on the pending product, if applicable. If not specified, + the batch creator is assumed. + + :returns: + :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow` + instance. + """ + model = self.app.model + enum = self.app.enum + session = self.app.get_session(batch) + use_local = self.use_local_products() + row = self.make_row() + + # set product info + if isinstance(product_info, str): + if use_local: + + # local_product + local = session.get(model.LocalProduct, product_info) + if not local: + raise ValueError("local product not found") + row.local_product = local + + else: # external product_id + #row.product_id = product_info + raise NotImplementedError + + else: + # pending_product + if not self.allow_unknown_products(): + raise TypeError("unknown/pending product not allowed for new orders") + row.product_id = None + row.local_product = None + pending = model.PendingProduct(status=enum.PendingProductStatus.PENDING, + created_by=user or batch.created_by) + fields = [ + 'scancode', + 'brand_name', + 'description', + 'size', + 'weighed', + 'department_id', + 'department_name', + 'special_order', + 'vendor_name', + 'vendor_item_code', + 'case_size', + 'unit_cost', + 'unit_price_reg', + 'notes', + ] + for key in fields: + setattr(pending, key, product_info.get(key)) + + # nb. this may convert float to decimal etc. + session.add(pending) + session.flush() + session.refresh(pending) + row.pending_product = pending + + # set order info + row.order_qty = order_qty + row.order_uom = order_uom + + # add row to batch self.add_row(batch, row) - session.add(row) session.flush() return row - def set_pending_product(self, row, data): + def update_item(self, row, product_info, order_qty, order_uom, user=None): """ - Set (add or update) pending product info for the given batch row. + Update an item/row, per given product and quantity. - This will clear the - :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id` - and set the - :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`, - creating a new record if needed. It then updates the pending - product record per the given ``data``, and finally calls - :meth:`refresh_row()`. - - Note that this does not update order quantity for the item. - - See also :meth:`add_pending_product()` to add a new row - instead of updating. + See also :meth:`add_item()`. :param row: :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow` - to be updated. + to update. - :param data: Dict of field data for the - :class:`~sideshow.db.model.products.PendingProduct` record. + :param product_info: Product ID string, or dict of + :class:`~sideshow.db.model.products.PendingProduct` data. + + :param order_qty: New + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty` + value for the row. + + :param order_uom: New + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom` + value for the row. + + :param user: + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + is performing the action. This is used to set + :attr:`~sideshow.db.model.products.PendingProduct.created_by` + on the pending product, if applicable. If not specified, + the batch creator is assumed. """ - if not self.allow_unknown_product(): - raise TypeError("unknown/pending product not allowed for new orders") - model = self.app.model enum = self.app.enum session = self.app.get_session(row) + use_local = self.use_local_products() - # values for these fields can be used as-is - simple_fields = [ - 'scancode', - 'brand_name', - 'description', - 'size', - 'weighed', - 'department_id', - 'department_name', - 'special_order', - 'vendor_name', - 'vendor_item_code', - 'notes', - 'unit_cost', - 'case_size', - 'case_cost', - 'unit_price_reg', - ] + # set product info + if isinstance(product_info, str): + if use_local: - # clear true product id - row.product_id = None + # local_product + local = session.get(model.LocalProduct, product_info) + if not local: + raise ValueError("local product not found") + row.local_product = local - # make pending product if needed - product = row.pending_product - if not product: - kw = dict(data) - kw.setdefault('status', enum.PendingProductStatus.PENDING) - product = model.PendingProduct(**kw) - session.add(product) - row.pending_product = product + else: # external product_id + #row.product_id = product_info + raise NotImplementedError + + else: + # pending_product + if not self.allow_unknown_products(): + raise TypeError("unknown/pending product not allowed for new orders") + row.product_id = None + row.local_product = None + pending = row.pending_product + if not pending: + pending = model.PendingProduct(status=enum.PendingProductStatus.PENDING, + created_by=user or row.batch.created_by) + session.add(pending) + row.pending_product = pending + fields = [ + 'scancode', + 'brand_name', + 'description', + 'size', + 'weighed', + 'department_id', + 'department_name', + 'special_order', + 'vendor_name', + 'vendor_item_code', + 'case_size', + 'unit_cost', + 'unit_price_reg', + 'notes', + ] + for key in fields: + setattr(pending, key, product_info.get(key)) + + # nb. this may convert float to decimal etc. session.flush() + session.refresh(pending) - # update pending product - for field in simple_fields: - if field in data: - setattr(product, field, data[field]) + # set order info + row.order_qty = order_qty + row.order_uom = order_uom # nb. this may convert float to decimal etc. session.flush() - session.refresh(product) + session.refresh(row) # refresh per new info self.refresh_row(row) - def refresh_row(self, row, now=None): + def refresh_row(self, row): """ - Refresh all data for the row. This is called when adding a - new row to the batch, or anytime the row is updated (e.g. when + Refresh data for the row. This is called when adding a new + row to the batch, or anytime the row is updated (e.g. when changing order quantity). This calls one of the following to update product-related - attributes for the row: + attributes: + * :meth:`refresh_row_from_external_product()` + * :meth:`refresh_row_from_local_product()` * :meth:`refresh_row_from_pending_product()` - * :meth:`refresh_row_from_true_product()` It then re-calculates the row's :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price` @@ -253,7 +387,7 @@ class NewOrderBatchHandler(BatchHandler): row.status_text = None # ensure product - if not row.product_id and not row.pending_product: + if not row.product_id and not row.local_product and not row.pending_product: row.status_code = row.STATUS_MISSING_PRODUCT return @@ -264,7 +398,9 @@ class NewOrderBatchHandler(BatchHandler): # update product attrs on row if row.product_id: - self.refresh_row_from_true_product(row) + self.refresh_row_from_external_product(row) + elif row.local_product: + self.refresh_row_from_local_product(row) else: self.refresh_row_from_pending_product(row) @@ -276,7 +412,7 @@ class NewOrderBatchHandler(BatchHandler): row.case_price_quoted = None if row.unit_price_sale is not None and ( not row.sale_ends - or row.sale_ends > (now or datetime.datetime.now())): + or row.sale_ends > datetime.datetime.now()): row.unit_price_quoted = row.unit_price_sale else: row.unit_price_quoted = row.unit_price_reg @@ -304,16 +440,15 @@ class NewOrderBatchHandler(BatchHandler): # all ok row.status_code = row.STATUS_OK - def refresh_row_from_pending_product(self, row): + def refresh_row_from_local_product(self, row): """ Update product-related attributes on the row, from its - :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product` + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.local_product` record. This is called automatically from :meth:`refresh_row()`. """ - product = row.pending_product - + product = row.local_product row.product_scancode = product.scancode row.product_brand = product.brand_name row.product_description = product.description @@ -326,10 +461,31 @@ class NewOrderBatchHandler(BatchHandler): row.unit_cost = product.unit_cost row.unit_price_reg = product.unit_price_reg - def refresh_row_from_true_product(self, row): + def refresh_row_from_pending_product(self, row): """ - Update product-related attributes on the row, from its "true" - product record indicated by + Update product-related attributes on the row, from its + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product` + record. + + This is called automatically from :meth:`refresh_row()`. + """ + product = row.pending_product + row.product_scancode = product.scancode + row.product_brand = product.brand_name + row.product_description = product.description + row.product_size = product.size + row.product_weighed = product.weighed + row.department_id = product.department_id + row.department_name = product.department_name + row.special_order = product.special_order + row.case_size = product.case_size + row.unit_cost = product.unit_cost + row.unit_price_reg = product.unit_price_reg + + def refresh_row_from_external_product(self, row): + """ + Update product-related attributes on the row, from its + :term:`external product` record indicated by :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`. This is called automatically from :meth:`refresh_row()`. @@ -354,31 +510,39 @@ class NewOrderBatchHandler(BatchHandler): def do_delete(self, batch, user, **kwargs): """ - Delete the given batch entirely. + Delete a batch completely. - If the batch has a - :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer` - record, that is deleted also. + If the batch has :term:`pending customer` or :term:`pending + product` records, they are also deleted - unless still + referenced by some order(s). """ - # maybe delete pending customer record, if it only exists for - # sake of this batch - if batch.pending_customer: - if len(batch.pending_customer.new_order_batches) == 1: - # TODO: check for past orders too - session = self.app.get_session(batch) - session.delete(batch.pending_customer) + session = self.app.get_session(batch) + + # maybe delete pending customer + customer = batch.pending_customer + if customer and not customer.orders: + session.delete(customer) + + # maybe delete pending products + for row in batch.rows: + product = row.pending_product + if product and not product.order_items: + session.delete(product) # continue with normal deletion super().do_delete(batch, user, **kwargs) def why_not_execute(self, batch, **kwargs): """ - By default this checks to ensure the batch has a customer and - at least one item. + By default this checks to ensure the batch has a customer with + phone number, and at least one item. """ - if not batch.customer_id and not batch.pending_customer: + if not batch.customer_name: return "Must assign the customer" + if not batch.phone_number: + return "Customer phone number is required" + rows = self.get_effective_rows(batch) if not rows: return "Must add at least one valid item" @@ -395,17 +559,113 @@ class NewOrderBatchHandler(BatchHandler): def execute(self, batch, user=None, progress=None, **kwargs): """ - By default, this will call :meth:`make_new_order()` and return - the new :class:`~sideshow.db.model.orders.Order` instance. + Execute the batch; this should make a proper :term:`order`. + + By default, this will call: + + * :meth:`make_local_customer()` + * :meth:`make_local_products()` + * :meth:`make_new_order()` + + And will return the new + :class:`~sideshow.db.model.orders.Order` instance. Note that callers should use :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()` instead, which calls this method automatically. """ rows = self.get_effective_rows(batch) + self.make_local_customer(batch) + self.make_local_products(batch, rows) order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs) return order + def make_local_customer(self, batch): + """ + If applicable, this converts the batch :term:`pending + customer` into a :term:`local customer`. + + This is called automatically from :meth:`execute()`. + + This logic will happen only if :meth:`use_local_customers()` + returns true, and the batch has pending instead of local + customer (so far). + + It will create a new + :class:`~sideshow.db.model.customers.LocalCustomer` record and + populate it from the batch + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`. + The latter is then deleted. + """ + if not self.use_local_customers(): + return + + # nothing to do if no pending customer + pending = batch.pending_customer + if not pending: + return + + session = self.app.get_session(batch) + + # maybe convert pending to local customer + if not batch.local_customer: + model = self.app.model + inspector = sa.inspect(model.LocalCustomer) + local = model.LocalCustomer() + for prop in inspector.column_attrs: + if hasattr(pending, prop.key): + setattr(local, prop.key, getattr(pending, prop.key)) + session.add(local) + batch.local_customer = local + + # remove pending customer + batch.pending_customer = None + session.delete(pending) + session.flush() + + def make_local_products(self, batch, rows): + """ + If applicable, this converts all :term:`pending products + ` into :term:`local products `. + + This is called automatically from :meth:`execute()`. + + This logic will happen only if :meth:`use_local_products()` + returns true, and the batch has pending instead of local items + (so far). + + For each affected row, it will create a new + :class:`~sideshow.db.model.products.LocalProduct` record and + populate it from the row + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`. + The latter is then deleted. + """ + if not self.use_local_products(): + return + + model = self.app.model + session = self.app.get_session(batch) + inspector = sa.inspect(model.LocalProduct) + for row in rows: + + if row.local_product or not row.pending_product: + continue + + pending = row.pending_product + local = model.LocalProduct() + + for prop in inspector.column_attrs: + if hasattr(pending, prop.key): + setattr(local, prop.key, getattr(pending, prop.key)) + session.add(local) + + row.local_product = local + row.pending_product = None + session.delete(pending) + + session.flush() + def make_new_order(self, batch, rows, user=None, progress=None, **kwargs): """ Create a new :term:`order` from the batch data. @@ -429,6 +689,7 @@ class NewOrderBatchHandler(BatchHandler): batch_fields = [ 'store_id', 'customer_id', + 'local_customer', 'pending_customer', 'customer_name', 'phone_number', @@ -437,7 +698,9 @@ class NewOrderBatchHandler(BatchHandler): ] row_fields = [ - 'pending_product_uuid', + 'product_id', + 'local_product', + 'pending_product', 'product_scancode', 'product_brand', 'product_description', diff --git a/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py b/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py index da1d591..be6eee8 100644 --- a/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py +++ b/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py @@ -26,8 +26,8 @@ def upgrade() -> None: sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus').create(op.get_bind()) sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').create(op.get_bind()) - # sideshow_pending_customer - op.create_table('sideshow_pending_customer', + # sideshow_customer_pending + op.create_table('sideshow_customer_pending', sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), sa.Column('customer_id', sa.String(length=20), nullable=True), sa.Column('full_name', sa.String(length=100), nullable=True), @@ -38,12 +38,24 @@ def upgrade() -> None: sa.Column('status', postgresql.ENUM('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus', create_type=False), nullable=False), sa.Column('created', sa.DateTime(timezone=True), nullable=False), sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False), - sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_pending_customer_created_by_uuid_user')), - sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_pending_customer')) + sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_customer_pending_created_by_uuid_user')), + sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_customer_pending')) ) - # sideshow_pending_product - op.create_table('sideshow_pending_product', + # sideshow_customer_local + op.create_table('sideshow_customer_local', + sa.Column('full_name', sa.String(length=100), nullable=True), + sa.Column('first_name', sa.String(length=50), nullable=True), + sa.Column('last_name', sa.String(length=50), nullable=True), + sa.Column('phone_number', sa.String(length=20), nullable=True), + sa.Column('email_address', sa.String(length=255), nullable=True), + sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), + sa.Column('external_id', sa.String(length=20), nullable=True), + sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_customer_local')) + ) + + # sideshow_product_pending + op.create_table('sideshow_product_pending', sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), sa.Column('product_id', sa.String(length=20), nullable=True), sa.Column('scancode', sa.String(length=14), nullable=True), @@ -63,8 +75,29 @@ def upgrade() -> None: sa.Column('status', postgresql.ENUM('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus', create_type=False), nullable=False), sa.Column('created', sa.DateTime(timezone=True), nullable=False), sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False), - sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_pending_product_created_by_uuid_user')), - sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_pending_product')) + sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_product_pending_created_by_uuid_user')), + sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_product_pending')) + ) + + # sideshow_product_local + op.create_table('sideshow_product_local', + sa.Column('scancode', sa.String(length=14), nullable=True), + sa.Column('brand_name', sa.String(length=100), nullable=True), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('size', sa.String(length=30), nullable=True), + sa.Column('weighed', sa.Boolean(), nullable=True), + sa.Column('department_id', sa.String(length=10), nullable=True), + sa.Column('department_name', sa.String(length=30), nullable=True), + sa.Column('special_order', sa.Boolean(), nullable=True), + sa.Column('vendor_name', sa.String(length=50), nullable=True), + sa.Column('vendor_item_code', sa.String(length=20), nullable=True), + sa.Column('case_size', sa.Numeric(precision=9, scale=4), nullable=True), + sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True), + sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), + sa.Column('external_id', sa.String(length=20), nullable=True), + sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_product_local')) ) # sideshow_order @@ -73,6 +106,7 @@ def upgrade() -> None: sa.Column('order_id', sa.Integer(), nullable=False), sa.Column('store_id', sa.String(length=10), nullable=True), sa.Column('customer_id', sa.String(length=20), nullable=True), + sa.Column('local_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True), sa.Column('pending_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True), sa.Column('customer_name', sa.String(length=100), nullable=True), sa.Column('phone_number', sa.String(length=20), nullable=True), @@ -80,7 +114,8 @@ def upgrade() -> None: sa.Column('total_price', sa.Numeric(precision=10, scale=3), nullable=True), sa.Column('created', sa.DateTime(timezone=True), nullable=False), sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False), - sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid'], name=op.f('fk_order_pending_customer_uuid_pending_customer')), + sa.ForeignKeyConstraint(['local_customer_uuid'], ['sideshow_customer_local.uuid'], name=op.f('fk_order_local_customer_uuid_local_customer')), + sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_customer_pending.uuid'], name=op.f('fk_order_pending_customer_uuid_pending_customer')), sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_order_created_by_uuid_user')), sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order')) ) @@ -91,6 +126,7 @@ def upgrade() -> None: sa.Column('order_uuid', wuttjamaican.db.util.UUID(), nullable=False), sa.Column('sequence', sa.Integer(), nullable=False), sa.Column('product_id', sa.String(length=20), nullable=True), + sa.Column('local_product_uuid', wuttjamaican.db.util.UUID(), nullable=True), sa.Column('pending_product_uuid', wuttjamaican.db.util.UUID(), nullable=True), sa.Column('product_scancode', sa.String(length=14), nullable=True), sa.Column('product_brand', sa.String(length=100), nullable=True), @@ -115,7 +151,8 @@ def upgrade() -> None: sa.Column('paid_amount', sa.Numeric(precision=8, scale=3), nullable=False), sa.Column('payment_transaction_number', sa.String(length=20), nullable=True), sa.ForeignKeyConstraint(['order_uuid'], ['sideshow_order.uuid'], name=op.f('fk_sideshow_order_item_order_uuid_order')), - sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid'], name=op.f('fk_sideshow_order_item_pending_product_uuid_pending_product')), + sa.ForeignKeyConstraint(['local_product_uuid'], ['sideshow_product_local.uuid'], name=op.f('fk_sideshow_order_item_local_product_uuid_local_product')), + sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_product_pending.uuid'], name=op.f('fk_sideshow_order_item_pending_product_uuid_pending_product')), sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order_item')) ) @@ -134,6 +171,7 @@ def upgrade() -> None: sa.Column('executed_by_uuid', wuttjamaican.db.util.UUID(), nullable=True), sa.Column('store_id', sa.String(length=10), nullable=True), sa.Column('customer_id', sa.String(length=20), nullable=True), + sa.Column('local_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True), sa.Column('pending_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True), sa.Column('customer_name', sa.String(length=100), nullable=True), sa.Column('phone_number', sa.String(length=20), nullable=True), @@ -141,7 +179,8 @@ def upgrade() -> None: sa.Column('total_price', sa.Numeric(precision=10, scale=3), nullable=True), sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_batch_neworder_created_by_uuid_user')), sa.ForeignKeyConstraint(['executed_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_batch_neworder_executed_by_uuid_user')), - sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid'], name=op.f('fk_sideshow_batch_neworder_pending_customer_uuid_pending_customer')), + sa.ForeignKeyConstraint(['local_customer_uuid'], ['sideshow_customer_local.uuid'], name=op.f('fk_sideshow_batch_neworder_local_customer_uuid_local_customer')), + sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_customer_pending.uuid'], name=op.f('fk_sideshow_batch_neworder_pending_customer_uuid_pending_customer')), sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_batch_neworder')) ) @@ -152,8 +191,9 @@ def upgrade() -> None: sa.Column('sequence', sa.Integer(), nullable=False), sa.Column('status_text', sa.String(length=255), nullable=True), sa.Column('modified', sa.DateTime(timezone=True), nullable=True), - sa.Column('pending_product_uuid', wuttjamaican.db.util.UUID(), nullable=True), sa.Column('product_id', sa.String(length=20), nullable=True), + sa.Column('local_product_uuid', wuttjamaican.db.util.UUID(), nullable=True), + sa.Column('pending_product_uuid', wuttjamaican.db.util.UUID(), nullable=True), sa.Column('product_scancode', sa.String(length=14), nullable=True), sa.Column('product_brand', sa.String(length=100), nullable=True), sa.Column('product_description', sa.String(length=255), nullable=True), @@ -173,9 +213,10 @@ def upgrade() -> None: sa.Column('case_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True), sa.Column('discount_percent', sa.Numeric(precision=5, scale=3), nullable=True), sa.Column('total_price', sa.Numeric(precision=8, scale=3), nullable=True), - sa.Column('status_code', sa.Integer(), nullable=False), + sa.Column('status_code', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['batch_uuid'], ['sideshow_batch_neworder.uuid'], name=op.f('fk_sideshow_batch_neworder_row_batch_uuid_batch_neworder')), - sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid'], name=op.f('fk_sideshow_batch_neworder_row_pending_product_uuid_pending_product')), + sa.ForeignKeyConstraint(['local_product_uuid'], ['sideshow_product_local.uuid'], name=op.f('fk_sideshow_batch_neworder_row_local_product_uuid_local_product')), + sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_product_pending.uuid'], name=op.f('fk_sideshow_batch_neworder_row_pending_product_uuid_pending_product')), sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_batch_neworder_row')) ) @@ -192,11 +233,17 @@ def downgrade() -> None: # sideshow_order op.drop_table('sideshow_order') - # sideshow_pending_product - op.drop_table('sideshow_pending_product') + # sideshow_product_local + op.drop_table('sideshow_product_local') - # sideshow_pending_customer - op.drop_table('sideshow_pending_customer') + # sideshow_product_pending + op.drop_table('sideshow_product_pending') + + # sideshow_customer_local + op.drop_table('sideshow_customer_local') + + # sideshow_customer_pending + op.drop_table('sideshow_customer_pending') # enums sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').drop(op.get_bind()) diff --git a/src/sideshow/db/model/__init__.py b/src/sideshow/db/model/__init__.py index 1395d59..28d09f3 100644 --- a/src/sideshow/db/model/__init__.py +++ b/src/sideshow/db/model/__init__.py @@ -32,6 +32,8 @@ Primary :term:`data models `: * :class:`~sideshow.db.model.orders.Order` * :class:`~sideshow.db.model.orders.OrderItem` +* :class:`~sideshow.db.model.customers.LocalCustomer` +* :class:`~sideshow.db.model.products.LocalProduct` * :class:`~sideshow.db.model.customers.PendingCustomer` * :class:`~sideshow.db.model.products.PendingProduct` @@ -45,8 +47,8 @@ And the :term:`batch` models: from wuttjamaican.db.model import * # sideshow models -from .customers import PendingCustomer -from .products import PendingProduct +from .customers import LocalCustomer, PendingCustomer +from .products import LocalProduct, PendingProduct from .orders import Order, OrderItem # batch models diff --git a/src/sideshow/db/model/batch/neworder.py b/src/sideshow/db/model/batch/neworder.py index f121b5c..9121dc6 100644 --- a/src/sideshow/db/model/batch/neworder.py +++ b/src/sideshow/db/model/batch/neworder.py @@ -58,7 +58,8 @@ class NewOrderBatch(model.BatchMixin, model.Base): @declared_attr def __table_args__(cls): return cls.__default_table_args__() + ( - sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid']), + sa.ForeignKeyConstraint(['local_customer_uuid'], ['sideshow_customer_local.uuid']), + sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_customer_pending.uuid']), ) STATUS_OK = 1 @@ -72,13 +73,27 @@ class NewOrderBatch(model.BatchMixin, model.Base): """) customer_id = sa.Column(sa.String(length=20), nullable=True, doc=""" - ID of the proper customer account to which the order pertains, if - applicable. + Proper account ID for the :term:`external customer` to which the + order pertains, if applicable. - This will be set only when an "existing" customer account can be - selected for the order. See also :attr:`pending_customer`. + See also :attr:`local_customer` and :attr:`pending_customer`. """) + local_customer_uuid = sa.Column(model.UUID(), nullable=True) + + @declared_attr + def local_customer(cls): + return orm.relationship( + 'LocalCustomer', + back_populates='new_order_batches', + doc=""" + Reference to the + :class:`~sideshow.db.model.customers.LocalCustomer` record + for the order, if applicable. + + See also :attr:`customer_id` and :attr:`pending_customer`. + """) + pending_customer_uuid = sa.Column(model.UUID(), nullable=True) @declared_attr @@ -91,8 +106,7 @@ class NewOrderBatch(model.BatchMixin, model.Base): :class:`~sideshow.db.model.customers.PendingCustomer` record for the order, if applicable. - This is set only when making an order for a "new / - unknown" customer. See also :attr:`customer_id`. + See also :attr:`customer_id` and :attr:`local_customer`. """) customer_name = sa.Column(sa.String(length=100), nullable=True, doc=""" @@ -126,7 +140,8 @@ class NewOrderBatchRow(model.BatchRowMixin, model.Base): @declared_attr def __table_args__(cls): return cls.__default_table_args__() + ( - sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid']), + sa.ForeignKeyConstraint(['local_product_uuid'], ['sideshow_product_local.uuid']), + sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_product_pending.uuid']), ) STATUS_OK = 1 @@ -158,13 +173,27 @@ class NewOrderBatchRow(model.BatchRowMixin, model.Base): """ product_id = sa.Column(sa.String(length=20), nullable=True, doc=""" - ID of the true product which the order item represents, if - applicable. + Proper ID for the :term:`external product` which the order item + represents, if applicable. - This will be set only when an "existing" product can be selected - for the order. See also :attr:`pending_product`. + See also :attr:`local_product` and :attr:`pending_product`. """) + local_product_uuid = sa.Column(model.UUID(), nullable=True) + + @declared_attr + def local_product(cls): + return orm.relationship( + 'LocalProduct', + back_populates='new_order_batch_rows', + doc=""" + Reference to the + :class:`~sideshow.db.model.products.LocalProduct` record + for the order item, if applicable. + + See also :attr:`product_id` and :attr:`pending_product`. + """) + pending_product_uuid = sa.Column(model.UUID(), nullable=True) @declared_attr @@ -177,8 +206,7 @@ class NewOrderBatchRow(model.BatchRowMixin, model.Base): :class:`~sideshow.db.model.products.PendingProduct` record for the order item, if applicable. - This is set only when making an order for a "new / - unknown" product. See also :attr:`product_id`. + See also :attr:`product_id` and :attr:`local_product`. """) product_scancode = sa.Column(sa.String(length=14), nullable=True, doc=""" diff --git a/src/sideshow/db/model/customers.py b/src/sideshow/db/model/customers.py index f845b30..aa62098 100644 --- a/src/sideshow/db/model/customers.py +++ b/src/sideshow/db/model/customers.py @@ -34,19 +34,13 @@ from wuttjamaican.db import model from sideshow.enum import PendingCustomerStatus -class PendingCustomer(model.Base): +class CustomerMixin: """ - A "pending" customer record, used when entering an :term:`order` - for new/unknown customer. + Base class for customer tables. This has shared columns, used by e.g.: + + * :class:`LocalCustomer` + * :class:`PendingCustomer` """ - __tablename__ = 'sideshow_pending_customer' - - uuid = model.uuid_column() - - customer_id = sa.Column(sa.String(length=20), nullable=True, doc=""" - ID of the proper customer account associated with this record, if - applicable. - """) full_name = sa.Column(sa.String(length=100), nullable=True, doc=""" Full display name for the customer account. @@ -68,6 +62,74 @@ class PendingCustomer(model.Base): Email address for the customer. """) + def __str__(self): + return self.full_name or "" + + +class LocalCustomer(CustomerMixin, model.Base): + """ + This table contains the :term:`local customer` records. + + Sideshow will do customer lookups against this table by default, + unless it's configured to use :term:`external customers ` instead. + + Also by default, when a :term:`new order batch` with a + :term:`pending customer` is executed, a new record is added to + this local customers table, for lookup next time. + """ + __tablename__ = 'sideshow_customer_local' + + uuid = model.uuid_column() + + external_id = sa.Column(sa.String(length=20), nullable=True, doc=""" + ID of the proper customer account associated with this record, if + applicable. + """) + + orders = orm.relationship( + 'Order', + order_by='Order.order_id.desc()', + back_populates='local_customer', + cascade_backrefs=False, + doc=""" + List of :class:`~sideshow.db.model.orders.Order` records + associated with this customer. + """) + + new_order_batches = orm.relationship( + 'NewOrderBatch', + order_by='NewOrderBatch.id.desc()', + back_populates='local_customer', + cascade_backrefs=False, + doc=""" + List of + :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` + records associated with this customer. + """) + + +class PendingCustomer(CustomerMixin, model.Base): + """ + This table contains the :term:`pending customer` records, used + when creating an :term:`order` for new/unknown customer. + + Sideshow will automatically create and (hopefully) delete these + records as needed. + + By default, when a :term:`new order batch` with a pending customer + is executed, a new record is added to the :term:`local customers + ` table, for lookup next time. + """ + __tablename__ = 'sideshow_customer_pending' + + uuid = model.uuid_column() + + customer_id = sa.Column(sa.String(length=20), nullable=True, doc=""" + ID of the proper customer account associated with this record, if + applicable. + """) + status = sa.Column(sa.Enum(PendingCustomerStatus), nullable=False, doc=""" Status code for the customer record. """) @@ -107,6 +169,3 @@ class PendingCustomer(model.Base): :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` records associated with this customer. """) - - def __str__(self): - return self.full_name or "" diff --git a/src/sideshow/db/model/orders.py b/src/sideshow/db/model/orders.py index c3392f7..f694028 100644 --- a/src/sideshow/db/model/orders.py +++ b/src/sideshow/db/model/orders.py @@ -63,14 +63,26 @@ class Order(model.Base): """) customer_id = sa.Column(sa.String(length=20), nullable=True, doc=""" - ID of the proper customer account to which the order pertains, if - applicable. + Proper account ID for the :term:`external customer` to which the + order pertains, if applicable. - This will be set only when an "existing" customer account can be - assigned for the order. See also :attr:`pending_customer`. + See also :attr:`local_customer` and :attr:`pending_customer`. """) - pending_customer_uuid = model.uuid_fk_column('sideshow_pending_customer.uuid', nullable=True) + local_customer_uuid = model.uuid_fk_column('sideshow_customer_local.uuid', nullable=True) + local_customer = orm.relationship( + 'LocalCustomer', + cascade_backrefs=False, + back_populates='orders', + doc=""" + Reference to the + :class:`~sideshow.db.model.customers.LocalCustomer` record + for the order, if applicable. + + See also :attr:`customer_id` and :attr:`pending_customer`. + """) + + pending_customer_uuid = model.uuid_fk_column('sideshow_customer_pending.uuid', nullable=True) pending_customer = orm.relationship( 'PendingCustomer', cascade_backrefs=False, @@ -80,8 +92,7 @@ class Order(model.Base): :class:`~sideshow.db.model.customers.PendingCustomer` record for the order, if applicable. - This is set only when the order is for a "new / unknown" - customer. See also :attr:`customer_id`. + See also :attr:`customer_id` and :attr:`local_customer`. """) customer_name = sa.Column(sa.String(length=100), nullable=True, doc=""" @@ -158,14 +169,26 @@ class OrderItem(model.Base): """) product_id = sa.Column(sa.String(length=20), nullable=True, doc=""" - ID of the true product which the order item represents, if - applicable. + Proper ID for the :term:`external product` which the order item + represents, if applicable. - This will be set only when an "existing" product can be selected - for the order. See also :attr:`pending_product`. + See also :attr:`local_product` and :attr:`pending_product`. """) - pending_product_uuid = model.uuid_fk_column('sideshow_pending_product.uuid', nullable=True) + local_product_uuid = model.uuid_fk_column('sideshow_product_local.uuid', nullable=True) + local_product = orm.relationship( + 'LocalProduct', + cascade_backrefs=False, + back_populates='order_items', + doc=""" + Reference to the + :class:`~sideshow.db.model.products.LocalProduct` record for + the order item, if applicable. + + See also :attr:`product_id` and :attr:`pending_product`. + """) + + pending_product_uuid = model.uuid_fk_column('sideshow_product_pending.uuid', nullable=True) pending_product = orm.relationship( 'PendingProduct', cascade_backrefs=False, @@ -175,8 +198,7 @@ class OrderItem(model.Base): :class:`~sideshow.db.model.products.PendingProduct` record for the order item, if applicable. - This is set only when the order item is for a "new / unknown" - product. See also :attr:`product_id`. + See also :attr:`product_id` and :attr:`local_product`. """) product_scancode = sa.Column(sa.String(length=14), nullable=True, doc=""" @@ -310,5 +332,15 @@ class OrderItem(model.Base): applicable/known. """) + @property + def full_description(self): + """ """ + fields = [ + self.product_brand or '', + self.product_description or '', + self.product_size or ''] + fields = [f.strip() for f in fields if f.strip()] + return ' '.join(fields) + def __str__(self): - return str(self.pending_product or self.product_description or "") + return self.full_description diff --git a/src/sideshow/db/model/products.py b/src/sideshow/db/model/products.py index 6113621..70c166d 100644 --- a/src/sideshow/db/model/products.py +++ b/src/sideshow/db/model/products.py @@ -34,18 +34,13 @@ from wuttjamaican.db import model from sideshow.enum import PendingProductStatus -class PendingProduct(model.Base): +class ProductMixin: """ - A "pending" product record, used when entering an :term:`order - item` for new/unknown product. + Base class for product tables. This has shared columns, used by e.g.: + + * :class:`LocalProduct` + * :class:`PendingProduct` """ - __tablename__ = 'sideshow_pending_product' - - uuid = model.uuid_column() - - product_id = sa.Column(sa.String(length=20), nullable=True, doc=""" - ID of the true product associated with this record, if applicable. - """) scancode = sa.Column(sa.String(length=14), nullable=True, doc=""" Scancode for the product, as string. @@ -117,6 +112,82 @@ class PendingProduct(model.Base): Arbitrary notes regarding the product, if applicable. """) + @property + def full_description(self): + """ """ + fields = [ + self.brand_name or '', + self.description or '', + self.size or ''] + fields = [f.strip() for f in fields if f.strip()] + return ' '.join(fields) + + def __str__(self): + return self.full_description + + +class LocalProduct(ProductMixin, model.Base): + """ + This table contains the :term:`local product` records. + + Sideshow will do customer lookups against this table by default, + unless it's configured to use :term:`external products ` instead. + + Also by default, when a :term:`new order batch` with + :term:`pending product(s) ` is executed, new + record(s) will be added to this local products table, for lookup + next time. + """ + __tablename__ = 'sideshow_product_local' + + uuid = model.uuid_column() + + external_id = sa.Column(sa.String(length=20), nullable=True, doc=""" + ID of the true external product associated with this record, if + applicable. + """) + + order_items = orm.relationship( + 'OrderItem', + back_populates='local_product', + cascade_backrefs=False, + doc=""" + List of :class:`~sideshow.db.model.orders.OrderItem` records + associated with this product. + """) + + new_order_batch_rows = orm.relationship( + 'NewOrderBatchRow', + back_populates='local_product', + cascade_backrefs=False, + doc=""" + List of + :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow` + records associated with this product. + """) + + +class PendingProduct(ProductMixin, model.Base): + """ + This table contains the :term:`pending product` records, used when + creating an :term:`order` for new/unknown product(s). + + Sideshow will automatically create and (hopefully) delete these + records as needed. + + By default, when a :term:`new order batch` with pending product(s) + is executed, new record(s) will be added to the :term:`local + products ` table, for lookup next time. + """ + __tablename__ = 'sideshow_product_pending' + + uuid = model.uuid_column() + + product_id = sa.Column(sa.String(length=20), nullable=True, doc=""" + ID of the true product associated with this record, if applicable. + """) + status = sa.Column(sa.Enum(PendingProductStatus), nullable=False, doc=""" Status code for the product record. """) @@ -138,10 +209,8 @@ class PendingProduct(model.Base): order_items = orm.relationship( 'OrderItem', - # TODO - # order_by='NewOrderBatchRow.id.desc()', - cascade_backrefs=False, back_populates='pending_product', + cascade_backrefs=False, doc=""" List of :class:`~sideshow.db.model.orders.OrderItem` records associated with this product. @@ -149,25 +218,10 @@ class PendingProduct(model.Base): new_order_batch_rows = orm.relationship( 'NewOrderBatchRow', - # TODO - # order_by='NewOrderBatchRow.id.desc()', - cascade_backrefs=False, back_populates='pending_product', + cascade_backrefs=False, doc=""" List of :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow` records associated with this product. """) - - @property - def full_description(self): - """ """ - fields = [ - self.brand_name or '', - self.description or '', - self.size or ''] - fields = [f.strip() for f in fields if f.strip()] - return ' '.join(fields) - - def __str__(self): - return self.full_description diff --git a/src/sideshow/web/forms/schema.py b/src/sideshow/web/forms/schema.py index 4b78a4b..85269c9 100644 --- a/src/sideshow/web/forms/schema.py +++ b/src/sideshow/web/forms/schema.py @@ -29,7 +29,7 @@ from wuttaweb.forms.schema import ObjectRef class OrderRef(ObjectRef): """ - Custom schema type for an :class:`~sideshow.db.model.orders.Order` + Schema type for an :class:`~sideshow.db.model.orders.Order` reference field. This is a subclass of @@ -51,9 +51,34 @@ class OrderRef(ObjectRef): return self.request.route_url('orders.view', uuid=order.uuid) +class LocalCustomerRef(ObjectRef): + """ + Schema type for a + :class:`~sideshow.db.model.customers.LocalCustomer` reference + field. + + This is a subclass of + :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`. + """ + + @property + def model_class(self): + """ """ + model = self.app.model + return model.LocalCustomer + + def sort_query(self, query): + """ """ + return query.order_by(self.model_class.full_name) + + def get_object_url(self, customer): + """ """ + return self.request.route_url('local_customers.view', uuid=customer.uuid) + + class PendingCustomerRef(ObjectRef): """ - Custom schema type for a + Schema type for a :class:`~sideshow.db.model.customers.PendingCustomer` reference field. @@ -76,9 +101,33 @@ class PendingCustomerRef(ObjectRef): return self.request.route_url('pending_customers.view', uuid=customer.uuid) +class LocalProductRef(ObjectRef): + """ + Schema type for a + :class:`~sideshow.db.model.products.LocalProduct` reference field. + + This is a subclass of + :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`. + """ + + @property + def model_class(self): + """ """ + model = self.app.model + return model.LocalProduct + + def sort_query(self, query): + """ """ + return query.order_by(self.model_class.scancode) + + def get_object_url(self, product): + """ """ + return self.request.route_url('local_products.view', uuid=product.uuid) + + class PendingProductRef(ObjectRef): """ - Custom schema type for a + Schema type for a :class:`~sideshow.db.model.products.PendingProduct` reference field. diff --git a/src/sideshow/web/menus.py b/src/sideshow/web/menus.py index 5feb017..2c319cc 100644 --- a/src/sideshow/web/menus.py +++ b/src/sideshow/web/menus.py @@ -36,14 +36,15 @@ class SideshowMenuHandler(base.MenuHandler): """ """ return [ self.make_orders_menu(request), - self.make_pending_menu(request), + self.make_customers_menu(request), + self.make_products_menu(request), self.make_batch_menu(request), self.make_admin_menu(request), ] def make_orders_menu(self, request, **kwargs): """ - Generate a typical Orders menu. + Generate the Orders menu. """ return { 'title': "Orders", @@ -55,34 +56,55 @@ class SideshowMenuHandler(base.MenuHandler): 'perm': 'orders.create', }, {'type': 'sep'}, - { - 'title': "All Orders", - 'route': 'orders', - 'perm': 'orders.list', - }, { 'title': "All Order Items", 'route': 'order_items', 'perm': 'order_items.list', }, + { + 'title': "All Orders", + 'route': 'orders', + 'perm': 'orders.list', + }, ], } - def make_pending_menu(self, request, **kwargs): + def make_customers_menu(self, request, **kwargs): """ - Generate a typical Pending menu. + Generate the Customers menu. """ return { - 'title': "Pending", + 'title': "Customers", 'type': 'menu', 'items': [ { - 'title': "Customers", + 'title': "Local Customers", + 'route': 'local_customers', + 'perm': 'local_customers.list', + }, + { + 'title': "Pending Customers", 'route': 'pending_customers', 'perm': 'pending_customers.list', }, + ], + } + + def make_products_menu(self, request, **kwargs): + """ + Generate the Products menu. + """ + return { + 'title': "Products", + 'type': 'menu', + 'items': [ { - 'title': "Products", + 'title': "Local Products", + 'route': 'local_products', + 'perm': 'local_products.list', + }, + { + 'title': "Pending Products", 'route': 'pending_products', 'perm': 'pending_products.list', }, @@ -91,7 +113,7 @@ class SideshowMenuHandler(base.MenuHandler): def make_batch_menu(self, request, **kwargs): """ - Generate a typical Batch menu. + Generate the Batch menu. """ return { 'title': "Batches", diff --git a/src/sideshow/web/templates/orders/configure.mako b/src/sideshow/web/templates/orders/configure.mako index 044d1fd..4dc23f4 100644 --- a/src/sideshow/web/templates/orders/configure.mako +++ b/src/sideshow/web/templates/orders/configure.mako @@ -7,15 +7,15 @@
- Allow creating orders for "unknown" products -

diff --git a/src/sideshow/web/templates/orders/create.mako b/src/sideshow/web/templates/orders/create.mako index 967a803..72345b5 100644 --- a/src/sideshow/web/templates/orders/create.mako +++ b/src/sideshow/web/templates/orders/create.mako @@ -130,28 +130,20 @@

- - + :style="{'flex-grow': customerID ? '0' : '1'}" + expanded /> - {{ refreshingCustomer ? "Refreshig" : "Refresh" }} + {{ refreshingCustomer ? "Refreshing" : "Refresh" }}
@@ -348,9 +340,9 @@ <${b}-modal % if request.use_oruga: - v-model:active="showingItemDialog" + v-model:active="editItemShowDialog" % else: - :active.sync="showingItemDialog" + :active.sync="editItemShowDialog" % endif :can-cancel="['escape', 'x']" > @@ -382,21 +374,12 @@
- - +
@@ -443,16 +426,16 @@
- +##

@@ -705,7 +688,7 @@
- + Cancel - {{ itemDialogSaving ? "Working, please wait..." : (this.editingItem ? "Update Item" : "Add Item") }} + {{ itemDialogSaving ? "Working, please wait..." : (this.editItemRow ? "Update Item" : "Add Item") }}
@@ -757,8 +740,10 @@ <${b}-table-column label="Unit Price" v-slot="props"> - - {{ props.row.unit_price_display }} + + {{ props.row.unit_price_quoted_display }} @@ -771,17 +756,15 @@ <${b}-table-column label="Vendor" v-slot="props"> - {{ props.row.vendor_display }} + {{ props.row.vendor_name }} <${b}-table-column field="actions" label="Actions" v-slot="props"> + @click.prevent="editItemInit(props.row)"> + % if request.use_oruga: @@ -846,13 +829,14 @@ batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n}, customerPanelOpen: false, - customerIsKnown: ${json.dumps(bool(batch.customer_id))|n}, - customerID: ${json.dumps(batch.customer_id)|n}, - customerName: ${json.dumps(batch.customer_name)|n}, - orderPhoneNumber: ${json.dumps(batch.phone_number)|n}, - orderEmailAddress: ${json.dumps(batch.email_address)|n}, + customerIsKnown: ${json.dumps(customer_is_known)|n}, + customerID: ${json.dumps(customer_id)|n}, + customerName: ${json.dumps(customer_name)|n}, + orderPhoneNumber: ${json.dumps(phone_number)|n}, + orderEmailAddress: ${json.dumps(email_address)|n}, refreshingCustomer: false, + newCustomerFullName: ${json.dumps(new_customer_full_name)|n}, newCustomerFirstName: ${json.dumps(new_customer_first_name)|n}, newCustomerLastName: ${json.dumps(new_customer_last_name)|n}, newCustomerPhone: ${json.dumps(new_customer_phone)|n}, @@ -867,8 +851,8 @@ items: ${json.dumps(order_items)|n}, - editingItem: null, - showingItemDialog: false, + editItemRow: null, + editItemShowDialog: false, itemDialogSaving: false, % if request.use_oruga: itemDialogTab: 'product', @@ -921,20 +905,8 @@ customerPanelHeader() { let text = "Customer" - if (this.customerIsKnown) { - if (this.customerID) { - ## TODO - text = "Customer: TODO" -## if (this.$refs.customerAutocomplete) { -## text = "Customer: " + this.$refs.customerAutocomplete.getDisplayText() -## } else { -## text = "Customer: " + this.customerName -## } - } - } else { - if (this.customerName) { - text = "Customer: " + this.customerName - } + if (this.customerName) { + text = "Customer: " + this.customerName } if (!this.customerPanelOpen) { @@ -1077,41 +1049,29 @@ } }, -## TODO -## watch: { -## -## contactIsKnown: function(val) { -## -## // when user clicks "contact is known" then we want to -## // set focus to the autocomplete component -## if (val) { -## this.$nextTick(() => { -## this.$refs.customerAutocomplete.focus() -## }) -## -## // if user has already specified a proper contact, -## // i.e. `contactUUID` is not null, *and* user has -## // clicked the "contact is not yet in the system" -## // button, i.e. `val` is false, then we want to *clear -## // out* the existing contact selection. this is -## // primarily to avoid any ambiguity. -## } else if (this.contactUUID) { -## this.$refs.customerAutocomplete.clearSelection() -## } -## }, -## -## productIsKnown(newval, oldval) { -## // TODO: seems like this should be better somehow? -## // e.g. maybe we should not be clearing *everything* -## // in case user accidentally clicks, and then clicks -## // "is known" again? and if we *should* clear all, -## // why does that require 2 steps? -## if (!newval) { -## this.selectedProduct = null -## this.clearProduct() -## } -## }, -## }, + watch: { + + customerIsKnown: function(val) { + + if (val) { + // user clicks "customer is in the system" + + // clear customer + this.customerChanged(null) + + // focus customer autocomplete + this.$nextTick(() => { + this.$refs.customerAutocomplete.focus() + }) + + } else { + // user clicks "customer is NOT in the system" + + // remove true customer; set pending (or null) + this.setPendingCustomer() + } + }, + }, methods: { @@ -1157,7 +1117,7 @@ this.submittingOrder = true const params = { - action: 'submit_new_order', + action: 'submit_order', } this.submitBatchData(params, response => { @@ -1173,29 +1133,28 @@ customerChanged(customerID, callback) { - let params - if (!customerID) { - params = { - action: 'unassign_contact', - } + const params = {} + if (customerID) { + params.action = 'assign_customer' + params.customer_id = customerID } else { - params = { - action: 'assign_contact', - customer_id: customerID, - } + params.action = 'unassign_customer' } - this.submitBatchData(params, response => { - this.customerID = response.data.customer_id - this.customerName = response.data.customer_name - this.orderPhoneNumber = response.data.phone_number - this.orderEmailAddress = response.data.email_address - this.addOtherPhoneNumber = response.data.add_phone_number - this.addOtherEmailAddress = response.data.add_email_address - this.contactPhones = response.data.contact_phones - this.contactEmails = response.data.contact_emails + + this.submitBatchData(params, ({data}) => { + this.customerID = data.customer_id + this.customerName = data.customer_name + this.orderPhoneNumber = data.phone_number + this.orderEmailAddress = data.email_address if (callback) { callback() } + }, response => { + this.$buefy.toast.open({ + message: "Update failed: " + (response.data.error || "(unknown error)"), + type: 'is-danger', + duration: 2000, // 2 seconds + }) }) }, @@ -1234,7 +1193,8 @@ } this.submitBatchData(params, response => { - this.customerName = response.data.new_customer_name + this.customerName = response.data.new_customer_full_name + this.newCustomerFullName = response.data.new_customer_full_name this.newCustomerFirstName = response.data.new_customer_first_name this.newCustomerLastName = response.data.new_customer_last_name this.newCustomerPhone = response.data.phone_number @@ -1254,6 +1214,40 @@ }, + // remove true customer; set pending customer if present + // (else null). this happens when user clicks "customer is + // NOT in the system" + setPendingCustomer() { + + let params + if (this.newCustomerFirstName) { + params = { + action: 'set_pending_customer', + first_name: this.newCustomerFirstName, + last_name: this.newCustomerLastName, + phone_number: this.newCustomerPhone, + email_address: this.newCustomerEmail, + } + } else { + params = { + action: 'unassign_customer', + } + } + + this.submitBatchData(params, ({data}) => { + this.customerID = data.customer_id + this.customerName = data.new_customer_full_name + this.orderPhoneNumber = data.phone_number + this.orderEmailAddress = data.email_address + }, response => { + this.$buefy.toast.open({ + message: "Update failed: " + (response.data.error || "(unknown error)"), + type: 'is-danger', + duration: 2000, // 2 seconds + }) + }) + }, + getCasePriceDisplay() { if (this.productIsKnown) { return this.productCasePriceDisplay @@ -1308,6 +1302,76 @@ } }, + clearProduct() { + this.productID = null + this.productDisplay = null + this.productScancode = null + this.productSize = null + this.productCaseQuantity = null + this.productUnitPrice = null + this.productUnitPriceDisplay = null + this.productUnitRegularPriceDisplay = null + this.productCasePrice = null + this.productCasePriceDisplay = null + this.productSalePrice = null + this.productSalePriceDisplay = null + this.productSaleEndsDisplay = null + this.productUnitChoices = this.defaultUnitChoices + }, + + productChanged(productID) { + if (productID) { + const params = { + action: 'get_product_info', + product_id: productID, + } + // nb. it is possible for the handler to "swap" + // the product selection, i.e. user chooses a "per + // LB" item but the handler only allows selling by + // the "case" item. so we do not assume the uuid + // received above is the correct one, but just use + // whatever came back from handler + this.submitBatchData(params, ({data}) => { + this.selectedProduct = data + + this.productID = data.product_id + this.productScancode = data.scancode + this.productDisplay = data.full_description + this.productSize = data.size + this.productCaseQuantity = data.case_size + + // TODO: what is the difference here + this.productUnitPrice = data.unit_price_reg + this.productUnitPriceDisplay = data.unit_price_reg_display + this.productUnitRegularPriceDisplay = data.unit_price_display + + this.productCasePrice = data.case_price_quoted + this.productCasePriceDisplay = data.case_price_quoted_display + + this.productSalePrice = data.unit_price_sale + this.productSalePriceDisplay = data.unit_price_sale_display + this.productSaleEndsDisplay = data.sale_ends_display + + // this.setProductUnitChoices(data.uom_choices) + + % if request.use_oruga: + this.itemDialogTab = 'quantity' + % else: + this.itemDialogTabIndex = 1 + % endif + + // nb. hack to force refresh for vue3 + this.refreshProductDescription += 1 + this.refreshTotalPrice += 1 + + }, response => { + this.clearProduct() + }) + } else { + this.clearProduct() + } + }, + ## TODO ## productLookupSelected(selected) { ## // TODO: this still is a hack somehow, am sure of it. @@ -1335,7 +1399,7 @@ showAddItemDialog() { this.customerPanelOpen = false - this.editingItem = null + this.editItemRow = null this.productIsKnown = true ## this.selectedProduct = null this.productID = null @@ -1364,14 +1428,15 @@ % else: this.itemDialogTabIndex = 0 % endif - this.showingItemDialog = true + this.editItemShowDialog = true this.$nextTick(() => { - this.$refs.productLookup.focus() + // this.$refs.productLookup.focus() + this.$refs.productAutocomplete.focus() }) }, - showEditItemDialog(row) { - this.editingItem = row + editItemInit(row) { + this.editItemRow = row this.productIsKnown = !!row.product_id this.productID = row.product_id @@ -1397,8 +1462,7 @@ this.productDisplay = row.product_full_description this.productScancode = row.product_scancode this.productSize = row.product_size - this.productCaseQuantity = row.case_quantity - this.productURL = row.product_url + this.productCaseQuantity = row.case_size this.productUnitPrice = row.unit_price_quoted this.productUnitPriceDisplay = row.unit_price_quoted_display this.productUnitRegularPriceDisplay = row.unit_price_reg_display @@ -1422,7 +1486,7 @@ % else: this.itemDialogTabIndex = 1 % endif - this.showingItemDialog = true + this.editItemShowDialog = true }, deleteItem(index) { @@ -1451,25 +1515,20 @@ itemDialogAttemptSave() { this.itemDialogSaving = true - let params = { - product_is_known: this.productIsKnown, + const params = { order_qty: this.productQuantity, order_uom: this.productUOM, } - % if allow_item_discounts: - params.discount_percent = this.productDiscountPercent - % endif - if (this.productIsKnown) { - params.product_uuid = this.productUUID + params.product_info = this.productID } else { - params.pending_product = this.pendingProduct + params.product_info = this.pendingProduct } - if (this.editingItem) { + if (this.editItemRow) { params.action = 'update_item' - params.uuid = this.editingItem.uuid + params.uuid = this.editItemRow.uuid } else { params.action = 'add_item' } @@ -1484,7 +1543,7 @@ // overwriting the item record, or else display will // not update properly for (let [key, value] of Object.entries(response.data.row)) { - this.editingItem[key] = value + this.editItemRow[key] = value } } @@ -1492,7 +1551,7 @@ this.batchTotalPriceDisplay = response.data.batch.total_price_display this.itemDialogSaving = false - this.showingItemDialog = false + this.editItemShowDialog = false }, response => { this.itemDialogSaving = false }) diff --git a/src/sideshow/web/views/batch/neworder.py b/src/sideshow/web/views/batch/neworder.py index 0c0aad5..5e45da1 100644 --- a/src/sideshow/web/views/batch/neworder.py +++ b/src/sideshow/web/views/batch/neworder.py @@ -29,7 +29,7 @@ from wuttaweb.forms.schema import WuttaMoney from sideshow.db.model import NewOrderBatch from sideshow.batch.neworder import NewOrderBatchHandler -from sideshow.web.forms.schema import PendingCustomerRef +from sideshow.web.forms.schema import LocalCustomerRef, PendingCustomerRef class NewOrderBatchView(BatchMasterView): @@ -87,6 +87,7 @@ class NewOrderBatchView(BatchMasterView): 'id', 'store_id', 'customer_id', + 'local_customer', 'pending_customer', 'customer_name', 'phone_number', @@ -115,9 +116,11 @@ class NewOrderBatchView(BatchMasterView): 'product_description', 'product_size', 'special_order', + 'unit_price_quoted', + 'case_size', + 'case_price_quoted', 'order_qty', 'order_uom', - 'case_size', 'total_price', 'status_code', ] @@ -138,6 +141,9 @@ class NewOrderBatchView(BatchMasterView): """ """ super().configure_form(f) + # local_customer + f.set_node('local_customer', LocalCustomerRef(self.request)) + # pending_customer f.set_node('pending_customer', PendingCustomerRef(self.request)) @@ -153,6 +159,14 @@ class NewOrderBatchView(BatchMasterView): # order_uom #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.ORDER_UOM) + # unit_price_quoted + g.set_label('unit_price_quoted', "Unit Price", column_only=True) + g.set_renderer('unit_price_quoted', 'currency') + + # case_price_quoted + g.set_label('case_price_quoted', "Case Price", column_only=True) + g.set_renderer('case_price_quoted', 'currency') + # total_price g.set_renderer('total_price', 'currency') diff --git a/src/sideshow/web/views/customers.py b/src/sideshow/web/views/customers.py index 9d1720e..850ec5e 100644 --- a/src/sideshow/web/views/customers.py +++ b/src/sideshow/web/views/customers.py @@ -27,7 +27,161 @@ Views for Customers from wuttaweb.views import MasterView from wuttaweb.forms.schema import UserRef, WuttaEnum -from sideshow.db.model import PendingCustomer +from sideshow.db.model import LocalCustomer, PendingCustomer + + +class LocalCustomerView(MasterView): + """ + Master view for + :class:`~sideshow.db.model.customers.LocalCustomer`; route prefix + is ``local_customers``. + + Notable URLs provided by this class: + + * ``/local/customers/`` + * ``/local/customers/new`` + * ``/local/customers/XXX`` + * ``/local/customers/XXX/edit`` + * ``/local/customers/XXX/delete`` + """ + model_class = LocalCustomer + model_title = "Local Customer" + route_prefix = 'local_customers' + url_prefix = '/local/customers' + + labels = { + 'external_id': "External ID", + } + + grid_columns = [ + 'external_id', + 'full_name', + 'first_name', + 'last_name', + 'phone_number', + 'email_address', + ] + + sort_defaults = 'full_name' + + form_fields = [ + 'external_id', + 'full_name', + 'first_name', + 'last_name', + 'phone_number', + 'email_address', + 'orders', + 'new_order_batches', + ] + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + # links + g.set_link('full_name') + g.set_link('first_name') + g.set_link('last_name') + g.set_link('phone_number') + g.set_link('email_address') + + def configure_form(self, f): + """ """ + super().configure_form(f) + customer = f.model_instance + + # external_id + if self.creating: + f.remove('external_id') + else: + f.set_readonly('external_id') + + # full_name + if self.creating or self.editing: + f.remove('full_name') + + # orders + if self.creating or self.editing: + f.remove('orders') + else: + f.set_grid('orders', self.make_orders_grid(customer)) + + # new_order_batches + if self.creating or self.editing: + f.remove('new_order_batches') + else: + f.set_grid('new_order_batches', self.make_new_order_batches_grid(customer)) + + def make_orders_grid(self, customer): + """ + Make and return the grid for the Orders field. + """ + model = self.app.model + route_prefix = self.get_route_prefix() + + grid = self.make_grid(key=f'{route_prefix}.view.orders', + model_class=model.Order, + data=customer.orders, + columns=[ + 'order_id', + 'total_price', + 'created', + 'created_by', + ], + labels={ + 'order_id': "Order ID", + }) + grid.set_renderer('total_price', grid.render_currency) + + if self.request.has_perm('orders.view'): + url = lambda order, i: self.request.route_url('orders.view', uuid=order.uuid) + grid.add_action('view', icon='eye', url=url) + grid.set_link('order_id') + + return grid + + def make_new_order_batches_grid(self, customer): + """ + Make and return the grid for the New Order Batches field. + """ + model = self.app.model + route_prefix = self.get_route_prefix() + + grid = self.make_grid(key=f'{route_prefix}.view.new_order_batches', + model_class=model.NewOrderBatch, + data=customer.new_order_batches, + columns=[ + 'id', + 'total_price', + 'created', + 'created_by', + 'executed', + ], + labels={ + 'id': "Batch ID", + }, + renderers={ + 'id': 'batch_id', + 'total_price': 'currency', + }) + + if self.request.has_perm('neworder_batches.view'): + url = lambda batch, i: self.request.route_url('neworder_batches.view', uuid=batch.uuid) + grid.add_action('view', icon='eye', url=url) + grid.set_link('id') + + return grid + + def objectify(self, form): + """ """ + enum = self.app.enum + customer = super().objectify(form) + + customer.full_name = self.app.make_full_name(customer.first_name, + customer.last_name) + + return customer class PendingCustomerView(MasterView): @@ -71,12 +225,9 @@ class PendingCustomerView(MasterView): 'customer_id', 'full_name', 'first_name', - 'middle_name', 'last_name', 'phone_number', - 'phone_type', 'email_address', - 'email_type', 'status', 'created', 'created_by', @@ -238,6 +389,9 @@ class PendingCustomerView(MasterView): def defaults(config, **kwargs): base = globals() + LocalCustomerView = kwargs.get('LocalCustomerView', base['LocalCustomerView']) + LocalCustomerView.defaults(config) + PendingCustomerView = kwargs.get('PendingCustomerView', base['PendingCustomerView']) PendingCustomerView.defaults(config) diff --git a/src/sideshow/web/views/orders.py b/src/sideshow/web/views/orders.py index ae580fe..bbd5811 100644 --- a/src/sideshow/web/views/orders.py +++ b/src/sideshow/web/views/orders.py @@ -31,11 +31,13 @@ import colander from sqlalchemy import orm from wuttaweb.views import MasterView -from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum +from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum from sideshow.db.model import Order, OrderItem from sideshow.batch.neworder import NewOrderBatchHandler -from sideshow.web.forms.schema import OrderRef, PendingCustomerRef, PendingProductRef +from sideshow.web.forms.schema import (OrderRef, + LocalCustomerRef, LocalProductRef, + PendingCustomerRef, PendingProductRef) log = logging.getLogger(__name__) @@ -82,6 +84,7 @@ class OrderView(MasterView): 'order_id', 'store_id', 'customer_id', + 'local_customer', 'pending_customer', 'customer_name', 'phone_number', @@ -165,6 +168,20 @@ class OrderView(MasterView): batch, which in turn creates a true :class:`~sideshow.db.model.orders.Order`, and user is redirected to the "view order" page. + + See also these methods which may be called from this one, + based on user actions: + + * :meth:`start_over()` + * :meth:`cancel_order()` + * :meth:`assign_customer()` + * :meth:`unassign_customer()` + * :meth:`set_pending_customer()` + * :meth:`get_product_info()` + * :meth:`add_item()` + * :meth:`update_item()` + * :meth:`delete_item()` + * :meth:`submit_order()` """ enum = self.app.enum self.creating = True @@ -188,22 +205,25 @@ class OrderView(MasterView): data = dict(self.request.json_body) action = data.pop('action') json_actions = [ - # 'assign_contact', - # 'unassign_contact', + 'assign_customer', + 'unassign_customer', # 'update_phone_number', # 'update_email_address', 'set_pending_customer', # 'get_customer_info', # # 'set_customer_data', - # 'get_product_info', + 'get_product_info', # 'get_past_items', 'add_item', 'update_item', 'delete_item', - 'submit_new_order', + 'submit_order', ] if action in json_actions: - result = getattr(self, action)(batch, data) + try: + result = getattr(self, action)(batch, data) + except Exception as error: + result = {'error': self.app.render_error(error)} return self.json_response(result) return self.json_response({'error': "unknown form action"}) @@ -215,8 +235,8 @@ class OrderView(MasterView): for row in batch.rows], 'default_uom_choices': self.get_default_uom_choices(), 'default_uom': None, # TODO? - 'allow_unknown_product': (self.batch_handler.allow_unknown_product() - and self.has_perm('create_unknown_product')), + 'allow_unknown_products': (self.batch_handler.allow_unknown_products() + and self.has_perm('create_unknown_product')), 'pending_product_required_fields': self.get_pending_product_required_fields(), }) return self.render_to_response('create', context) @@ -255,6 +275,96 @@ class OrderView(MasterView): return batch + def customer_autocomplete(self): + """ + AJAX view for customer autocomplete, when entering new order. + + This should invoke a configured handler for the autocomplete + behavior, but that is not yet implemented. For now it uses + built-in logic only, which queries the + :class:`~sideshow.db.model.customers.LocalCustomer` table. + """ + session = self.Session() + term = self.request.GET.get('term', '').strip() + if not term: + return [] + return self.mock_autocomplete_customers(session, term, user=self.request.user) + + # TODO: move this to some handler + def mock_autocomplete_customers(self, session, term, user=None): + """ """ + import sqlalchemy as sa + + model = self.app.model + + # base query + query = session.query(model.LocalCustomer) + + # filter query + criteria = [model.LocalCustomer.full_name.ilike(f'%{word}%') + for word in term.split()] + query = query.filter(sa.and_(*criteria)) + + # sort query + query = query.order_by(model.LocalCustomer.full_name) + + # get data + # TODO: need max_results option + customers = query.all() + + # get results + def result(customer): + return {'value': customer.uuid.hex, + 'label': customer.full_name} + return [result(c) for c in customers] + + def product_autocomplete(self): + """ + AJAX view for product autocomplete, when entering new order. + + This should invoke a configured handler for the autocomplete + behavior, but that is not yet implemented. For now it uses + built-in logic only, which queries the + :class:`~sideshow.db.model.products.LocalProduct` table. + """ + session = self.Session() + term = self.request.GET.get('term', '').strip() + if not term: + return [] + return self.mock_autocomplete_products(session, term, user=self.request.user) + + # TODO: move this to some handler + def mock_autocomplete_products(self, session, term, user=None): + """ """ + import sqlalchemy as sa + + model = self.app.model + + # base query + query = session.query(model.LocalProduct) + + # filter query + criteria = [] + for word in term.split(): + criteria.append(sa.or_( + model.LocalProduct.brand_name.ilike(f'%{word}%'), + model.LocalProduct.description.ilike(f'%{word}%'))) + query = query.filter(sa.and_(*criteria)) + + # sort query + query = query.order_by(model.LocalProduct.brand_name, + model.LocalProduct.description) + + # get data + # TODO: need max_results option + products = query.all() + + # get results + def result(product): + return {'value': product.uuid.hex, + 'label': product.full_description} + return [result(c) for c in products] + def get_pending_product_required_fields(self): """ """ required = [] @@ -274,7 +384,10 @@ class OrderView(MasterView): new batch for them. This is a "batch action" method which may be called from - :meth:`create()`. + :meth:`create()`. See also: + + * :meth:`cancel_order()` + * :meth:`submit_order()` """ # drop current batch self.batch_handler.do_delete(batch, self.request.user) @@ -291,7 +404,10 @@ class OrderView(MasterView): back to "List Orders" page. This is a "batch action" method which may be called from - :meth:`create()`. + :meth:`create()`. See also: + + * :meth:`start_over()` + * :meth:`submit_order()` """ self.batch_handler.do_delete(batch, self.request.user) self.Session.flush() @@ -306,86 +422,193 @@ class OrderView(MasterView): def get_context_customer(self, batch): """ """ context = { - 'customer_id': batch.customer_id, + 'customer_is_known': True, + 'customer_id': None, 'customer_name': batch.customer_name, 'phone_number': batch.phone_number, 'email_address': batch.email_address, - 'new_customer_name': None, - 'new_customer_first_name': None, - 'new_customer_last_name': None, - 'new_customer_phone': None, - 'new_customer_email': None, } + # customer_id + use_local = self.batch_handler.use_local_customers() + if use_local: + local = batch.local_customer + if local: + context['customer_id'] = local.uuid.hex + else: # use external + context['customer_id'] = batch.customer_id + + # pending customer pending = batch.pending_customer if pending: context.update({ 'new_customer_first_name': pending.first_name, 'new_customer_last_name': pending.last_name, - 'new_customer_name': pending.full_name, + 'new_customer_full_name': pending.full_name, 'new_customer_phone': pending.phone_number, 'new_customer_email': pending.email_address, }) - # figure out if customer is "known" from user's perspective. - # if we have an ID then it's definitely known, otherwise if we - # have a pending customer then it's definitely *not* known, - # but if no pending customer yet then we can still "assume" it - # is known, by default, until user specifies otherwise. - if batch.customer_id: - context['customer_is_known'] = True - else: - context['customer_is_known'] = not pending + # declare customer "not known" only if pending is in use + if (pending + and not batch.customer_id and not batch.local_customer + and batch.customer_name): + context['customer_is_known'] = False return context + def assign_customer(self, batch, data): + """ + Assign the true customer account for a batch. + + This calls + :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()` + for the heavy lifting. + + This is a "batch action" method which may be called from + :meth:`create()`. See also: + + * :meth:`unassign_customer()` + * :meth:`set_pending_customer()` + """ + customer_id = data.get('customer_id') + if not customer_id: + return {'error': "Must provide customer_id"} + + self.batch_handler.set_customer(batch, customer_id) + return self.get_context_customer(batch) + + def unassign_customer(self, batch, data): + """ + Clear the customer info for a batch. + + This calls + :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()` + for the heavy lifting. + + This is a "batch action" method which may be called from + :meth:`create()`. See also: + + * :meth:`assign_customer()` + * :meth:`set_pending_customer()` + """ + self.batch_handler.set_customer(batch, None) + return self.get_context_customer(batch) + def set_pending_customer(self, batch, data): """ This will set/update the batch pending customer info. This calls - :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_pending_customer()` + :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()` for the heavy lifting. + This is a "batch action" method which may be called from + :meth:`create()`. See also: + + * :meth:`assign_customer()` + * :meth:`unassign_customer()` + """ + self.batch_handler.set_customer(batch, data, user=self.request.user) + return self.get_context_customer(batch) + + def get_product_info(self, batch, data): + """ + Fetch data for a specific product. (Nothing is modified.) + + Depending on config, this will fetch a :term:`local product` + or :term:`external product` to get the data. + + This should invoke a configured handler for the query + behavior, but that is not yet implemented. For now it uses + built-in logic only, which queries the + :class:`~sideshow.db.model.products.LocalProduct` table. + This is a "batch action" method which may be called from :meth:`create()`. """ - data['created_by'] = self.request.user - try: - self.batch_handler.set_pending_customer(batch, data) - except Exception as error: - return {'error': self.app.render_error(error)} + product_id = data.get('product_id') + if not product_id: + return {'error': "Must specify a product ID"} - self.Session.flush() - context = self.get_context_customer(batch) - return context + use_local = self.batch_handler.use_local_products() + if use_local: + data = self.get_local_product_info(product_id) + else: + raise NotImplementedError("TODO: add integration handler") + + if 'error' in data: + return data + + if 'unit_price_reg' in data and 'unit_price_reg_display' not in data: + data['unit_price_reg_display'] = self.app.render_currency(data['unit_price_reg']) + + if 'unit_price_reg' in data and 'unit_price_quoted' not in data: + data['unit_price_quoted'] = data['unit_price_reg'] + + if 'unit_price_quoted' in data and 'unit_price_quoted_display' not in data: + data['unit_price_quoted_display'] = self.app.render_currency(data['unit_price_quoted']) + + if 'case_price_quoted' not in data: + if data.get('unit_price_quoted') is not None and data.get('case_size') is not None: + data['case_price_quoted'] = data['unit_price_quoted'] * data['case_size'] + + if 'case_price_quoted' in data and 'case_price_quoted_display' not in data: + data['case_price_quoted_display'] = self.app.render_currency(data['case_price_quoted']) + + decimal_fields = [ + 'case_size', + 'unit_price_reg', + 'unit_price_quoted', + 'case_price_quoted', + ] + + for field in decimal_fields: + if field in list(data): + value = data[field] + if isinstance(value, decimal.Decimal): + data[field] = float(value) + + return data + + # TODO: move this to some handler + def get_local_product_info(self, product_id): + """ """ + model = self.app.model + session = self.Session() + product = session.get(model.LocalProduct, product_id) + if not product: + return {'error': "Product not found"} + + return { + 'product_id': product.uuid.hex, + 'scancode': product.scancode, + 'brand_name': product.brand_name, + 'description': product.description, + 'size': product.size, + 'full_description': product.full_description, + 'weighed': product.weighed, + 'special_order': product.special_order, + 'department_id': product.department_id, + 'department_name': product.department_name, + 'case_size': product.case_size, + 'unit_price_reg': product.unit_price_reg, + 'vendor_name': product.vendor_name, + 'vendor_item_code': product.vendor_item_code, + } def add_item(self, batch, data): """ This adds a row to the user's current new order batch. This is a "batch action" method which may be called from - :meth:`create()`. + :meth:`create()`. See also: + + * :meth:`update_item()` + * :meth:`delete_item()` """ - order_qty = decimal.Decimal(data.get('order_qty') or '0') - order_uom = data['order_uom'] - - if data.get('product_is_known'): - raise NotImplementedError - - else: # unknown product; add pending - pending = data['pending_product'] - - for field in ('unit_cost', 'unit_price_reg', 'case_size'): - if field in pending: - try: - pending[field] = decimal.Decimal(pending[field]) - except decimal.InvalidOperation: - return {'error': f"Invalid entry for field: {field}"} - - pending['created_by'] = self.request.user - row = self.batch_handler.add_pending_product(batch, pending, - order_qty, order_uom) + row = self.batch_handler.add_item(batch, data['product_info'], + data['order_qty'], data['order_uom']) return {'batch': self.normalize_batch(batch), 'row': self.normalize_row(row)} @@ -395,15 +618,17 @@ class OrderView(MasterView): This updates a row in the user's current new order batch. This is a "batch action" method which may be called from - :meth:`create()`. + :meth:`create()`. See also: + + * :meth:`add_item()` + * :meth:`delete_item()` """ model = self.app.model - enum = self.app.enum session = self.Session() uuid = data.get('uuid') if not uuid: - return {'error': "Must specify a row UUID"} + return {'error': "Must specify row UUID"} row = session.get(model.NewOrderBatchRow, uuid) if not row: @@ -412,20 +637,8 @@ class OrderView(MasterView): if row.batch is not batch: return {'error': "Row is for wrong batch"} - order_qty = decimal.Decimal(data.get('order_qty') or '0') - order_uom = data['order_uom'] - - if data.get('product_is_known'): - raise NotImplementedError - - else: # pending product - - # set these first, since row will be refreshed below - row.order_qty = order_qty - row.order_uom = order_uom - - # nb. this will refresh the row - self.batch_handler.set_pending_product(row, data['pending_product']) + self.batch_handler.update_item(row, data['product_info'], + data['order_qty'], data['order_uom']) return {'batch': self.normalize_batch(batch), 'row': self.normalize_row(row)} @@ -435,7 +648,10 @@ class OrderView(MasterView): This deletes a row from the user's current new order batch. This is a "batch action" method which may be called from - :meth:`create()`. + :meth:`create()`. See also: + + * :meth:`add_item()` + * :meth:`update_item()` """ model = self.app.model session = self.app.get_session(batch) @@ -452,16 +668,18 @@ class OrderView(MasterView): return {'error': "Row is for wrong batch"} self.batch_handler.do_remove_row(row) - session.flush() return {'batch': self.normalize_batch(batch)} - def submit_new_order(self, batch, data): + def submit_order(self, batch, data): """ This submits the user's current new order batch, hence executing the batch and creating the true order. This is a "batch action" method which may be called from - :meth:`create()`. + :meth:`create()`. See also: + + * :meth:`start_over()` + * :meth:`cancel_order()` """ user = self.request.user reason = self.batch_handler.why_not_execute(batch, user=user) @@ -502,6 +720,7 @@ class OrderView(MasterView): data = { 'uuid': row.uuid.hex, 'sequence': row.sequence, + 'product_id': None, 'product_scancode': row.product_scancode, 'product_brand': row.product_brand, 'product_description': row.product_description, @@ -509,8 +728,8 @@ class OrderView(MasterView): 'product_weighed': row.product_weighed, 'department_display': row.department_name, 'special_order': row.special_order, - 'case_size': self.app.render_quantity(row.case_size), - 'order_qty': self.app.render_quantity(row.order_qty), + 'case_size': float(row.case_size) if row.case_size is not None else None, + 'order_qty': float(row.order_qty), 'order_uom': row.order_uom, 'order_uom_choices': self.get_default_uom_choices(), 'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None, @@ -523,6 +742,33 @@ class OrderView(MasterView): 'status_text': row.status_text, } + use_local = self.batch_handler.use_local_products() + + # product_id + if use_local: + if row.local_product: + data['product_id'] = row.local_product.uuid.hex + else: + data['product_id'] = row.product_id + + # product_full_description + if use_local: + if row.local_product: + data['product_full_description'] = row.local_product.full_description + else: # use external + pass # TODO + if not data.get('product_id') and row.pending_product: + data['product_full_description'] = row.pending_product.full_description + + # vendor_name + if use_local: + if row.local_product: + data['vendor_name'] = row.local_product.vendor_name + else: # use external + pass # TODO + if not data.get('product_id') and row.pending_product: + data['vendor_name'] = row.pending_product.vendor_name + if row.unit_price_reg: data['unit_price_reg'] = float(row.unit_price_reg) data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg) @@ -535,21 +781,8 @@ class OrderView(MasterView): data['sale_ends'] = str(row.sale_ends) data['sale_ends_display'] = self.app.render_date(row.sale_ends) - # if row.unit_price_sale and row.unit_price_quoted == row.unit_price_sale: - # data['pricing_reflects_sale'] = True - - # TODO - if row.pending_product: - data['product_full_description'] = row.pending_product.full_description - # else: - # data['product_full_description'] = row.product_description - - # if row.pending_product: - # data['vendor_display'] = row.pending_product.vendor_name - if row.pending_product: pending = row.pending_product - # data['vendor_display'] = pending.vendor_name data['pending_product'] = { 'uuid': pending.uuid.hex, 'scancode': pending.scancode, @@ -569,14 +802,15 @@ class OrderView(MasterView): # display text for order qty/uom if row.order_uom == enum.ORDER_UOM_CASE: + order_qty = self.app.render_quantity(row.order_qty) if row.case_size is None: case_qty = unit_qty = '??' else: - case_qty = data['case_size'] + case_qty = self.app.render_quantity(row.case_size) unit_qty = self.app.render_quantity(row.order_qty * row.case_size) CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE] EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] - data['order_qty_display'] = (f"{data['order_qty']} {CS} " + data['order_qty_display'] = (f"{order_qty} {CS} " f"(× {case_qty} = {unit_qty} {EA})") else: unit_qty = self.app.render_quantity(row.order_qty) @@ -592,9 +826,16 @@ class OrderView(MasterView): def configure_form(self, f): """ """ super().configure_form(f) + order = f.model_instance + + # local_customer + f.set_node('local_customer', LocalCustomerRef(self.request)) # pending_customer - f.set_node('pending_customer', PendingCustomerRef(self.request)) + if order.customer_id or order.local_customer: + f.remove('pending_customer') + else: + f.set_node('pending_customer', PendingCustomerRef(self.request)) # total_price f.set_node('total_price', WuttaMoney(self.request)) @@ -672,7 +913,7 @@ class OrderView(MasterView): settings = [ # products - {'name': 'sideshow.orders.allow_unknown_product', + {'name': 'sideshow.orders.allow_unknown_products', 'type': bool, 'default': True}, ] @@ -702,7 +943,9 @@ class OrderView(MasterView): @classmethod def _order_defaults(cls, config): + route_prefix = cls.get_route_prefix() permission_prefix = cls.get_permission_prefix() + url_prefix = cls.get_url_prefix() model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() @@ -716,6 +959,24 @@ class OrderView(MasterView): f'{permission_prefix}.create_unknown_product', f"Create new {model_title} for unknown/pending product") + # customer autocomplete + config.add_route(f'{route_prefix}.customer_autocomplete', + f'{url_prefix}/customer-autocomplete', + request_method='GET') + config.add_view(cls, attr='customer_autocomplete', + route_name=f'{route_prefix}.customer_autocomplete', + renderer='json', + permission=f'{permission_prefix}.list') + + # product autocomplete + config.add_route(f'{route_prefix}.product_autocomplete', + f'{url_prefix}/product-autocomplete', + request_method='GET') + config.add_view(cls, attr='product_autocomplete', + route_name=f'{route_prefix}.product_autocomplete', + renderer='json', + permission=f'{permission_prefix}.list') + class OrderItemView(MasterView): """ @@ -745,7 +1006,8 @@ class OrderItemView(MasterView): 'product_brand': "Brand", 'product_description': "Description", 'product_size': "Size", - 'department_name': "Department", + 'product_weighed': "Sold by Weight", + 'department_id': "Department ID", 'order_uom': "Order UOM", 'status_code': "Status", } @@ -773,6 +1035,7 @@ class OrderItemView(MasterView): # 'customer_name', 'sequence', 'product_id', + 'local_product', 'pending_product', 'product_scancode', 'product_brand', @@ -852,27 +1115,46 @@ class OrderItemView(MasterView): enum = self.app.enum return enum.ORDER_ITEM_STATUS[value] + def get_instance_title(self, item): + """ """ + enum = self.app.enum + title = str(item) + status = enum.ORDER_ITEM_STATUS[item.status_code] + return f"({status}) {title}" + def configure_form(self, f): """ """ super().configure_form(f) enum = self.app.enum + item = f.model_instance # order f.set_node('order', OrderRef(self.request)) + # local_product + f.set_node('local_product', LocalProductRef(self.request)) + # pending_product - f.set_node('pending_product', PendingProductRef(self.request)) + if item.product_id or item.local_product: + f.remove('pending_product') + else: + f.set_node('pending_product', PendingProductRef(self.request)) # order_qty f.set_node('order_qty', WuttaQuantity(self.request)) # order_uom - # TODO - #f.set_node('order_uom', WuttaEnum(self.request, enum.OrderUOM)) + f.set_node('order_uom', WuttaDictEnum(self.request, enum.ORDER_UOM)) # case_size f.set_node('case_size', WuttaQuantity(self.request)) + # unit_cost + f.set_node('unit_cost', WuttaMoney(self.request, scale=4)) + + # unit_price_reg + f.set_node('unit_price_reg', WuttaMoney(self.request)) + # unit_price_quoted f.set_node('unit_price_quoted', WuttaMoney(self.request)) @@ -882,18 +1164,21 @@ class OrderItemView(MasterView): # total_price f.set_node('total_price', WuttaMoney(self.request)) + # status + f.set_node('status_code', WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS)) + # paid_amount f.set_node('paid_amount', WuttaMoney(self.request)) def get_xref_buttons(self, item): """ """ buttons = super().get_xref_buttons(item) - model = self.app.model if self.request.has_perm('orders.view'): url = self.request.route_url('orders.view', uuid=item.order_uuid) buttons.append( - self.make_button("View the Order", primary=True, icon_left='eye', url=url)) + self.make_button("View the Order", url=url, + primary=True, icon_left='eye')) return buttons diff --git a/src/sideshow/web/views/products.py b/src/sideshow/web/views/products.py index 90b94ab..98341b7 100644 --- a/src/sideshow/web/views/products.py +++ b/src/sideshow/web/views/products.py @@ -25,9 +25,194 @@ Views for Products """ from wuttaweb.views import MasterView -from wuttaweb.forms.schema import UserRef, WuttaEnum, WuttaMoney +from wuttaweb.forms.schema import UserRef, WuttaEnum, WuttaMoney, WuttaQuantity -from sideshow.db.model import PendingProduct +from sideshow.db.model import LocalProduct, PendingProduct + + +class LocalProductView(MasterView): + """ + Master view for :class:`~sideshow.db.model.products.LocalProduct`; + route prefix is ``local_products``. + + Notable URLs provided by this class: + + * ``/local/products/`` + * ``/local/products/new`` + * ``/local/products/XXX`` + * ``/local/products/XXX/edit`` + * ``/local/products/XXX/delete`` + """ + model_class = LocalProduct + model_title = "Local Product" + route_prefix = 'local_products' + url_prefix = '/local/products' + + labels = { + 'external_id': "External ID", + 'department_id': "Department ID", + } + + grid_columns = [ + 'scancode', + 'brand_name', + 'description', + 'size', + 'department_name', + 'special_order', + 'case_size', + 'unit_cost', + 'unit_price_reg', + ] + + sort_defaults = 'scancode' + + form_fields = [ + 'external_id', + 'scancode', + 'brand_name', + 'description', + 'size', + 'department_id', + 'department_name', + 'special_order', + 'vendor_name', + 'vendor_item_code', + 'case_size', + 'unit_cost', + 'unit_price_reg', + 'notes', + 'orders', + 'new_order_batches', + ] + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + # unit_cost + g.set_renderer('unit_cost', 'currency', scale=4) + + # unit_price_reg + g.set_label('unit_price_reg', "Reg. Price", column_only=True) + g.set_renderer('unit_price_reg', 'currency') + + # links + g.set_link('scancode') + g.set_link('brand_name') + g.set_link('description') + g.set_link('size') + + def configure_form(self, f): + """ """ + super().configure_form(f) + enum = self.app.enum + product = f.model_instance + + # external_id + if self.creating: + f.remove('external_id') + else: + f.set_readonly('external_id') + + # TODO: should not have to explicitly mark these nodes + # as required=False.. i guess i do for now b/c i am + # totally overriding the node from colanderlachemy + + # case_size + f.set_node('case_size', WuttaQuantity(self.request)) + f.set_required('case_size', False) + + # unit_cost + f.set_node('unit_cost', WuttaMoney(self.request, scale=4)) + f.set_required('unit_cost', False) + + # unit_price_reg + f.set_node('unit_price_reg', WuttaMoney(self.request)) + f.set_required('unit_price_reg', False) + + # notes + f.set_widget('notes', 'notes') + + # orders + if self.creating or self.editing: + f.remove('orders') + else: + f.set_grid('orders', self.make_orders_grid(product)) + + # new_order_batches + if self.creating or self.editing: + f.remove('new_order_batches') + else: + f.set_grid('new_order_batches', self.make_new_order_batches_grid(product)) + + def make_orders_grid(self, product): + """ + Make and return the grid for the Orders field. + """ + model = self.app.model + route_prefix = self.get_route_prefix() + + orders = set([item.order for item in product.order_items]) + orders = sorted(orders, key=lambda order: order.order_id) + + grid = self.make_grid(key=f'{route_prefix}.view.orders', + model_class=model.Order, + data=orders, + columns=[ + 'order_id', + 'total_price', + 'created', + 'created_by', + ], + labels={ + 'order_id': "Order ID", + }, + renderers={ + 'total_price': 'currency', + }) + + if self.request.has_perm('orders.view'): + url = lambda order, i: self.request.route_url('orders.view', uuid=order.uuid) + grid.add_action('view', icon='eye', url=url) + grid.set_link('order_id') + + return grid + + def make_new_order_batches_grid(self, product): + """ + Make and return the grid for the New Order Batches field. + """ + model = self.app.model + route_prefix = self.get_route_prefix() + + batches = set([row.batch for row in product.new_order_batch_rows]) + batches = sorted(batches, key=lambda batch: batch.id) + + grid = self.make_grid(key=f'{route_prefix}.view.new_order_batches', + model_class=model.NewOrderBatch, + data=batches, + columns=[ + 'id', + 'total_price', + 'created', + 'created_by', + 'executed', + ], + labels={ + 'id': "Batch ID", + 'status_code': "Status", + }, + renderers={ + 'id': 'batch_id', + }) + + if self.request.has_perm('neworder_batches.view'): + url = lambda batch, i: self.request.route_url('neworder_batches.view', uuid=batch.uuid) + grid.add_action('view', icon='eye', url=url) + grid.set_link('id') + + return grid class PendingProductView(MasterView): @@ -249,6 +434,9 @@ class PendingProductView(MasterView): def defaults(config, **kwargs): base = globals() + LocalProductView = kwargs.get('LocalProductView', base['LocalProductView']) + LocalProductView.defaults(config) + PendingProductView = kwargs.get('PendingProductView', base['PendingProductView']) PendingProductView.defaults(config) diff --git a/tests/batch/test_neworder.py b/tests/batch/test_neworder.py index 757a2dc..719efcb 100644 --- a/tests/batch/test_neworder.py +++ b/tests/batch/test_neworder.py @@ -2,6 +2,7 @@ import datetime import decimal +from unittest.mock import patch from wuttjamaican.testing import DataTestCase @@ -19,71 +20,133 @@ class TestNewOrderBatchHandler(DataTestCase): def make_handler(self): return mod.NewOrderBatchHandler(self.config) - def tets_allow_unknown_product(self): + def tets_use_local_customers(self): handler = self.make_handler() # true by default - self.assertTrue(handler.allow_unknown_product()) + self.assertTrue(handler.use_local_customers()) # config can disable - config.setdefault('sideshow.orders.allow_unknown_product', 'false') - self.assertFalse(handler.allow_unknown_product()) + config.setdefault('sideshow.orders.use_local_customers', 'false') + self.assertFalse(handler.use_local_customers()) - def test_set_pending_customer(self): + def tets_use_local_products(self): + handler = self.make_handler() + + # true by default + self.assertTrue(handler.use_local_products()) + + # config can disable + config.setdefault('sideshow.orders.use_local_products', 'false') + self.assertFalse(handler.use_local_products()) + + def tets_allow_unknown_products(self): + handler = self.make_handler() + + # true by default + self.assertTrue(handler.allow_unknown_products()) + + # config can disable + config.setdefault('sideshow.orders.allow_unknown_products', 'false') + self.assertFalse(handler.allow_unknown_products()) + + def test_set_customer(self): model = self.app.model handler = self.make_handler() user = model.User(username='barney') self.session.add(user) - batch = handler.make_batch(self.session, created_by=user, customer_id=42) - self.assertEqual(batch.customer_id, 42) + # customer starts blank + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + self.session.flush() + self.assertIsNone(batch.customer_id) + self.assertIsNone(batch.local_customer) self.assertIsNone(batch.pending_customer) self.assertIsNone(batch.customer_name) self.assertIsNone(batch.phone_number) self.assertIsNone(batch.email_address) - # auto full_name - handler.set_pending_customer(batch, { + # pending, typical (nb. full name is automatic) + handler.set_customer(batch, { 'first_name': "Fred", 'last_name': "Flintstone", 'phone_number': '555-1234', 'email_address': 'fred@mailinator.com', }) self.assertIsNone(batch.customer_id) + self.assertIsNone(batch.local_customer) self.assertIsInstance(batch.pending_customer, model.PendingCustomer) customer = batch.pending_customer - self.assertEqual(customer.full_name, "Fred Flintstone") self.assertEqual(customer.first_name, "Fred") self.assertEqual(customer.last_name, "Flintstone") + self.assertEqual(customer.full_name, "Fred Flintstone") self.assertEqual(customer.phone_number, '555-1234') self.assertEqual(customer.email_address, 'fred@mailinator.com') self.assertEqual(batch.customer_name, "Fred Flintstone") self.assertEqual(batch.phone_number, '555-1234') self.assertEqual(batch.email_address, 'fred@mailinator.com') - # explicit full_name - batch = handler.make_batch(self.session, created_by=user, customer_id=42) - handler.set_pending_customer(batch, { - 'full_name': "Freddy Flintstone", - 'first_name': "Fred", - 'last_name': "Flintstone", - 'phone_number': '555-1234', - 'email_address': 'fred@mailinator.com', - }) + # pending, minimal + last_customer = customer # save ref to prev record + handler.set_customer(batch, {'full_name': "Wilma Flintstone"}) self.assertIsNone(batch.customer_id) + self.assertIsNone(batch.local_customer) self.assertIsInstance(batch.pending_customer, model.PendingCustomer) customer = batch.pending_customer - self.assertEqual(customer.full_name, "Freddy Flintstone") - self.assertEqual(customer.first_name, "Fred") - self.assertEqual(customer.last_name, "Flintstone") - self.assertEqual(customer.phone_number, '555-1234') - self.assertEqual(customer.email_address, 'fred@mailinator.com') - self.assertEqual(batch.customer_name, "Freddy Flintstone") - self.assertEqual(batch.phone_number, '555-1234') - self.assertEqual(batch.email_address, 'fred@mailinator.com') + self.assertIs(customer, last_customer) + self.assertEqual(customer.full_name, "Wilma Flintstone") + self.assertIsNone(customer.first_name) + self.assertIsNone(customer.last_name) + self.assertIsNone(customer.phone_number) + self.assertIsNone(customer.email_address) + self.assertEqual(batch.customer_name, "Wilma Flintstone") + self.assertIsNone(batch.phone_number) + self.assertIsNone(batch.email_address) - def test_add_pending_product(self): + # local customer + local = model.LocalCustomer(full_name="Bam Bam", + first_name="Bam", last_name="Bam", + phone_number='555-4321') + self.session.add(local) + self.session.flush() + handler.set_customer(batch, local.uuid.hex) + self.session.flush() + self.assertIsNone(batch.customer_id) + # nb. pending customer does not get removed + self.assertIsInstance(batch.pending_customer, model.PendingCustomer) + self.assertIsInstance(batch.local_customer, model.LocalCustomer) + customer = batch.local_customer + self.assertEqual(customer.full_name, "Bam Bam") + self.assertEqual(customer.first_name, "Bam") + self.assertEqual(customer.last_name, "Bam") + self.assertEqual(customer.phone_number, '555-4321') + self.assertIsNone(customer.email_address) + self.assertEqual(batch.customer_name, "Bam Bam") + self.assertEqual(batch.phone_number, '555-4321') + self.assertIsNone(batch.email_address) + + # local customer, not found + mock_uuid = self.app.make_true_uuid() + self.assertRaises(ValueError, handler.set_customer, batch, mock_uuid.hex) + + # external lookup not implemented + self.config.setdefault('sideshow.orders.use_local_customers', 'false') + self.assertRaises(NotImplementedError, handler.set_customer, batch, '42') + + # null + handler.set_customer(batch, None) + self.session.flush() + self.assertIsNone(batch.customer_id) + # nb. pending customer does not get removed + self.assertIsInstance(batch.pending_customer, model.PendingCustomer) + self.assertIsNone(batch.local_customer) + self.assertIsNone(batch.customer_name) + self.assertIsNone(batch.phone_number) + self.assertIsNone(batch.email_address) + + def test_add_item(self): model = self.app.model enum = self.app.enum handler = self.make_handler() @@ -95,144 +158,290 @@ class TestNewOrderBatchHandler(DataTestCase): self.session.add(batch) self.assertEqual(len(batch.rows), 0) + # pending, typical kw = dict( - scancode='07430500132', + scancode='07430500001', brand_name='Bragg', description='Vinegar', - size='32oz', + size='1oz', case_size=12, - unit_cost=decimal.Decimal('3.99'), - unit_price_reg=decimal.Decimal('5.99'), - created_by=user, + unit_cost=decimal.Decimal('1.99'), + unit_price_reg=decimal.Decimal('2.99'), ) - row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_UNIT) + row = handler.add_item(batch, kw, 1, enum.ORDER_UOM_UNIT) + # nb. this is the first row in batch self.assertEqual(len(batch.rows), 1) self.assertIs(batch.rows[0], row) - - self.assertEqual(row.product_scancode, '07430500132') - self.assertEqual(row.product_brand, 'Bragg') - self.assertEqual(row.product_description, 'Vinegar') - self.assertEqual(row.product_size, '32oz') - self.assertEqual(row.case_size, 12) - self.assertEqual(row.unit_cost, decimal.Decimal('3.99')) - self.assertEqual(row.unit_price_reg, decimal.Decimal('5.99')) - self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99')) - self.assertEqual(row.case_price_quoted, decimal.Decimal('71.88')) - + self.assertIsNone(row.product_id) + self.assertIsNone(row.local_product) product = row.pending_product self.assertIsInstance(product, model.PendingProduct) - self.assertEqual(product.scancode, '07430500132') + self.assertEqual(product.scancode, '07430500001') self.assertEqual(product.brand_name, 'Bragg') self.assertEqual(product.description, 'Vinegar') - self.assertEqual(product.size, '32oz') + self.assertEqual(product.size, '1oz') self.assertEqual(product.case_size, 12) - self.assertEqual(product.unit_cost, decimal.Decimal('3.99')) - self.assertEqual(product.unit_price_reg, decimal.Decimal('5.99')) - self.assertIs(product.created_by, user) + self.assertEqual(product.unit_cost, decimal.Decimal('1.99')) + self.assertEqual(product.unit_price_reg, decimal.Decimal('2.99')) + self.assertEqual(row.product_scancode, '07430500001') + self.assertEqual(row.product_brand, 'Bragg') + self.assertEqual(row.product_description, 'Vinegar') + self.assertEqual(row.product_size, '1oz') + self.assertEqual(row.case_size, 12) + self.assertEqual(row.unit_cost, decimal.Decimal('1.99')) + self.assertEqual(row.unit_price_reg, decimal.Decimal('2.99')) + self.assertEqual(row.unit_price_quoted, decimal.Decimal('2.99')) + self.assertEqual(row.case_price_quoted, decimal.Decimal('35.88')) + self.assertEqual(row.total_price, decimal.Decimal('2.99')) - # error if unknown products not allowed - self.config.setdefault('sideshow.orders.allow_unknown_product', 'false') - self.assertRaises(TypeError, handler.add_pending_product, batch, kw, 1, enum.ORDER_UOM_UNIT) - - def test_set_pending_product(self): - model = self.app.model - enum = self.app.enum - handler = self.make_handler() - - user = model.User(username='barney') - self.session.add(user) - - batch = handler.make_batch(self.session, created_by=user) - self.session.add(batch) - self.assertEqual(len(batch.rows), 0) - - # start with mock product_id - row = handler.make_row(product_id=42, order_qty=1, order_uom=enum.ORDER_UOM_UNIT) - handler.add_row(batch, row) - self.session.flush() - self.assertEqual(row.product_id, 42) - self.assertIsNone(row.pending_product) + # pending, minimal + row = handler.add_item(batch, {'description': "Tangerines"}, 1, enum.ORDER_UOM_UNIT) + self.assertIsNone(row.product_id) + self.assertIsNone(row.local_product) + product = row.pending_product + self.assertIsInstance(product, model.PendingProduct) + self.assertIsNone(product.scancode) + self.assertIsNone(product.brand_name) + self.assertEqual(product.description, 'Tangerines') + self.assertIsNone(product.size) + self.assertIsNone(product.case_size) + self.assertIsNone(product.unit_cost) + self.assertIsNone(product.unit_price_reg) self.assertIsNone(row.product_scancode) self.assertIsNone(row.product_brand) - self.assertIsNone(row.product_description) + self.assertEqual(row.product_description, 'Tangerines') self.assertIsNone(row.product_size) self.assertIsNone(row.case_size) self.assertIsNone(row.unit_cost) self.assertIsNone(row.unit_price_reg) self.assertIsNone(row.unit_price_quoted) - - # set pending, which clears product_id - handler.set_pending_product(row, dict( - scancode='07430500132', - brand_name='Bragg', - description='Vinegar', - size='32oz', - case_size=12, - unit_cost=decimal.Decimal('3.99'), - unit_price_reg=decimal.Decimal('5.99'), - created_by=user, - )) - self.session.flush() - self.assertIsNone(row.product_id) - self.assertIsInstance(row.pending_product, model.PendingProduct) - self.assertEqual(row.product_scancode, '07430500132') - self.assertEqual(row.product_brand, 'Bragg') - self.assertEqual(row.product_description, 'Vinegar') - self.assertEqual(row.product_size, '32oz') - self.assertEqual(row.case_size, 12) - self.assertEqual(row.unit_cost, decimal.Decimal('3.99')) - self.assertEqual(row.unit_price_reg, decimal.Decimal('5.99')) - self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99')) - self.assertEqual(row.case_price_quoted, decimal.Decimal('71.88')) - product = row.pending_product - self.assertIsInstance(product, model.PendingProduct) - self.assertEqual(product.scancode, '07430500132') - self.assertEqual(product.brand_name, 'Bragg') - self.assertEqual(product.description, 'Vinegar') - self.assertEqual(product.size, '32oz') - self.assertEqual(product.case_size, 12) - self.assertEqual(product.unit_cost, decimal.Decimal('3.99')) - self.assertEqual(product.unit_price_reg, decimal.Decimal('5.99')) - self.assertIs(product.created_by, user) - - # set again to update pending - handler.set_pending_product(row, dict( - scancode='07430500116', - size='16oz', - unit_cost=decimal.Decimal('2.19'), - unit_price_reg=decimal.Decimal('3.59'), - )) - self.session.flush() - self.assertIsNone(row.product_id) - self.assertIsInstance(row.pending_product, model.PendingProduct) - self.assertEqual(row.product_scancode, '07430500116') - self.assertEqual(row.product_brand, 'Bragg') - self.assertEqual(row.product_description, 'Vinegar') - self.assertEqual(row.product_size, '16oz') - self.assertEqual(row.case_size, 12) - self.assertEqual(row.unit_cost, decimal.Decimal('2.19')) - self.assertEqual(row.unit_price_reg, decimal.Decimal('3.59')) - self.assertEqual(row.unit_price_quoted, decimal.Decimal('3.59')) - self.assertEqual(row.case_price_quoted, decimal.Decimal('43.08')) - product = row.pending_product - self.assertIsInstance(product, model.PendingProduct) - self.assertEqual(product.scancode, '07430500116') - self.assertEqual(product.brand_name, 'Bragg') - self.assertEqual(product.description, 'Vinegar') - self.assertEqual(product.size, '16oz') - self.assertEqual(product.case_size, 12) - self.assertEqual(product.unit_cost, decimal.Decimal('2.19')) - self.assertEqual(product.unit_price_reg, decimal.Decimal('3.59')) - self.assertIs(product.created_by, user) + self.assertIsNone(row.case_price_quoted) + self.assertIsNone(row.total_price) # error if unknown products not allowed - self.config.setdefault('sideshow.orders.allow_unknown_product', 'false') - self.assertRaises(TypeError, handler.set_pending_product, row, dict( - scancode='07430500116', - size='16oz', - unit_cost=decimal.Decimal('2.19'), - unit_price_reg=decimal.Decimal('3.59'), - )) + self.config.setdefault('sideshow.orders.allow_unknown_products', 'false') + self.assertRaises(TypeError, handler.add_item, batch, kw, 1, enum.ORDER_UOM_UNIT) + + # local product + local = model.LocalProduct(scancode='07430500002', + description='Vinegar', + size='2oz', + unit_price_reg=2.99, + case_size=12) + self.session.add(local) + self.session.flush() + row = handler.add_item(batch, local.uuid.hex, 1, enum.ORDER_UOM_CASE) + self.session.flush() + self.session.refresh(row) + self.session.refresh(local) + self.assertIsNone(row.product_id) + self.assertIsNone(row.pending_product) + product = row.local_product + self.assertIsInstance(product, model.LocalProduct) + self.assertEqual(product.scancode, '07430500002') + self.assertIsNone(product.brand_name) + self.assertEqual(product.description, 'Vinegar') + self.assertEqual(product.size, '2oz') + self.assertEqual(product.case_size, 12) + self.assertIsNone(product.unit_cost) + self.assertEqual(product.unit_price_reg, decimal.Decimal('2.99')) + self.assertEqual(row.product_scancode, '07430500002') + self.assertIsNone(row.product_brand) + self.assertEqual(row.product_description, 'Vinegar') + self.assertEqual(row.product_size, '2oz') + self.assertEqual(row.case_size, 12) + self.assertIsNone(row.unit_cost) + self.assertEqual(row.unit_price_reg, decimal.Decimal('2.99')) + self.assertEqual(row.unit_price_quoted, decimal.Decimal('2.99')) + self.assertEqual(row.case_price_quoted, decimal.Decimal('35.88')) + self.assertEqual(row.total_price, decimal.Decimal('35.88')) + + # local product, not found + mock_uuid = self.app.make_true_uuid() + self.assertRaises(ValueError, handler.add_item, + batch, mock_uuid.hex, 1, enum.ORDER_UOM_CASE) + + # external lookup not implemented + self.config.setdefault('sideshow.orders.use_local_products', 'false') + self.assertRaises(NotImplementedError, handler.add_item, + batch, '42', 1, enum.ORDER_UOM_CASE) + + def test_update_item(self): + model = self.app.model + enum = self.app.enum + handler = self.make_handler() + + user = model.User(username='barney') + self.session.add(user) + + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + self.assertEqual(len(batch.rows), 0) + + # start with typical pending product + kw = dict( + scancode='07430500001', + brand_name='Bragg', + description='Vinegar', + size='1oz', + case_size=12, + unit_cost=decimal.Decimal('1.99'), + unit_price_reg=decimal.Decimal('2.99'), + ) + row = handler.add_item(batch, kw, 1, enum.ORDER_UOM_CASE) + self.assertIsNone(row.product_id) + self.assertIsNone(row.local_product) + product = row.pending_product + self.assertIsInstance(product, model.PendingProduct) + self.assertEqual(product.scancode, '07430500001') + self.assertEqual(product.brand_name, 'Bragg') + self.assertEqual(product.description, 'Vinegar') + self.assertEqual(product.size, '1oz') + self.assertEqual(product.case_size, 12) + self.assertEqual(product.unit_cost, decimal.Decimal('1.99')) + self.assertEqual(product.unit_price_reg, decimal.Decimal('2.99')) + self.assertEqual(row.product_scancode, '07430500001') + self.assertEqual(row.product_brand, 'Bragg') + self.assertEqual(row.product_description, 'Vinegar') + self.assertEqual(row.product_size, '1oz') + self.assertEqual(row.case_size, 12) + self.assertEqual(row.unit_cost, decimal.Decimal('1.99')) + self.assertEqual(row.unit_price_reg, decimal.Decimal('2.99')) + self.assertEqual(row.unit_price_quoted, decimal.Decimal('2.99')) + self.assertEqual(row.case_price_quoted, decimal.Decimal('35.88')) + self.assertEqual(row.order_qty, 1) + self.assertEqual(row.order_uom, enum.ORDER_UOM_CASE) + self.assertEqual(row.total_price, decimal.Decimal('35.88')) + + # set pending, minimal + handler.update_item(row, {'description': 'Vinegar'}, 1, enum.ORDER_UOM_UNIT) + # self.session.flush() + self.assertIsNone(row.product_id) + self.assertIsNone(row.local_product) + product = row.pending_product + self.assertIsInstance(product, model.PendingProduct) + self.assertIsNone(product.scancode) + self.assertIsNone(product.brand_name) + self.assertEqual(product.description, 'Vinegar') + self.assertIsNone(product.size) + self.assertIsNone(product.case_size) + self.assertIsNone(product.unit_cost) + self.assertIsNone(product.unit_price_reg) + self.assertIsNone(row.product_scancode) + self.assertIsNone(row.product_brand) + self.assertEqual(row.product_description, 'Vinegar') + self.assertIsNone(row.product_size) + self.assertIsNone(row.case_size) + self.assertIsNone(row.unit_cost) + self.assertIsNone(row.unit_price_reg) + self.assertIsNone(row.unit_price_quoted) + self.assertIsNone(row.case_price_quoted) + self.assertEqual(row.order_qty, 1) + self.assertEqual(row.order_uom, enum.ORDER_UOM_UNIT) + self.assertIsNone(row.total_price) + + # start over, new row w/ local product + local = model.LocalProduct(scancode='07430500002', + description='Vinegar', + size='2oz', + unit_price_reg=3.99, + case_size=12) + self.session.add(local) + self.session.flush() + row = handler.add_item(batch, local.uuid.hex, 1, enum.ORDER_UOM_CASE) + self.session.flush() + self.session.refresh(row) + self.session.refresh(local) + self.assertIsNone(row.product_id) + self.assertIsNone(row.pending_product) + product = row.local_product + self.assertIsInstance(product, model.LocalProduct) + self.assertEqual(product.scancode, '07430500002') + self.assertIsNone(product.brand_name) + self.assertEqual(product.description, 'Vinegar') + self.assertEqual(product.size, '2oz') + self.assertEqual(product.case_size, 12) + self.assertIsNone(product.unit_cost) + self.assertEqual(product.unit_price_reg, decimal.Decimal('3.99')) + self.assertEqual(row.product_scancode, '07430500002') + self.assertIsNone(row.product_brand) + self.assertEqual(row.product_description, 'Vinegar') + self.assertEqual(row.product_size, '2oz') + self.assertEqual(row.case_size, 12) + self.assertIsNone(row.unit_cost) + self.assertEqual(row.unit_price_reg, decimal.Decimal('3.99')) + self.assertEqual(row.unit_price_quoted, decimal.Decimal('3.99')) + self.assertEqual(row.case_price_quoted, decimal.Decimal('47.88')) + self.assertEqual(row.order_qty, 1) + self.assertEqual(row.order_uom, enum.ORDER_UOM_CASE) + self.assertEqual(row.total_price, decimal.Decimal('47.88')) + + # update w/ pending product + handler.update_item(row, kw, 2, enum.ORDER_UOM_CASE) + self.assertIsNone(row.product_id) + self.assertIsNone(row.local_product) + product = row.pending_product + self.assertIsInstance(product, model.PendingProduct) + self.assertEqual(product.scancode, '07430500001') + self.assertEqual(product.brand_name, 'Bragg') + self.assertEqual(product.description, 'Vinegar') + self.assertEqual(product.size, '1oz') + self.assertEqual(product.case_size, 12) + self.assertEqual(product.unit_cost, decimal.Decimal('1.99')) + self.assertEqual(product.unit_price_reg, decimal.Decimal('2.99')) + self.assertEqual(row.product_scancode, '07430500001') + self.assertEqual(row.product_brand, 'Bragg') + self.assertEqual(row.product_description, 'Vinegar') + self.assertEqual(row.product_size, '1oz') + self.assertEqual(row.case_size, 12) + self.assertEqual(row.unit_cost, decimal.Decimal('1.99')) + self.assertEqual(row.unit_price_reg, decimal.Decimal('2.99')) + self.assertEqual(row.unit_price_quoted, decimal.Decimal('2.99')) + self.assertEqual(row.case_price_quoted, decimal.Decimal('35.88')) + self.assertEqual(row.order_qty, 2) + self.assertEqual(row.order_uom, enum.ORDER_UOM_CASE) + self.assertEqual(row.total_price, decimal.Decimal('71.76')) + + # update w/ pending, error if not allowed + self.config.setdefault('sideshow.orders.allow_unknown_products', 'false') + self.assertRaises(TypeError, handler.update_item, row, kw, 1, enum.ORDER_UOM_UNIT) + + # update w/ local product + handler.update_item(row, local.uuid.hex, 1, enum.ORDER_UOM_CASE) + self.assertIsNone(row.product_id) + # nb. pending remains intact here + self.assertIsNotNone(row.pending_product) + product = row.local_product + self.assertIsInstance(product, model.LocalProduct) + self.assertEqual(product.scancode, '07430500002') + self.assertIsNone(product.brand_name) + self.assertEqual(product.description, 'Vinegar') + self.assertEqual(product.size, '2oz') + self.assertEqual(product.case_size, 12) + self.assertIsNone(product.unit_cost) + self.assertEqual(product.unit_price_reg, decimal.Decimal('3.99')) + self.assertEqual(row.product_scancode, '07430500002') + self.assertIsNone(row.product_brand) + self.assertEqual(row.product_description, 'Vinegar') + self.assertEqual(row.product_size, '2oz') + self.assertEqual(row.case_size, 12) + self.assertIsNone(row.unit_cost) + self.assertEqual(row.unit_price_reg, decimal.Decimal('3.99')) + self.assertEqual(row.unit_price_quoted, decimal.Decimal('3.99')) + self.assertEqual(row.case_price_quoted, decimal.Decimal('47.88')) + self.assertEqual(row.order_qty, 1) + self.assertEqual(row.order_uom, enum.ORDER_UOM_CASE) + self.assertEqual(row.total_price, decimal.Decimal('47.88')) + + # update w/ local, not found + mock_uuid = self.app.make_true_uuid() + self.assertRaises(ValueError, handler.update_item, + batch, mock_uuid.hex, 1, enum.ORDER_UOM_CASE) + + # external lookup not implemented + self.config.setdefault('sideshow.orders.use_local_products', 'false') + self.assertRaises(NotImplementedError, handler.update_item, + row, '42', 1, enum.ORDER_UOM_CASE) def test_refresh_row(self): model = self.app.model @@ -387,7 +596,7 @@ class TestNewOrderBatchHandler(DataTestCase): unit_price_reg=decimal.Decimal('5.99'), created_by=user, ) - row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_CASE) + row = handler.add_item(batch, kw, 1, enum.ORDER_UOM_CASE) self.session.add(row) self.session.flush() self.assertEqual(batch.row_count, 1) @@ -423,25 +632,70 @@ class TestNewOrderBatchHandler(DataTestCase): self.assertNotIn(batch, self.session) self.assertEqual(self.session.query(model.PendingCustomer).count(), 0) - # make new pending customer - customer = model.PendingCustomer(full_name="Fred Flintstone", + # make new pending customer, assigned to batch + order + customer = model.PendingCustomer(full_name="Wilma Flintstone", status=enum.PendingCustomerStatus.PENDING, created_by=user) self.session.add(customer) + batch = handler.make_batch(self.session, created_by=user, pending_customer=customer) + self.session.add(batch) + order = model.Order(order_id=77, created_by=user, pending_customer=customer) + self.session.add(order) + self.session.flush() - # make 2 batches with same pending customer - batch1 = handler.make_batch(self.session, created_by=user, pending_customer=customer) - batch2 = handler.make_batch(self.session, created_by=user, pending_customer=customer) - self.session.add(batch1) - self.session.add(batch2) + # deleting batch will *not* delete pending customer + self.assertIn(batch, self.session) + self.assertEqual(self.session.query(model.PendingCustomer).count(), 1) + handler.do_delete(batch, user) self.session.commit() + self.assertNotIn(batch, self.session) + self.assertEqual(self.session.query(model.PendingCustomer).count(), 1) - # deleting 1 will not delete pending customer - self.assertEqual(self.session.query(model.PendingCustomer).count(), 1) - handler.do_delete(batch1, user) + # make new pending product, associate w/ batch + order + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + handler.set_customer(batch, {'full_name': "Jack Black"}) + row = handler.add_item(batch, dict( + scancode='07430500132', + brand_name='Bragg', + description='Vinegar', + size='32oz', + unit_price_reg=5.99, + ), 1, enum.ORDER_UOM_UNIT) + product = row.pending_product + order = model.Order(order_id=33, created_by=user) + item = model.OrderItem(pending_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT, + status_code=enum.ORDER_ITEM_STATUS_INITIATED) + order.items.append(item) + self.session.add(order) + self.session.flush() + + # deleting batch will *not* delete pending product + self.assertIn(batch, self.session) + self.assertEqual(self.session.query(model.PendingProduct).count(), 1) + handler.do_delete(batch, user) self.session.commit() + self.assertNotIn(batch, self.session) self.assertEqual(self.session.query(model.PendingCustomer).count(), 1) - self.assertIs(batch2.pending_customer, customer) + + # make another batch w/ same pending product + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + row = handler.make_row(pending_product=product, + order_qty=1, order_uom=enum.ORDER_UOM_UNIT) + handler.add_row(batch, row) + + # also delete the associated order + self.session.delete(order) + self.session.flush() + + # deleting this batch *will* delete pending product + self.assertIn(batch, self.session) + self.assertEqual(self.session.query(model.PendingProduct).count(), 1) + handler.do_delete(batch, user) + self.session.commit() + self.assertNotIn(batch, self.session) + self.assertEqual(self.session.query(model.PendingProduct).count(), 0) def test_get_effective_rows(self): model = self.app.model @@ -492,6 +746,12 @@ class TestNewOrderBatchHandler(DataTestCase): self.assertEqual(reason, "Must assign the customer") batch.customer_id = 42 + batch.customer_name = "Fred Flintstone" + + reason = handler.why_not_execute(batch) + self.assertEqual(reason, "Customer phone number is required") + + batch.phone_number = '555-1234' reason = handler.why_not_execute(batch) self.assertEqual(reason, "Must add at least one valid item") @@ -506,13 +766,206 @@ class TestNewOrderBatchHandler(DataTestCase): unit_price_reg=decimal.Decimal('5.99'), created_by=user, ) - row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_CASE) + row = handler.add_item(batch, kw, 1, enum.ORDER_UOM_CASE) self.session.add(row) self.session.flush() reason = handler.why_not_execute(batch) self.assertIsNone(reason) + def test_make_local_customer(self): + model = self.app.model + enum = self.app.enum + handler = self.make_handler() + + user = model.User(username='barney') + self.session.add(user) + + # make a typical batch + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + handler.set_customer(batch, {'first_name': "John", 'last_name': "Doe", + 'phone_number': '555-1234'}) + row = handler.add_item(batch, dict( + scancode='07430500132', + brand_name='Bragg', + description='Vinegar', + size='32oz', + unit_price_reg=5.99, + ), 1, enum.ORDER_UOM_UNIT) + self.session.flush() + + # making local customer removes pending customer + self.assertEqual(self.session.query(model.PendingCustomer).count(), 1) + self.assertEqual(self.session.query(model.LocalCustomer).count(), 0) + self.assertIsNotNone(batch.pending_customer) + self.assertIsNone(batch.local_customer) + handler.make_local_customer(batch) + self.assertEqual(self.session.query(model.PendingCustomer).count(), 0) + self.assertEqual(self.session.query(model.LocalCustomer).count(), 1) + self.assertIsNone(batch.pending_customer) + local = batch.local_customer + self.assertIsNotNone(local) + self.assertEqual(local.first_name, "John") + self.assertEqual(local.last_name, "Doe") + self.assertEqual(local.full_name, "John Doe") + self.assertEqual(local.phone_number, '555-1234') + + # trying again does nothing + handler.make_local_customer(batch) + self.assertEqual(self.session.query(model.PendingCustomer).count(), 0) + self.assertEqual(self.session.query(model.LocalCustomer).count(), 1) + self.assertIsNone(batch.pending_customer) + local = batch.local_customer + self.assertIsNotNone(local) + self.assertEqual(local.first_name, "John") + self.assertEqual(local.last_name, "Doe") + self.assertEqual(local.full_name, "John Doe") + self.assertEqual(local.phone_number, '555-1234') + + # make another typical batch + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + handler.set_customer(batch, {'first_name': "Chuck", 'last_name': "Norris", + 'phone_number': '555-1234'}) + row = handler.add_item(batch, dict( + scancode='07430500132', + brand_name='Bragg', + description='Vinegar', + size='32oz', + unit_price_reg=5.99, + ), 1, enum.ORDER_UOM_UNIT) + self.session.flush() + + # should do nothing if local customers disabled + with patch.object(handler, 'use_local_customers', return_value=False): + self.assertEqual(self.session.query(model.PendingCustomer).count(), 1) + self.assertEqual(self.session.query(model.LocalCustomer).count(), 1) + self.assertIsNotNone(batch.pending_customer) + self.assertIsNone(batch.local_customer) + handler.make_local_customer(batch) + self.assertEqual(self.session.query(model.PendingCustomer).count(), 1) + self.assertEqual(self.session.query(model.LocalCustomer).count(), 1) + self.assertIsNotNone(batch.pending_customer) + self.assertIsNone(batch.local_customer) + + # but things happen by default, since local customers enabled + self.assertEqual(self.session.query(model.PendingCustomer).count(), 1) + self.assertEqual(self.session.query(model.LocalCustomer).count(), 1) + self.assertIsNotNone(batch.pending_customer) + self.assertIsNone(batch.local_customer) + handler.make_local_customer(batch) + self.assertEqual(self.session.query(model.PendingCustomer).count(), 0) + self.assertEqual(self.session.query(model.LocalCustomer).count(), 2) + self.assertIsNone(batch.pending_customer) + local = batch.local_customer + self.assertIsNotNone(local) + self.assertEqual(local.first_name, "Chuck") + self.assertEqual(local.last_name, "Norris") + self.assertEqual(local.full_name, "Chuck Norris") + self.assertEqual(local.phone_number, '555-1234') + + def test_make_local_products(self): + model = self.app.model + enum = self.app.enum + handler = self.make_handler() + + user = model.User(username='barney') + self.session.add(user) + + # make a batch w/ one each local + pending products + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + handler.set_customer(batch, {'full_name': "John Doe"}) + local = model.LocalProduct(scancode='07430500116', + brand_name='Bragg', + description='Vinegar', + size='16oz', + unit_price_reg=3.59) + self.session.add(local) + self.session.flush() + row1 = handler.add_item(batch, local.uuid.hex, 1, enum.ORDER_UOM_UNIT) + row2 = handler.add_item(batch, dict( + scancode='07430500132', + brand_name='Bragg', + description='Vinegar', + size='32oz', + unit_price_reg=5.99, + ), 1, enum.ORDER_UOM_UNIT) + self.session.flush() + + # making local product removes pending product + self.assertEqual(self.session.query(model.PendingProduct).count(), 1) + self.assertEqual(self.session.query(model.LocalProduct).count(), 1) + self.assertIsNotNone(row2.pending_product) + self.assertIsNone(row2.local_product) + handler.make_local_products(batch, batch.rows) + self.assertEqual(self.session.query(model.PendingProduct).count(), 0) + self.assertEqual(self.session.query(model.LocalProduct).count(), 2) + self.assertIsNone(row2.pending_product) + local = row2.local_product + self.assertIsNotNone(local) + self.assertEqual(local.scancode, '07430500132') + self.assertEqual(local.brand_name, 'Bragg') + self.assertEqual(local.description, 'Vinegar') + self.assertEqual(local.size, '32oz') + self.assertEqual(local.unit_price_reg, decimal.Decimal('5.99')) + + # trying again does nothing + handler.make_local_products(batch, batch.rows) + self.assertEqual(self.session.query(model.PendingProduct).count(), 0) + self.assertEqual(self.session.query(model.LocalProduct).count(), 2) + self.assertIsNone(row2.pending_product) + local = row2.local_product + self.assertIsNotNone(local) + self.assertEqual(local.scancode, '07430500132') + self.assertEqual(local.brand_name, 'Bragg') + self.assertEqual(local.description, 'Vinegar') + self.assertEqual(local.size, '32oz') + self.assertEqual(local.unit_price_reg, decimal.Decimal('5.99')) + + # make another typical batch + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + handler.set_customer(batch, {'full_name': "Chuck Norris"}) + row = handler.add_item(batch, dict( + scancode='07430500164', + brand_name='Bragg', + description='Vinegar', + size='64oz', + unit_price_reg=9.99, + ), 1, enum.ORDER_UOM_UNIT) + self.session.flush() + + # should do nothing if local products disabled + with patch.object(handler, 'use_local_products', return_value=False): + self.assertEqual(self.session.query(model.PendingProduct).count(), 1) + self.assertEqual(self.session.query(model.LocalProduct).count(), 2) + self.assertIsNotNone(row.pending_product) + self.assertIsNone(row.local_product) + handler.make_local_products(batch, batch.rows) + self.assertEqual(self.session.query(model.PendingProduct).count(), 1) + self.assertEqual(self.session.query(model.LocalProduct).count(), 2) + self.assertIsNotNone(row.pending_product) + self.assertIsNone(row.local_product) + + # but things happen by default, since local products enabled + self.assertEqual(self.session.query(model.PendingProduct).count(), 1) + self.assertEqual(self.session.query(model.LocalProduct).count(), 2) + self.assertIsNotNone(row.pending_product) + self.assertIsNone(row.local_product) + handler.make_local_products(batch, batch.rows) + self.assertEqual(self.session.query(model.PendingProduct).count(), 0) + self.assertEqual(self.session.query(model.LocalProduct).count(), 3) + self.assertIsNone(row.pending_product) + local = row.local_product + self.assertIsNotNone(local) + self.assertEqual(local.scancode, '07430500164') + self.assertEqual(local.brand_name, 'Bragg') + self.assertEqual(local.description, 'Vinegar') + self.assertEqual(local.size, '64oz') + self.assertEqual(local.unit_price_reg, decimal.Decimal('9.99')) + def test_make_new_order(self): model = self.app.model enum = self.app.enum @@ -534,7 +987,7 @@ class TestNewOrderBatchHandler(DataTestCase): unit_price_reg=decimal.Decimal('5.99'), created_by=user, ) - row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_CASE) + row = handler.add_item(batch, kw, 1, enum.ORDER_UOM_CASE) self.session.add(row) self.session.flush() @@ -574,7 +1027,7 @@ class TestNewOrderBatchHandler(DataTestCase): unit_price_reg=decimal.Decimal('5.99'), created_by=user, ) - row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_CASE) + row = handler.add_item(batch, kw, 1, enum.ORDER_UOM_CASE) self.session.add(row) self.session.flush() diff --git a/tests/db/model/test_orders.py b/tests/db/model/test_orders.py index b0ad9f4..7169991 100644 --- a/tests/db/model/test_orders.py +++ b/tests/db/model/test_orders.py @@ -19,6 +19,19 @@ class TestOrder(DataTestCase): class TestOrderItem(DataTestCase): + def test_full_description(self): + + item = mod.OrderItem() + self.assertEqual(item.full_description, "") + + item = mod.OrderItem(product_description="Vinegar") + self.assertEqual(item.full_description, "Vinegar") + + item = mod.OrderItem(product_brand='Bragg', + product_description='Vinegar', + product_size='32oz') + self.assertEqual(item.full_description, "Bragg Vinegar 32oz") + def test_str(self): item = mod.OrderItem() @@ -27,8 +40,7 @@ class TestOrderItem(DataTestCase): item = mod.OrderItem(product_description="Vinegar") self.assertEqual(str(item), "Vinegar") - product = PendingProduct(brand_name="Bragg", - description="Vinegar", - size="32oz") - item = mod.OrderItem(pending_product=product) + item = mod.OrderItem(product_brand='Bragg', + product_description='Vinegar', + product_size='32oz') self.assertEqual(str(item), "Bragg Vinegar 32oz") diff --git a/tests/web/forms/test_schema.py b/tests/web/forms/test_schema.py index 38ff106..3dd838a 100644 --- a/tests/web/forms/test_schema.py +++ b/tests/web/forms/test_schema.py @@ -32,6 +32,31 @@ class TestOrderRef(WebTestCase): self.assertIn(f'/orders/{order.uuid}', url) +class TestLocalCustomerRef(WebTestCase): + + def test_sort_query(self): + typ = mod.LocalCustomerRef(self.request, session=self.session) + query = typ.get_query() + self.assertIsInstance(query, orm.Query) + sorted_query = typ.sort_query(query) + self.assertIsInstance(sorted_query, orm.Query) + self.assertIsNot(sorted_query, query) + + def test_get_object_url(self): + self.pyramid_config.add_route('local_customers.view', '/local/customers/{uuid}') + model = self.app.model + enum = self.app.enum + + customer = model.LocalCustomer() + self.session.add(customer) + self.session.commit() + + typ = mod.LocalCustomerRef(self.request, session=self.session) + url = typ.get_object_url(customer) + self.assertIsNotNone(url) + self.assertIn(f'/local/customers/{customer.uuid}', url) + + class TestPendingCustomerRef(WebTestCase): def test_sort_query(self): @@ -60,6 +85,31 @@ class TestPendingCustomerRef(WebTestCase): self.assertIn(f'/pending/customers/{customer.uuid}', url) +class TestLocalProductRef(WebTestCase): + + def test_sort_query(self): + typ = mod.LocalProductRef(self.request, session=self.session) + query = typ.get_query() + self.assertIsInstance(query, orm.Query) + sorted_query = typ.sort_query(query) + self.assertIsInstance(sorted_query, orm.Query) + self.assertIsNot(sorted_query, query) + + def test_get_object_url(self): + self.pyramid_config.add_route('local_products.view', '/local/products/{uuid}') + model = self.app.model + enum = self.app.enum + + product = model.LocalProduct() + self.session.add(product) + self.session.commit() + + typ = mod.LocalProductRef(self.request, session=self.session) + url = typ.get_object_url(product) + self.assertIsNotNone(url) + self.assertIn(f'/local/products/{product.uuid}', url) + + class TestPendingProductRef(WebTestCase): def test_sort_query(self): diff --git a/tests/web/test_menus.py b/tests/web/test_menus.py index bff33cd..36fdc56 100644 --- a/tests/web/test_menus.py +++ b/tests/web/test_menus.py @@ -9,4 +9,4 @@ class TestSideshowMenuHandler(WebTestCase): def test_make_menus(self): handler = mod.SideshowMenuHandler(self.config) menus = handler.make_menus(self.request) - self.assertEqual(len(menus), 4) + self.assertEqual(len(menus), 5) diff --git a/tests/web/views/test_customers.py b/tests/web/views/test_customers.py index b8f1db1..d68e48f 100644 --- a/tests/web/views/test_customers.py +++ b/tests/web/views/test_customers.py @@ -16,6 +16,114 @@ class TestIncludeme(WebTestCase): mod.includeme(self.pyramid_config) +class TestLocalCustomerView(WebTestCase): + + def make_view(self): + return mod.LocalCustomerView(self.request) + + def test_configure_grid(self): + model = self.app.model + view = self.make_view() + grid = view.make_grid(model_class=model.LocalCustomer) + self.assertNotIn('full_name', grid.linked_columns) + view.configure_grid(grid) + self.assertIn('full_name', grid.linked_columns) + + def test_configure_form(self): + model = self.app.model + view = self.make_view() + + # creating + with patch.object(view, 'creating', new=True): + form = view.make_form(model_class=model.LocalCustomer) + view.configure_form(form) + self.assertNotIn('external_id', form) + self.assertNotIn('full_name', form) + self.assertNotIn('orders', form) + self.assertNotIn('new_order_batches', form) + + user = model.User(username='barney') + self.session.add(user) + customer = model.LocalCustomer() + self.session.add(customer) + self.session.commit() + + # viewing + with patch.object(view, 'viewing', new=True): + form = view.make_form(model_instance=customer) + view.configure_form(form) + self.assertIn('external_id', form) + self.assertIn('full_name', form) + self.assertIn('orders', form) + self.assertIn('new_order_batches', form) + + def test_make_orders_grid(self): + model = self.app.model + view = self.make_view() + + user = model.User(username='barney') + self.session.add(user) + customer = model.LocalCustomer() + self.session.add(customer) + order = model.Order(order_id=42, local_customer=customer, created_by=user) + self.session.add(order) + self.session.commit() + + # no view perm + grid = view.make_orders_grid(customer) + self.assertEqual(len(grid.actions), 0) + + # with view perm + with patch.object(self.request, 'is_root', new=True): + grid = view.make_orders_grid(customer) + self.assertEqual(len(grid.actions), 1) + self.assertEqual(grid.actions[0].key, 'view') + + def test_make_new_order_batches_grid(self): + model = self.app.model + handler = NewOrderBatchHandler(self.config) + view = self.make_view() + + user = model.User(username='barney') + self.session.add(user) + customer = model.LocalCustomer() + self.session.add(customer) + batch = handler.make_batch(self.session, local_customer=customer, created_by=user) + self.session.add(batch) + self.session.commit() + + # no view perm + grid = view.make_new_order_batches_grid(customer) + self.assertEqual(len(grid.actions), 0) + + # with view perm + with patch.object(self.request, 'is_root', new=True): + grid = view.make_new_order_batches_grid(customer) + self.assertEqual(len(grid.actions), 1) + self.assertEqual(grid.actions[0].key, 'view') + + def test_objectify(self): + model = self.app.model + view = self.make_view() + + user = model.User(username='barney') + self.session.add(user) + self.session.commit() + + with patch.object(view, 'creating', new=True): + with patch.object(self.request, 'user', new=user): + form = view.make_model_form() + with patch.object(form, 'validated', create=True, new={ + 'first_name': 'Chuck', + 'last_name': 'Norris', + }): + customer = view.objectify(form) + self.assertIsInstance(customer, model.LocalCustomer) + self.assertEqual(customer.first_name, 'Chuck') + self.assertEqual(customer.last_name, 'Norris') + self.assertEqual(customer.full_name, 'Chuck Norris') + + class TestPendingCustomerView(WebTestCase): def make_view(self): diff --git a/tests/web/views/test_orders.py b/tests/web/views/test_orders.py index 3925832..ab996f1 100644 --- a/tests/web/views/test_orders.py +++ b/tests/web/views/test_orders.py @@ -13,7 +13,7 @@ from wuttaweb.forms.schema import WuttaMoney from sideshow.batch.neworder import NewOrderBatchHandler from sideshow.testing import WebTestCase from sideshow.web.views import orders as mod -from sideshow.web.forms.schema import OrderRef +from sideshow.web.forms.schema import OrderRef, PendingProductRef class TestIncludeme(WebTestCase): @@ -27,6 +27,9 @@ class TestOrderView(WebTestCase): def make_view(self): return mod.OrderView(self.request) + def make_handler(self): + return NewOrderBatchHandler(self.config) + def test_configure_grid(self): model = self.app.model view = self.make_view() @@ -40,6 +43,7 @@ class TestOrderView(WebTestCase): def test_create(self): self.pyramid_config.include('sideshow.web.views') model = self.app.model + enum = self.app.enum view = self.make_view() user = model.User(username='barney') @@ -91,7 +95,7 @@ class TestOrderView(WebTestCase): 'customer_name': 'Fred Flintstone', 'phone_number': '555-1234', 'email_address': 'fred@mailinator.com', - 'new_customer_name': 'Fred Flintstone', + 'new_customer_full_name': 'Fred Flintstone', 'new_customer_first_name': 'Fred', 'new_customer_last_name': 'Flintstone', 'new_customer_phone': '555-1234', @@ -108,6 +112,40 @@ class TestOrderView(WebTestCase): self.assertEqual(response.content_type, 'application/json') self.assertEqual(response.json_body, {'error': 'unknown form action'}) + # add item + with patch.multiple(self.request, create=True, + method='POST', + json_body={'action': 'add_item', + 'product_info': { + 'scancode': '07430500132', + 'description': 'Vinegar', + 'unit_price_reg': 5.99, + }, + 'order_qty': 1, + 'order_uom': enum.ORDER_UOM_UNIT}): + response = view.create() + self.assertIsInstance(response, Response) + self.assertEqual(response.content_type, 'application/json') + data = response.json_body + self.assertEqual(sorted(data), ['batch', 'row']) + + # add item, w/ error + with patch.object(NewOrderBatchHandler, 'add_item', side_effect=RuntimeError): + with patch.multiple(self.request, create=True, + method='POST', + json_body={'action': 'add_item', + 'product_info': { + 'scancode': '07430500116', + 'description': 'Vinegar', + 'unit_price_reg': 3.59, + }, + 'order_qty': 1, + 'order_uom': enum.ORDER_UOM_UNIT}): + response = view.create() + self.assertIsInstance(response, Response) + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.json_body, {'error': 'RuntimeError'}) + def test_get_current_batch(self): model = self.app.model handler = NewOrderBatchHandler(self.config) @@ -137,6 +175,75 @@ class TestOrderView(WebTestCase): self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1) self.assertIs(batch2, batch) + def test_customer_autocomplete(self): + model = self.app.model + view = self.make_view() + + with patch.object(view, 'Session', return_value=self.session): + + # empty results by default + self.assertEqual(view.customer_autocomplete(), []) + with patch.object(self.request, 'GET', new={'term': 'foo'}, create=True): + self.assertEqual(view.customer_autocomplete(), []) + + # add a customer + customer = model.LocalCustomer(full_name="Chuck Norris") + self.session.add(customer) + self.session.flush() + + # search for chuck finds chuck + with patch.object(self.request, 'GET', new={'term': 'chuck'}, create=True): + result = view.customer_autocomplete() + self.assertEqual(len(result), 1) + self.assertEqual(result[0], { + 'value': customer.uuid.hex, + 'label': "Chuck Norris", + }) + + # search for sally finds nothing + with patch.object(self.request, 'GET', new={'term': 'sally'}, create=True): + result = view.customer_autocomplete() + self.assertEqual(result, []) + + def test_product_autocomplete(self): + model = self.app.model + view = self.make_view() + + with patch.object(view, 'Session', return_value=self.session): + + # empty results by default + self.assertEqual(view.product_autocomplete(), []) + with patch.object(self.request, 'GET', new={'term': 'foo'}, create=True): + self.assertEqual(view.product_autocomplete(), []) + + # add a product + product = model.LocalProduct(brand_name="Bragg's", description="Vinegar") + self.session.add(product) + self.session.flush() + + # search for vinegar finds product + with patch.object(self.request, 'GET', new={'term': 'vinegar'}, create=True): + result = view.product_autocomplete() + self.assertEqual(len(result), 1) + self.assertEqual(result[0], { + 'value': product.uuid.hex, + 'label': "Bragg's Vinegar", + }) + + # search for brag finds product + with patch.object(self.request, 'GET', new={'term': 'brag'}, create=True): + result = view.product_autocomplete() + self.assertEqual(len(result), 1) + self.assertEqual(result[0], { + 'value': product.uuid.hex, + 'label': "Bragg's Vinegar", + }) + + # search for juice finds nothing + with patch.object(self.request, 'GET', new={'term': 'juice'}, create=True): + result = view.product_autocomplete() + self.assertEqual(result, []) + def test_get_pending_product_required_fields(self): model = self.app.model view = self.make_view() @@ -158,38 +265,51 @@ class TestOrderView(WebTestCase): model = self.app.model handler = NewOrderBatchHandler(self.config) view = self.make_view() + view.batch_handler = handler user = model.User(username='barney') self.session.add(user) - # with true customer + # with external customer + with patch.object(handler, 'use_local_customers', return_value=False): + batch = handler.make_batch(self.session, created_by=user, + customer_id=42, customer_name='Fred Flintstone', + phone_number='555-1234', email_address='fred@mailinator.com') + self.session.add(batch) + self.session.flush() + context = view.get_context_customer(batch) + self.assertEqual(context, { + 'customer_is_known': True, + 'customer_id': 42, + 'customer_name': 'Fred Flintstone', + 'phone_number': '555-1234', + 'email_address': 'fred@mailinator.com', + }) + + # with local customer + local = model.LocalCustomer(full_name="Betty Boop") + self.session.add(local) batch = handler.make_batch(self.session, created_by=user, - customer_id=42, customer_name='Fred Flintstone', - phone_number='555-1234', email_address='fred@mailinator.com') + local_customer=local, customer_name='Betty Boop', + phone_number='555-8888') self.session.add(batch) self.session.flush() context = view.get_context_customer(batch) self.assertEqual(context, { 'customer_is_known': True, - 'customer_id': 42, - 'customer_name': 'Fred Flintstone', - 'phone_number': '555-1234', - 'email_address': 'fred@mailinator.com', - 'new_customer_name': None, - 'new_customer_first_name': None, - 'new_customer_last_name': None, - 'new_customer_phone': None, - 'new_customer_email': None, + 'customer_id': local.uuid.hex, + 'customer_name': 'Betty Boop', + 'phone_number': '555-8888', + 'email_address': None, }) # with pending customer batch = handler.make_batch(self.session, created_by=user) self.session.add(batch) - handler.set_pending_customer(batch, dict( + handler.set_customer(batch, dict( full_name="Fred Flintstone", first_name="Fred", last_name="Flintstone", phone_number='555-1234', email_address='fred@mailinator.com', - created_by=user, )) self.session.flush() context = view.get_context_customer(batch) @@ -199,7 +319,7 @@ class TestOrderView(WebTestCase): 'customer_name': 'Fred Flintstone', 'phone_number': '555-1234', 'email_address': 'fred@mailinator.com', - 'new_customer_name': 'Fred Flintstone', + 'new_customer_full_name': 'Fred Flintstone', 'new_customer_first_name': 'Fred', 'new_customer_last_name': 'Flintstone', 'new_customer_phone': '555-1234', @@ -217,11 +337,6 @@ class TestOrderView(WebTestCase): 'customer_name': None, 'phone_number': None, 'email_address': None, - 'new_customer_name': None, - 'new_customer_first_name': None, - 'new_customer_last_name': None, - 'new_customer_phone': None, - 'new_customer_email': None, }) def test_start_over(self): @@ -268,6 +383,80 @@ class TestOrderView(WebTestCase): self.session.flush() self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0) + def test_assign_customer(self): + self.pyramid_config.add_route('orders.create', '/orders/new') + model = self.app.model + handler = NewOrderBatchHandler(self.config) + view = self.make_view() + + user = model.User(username='barney') + self.session.add(user) + weirdal = model.LocalCustomer(full_name="Weird Al") + self.session.add(weirdal) + self.session.flush() + + with patch.object(view, 'batch_handler', create=True, new=handler): + with patch.object(view, 'Session', return_value=self.session): + with patch.object(self.request, 'user', new=user): + batch = view.get_current_batch() + + # normal + self.assertIsNone(batch.local_customer) + self.assertIsNone(batch.pending_customer) + context = view.assign_customer(batch, {'customer_id': weirdal.uuid.hex}) + self.assertIsNone(batch.pending_customer) + self.assertIs(batch.local_customer, weirdal) + self.assertEqual(context, { + 'customer_is_known': True, + 'customer_id': weirdal.uuid.hex, + 'customer_name': 'Weird Al', + 'phone_number': None, + 'email_address': None, + }) + + # missing customer_id + context = view.assign_customer(batch, {}) + self.assertEqual(context, {'error': "Must provide customer_id"}) + + def test_unassign_customer(self): + self.pyramid_config.add_route('orders.create', '/orders/new') + model = self.app.model + handler = NewOrderBatchHandler(self.config) + view = self.make_view() + + user = model.User(username='barney') + self.session.add(user) + self.session.flush() + + with patch.object(view, 'batch_handler', create=True, new=handler): + with patch.object(view, 'Session', return_value=self.session): + with patch.object(self.request, 'user', new=user): + batch = view.get_current_batch() + view.set_pending_customer(batch, {'first_name': 'Jack', + 'last_name': 'Black'}) + + # normal + self.assertIsNone(batch.local_customer) + self.assertIsNotNone(batch.pending_customer) + self.assertEqual(batch.customer_name, 'Jack Black') + context = view.unassign_customer(batch, {}) + # nb. pending record remains, but not used + self.assertIsNotNone(batch.pending_customer) + self.assertIsNone(batch.customer_name) + self.assertIsNone(batch.local_customer) + self.assertEqual(context, { + 'customer_is_known': True, + 'customer_id': None, + 'customer_name': None, + 'phone_number': None, + 'email_address': None, + 'new_customer_full_name': 'Jack Black', + 'new_customer_first_name': 'Jack', + 'new_customer_last_name': 'Black', + 'new_customer_phone': None, + 'new_customer_email': None, + }) + def test_set_pending_customer(self): self.pyramid_config.add_route('orders.create', '/orders/new') model = self.app.model @@ -301,19 +490,58 @@ class TestOrderView(WebTestCase): 'customer_name': 'Fred Flintstone', 'phone_number': '555-1234', 'email_address': 'fred@mailinator.com', - 'new_customer_name': 'Fred Flintstone', + 'new_customer_full_name': 'Fred Flintstone', 'new_customer_first_name': 'Fred', 'new_customer_last_name': 'Flintstone', 'new_customer_phone': '555-1234', 'new_customer_email': 'fred@mailinator.com', }) - # error - with patch.object(handler, 'set_pending_customer', side_effect=RuntimeError): - context = view.set_pending_customer(batch, data) - self.assertEqual(context, { - 'error': 'RuntimeError', - }) + def test_get_product_info(self): + model = self.app.model + handler = self.make_handler() + view = self.make_view() + + user = model.User(username='barney') + self.session.add(user) + local = model.LocalProduct(scancode='07430500132', + brand_name='Bragg', + description='Vinegar', + size='32oz', + case_size=12, + unit_price_reg=decimal.Decimal('5.99')) + self.session.add(local) + self.session.flush() + + with patch.object(view, 'Session', return_value=self.session): + with patch.object(view, 'batch_handler', create=True, new=handler): + with patch.object(self.request, 'user', new=user): + batch = view.get_current_batch() + + # typical, for local product + context = view.get_product_info(batch, {'product_id': local.uuid.hex}) + self.assertEqual(context['product_id'], local.uuid.hex) + self.assertEqual(context['scancode'], '07430500132') + self.assertEqual(context['brand_name'], 'Bragg') + self.assertEqual(context['description'], 'Vinegar') + self.assertEqual(context['size'], '32oz') + self.assertEqual(context['full_description'], 'Bragg Vinegar 32oz') + self.assertEqual(context['case_size'], 12) + self.assertEqual(context['unit_price_reg'], 5.99) + + # error if local product missing + mock_uuid = self.app.make_true_uuid() + context = view.get_product_info(batch, {'product_id': mock_uuid.hex}) + self.assertEqual(context, {'error': "Product not found"}) + + # error if no product_id + context = view.get_product_info(batch, {}) + self.assertEqual(context, {'error': "Must specify a product ID"}) + + # external lookup not implemented (yet) + with patch.object(handler, 'use_local_products', return_value=False): + self.assertRaises(NotImplementedError, view.get_product_info, + batch, {'product_id': '42'}) def test_add_item(self): model = self.app.model @@ -326,7 +554,7 @@ class TestOrderView(WebTestCase): self.session.commit() data = { - 'pending_product': { + 'product_info': { 'scancode': '07430500132', 'brand_name': 'Bragg', 'description': 'Vinegar', @@ -353,16 +581,10 @@ class TestOrderView(WebTestCase): row = batch.rows[0] self.assertIsInstance(row.pending_product, model.PendingProduct) - # pending w/ invalid price - with patch.dict(data['pending_product'], unit_price_reg='invalid'): - result = view.add_item(batch, data) - self.assertEqual(result, {'error': "Invalid entry for field: unit_price_reg"}) - self.session.flush() - self.assertEqual(len(batch.rows), 1) # still just the 1st row - - # true product not yet supported - with patch.dict(data, product_is_known=True): - self.assertRaises(NotImplementedError, view.add_item, batch, data) + # external product not yet supported + with patch.object(handler, 'use_local_products', return_value=False): + with patch.dict(data, product_info='42'): + self.assertRaises(NotImplementedError, view.add_item, batch, data) def test_update_item(self): model = self.app.model @@ -375,7 +597,7 @@ class TestOrderView(WebTestCase): self.session.commit() data = { - 'pending_product': { + 'product_info': { 'scancode': '07430500132', 'brand_name': 'Bragg', 'description': 'Vinegar', @@ -403,7 +625,7 @@ class TestOrderView(WebTestCase): # missing row uuid result = view.update_item(batch, data) - self.assertEqual(result, {'error': "Must specify a row UUID"}) + self.assertEqual(result, {'error': "Must specify row UUID"}) # row not found with patch.dict(data, uuid=self.app.make_true_uuid()): @@ -420,16 +642,18 @@ class TestOrderView(WebTestCase): result = view.update_item(batch, data) self.assertEqual(result, {'error': "Row is for wrong batch"}) - # set row for remaining tests - data['uuid'] = row.uuid - # true product not yet supported - with patch.dict(data, product_is_known=True): - self.assertRaises(NotImplementedError, view.update_item, batch, data) + with patch.object(handler, 'use_local_products', return_value=False): + self.assertRaises(NotImplementedError, view.update_item, batch, { + 'uuid': row.uuid, + 'product_info': '42', + 'order_qty': 1, + 'order_uom': enum.ORDER_UOM_UNIT, + }) # update row, pending product - with patch.dict(data, order_qty=2): - with patch.dict(data['pending_product'], scancode='07430500116'): + with patch.dict(data, uuid=row.uuid, order_qty=2): + with patch.dict(data['product_info'], scancode='07430500116'): self.assertEqual(row.product_scancode, '07430500132') self.assertEqual(row.order_qty, 1) result = view.update_item(batch, data) @@ -438,7 +662,7 @@ class TestOrderView(WebTestCase): self.assertEqual(row.order_qty, 2) self.assertEqual(row.pending_product.scancode, '07430500116') self.assertEqual(result['row']['product_scancode'], '07430500116') - self.assertEqual(result['row']['order_qty'], '2') + self.assertEqual(result['row']['order_qty'], 2) def test_delete_item(self): model = self.app.model @@ -451,7 +675,7 @@ class TestOrderView(WebTestCase): self.session.commit() data = { - 'pending_product': { + 'product_info': { 'scancode': '07430500132', 'brand_name': 'Bragg', 'description': 'Vinegar', @@ -506,7 +730,7 @@ class TestOrderView(WebTestCase): self.assertEqual(len(batch.rows), 0) self.assertEqual(batch.row_count, 0) - def test_submit_new_order(self): + def test_submit_order(self): self.pyramid_config.add_route('orders.view', '/orders/{uuid}') model = self.app.model enum = self.app.enum @@ -518,7 +742,7 @@ class TestOrderView(WebTestCase): self.session.commit() data = { - 'pending_product': { + 'product_info': { 'scancode': '07430500132', 'brand_name': 'Bragg', 'description': 'Vinegar', @@ -534,28 +758,33 @@ class TestOrderView(WebTestCase): with patch.object(view, 'Session', return_value=self.session): with patch.object(self.request, 'user', new=user): batch = view.get_current_batch() - self.session.flush() self.assertEqual(len(batch.rows), 0) # add row w/ pending product view.add_item(batch, data) - self.session.flush() + self.assertEqual(len(batch.rows), 1) row = batch.rows[0] self.assertIsInstance(row.pending_product, model.PendingProduct) self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99')) # execute not allowed yet (no customer) - result = view.submit_new_order(batch, {}) + result = view.submit_order(batch, {}) self.assertEqual(result, {'error': "Must assign the customer"}) + # execute not allowed yet (no phone number) + view.set_pending_customer(batch, {'full_name': 'John Doe'}) + result = view.submit_order(batch, {}) + self.assertEqual(result, {'error': "Customer phone number is required"}) + # submit/execute ok - batch.customer_id = 42 - result = view.submit_new_order(batch, {}) + view.set_pending_customer(batch, {'full_name': 'John Doe', + 'phone_number': '555-1234'}) + result = view.submit_order(batch, {}) self.assertEqual(sorted(result), ['next_url']) self.assertIn('/orders/', result['next_url']) # error (already executed) - result = view.submit_new_order(batch, {}) + result = view.submit_order(batch, {}) self.assertEqual(result, { 'error': f"ValueError: batch has already been executed: {batch}", }) @@ -585,9 +814,8 @@ class TestOrderView(WebTestCase): 'size': '32oz', 'unit_price_reg': 5.99, 'case_size': 12, - 'created_by': user, } - row = handler.add_pending_product(batch, pending, 1, enum.ORDER_UOM_CASE) + row = handler.add_item(batch, pending, 1, enum.ORDER_UOM_CASE) self.session.commit() data = view.normalize_batch(batch) @@ -604,11 +832,15 @@ class TestOrderView(WebTestCase): enum = self.app.enum handler = NewOrderBatchHandler(self.config) view = self.make_view() + view.batch_handler = handler user = model.User(username='barney') self.session.add(user) batch = handler.make_batch(self.session, created_by=user) self.session.add(batch) + self.session.flush() + + # add 1st row w/ pending product pending = { 'scancode': '07430500132', 'brand_name': 'Bragg', @@ -616,19 +848,22 @@ class TestOrderView(WebTestCase): 'size': '32oz', 'unit_price_reg': 5.99, 'case_size': 12, - 'created_by': user, + 'vendor_name': 'Acme Warehouse', + 'vendor_item_code': '1234', } - row = handler.add_pending_product(batch, pending, 2, enum.ORDER_UOM_CASE) - self.session.commit() + row1 = handler.add_item(batch, pending, 2, enum.ORDER_UOM_CASE) - # normal - data = view.normalize_row(row) + # typical, pending product + data = view.normalize_row(row1) self.assertIsInstance(data, dict) - self.assertEqual(data['uuid'], row.uuid.hex) + self.assertEqual(data['uuid'], row1.uuid.hex) self.assertEqual(data['sequence'], 1) + self.assertIsNone(data['product_id']) self.assertEqual(data['product_scancode'], '07430500132') - self.assertEqual(data['case_size'], '12') - self.assertEqual(data['order_qty'], '2') + self.assertEqual(data['product_full_description'], 'Bragg Vinegar 32oz') + self.assertEqual(data['case_size'], 12) + self.assertEqual(data['vendor_name'], 'Acme Warehouse') + self.assertEqual(data['order_qty'], 2) self.assertEqual(data['order_uom'], 'CS') self.assertEqual(data['order_qty_display'], '2 Cases (× 12 = 24 Units)') self.assertEqual(data['unit_price_reg'], 5.99) @@ -644,9 +879,9 @@ class TestOrderView(WebTestCase): self.assertEqual(data['total_price'], 143.76) self.assertEqual(data['total_price_display'], '$143.76') self.assertIsNone(data['special_order']) - self.assertEqual(data['status_code'], row.STATUS_OK) + self.assertEqual(data['status_code'], row1.STATUS_OK) self.assertEqual(data['pending_product'], { - 'uuid': row.pending_product_uuid.hex, + 'uuid': row1.pending_product_uuid.hex, 'scancode': '07430500132', 'brand_name': 'Bragg', 'description': 'Vinegar', @@ -654,44 +889,117 @@ class TestOrderView(WebTestCase): 'department_id': None, 'department_name': None, 'unit_price_reg': 5.99, - 'vendor_name': None, - 'vendor_item_code': None, + 'vendor_name': 'Acme Warehouse', + 'vendor_item_code': '1234', 'unit_cost': None, 'case_size': 12.0, 'notes': None, 'special_order': None, }) + # the next few tests will morph 1st row.. + # unknown case size - row.pending_product.case_size = None - handler.refresh_row(row) + row1.pending_product.case_size = None + handler.refresh_row(row1) self.session.flush() - data = view.normalize_row(row) + data = view.normalize_row(row1) + self.assertIsNone(data['case_size']) self.assertEqual(data['order_qty_display'], '2 Cases (× ?? = ?? Units)') # order by unit - row.order_uom = enum.ORDER_UOM_UNIT - handler.refresh_row(row) + row1.order_uom = enum.ORDER_UOM_UNIT + handler.refresh_row(row1) self.session.flush() - data = view.normalize_row(row) + data = view.normalize_row(row1) + self.assertEqual(data['order_uom'], enum.ORDER_UOM_UNIT) self.assertEqual(data['order_qty_display'], '2 Units') # item on sale - row.pending_product.case_size = 12 - row.unit_price_sale = decimal.Decimal('5.19') - row.sale_ends = datetime.datetime(2025, 1, 5, 20, 32) - handler.refresh_row(row, now=datetime.datetime(2025, 1, 5, 19)) + row1.pending_product.case_size = 12 + row1.unit_price_sale = decimal.Decimal('5.19') + row1.sale_ends = datetime.datetime(2099, 1, 5, 20, 32) + handler.refresh_row(row1) self.session.flush() - data = view.normalize_row(row) + data = view.normalize_row(row1) self.assertEqual(data['unit_price_sale'], 5.19) self.assertEqual(data['unit_price_sale_display'], '$5.19') - self.assertEqual(data['sale_ends'], '2025-01-05 20:32:00') - self.assertEqual(data['sale_ends_display'], '2025-01-05') + self.assertEqual(data['sale_ends'], '2099-01-05 20:32:00') + self.assertEqual(data['sale_ends_display'], '2099-01-05') self.assertEqual(data['unit_price_quoted'], 5.19) self.assertEqual(data['unit_price_quoted_display'], '$5.19') self.assertEqual(data['case_price_quoted'], 62.28) self.assertEqual(data['case_price_quoted_display'], '$62.28') + # add 2nd row w/ local product + local = model.LocalProduct(brand_name="Lay's", + description="Potato Chips", + vendor_name='Acme Distribution', + unit_price_reg=3.29) + self.session.add(local) + self.session.flush() + row2 = handler.add_item(batch, local.uuid.hex, 1, enum.ORDER_UOM_UNIT) + + # typical, local product + data = view.normalize_row(row2) + self.assertEqual(data['uuid'], row2.uuid.hex) + self.assertEqual(data['sequence'], 2) + self.assertEqual(data['product_id'], local.uuid.hex) + self.assertIsNone(data['product_scancode']) + self.assertEqual(data['product_full_description'], "Lay's Potato Chips") + self.assertIsNone(data['case_size']) + self.assertEqual(data['vendor_name'], 'Acme Distribution') + self.assertEqual(data['order_qty'], 1) + self.assertEqual(data['order_uom'], 'EA') + self.assertEqual(data['order_qty_display'], '1 Units') + self.assertEqual(data['unit_price_reg'], 3.29) + self.assertEqual(data['unit_price_reg_display'], '$3.29') + self.assertNotIn('unit_price_sale', data) + self.assertNotIn('unit_price_sale_display', data) + self.assertNotIn('sale_ends', data) + self.assertNotIn('sale_ends_display', data) + self.assertEqual(data['unit_price_quoted'], 3.29) + self.assertEqual(data['unit_price_quoted_display'], '$3.29') + self.assertIsNone(data['case_price_quoted']) + self.assertEqual(data['case_price_quoted_display'], '') + self.assertEqual(data['total_price'], 3.29) + self.assertEqual(data['total_price_display'], '$3.29') + self.assertIsNone(data['special_order']) + self.assertEqual(data['status_code'], row2.STATUS_OK) + self.assertNotIn('pending_product', data) + + # the next few tests will morph 2nd row.. + + # typical, external product + row2.product_id = '42' + with patch.object(handler, 'use_local_products', return_value=False): + data = view.normalize_row(row2) + self.assertEqual(data['uuid'], row2.uuid.hex) + self.assertEqual(data['sequence'], 2) + self.assertEqual(data['product_id'], '42') + self.assertIsNone(data['product_scancode']) + self.assertNotIn('product_full_description', data) # TODO + self.assertIsNone(data['case_size']) + self.assertNotIn('vendor_name', data) # TODO + self.assertEqual(data['order_qty'], 1) + self.assertEqual(data['order_uom'], 'EA') + self.assertEqual(data['order_qty_display'], '1 Units') + self.assertEqual(data['unit_price_reg'], 3.29) + self.assertEqual(data['unit_price_reg_display'], '$3.29') + self.assertNotIn('unit_price_sale', data) + self.assertNotIn('unit_price_sale_display', data) + self.assertNotIn('sale_ends', data) + self.assertNotIn('sale_ends_display', data) + self.assertEqual(data['unit_price_quoted'], 3.29) + self.assertEqual(data['unit_price_quoted_display'], '$3.29') + self.assertIsNone(data['case_price_quoted']) + self.assertEqual(data['case_price_quoted_display'], '') + self.assertEqual(data['total_price'], 3.29) + self.assertEqual(data['total_price_display'], '$3.29') + self.assertIsNone(data['special_order']) + self.assertEqual(data['status_code'], row2.STATUS_OK) + self.assertNotIn('pending_product', data) + def test_get_instance_title(self): model = self.app.model view = self.make_view() @@ -715,13 +1023,31 @@ class TestOrderView(WebTestCase): self.session.add(order) self.session.commit() - # viewing + # viewing (no customer) with patch.object(view, 'viewing', new=True): form = view.make_form(model_instance=order) # nb. this is to avoid include/exclude ambiguity form.remove('items') view.configure_form(form) schema = form.get_schema() + self.assertIn('pending_customer', form) + self.assertIsInstance(schema['total_price'].typ, WuttaMoney) + + # assign local customer + local = model.LocalCustomer(first_name='Jack', last_name='Black', + phone_number='555-1234') + self.session.add(local) + order.local_customer = local + self.session.flush() + + # viewing (local customer) + with patch.object(view, 'viewing', new=True): + form = view.make_form(model_instance=order) + # nb. this is to avoid include/exclude ambiguity + form.remove('items') + view.configure_form(form) + self.assertNotIn('pending_customer', form) + schema = form.get_schema() self.assertIsInstance(schema['total_price'].typ, WuttaMoney) def test_get_xref_buttons(self): @@ -831,6 +1157,46 @@ class TestOrderView(WebTestCase): url = view.get_row_action_url_view(item, 0) self.assertIn(f'/order-items/{item.uuid}', url) + def test_configure(self): + self.pyramid_config.add_route('home', '/') + self.pyramid_config.add_route('login', '/auth/login') + self.pyramid_config.add_route('orders', '/orders/') + model = self.app.model + view = self.make_view() + + with patch.object(view, 'Session', return_value=self.session): + with patch.multiple(self.config, usedb=True, preferdb=True): + + # sanity check + allowed = self.config.get_bool('sideshow.orders.allow_unknown_products', + session=self.session) + self.assertIsNone(allowed) + self.assertEqual(self.session.query(model.Setting).count(), 0) + + # fetch initial page + response = view.configure() + self.assertIsInstance(response, Response) + self.assertNotIsInstance(response, HTTPFound) + self.session.flush() + allowed = self.config.get_bool('sideshow.orders.allow_unknown_products', + session=self.session) + self.assertIsNone(allowed) + self.assertEqual(self.session.query(model.Setting).count(), 0) + + # post new settings + with patch.multiple(self.request, create=True, + method='POST', + POST={ + 'sideshow.orders.allow_unknown_products': 'true', + }): + response = view.configure() + self.assertIsInstance(response, HTTPFound) + self.session.flush() + allowed = self.config.get_bool('sideshow.orders.allow_unknown_products', + session=self.session) + self.assertTrue(allowed) + self.assertTrue(self.session.query(model.Setting).count() > 1) + class TestOrderItemView(WebTestCase): @@ -864,6 +1230,18 @@ class TestOrderItemView(WebTestCase): self.assertEqual(view.render_status_code(None, None, enum.ORDER_ITEM_STATUS_INITIATED), 'initiated') + def test_get_instance_title(self): + model = self.app.model + enum = self.app.enum + view = self.make_view() + + item = model.OrderItem(product_brand='Bragg', + product_description='Vinegar', + product_size='32oz', + status_code=enum.ORDER_ITEM_STATUS_INITIATED) + title = view.get_instance_title(item) + self.assertEqual(title, "(initiated) Bragg Vinegar 32oz") + def test_configure_form(self): model = self.app.model enum = self.app.enum @@ -871,12 +1249,24 @@ class TestOrderItemView(WebTestCase): item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_INITIATED) - # viewing + # viewing, w/ pending product with patch.object(view, 'viewing', new=True): form = view.make_form(model_instance=item) view.configure_form(form) schema = form.get_schema() self.assertIsInstance(schema['order'].typ, OrderRef) + self.assertIn('pending_product', form) + self.assertIsInstance(schema['pending_product'].typ, PendingProductRef) + + # viewing, w/ local product + local = model.LocalProduct() + item.local_product = local + with patch.object(view, 'viewing', new=True): + form = view.make_form(model_instance=item) + view.configure_form(form) + schema = form.get_schema() + self.assertIsInstance(schema['order'].typ, OrderRef) + self.assertNotIn('pending_product', form) def test_get_xref_buttons(self): self.pyramid_config.add_route('orders.view', '/orders/{uuid}') diff --git a/tests/web/views/test_products.py b/tests/web/views/test_products.py index e7e61fe..c9782dd 100644 --- a/tests/web/views/test_products.py +++ b/tests/web/views/test_products.py @@ -16,6 +16,104 @@ class TestIncludeme(WebTestCase): mod.includeme(self.pyramid_config) +class TestLocalProductView(WebTestCase): + + def make_view(self): + return mod.LocalProductView(self.request) + + def test_configure_grid(self): + model = self.app.model + view = self.make_view() + grid = view.make_grid(model_class=model.LocalProduct) + self.assertNotIn('scancode', grid.linked_columns) + self.assertNotIn('brand_name', grid.linked_columns) + self.assertNotIn('description', grid.linked_columns) + view.configure_grid(grid) + self.assertIn('scancode', grid.linked_columns) + self.assertIn('brand_name', grid.linked_columns) + self.assertIn('description', grid.linked_columns) + + def test_configure_form(self): + model = self.app.model + view = self.make_view() + + # creating + with patch.object(view, 'creating', new=True): + form = view.make_form(model_class=model.LocalProduct) + self.assertIn('external_id', form) + view.configure_form(form) + self.assertNotIn('external_id', form) + + user = model.User(username='barney') + self.session.add(user) + product = model.LocalProduct() + self.session.add(product) + self.session.commit() + + # viewing + with patch.object(view, 'viewing', new=True): + form = view.make_form(model_instance=product) + self.assertNotIn('external_id', form.readonly_fields) + self.assertNotIn('local_products.view.orders', form.grid_vue_context) + view.configure_form(form) + self.assertIn('external_id', form.readonly_fields) + self.assertIn('local_products.view.orders', form.grid_vue_context) + + def test_make_orders_grid(self): + model = self.app.model + enum = self.app.enum + view = self.make_view() + + user = model.User(username='barney') + self.session.add(user) + order = model.Order(order_id=42, customer_id=42, created_by=user) + product = model.LocalProduct() + self.session.add(product) + item = model.OrderItem(local_product=product, + order_qty=1, order_uom=enum.ORDER_UOM_UNIT, + status_code=enum.ORDER_ITEM_STATUS_INITIATED) + order.items.append(item) + self.session.add(order) + self.session.commit() + + # no view perm + grid = view.make_orders_grid(product) + self.assertEqual(len(grid.actions), 0) + + # with view perm + with patch.object(self.request, 'is_root', new=True): + grid = view.make_orders_grid(product) + self.assertEqual(len(grid.actions), 1) + self.assertEqual(grid.actions[0].key, 'view') + + def test_make_new_order_batches_grid(self): + model = self.app.model + enum = self.app.enum + handler = NewOrderBatchHandler(self.config) + view = self.make_view() + + user = model.User(username='barney') + self.session.add(user) + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + product = model.LocalProduct() + self.session.add(product) + row = handler.make_row(local_product=product, + order_qty=1, order_uom=enum.ORDER_UOM_UNIT) + handler.add_row(batch, row) + self.session.commit() + + # no view perm + grid = view.make_new_order_batches_grid(product) + self.assertEqual(len(grid.actions), 0) + + # with view perm + with patch.object(self.request, 'is_root', new=True): + grid = view.make_new_order_batches_grid(product) + self.assertEqual(len(grid.actions), 1) + self.assertEqual(grid.actions[0].key, 'view') + + class TestPendingProductView(WebTestCase): def make_view(self):