Coverage for .tox/coverage/lib/python3.11/site-packages/sideshow/batch/neworder.py: 100%
387 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 07:16 -0500
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 07:16 -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"""
27import datetime
28import decimal
29from collections import OrderedDict
31import sqlalchemy as sa
33from wuttjamaican.batch import BatchHandler
35from sideshow.db.model import NewOrderBatch
38class NewOrderBatchHandler(BatchHandler):
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 """
52 model_class = NewOrderBatch
54 def get_default_store_id(self):
55 """
56 Returns the configured default value for
57 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`,
58 or ``None``.
59 """
60 return self.config.get('sideshow.orders.default_store_id')
62 def use_local_customers(self):
63 """
64 Returns boolean indicating whether :term:`local customer`
65 accounts should be used. This is true by default, but may be
66 false for :term:`external customer` lookups.
67 """
68 return self.config.get_bool('sideshow.orders.use_local_customers',
69 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',
78 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('sideshow.orders.allow_unknown_products',
90 default=True)
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('sideshow.orders.allow_item_discounts',
98 default=False)
100 def allow_item_discounts_if_on_sale(self):
101 """
102 Returns boolean indicating whether per-item discounts are
103 allowed even when the item is already on sale.
104 """
105 return self.config.get_bool('sideshow.orders.allow_item_discounts_if_on_sale',
106 default=False)
108 def get_default_item_discount(self):
109 """
110 Returns the default item discount percentage, e.g. 15.
112 :rtype: :class:`~python:decimal.Decimal` or ``None``
113 """
114 discount = self.config.get('sideshow.orders.default_item_discount')
115 if discount:
116 return decimal.Decimal(discount)
118 def autocomplete_customers_external(self, session, term, user=None):
119 """
120 Return autocomplete search results for :term:`external
121 customer` records.
123 There is no default logic here; subclass must implement.
125 :param session: Current app :term:`db session`.
127 :param term: Search term string from user input.
129 :param user:
130 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
131 is doing the search, if known.
133 :returns: List of search results; each should be a dict with
134 ``value`` and ``label`` keys.
135 """
136 raise NotImplementedError
138 def autocomplete_customers_local(self, session, term, user=None):
139 """
140 Return autocomplete search results for
141 :class:`~sideshow.db.model.customers.LocalCustomer` records.
143 :param session: Current app :term:`db session`.
145 :param term: Search term string from user input.
147 :param user:
148 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
149 is doing the search, if known.
151 :returns: List of search results; each should be a dict with
152 ``value`` and ``label`` keys.
153 """
154 model = self.app.model
156 # base query
157 query = session.query(model.LocalCustomer)
159 # filter query
160 criteria = [model.LocalCustomer.full_name.ilike(f'%{word}%')
161 for word in term.split()]
162 query = query.filter(sa.and_(*criteria))
164 # sort query
165 query = query.order_by(model.LocalCustomer.full_name)
167 # get data
168 # TODO: need max_results option
169 customers = query.all()
171 # get results
172 def result(customer):
173 return {'value': customer.uuid.hex,
174 'label': customer.full_name}
175 return [result(c) for c in customers]
177 def init_batch(self, batch, session=None, progress=None, **kwargs):
178 """
179 Initialize a new batch.
181 This sets the
182 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`,
183 if the batch does not yet have one and a default is
184 configured.
185 """
186 if not batch.store_id:
187 batch.store_id = self.get_default_store_id()
189 def set_customer(self, batch, customer_info, user=None):
190 """
191 Set/update customer info for the batch.
193 This will first set one of the following:
195 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id`
196 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.local_customer`
197 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
199 Note that a new
200 :class:`~sideshow.db.model.customers.PendingCustomer` record
201 is created if necessary.
203 And then it will update customer-related attributes via one of:
205 * :meth:`refresh_batch_from_external_customer()`
206 * :meth:`refresh_batch_from_local_customer()`
207 * :meth:`refresh_batch_from_pending_customer()`
209 Note that ``customer_info`` may be ``None``, which will cause
210 customer attributes to be set to ``None`` also.
212 :param batch:
213 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
214 update.
216 :param customer_info: Customer ID string, or dict of
217 :class:`~sideshow.db.model.customers.PendingCustomer` data,
218 or ``None`` to clear the customer info.
220 :param user:
221 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
222 is performing the action. This is used to set
223 :attr:`~sideshow.db.model.customers.PendingCustomer.created_by`
224 on the pending customer, if applicable. If not specified,
225 the batch creator is assumed.
226 """
227 model = self.app.model
228 enum = self.app.enum
229 session = self.app.get_session(batch)
230 use_local = self.use_local_customers()
232 # set customer info
233 if isinstance(customer_info, str):
234 if use_local:
236 # local_customer
237 customer = session.get(model.LocalCustomer, customer_info)
238 if not customer:
239 raise ValueError("local customer not found")
240 batch.local_customer = customer
241 self.refresh_batch_from_local_customer(batch)
243 else: # external customer_id
244 batch.customer_id = customer_info
245 self.refresh_batch_from_external_customer(batch)
247 elif customer_info:
249 # pending_customer
250 batch.customer_id = None
251 batch.local_customer = None
252 customer = batch.pending_customer
253 if not customer:
254 customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
255 created_by=user or batch.created_by)
256 session.add(customer)
257 batch.pending_customer = customer
258 fields = [
259 'full_name',
260 'first_name',
261 'last_name',
262 'phone_number',
263 'email_address',
264 ]
265 for key in fields:
266 setattr(customer, key, customer_info.get(key))
267 if 'full_name' not in customer_info:
268 customer.full_name = self.app.make_full_name(customer.first_name,
269 customer.last_name)
270 self.refresh_batch_from_pending_customer(batch)
272 else:
274 # null
275 batch.customer_id = None
276 batch.local_customer = None
277 batch.customer_name = None
278 batch.phone_number = None
279 batch.email_address = None
281 session.flush()
283 def refresh_batch_from_external_customer(self, batch):
284 """
285 Update customer-related attributes on the batch, from its
286 :term:`external customer` record.
288 This is called automatically from :meth:`set_customer()`.
290 There is no default logic here; subclass must implement.
291 """
292 raise NotImplementedError
294 def refresh_batch_from_local_customer(self, batch):
295 """
296 Update customer-related attributes on the batch, from its
297 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.local_customer`
298 record.
300 This is called automatically from :meth:`set_customer()`.
301 """
302 customer = batch.local_customer
303 batch.customer_name = customer.full_name
304 batch.phone_number = customer.phone_number
305 batch.email_address = customer.email_address
307 def refresh_batch_from_pending_customer(self, batch):
308 """
309 Update customer-related attributes on the batch, from its
310 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
311 record.
313 This is called automatically from :meth:`set_customer()`.
314 """
315 customer = batch.pending_customer
316 batch.customer_name = customer.full_name
317 batch.phone_number = customer.phone_number
318 batch.email_address = customer.email_address
320 def autocomplete_products_external(self, session, term, user=None):
321 """
322 Return autocomplete search results for :term:`external
323 product` records.
325 There is no default logic here; subclass must implement.
327 :param session: Current app :term:`db session`.
329 :param term: Search term string from user input.
331 :param user:
332 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
333 is doing the search, if known.
335 :returns: List of search results; each should be a dict with
336 ``value`` and ``label`` keys.
337 """
338 raise NotImplementedError
340 def autocomplete_products_local(self, session, term, user=None):
341 """
342 Return autocomplete search results for
343 :class:`~sideshow.db.model.products.LocalProduct` records.
345 :param session: Current app :term:`db session`.
347 :param term: Search term string from user input.
349 :param user:
350 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
351 is doing the search, if known.
353 :returns: List of search results; each should be a dict with
354 ``value`` and ``label`` keys.
355 """
356 model = self.app.model
358 # base query
359 query = session.query(model.LocalProduct)
361 # filter query
362 criteria = []
363 for word in term.split():
364 criteria.append(sa.or_(
365 model.LocalProduct.brand_name.ilike(f'%{word}%'),
366 model.LocalProduct.description.ilike(f'%{word}%')))
367 query = query.filter(sa.and_(*criteria))
369 # sort query
370 query = query.order_by(model.LocalProduct.brand_name,
371 model.LocalProduct.description)
373 # get data
374 # TODO: need max_results option
375 products = query.all()
377 # get results
378 def result(product):
379 return {'value': product.uuid.hex,
380 'label': product.full_description}
381 return [result(c) for c in products]
383 def get_default_uom_choices(self):
384 """
385 Returns a list of ordering UOM choices which should be
386 presented to the user by default.
388 The built-in logic here will return everything from
389 :data:`~sideshow.enum.ORDER_UOM`.
391 :returns: List of dicts, each with ``key`` and ``value``
392 corresponding to the UOM code and label, respectively.
393 """
394 enum = self.app.enum
395 return [{'key': key, 'value': val}
396 for key, val in enum.ORDER_UOM.items()]
398 def get_product_info_external(self, session, product_id, user=None):
399 """
400 Returns basic info for an :term:`external product` as pertains
401 to ordering.
403 When user has located a product via search, and must then
404 choose order quantity and UOM based on case size, pricing
405 etc., this method is called to retrieve the product info.
407 There is no default logic here; subclass must implement. See
408 also :meth:`get_product_info_local()`.
410 :param session: Current app :term:`db session`.
412 :param product_id: Product ID string for which to retrieve
413 info.
415 :param user:
416 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
417 is performing the action, if known.
419 :returns: Dict of product info. Should raise error instead of
420 returning ``None`` if product not found.
422 This method should only be called after a product has been
423 identified via autocomplete/search lookup; therefore the
424 ``product_id`` should be valid, and the caller can expect this
425 method to *always* return a dict. If for some reason the
426 product cannot be found here, an error should be raised.
428 The dict should contain as much product info as is available
429 and needed; if some are missing it should not cause too much
430 trouble in the app. Here is a basic example::
432 def get_product_info_external(self, session, product_id, user=None):
433 ext_model = get_external_model()
434 ext_session = make_external_session()
436 ext_product = ext_session.get(ext_model.Product, product_id)
437 if not ext_product:
438 ext_session.close()
439 raise ValueError(f"external product not found: {product_id}")
441 info = {
442 'product_id': product_id,
443 'scancode': product.scancode,
444 'brand_name': product.brand_name,
445 'description': product.description,
446 'size': product.size,
447 'weighed': product.sold_by_weight,
448 'special_order': False,
449 'department_id': str(product.department_number),
450 'department_name': product.department_name,
451 'case_size': product.case_size,
452 'unit_price_reg': product.unit_price_reg,
453 'vendor_name': product.vendor_name,
454 'vendor_item_code': product.vendor_item_code,
455 }
457 ext_session.close()
458 return info
459 """
460 raise NotImplementedError
462 def get_product_info_local(self, session, uuid, user=None):
463 """
464 Returns basic info for a :term:`local product` as pertains to
465 ordering.
467 When user has located a product via search, and must then
468 choose order quantity and UOM based on case size, pricing
469 etc., this method is called to retrieve the product info.
471 See :meth:`get_product_info_external()` for more explanation.
473 This method will locate the
474 :class:`~sideshow.db.model.products.LocalProduct` record, then
475 (if found) it calls :meth:`normalize_local_product()` and
476 returns the result.
478 :param session: Current :term:`db session`.
480 :param uuid: UUID for the desired
481 :class:`~sideshow.db.model.products.LocalProduct`.
483 :param user:
484 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
485 is performing the action, if known.
487 :returns: Dict of product info.
488 """
489 model = self.app.model
490 product = session.get(model.LocalProduct, uuid)
491 if not product:
492 raise ValueError(f"Local Product not found: {uuid}")
494 return self.normalize_local_product(product)
496 def normalize_local_product(self, product):
497 """
498 Returns a normalized dict of info for the given :term:`local
499 product`.
501 This is called by:
503 * :meth:`get_product_info_local()`
504 * :meth:`get_past_products()`
506 :param product:
507 :class:`~sideshow.db.model.products.LocalProduct` instance.
509 :returns: Dict of product info.
511 The keys for this dict should essentially one-to-one for the
512 product fields, with one exception:
514 * ``product_id`` will be set to the product UUID as string
515 """
516 return {
517 'product_id': product.uuid.hex,
518 'scancode': product.scancode,
519 'brand_name': product.brand_name,
520 'description': product.description,
521 'size': product.size,
522 'full_description': product.full_description,
523 'weighed': product.weighed,
524 'special_order': product.special_order,
525 'department_id': product.department_id,
526 'department_name': product.department_name,
527 'case_size': product.case_size,
528 'unit_price_reg': product.unit_price_reg,
529 'vendor_name': product.vendor_name,
530 'vendor_item_code': product.vendor_item_code,
531 }
533 def get_past_orders(self, batch):
534 """
535 Retrieve a (possibly empty) list of past :term:`orders
536 <order>` for the batch customer.
538 This is called by :meth:`get_past_products()`.
540 :param batch:
541 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
542 instance.
544 :returns: List of :class:`~sideshow.db.model.orders.Order`
545 records.
546 """
547 model = self.app.model
548 session = self.app.get_session(batch)
549 orders = session.query(model.Order)
551 if batch.customer_id:
552 orders = orders.filter(model.Order.customer_id == batch.customer_id)
553 elif batch.local_customer:
554 orders = orders.filter(model.Order.local_customer == batch.local_customer)
555 else:
556 raise ValueError(f"batch has no customer: {batch}")
558 orders = orders.order_by(model.Order.created.desc())
559 return orders.all()
561 def get_past_products(self, batch, user=None):
562 """
563 Retrieve a (possibly empty) list of products which have been
564 previously ordered by the batch customer.
566 Note that this does not return :term:`order items <order
567 item>`, nor does it return true product records, but rather it
568 returns a list of dicts. Each will have product info but will
569 *not* have order quantity etc.
571 This method calls :meth:`get_past_orders()` and then iterates
572 through each order item therein. Any duplicated products
573 encountered will be skipped, so the final list contains unique
574 products.
576 Each dict in the result is obtained by calling one of:
578 * :meth:`normalize_local_product()`
579 * :meth:`get_product_info_external()`
581 :param batch:
582 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
583 instance.
585 :param user:
586 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
587 is performing the action, if known.
589 :returns: List of product info dicts.
590 """
591 model = self.app.model
592 session = self.app.get_session(batch)
593 use_local = self.use_local_products()
594 user = user or batch.created_by
595 products = OrderedDict()
597 # track down all order items for batch contact
598 for order in self.get_past_orders(batch):
599 for item in order.items:
601 # nb. we only need the first match for each product
602 if use_local:
603 product = item.local_product
604 if product and product.uuid not in products:
605 products[product.uuid] = self.normalize_local_product(product)
606 elif item.product_id and item.product_id not in products:
607 products[item.product_id] = self.get_product_info_external(
608 session, item.product_id, user=user)
610 products = list(products.values())
611 for product in products:
613 price = product['unit_price_reg']
615 if 'unit_price_reg_display' not in product:
616 product['unit_price_reg_display'] = self.app.render_currency(price)
618 if 'unit_price_quoted' not in product:
619 product['unit_price_quoted'] = price
621 if 'unit_price_quoted_display' not in product:
622 product['unit_price_quoted_display'] = product['unit_price_reg_display']
624 if ('case_price_quoted' not in product
625 and product.get('unit_price_quoted') is not None
626 and product.get('case_size') is not None):
627 product['case_price_quoted'] = product['unit_price_quoted'] * product['case_size']
629 if ('case_price_quoted_display' not in product
630 and 'case_price_quoted' in product):
631 product['case_price_quoted_display'] = self.app.render_currency(
632 product['case_price_quoted'])
634 return products
636 def add_item(self, batch, product_info, order_qty, order_uom,
637 discount_percent=None, user=None):
638 """
639 Add a new item/row to the batch, for given product and quantity.
641 See also :meth:`update_item()`.
643 :param batch:
644 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
645 update.
647 :param product_info: Product ID string, or dict of
648 :class:`~sideshow.db.model.products.PendingProduct` data.
650 :param order_qty:
651 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty`
652 value for the new row.
654 :param order_uom:
655 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
656 value for the new row.
658 :param discount_percent: Sets the
659 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent`
660 for the row, if allowed.
662 :param user:
663 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
664 is performing the action. This is used to set
665 :attr:`~sideshow.db.model.products.PendingProduct.created_by`
666 on the pending product, if applicable. If not specified,
667 the batch creator is assumed.
669 :returns:
670 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
671 instance.
672 """
673 model = self.app.model
674 enum = self.app.enum
675 session = self.app.get_session(batch)
676 use_local = self.use_local_products()
677 row = self.make_row()
679 # set product info
680 if isinstance(product_info, str):
681 if use_local:
683 # local_product
684 local = session.get(model.LocalProduct, product_info)
685 if not local:
686 raise ValueError("local product not found")
687 row.local_product = local
689 else: # external product_id
690 row.product_id = product_info
692 else:
693 # pending_product
694 if not self.allow_unknown_products():
695 raise TypeError("unknown/pending product not allowed for new orders")
696 row.product_id = None
697 row.local_product = None
698 pending = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
699 created_by=user or batch.created_by)
700 fields = [
701 'scancode',
702 'brand_name',
703 'description',
704 'size',
705 'weighed',
706 'department_id',
707 'department_name',
708 'special_order',
709 'vendor_name',
710 'vendor_item_code',
711 'case_size',
712 'unit_cost',
713 'unit_price_reg',
714 'notes',
715 ]
716 for key in fields:
717 setattr(pending, key, product_info.get(key))
719 # nb. this may convert float to decimal etc.
720 session.add(pending)
721 session.flush()
722 session.refresh(pending)
723 row.pending_product = pending
725 # set order info
726 row.order_qty = order_qty
727 row.order_uom = order_uom
729 # discount
730 if self.allow_item_discounts():
731 row.discount_percent = discount_percent or 0
733 # add row to batch
734 self.add_row(batch, row)
735 session.flush()
736 return row
738 def update_item(self, row, product_info, order_qty, order_uom,
739 discount_percent=None, user=None):
740 """
741 Update an item/row, per given product and quantity.
743 See also :meth:`add_item()`.
745 :param row:
746 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
747 to update.
749 :param product_info: Product ID string, or dict of
750 :class:`~sideshow.db.model.products.PendingProduct` data.
752 :param order_qty: New
753 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty`
754 value for the row.
756 :param order_uom: New
757 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
758 value for the row.
760 :param discount_percent: Sets the
761 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent`
762 for the row, if allowed.
764 :param user:
765 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
766 is performing the action. This is used to set
767 :attr:`~sideshow.db.model.products.PendingProduct.created_by`
768 on the pending product, if applicable. If not specified,
769 the batch creator is assumed.
770 """
771 model = self.app.model
772 enum = self.app.enum
773 session = self.app.get_session(row)
774 use_local = self.use_local_products()
776 # set product info
777 if isinstance(product_info, str):
778 if use_local:
780 # local_product
781 local = session.get(model.LocalProduct, product_info)
782 if not local:
783 raise ValueError("local product not found")
784 row.local_product = local
786 else: # external product_id
787 row.product_id = product_info
789 else:
790 # pending_product
791 if not self.allow_unknown_products():
792 raise TypeError("unknown/pending product not allowed for new orders")
793 row.product_id = None
794 row.local_product = None
795 pending = row.pending_product
796 if not pending:
797 pending = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
798 created_by=user or row.batch.created_by)
799 session.add(pending)
800 row.pending_product = pending
801 fields = [
802 'scancode',
803 'brand_name',
804 'description',
805 'size',
806 'weighed',
807 'department_id',
808 'department_name',
809 'special_order',
810 'vendor_name',
811 'vendor_item_code',
812 'case_size',
813 'unit_cost',
814 'unit_price_reg',
815 'notes',
816 ]
817 for key in fields:
818 setattr(pending, key, product_info.get(key))
820 # nb. this may convert float to decimal etc.
821 session.flush()
822 session.refresh(pending)
824 # set order info
825 row.order_qty = order_qty
826 row.order_uom = order_uom
828 # discount
829 if self.allow_item_discounts():
830 row.discount_percent = discount_percent or 0
832 # nb. this may convert float to decimal etc.
833 session.flush()
834 session.refresh(row)
836 # refresh per new info
837 self.refresh_row(row)
839 def refresh_row(self, row):
840 """
841 Refresh data for the row. This is called when adding a new
842 row to the batch, or anytime the row is updated (e.g. when
843 changing order quantity).
845 This calls one of the following to update product-related
846 attributes:
848 * :meth:`refresh_row_from_external_product()`
849 * :meth:`refresh_row_from_local_product()`
850 * :meth:`refresh_row_from_pending_product()`
852 It then re-calculates the row's
853 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price`
854 and updates the batch accordingly.
856 It also sets the row
857 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.status_code`.
858 """
859 enum = self.app.enum
860 row.status_code = None
861 row.status_text = None
863 # ensure product
864 if not row.product_id and not row.local_product and not row.pending_product:
865 row.status_code = row.STATUS_MISSING_PRODUCT
866 return
868 # ensure order qty/uom
869 if not row.order_qty or not row.order_uom:
870 row.status_code = row.STATUS_MISSING_ORDER_QTY
871 return
873 # update product attrs on row
874 if row.product_id:
875 self.refresh_row_from_external_product(row)
876 elif row.local_product:
877 self.refresh_row_from_local_product(row)
878 else:
879 self.refresh_row_from_pending_product(row)
881 # we need to know if total price changes
882 old_total = row.total_price
884 # update quoted price
885 row.unit_price_quoted = None
886 row.case_price_quoted = None
887 if row.unit_price_sale is not None and (
888 not row.sale_ends
889 or row.sale_ends > datetime.datetime.now()):
890 row.unit_price_quoted = row.unit_price_sale
891 else:
892 row.unit_price_quoted = row.unit_price_reg
893 if row.unit_price_quoted is not None and row.case_size:
894 row.case_price_quoted = row.unit_price_quoted * row.case_size
896 # update row total price
897 row.total_price = None
898 if row.order_uom == enum.ORDER_UOM_CASE:
899 # TODO: why are we not using case price again?
900 # if row.case_price_quoted:
901 # row.total_price = row.case_price_quoted * row.order_qty
902 if row.unit_price_quoted is not None and row.case_size is not None:
903 row.total_price = row.unit_price_quoted * row.case_size * row.order_qty
904 else: # ORDER_UOM_UNIT (or similar)
905 if row.unit_price_quoted is not None:
906 row.total_price = row.unit_price_quoted * row.order_qty
907 if row.total_price is not None:
908 if row.discount_percent and self.allow_item_discounts():
909 row.total_price = (float(row.total_price)
910 * (100 - float(row.discount_percent))
911 / 100.0)
912 row.total_price = decimal.Decimal(f'{row.total_price:0.2f}')
914 # update batch if total price changed
915 if row.total_price != old_total:
916 batch = row.batch
917 batch.total_price = ((batch.total_price or 0)
918 + (row.total_price or 0)
919 - (old_total or 0))
921 # all ok
922 row.status_code = row.STATUS_OK
924 def refresh_row_from_local_product(self, row):
925 """
926 Update product-related attributes on the row, from its
927 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.local_product`
928 record.
930 This is called automatically from :meth:`refresh_row()`.
931 """
932 product = row.local_product
933 row.product_scancode = product.scancode
934 row.product_brand = product.brand_name
935 row.product_description = product.description
936 row.product_size = product.size
937 row.product_weighed = product.weighed
938 row.department_id = product.department_id
939 row.department_name = product.department_name
940 row.special_order = product.special_order
941 row.vendor_name = product.vendor_name
942 row.vendor_item_code = product.vendor_item_code
943 row.case_size = product.case_size
944 row.unit_cost = product.unit_cost
945 row.unit_price_reg = product.unit_price_reg
947 def refresh_row_from_pending_product(self, row):
948 """
949 Update product-related attributes on the row, from its
950 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`
951 record.
953 This is called automatically from :meth:`refresh_row()`.
954 """
955 product = row.pending_product
956 row.product_scancode = product.scancode
957 row.product_brand = product.brand_name
958 row.product_description = product.description
959 row.product_size = product.size
960 row.product_weighed = product.weighed
961 row.department_id = product.department_id
962 row.department_name = product.department_name
963 row.special_order = product.special_order
964 row.vendor_name = product.vendor_name
965 row.vendor_item_code = product.vendor_item_code
966 row.case_size = product.case_size
967 row.unit_cost = product.unit_cost
968 row.unit_price_reg = product.unit_price_reg
970 def refresh_row_from_external_product(self, row):
971 """
972 Update product-related attributes on the row, from its
973 :term:`external product` record indicated by
974 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`.
976 This is called automatically from :meth:`refresh_row()`.
978 There is no default logic here; subclass must implement as
979 needed.
980 """
981 raise NotImplementedError
983 def remove_row(self, row):
984 """
985 Remove a row from its batch.
987 This also will update the batch
988 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.total_price`
989 accordingly.
990 """
991 if row.total_price:
992 batch = row.batch
993 batch.total_price = (batch.total_price or 0) - row.total_price
995 super().remove_row(row)
997 def do_delete(self, batch, user, **kwargs):
998 """
999 Delete a batch completely.
1001 If the batch has :term:`pending customer` or :term:`pending
1002 product` records, they are also deleted - unless still
1003 referenced by some order(s).
1004 """
1005 session = self.app.get_session(batch)
1007 # maybe delete pending customer
1008 customer = batch.pending_customer
1009 if customer and not customer.orders:
1010 session.delete(customer)
1012 # maybe delete pending products
1013 for row in batch.rows:
1014 product = row.pending_product
1015 if product and not product.order_items:
1016 session.delete(product)
1018 # continue with normal deletion
1019 super().do_delete(batch, user, **kwargs)
1021 def why_not_execute(self, batch, **kwargs):
1022 """
1023 By default this checks to ensure the batch has a customer with
1024 phone number, and at least one item. It also may check to
1025 ensure the store is assigned, if applicable.
1026 """
1027 if not batch.store_id:
1028 order_handler = self.app.get_order_handler()
1029 if order_handler.expose_store_id():
1030 return "Must assign the store"
1032 if not batch.customer_name:
1033 return "Must assign the customer"
1035 if not batch.phone_number:
1036 return "Customer phone number is required"
1038 rows = self.get_effective_rows(batch)
1039 if not rows:
1040 return "Must add at least one valid item"
1042 def get_effective_rows(self, batch):
1043 """
1044 Only rows with
1045 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.STATUS_OK`
1046 are "effective" - i.e. rows with other status codes will not
1047 be created as proper order items.
1048 """
1049 return [row for row in batch.rows
1050 if row.status_code == row.STATUS_OK]
1052 def execute(self, batch, user=None, progress=None, **kwargs):
1053 """
1054 Execute the batch; this should make a proper :term:`order`.
1056 By default, this will call:
1058 * :meth:`make_local_customer()`
1059 * :meth:`process_pending_products()`
1060 * :meth:`make_new_order()`
1062 And will return the new
1063 :class:`~sideshow.db.model.orders.Order` instance.
1065 Note that callers should use
1066 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
1067 instead, which calls this method automatically.
1068 """
1069 rows = self.get_effective_rows(batch)
1070 self.make_local_customer(batch)
1071 self.process_pending_products(batch, rows)
1072 order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
1073 return order
1075 def make_local_customer(self, batch):
1076 """
1077 If applicable, this converts the batch :term:`pending
1078 customer` into a :term:`local customer`.
1080 This is called automatically from :meth:`execute()`.
1082 This logic will happen only if :meth:`use_local_customers()`
1083 returns true, and the batch has pending instead of local
1084 customer (so far).
1086 It will create a new
1087 :class:`~sideshow.db.model.customers.LocalCustomer` record and
1088 populate it from the batch
1089 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`.
1090 The latter is then deleted.
1091 """
1092 if not self.use_local_customers():
1093 return
1095 # nothing to do if no pending customer
1096 pending = batch.pending_customer
1097 if not pending:
1098 return
1100 session = self.app.get_session(batch)
1102 # maybe convert pending to local customer
1103 if not batch.local_customer:
1104 model = self.app.model
1105 inspector = sa.inspect(model.LocalCustomer)
1106 local = model.LocalCustomer()
1107 for prop in inspector.column_attrs:
1108 if hasattr(pending, prop.key):
1109 setattr(local, prop.key, getattr(pending, prop.key))
1110 session.add(local)
1111 batch.local_customer = local
1113 # remove pending customer
1114 batch.pending_customer = None
1115 session.delete(pending)
1116 session.flush()
1118 def process_pending_products(self, batch, rows):
1119 """
1120 Process any :term:`pending products <pending product>` which
1121 are present in the batch.
1123 This is called automatically from :meth:`execute()`.
1125 If :term:`local products <local product>` are used, this will
1126 convert the pending products to local products.
1128 If :term:`external products <external product>` are used, this
1129 will update the pending product records' status to indicate
1130 they are ready to be resolved.
1131 """
1132 enum = self.app.enum
1133 model = self.app.model
1134 session = self.app.get_session(batch)
1136 if self.use_local_products():
1137 inspector = sa.inspect(model.LocalProduct)
1138 for row in rows:
1140 if row.local_product or not row.pending_product:
1141 continue
1143 pending = row.pending_product
1144 local = model.LocalProduct()
1146 for prop in inspector.column_attrs:
1147 if hasattr(pending, prop.key):
1148 setattr(local, prop.key, getattr(pending, prop.key))
1149 session.add(local)
1151 row.local_product = local
1152 row.pending_product = None
1153 session.delete(pending)
1155 else: # external products; pending should be marked 'ready'
1156 for row in rows:
1157 pending = row.pending_product
1158 if pending:
1159 pending.status = enum.PendingProductStatus.READY
1161 session.flush()
1163 def make_new_order(self, batch, rows, user=None, progress=None, **kwargs):
1164 """
1165 Create a new :term:`order` from the batch data.
1167 This is called automatically from :meth:`execute()`.
1169 :param batch:
1170 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
1171 instance.
1173 :param rows: List of effective rows for the batch, i.e. which
1174 rows should be converted to :term:`order items <order
1175 item>`.
1177 :returns: :class:`~sideshow.db.model.orders.Order` instance.
1178 """
1179 model = self.app.model
1180 enum = self.app.enum
1181 session = self.app.get_session(batch)
1183 batch_fields = [
1184 'store_id',
1185 'customer_id',
1186 'local_customer',
1187 'pending_customer',
1188 'customer_name',
1189 'phone_number',
1190 'email_address',
1191 'total_price',
1192 ]
1194 row_fields = [
1195 'product_id',
1196 'local_product',
1197 'pending_product',
1198 'product_scancode',
1199 'product_brand',
1200 'product_description',
1201 'product_size',
1202 'product_weighed',
1203 'department_id',
1204 'department_name',
1205 'vendor_name',
1206 'vendor_item_code',
1207 'case_size',
1208 'order_qty',
1209 'order_uom',
1210 'unit_cost',
1211 'unit_price_quoted',
1212 'case_price_quoted',
1213 'unit_price_reg',
1214 'unit_price_sale',
1215 'sale_ends',
1216 'discount_percent',
1217 'total_price',
1218 'special_order',
1219 ]
1221 # make order
1222 kw = dict([(field, getattr(batch, field))
1223 for field in batch_fields])
1224 kw['order_id'] = batch.id
1225 kw['created_by'] = user
1226 order = model.Order(**kw)
1227 session.add(order)
1228 session.flush()
1230 def convert(row, i):
1232 # make order item
1233 kw = dict([(field, getattr(row, field))
1234 for field in row_fields])
1235 item = model.OrderItem(**kw)
1236 order.items.append(item)
1238 # set item status
1239 self.set_initial_item_status(item, user)
1241 self.app.progress_loop(convert, rows, progress,
1242 message="Converting batch rows to order items")
1243 session.flush()
1244 return order
1246 def set_initial_item_status(self, item, user, **kwargs):
1247 """
1248 Set the initial status and attach event(s) for the given item.
1250 This is called from :meth:`make_new_order()` for each item
1251 after it is added to the order.
1253 Default logic will set status to
1254 :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY` and attach 2
1255 events:
1257 * :data:`~sideshow.enum.ORDER_ITEM_EVENT_INITIATED`
1258 * :data:`~sideshow.enum.ORDER_ITEM_EVENT_READY`
1260 :param item: :class:`~sideshow.db.model.orders.OrderItem`
1261 being added to the new order.
1263 :param user:
1264 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
1265 is performing the action.
1266 """
1267 enum = self.app.enum
1268 item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user)
1269 item.add_event(enum.ORDER_ITEM_EVENT_READY, user)
1270 item.status_code = enum.ORDER_ITEM_STATUS_READY