Coverage for .tox/coverage/lib/python3.11/site-packages/sideshow/batch/neworder.py: 100%
386 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-22 12:21 -0500
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-22 12:21 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# Sideshow -- Case/Special Order Tracker
5# Copyright © 2024-2025 Lance Edgar
6#
7# This file is part of Sideshow.
8#
9# Sideshow is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# Sideshow is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17# General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24New Order Batch Handler
25"""
27# pylint: disable=too-many-lines
29import decimal
30from collections import OrderedDict
32import sqlalchemy as sa
34from wuttjamaican.batch import BatchHandler
36from sideshow.db.model import NewOrderBatch
39class NewOrderBatchHandler(BatchHandler): # pylint: disable=too-many-public-methods
40 """
41 The :term:`batch handler` for :term:`new order batches <new order
42 batch>`.
44 This is responsible for business logic around the creation of new
45 :term:`orders <order>`. A
46 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` tracks
47 all user input until they "submit" (execute) at which point an
48 :class:`~sideshow.db.model.orders.Order` is created.
50 After the batch has executed the :term:`order handler` takes over
51 responsibility for the rest of the order lifecycle.
52 """
54 model_class = NewOrderBatch
56 def get_default_store_id(self):
57 """
58 Returns the configured default value for
59 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`,
60 or ``None``.
61 """
62 return self.config.get("sideshow.orders.default_store_id")
64 def use_local_customers(self):
65 """
66 Returns boolean indicating whether :term:`local customer`
67 accounts should be used. This is true by default, but may be
68 false for :term:`external customer` lookups.
69 """
70 return self.config.get_bool("sideshow.orders.use_local_customers", default=True)
72 def use_local_products(self):
73 """
74 Returns boolean indicating whether :term:`local product`
75 records should be used. This is true by default, but may be
76 false for :term:`external product` lookups.
77 """
78 return self.config.get_bool("sideshow.orders.use_local_products", default=True)
80 def allow_unknown_products(self):
81 """
82 Returns boolean indicating whether :term:`pending products
83 <pending product>` are allowed when creating an order.
85 This is true by default, so user can enter new/unknown product
86 when creating an order. This can be disabled, to force user
87 to choose existing local/external product.
88 """
89 return self.config.get_bool(
90 "sideshow.orders.allow_unknown_products", default=True
91 )
93 def allow_item_discounts(self):
94 """
95 Returns boolean indicating whether per-item discounts are
96 allowed when creating an order.
97 """
98 return self.config.get_bool(
99 "sideshow.orders.allow_item_discounts", default=False
100 )
102 def allow_item_discounts_if_on_sale(self):
103 """
104 Returns boolean indicating whether per-item discounts are
105 allowed even when the item is already on sale.
106 """
107 return self.config.get_bool(
108 "sideshow.orders.allow_item_discounts_if_on_sale", default=False
109 )
111 def get_default_item_discount(self):
112 """
113 Returns the default item discount percentage, e.g. 15.
115 :rtype: :class:`~python:decimal.Decimal` or ``None``
116 """
117 discount = self.config.get("sideshow.orders.default_item_discount")
118 if discount:
119 return decimal.Decimal(discount)
120 return None
122 def autocomplete_customers_external(self, session, term, user=None):
123 """
124 Return autocomplete search results for :term:`external
125 customer` records.
127 There is no default logic here; subclass must implement.
129 :param session: Current app :term:`db session`.
131 :param term: Search term string from user input.
133 :param user:
134 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
135 is doing the search, if known.
137 :returns: List of search results; each should be a dict with
138 ``value`` and ``label`` keys.
139 """
140 raise NotImplementedError
142 def autocomplete_customers_local( # pylint: disable=unused-argument
143 self, session, term, user=None
144 ):
145 """
146 Return autocomplete search results for
147 :class:`~sideshow.db.model.customers.LocalCustomer` records.
149 :param session: Current app :term:`db session`.
151 :param term: Search term string from user input.
153 :param user:
154 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
155 is doing the search, if known.
157 :returns: List of search results; each should be a dict with
158 ``value`` and ``label`` keys.
159 """
160 model = self.app.model
162 # base query
163 query = session.query(model.LocalCustomer)
165 # filter query
166 criteria = [
167 model.LocalCustomer.full_name.ilike(f"%{word}%") for word in term.split()
168 ]
169 query = query.filter(sa.and_(*criteria))
171 # sort query
172 query = query.order_by(model.LocalCustomer.full_name)
174 # get data
175 # TODO: need max_results option
176 customers = query.all()
178 # get results
179 def result(customer):
180 return {"value": customer.uuid.hex, "label": customer.full_name}
182 return [result(c) for c in customers]
184 def init_batch(self, batch, session=None, progress=None, **kwargs):
185 """
186 Initialize a new batch.
188 This sets the
189 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`,
190 if the batch does not yet have one and a default is
191 configured.
192 """
193 if not batch.store_id:
194 batch.store_id = self.get_default_store_id()
196 def set_customer(self, batch, customer_info, user=None):
197 """
198 Set/update customer info for the batch.
200 This will first set one of the following:
202 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id`
203 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.local_customer`
204 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
206 Note that a new
207 :class:`~sideshow.db.model.customers.PendingCustomer` record
208 is created if necessary.
210 And then it will update customer-related attributes via one of:
212 * :meth:`refresh_batch_from_external_customer()`
213 * :meth:`refresh_batch_from_local_customer()`
214 * :meth:`refresh_batch_from_pending_customer()`
216 Note that ``customer_info`` may be ``None``, which will cause
217 customer attributes to be set to ``None`` also.
219 :param batch:
220 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
221 update.
223 :param customer_info: Customer ID string, or dict of
224 :class:`~sideshow.db.model.customers.PendingCustomer` data,
225 or ``None`` to clear the customer info.
227 :param user:
228 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
229 is performing the action. This is used to set
230 :attr:`~sideshow.db.model.customers.PendingCustomer.created_by`
231 on the pending customer, if applicable. If not specified,
232 the batch creator is assumed.
233 """
234 model = self.app.model
235 enum = self.app.enum
236 session = self.app.get_session(batch)
237 use_local = self.use_local_customers()
239 # set customer info
240 if isinstance(customer_info, str):
241 if use_local:
243 # local_customer
244 customer = session.get(model.LocalCustomer, customer_info)
245 if not customer:
246 raise ValueError("local customer not found")
247 batch.local_customer = customer
248 self.refresh_batch_from_local_customer(batch)
250 else: # external customer_id
251 batch.customer_id = customer_info
252 self.refresh_batch_from_external_customer(batch)
254 elif customer_info:
256 # pending_customer
257 batch.customer_id = None
258 batch.local_customer = None
259 customer = batch.pending_customer
260 if not customer:
261 customer = model.PendingCustomer(
262 status=enum.PendingCustomerStatus.PENDING,
263 created_by=user or batch.created_by,
264 )
265 session.add(customer)
266 batch.pending_customer = customer
267 fields = [
268 "full_name",
269 "first_name",
270 "last_name",
271 "phone_number",
272 "email_address",
273 ]
274 for key in fields:
275 setattr(customer, key, customer_info.get(key))
276 if "full_name" not in customer_info:
277 customer.full_name = self.app.make_full_name(
278 customer.first_name, customer.last_name
279 )
280 self.refresh_batch_from_pending_customer(batch)
282 else:
284 # null
285 batch.customer_id = None
286 batch.local_customer = None
287 batch.customer_name = None
288 batch.phone_number = None
289 batch.email_address = None
291 session.flush()
293 def refresh_batch_from_external_customer(self, batch):
294 """
295 Update customer-related attributes on the batch, from its
296 :term:`external customer` record.
298 This is called automatically from :meth:`set_customer()`.
300 There is no default logic here; subclass must implement.
301 """
302 raise NotImplementedError
304 def refresh_batch_from_local_customer(self, batch):
305 """
306 Update customer-related attributes on the batch, from its
307 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.local_customer`
308 record.
310 This is called automatically from :meth:`set_customer()`.
311 """
312 customer = batch.local_customer
313 batch.customer_name = customer.full_name
314 batch.phone_number = customer.phone_number
315 batch.email_address = customer.email_address
317 def refresh_batch_from_pending_customer(self, batch):
318 """
319 Update customer-related attributes on the batch, from its
320 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
321 record.
323 This is called automatically from :meth:`set_customer()`.
324 """
325 customer = batch.pending_customer
326 batch.customer_name = customer.full_name
327 batch.phone_number = customer.phone_number
328 batch.email_address = customer.email_address
330 def autocomplete_products_external(self, session, term, user=None):
331 """
332 Return autocomplete search results for :term:`external
333 product` records.
335 There is no default logic here; subclass must implement.
337 :param session: Current app :term:`db session`.
339 :param term: Search term string from user input.
341 :param user:
342 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
343 is doing the search, if known.
345 :returns: List of search results; each should be a dict with
346 ``value`` and ``label`` keys.
347 """
348 raise NotImplementedError
350 def autocomplete_products_local( # pylint: disable=unused-argument
351 self, session, term, user=None
352 ):
353 """
354 Return autocomplete search results for
355 :class:`~sideshow.db.model.products.LocalProduct` records.
357 :param session: Current app :term:`db session`.
359 :param term: Search term string from user input.
361 :param user:
362 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
363 is doing the search, if known.
365 :returns: List of search results; each should be a dict with
366 ``value`` and ``label`` keys.
367 """
368 model = self.app.model
370 # base query
371 query = session.query(model.LocalProduct)
373 # filter query
374 criteria = []
375 for word in term.split():
376 criteria.append(
377 sa.or_(
378 model.LocalProduct.brand_name.ilike(f"%{word}%"),
379 model.LocalProduct.description.ilike(f"%{word}%"),
380 )
381 )
382 query = query.filter(sa.and_(*criteria))
384 # sort query
385 query = query.order_by(
386 model.LocalProduct.brand_name, model.LocalProduct.description
387 )
389 # get data
390 # TODO: need max_results option
391 products = query.all()
393 # get results
394 def result(product):
395 return {"value": product.uuid.hex, "label": product.full_description}
397 return [result(c) for c in products]
399 def get_default_uom_choices(self):
400 """
401 Returns a list of ordering UOM choices which should be
402 presented to the user by default.
404 The built-in logic here will return everything from
405 :data:`~sideshow.enum.ORDER_UOM`.
407 :returns: List of dicts, each with ``key`` and ``value``
408 corresponding to the UOM code and label, respectively.
409 """
410 enum = self.app.enum
411 return [{"key": key, "value": val} for key, val in enum.ORDER_UOM.items()]
413 def get_product_info_external(self, session, product_id, user=None):
414 """
415 Returns basic info for an :term:`external product` as pertains
416 to ordering.
418 When user has located a product via search, and must then
419 choose order quantity and UOM based on case size, pricing
420 etc., this method is called to retrieve the product info.
422 There is no default logic here; subclass must implement. See
423 also :meth:`get_product_info_local()`.
425 :param session: Current app :term:`db session`.
427 :param product_id: Product ID string for which to retrieve
428 info.
430 :param user:
431 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
432 is performing the action, if known.
434 :returns: Dict of product info. Should raise error instead of
435 returning ``None`` if product not found.
437 This method should only be called after a product has been
438 identified via autocomplete/search lookup; therefore the
439 ``product_id`` should be valid, and the caller can expect this
440 method to *always* return a dict. If for some reason the
441 product cannot be found here, an error should be raised.
443 The dict should contain as much product info as is available
444 and needed; if some are missing it should not cause too much
445 trouble in the app. Here is a basic example::
447 def get_product_info_external(self, session, product_id, user=None):
448 ext_model = get_external_model()
449 ext_session = make_external_session()
451 ext_product = ext_session.get(ext_model.Product, product_id)
452 if not ext_product:
453 ext_session.close()
454 raise ValueError(f"external product not found: {product_id}")
456 info = {
457 'product_id': product_id,
458 'scancode': product.scancode,
459 'brand_name': product.brand_name,
460 'description': product.description,
461 'size': product.size,
462 'weighed': product.sold_by_weight,
463 'special_order': False,
464 'department_id': str(product.department_number),
465 'department_name': product.department_name,
466 'case_size': product.case_size,
467 'unit_price_reg': product.unit_price_reg,
468 'vendor_name': product.vendor_name,
469 'vendor_item_code': product.vendor_item_code,
470 }
472 ext_session.close()
473 return info
474 """
475 raise NotImplementedError
477 def get_product_info_local( # pylint: disable=unused-argument
478 self, session, uuid, user=None
479 ):
480 """
481 Returns basic info for a :term:`local product` as pertains to
482 ordering.
484 When user has located a product via search, and must then
485 choose order quantity and UOM based on case size, pricing
486 etc., this method is called to retrieve the product info.
488 See :meth:`get_product_info_external()` for more explanation.
490 This method will locate the
491 :class:`~sideshow.db.model.products.LocalProduct` record, then
492 (if found) it calls :meth:`normalize_local_product()` and
493 returns the result.
495 :param session: Current :term:`db session`.
497 :param uuid: UUID for the desired
498 :class:`~sideshow.db.model.products.LocalProduct`.
500 :param user:
501 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
502 is performing the action, if known.
504 :returns: Dict of product info.
505 """
506 model = self.app.model
507 product = session.get(model.LocalProduct, uuid)
508 if not product:
509 raise ValueError(f"Local Product not found: {uuid}")
511 return self.normalize_local_product(product)
513 def normalize_local_product(self, product):
514 """
515 Returns a normalized dict of info for the given :term:`local
516 product`.
518 This is called by:
520 * :meth:`get_product_info_local()`
521 * :meth:`get_past_products()`
523 :param product:
524 :class:`~sideshow.db.model.products.LocalProduct` instance.
526 :returns: Dict of product info.
528 The keys for this dict should essentially one-to-one for the
529 product fields, with one exception:
531 * ``product_id`` will be set to the product UUID as string
532 """
533 return {
534 "product_id": product.uuid.hex,
535 "scancode": product.scancode,
536 "brand_name": product.brand_name,
537 "description": product.description,
538 "size": product.size,
539 "full_description": product.full_description,
540 "weighed": product.weighed,
541 "special_order": product.special_order,
542 "department_id": product.department_id,
543 "department_name": product.department_name,
544 "case_size": product.case_size,
545 "unit_price_reg": product.unit_price_reg,
546 "vendor_name": product.vendor_name,
547 "vendor_item_code": product.vendor_item_code,
548 }
550 def get_past_orders(self, batch):
551 """
552 Retrieve a (possibly empty) list of past :term:`orders
553 <order>` for the batch customer.
555 This is called by :meth:`get_past_products()`.
557 :param batch:
558 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
559 instance.
561 :returns: List of :class:`~sideshow.db.model.orders.Order`
562 records.
563 """
564 model = self.app.model
565 session = self.app.get_session(batch)
566 orders = session.query(model.Order)
568 if batch.customer_id:
569 orders = orders.filter(model.Order.customer_id == batch.customer_id)
570 elif batch.local_customer:
571 orders = orders.filter(model.Order.local_customer == batch.local_customer)
572 else:
573 raise ValueError(f"batch has no customer: {batch}")
575 orders = orders.order_by(model.Order.created.desc())
576 return orders.all()
578 def get_past_products(self, batch, user=None):
579 """
580 Retrieve a (possibly empty) list of products which have been
581 previously ordered by the batch customer.
583 Note that this does not return :term:`order items <order
584 item>`, nor does it return true product records, but rather it
585 returns a list of dicts. Each will have product info but will
586 *not* have order quantity etc.
588 This method calls :meth:`get_past_orders()` and then iterates
589 through each order item therein. Any duplicated products
590 encountered will be skipped, so the final list contains unique
591 products.
593 Each dict in the result is obtained by calling one of:
595 * :meth:`normalize_local_product()`
596 * :meth:`get_product_info_external()`
598 :param batch:
599 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
600 instance.
602 :param user:
603 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
604 is performing the action, if known.
606 :returns: List of product info dicts.
607 """
608 session = self.app.get_session(batch)
609 use_local = self.use_local_products()
610 user = user or batch.created_by
611 products = OrderedDict()
613 # track down all order items for batch contact
614 for order in self.get_past_orders(batch):
615 for item in order.items:
617 # nb. we only need the first match for each product
618 if use_local:
619 product = item.local_product
620 if product and product.uuid not in products:
621 products[product.uuid] = self.normalize_local_product(product)
622 elif item.product_id and item.product_id not in products:
623 products[item.product_id] = self.get_product_info_external(
624 session, item.product_id, user=user
625 )
627 products = list(products.values())
628 for product in products:
630 price = product["unit_price_reg"]
632 if "unit_price_reg_display" not in product:
633 product["unit_price_reg_display"] = self.app.render_currency(price)
635 if "unit_price_quoted" not in product:
636 product["unit_price_quoted"] = price
638 if "unit_price_quoted_display" not in product:
639 product["unit_price_quoted_display"] = product["unit_price_reg_display"]
641 if (
642 "case_price_quoted" not in product
643 and product.get("unit_price_quoted") is not None
644 and product.get("case_size") is not None
645 ):
646 product["case_price_quoted"] = (
647 product["unit_price_quoted"] * product["case_size"]
648 )
650 if (
651 "case_price_quoted_display" not in product
652 and "case_price_quoted" in product
653 ):
654 product["case_price_quoted_display"] = self.app.render_currency(
655 product["case_price_quoted"]
656 )
658 return products
660 def add_item( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
661 self,
662 batch,
663 product_info,
664 order_qty,
665 order_uom,
666 discount_percent=None,
667 user=None,
668 ):
669 """
670 Add a new item/row to the batch, for given product and quantity.
672 See also :meth:`update_item()`.
674 :param batch:
675 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
676 update.
678 :param product_info: Product ID string, or dict of
679 :class:`~sideshow.db.model.products.PendingProduct` data.
681 :param order_qty:
682 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty`
683 value for the new row.
685 :param order_uom:
686 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
687 value for the new row.
689 :param discount_percent: Sets the
690 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent`
691 for the row, if allowed.
693 :param user:
694 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
695 is performing the action. This is used to set
696 :attr:`~sideshow.db.model.products.PendingProduct.created_by`
697 on the pending product, if applicable. If not specified,
698 the batch creator is assumed.
700 :returns:
701 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
702 instance.
703 """
704 model = self.app.model
705 enum = self.app.enum
706 session = self.app.get_session(batch)
707 use_local = self.use_local_products()
708 row = self.make_row()
710 # set product info
711 if isinstance(product_info, str):
712 if use_local:
714 # local_product
715 local = session.get(model.LocalProduct, product_info)
716 if not local:
717 raise ValueError("local product not found")
718 row.local_product = local
720 else: # external product_id
721 row.product_id = product_info
723 else:
724 # pending_product
725 if not self.allow_unknown_products():
726 raise TypeError("unknown/pending product not allowed for new orders")
727 row.product_id = None
728 row.local_product = None
729 pending = model.PendingProduct(
730 status=enum.PendingProductStatus.PENDING,
731 created_by=user or batch.created_by,
732 )
733 fields = [
734 "scancode",
735 "brand_name",
736 "description",
737 "size",
738 "weighed",
739 "department_id",
740 "department_name",
741 "special_order",
742 "vendor_name",
743 "vendor_item_code",
744 "case_size",
745 "unit_cost",
746 "unit_price_reg",
747 "notes",
748 ]
749 for key in fields:
750 setattr(pending, key, product_info.get(key))
752 # nb. this may convert float to decimal etc.
753 session.add(pending)
754 session.flush()
755 session.refresh(pending)
756 row.pending_product = pending
758 # set order info
759 row.order_qty = order_qty
760 row.order_uom = order_uom
762 # discount
763 if self.allow_item_discounts():
764 row.discount_percent = discount_percent or 0
766 # add row to batch
767 self.add_row(batch, row)
768 session.flush()
769 return row
771 def update_item( # pylint: disable=too-many-arguments,too-many-positional-arguments
772 self, row, product_info, order_qty, order_uom, discount_percent=None, user=None
773 ):
774 """
775 Update an item/row, per given product and quantity.
777 See also :meth:`add_item()`.
779 :param row:
780 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
781 to update.
783 :param product_info: Product ID string, or dict of
784 :class:`~sideshow.db.model.products.PendingProduct` data.
786 :param order_qty: New
787 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty`
788 value for the row.
790 :param order_uom: New
791 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
792 value for the row.
794 :param discount_percent: Sets the
795 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent`
796 for the row, if allowed.
798 :param user:
799 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
800 is performing the action. This is used to set
801 :attr:`~sideshow.db.model.products.PendingProduct.created_by`
802 on the pending product, if applicable. If not specified,
803 the batch creator is assumed.
804 """
805 model = self.app.model
806 enum = self.app.enum
807 session = self.app.get_session(row)
808 use_local = self.use_local_products()
810 # set product info
811 if isinstance(product_info, str):
812 if use_local:
814 # local_product
815 local = session.get(model.LocalProduct, product_info)
816 if not local:
817 raise ValueError("local product not found")
818 row.local_product = local
820 else: # external product_id
821 row.product_id = product_info
823 else:
824 # pending_product
825 if not self.allow_unknown_products():
826 raise TypeError("unknown/pending product not allowed for new orders")
827 row.product_id = None
828 row.local_product = None
829 pending = row.pending_product
830 if not pending:
831 pending = model.PendingProduct(
832 status=enum.PendingProductStatus.PENDING,
833 created_by=user or row.batch.created_by,
834 )
835 session.add(pending)
836 row.pending_product = pending
837 fields = [
838 "scancode",
839 "brand_name",
840 "description",
841 "size",
842 "weighed",
843 "department_id",
844 "department_name",
845 "special_order",
846 "vendor_name",
847 "vendor_item_code",
848 "case_size",
849 "unit_cost",
850 "unit_price_reg",
851 "notes",
852 ]
853 for key in fields:
854 setattr(pending, key, product_info.get(key))
856 # nb. this may convert float to decimal etc.
857 session.flush()
858 session.refresh(pending)
860 # set order info
861 row.order_qty = order_qty
862 row.order_uom = order_uom
864 # discount
865 if self.allow_item_discounts():
866 row.discount_percent = discount_percent or 0
868 # nb. this may convert float to decimal etc.
869 session.flush()
870 session.refresh(row)
872 # refresh per new info
873 self.refresh_row(row)
875 def refresh_row(self, row): # pylint: disable=too-many-branches
876 """
877 Refresh data for the row. This is called when adding a new
878 row to the batch, or anytime the row is updated (e.g. when
879 changing order quantity).
881 This calls one of the following to update product-related
882 attributes:
884 * :meth:`refresh_row_from_external_product()`
885 * :meth:`refresh_row_from_local_product()`
886 * :meth:`refresh_row_from_pending_product()`
888 It then re-calculates the row's
889 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price`
890 and updates the batch accordingly.
892 It also sets the row
893 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.status_code`.
894 """
895 enum = self.app.enum
896 row.status_code = None
897 row.status_text = None
899 # ensure product
900 if not row.product_id and not row.local_product and not row.pending_product:
901 row.status_code = row.STATUS_MISSING_PRODUCT
902 return
904 # ensure order qty/uom
905 if not row.order_qty or not row.order_uom:
906 row.status_code = row.STATUS_MISSING_ORDER_QTY
907 return
909 # update product attrs on row
910 if row.product_id:
911 self.refresh_row_from_external_product(row)
912 elif row.local_product:
913 self.refresh_row_from_local_product(row)
914 else:
915 self.refresh_row_from_pending_product(row)
917 # we need to know if total price changes
918 old_total = row.total_price
920 # update quoted price
921 row.unit_price_quoted = None
922 row.case_price_quoted = None
923 if row.unit_price_sale is not None and (
924 not row.sale_ends or row.sale_ends > self.app.make_utc()
925 ):
926 row.unit_price_quoted = row.unit_price_sale
927 else:
928 row.unit_price_quoted = row.unit_price_reg
929 if row.unit_price_quoted is not None and row.case_size:
930 row.case_price_quoted = row.unit_price_quoted * row.case_size
932 # update row total price
933 row.total_price = None
934 if row.order_uom == enum.ORDER_UOM_CASE:
935 # TODO: why are we not using case price again?
936 # if row.case_price_quoted:
937 # row.total_price = row.case_price_quoted * row.order_qty
938 if row.unit_price_quoted is not None and row.case_size is not None:
939 row.total_price = row.unit_price_quoted * row.case_size * row.order_qty
940 else: # ORDER_UOM_UNIT (or similar)
941 if row.unit_price_quoted is not None:
942 row.total_price = row.unit_price_quoted * row.order_qty
943 if row.total_price is not None:
944 if row.discount_percent and self.allow_item_discounts():
945 row.total_price = (
946 float(row.total_price) * (100 - float(row.discount_percent)) / 100.0
947 )
948 row.total_price = decimal.Decimal(f"{row.total_price:0.2f}")
950 # update batch if total price changed
951 if row.total_price != old_total:
952 batch = row.batch
953 batch.total_price = (
954 (batch.total_price or 0) + (row.total_price or 0) - (old_total or 0)
955 )
957 # all ok
958 row.status_code = row.STATUS_OK
960 def refresh_row_from_local_product(self, row):
961 """
962 Update product-related attributes on the row, from its
963 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.local_product`
964 record.
966 This is called automatically from :meth:`refresh_row()`.
967 """
968 product = row.local_product
969 row.product_scancode = product.scancode
970 row.product_brand = product.brand_name
971 row.product_description = product.description
972 row.product_size = product.size
973 row.product_weighed = product.weighed
974 row.department_id = product.department_id
975 row.department_name = product.department_name
976 row.special_order = product.special_order
977 row.vendor_name = product.vendor_name
978 row.vendor_item_code = product.vendor_item_code
979 row.case_size = product.case_size
980 row.unit_cost = product.unit_cost
981 row.unit_price_reg = product.unit_price_reg
983 def refresh_row_from_pending_product(self, row):
984 """
985 Update product-related attributes on the row, from its
986 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`
987 record.
989 This is called automatically from :meth:`refresh_row()`.
990 """
991 product = row.pending_product
992 row.product_scancode = product.scancode
993 row.product_brand = product.brand_name
994 row.product_description = product.description
995 row.product_size = product.size
996 row.product_weighed = product.weighed
997 row.department_id = product.department_id
998 row.department_name = product.department_name
999 row.special_order = product.special_order
1000 row.vendor_name = product.vendor_name
1001 row.vendor_item_code = product.vendor_item_code
1002 row.case_size = product.case_size
1003 row.unit_cost = product.unit_cost
1004 row.unit_price_reg = product.unit_price_reg
1006 def refresh_row_from_external_product(self, row):
1007 """
1008 Update product-related attributes on the row, from its
1009 :term:`external product` record indicated by
1010 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`.
1012 This is called automatically from :meth:`refresh_row()`.
1014 There is no default logic here; subclass must implement as
1015 needed.
1016 """
1017 raise NotImplementedError
1019 def remove_row(self, row):
1020 """
1021 Remove a row from its batch.
1023 This also will update the batch
1024 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.total_price`
1025 accordingly.
1026 """
1027 if row.total_price:
1028 batch = row.batch
1029 batch.total_price = (batch.total_price or 0) - row.total_price
1031 super().remove_row(row)
1033 def do_delete(self, batch, user, **kwargs): # pylint: disable=arguments-differ
1034 """
1035 Delete a batch completely.
1037 If the batch has :term:`pending customer` or :term:`pending
1038 product` records, they are also deleted - unless still
1039 referenced by some order(s).
1040 """
1041 session = self.app.get_session(batch)
1043 # maybe delete pending customer
1044 customer = batch.pending_customer
1045 if customer and not customer.orders:
1046 session.delete(customer)
1048 # maybe delete pending products
1049 for row in batch.rows:
1050 product = row.pending_product
1051 if product and not product.order_items:
1052 session.delete(product)
1054 # continue with normal deletion
1055 super().do_delete(batch, user, **kwargs)
1057 def why_not_execute(self, batch, **kwargs): # pylint: disable=arguments-differ
1058 """
1059 By default this checks to ensure the batch has a customer with
1060 phone number, and at least one item. It also may check to
1061 ensure the store is assigned, if applicable.
1062 """
1063 if not batch.store_id:
1064 order_handler = self.app.get_order_handler()
1065 if order_handler.expose_store_id():
1066 return "Must assign the store"
1068 if not batch.customer_name:
1069 return "Must assign the customer"
1071 if not batch.phone_number:
1072 return "Customer phone number is required"
1074 rows = self.get_effective_rows(batch)
1075 if not rows:
1076 return "Must add at least one valid item"
1078 return None
1080 def get_effective_rows(self, batch):
1081 """
1082 Only rows with
1083 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.STATUS_OK`
1084 are "effective" - i.e. rows with other status codes will not
1085 be created as proper order items.
1086 """
1087 return [row for row in batch.rows if row.status_code == row.STATUS_OK]
1089 def execute(self, batch, user=None, progress=None, **kwargs):
1090 """
1091 Execute the batch; this should make a proper :term:`order`.
1093 By default, this will call:
1095 * :meth:`make_local_customer()`
1096 * :meth:`process_pending_products()`
1097 * :meth:`make_new_order()`
1099 And will return the new
1100 :class:`~sideshow.db.model.orders.Order` instance.
1102 Note that callers should use
1103 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
1104 instead, which calls this method automatically.
1105 """
1106 rows = self.get_effective_rows(batch)
1107 self.make_local_customer(batch)
1108 self.process_pending_products(batch, rows)
1109 order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
1110 return order
1112 def make_local_customer(self, batch):
1113 """
1114 If applicable, this converts the batch :term:`pending
1115 customer` into a :term:`local customer`.
1117 This is called automatically from :meth:`execute()`.
1119 This logic will happen only if :meth:`use_local_customers()`
1120 returns true, and the batch has pending instead of local
1121 customer (so far).
1123 It will create a new
1124 :class:`~sideshow.db.model.customers.LocalCustomer` record and
1125 populate it from the batch
1126 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`.
1127 The latter is then deleted.
1128 """
1129 if not self.use_local_customers():
1130 return
1132 # nothing to do if no pending customer
1133 pending = batch.pending_customer
1134 if not pending:
1135 return
1137 session = self.app.get_session(batch)
1139 # maybe convert pending to local customer
1140 if not batch.local_customer:
1141 model = self.app.model
1142 inspector = sa.inspect(model.LocalCustomer)
1143 local = model.LocalCustomer()
1144 for prop in inspector.column_attrs:
1145 if hasattr(pending, prop.key):
1146 setattr(local, prop.key, getattr(pending, prop.key))
1147 session.add(local)
1148 batch.local_customer = local
1150 # remove pending customer
1151 batch.pending_customer = None
1152 session.delete(pending)
1153 session.flush()
1155 def process_pending_products(self, batch, rows):
1156 """
1157 Process any :term:`pending products <pending product>` which
1158 are present in the batch.
1160 This is called automatically from :meth:`execute()`.
1162 If :term:`local products <local product>` are used, this will
1163 convert the pending products to local products.
1165 If :term:`external products <external product>` are used, this
1166 will update the pending product records' status to indicate
1167 they are ready to be resolved.
1168 """
1169 enum = self.app.enum
1170 model = self.app.model
1171 session = self.app.get_session(batch)
1173 if self.use_local_products():
1174 inspector = sa.inspect(model.LocalProduct)
1175 for row in rows:
1177 if row.local_product or not row.pending_product:
1178 continue
1180 pending = row.pending_product
1181 local = model.LocalProduct()
1183 for prop in inspector.column_attrs:
1184 if hasattr(pending, prop.key):
1185 setattr(local, prop.key, getattr(pending, prop.key))
1186 session.add(local)
1188 row.local_product = local
1189 row.pending_product = None
1190 session.delete(pending)
1192 else: # external products; pending should be marked 'ready'
1193 for row in rows:
1194 pending = row.pending_product
1195 if pending:
1196 pending.status = enum.PendingProductStatus.READY
1198 session.flush()
1200 def make_new_order(self, batch, rows, user=None, progress=None):
1201 """
1202 Create a new :term:`order` from the batch data.
1204 This is called automatically from :meth:`execute()`.
1206 :param batch:
1207 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
1208 instance.
1210 :param rows: List of effective rows for the batch, i.e. which
1211 rows should be converted to :term:`order items <order
1212 item>`.
1214 :returns: :class:`~sideshow.db.model.orders.Order` instance.
1215 """
1216 model = self.app.model
1217 session = self.app.get_session(batch)
1219 batch_fields = [
1220 "store_id",
1221 "customer_id",
1222 "local_customer",
1223 "pending_customer",
1224 "customer_name",
1225 "phone_number",
1226 "email_address",
1227 "total_price",
1228 ]
1230 row_fields = [
1231 "product_id",
1232 "local_product",
1233 "pending_product",
1234 "product_scancode",
1235 "product_brand",
1236 "product_description",
1237 "product_size",
1238 "product_weighed",
1239 "department_id",
1240 "department_name",
1241 "vendor_name",
1242 "vendor_item_code",
1243 "case_size",
1244 "order_qty",
1245 "order_uom",
1246 "unit_cost",
1247 "unit_price_quoted",
1248 "case_price_quoted",
1249 "unit_price_reg",
1250 "unit_price_sale",
1251 "sale_ends",
1252 "discount_percent",
1253 "total_price",
1254 "special_order",
1255 ]
1257 # make order
1258 kw = {field: getattr(batch, field) for field in batch_fields}
1259 kw["order_id"] = batch.id
1260 kw["created_by"] = user
1261 order = model.Order(**kw)
1262 session.add(order)
1263 session.flush()
1265 def convert(row, i): # pylint: disable=unused-argument
1267 # make order item
1268 kw = {field: getattr(row, field) for field in row_fields}
1269 item = model.OrderItem(**kw)
1270 order.items.append(item)
1272 # set item status
1273 self.set_initial_item_status(item, user)
1275 self.app.progress_loop(
1276 convert, rows, progress, message="Converting batch rows to order items"
1277 )
1278 session.flush()
1279 return order
1281 def set_initial_item_status(self, item, user):
1282 """
1283 Set the initial status and attach event(s) for the given item.
1285 This is called from :meth:`make_new_order()` for each item
1286 after it is added to the order.
1288 Default logic will set status to
1289 :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY` and attach 2
1290 events:
1292 * :data:`~sideshow.enum.ORDER_ITEM_EVENT_INITIATED`
1293 * :data:`~sideshow.enum.ORDER_ITEM_EVENT_READY`
1295 :param item: :class:`~sideshow.db.model.orders.OrderItem`
1296 being added to the new order.
1298 :param user:
1299 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
1300 is performing the action.
1301 """
1302 enum = self.app.enum
1303 item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user)
1304 item.add_event(enum.ORDER_ITEM_EVENT_READY, user)
1305 item.status_code = enum.ORDER_ITEM_STATUS_READY