feat: add basic support for local customer, product lookups

also convert pending to local (where relevant) when executing batch
This commit is contained in:
Lance Edgar 2025-01-09 12:13:58 -06:00
parent ebd22fe6ee
commit a4ad23c7fa
23 changed files with 3205 additions and 796 deletions

View file

@ -5,6 +5,46 @@ Glossary
.. glossary:: .. glossary::
:sorted: :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 order
This is the central focus of the app; it refers to a customer This is the central focus of the app; it refers to a customer
case/special order which is tracked over time, from placement to case/special order which is tracked over time, from placement to
@ -20,17 +60,19 @@ Glossary
sibling items. sibling items.
pending customer pending customer
Generally refers to a "new / unknown" customer, e.g. for whom a A "temporary" customer record used when creating an :term:`order`
new order is being created. This allows the order lifecycle to for new/unknown customer.
get going before the customer has a proper account in the system.
See :class:`~sideshow.db.model.customers.PendingCustomer` for the The data model for this is
data model. :class:`~sideshow.db.model.customers.PendingCustomer`.
See also :term:`external customer` and :term:`pending customer`.
pending product pending product
Generally refers to a "new / unknown" product, e.g. for which a A "temporary" product record used when creating an :term:`order`
new order is being created. This allows the order lifecycle to for new/unknown product.
get going before the product has a true record in the system.
See :class:`~sideshow.db.model.products.PendingProduct` for the The data model for this is
data model. :class:`~sideshow.db.model.products.PendingProduct`.
See also :term:`external product` and :term:`pending product`.

View file

