Coverage for .tox / coverage / lib / python3.11 / site-packages / sideshow / batch / neworder.py: 100%
386 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-15 17:10 -0600
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-15 17:10 -0600
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"""
26# pylint: disable=too-many-lines
28import decimal
29from collections import OrderedDict
31import sqlalchemy as sa
33from wuttjamaican.batch import BatchHandler
35from sideshow.db.model import NewOrderBatch
38class NewOrderBatchHandler(BatchHandler): # pylint: disable=too-many-public-methods
39 """
40 The :term:`batch handler` for :term:`new order batches <new order
41 batch>`.
43 This is responsible for business logic around the creation of new
44 :term:`orders <order>`. A
45 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` tracks
46 all user input until they "submit" (execute) at which point an
47 :class:`~sideshow.db.model.orders.Order` is created.
49 After the batch has executed the :term:`order handler` takes over
50 responsibility for the rest of the order lifecycle.
51 """
53 model_class = NewOrderBatch
55 def get_default_store_id(self):
56 """
57 Returns the configured default value for
58 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`,
59 or ``None``.
60 """
61 return self.config.get("sideshow.orders.default_store_id")
63 def use_local_customers(self):
64 """
65 Returns boolean indicating whether :term:`local customer`
66 accounts should be used. This is true by default, but may be
67 false for :term:`external customer` lookups.
68 """
69 return self.config.get_bool("sideshow.orders.use_local_customers", default=True)
71 def use_local_products(self):
72 """
73 Returns boolean indicating whether :term:`local product`
74 records should be used. This is true by default, but may be
75 false for :term:`external product` lookups.
76 """
77 return self.config.get_bool("sideshow.orders.use_local_products", default=True)
79 def allow_unknown_products(self):
80 """
81 Returns boolean indicating whether :term:`pending products
82 <pending product>` are allowed when creating an order.
84 This is true by default, so user can enter new/unknown product
85 when creating an order. This can be disabled, to force user
86 to choose existing local/external product.
87 """
88 return self.config.get_bool(
89 "sideshow.orders.allow_unknown_products", default=True
90 )
92 def allow_item_discounts(self):
93 """
94 Returns boolean indicating whether per-item discounts are
95 allowed when creating an order.
96 """
97 return self.config.get_bool(
98 "sideshow.orders.allow_item_discounts", default=False
99 )
101 def allow_item_discounts_if_on_sale(self):
102 """
103 Returns boolean indicating whether per-item discounts are
104 allowed even when the item is already on sale.
105 """
106 return self.config.get_bool(
107 "sideshow.orders.allow_item_discounts_if_on_sale", default=False
108 )
110 def get_default_item_discount(self):
111 """
112 Returns the default item discount percentage, e.g. 15.
114 :rtype: :class:`~python:decimal.Decimal` or ``None``
115 """
116 discount = self.config.get("sideshow.orders.default_item_discount")
117 if discount:
118 return decimal.Decimal(discount)
119 return None
121 def autocomplete_customers_external(self, session, term, user=None):
122 """
123 Return autocomplete search results for :term:`external
124 customer` records.
126 There is no default logic here; subclass must implement.
128 :param session: Current app :term:`db session`.
130 :param term: Search term string from user input.
132 :param user:
133 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
134 is doing the search, if known.
136 :returns: List of search results; each should be a dict with
137 ``value`` and ``label`` keys.
138 """
139 raise NotImplementedError
141 def autocomplete_customers_local( # pylint: disable=unused-argument
142 self, session, term, user=None
143 ):
144 """
145 Return autocomplete search results for
146 :class:`~sideshow.db.model.customers.LocalCustomer` records.
148 :param session: Current app :term:`db session`.
150 :param term: Search term string from user input.
152 :param user:
153 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
154 is doing the search, if known.
156 :returns: List of search results; each should be a dict with
157 ``value`` and ``label`` keys.
158 """
159 model = self.app.model
161 # base query
162 query = session.query(model.LocalCustomer)
164 # filter query
165 criteria = [
166 model.LocalCustomer.full_name.ilike(f"%{word}%") for word in term.split()
167 ]
168 query = query.filter(sa.and_(*criteria))
170 # sort query
171 query = query.order_by(model.LocalCustomer.full_name)
173 # get data
174 # TODO: need max_results option
175 customers = query.all()
177 # get results
178 def result(customer):
179 return {"value": customer.uuid.hex, "label": customer.full_name}
181 return [result(c) for c in customers]
183 def init_batch(self, batch, session=None, progress=None, **kwargs):
184 """
185 Initialize a new batch.
187 This sets the
188 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`,
189 if the batch does not yet have one and a default is
190 configured.
191 """
192 if not batch.store_id:
193 batch.store_id = self.get_default_store_id()
195 def set_customer(self, batch, customer_info, user=None):
196 """
197 Set/update customer info for the batch.
199 This will first set one of the following:
201 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id`
202 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.local_customer`
203 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
205 Note that a new
206 :class:`~sideshow.db.model.customers.PendingCustomer` record
207 is created if necessary.
209 And then it will update customer-related attributes via one of:
211 * :meth:`refresh_batch_from_external_customer()`
212 * :meth:`refresh_batch_from_local_customer()`
213 * :meth:`refresh_batch_from_pending_customer()`
215 Note that ``customer_info`` may be ``None``, which will cause
216 customer attributes to be set to ``None`` also.
218 :param batch:
219 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
220 update.
222 :param customer_info: Customer ID string, or dict of
223 :class:`~sideshow.db.model.customers.PendingCustomer` data,
224 or ``None`` to clear the customer info.
226 :param user:
227 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
228 is performing the action. This is used to set
229 :attr:`~sideshow.db.model.customers.PendingCustomer.created_by`
230 on the pending customer, if applicable. If not specified,
231 the batch creator is assumed.
232 """
233 model = self.app.model
234 enum = self.app.enum
235 session = self.app.get_session(batch)
236 use_local = self.use_local_customers()
238 # set customer info
239 if isinstance(customer_info, str):
240 if use_local:
242 # local_customer
243 customer = session.get(model.LocalCustomer, customer_info)
244 if not customer:
245 raise ValueError("local customer not found")
246 batch.local_customer = customer
247 self.refresh_batch_from_local_customer(batch)
249 else: # external customer_id
250 batch.customer_id = customer_info
251 self.refresh_batch_from_external_customer(batch)
253 elif customer_info:
255 # pending_customer
256 batch.customer_id = None
257 batch.local_customer = None
258 customer = batch.pending_customer
259 if not customer:
260 customer = model.PendingCustomer(
261 status=enum.PendingCustomerStatus.PENDING,
262 created_by=user or batch.created_by,
263 )
264 session.add(customer)
265 batch.pending_customer = customer
266 fields = [
267 "full_name",
268 "first_name",
269 "last_name",
270 "phone_number",
271 "email_address",
272 ]
273 for key in fields:
274 setattr(customer, key, customer_info.get(key))
275 if "full_name" not in customer_info:
276 customer.full_name = self.app.make_full_name(
277 customer.first_name, customer.last_name
278 )
279 self.refresh_batch_from_pending_customer(batch)
281 else:
283 # null
284 batch.customer_id = None
285 batch.local_customer = None
286 batch.customer_name = None
287 batch.phone_number = None
288 batch.email_address = None
290 session.flush()
292 def refresh_batch_from_external_customer(self, batch):
293 """
294 Update customer-related attributes on the batch, from its
295 :term:`external customer` record.
297 This is called automatically from :meth:`set_customer()`.
299 There is no default logic here; subclass must implement.
300 """
301 raise NotImplementedError
303 def refresh_batch_from_local_customer(self, batch):
304 """
305 Update customer-related attributes on the batch, from its
306 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.local_customer`
307 record.
309 This is called automatically from :meth:`set_customer()`.
310 """
311 customer = batch.local_customer
312 batch.customer_name = customer.full_name
313 batch.phone_number = customer.phone_number
314 batch.email_address = customer.email_address
316 def refresh_batch_from_pending_customer(self, batch):
317 """
318 Update customer-related attributes on the batch, from its
319 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
320 record.
322 This is called automatically from :meth:`set_customer()`.
323 """
324 customer = batch.pending_customer
325 batch.customer_name = customer.full_name
326 batch.phone_number = customer.phone_number
327 batch.email_address = customer.email_address
329 def autocomplete_products_external(self, session, term, user=None):
330 """
331 Return autocomplete search results for :term:`external
332 product` records.
334 There is no default logic here; subclass must implement.
336 :param session: Current app :term:`db session`.
338 :param term: Search term string from user input.
340 :param user:
341 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
342 is doing the search, if known.
344 :returns: List of search results; each should be a dict with
345 ``value`` and ``label`` keys.
346 """
347 raise NotImplementedError
349 def autocomplete_products_local( # pylint: disable=unused-argument
350 self, session, term, user=None
351 ):
352 """
353 Return autocomplete search results for
354 :class:`~sideshow.db.model.products.LocalProduct` records.
356 :param session: Current app :term:`db session`.
358 :param term: Search term string from user input.
360 :param user:
361 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
362 is doing the search, if known.
364 :returns: List of search results; each should be a dict with
365 ``value`` and ``label`` keys.
366 """
367 model = self.app.model
369 # base query
370 query = session.query(model.LocalProduct)
372 # filter query
373 criteria = []
374 for word in term.split():
375 criteria.append(
376 sa.or_(
377 model.LocalProduct.brand_name.ilike(f"%{word}%"),
378 model.LocalProduct.description.ilike(f"%{word}%"),
379 )
380 )
381 query = query.filter(sa.and_(*criteria))
383 # sort query
384 query = query.order_by(
385 model.LocalProduct.brand_name, model.LocalProduct.description
386 )
388 # get data
389 # TODO: need max_results option
390 products = query.all()
392 # get results
393 def result(product):
394 return {"value": product.uuid.hex, "label": product.full_description}
396 return [result(c) for c in products]
398 def get_default_uom_choices(self):
399 """
400 Returns a list of ordering UOM choices which should be
401 presented to the user by default.
403 The built-in logic here will return everything from
404 :data:`~sideshow.enum.ORDER_UOM`.
406 :returns: List of dicts, each with ``key`` and ``value``
407 corresponding to the UOM code and label, respectively.
408 """
409 enum = self.app.enum
410 return [{"key": key, "value": val} for key, val in enum.ORDER_UOM.items()]
412 def get_product_info_external(self, session, product_id, user=None):
413 """
414 Returns basic info for an :term:`external product` as pertains
415 to ordering.
417 When user has located a product via search, and must then
418 choose order quantity and UOM based on case size, pricing
419 etc., this method is called to retrieve the product info.
421 There is no default logic here; subclass must implement. See
422 also :meth:`get_product_info_local()`.
424 :param session: Current app :term:`db session`.
426 :param product_id: Product ID string for which to retrieve
427 info.
429 :param user:
430 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
431 is performing the action, if known.
433 :returns: Dict of product info. Should raise error instead of
434 returning ``None`` if product not found.
436 This method should only be called after a product has been
437 identified via autocomplete/search lookup; therefore the
438 ``product_id`` should be valid, and the caller can expect this
439 method to *always* return a dict. If for some reason the
440 product cannot be found here, an error should be raised.
442 The dict should contain as much product info as is available
443 and needed; if some are missing it should not cause too much
444 trouble in the app. Here is a basic example::
446 def get_product_info_external(self, session, product_id, user=None):
447 ext_model = get_external_model()
448 ext_session = make_external_session()
450 ext_product = ext_session.get(ext_model.Product, product_id)
451 if not ext_product:
452 ext_session.close()
453 raise ValueError(f"external product not found: {product_id}")
455 info = {
456 'product_id': product_id,
457 'scancode': product.scancode,
458 'brand_name': product.brand_name,
459 'description': product.description,
460 'size': product.size,
461 'weighed': product.sold_by_weight,
462 'special_order': False,
463 'department_id': str(product.department_number),
464 'department_name': product.department_name,
465 'case_size': product.case_size,
466 'unit_price_reg': product.unit_price_reg,
467 'vendor_name': product.vendor_name,
468 'vendor_item_code': product.vendor_item_code,
469 }
471 ext_session.close()
472 return info
473 """
474 raise NotImplementedError
476 def get_product_info_local( # pylint: disable=unused-argument
477 self, session, uuid, user=None
478 ):
479 """
480 Returns basic info for a :term:`local product` as pertains to
481 ordering.
483 When user has located a product via search, and must then
484 choose order quantity and UOM based on case size, pricing
485 etc., this method is called to retrieve the product info.
487 See :meth:`get_product_info_external()` for more explanation.
489 This method will locate the
490 :class:`~sideshow.db.model.products.LocalProduct` record, then
491 (if found) it calls :meth:`normalize_local_product()` and
492 returns the result.
494 :param session: Current :term:`db session`.
496 :param uuid: UUID for the desired
497 :class:`~sideshow.db.model.products.LocalProduct`.
499 :param user:
500 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
501 is performing the action, if known.
503 :returns: Dict of product info.
504 """
505 model = self.app.model
506 product = session.get(model.LocalProduct, uuid)
507 if not product:
508 raise ValueError(f"Local Product not found: {uuid}")
510 return self.normalize_local_product(product)
512 def normalize_local_product(self, product):
513 """
514 Returns a normalized dict of info for the given :term:`local
515 product`.
517 This is called by:
519 * :meth:`get_product_info_local()`
520 * :meth:`get_past_products()`
522 :param product:
523 :class:`~sideshow.db.model.products.LocalProduct` instance.
525 :returns: Dict of product info.
527 The keys for this dict should essentially one-to-one for the
528 product fields, with one exception:
530 * ``product_id`` will be set to the product UUID as string
531 """
532 return {
533 "product_id": product.uuid.hex,
534 "scancode": product.scancode,
535 "brand_name": product.brand_name,
536 "description": product.description,
537 "size": product.size,
538 "full_description": product.full_description,
539 "weighed": product.weighed,
540 "special_order": product.special_order,
541 "department_id": product.department_id,
542 "department_name": product.department_name,
543 "case_size": product.case_size,
544 "unit_price_reg": product.unit_price_reg,
545 "vendor_name": product.vendor_name,
546 "vendor_item_code": product.vendor_item_code,
547 }
549 def get_past_orders(self, batch):
550 """
551 Retrieve a (possibly empty) list of past :term:`orders
552 <order>` for the batch customer.
554 This is called by :meth:`get_past_products()`.
556 :param batch:
557 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
558 instance.
560 :returns: List of :class:`~sideshow.db.model.orders.Order`
561 records.
562 """
563 model = self.app.model
564 session = self.app.get_session(batch)
565 orders = session.query(model.Order)
567 if batch.customer_id:
568 orders = orders.filter(model.Order.customer_id == batch.customer_id)
569 elif batch.local_customer:
570 orders = orders.filter(model.Order.local_customer == batch.local_customer)
571 else:
572 raise ValueError(f"batch has no customer: {batch}")
574 orders = orders.order_by(model.Order.created.desc())
575 return orders.all()
577 def get_past_products(self, batch, user=None):
578 """
579 Retrieve a (possibly empty) list of products which have been
580 previously ordered by the batch customer.
582 Note that this does not return :term:`order items <order
583 item>`, nor does it return true product records, but rather it
584 returns a list of dicts. Each will have product info but will
585 *not* have order quantity etc.
587 This method calls :meth:`get_past_orders()` and then iterates
588 through each order item therein. Any duplicated products
589 encountered will be skipped, so the final list contains unique
590 products.
592 Each dict in the result is obtained by calling one of:
594 * :meth:`normalize_local_product()`
595 * :meth:`get_product_info_external()`
597 :param batch:
598 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
599 instance.
601 :param user:
602 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
603 is performing the action, if known.
605 :returns: List of product info dicts.
606 """
607 session = self.app.get_session(batch)
608 use_local = self.use_local_products()
609 user = user or batch.created_by
610 products = OrderedDict()
612 # track down all order items for batch contact
613 for order in self.get_past_orders(batch):
614 for item in order.items:
616 # nb. we only need the first match for each product
617 if use_local:
618 product = item.local_product
619 if product and product.uuid not in products:
620 products[product.uuid] = self.normalize_local_product(product)
621 elif item.product_id and item.product_id not in products:
622 products[item.product_id] = self.get_product_info_external(
623 session, item.product_id, user=user
624 )
626 products = list(products.values())
627 for product in products:
629 price = product["unit_price_reg"]
631 if "unit_price_reg_display" not in product:
632 product["unit_price_reg_display"] = self.app.render_currency(price)
634 if "unit_price_quoted" not in product:
635 product["unit_price_quoted"] = price
637 if "unit_price_quoted_display" not in product:
638 product["unit_price_quoted_display"] = product["unit_price_reg_display"]
640 if (
641 "case_price_quoted" not in product
642 and product.get("unit_price_quoted") is not None
643 and product.get("case_size") is not None
644 ):
645 product["case_price_quoted"] = (
646 product["unit_price_quoted"] * product["case_size"]
647 )
649 if (
650 "case_price_quoted_display" not in product
651 and "case_price_quoted" in product
652 ):
653 product["case_price_quoted_display"] = self.app.render_currency(
654 product["case_price_quoted"]
655 )
657 return products
659 def add_item( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
660 self,
661 batch,
662 product_info,
663 order_qty,
664 order_uom,
665 discount_percent=None,
666 user=None,
667 ):
668 """
669 Add a new item/row to the batch, for given product and quantity.
671 See also :meth:`update_item()`.
673 :param batch:
674 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
675 update.
677 :param product_info: Product ID string, or dict of
678 :class:`~sideshow.db.model.products.PendingProduct` data.
680 :param order_qty:
681 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty`
682 value for the new row.
684 :param order_uom:
685 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
686 value for the new row.
688 :param discount_percent: Sets the
689 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent`
690 for the row, if allowed.
692 :param user:
693 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
694 is performing the action. This is used to set
695 :attr:`~sideshow.db.model.products.PendingProduct.created_by`
696 on the pending product, if applicable. If not specified,
697 the batch creator is assumed.
699 :returns:
700 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
701 instance.
702 """
703 model = self.app.model
704 enum = self.app.enum
705 session = self.app.get_session(batch)
706 use_local = self.use_local_products()
707 row = self.make_row()
709 # set product info
710 if isinstance(product_info, str):
711 if use_local:
713 # local_product
714 local = session.get(model.LocalProduct, product_info)
715 if not local:
716 raise ValueError("local product not found")
717 row.local_product = local
719 else: # external product_id
720 row.product_id = product_info
722 else:
723 # pending_product
724 if not self.allow_unknown_products():
725 raise TypeError("unknown/pending product not allowed for new orders")
726 row.product_id = None
727 row.local_product = None
728 pending = model.PendingProduct(
729 status=enum.PendingProductStatus.PENDING,
730 created_by=user or batch.created_by,
731 )
732 fields = [
733 "scancode",
734 "brand_name",
735 "description",
736 "size",
737 "weighed",
738 "department_id",
739 "department_name",
740 "special_order",
741 "vendor_name",
742 "vendor_item_code",
743 "case_size",
744 "unit_cost",
745 "unit_price_reg",
746 "notes",
747 ]
748 for key in fields:
749 setattr(pending, key, product_info.get(key))
751 # nb. this may convert float to decimal etc.
752 session.add(pending)
753 session.flush()
754 session.refresh(pending)
755 row.pending_product = pending
757 # set order info
758 row.order_qty = order_qty
759 row.order_uom = order_uom
761 # discount
762 if self.allow_item_discounts():
763 row.discount_percent = discount_percent or 0
765 # add row to batch
766 self.add_row(batch, row)
767 session.flush()
768 return row
770 def update_item( # pylint: disable=too-many-arguments,too-many-positional-arguments
771 self, row, product_info, order_qty, order_uom, discount_percent=None, user=None
772 ):
773 """
774 Update an item/row, per given product and quantity.
776 See also :meth:`add_item()`.
778 :param row:
779 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
780 to update.
782 :param product_info: Product ID string, or dict of
783 :class:`~sideshow.db.model.products.PendingProduct` data.
785 :param order_qty: New
786 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty`
787 value for the row.
789 :param order_uom: New
790 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
791 value for the row.
793 :param discount_percent: Sets the
794 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent`
795 for the row, if allowed.
797 :param user:
798 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
799 is performing the action. This is used to set
800 :attr:`~sideshow.db.model.products.PendingProduct.created_by`
801 on the pending product, if applicable. If not specified,
802 the batch creator is assumed.
803 """
804 model = self.app.model
805 enum = self.app.enum
806 session = self.app.get_session(row)
807 use_local = self.use_local_products()
809 # set product info
810 if isinstance(product_info, str):
811 if use_local:
813 # local_product
814 local = session.get(model.LocalProduct, product_info)
815 if not local:
816 raise ValueError("local product not found")
817 row.local_product = local
819 else: # external product_id
820 row.product_id = product_info
822 else:
823 # pending_product
824 if not self.allow_unknown_products():
825 raise TypeError("unknown/pending product not allowed for new orders")
826 row.product_id = None
827 row.local_product = None
828 pending = row.pending_product
829 if not pending:
830 pending = model.PendingProduct(
831 status=enum.PendingProductStatus.PENDING,
832 created_by=user or row.batch.created_by,
833 )
834 session.add(pending)
835 row.pending_product = pending
836 fields = [
837 "scancode",
838 "brand_name",
839 "description",
840 "size",
841 "weighed",
842 "department_id",
843 "department_name",
844 "special_order",
845 "vendor_name",
846 "vendor_item_code",
847 "case_size",
848 "unit_cost",
849 "unit_price_reg",
850 "notes",
851 ]
852 for key in fields:
853 setattr(pending, key, product_info.get(key))
855 # nb. this may convert float to decimal etc.
856 session.flush()
857 session.refresh(pending)
859 # set order info
860 row.order_qty = order_qty
861 row.order_uom = order_uom
863 # discount
864 if self.allow_item_discounts():
865 row.discount_percent = discount_percent or 0
867 # nb. this may convert float to decimal etc.
868 session.flush()
869 session.refresh(row)
871 # refresh per new info
872 self.refresh_row(row)
874 def refresh_row(self, row): # pylint: disable=too-many-branches
875 """
876 Refresh data for the row. This is called when adding a new
877 row to the batch, or anytime the row is updated (e.g. when
878 changing order quantity).
880 This calls one of the following to update product-related
881 attributes:
883 * :meth:`refresh_row_from_external_product()`
884 * :meth:`refresh_row_from_local_product()`
885 * :meth:`refresh_row_from_pending_product()`
887 It then re-calculates the row's
888 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price`
889 and updates the batch accordingly.
891 It also sets the row
892 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.status_code`.
893 """
894 enum = self.app.enum
895 row.status_code = None
896 row.status_text = None
898 # ensure product
899 if not row.product_id and not row.local_product and not row.pending_product:
900 row.status_code = row.STATUS_MISSING_PRODUCT
901 return
903 # ensure order qty/uom
904 if not row.order_qty or not row.order_uom:
905 row.status_code = row.STATUS_MISSING_ORDER_QTY
906 return
908 # update product attrs on row
909 if row.product_id:
910 self.refresh_row_from_external_product(row)
911 elif row.local_product:
912 self.refresh_row_from_local_product(row)
913 else:
914 self.refresh_row_from_pending_product(row)
916 # we need to know if total price changes
917 old_total = row.total_price
919 # update quoted price
920 row.unit_price_quoted = None
921 row.case_price_quoted = None
922 if row.unit_price_sale is not None and (
923 not row.sale_ends or row.sale_ends > self.app.make_utc()
924 ):
925 row.unit_price_quoted = row.unit_price_sale
926 else:
927 row.unit_price_quoted = row.unit_price_reg
928 if row.unit_price_quoted is not None and row.case_size:
929 row.case_price_quoted = row.unit_price_quoted * row.case_size
931 # update row total price
932 row.total_price = None
933 if row.order_uom == enum.ORDER_UOM_CASE:
934 # TODO: why are we not using case price again?
935 # if row.case_price_quoted:
936 # row.total_price = row.case_price_quoted * row.order_qty
937 if row.unit_price_quoted is not None and row.case_size is not None:
938 row.total_price = row.unit_price_quoted * row.case_size * row.order_qty
939 else: # ORDER_UOM_UNIT (or similar)
940 if row.unit_price_quoted is not None:
941 row.total_price = row.unit_price_quoted * row.order_qty
942 if row.total_price is not None:
943 if row.discount_percent and self.allow_item_discounts():
944 row.total_price = (
945 float(row.total_price) * (100 - float(row.discount_percent)) / 100.0
946 )
947 row.total_price = decimal.Decimal(f"{row.total_price:0.2f}")
949 # update batch if total price changed
950 if row.total_price != old_total:
951 batch = row.batch
952 batch.total_price = (
953 (batch.total_price or 0) + (row.total_price or 0) - (old_total or 0)
954 )
956 # all ok
957 row.status_code = row.STATUS_OK
959 def refresh_row_from_local_product(self, row):
960 """
961 Update product-related attributes on the row, from its
962 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.local_product`
963 record.
965 This is called automatically from :meth:`refresh_row()`.
966 """
967 product = row.local_product
968 row.product_scancode = product.scancode
969 row.product_brand = product.brand_name
970 row.product_description = product.description
971 row.product_size = product.size
972 row.product_weighed = product.weighed
973 row.department_id = product.department_id
974 row.department_name = product.department_name
975 row.special_order = product.special_order
976 row.vendor_name = product.vendor_name
977 row.vendor_item_code = product.vendor_item_code
978 row.case_size = product.case_size
979 row.unit_cost = product.unit_cost
980 row.unit_price_reg = product.unit_price_reg
982 def refresh_row_from_pending_product(self, row):
983 """
984 Update product-related attributes on the row, from its
985 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`
986 record.
988 This is called automatically from :meth:`refresh_row()`.
989 """
990 product = row.pending_product
991 row.product_scancode = product.scancode
992 row.product_brand = product.brand_name
993 row.product_description = product.description
994 row.product_size = product.size
995 row.product_weighed = product.weighed
996 row.department_id = product.department_id
997 row.department_name = product.department_name
998 row.special_order = product.special_order
999 row.vendor_name = product.vendor_name
1000 row.vendor_item_code = product.vendor_item_code
1001 row.case_size = product.case_size
1002 row.unit_cost = product.unit_cost
1003 row.unit_price_reg = product.unit_price_reg
1005 def refresh_row_from_external_product(self, row):
1006 """
1007 Update product-related attributes on the row, from its
1008 :term:`external product` record indicated by
1009 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`.
1011 This is called automatically from :meth:`refresh_row()`.
1013 There is no default logic here; subclass must implement as
1014 needed.
1015 """
1016 raise NotImplementedError
1018 def remove_row(self, row):
1019 """
1020 Remove a row from its batch.
1022 This also will update the batch
1023 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.total_price`
1024 accordingly.
1025 """
1026 if row.total_price:
1027 batch = row.batch
1028 batch.total_price = (batch.total_price or 0) - row.total_price
1030 super().remove_row(row)
1032 def do_delete(self, batch, user, **kwargs): # pylint: disable=arguments-differ
1033 """
1034 Delete a batch completely.
1036 If the batch has :term:`pending customer` or :term:`pending
1037 product` records, they are also deleted - unless still
1038 referenced by some order(s).
1039 """
1040 session = self.app.get_session(batch)
1042 # maybe delete pending customer
1043 customer = batch.pending_customer
1044 if customer and not customer.orders:
1045 session.delete(customer)
1047 # maybe delete pending products
1048 for row in batch.rows:
1049 product = row.pending_product
1050 if product and not product.order_items:
1051 session.delete(product)
1053 # continue with normal deletion
1054 super().do_delete(batch, user, **kwargs)
1056 def why_not_execute(self, batch, **kwargs): # pylint: disable=arguments-differ
1057 """
1058 By default this checks to ensure the batch has a customer with
1059 phone number, and at least one item. It also may check to
1060 ensure the store is assigned, if applicable.
1061 """
1062 if not batch.store_id:
1063 order_handler = self.app.get_order_handler()
1064 if order_handler.expose_store_id():
1065 return "Must assign the store"
1067 if not batch.customer_name:
1068 return "Must assign the customer"
1070 if not batch.phone_number:
1071 return "Customer phone number is required"
1073 rows = self.get_effective_rows(batch)
1074 if not rows:
1075 return "Must add at least one valid item"
1077 return None
1079 def get_effective_rows(self, batch):
1080 """
1081 Only rows with
1082 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.STATUS_OK`
1083 are "effective" - i.e. rows with other status codes will not
1084 be created as proper order items.
1085 """
1086 return [row for row in batch.rows if row.status_code == row.STATUS_OK]
1088 def execute(self, batch, user=None, progress=None, **kwargs):
1089 """
1090 Execute the batch; this should make a proper :term:`order`.
1092 By default, this will call:
1094 * :meth:`make_local_customer()`
1095 * :meth:`process_pending_products()`
1096 * :meth:`make_new_order()`
1098 And will return the new
1099 :class:`~sideshow.db.model.orders.Order` instance.
1101 Note that callers should use
1102 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
1103 instead, which calls this method automatically.
1104 """
1105 rows = self.get_effective_rows(batch)
1106 self.make_local_customer(batch)
1107 self.process_pending_products(batch, rows)
1108 order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
1109 return order
1111 def make_local_customer(self, batch):
1112 """
1113 If applicable, this converts the batch :term:`pending
1114 customer` into a :term:`local customer`.
1116 This is called automatically from :meth:`execute()`.
1118 This logic will happen only if :meth:`use_local_customers()`
1119 returns true, and the batch has pending instead of local
1120 customer (so far).
1122 It will create a new
1123 :class:`~sideshow.db.model.customers.LocalCustomer` record and
1124 populate it from the batch
1125 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`.
1126 The latter is then deleted.
1127 """
1128 if not self.use_local_customers():
1129 return
1131 # nothing to do if no pending customer
1132 pending = batch.pending_customer
1133 if not pending:
1134 return
1136 session = self.app.get_session(batch)
1138 # maybe convert pending to local customer
1139 if not batch.local_customer:
1140 model = self.app.model
1141 inspector = sa.inspect(model.LocalCustomer)
1142 local = model.LocalCustomer()
1143 for prop in inspector.column_attrs:
1144 if hasattr(pending, prop.key):
1145 setattr(local, prop.key, getattr(pending, prop.key))
1146 session.add(local)
1147 batch.local_customer = local
1149 # remove pending customer
1150 batch.pending_customer = None
1151 session.delete(pending)
1152 session.flush()
1154 def process_pending_products(self, batch, rows):
1155 """
1156 Process any :term:`pending products <pending product>` which
1157 are present in the batch.
1159 This is called automatically from :meth:`execute()`.
1161 If :term:`local products <local product>` are used, this will
1162 convert the pending products to local products.
1164 If :term:`external products <external product>` are used, this
1165 will update the pending product records' status to indicate
1166 they are ready to be resolved.
1167 """
1168 enum = self.app.enum
1169 model = self.app.model
1170 session = self.app.get_session(batch)
1172 if self.use_local_products():
1173 inspector = sa.inspect(model.LocalProduct)
1174 for row in rows:
1176 if row.local_product or not row.pending_product:
1177 continue
1179 pending = row.pending_product
1180 local = model.LocalProduct()
1182 for prop in inspector.column_attrs:
1183 if hasattr(pending, prop.key):
1184 setattr(local, prop.key, getattr(pending, prop.key))
1185 session.add(local)
1187 row.local_product = local
1188 row.pending_product = None
1189 session.delete(pending)
1191 else: # external products; pending should be marked 'ready'
1192 for row in rows:
1193 pending = row.pending_product
1194 if pending:
1195 pending.status = enum.PendingProductStatus.READY
1197 session.flush()
1199 def make_new_order(self, batch, rows, user=None, progress=None):
1200 """
1201 Create a new :term:`order` from the batch data.
1203 This is called automatically from :meth:`execute()`.
1205 :param batch:
1206 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
1207 instance.
1209 :param rows: List of effective rows for the batch, i.e. which
1210 rows should be converted to :term:`order items <order
1211 item>`.
1213 :returns: :class:`~sideshow.db.model.orders.Order` instance.
1214 """
1215 model = self.app.model
1216 session = self.app.get_session(batch)
1218 batch_fields = [
1219 "store_id",
1220 "customer_id",
1221 "local_customer",
1222 "pending_customer",
1223 "customer_name",
1224 "phone_number",
1225 "email_address",
1226 "total_price",
1227 ]
1229 row_fields = [
1230 "product_id",
1231 "local_product",
1232 "pending_product",
1233 "product_scancode",
1234 "product_brand",
1235 "product_description",
1236 "product_size",
1237 "product_weighed",
1238 "department_id",
1239 "department_name",
1240 "vendor_name",
1241 "vendor_item_code",
1242 "case_size",
1243 "order_qty",
1244 "order_uom",
1245 "unit_cost",
1246 "unit_price_quoted",
1247 "case_price_quoted",
1248 "unit_price_reg",
1249 "unit_price_sale",
1250 "sale_ends",
1251 "discount_percent",
1252 "total_price",
1253 "special_order",
1254 ]
1256 # make order
1257 kw = {field: getattr(batch, field) for field in batch_fields}
1258 kw["order_id"] = batch.id
1259 kw["created_by"] = user
1260 order = model.Order(**kw)
1261 session.add(order)
1262 session.flush()
1264 def convert(row, i): # pylint: disable=unused-argument
1266 # make order item
1267 kw = {field: getattr(row, field) for field in row_fields}
1268 item = model.OrderItem(**kw)
1269 order.items.append(item)
1271 # set item status
1272 self.set_initial_item_status(item, user)
1274 self.app.progress_loop(
1275 convert, rows, progress, message="Converting batch rows to order items"
1276 )
1277 session.flush()
1278 return order
1280 def set_initial_item_status(self, item, user):
1281 """
1282 Set the initial status and attach event(s) for the given item.
1284 This is called from :meth:`make_new_order()` for each item
1285 after it is added to the order.
1287 Default logic will set status to
1288 :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY` and attach 2
1289 events:
1291 * :data:`~sideshow.enum.ORDER_ITEM_EVENT_INITIATED`
1292 * :data:`~sideshow.enum.ORDER_ITEM_EVENT_READY`
1294 :param item: :class:`~sideshow.db.model.orders.OrderItem`
1295 being added to the new order.
1297 :param user:
1298 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
1299 is performing the action.
1300 """
1301 enum = self.app.enum
1302 item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user)
1303 item.add_event(enum.ORDER_ITEM_EVENT_READY, user)
1304 item.status_code = enum.ORDER_ITEM_STATUS_READY