@ -27,6 +27,8 @@ New Order Batch Handler
import datetime import datetime
import decimal import decimal
import sqlalchemy as sa
from wuttjamaican.batch import BatchHandler from wuttjamaican.batch import BatchHandler
from sideshow.db.model import NewOrderBatch from sideshow.db.model import NewOrderBatch
@ -34,7 +36,8 @@ from sideshow.db.model import NewOrderBatch
class NewOrderBatchHandler(BatchHandler): class NewOrderBatchHandler(BatchHandler):
""" """
The :term:`batch handler` for New Order Batches. The :term:`batch handler` for :term:`new order batches <new order
batch>`.
This is responsible for business logic around the creation of new This is responsible for business logic around the creation of new
:term:`orders <order>`. A :term:`orders <order>`. A
@ -44,202 +47,333 @@ class NewOrderBatchHandler(BatchHandler):
""" """
model_class = NewOrderBatch model_class = NewOrderBatch
def allow_unknown_product(self): def use_local_customers(self):
""" """
Returns a boolean indicating whether "unknown" (pending) Returns boolean indicating whether :term:`local customer`
products are allowed when creating a new order. 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) default=True)
def set_pending_customer(self, batch, data): def use_local_products(self):
""" """
Set (add or update) pending customer info for the batch. Returns boolean indicating whether :term:`local product`
records should be used. This is true by default, but may be
This will clear the false for :term:`external product` lookups.
: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.
""" """
model = self.app.model return self.config.get_bool('sideshow.orders.use_local_products',
enum = self.app.enum default=True)
# remove customer account if set def allow_unknown_products(self):
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):
""" """
Add a new row to the batch, for the given "pending" product Returns boolean indicating whether :term:`pending products
and order quantity. <pending product>` 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: :param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to :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 :param customer_info: Customer ID string, or dict of
new :class:`~sideshow.db.model.products.PendingProduct`. :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 :param user:
order. :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is performing the action. This is used to set
:param order_uom: UOM for the order quantity; must be a code :attr:`~sideshow.db.model.customers.PendingCustomer.created_by`
from :data:`~sideshow.enum.ORDER_UOM`. on the pending customer, if applicable. If not specified,
the batch creator is assumed.
:returns:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
which was added to the batch.
""" """
if not self.allow_unknown_product():
raise TypeError("unknown/pending product not allowed for new orders")
model = self.app.model model = self.app.model
enum = self.app.enum enum = self.app.enum
session = self.app.get_session(batch) 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() session.flush()
# nb. this may convert float to decimal etc.
session.refresh(product)
# make/add new row, w/ pending product def add_item(self, batch, product_info, order_qty, order_uom, user=None):
row = self.make_row(pending_product=product, """
order_qty=order_qty, order_uom=order_uom) 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) self.add_row(batch, row)
session.add(row)
session.flush() session.flush()
return row 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 See also :meth:`add_item()`.
: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.
:param row: :param row:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow` :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
to be updated. to update.
:param data: Dict of field data for the :param product_info: Product ID string, or dict of
:class:`~sideshow.db.model.products.PendingProduct` record. :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 model = self.app.model
enum = self.app.enum enum = self.app.enum
session = self.app.get_session(row) session = self.app.get_session(row)
use_local = self.use_local_products()
# values for these fields can be used as-is # set product info
simple_fields = [ if isinstance(product_info, str):
'scancode', if use_local:
'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',
]
# clear true product id # local_product
row.product_id = None 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 else: # external product_id
product = row.pending_product #row.product_id = product_info
if not product: raise NotImplementedError
kw = dict(data)
kw.setdefault('status', enum.PendingProductStatus.PENDING) else:
product = model.PendingProduct(**kw) # pending_product
session.add(product) if not self.allow_unknown_products():
row.pending_product = product 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.flush()
session.refresh(pending)
# update pending product # set order info
for field in simple_fields: row.order_qty = order_qty
if field in data: row.order_uom = order_uom
setattr(product, field, data[field])
# nb. this may convert float to decimal etc. # nb. this may convert float to decimal etc.
session.flush() session.flush()
session.refresh(product) session.refresh(row)
# refresh per new info # refresh per new info
self.refresh_row(row) 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 Refresh data for the row. This is called when adding a new
new row to the batch, or anytime the row is updated (e.g. when row to the batch, or anytime the row is updated (e.g. when
changing order quantity). changing order quantity).
This calls one of the following to update product-related 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_pending_product()`
* :meth:`refresh_row_from_true_product()`
It then re-calculates the row's It then re-calculates the row's
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price` :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price`
@ -253,7 +387,7 @@ class NewOrderBatchHandler(BatchHandler):
row.status_text = None row.status_text = None
# ensure product # 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 row.status_code = row.STATUS_MISSING_PRODUCT
return return
@ -264,7 +398,9 @@ class NewOrderBatchHandler(BatchHandler):
# update product attrs on row # update product attrs on row
if row.product_id: 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: else:
self.refresh_row_from_pending_product(row) self.refresh_row_from_pending_product(row)
@ -276,7 +412,7 @@ class NewOrderBatchHandler(BatchHandler):
row.case_price_quoted = None row.case_price_quoted = None
if row.unit_price_sale is not None and ( if row.unit_price_sale is not None and (
not row.sale_ends 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 row.unit_price_quoted = row.unit_price_sale
else: else:
row.unit_price_quoted = row.unit_price_reg row.unit_price_quoted = row.unit_price_reg
@ -304,16 +440,15 @@ class NewOrderBatchHandler(BatchHandler):
# all ok # all ok
row.status_code = row.STATUS_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 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. record.
This is called automatically from :meth:`refresh_row()`. This is called automatically from :meth:`refresh_row()`.
""" """
product = row.pending_product product = row.local_product
row.product_scancode = product.scancode row.product_scancode = product.scancode
row.product_brand = product.brand_name row.product_brand = product.brand_name
row.product_description = product.description row.product_description = product.description
@ -326,10 +461,31 @@ class NewOrderBatchHandler(BatchHandler):
row.unit_cost = product.unit_cost row.unit_cost = product.unit_cost
row.unit_price_reg = product.unit_price_reg 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" Update product-related attributes on the row, from its
product record indicated by :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`. :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`.
This is called automatically from :meth:`refresh_row()`. This is called automatically from :meth:`refresh_row()`.
@ -354,31 +510,39 @@ class NewOrderBatchHandler(BatchHandler):
def do_delete(self, batch, user, **kwargs): def do_delete(self, batch, user, **kwargs):
""" """
Delete the given batch entirely. Delete a batch completely.
If the batch has a If the batch has :term:`pending customer` or :term:`pending
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer` product` records, they are also deleted - unless still
record, that is deleted also. referenced by some order(s).
""" """
# maybe delete pending customer record, if it only exists for session = self.app.get_session(batch)
# sake of this batch
if batch.pending_customer: # maybe delete pending customer
if len(batch.pending_customer.new_order_batches) == 1: customer = batch.pending_customer
# TODO: check for past orders too if customer and not customer.orders:
session = self.app.get_session(batch) session.delete(customer)
session.delete(batch.pending_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 # continue with normal deletion
super().do_delete(batch, user, **kwargs) super().do_delete(batch, user, **kwargs)
def why_not_execute(self, batch, **kwargs): def why_not_execute(self, batch, **kwargs):
""" """
By default this checks to ensure the batch has a customer and By default this checks to ensure the batch has a customer with
at least one item. 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" return "Must assign the customer"
if not batch.phone_number:
return "Customer phone number is required"
rows = self.get_effective_rows(batch) rows = self.get_effective_rows(batch)
if not rows: if not rows:
return "Must add at least one valid item" return "Must add at least one valid item"
@ -395,17 +559,113 @@ class NewOrderBatchHandler(BatchHandler):
def execute(self, batch, user=None, progress=None, **kwargs): def execute(self, batch, user=None, progress=None, **kwargs):
""" """
By default, this will call :meth:`make_new_order()` and return Execute the batch; this should make a proper :term:`order`.
the new :class:`~sideshow.db.model.orders.Order` instance.
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 Note that callers should use
:meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()` :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
instead, which calls this method automatically. instead, which calls this method automatically.
""" """
rows = self.get_effective_rows(batch) 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) order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
return order 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
<pending product>` into :term:`local products <local
product>`.
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): def make_new_order(self, batch, rows, user=None, progress=None, **kwargs):
""" """
Create a new :term:`order` from the batch data. Create a new :term:`order` from the batch data.
@ -429,6 +689,7 @@ class NewOrderBatchHandler(BatchHandler):
batch_fields = [ batch_fields = [
'store_id', 'store_id',
'customer_id', 'customer_id',
'local_customer',
'pending_customer', 'pending_customer',
'customer_name', 'customer_name',
'phone_number', 'phone_number',
@ -437,7 +698,9 @@ class NewOrderBatchHandler(BatchHandler):
] ]
row_fields = [ row_fields = [
'pending_product_uuid', 'product_id',
'local_product',
'pending_product',
'product_scancode', 'product_scancode',
'product_brand', 'product_brand',
'product_description', 'product_description',

View file

@ -26,8 +26,8 @@ def upgrade() -> None:
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus').create(op.get_bind()) sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus').create(op.get_bind())
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').create(op.get_bind()) sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').create(op.get_bind())
# sideshow_pending_customer # sideshow_customer_pending
op.create_table('sideshow_pending_customer', op.create_table('sideshow_customer_pending',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('customer_id', sa.String(length=20), nullable=True), sa.Column('customer_id', sa.String(length=20), nullable=True),
sa.Column('full_name', sa.String(length=100), 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('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', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), 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.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_pending_customer')) sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_customer_pending'))
) )
# sideshow_pending_product # sideshow_customer_local
op.create_table('sideshow_pending_product', 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('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('product_id', sa.String(length=20), nullable=True), sa.Column('product_id', sa.String(length=20), nullable=True),
sa.Column('scancode', sa.String(length=14), 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('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', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), 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.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_pending_product')) 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 # sideshow_order
@ -73,6 +106,7 @@ def upgrade() -> None:
sa.Column('order_id', sa.Integer(), nullable=False), sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('store_id', sa.String(length=10), nullable=True), sa.Column('store_id', sa.String(length=10), nullable=True),
sa.Column('customer_id', sa.String(length=20), 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('pending_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('customer_name', sa.String(length=100), nullable=True), sa.Column('customer_name', sa.String(length=100), nullable=True),
sa.Column('phone_number', sa.String(length=20), 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('total_price', sa.Numeric(precision=10, scale=3), nullable=True),
sa.Column('created', sa.DateTime(timezone=True), nullable=False), sa.Column('created', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), 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.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_order_created_by_uuid_user')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order')) 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('order_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('sequence', sa.Integer(), nullable=False), sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('product_id', sa.String(length=20), 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('pending_product_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('product_scancode', sa.String(length=14), 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_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('paid_amount', sa.Numeric(precision=8, scale=3), nullable=False),
sa.Column('payment_transaction_number', sa.String(length=20), nullable=True), 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(['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')) 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('executed_by_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('store_id', sa.String(length=10), nullable=True), sa.Column('store_id', sa.String(length=10), nullable=True),
sa.Column('customer_id', sa.String(length=20), 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('pending_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('customer_name', sa.String(length=100), nullable=True), sa.Column('customer_name', sa.String(length=100), nullable=True),
sa.Column('phone_number', sa.String(length=20), 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.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(['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(['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')) 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('sequence', sa.Integer(), nullable=False),
sa.Column('status_text', sa.String(length=255), nullable=True), sa.Column('status_text', sa.String(length=255), nullable=True),
sa.Column('modified', sa.DateTime(timezone=True), 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('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_scancode', sa.String(length=14), nullable=True),
sa.Column('product_brand', sa.String(length=100), nullable=True), sa.Column('product_brand', sa.String(length=100), nullable=True),
sa.Column('product_description', sa.String(length=255), 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('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('discount_percent', sa.Numeric(precision=5, scale=3), nullable=True),
sa.Column('total_price', sa.Numeric(precision=8, 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(['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')) sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_batch_neworder_row'))
) )
@ -192,11 +233,17 @@ def downgrade() -> None:
# sideshow_order # sideshow_order
op.drop_table('sideshow_order') op.drop_table('sideshow_order')
# sideshow_pending_product # sideshow_product_local
op.drop_table('sideshow_pending_product') op.drop_table('sideshow_product_local')
# sideshow_pending_customer # sideshow_product_pending
op.drop_table('sideshow_pending_customer') 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 # enums
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').drop(op.get_bind()) sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').drop(op.get_bind())

View file

@ -32,6 +32,8 @@ Primary :term:`data models <data model>`:
* :class:`~sideshow.db.model.orders.Order` * :class:`~sideshow.db.model.orders.Order`
* :class:`~sideshow.db.model.orders.OrderItem` * :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.customers.PendingCustomer`
* :class:`~sideshow.db.model.products.PendingProduct` * :class:`~sideshow.db.model.products.PendingProduct`
@ -45,8 +47,8 @@ And the :term:`batch` models:
from wuttjamaican.db.model import * from wuttjamaican.db.model import *
# sideshow models # sideshow models
from .customers import PendingCustomer from .customers import LocalCustomer, PendingCustomer
from .products import PendingProduct from .products import LocalProduct, PendingProduct
from .orders import Order, OrderItem from .orders import Order, OrderItem
# batch models # batch models

View file

@ -58,7 +58,8 @@ class NewOrderBatch(model.BatchMixin, model.Base):
@declared_attr @declared_attr
def __table_args__(cls): def __table_args__(cls):
return cls.__default_table_args__() + ( 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 STATUS_OK = 1
@ -72,13 +73,27 @@ class NewOrderBatch(model.BatchMixin, model.Base):
""") """)
customer_id = sa.Column(sa.String(length=20), nullable=True, doc=""" customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the proper customer account to which the order pertains, if Proper account ID for the :term:`external customer` to which the
applicable. order pertains, if applicable.
This will be set only when an "existing" customer account can be See also :attr:`local_customer` and :attr:`pending_customer`.
selected for the order. See also :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) pending_customer_uuid = sa.Column(model.UUID(), nullable=True)
@declared_attr @declared_attr
@ -91,8 +106,7 @@ class NewOrderBatch(model.BatchMixin, model.Base):
:class:`~sideshow.db.model.customers.PendingCustomer` :class:`~sideshow.db.model.customers.PendingCustomer`
record for the order, if applicable. record for the order, if applicable.
This is set only when making an order for a "new / See also :attr:`customer_id` and :attr:`local_customer`.
unknown" customer. See also :attr:`customer_id`.
""") """)
customer_name = sa.Column(sa.String(length=100), nullable=True, doc=""" customer_name = sa.Column(sa.String(length=100), nullable=True, doc="""
@ -126,7 +140,8 @@ class NewOrderBatchRow(model.BatchRowMixin, model.Base):
@declared_attr @declared_attr
def __table_args__(cls): def __table_args__(cls):
return cls.__default_table_args__() + ( 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 STATUS_OK = 1
@ -158,13 +173,27 @@ class NewOrderBatchRow(model.BatchRowMixin, model.Base):
""" """
product_id = sa.Column(sa.String(length=20), nullable=True, doc=""" product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the true product which the order item represents, if Proper ID for the :term:`external product` which the order item
applicable. represents, if applicable.
This will be set only when an "existing" product can be selected See also :attr:`local_product` and :attr:`pending_product`.
for the order. See also :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) pending_product_uuid = sa.Column(model.UUID(), nullable=True)
@declared_attr @declared_attr
@ -177,8 +206,7 @@ class NewOrderBatchRow(model.BatchRowMixin, model.Base):
:class:`~sideshow.db.model.products.PendingProduct` record :class:`~sideshow.db.model.products.PendingProduct` record
for the order item, if applicable. for the order item, if applicable.
This is set only when making an order for a "new / See also :attr:`product_id` and :attr:`local_product`.
unknown" product. See also :attr:`product_id`.
""") """)
product_scancode = sa.Column(sa.String(length=14), nullable=True, doc=""" product_scancode = sa.Column(sa.String(length=14), nullable=True, doc="""

View file

@ -34,19 +34,13 @@ from wuttjamaican.db import model
from sideshow.enum import PendingCustomerStatus from sideshow.enum import PendingCustomerStatus
class PendingCustomer(model.Base): class CustomerMixin:
""" """
A "pending" customer record, used when entering an :term:`order` Base class for customer tables. This has shared columns, used by e.g.:
for new/unknown customer.
* :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_name = sa.Column(sa.String(length=100), nullable=True, doc="""
Full display name for the customer account. Full display name for the customer account.
@ -68,6 +62,74 @@ class PendingCustomer(model.Base):
Email address for the customer. 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 <external
customer>` 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
<local customer>` 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 = sa.Column(sa.Enum(PendingCustomerStatus), nullable=False, doc="""
Status code for the customer record. Status code for the customer record.
""") """)
@ -107,6 +169,3 @@ class PendingCustomer(model.Base):
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
records associated with this customer. records associated with this customer.
""") """)
def __str__(self):
return self.full_name or ""

View file

@ -63,14 +63,26 @@ class Order(model.Base):
""") """)
customer_id = sa.Column(sa.String(length=20), nullable=True, doc=""" customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the proper customer account to which the order pertains, if Proper account ID for the :term:`external customer` to which the
applicable. order pertains, if applicable.
This will be set only when an "existing" customer account can be See also :attr:`local_customer` and :attr:`pending_customer`.
assigned for the order. See also :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( pending_customer = orm.relationship(
'PendingCustomer', 'PendingCustomer',
cascade_backrefs=False, cascade_backrefs=False,
@ -80,8 +92,7 @@ class Order(model.Base):
:class:`~sideshow.db.model.customers.PendingCustomer` record :class:`~sideshow.db.model.customers.PendingCustomer` record
for the order, if applicable. for the order, if applicable.
This is set only when the order is for a "new / unknown" See also :attr:`customer_id` and :attr:`local_customer`.
customer. See also :attr:`customer_id`.
""") """)
customer_name = sa.Column(sa.String(length=100), nullable=True, doc=""" 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=""" product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the true product which the order item represents, if Proper ID for the :term:`external product` which the order item
applicable. represents, if applicable.
This will be set only when an "existing" product can be selected See also :attr:`local_product` and :attr:`pending_product`.
for the order. See also :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( pending_product = orm.relationship(
'PendingProduct', 'PendingProduct',
cascade_backrefs=False, cascade_backrefs=False,
@ -175,8 +198,7 @@ class OrderItem(model.Base):
:class:`~sideshow.db.model.products.PendingProduct` record for :class:`~sideshow.db.model.products.PendingProduct` record for
the order item, if applicable. the order item, if applicable.
This is set only when the order item is for a "new / unknown" See also :attr:`product_id` and :attr:`local_product`.
product. See also :attr:`product_id`.
""") """)
product_scancode = sa.Column(sa.String(length=14), nullable=True, doc=""" product_scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
@ -310,5 +332,15 @@ class OrderItem(model.Base):
applicable/known. 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): def __str__(self):
return str(self.pending_product or self.product_description or "") return self.full_description

View file

@ -34,18 +34,13 @@ from wuttjamaican.db import model
from sideshow.enum import PendingProductStatus from sideshow.enum import PendingProductStatus
class PendingProduct(model.Base): class ProductMixin:
""" """
A "pending" product record, used when entering an :term:`order Base class for product tables. This has shared columns, used by e.g.:
item` for new/unknown product.
* :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 = sa.Column(sa.String(length=14), nullable=True, doc="""
Scancode for the product, as string. Scancode for the product, as string.
@ -117,6 +112,82 @@ class PendingProduct(model.Base):
Arbitrary notes regarding the product, if applicable. 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 <external
product>` instead.
Also by default, when a :term:`new order batch` with
:term:`pending product(s) <pending product>` 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 <local product>` 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 = sa.Column(sa.Enum(PendingProductStatus), nullable=False, doc="""
Status code for the product record. Status code for the product record.
""") """)
@ -138,10 +209,8 @@ class PendingProduct(model.Base):
order_items = orm.relationship( order_items = orm.relationship(
'OrderItem', 'OrderItem',
# TODO
# order_by='NewOrderBatchRow.id.desc()',
cascade_backrefs=False,
back_populates='pending_product', back_populates='pending_product',
cascade_backrefs=False,
doc=""" doc="""
List of :class:`~sideshow.db.model.orders.OrderItem` records List of :class:`~sideshow.db.model.orders.OrderItem` records
associated with this product. associated with this product.
@ -149,25 +218,10 @@ class PendingProduct(model.Base):
new_order_batch_rows = orm.relationship( new_order_batch_rows = orm.relationship(
'NewOrderBatchRow', 'NewOrderBatchRow',
# TODO
# order_by='NewOrderBatchRow.id.desc()',
cascade_backrefs=False,
back_populates='pending_product', back_populates='pending_product',
cascade_backrefs=False,
doc=""" doc="""
List of List of
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow` :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
records associated with this product. 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

View file

@ -29,7 +29,7 @@ from wuttaweb.forms.schema import ObjectRef
class OrderRef(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. reference field.
This is a subclass of This is a subclass of
@ -51,9 +51,34 @@ class OrderRef(ObjectRef):
return self.request.route_url('orders.view', uuid=order.uuid) 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): class PendingCustomerRef(ObjectRef):
""" """
Custom schema type for a Schema type for a
:class:`~sideshow.db.model.customers.PendingCustomer` reference :class:`~sideshow.db.model.customers.PendingCustomer` reference
field. field.
@ -76,9 +101,33 @@ class PendingCustomerRef(ObjectRef):
return self.request.route_url('pending_customers.view', uuid=customer.uuid) 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): class PendingProductRef(ObjectRef):
""" """
Custom schema type for a Schema type for a
:class:`~sideshow.db.model.products.PendingProduct` reference :class:`~sideshow.db.model.products.PendingProduct` reference
field. field.

View file

@ -36,14 +36,15 @@ class SideshowMenuHandler(base.MenuHandler):
""" """ """ """
return [ return [
self.make_orders_menu(request), 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_batch_menu(request),
self.make_admin_menu(request), self.make_admin_menu(request),
] ]
def make_orders_menu(self, request, **kwargs): def make_orders_menu(self, request, **kwargs):
""" """
Generate a typical Orders menu. Generate the Orders menu.
""" """
return { return {
'title': "Orders", 'title': "Orders",
@ -55,34 +56,55 @@ class SideshowMenuHandler(base.MenuHandler):
'perm': 'orders.create', 'perm': 'orders.create',
}, },
{'type': 'sep'}, {'type': 'sep'},
{
'title': "All Orders",
'route': 'orders',
'perm': 'orders.list',
},
{ {
'title': "All Order Items", 'title': "All Order Items",
'route': 'order_items', 'route': 'order_items',
'perm': 'order_items.list', '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 { return {
'title': "Pending", 'title': "Customers",
'type': 'menu', 'type': 'menu',
'items': [ 'items': [
{ {
'title': "Customers", 'title': "Local Customers",
'route': 'local_customers',
'perm': 'local_customers.list',
},
{
'title': "Pending Customers",
'route': 'pending_customers', 'route': 'pending_customers',
'perm': 'pending_customers.list', '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', 'route': 'pending_products',
'perm': 'pending_products.list', 'perm': 'pending_products.list',
}, },
@ -91,7 +113,7 @@ class SideshowMenuHandler(base.MenuHandler):
def make_batch_menu(self, request, **kwargs): def make_batch_menu(self, request, **kwargs):
""" """
Generate a typical Batch menu. Generate the Batch menu.
""" """
return { return {
'title': "Batches", 'title': "Batches",

View file

@ -7,15 +7,15 @@
<div class="block" style="padding-left: 2rem;"> <div class="block" style="padding-left: 2rem;">
<b-field message="If set, user can enter details of an arbitrary new &quot;pending&quot; product."> <b-field message="If set, user can enter details of an arbitrary new &quot;pending&quot; product.">
<b-checkbox name="sideshow.orders.allow_unknown_product" <b-checkbox name="sideshow.orders.allow_unknown_products"
v-model="simpleSettings['sideshow.orders.allow_unknown_product']" v-model="simpleSettings['sideshow.orders.allow_unknown_products']"
native-value="true" native-value="true"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true">
Allow creating orders for "unknown" products Allow creating orders for "unknown" products
</b-checkbox> </b-checkbox>
</b-field> </b-field>
<div v-show="simpleSettings['sideshow.orders.allow_unknown_product']" <div v-show="simpleSettings['sideshow.orders.allow_unknown_products']"
style="padding-left: 2rem;"> style="padding-left: 2rem;">
<p class="block"> <p class="block">

View file

@ -130,28 +130,20 @@
<b-field label="Customer"> <b-field label="Customer">
<div style="display: flex; gap: 1rem; width: 100%;"> <div style="display: flex; gap: 1rem; width: 100%;">
<b-autocomplete ref="customerAutocomplete" <wutta-autocomplete ref="customerAutocomplete"
v-model="customerID" v-model="customerID"
:style="{'flex-grow': customerID ? '0' : '1'}" :display="customerName"
expanded service-url="${url(f'{route_prefix}.customer_autocomplete')}"
placeholder="Enter name or phone number" placeholder="Enter name or phone number"
## TODO
## serviceUrl="${url(f'{route_prefix}.customer_autocomplete')}"
% if request.use_oruga:
## :assigned-label="customerName"
@update:model-value="customerChanged"
% else:
## :initial-label="customerName"
@input="customerChanged" @input="customerChanged"
% endif :style="{'flex-grow': customerID ? '0' : '1'}"
> expanded />
</b-autocomplete>
<b-button v-if="customerID" <b-button v-if="customerID"
@click="refreshCustomer" @click="refreshCustomer"
icon-pack="fas" icon-pack="fas"
icon-left="redo" icon-left="redo"
:disabled="refreshingCustomer"> :disabled="refreshingCustomer">
{{ refreshingCustomer ? "Refreshig" : "Refresh" }} {{ refreshingCustomer ? "Refreshing" : "Refresh" }}
</b-button> </b-button>
</div> </div>
</b-field> </b-field>
@ -348,9 +340,9 @@
<${b}-modal <${b}-modal
% if request.use_oruga: % if request.use_oruga:
v-model:active="showingItemDialog" v-model:active="editItemShowDialog"
% else: % else:
:active.sync="showingItemDialog" :active.sync="editItemShowDialog"
% endif % endif
:can-cancel="['escape', 'x']" :can-cancel="['escape', 'x']"
> >
@ -382,21 +374,12 @@
<div style="flex-grow: 1;"> <div style="flex-grow: 1;">
<b-field label="Product"> <b-field label="Product">
<b-autocomplete ref="productLookup" <wutta-autocomplete ref="productAutocomplete"
v-model="productID" v-model="productID"
## :style="{'flex-grow': customerID ? '0' : '1'}" :display="productDisplay"
## expanded service-url="${url(f'{route_prefix}.product_autocomplete')}"
## placeholder="Enter name or phone number" placeholder="Enter brand, description etc."
## ## serviceUrl="${url(f'{route_prefix}.customer_autocomplete')}" @input="productChanged" />
## % if request.use_oruga:
## ## :assigned-label="customerName"
## @update:model-value="customerChanged"
## % else:
## ## :initial-label="customerName"
## @input="customerChanged"
## % endif
>
</b-autocomplete>
</b-field> </b-field>
<div v-if="productID"> <div v-if="productID">
@ -443,16 +426,16 @@
</div> </div>
</div> </div>
<img v-if="productID" ## <img v-if="productID"
:src="productImageURL" ## :src="productImageURL"
style="max-height: 150px; max-width: 150px; "/> ## style="max-height: 150px; max-width: 150px; "/>
</div> </div>
<br /> <br />
<div class="field"> <div class="field">
<b-radio v-model="productIsKnown" <b-radio v-model="productIsKnown"
% if not allow_unknown_product: % if not allow_unknown_products:
disabled disabled
% endif % endif
:native-value="false"> :native-value="false">
@ -705,7 +688,7 @@
</${b}-tabs> </${b}-tabs>
<div class="buttons"> <div class="buttons">
<b-button @click="showingItemDialog = false"> <b-button @click="editItemShowDialog = false">
Cancel Cancel
</b-button> </b-button>
<b-button type="is-primary" <b-button type="is-primary"
@ -713,7 +696,7 @@
:disabled="itemDialogSaveDisabled" :disabled="itemDialogSaveDisabled"
icon-pack="fas" icon-pack="fas"
icon-left="save"> icon-left="save">
{{ itemDialogSaving ? "Working, please wait..." : (this.editingItem ? "Update Item" : "Add Item") }} {{ itemDialogSaving ? "Working, please wait..." : (this.editItemRow ? "Update Item" : "Add Item") }}
</b-button> </b-button>
</div> </div>
@ -757,8 +740,10 @@
<${b}-table-column label="Unit Price" <${b}-table-column label="Unit Price"
v-slot="props"> v-slot="props">
<span :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null"> <span
{{ props.row.unit_price_display }} ##:class="props.row.pricing_reflects_sale ? 'has-background-warning' : null"
>
{{ props.row.unit_price_quoted_display }}
</span> </span>
</${b}-table-column> </${b}-table-column>
@ -771,17 +756,15 @@
<${b}-table-column label="Vendor" <${b}-table-column label="Vendor"
v-slot="props"> v-slot="props">
{{ props.row.vendor_display }} {{ props.row.vendor_name }}
</${b}-table-column> </${b}-table-column>
<${b}-table-column field="actions" <${b}-table-column field="actions"
label="Actions" label="Actions"
v-slot="props"> v-slot="props">
<a href="#" <a href="#"
% if not request.use_oruga: @click.prevent="editItemInit(props.row)">
class="grid-action"
% endif
@click.prevent="showEditItemDialog(props.row)">
% if request.use_oruga: % if request.use_oruga:
<span class="icon-text"> <span class="icon-text">
<o-icon icon="edit" /> <o-icon icon="edit" />
@ -846,13 +829,14 @@
batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n}, batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n},
customerPanelOpen: false, customerPanelOpen: false,
customerIsKnown: ${json.dumps(bool(batch.customer_id))|n}, customerIsKnown: ${json.dumps(customer_is_known)|n},
customerID: ${json.dumps(batch.customer_id)|n}, customerID: ${json.dumps(customer_id)|n},
customerName: ${json.dumps(batch.customer_name)|n}, customerName: ${json.dumps(customer_name)|n},
orderPhoneNumber: ${json.dumps(batch.phone_number)|n}, orderPhoneNumber: ${json.dumps(phone_number)|n},
orderEmailAddress: ${json.dumps(batch.email_address)|n}, orderEmailAddress: ${json.dumps(email_address)|n},
refreshingCustomer: false, refreshingCustomer: false,
newCustomerFullName: ${json.dumps(new_customer_full_name)|n},
newCustomerFirstName: ${json.dumps(new_customer_first_name)|n}, newCustomerFirstName: ${json.dumps(new_customer_first_name)|n},
newCustomerLastName: ${json.dumps(new_customer_last_name)|n}, newCustomerLastName: ${json.dumps(new_customer_last_name)|n},
newCustomerPhone: ${json.dumps(new_customer_phone)|n}, newCustomerPhone: ${json.dumps(new_customer_phone)|n},
@ -867,8 +851,8 @@
items: ${json.dumps(order_items)|n}, items: ${json.dumps(order_items)|n},
editingItem: null, editItemRow: null,
showingItemDialog: false, editItemShowDialog: false,
itemDialogSaving: false, itemDialogSaving: false,
% if request.use_oruga: % if request.use_oruga:
itemDialogTab: 'product', itemDialogTab: 'product',
@ -921,20 +905,8 @@
customerPanelHeader() { customerPanelHeader() {
let text = "Customer" let text = "Customer"
if (this.customerIsKnown) { if (this.customerName) {
if (this.customerID) { text = "Customer: " + this.customerName
## 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.customerPanelOpen) { if (!this.customerPanelOpen) {
@ -1077,41 +1049,29 @@
} }
}, },
## TODO watch: {
## watch: {
## customerIsKnown: function(val) {
## contactIsKnown: function(val) {
## if (val) {
## // when user clicks "contact is known" then we want to // user clicks "customer is in the system"
## // set focus to the autocomplete component
## if (val) { // clear customer
## this.$nextTick(() => { this.customerChanged(null)
## this.$refs.customerAutocomplete.focus()
## }) // focus customer autocomplete
## this.$nextTick(() => {
## // if user has already specified a proper contact, this.$refs.customerAutocomplete.focus()
## // 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 } else {
## // out* the existing contact selection. this is // user clicks "customer is NOT in the system"
## // primarily to avoid any ambiguity.
## } else if (this.contactUUID) { // remove true customer; set pending (or null)
## this.$refs.customerAutocomplete.clearSelection() this.setPendingCustomer()
## } }
## }, },
## },
## 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()
## }
## },
## },
methods: { methods: {
@ -1157,7 +1117,7 @@
this.submittingOrder = true this.submittingOrder = true
const params = { const params = {
action: 'submit_new_order', action: 'submit_order',
} }
this.submitBatchData(params, response => { this.submitBatchData(params, response => {
@ -1173,29 +1133,28 @@
customerChanged(customerID, callback) { customerChanged(customerID, callback) {
let params const params = {}
if (!customerID) { if (customerID) {
params = { params.action = 'assign_customer'
action: 'unassign_contact', params.customer_id = customerID
}
} else { } else {
params = { params.action = 'unassign_customer'
action: 'assign_contact',
customer_id: customerID,
}
} }
this.submitBatchData(params, response => {
this.customerID = response.data.customer_id this.submitBatchData(params, ({data}) => {
this.customerName = response.data.customer_name this.customerID = data.customer_id
this.orderPhoneNumber = response.data.phone_number this.customerName = data.customer_name
this.orderEmailAddress = response.data.email_address this.orderPhoneNumber = data.phone_number
this.addOtherPhoneNumber = response.data.add_phone_number this.orderEmailAddress = data.email_address
this.addOtherEmailAddress = response.data.add_email_address
this.contactPhones = response.data.contact_phones
this.contactEmails = response.data.contact_emails
if (callback) { if (callback) {
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.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.newCustomerFirstName = response.data.new_customer_first_name
this.newCustomerLastName = response.data.new_customer_last_name this.newCustomerLastName = response.data.new_customer_last_name
this.newCustomerPhone = response.data.phone_number 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() { getCasePriceDisplay() {
if (this.productIsKnown) { if (this.productIsKnown) {
return this.productCasePriceDisplay 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 ## TODO
## productLookupSelected(selected) { ## productLookupSelected(selected) {
## // TODO: this still is a hack somehow, am sure of it. ## // TODO: this still is a hack somehow, am sure of it.
@ -1335,7 +1399,7 @@
showAddItemDialog() { showAddItemDialog() {
this.customerPanelOpen = false this.customerPanelOpen = false
this.editingItem = null this.editItemRow = null
this.productIsKnown = true this.productIsKnown = true
## this.selectedProduct = null ## this.selectedProduct = null
this.productID = null this.productID = null
@ -1364,14 +1428,15 @@
% else: % else:
this.itemDialogTabIndex = 0 this.itemDialogTabIndex = 0
% endif % endif
this.showingItemDialog = true this.editItemShowDialog = true
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.productLookup.focus() // this.$refs.productLookup.focus()
this.$refs.productAutocomplete.focus()
}) })
}, },
showEditItemDialog(row) { editItemInit(row) {
this.editingItem = row this.editItemRow = row
this.productIsKnown = !!row.product_id this.productIsKnown = !!row.product_id
this.productID = row.product_id this.productID = row.product_id
@ -1397,8 +1462,7 @@
this.productDisplay = row.product_full_description this.productDisplay = row.product_full_description
this.productScancode = row.product_scancode this.productScancode = row.product_scancode
this.productSize = row.product_size this.productSize = row.product_size
this.productCaseQuantity = row.case_quantity this.productCaseQuantity = row.case_size
this.productURL = row.product_url
this.productUnitPrice = row.unit_price_quoted this.productUnitPrice = row.unit_price_quoted
this.productUnitPriceDisplay = row.unit_price_quoted_display this.productUnitPriceDisplay = row.unit_price_quoted_display
this.productUnitRegularPriceDisplay = row.unit_price_reg_display this.productUnitRegularPriceDisplay = row.unit_price_reg_display
@ -1422,7 +1486,7 @@
% else: % else:
this.itemDialogTabIndex = 1 this.itemDialogTabIndex = 1
% endif % endif
this.showingItemDialog = true this.editItemShowDialog = true
}, },
deleteItem(index) { deleteItem(index) {
@ -1451,25 +1515,20 @@
itemDialogAttemptSave() { itemDialogAttemptSave() {
this.itemDialogSaving = true this.itemDialogSaving = true
let params = { const params = {
product_is_known: this.productIsKnown,
order_qty: this.productQuantity, order_qty: this.productQuantity,
order_uom: this.productUOM, order_uom: this.productUOM,
} }
% if allow_item_discounts:
params.discount_percent = this.productDiscountPercent
% endif
if (this.productIsKnown) { if (this.productIsKnown) {
params.product_uuid = this.productUUID params.product_info = this.productID
} else { } else {
params.pending_product = this.pendingProduct params.product_info = this.pendingProduct
} }
if (this.editingItem) { if (this.editItemRow) {
params.action = 'update_item' params.action = 'update_item'
params.uuid = this.editingItem.uuid params.uuid = this.editItemRow.uuid
} else { } else {
params.action = 'add_item' params.action = 'add_item'
} }
@ -1484,7 +1543,7 @@
// overwriting the item record, or else display will // overwriting the item record, or else display will
// not update properly // not update properly
for (let [key, value] of Object.entries(response.data.row)) { 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.batchTotalPriceDisplay = response.data.batch.total_price_display
this.itemDialogSaving = false this.itemDialogSaving = false
this.showingItemDialog = false this.editItemShowDialog = false
}, response => { }, response => {
this.itemDialogSaving = false this.itemDialogSaving = false
}) })

View file

@ -29,7 +29,7 @@ from wuttaweb.forms.schema import WuttaMoney
from sideshow.db.model import NewOrderBatch from sideshow.db.model import NewOrderBatch
from sideshow.batch.neworder import NewOrderBatchHandler from sideshow.batch.neworder import NewOrderBatchHandler
from sideshow.web.forms.schema import PendingCustomerRef from sideshow.web.forms.schema import LocalCustomerRef, PendingCustomerRef
class NewOrderBatchView(BatchMasterView): class NewOrderBatchView(BatchMasterView):
@ -87,6 +87,7 @@ class NewOrderBatchView(BatchMasterView):
'id', 'id',
'store_id', 'store_id',
'customer_id', 'customer_id',
'local_customer',
'pending_customer', 'pending_customer',
'customer_name', 'customer_name',
'phone_number', 'phone_number',
@ -115,9 +116,11 @@ class NewOrderBatchView(BatchMasterView):
'product_description', 'product_description',
'product_size', 'product_size',
'special_order', 'special_order',
'unit_price_quoted',
'case_size',
'case_price_quoted',
'order_qty', 'order_qty',
'order_uom', 'order_uom',
'case_size',
'total_price', 'total_price',
'status_code', 'status_code',
] ]
@ -138,6 +141,9 @@ class NewOrderBatchView(BatchMasterView):
""" """ """ """
super().configure_form(f) super().configure_form(f)
# local_customer
f.set_node('local_customer', LocalCustomerRef(self.request))
# pending_customer # pending_customer
f.set_node('pending_customer', PendingCustomerRef(self.request)) f.set_node('pending_customer', PendingCustomerRef(self.request))
@ -153,6 +159,14 @@ class NewOrderBatchView(BatchMasterView):
# order_uom # order_uom
#g.set_renderer('order_uom', self.grid_render_enum, enum=enum.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 # total_price
g.set_renderer('total_price', 'currency') g.set_renderer('total_price', 'currency')

View file

@ -27,7 +27,161 @@ Views for Customers
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaEnum 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): class PendingCustomerView(MasterView):
@ -71,12 +225,9 @@ class PendingCustomerView(MasterView):
'customer_id', 'customer_id',
'full_name', 'full_name',
'first_name', 'first_name',
'middle_name',
'last_name', 'last_name',
'phone_number', 'phone_number',
'phone_type',
'email_address', 'email_address',
'email_type',
'status', 'status',
'created', 'created',
'created_by', 'created_by',
@ -238,6 +389,9 @@ class PendingCustomerView(MasterView):
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()
LocalCustomerView = kwargs.get('LocalCustomerView', base['LocalCustomerView'])
LocalCustomerView.defaults(config)
PendingCustomerView = kwargs.get('PendingCustomerView', base['PendingCustomerView']) PendingCustomerView = kwargs.get('PendingCustomerView', base['PendingCustomerView'])
PendingCustomerView.defaults(config) PendingCustomerView.defaults(config)

View file

@ -31,11 +31,13 @@ import colander
from sqlalchemy import orm from sqlalchemy import orm
from wuttaweb.views import MasterView 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.db.model import Order, OrderItem
from sideshow.batch.neworder import NewOrderBatchHandler 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__) log = logging.getLogger(__name__)
@ -82,6 +84,7 @@ class OrderView(MasterView):
'order_id', 'order_id',
'store_id', 'store_id',
'customer_id', 'customer_id',
'local_customer',
'pending_customer', 'pending_customer',
'customer_name', 'customer_name',
'phone_number', 'phone_number',
@ -165,6 +168,20 @@ class OrderView(MasterView):
batch, which in turn creates a true batch, which in turn creates a true
:class:`~sideshow.db.model.orders.Order`, and user is :class:`~sideshow.db.model.orders.Order`, and user is
redirected to the "view order" page. 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 enum = self.app.enum
self.creating = True self.creating = True
@ -188,22 +205,25 @@ class OrderView(MasterView):
data = dict(self.request.json_body) data = dict(self.request.json_body)
action = data.pop('action') action = data.pop('action')
json_actions = [ json_actions = [
# 'assign_contact', 'assign_customer',
# 'unassign_contact', 'unassign_customer',
# 'update_phone_number', # 'update_phone_number',
# 'update_email_address', # 'update_email_address',
'set_pending_customer', 'set_pending_customer',
# 'get_customer_info', # 'get_customer_info',
# # 'set_customer_data', # # 'set_customer_data',
# 'get_product_info', 'get_product_info',
# 'get_past_items', # 'get_past_items',
'add_item', 'add_item',
'update_item', 'update_item',
'delete_item', 'delete_item',
'submit_new_order', 'submit_order',
] ]
if action in json_actions: 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(result)
return self.json_response({'error': "unknown form action"}) return self.json_response({'error': "unknown form action"})
@ -215,8 +235,8 @@ class OrderView(MasterView):
for row in batch.rows], for row in batch.rows],
'default_uom_choices': self.get_default_uom_choices(), 'default_uom_choices': self.get_default_uom_choices(),
'default_uom': None, # TODO? 'default_uom': None, # TODO?
'allow_unknown_product': (self.batch_handler.allow_unknown_product() 'allow_unknown_products': (self.batch_handler.allow_unknown_products()
and self.has_perm('create_unknown_product')), and self.has_perm('create_unknown_product')),
'pending_product_required_fields': self.get_pending_product_required_fields(), 'pending_product_required_fields': self.get_pending_product_required_fields(),
}) })
return self.render_to_response('create', context) return self.render_to_response('create', context)
@ -255,6 +275,96 @@ class OrderView(MasterView):
return batch 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): def get_pending_product_required_fields(self):
""" """ """ """
required = [] required = []
@ -274,7 +384,10 @@ class OrderView(MasterView):
new batch for them. new batch for them.
This is a "batch action" method which may be called from 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 # drop current batch
self.batch_handler.do_delete(batch, self.request.user) self.batch_handler.do_delete(batch, self.request.user)
@ -291,7 +404,10 @@ class OrderView(MasterView):
back to "List Orders" page. back to "List Orders" page.
This is a "batch action" method which may be called from 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.batch_handler.do_delete(batch, self.request.user)
self.Session.flush() self.Session.flush()
@ -306,86 +422,193 @@ class OrderView(MasterView):
def get_context_customer(self, batch): def get_context_customer(self, batch):
""" """ """ """
context = { context = {
'customer_id': batch.customer_id, 'customer_is_known': True,
'customer_id': None,
'customer_name': batch.customer_name, 'customer_name': batch.customer_name,
'phone_number': batch.phone_number, 'phone_number': batch.phone_number,
'email_address': batch.email_address, '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 pending = batch.pending_customer
if pending: if pending:
context.update({ context.update({
'new_customer_first_name': pending.first_name, 'new_customer_first_name': pending.first_name,
'new_customer_last_name': pending.last_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_phone': pending.phone_number,
'new_customer_email': pending.email_address, 'new_customer_email': pending.email_address,
}) })
# figure out if customer is "known" from user's perspective. # declare customer "not known" only if pending is in use
# if we have an ID then it's definitely known, otherwise if we if (pending
# have a pending customer then it's definitely *not* known, and not batch.customer_id and not batch.local_customer
# but if no pending customer yet then we can still "assume" it and batch.customer_name):
# is known, by default, until user specifies otherwise. context['customer_is_known'] = False
if batch.customer_id:
context['customer_is_known'] = True
else:
context['customer_is_known'] = not pending
return context 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): def set_pending_customer(self, batch, data):
""" """
This will set/update the batch pending customer info. This will set/update the batch pending customer info.
This calls This calls
:meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_pending_customer()` :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_customer()`
for the heavy lifting. 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 This is a "batch action" method which may be called from
:meth:`create()`. :meth:`create()`.
""" """
data['created_by'] = self.request.user product_id = data.get('product_id')
try: if not product_id:
self.batch_handler.set_pending_customer(batch, data) return {'error': "Must specify a product ID"}
except Exception as error:
return {'error': self.app.render_error(error)}
self.Session.flush() use_local = self.batch_handler.use_local_products()
context = self.get_context_customer(batch) if use_local:
return context 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): def add_item(self, batch, data):
""" """
This adds a row to the user's current new order batch. This adds a row to the user's current new order batch.
This is a "batch action" method which may be called from 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') row = self.batch_handler.add_item(batch, data['product_info'],
order_uom = data['order_uom'] data['order_qty'], 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)
return {'batch': self.normalize_batch(batch), return {'batch': self.normalize_batch(batch),
'row': self.normalize_row(row)} '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 updates a row in the user's current new order batch.
This is a "batch action" method which may be called from 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 model = self.app.model
enum = self.app.enum
session = self.Session() session = self.Session()
uuid = data.get('uuid') uuid = data.get('uuid')
if not uuid: if not uuid:
return {'error': "Must specify a row UUID"} return {'error': "Must specify row UUID"}
row = session.get(model.NewOrderBatchRow, uuid) row = session.get(model.NewOrderBatchRow, uuid)
if not row: if not row:
@ -412,20 +637,8 @@ class OrderView(MasterView):
if row.batch is not batch: if row.batch is not batch:
return {'error': "Row is for wrong batch"} return {'error': "Row is for wrong batch"}
order_qty = decimal.Decimal(data.get('order_qty') or '0') self.batch_handler.update_item(row, data['product_info'],
order_uom = data['order_uom'] data['order_qty'], 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'])
return {'batch': self.normalize_batch(batch), return {'batch': self.normalize_batch(batch),
'row': self.normalize_row(row)} '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 deletes a row from the user's current new order batch.
This is a "batch action" method which may be called from 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 model = self.app.model
session = self.app.get_session(batch) session = self.app.get_session(batch)
@ -452,16 +668,18 @@ class OrderView(MasterView):
return {'error': "Row is for wrong batch"} return {'error': "Row is for wrong batch"}
self.batch_handler.do_remove_row(row) self.batch_handler.do_remove_row(row)
session.flush()
return {'batch': self.normalize_batch(batch)} 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 This submits the user's current new order batch, hence
executing the batch and creating the true order. executing the batch and creating the true order.
This is a "batch action" method which may be called from 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 user = self.request.user
reason = self.batch_handler.why_not_execute(batch, user=user) reason = self.batch_handler.why_not_execute(batch, user=user)
@ -502,6 +720,7 @@ class OrderView(MasterView):
data = { data = {
'uuid': row.uuid.hex, 'uuid': row.uuid.hex,
'sequence': row.sequence, 'sequence': row.sequence,
'product_id': None,
'product_scancode': row.product_scancode, 'product_scancode': row.product_scancode,
'product_brand': row.product_brand, 'product_brand': row.product_brand,
'product_description': row.product_description, 'product_description': row.product_description,
@ -509,8 +728,8 @@ class OrderView(MasterView):
'product_weighed': row.product_weighed, 'product_weighed': row.product_weighed,
'department_display': row.department_name, 'department_display': row.department_name,
'special_order': row.special_order, 'special_order': row.special_order,
'case_size': self.app.render_quantity(row.case_size), 'case_size': float(row.case_size) if row.case_size is not None else None,
'order_qty': self.app.render_quantity(row.order_qty), 'order_qty': float(row.order_qty),
'order_uom': row.order_uom, 'order_uom': row.order_uom,
'order_uom_choices': self.get_default_uom_choices(), '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, '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, '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: if row.unit_price_reg:
data['unit_price_reg'] = float(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) 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'] = str(row.sale_ends)
data['sale_ends_display'] = self.app.render_date(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: if row.pending_product:
pending = row.pending_product pending = row.pending_product
# data['vendor_display'] = pending.vendor_name
data['pending_product'] = { data['pending_product'] = {
'uuid': pending.uuid.hex, 'uuid': pending.uuid.hex,
'scancode': pending.scancode, 'scancode': pending.scancode,
@ -569,14 +802,15 @@ class OrderView(MasterView):
# display text for order qty/uom # display text for order qty/uom
if row.order_uom == enum.ORDER_UOM_CASE: if row.order_uom == enum.ORDER_UOM_CASE:
order_qty = self.app.render_quantity(row.order_qty)
if row.case_size is None: if row.case_size is None:
case_qty = unit_qty = '??' case_qty = unit_qty = '??'
else: 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) unit_qty = self.app.render_quantity(row.order_qty * row.case_size)
CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE] CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE]
EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] 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"(&times; {case_qty} = {unit_qty} {EA})") f"(&times; {case_qty} = {unit_qty} {EA})")
else: else:
unit_qty = self.app.render_quantity(row.order_qty) unit_qty = self.app.render_quantity(row.order_qty)
@ -592,9 +826,16 @@ class OrderView(MasterView):
def configure_form(self, f): def configure_form(self, f):
""" """ """ """
super().configure_form(f) super().configure_form(f)
order = f.model_instance
# local_customer
f.set_node('local_customer', LocalCustomerRef(self.request))
# pending_customer # 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 # total_price
f.set_node('total_price', WuttaMoney(self.request)) f.set_node('total_price', WuttaMoney(self.request))
@ -672,7 +913,7 @@ class OrderView(MasterView):
settings = [ settings = [
# products # products
{'name': 'sideshow.orders.allow_unknown_product', {'name': 'sideshow.orders.allow_unknown_products',
'type': bool, 'type': bool,
'default': True}, 'default': True},
] ]
@ -702,7 +943,9 @@ class OrderView(MasterView):
@classmethod @classmethod
def _order_defaults(cls, config): def _order_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix() permission_prefix = cls.get_permission_prefix()
url_prefix = cls.get_url_prefix()
model_title = cls.get_model_title() model_title = cls.get_model_title()
model_title_plural = cls.get_model_title_plural() model_title_plural = cls.get_model_title_plural()
@ -716,6 +959,24 @@ class OrderView(MasterView):
f'{permission_prefix}.create_unknown_product', f'{permission_prefix}.create_unknown_product',
f"Create new {model_title} for unknown/pending 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): class OrderItemView(MasterView):
""" """
@ -745,7 +1006,8 @@ class OrderItemView(MasterView):
'product_brand': "Brand", 'product_brand': "Brand",
'product_description': "Description", 'product_description': "Description",
'product_size': "Size", 'product_size': "Size",
'department_name': "Department", 'product_weighed': "Sold by Weight",
'department_id': "Department ID",
'order_uom': "Order UOM", 'order_uom': "Order UOM",
'status_code': "Status", 'status_code': "Status",
} }
@ -773,6 +1035,7 @@ class OrderItemView(MasterView):
# 'customer_name', # 'customer_name',
'sequence', 'sequence',
'product_id', 'product_id',
'local_product',
'pending_product', 'pending_product',
'product_scancode', 'product_scancode',
'product_brand', 'product_brand',
@ -852,27 +1115,46 @@ class OrderItemView(MasterView):
enum = self.app.enum enum = self.app.enum
return enum.ORDER_ITEM_STATUS[value] 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): def configure_form(self, f):
""" """ """ """
super().configure_form(f) super().configure_form(f)
enum = self.app.enum enum = self.app.enum
item = f.model_instance
# order # order
f.set_node('order', OrderRef(self.request)) f.set_node('order', OrderRef(self.request))
# local_product
f.set_node('local_product', LocalProductRef(self.request))
# pending_product # 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 # order_qty
f.set_node('order_qty', WuttaQuantity(self.request)) f.set_node('order_qty', WuttaQuantity(self.request))
# order_uom # order_uom
# TODO f.set_node('order_uom', WuttaDictEnum(self.request, enum.ORDER_UOM))
#f.set_node('order_uom', WuttaEnum(self.request, enum.OrderUOM))
# case_size # case_size
f.set_node('case_size', WuttaQuantity(self.request)) 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 # unit_price_quoted
f.set_node('unit_price_quoted', WuttaMoney(self.request)) f.set_node('unit_price_quoted', WuttaMoney(self.request))
@ -882,18 +1164,21 @@ class OrderItemView(MasterView):
# total_price # total_price
f.set_node('total_price', WuttaMoney(self.request)) f.set_node('total_price', WuttaMoney(self.request))
# status
f.set_node('status_code', WuttaDictEnum(self.request, enum.ORDER_ITEM_STATUS))
# paid_amount # paid_amount
f.set_node('paid_amount', WuttaMoney(self.request)) f.set_node('paid_amount', WuttaMoney(self.request))
def get_xref_buttons(self, item): def get_xref_buttons(self, item):
""" """ """ """
buttons = super().get_xref_buttons(item) buttons = super().get_xref_buttons(item)
model = self.app.model
if self.request.has_perm('orders.view'): if self.request.has_perm('orders.view'):
url = self.request.route_url('orders.view', uuid=item.order_uuid) url = self.request.route_url('orders.view', uuid=item.order_uuid)
buttons.append( 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 return buttons

View file

@ -25,9 +25,194 @@ Views for Products
""" """
from wuttaweb.views import MasterView 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): class PendingProductView(MasterView):
@ -249,6 +434,9 @@ class PendingProductView(MasterView):
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()
LocalProductView = kwargs.get('LocalProductView', base['LocalProductView'])
LocalProductView.defaults(config)
PendingProductView = kwargs.get('PendingProductView', base['PendingProductView']) PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
PendingProductView.defaults(config) PendingProductView.defaults(config)

View file

@ -2,6 +2,7 @@
import datetime import datetime
import decimal import decimal
from unittest.mock import patch
from wuttjamaican.testing import DataTestCase from wuttjamaican.testing import DataTestCase
@ -19,71 +20,133 @@ class TestNewOrderBatchHandler(DataTestCase):
def make_handler(self): def make_handler(self):
return mod.NewOrderBatchHandler(self.config) return mod.NewOrderBatchHandler(self.config)
def tets_allow_unknown_product(self): def tets_use_local_customers(self):
handler = self.make_handler() handler = self.make_handler()
# true by default # true by default
self.assertTrue(handler.allow_unknown_product()) self.assertTrue(handler.use_local_customers())
# config can disable # config can disable
config.setdefault('sideshow.orders.allow_unknown_product', 'false') config.setdefault('sideshow.orders.use_local_customers', 'false')
self.assertFalse(handler.allow_unknown_product()) 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 model = self.app.model
handler = self.make_handler() handler = self.make_handler()
user = model.User(username='barney') user = model.User(username='barney')
self.session.add(user) self.session.add(user)
batch = handler.make_batch(self.session, created_by=user, customer_id=42) # customer starts blank
self.assertEqual(batch.customer_id, 42) 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.pending_customer)
self.assertIsNone(batch.customer_name) self.assertIsNone(batch.customer_name)
self.assertIsNone(batch.phone_number) self.assertIsNone(batch.phone_number)
self.assertIsNone(batch.email_address) self.assertIsNone(batch.email_address)
# auto full_name # pending, typical (nb. full name is automatic)
handler.set_pending_customer(batch, { handler.set_customer(batch, {
'first_name': "Fred", 'first_name': "Fred",
'last_name': "Flintstone", 'last_name': "Flintstone",
'phone_number': '555-1234', 'phone_number': '555-1234',
'email_address': 'fred@mailinator.com', 'email_address': 'fred@mailinator.com',
}) })
self.assertIsNone(batch.customer_id) self.assertIsNone(batch.customer_id)
self.assertIsNone(batch.local_customer)
self.assertIsInstance(batch.pending_customer, model.PendingCustomer) self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
customer = batch.pending_customer customer = batch.pending_customer
self.assertEqual(customer.full_name, "Fred Flintstone")
self.assertEqual(customer.first_name, "Fred") self.assertEqual(customer.first_name, "Fred")
self.assertEqual(customer.last_name, "Flintstone") self.assertEqual(customer.last_name, "Flintstone")
self.assertEqual(customer.full_name, "Fred Flintstone")
self.assertEqual(customer.phone_number, '555-1234') self.assertEqual(customer.phone_number, '555-1234')
self.assertEqual(customer.email_address, 'fred@mailinator.com') self.assertEqual(customer.email_address, 'fred@mailinator.com')
self.assertEqual(batch.customer_name, "Fred Flintstone") self.assertEqual(batch.customer_name, "Fred Flintstone")
self.assertEqual(batch.phone_number, '555-1234') self.assertEqual(batch.phone_number, '555-1234')
self.assertEqual(batch.email_address, 'fred@mailinator.com') self.assertEqual(batch.email_address, 'fred@mailinator.com')
# explicit full_name # pending, minimal
batch = handler.make_batch(self.session, created_by=user, customer_id=42) last_customer = customer # save ref to prev record
handler.set_pending_customer(batch, { handler.set_customer(batch, {'full_name': "Wilma Flintstone"})
'full_name': "Freddy Flintstone",
'first_name': "Fred",
'last_name': "Flintstone",
'phone_number': '555-1234',
'email_address': 'fred@mailinator.com',
})
self.assertIsNone(batch.customer_id) self.assertIsNone(batch.customer_id)
self.assertIsNone(batch.local_customer)
self.assertIsInstance(batch.pending_customer, model.PendingCustomer) self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
customer = batch.pending_customer customer = batch.pending_customer
self.assertEqual(customer.full_name, "Freddy Flintstone") self.assertIs(customer, last_customer)
self.assertEqual(customer.first_name, "Fred") self.assertEqual(customer.full_name, "Wilma Flintstone")
self.assertEqual(customer.last_name, "Flintstone") self.assertIsNone(customer.first_name)
self.assertEqual(customer.phone_number, '555-1234') self.assertIsNone(customer.last_name)
self.assertEqual(customer.email_address, 'fred@mailinator.com') self.assertIsNone(customer.phone_number)
self.assertEqual(batch.customer_name, "Freddy Flintstone") self.assertIsNone(customer.email_address)
self.assertEqual(batch.phone_number, '555-1234') self.assertEqual(batch.customer_name, "Wilma Flintstone")
self.assertEqual(batch.email_address, 'fred@mailinator.com') 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 model = self.app.model
enum = self.app.enum enum = self.app.enum
handler = self.make_handler() handler = self.make_handler()
@ -95,144 +158,290 @@ class TestNewOrderBatchHandler(DataTestCase):
self.session.add(batch) self.session.add(batch)
self.assertEqual(len(batch.rows), 0) self.assertEqual(len(batch.rows), 0)
# pending, typical
kw = dict( kw = dict(
scancode='07430500132', scancode='07430500001',
brand_name='Bragg', brand_name='Bragg',
description='Vinegar', description='Vinegar',
size='32oz', size='1oz',
case_size=12, case_size=12,
unit_cost=decimal.Decimal('3.99'), unit_cost=decimal.Decimal('1.99'),
unit_price_reg=decimal.Decimal('5.99'), unit_price_reg=decimal.Decimal('2.99'),
created_by=user,
) )
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.assertEqual(len(batch.rows), 1)
self.assertIs(batch.rows[0], row) self.assertIs(batch.rows[0], row)
self.assertIsNone(row.product_id)
self.assertEqual(row.product_scancode, '07430500132') self.assertIsNone(row.local_product)
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 product = row.pending_product
self.assertIsInstance(product, model.PendingProduct) self.assertIsInstance(product, model.PendingProduct)
self.assertEqual(product.scancode, '07430500132') self.assertEqual(product.scancode, '07430500001')
self.assertEqual(product.brand_name, 'Bragg') self.assertEqual(product.brand_name, 'Bragg')
self.assertEqual(product.description, 'Vinegar') self.assertEqual(product.description, 'Vinegar')
self.assertEqual(product.size, '32oz') self.assertEqual(product.size, '1oz')
self.assertEqual(product.case_size, 12) self.assertEqual(product.case_size, 12)
self.assertEqual(product.unit_cost, decimal.Decimal('3.99')) self.assertEqual(product.unit_cost, decimal.Decimal('1.99'))
self.assertEqual(product.unit_price_reg, decimal.Decimal('5.99')) self.assertEqual(product.unit_price_reg, decimal.Decimal('2.99'))
self.assertIs(product.created_by, user) 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 # pending, minimal
self.config.setdefault('sideshow.orders.allow_unknown_product', 'false') row = handler.add_item(batch, {'description': "Tangerines"}, 1, enum.ORDER_UOM_UNIT)
self.assertRaises(TypeError, handler.add_pending_product, batch, kw, 1, enum.ORDER_UOM_UNIT) self.assertIsNone(row.product_id)
self.assertIsNone(row.local_product)
def test_set_pending_product(self): product = row.pending_product
model = self.app.model self.assertIsInstance(product, model.PendingProduct)
enum = self.app.enum self.assertIsNone(product.scancode)
handler = self.make_handler() self.assertIsNone(product.brand_name)
self.assertEqual(product.description, 'Tangerines')
user = model.User(username='barney') self.assertIsNone(product.size)
self.session.add(user) self.assertIsNone(product.case_size)
self.assertIsNone(product.unit_cost)
batch = handler.make_batch(self.session, created_by=user) self.assertIsNone(product.unit_price_reg)
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)
self.assertIsNone(row.product_scancode) self.assertIsNone(row.product_scancode)
self.assertIsNone(row.product_brand) self.assertIsNone(row.product_brand)
self.assertIsNone(row.product_description) self.assertEqual(row.product_description, 'Tangerines')
self.assertIsNone(row.product_size) self.assertIsNone(row.product_size)
self.assertIsNone(row.case_size) self.assertIsNone(row.case_size)
self.assertIsNone(row.unit_cost) self.assertIsNone(row.unit_cost)
self.assertIsNone(row.unit_price_reg) self.assertIsNone(row.unit_price_reg)
self.assertIsNone(row.unit_price_quoted) self.assertIsNone(row.unit_price_quoted)
self.assertIsNone(row.case_price_quoted)
# set pending, which clears product_id self.assertIsNone(row.total_price)
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)
# error if unknown products not allowed # error if unknown products not allowed
self.config.setdefault('sideshow.orders.allow_unknown_product', 'false') self.config.setdefault('sideshow.orders.allow_unknown_products', 'false')
self.assertRaises(TypeError, handler.set_pending_product, row, dict( self.assertRaises(TypeError, handler.add_item, batch, kw, 1, enum.ORDER_UOM_UNIT)
scancode='07430500116',
size='16oz', # local product
unit_cost=decimal.Decimal('2.19'), local = model.LocalProduct(scancode='07430500002',
unit_price_reg=decimal.Decimal('3.59'), 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): def test_refresh_row(self):
model = self.app.model model = self.app.model
@ -387,7 +596,7 @@ class TestNewOrderBatchHandler(DataTestCase):
unit_price_reg=decimal.Decimal('5.99'), unit_price_reg=decimal.Decimal('5.99'),
created_by=user, 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.add(row)
self.session.flush() self.session.flush()
self.assertEqual(batch.row_count, 1) self.assertEqual(batch.row_count, 1)
@ -423,25 +632,70 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertNotIn(batch, self.session) self.assertNotIn(batch, self.session)
self.assertEqual(self.session.query(model.PendingCustomer).count(), 0) self.assertEqual(self.session.query(model.PendingCustomer).count(), 0)
# make new pending customer # make new pending customer, assigned to batch + order
customer = model.PendingCustomer(full_name="Fred Flintstone", customer = model.PendingCustomer(full_name="Wilma Flintstone",
status=enum.PendingCustomerStatus.PENDING, status=enum.PendingCustomerStatus.PENDING,
created_by=user) created_by=user)
self.session.add(customer) 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 # deleting batch will *not* delete pending customer
batch1 = handler.make_batch(self.session, created_by=user, pending_customer=customer) self.assertIn(batch, self.session)
batch2 = handler.make_batch(self.session, created_by=user, pending_customer=customer) self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
self.session.add(batch1) handler.do_delete(batch, user)
self.session.add(batch2)
self.session.commit() self.session.commit()
self.assertNotIn(batch, self.session)
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
# deleting 1 will not delete pending customer # make new pending product, associate w/ batch + order
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1) batch = handler.make_batch(self.session, created_by=user)
handler.do_delete(batch1, 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.session.commit()
self.assertNotIn(batch, self.session)
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1) 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): def test_get_effective_rows(self):
model = self.app.model model = self.app.model
@ -492,6 +746,12 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(reason, "Must assign the customer") self.assertEqual(reason, "Must assign the customer")
batch.customer_id = 42 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) reason = handler.why_not_execute(batch)
self.assertEqual(reason, "Must add at least one valid item") self.assertEqual(reason, "Must add at least one valid item")
@ -506,13 +766,206 @@ class TestNewOrderBatchHandler(DataTestCase):
unit_price_reg=decimal.Decimal('5.99'), unit_price_reg=decimal.Decimal('5.99'),
created_by=user, 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.add(row)
self.session.flush() self.session.flush()
reason = handler.why_not_execute(batch) reason = handler.why_not_execute(batch)
self.assertIsNone(reason) 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): def test_make_new_order(self):
model = self.app.model model = self.app.model
enum = self.app.enum enum = self.app.enum
@ -534,7 +987,7 @@ class TestNewOrderBatchHandler(DataTestCase):
unit_price_reg=decimal.Decimal('5.99'), unit_price_reg=decimal.Decimal('5.99'),
created_by=user, 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.add(row)
self.session.flush() self.session.flush()
@ -574,7 +1027,7 @@ class TestNewOrderBatchHandler(DataTestCase):
unit_price_reg=decimal.Decimal('5.99'), unit_price_reg=decimal.Decimal('5.99'),
created_by=user, 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.add(row)
self.session.flush() self.session.flush()

View file

@ -19,6 +19,19 @@ class TestOrder(DataTestCase):
class TestOrderItem(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): def test_str(self):
item = mod.OrderItem() item = mod.OrderItem()
@ -27,8 +40,7 @@ class TestOrderItem(DataTestCase):
item = mod.OrderItem(product_description="Vinegar") item = mod.OrderItem(product_description="Vinegar")
self.assertEqual(str(item), "Vinegar") self.assertEqual(str(item), "Vinegar")
product = PendingProduct(brand_name="Bragg", item = mod.OrderItem(product_brand='Bragg',
description="Vinegar", product_description='Vinegar',
size="32oz") product_size='32oz')
item = mod.OrderItem(pending_product=product)
self.assertEqual(str(item), "Bragg Vinegar 32oz") self.assertEqual(str(item), "Bragg Vinegar 32oz")

View file

@ -32,6 +32,31 @@ class TestOrderRef(WebTestCase):
self.assertIn(f'/orders/{order.uuid}', url) 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): class TestPendingCustomerRef(WebTestCase):
def test_sort_query(self): def test_sort_query(self):
@ -60,6 +85,31 @@ class TestPendingCustomerRef(WebTestCase):
self.assertIn(f'/pending/customers/{customer.uuid}', url) 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): class TestPendingProductRef(WebTestCase):
def test_sort_query(self): def test_sort_query(self):

View file

@ -9,4 +9,4 @@ class TestSideshowMenuHandler(WebTestCase):
def test_make_menus(self): def test_make_menus(self):
handler = mod.SideshowMenuHandler(self.config) handler = mod.SideshowMenuHandler(self.config)
menus = handler.make_menus(self.request) menus = handler.make_menus(self.request)
self.assertEqual(len(menus), 4) self.assertEqual(len(menus), 5)

View file

@ -16,6 +16,114 @@ class TestIncludeme(WebTestCase):
mod.includeme(self.pyramid_config) 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): class TestPendingCustomerView(WebTestCase):
def make_view(self): def make_view(self):

View file

@ -13,7 +13,7 @@ from wuttaweb.forms.schema import WuttaMoney
from sideshow.batch.neworder import NewOrderBatchHandler from sideshow.batch.neworder import NewOrderBatchHandler
from sideshow.testing import WebTestCase from sideshow.testing import WebTestCase
from sideshow.web.views import orders as mod 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): class TestIncludeme(WebTestCase):
@ -27,6 +27,9 @@ class TestOrderView(WebTestCase):
def make_view(self): def make_view(self):
return mod.OrderView(self.request) return mod.OrderView(self.request)
def make_handler(self):
return NewOrderBatchHandler(self.config)
def test_configure_grid(self): def test_configure_grid(self):
model = self.app.model model = self.app.model
view = self.make_view() view = self.make_view()
@ -40,6 +43,7 @@ class TestOrderView(WebTestCase):
def test_create(self): def test_create(self):
self.pyramid_config.include('sideshow.web.views') self.pyramid_config.include('sideshow.web.views')
model = self.app.model model = self.app.model
enum = self.app.enum
view = self.make_view() view = self.make_view()
user = model.User(username='barney') user = model.User(username='barney')
@ -91,7 +95,7 @@ class TestOrderView(WebTestCase):
'customer_name': 'Fred Flintstone', 'customer_name': 'Fred Flintstone',
'phone_number': '555-1234', 'phone_number': '555-1234',
'email_address': 'fred@mailinator.com', 'email_address': 'fred@mailinator.com',
'new_customer_name': 'Fred Flintstone', 'new_customer_full_name': 'Fred Flintstone',
'new_customer_first_name': 'Fred', 'new_customer_first_name': 'Fred',
'new_customer_last_name': 'Flintstone', 'new_customer_last_name': 'Flintstone',
'new_customer_phone': '555-1234', 'new_customer_phone': '555-1234',
@ -108,6 +112,40 @@ class TestOrderView(WebTestCase):
self.assertEqual(response.content_type, 'application/json') self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.json_body, {'error': 'unknown form action'}) 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): def test_get_current_batch(self):
model = self.app.model model = self.app.model
handler = NewOrderBatchHandler(self.config) handler = NewOrderBatchHandler(self.config)
@ -137,6 +175,75 @@ class TestOrderView(WebTestCase):
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1) self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
self.assertIs(batch2, batch) 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): def test_get_pending_product_required_fields(self):
model = self.app.model model = self.app.model
view = self.make_view() view = self.make_view()
@ -158,38 +265,51 @@ class TestOrderView(WebTestCase):
model = self.app.model model = self.app.model
handler = NewOrderBatchHandler(self.config) handler = NewOrderBatchHandler(self.config)
view = self.make_view() view = self.make_view()
view.batch_handler = handler
user = model.User(username='barney') user = model.User(username='barney')
self.session.add(user) 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, batch = handler.make_batch(self.session, created_by=user,
customer_id=42, customer_name='Fred Flintstone', local_customer=local, customer_name='Betty Boop',
phone_number='555-1234', email_address='fred@mailinator.com') phone_number='555-8888')
self.session.add(batch) self.session.add(batch)
self.session.flush() self.session.flush()
context = view.get_context_customer(batch) context = view.get_context_customer(batch)
self.assertEqual(context, { self.assertEqual(context, {
'customer_is_known': True, 'customer_is_known': True,
'customer_id': 42, 'customer_id': local.uuid.hex,
'customer_name': 'Fred Flintstone', 'customer_name': 'Betty Boop',
'phone_number': '555-1234', 'phone_number': '555-8888',
'email_address': 'fred@mailinator.com', '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,
}) })
# with pending customer # with pending customer
batch = handler.make_batch(self.session, created_by=user) batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch) self.session.add(batch)
handler.set_pending_customer(batch, dict( handler.set_customer(batch, dict(
full_name="Fred Flintstone", full_name="Fred Flintstone",
first_name="Fred", last_name="Flintstone", first_name="Fred", last_name="Flintstone",
phone_number='555-1234', email_address='fred@mailinator.com', phone_number='555-1234', email_address='fred@mailinator.com',
created_by=user,
)) ))
self.session.flush() self.session.flush()
context = view.get_context_customer(batch) context = view.get_context_customer(batch)
@ -199,7 +319,7 @@ class TestOrderView(WebTestCase):
'customer_name': 'Fred Flintstone', 'customer_name': 'Fred Flintstone',
'phone_number': '555-1234', 'phone_number': '555-1234',
'email_address': 'fred@mailinator.com', 'email_address': 'fred@mailinator.com',
'new_customer_name': 'Fred Flintstone', 'new_customer_full_name': 'Fred Flintstone',
'new_customer_first_name': 'Fred', 'new_customer_first_name': 'Fred',
'new_customer_last_name': 'Flintstone', 'new_customer_last_name': 'Flintstone',
'new_customer_phone': '555-1234', 'new_customer_phone': '555-1234',
@ -217,11 +337,6 @@ class TestOrderView(WebTestCase):
'customer_name': None, 'customer_name': None,
'phone_number': None, 'phone_number': None,
'email_address': 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): def test_start_over(self):
@ -268,6 +383,80 @@ class TestOrderView(WebTestCase):
self.session.flush() self.session.flush()
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0) 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): def test_set_pending_customer(self):
self.pyramid_config.add_route('orders.create', '/orders/new') self.pyramid_config.add_route('orders.create', '/orders/new')
model = self.app.model model = self.app.model
@ -301,19 +490,58 @@ class TestOrderView(WebTestCase):
'customer_name': 'Fred Flintstone', 'customer_name': 'Fred Flintstone',
'phone_number': '555-1234', 'phone_number': '555-1234',
'email_address': 'fred@mailinator.com', 'email_address': 'fred@mailinator.com',
'new_customer_name': 'Fred Flintstone', 'new_customer_full_name': 'Fred Flintstone',
'new_customer_first_name': 'Fred', 'new_customer_first_name': 'Fred',
'new_customer_last_name': 'Flintstone', 'new_customer_last_name': 'Flintstone',
'new_customer_phone': '555-1234', 'new_customer_phone': '555-1234',
'new_customer_email': 'fred@mailinator.com', 'new_customer_email': 'fred@mailinator.com',
}) })
# error def test_get_product_info(self):
with patch.object(handler, 'set_pending_customer', side_effect=RuntimeError): model = self.app.model
context = view.set_pending_customer(batch, data) handler = self.make_handler()
self.assertEqual(context, { view = self.make_view()
'error': 'RuntimeError',
}) 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): def test_add_item(self):
model = self.app.model model = self.app.model
@ -326,7 +554,7 @@ class TestOrderView(WebTestCase):
self.session.commit() self.session.commit()
data = { data = {
'pending_product': { 'product_info': {
'scancode': '07430500132', 'scancode': '07430500132',
'brand_name': 'Bragg', 'brand_name': 'Bragg',
'description': 'Vinegar', 'description': 'Vinegar',
@ -353,16 +581,10 @@ class TestOrderView(WebTestCase):
row = batch.rows[0] row = batch.rows[0]
self.assertIsInstance(row.pending_product, model.PendingProduct) self.assertIsInstance(row.pending_product, model.PendingProduct)
# pending w/ invalid price # external product not yet supported
with patch.dict(data['pending_product'], unit_price_reg='invalid'): with patch.object(handler, 'use_local_products', return_value=False):
result = view.add_item(batch, data) with patch.dict(data, product_info='42'):
self.assertEqual(result, {'error': "Invalid entry for field: unit_price_reg"}) self.assertRaises(NotImplementedError, view.add_item, batch, data)
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)
def test_update_item(self): def test_update_item(self):
model = self.app.model model = self.app.model
@ -375,7 +597,7 @@ class TestOrderView(WebTestCase):
self.session.commit() self.session.commit()
data = { data = {
'pending_product': { 'product_info': {
'scancode': '07430500132', 'scancode': '07430500132',
'brand_name': 'Bragg', 'brand_name': 'Bragg',
'description': 'Vinegar', 'description': 'Vinegar',
@ -403,7 +625,7 @@ class TestOrderView(WebTestCase):
# missing row uuid # missing row uuid
result = view.update_item(batch, data) 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 # row not found
with patch.dict(data, uuid=self.app.make_true_uuid()): with patch.dict(data, uuid=self.app.make_true_uuid()):
@ -420,16 +642,18 @@ class TestOrderView(WebTestCase):
result = view.update_item(batch, data) result = view.update_item(batch, data)
self.assertEqual(result, {'error': "Row is for wrong batch"}) self.assertEqual(result, {'error': "Row is for wrong batch"})
# set row for remaining tests
data['uuid'] = row.uuid
# true product not yet supported # true product not yet supported
with patch.dict(data, product_is_known=True): with patch.object(handler, 'use_local_products', return_value=False):
self.assertRaises(NotImplementedError, view.update_item, batch, data) 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 # update row, pending product
with patch.dict(data, order_qty=2): with patch.dict(data, uuid=row.uuid, order_qty=2):
with patch.dict(data['pending_product'], scancode='07430500116'): with patch.dict(data['product_info'], scancode='07430500116'):
self.assertEqual(row.product_scancode, '07430500132') self.assertEqual(row.product_scancode, '07430500132')
self.assertEqual(row.order_qty, 1) self.assertEqual(row.order_qty, 1)
result = view.update_item(batch, data) result = view.update_item(batch, data)
@ -438,7 +662,7 @@ class TestOrderView(WebTestCase):
self.assertEqual(row.order_qty, 2) self.assertEqual(row.order_qty, 2)
self.assertEqual(row.pending_product.scancode, '07430500116') self.assertEqual(row.pending_product.scancode, '07430500116')
self.assertEqual(result['row']['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): def test_delete_item(self):
model = self.app.model model = self.app.model
@ -451,7 +675,7 @@ class TestOrderView(WebTestCase):
self.session.commit() self.session.commit()
data = { data = {
'pending_product': { 'product_info': {
'scancode': '07430500132', 'scancode': '07430500132',
'brand_name': 'Bragg', 'brand_name': 'Bragg',
'description': 'Vinegar', 'description': 'Vinegar',
@ -506,7 +730,7 @@ class TestOrderView(WebTestCase):
self.assertEqual(len(batch.rows), 0) self.assertEqual(len(batch.rows), 0)
self.assertEqual(batch.row_count, 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}') self.pyramid_config.add_route('orders.view', '/orders/{uuid}')
model = self.app.model model = self.app.model
enum = self.app.enum enum = self.app.enum
@ -518,7 +742,7 @@ class TestOrderView(WebTestCase):
self.session.commit() self.session.commit()
data = { data = {
'pending_product': { 'product_info': {
'scancode': '07430500132', 'scancode': '07430500132',
'brand_name': 'Bragg', 'brand_name': 'Bragg',
'description': 'Vinegar', 'description': 'Vinegar',
@ -534,28 +758,33 @@ class TestOrderView(WebTestCase):
with patch.object(view, 'Session', return_value=self.session): with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.request, 'user', new=user): with patch.object(self.request, 'user', new=user):
batch = view.get_current_batch() batch = view.get_current_batch()
self.session.flush()
self.assertEqual(len(batch.rows), 0) self.assertEqual(len(batch.rows), 0)
# add row w/ pending product # add row w/ pending product
view.add_item(batch, data) view.add_item(batch, data)
self.session.flush() self.assertEqual(len(batch.rows), 1)
row = batch.rows[0] row = batch.rows[0]
self.assertIsInstance(row.pending_product, model.PendingProduct) self.assertIsInstance(row.pending_product, model.PendingProduct)
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99')) self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
# execute not allowed yet (no customer) # 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"}) 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 # submit/execute ok
batch.customer_id = 42 view.set_pending_customer(batch, {'full_name': 'John Doe',
result = view.submit_new_order(batch, {}) 'phone_number': '555-1234'})
result = view.submit_order(batch, {})
self.assertEqual(sorted(result), ['next_url']) self.assertEqual(sorted(result), ['next_url'])
self.assertIn('/orders/', result['next_url']) self.assertIn('/orders/', result['next_url'])
# error (already executed) # error (already executed)
result = view.submit_new_order(batch, {}) result = view.submit_order(batch, {})
self.assertEqual(result, { self.assertEqual(result, {
'error': f"ValueError: batch has already been executed: {batch}", 'error': f"ValueError: batch has already been executed: {batch}",
}) })
@ -585,9 +814,8 @@ class TestOrderView(WebTestCase):
'size': '32oz', 'size': '32oz',
'unit_price_reg': 5.99, 'unit_price_reg': 5.99,
'case_size': 12, '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() self.session.commit()
data = view.normalize_batch(batch) data = view.normalize_batch(batch)
@ -604,11 +832,15 @@ class TestOrderView(WebTestCase):
enum = self.app.enum enum = self.app.enum
handler = NewOrderBatchHandler(self.config) handler = NewOrderBatchHandler(self.config)
view = self.make_view() view = self.make_view()
view.batch_handler = handler
user = model.User(username='barney') user = model.User(username='barney')
self.session.add(user) self.session.add(user)
batch = handler.make_batch(self.session, created_by=user) batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch) self.session.add(batch)
self.session.flush()
# add 1st row w/ pending product
pending = { pending = {
'scancode': '07430500132', 'scancode': '07430500132',
'brand_name': 'Bragg', 'brand_name': 'Bragg',
@ -616,19 +848,22 @@ class TestOrderView(WebTestCase):
'size': '32oz', 'size': '32oz',
'unit_price_reg': 5.99, 'unit_price_reg': 5.99,
'case_size': 12, '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) row1 = handler.add_item(batch, pending, 2, enum.ORDER_UOM_CASE)
self.session.commit()
# normal # typical, pending product
data = view.normalize_row(row) data = view.normalize_row(row1)
self.assertIsInstance(data, dict) self.assertIsInstance(data, dict)
self.assertEqual(data['uuid'], row.uuid.hex) self.assertEqual(data['uuid'], row1.uuid.hex)
self.assertEqual(data['sequence'], 1) self.assertEqual(data['sequence'], 1)
self.assertIsNone(data['product_id'])
self.assertEqual(data['product_scancode'], '07430500132') self.assertEqual(data['product_scancode'], '07430500132')
self.assertEqual(data['case_size'], '12') self.assertEqual(data['product_full_description'], 'Bragg Vinegar 32oz')
self.assertEqual(data['order_qty'], '2') 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_uom'], 'CS')
self.assertEqual(data['order_qty_display'], '2 Cases (&times; 12 = 24 Units)') self.assertEqual(data['order_qty_display'], '2 Cases (&times; 12 = 24 Units)')
self.assertEqual(data['unit_price_reg'], 5.99) 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'], 143.76)
self.assertEqual(data['total_price_display'], '$143.76') self.assertEqual(data['total_price_display'], '$143.76')
self.assertIsNone(data['special_order']) 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'], { self.assertEqual(data['pending_product'], {
'uuid': row.pending_product_uuid.hex, 'uuid': row1.pending_product_uuid.hex,
'scancode': '07430500132', 'scancode': '07430500132',
'brand_name': 'Bragg', 'brand_name': 'Bragg',
'description': 'Vinegar', 'description': 'Vinegar',
@ -654,44 +889,117 @@ class TestOrderView(WebTestCase):
'department_id': None, 'department_id': None,
'department_name': None, 'department_name': None,
'unit_price_reg': 5.99, 'unit_price_reg': 5.99,
'vendor_name': None, 'vendor_name': 'Acme Warehouse',
'vendor_item_code': None, 'vendor_item_code': '1234',
'unit_cost': None, 'unit_cost': None,
'case_size': 12.0, 'case_size': 12.0,
'notes': None, 'notes': None,
'special_order': None, 'special_order': None,
}) })
# the next few tests will morph 1st row..
# unknown case size # unknown case size
row.pending_product.case_size = None row1.pending_product.case_size = None
handler.refresh_row(row) handler.refresh_row(row1)
self.session.flush() 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 (&times; ?? = ?? Units)') self.assertEqual(data['order_qty_display'], '2 Cases (&times; ?? = ?? Units)')
# order by unit # order by unit
row.order_uom = enum.ORDER_UOM_UNIT row1.order_uom = enum.ORDER_UOM_UNIT
handler.refresh_row(row) handler.refresh_row(row1)
self.session.flush() 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') self.assertEqual(data['order_qty_display'], '2 Units')
# item on sale # item on sale
row.pending_product.case_size = 12 row1.pending_product.case_size = 12
row.unit_price_sale = decimal.Decimal('5.19') row1.unit_price_sale = decimal.Decimal('5.19')
row.sale_ends = datetime.datetime(2025, 1, 5, 20, 32) row1.sale_ends = datetime.datetime(2099, 1, 5, 20, 32)
handler.refresh_row(row, now=datetime.datetime(2025, 1, 5, 19)) handler.refresh_row(row1)
self.session.flush() 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'], 5.19)
self.assertEqual(data['unit_price_sale_display'], '$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'], '2099-01-05 20:32:00')
self.assertEqual(data['sale_ends_display'], '2025-01-05') self.assertEqual(data['sale_ends_display'], '2099-01-05')
self.assertEqual(data['unit_price_quoted'], 5.19) self.assertEqual(data['unit_price_quoted'], 5.19)
self.assertEqual(data['unit_price_quoted_display'], '$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'], 62.28)
self.assertEqual(data['case_price_quoted_display'], '$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): def test_get_instance_title(self):
model = self.app.model model = self.app.model
view = self.make_view() view = self.make_view()
@ -715,13 +1023,31 @@ class TestOrderView(WebTestCase):
self.session.add(order) self.session.add(order)
self.session.commit() self.session.commit()
# viewing # viewing (no customer)
with patch.object(view, 'viewing', new=True): with patch.object(view, 'viewing', new=True):
form = view.make_form(model_instance=order) form = view.make_form(model_instance=order)
# nb. this is to avoid include/exclude ambiguity # nb. this is to avoid include/exclude ambiguity
form.remove('items') form.remove('items')
view.configure_form(form) view.configure_form(form)
schema = form.get_schema() 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) self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
def test_get_xref_buttons(self): def test_get_xref_buttons(self):
@ -831,6 +1157,46 @@ class TestOrderView(WebTestCase):
url = view.get_row_action_url_view(item, 0) url = view.get_row_action_url_view(item, 0)
self.assertIn(f'/order-items/{item.uuid}', url) 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): class TestOrderItemView(WebTestCase):
@ -864,6 +1230,18 @@ class TestOrderItemView(WebTestCase):
self.assertEqual(view.render_status_code(None, None, enum.ORDER_ITEM_STATUS_INITIATED), self.assertEqual(view.render_status_code(None, None, enum.ORDER_ITEM_STATUS_INITIATED),
'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): def test_configure_form(self):
model = self.app.model model = self.app.model
enum = self.app.enum enum = self.app.enum
@ -871,12 +1249,24 @@ class TestOrderItemView(WebTestCase):
item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_INITIATED) item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_INITIATED)
# viewing # viewing, w/ pending product
with patch.object(view, 'viewing', new=True): with patch.object(view, 'viewing', new=True):
form = view.make_form(model_instance=item) form = view.make_form(model_instance=item)
view.configure_form(form) view.configure_form(form)
schema = form.get_schema() schema = form.get_schema()
self.assertIsInstance(schema['order'].typ, OrderRef) 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): def test_get_xref_buttons(self):
self.pyramid_config.add_route('orders.view', '/orders/{uuid}') self.pyramid_config.add_route('orders.view', '/orders/{uuid}')

View file

@ -16,6 +16,104 @@ class TestIncludeme(WebTestCase):
mod.includeme(self.pyramid_config) 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): class TestPendingProductView(WebTestCase):
def make_view(self): def make_view(self):