Coverage for src/sideshow/batch/neworder.py: 0%
325 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-26 13:16 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-26 13:16 -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"""
27import datetime
28import decimal
30import sqlalchemy as sa
32from wuttjamaican.batch import BatchHandler
34from sideshow.db.model import NewOrderBatch
37class NewOrderBatchHandler(BatchHandler):
38 """
39 The :term:`batch handler` for :term:`new order batches <new order
40 batch>`.
42 This is responsible for business logic around the creation of new
43 :term:`orders <order>`. A
44 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` tracks
45 all user input until they "submit" (execute) at which point an
46 :class:`~sideshow.db.model.orders.Order` is created.
48 After the batch has executed the :term:`order handler` takes over
49 responsibility for the rest of the order lifecycle.
50 """
51 model_class = NewOrderBatch
53 def use_local_customers(self):
54 """
55 Returns boolean indicating whether :term:`local customer`
56 accounts should be used. This is true by default, but may be
57 false for :term:`external customer` lookups.
58 """
59 return self.config.get_bool('sideshow.orders.use_local_customers',
60 default=True)
62 def use_local_products(self):
63 """
64 Returns boolean indicating whether :term:`local product`
65 records should be used. This is true by default, but may be
66 false for :term:`external product` lookups.
67 """
68 return self.config.get_bool('sideshow.orders.use_local_products',
69 default=True)
71 def allow_unknown_products(self):
72 """
73 Returns boolean indicating whether :term:`pending products
74 <pending product>` are allowed when creating an order.
76 This is true by default, so user can enter new/unknown product
77 when creating an order. This can be disabled, to force user
78 to choose existing local/external product.
79 """
80 return self.config.get_bool('sideshow.orders.allow_unknown_products',
81 default=True)
83 def allow_item_discounts(self):
84 """
85 Returns boolean indicating whether per-item discounts are
86 allowed when creating an order.
87 """
88 return self.config.get_bool('sideshow.orders.allow_item_discounts',
89 default=False)
91 def allow_item_discounts_if_on_sale(self):
92 """
93 Returns boolean indicating whether per-item discounts are
94 allowed even when the item is already on sale.
95 """
96 return self.config.get_bool('sideshow.orders.allow_item_discounts_if_on_sale',
97 default=False)
99 def get_default_item_discount(self):
100 """
101 Returns the default item discount percentage, e.g. 15.
103 :rtype: :class:`~python:decimal.Decimal` or ``None``
104 """
105 discount = self.config.get('sideshow.orders.default_item_discount')
106 if discount:
107 return decimal.Decimal(discount)
109 def autocomplete_customers_external(self, session, term, user=None):
110 """
111 Return autocomplete search results for :term:`external
112 customer` records.
114 There is no default logic here; subclass must implement.
116 :param session: Current app :term:`db session`.
118 :param term: Search term string from user input.
120 :param user:
121 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
122 is doing the search, if known.
124 :returns: List of search results; each should be a dict with
125 ``value`` and ``label`` keys.
126 """
127 raise NotImplementedError
129 def autocomplete_customers_local(self, session, term, user=None):
130 """
131 Return autocomplete search results for
132 :class:`~sideshow.db.model.customers.LocalCustomer` records.
134 :param session: Current app :term:`db session`.
136 :param term: Search term string from user input.
138 :param user:
139 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
140 is doing the search, if known.
142 :returns: List of search results; each should be a dict with
143 ``value`` and ``label`` keys.
144 """
145 model = self.app.model
147 # base query
148 query = session.query(model.LocalCustomer)
150 # filter query
151 criteria = [model.LocalCustomer.full_name.ilike(f'%{word}%')
152 for word in term.split()]
153 query = query.filter(sa.and_(*criteria))
155 # sort query
156 query = query.order_by(model.LocalCustomer.full_name)
158 # get data
159 # TODO: need max_results option
160 customers = query.all()
162 # get results
163 def result(customer):
164 return {'value': customer.uuid.hex,
165 'label': customer.full_name}
166 return [result(c) for c in customers]
168 def set_customer(self, batch, customer_info, user=None):
169 """
170 Set/update customer info for the batch.
172 This will first set one of the following:
174 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id`
175 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.local_customer`
176 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
178 Note that a new
179 :class:`~sideshow.db.model.customers.PendingCustomer` record
180 is created if necessary.
182 And then it will update customer-related attributes via one of:
184 * :meth:`refresh_batch_from_external_customer()`
185 * :meth:`refresh_batch_from_local_customer()`
186 * :meth:`refresh_batch_from_pending_customer()`
188 Note that ``customer_info`` may be ``None``, which will cause
189 customer attributes to be set to ``None`` also.
191 :param batch:
192 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
193 update.
195 :param customer_info: Customer ID string, or dict of
196 :class:`~sideshow.db.model.customers.PendingCustomer` data,
197 or ``None`` to clear the customer info.
199 :param user:
200 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
201 is performing the action. This is used to set
202 :attr:`~sideshow.db.model.customers.PendingCustomer.created_by`
203 on the pending customer, if applicable. If not specified,
204 the batch creator is assumed.
205 """
206 model = self.app.model
207 enum = self.app.enum
208 session = self.app.get_session(batch)
209 use_local = self.use_local_customers()
211 # set customer info
212 if isinstance(customer_info, str):
213 if use_local:
215 # local_customer
216 customer = session.get(model.LocalCustomer, customer_info)
217 if not customer:
218 raise ValueError("local customer not found")
219 batch.local_customer = customer
220 self.refresh_batch_from_local_customer(batch)
222 else: # external customer_id
223 batch.customer_id = customer_info
224 self.refresh_batch_from_external_customer(batch)
226 elif customer_info:
228 # pending_customer
229 batch.customer_id = None
230 batch.local_customer = None
231 customer = batch.pending_customer
232 if not customer:
233 customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
234 created_by=user or batch.created_by)
235 session.add(customer)
236 batch.pending_customer = customer
237 fields = [
238 'full_name',
239 'first_name',
240 'last_name',
241 'phone_number',
242 'email_address',
243 ]
244 for key in fields:
245 setattr(customer, key, customer_info.get(key))
246 if 'full_name' not in customer_info:
247 customer.full_name = self.app.make_full_name(customer.first_name,
248 customer.last_name)
249 self.refresh_batch_from_pending_customer(batch)
251 else:
253 # null
254 batch.customer_id = None
255 batch.local_customer = None
256 batch.customer_name = None
257 batch.phone_number = None
258 batch.email_address = None
260 session.flush()
262 def refresh_batch_from_external_customer(self, batch):
263 """
264 Update customer-related attributes on the batch, from its
265 :term:`external customer` record.
267 This is called automatically from :meth:`set_customer()`.
269 There is no default logic here; subclass must implement.
270 """
271 raise NotImplementedError
273 def refresh_batch_from_local_customer(self, batch):
274 """
275 Update customer-related attributes on the batch, from its
276 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.local_customer`
277 record.
279 This is called automatically from :meth:`set_customer()`.
280 """
281 customer = batch.local_customer
282 batch.customer_name = customer.full_name
283 batch.phone_number = customer.phone_number
284 batch.email_address = customer.email_address
286 def refresh_batch_from_pending_customer(self, batch):
287 """
288 Update customer-related attributes on the batch, from its
289 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
290 record.
292 This is called automatically from :meth:`set_customer()`.
293 """
294 customer = batch.pending_customer
295 batch.customer_name = customer.full_name
296 batch.phone_number = customer.phone_number
297 batch.email_address = customer.email_address
299 def autocomplete_products_external(self, session, term, user=None):
300 """
301 Return autocomplete search results for :term:`external
302 product` records.
304 There is no default logic here; subclass must implement.
306 :param session: Current app :term:`db session`.
308 :param term: Search term string from user input.
310 :param user:
311 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
312 is doing the search, if known.
314 :returns: List of search results; each should be a dict with
315 ``value`` and ``label`` keys.
316 """
317 raise NotImplementedError
319 def autocomplete_products_local(self, session, term, user=None):
320 """
321 Return autocomplete search results for
322 :class:`~sideshow.db.model.products.LocalProduct` records.
324 :param session: Current app :term:`db session`.
326 :param term: Search term string from user input.
328 :param user:
329 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
330 is doing the search, if known.
332 :returns: List of search results; each should be a dict with
333 ``value`` and ``label`` keys.
334 """
335 model = self.app.model
337 # base query
338 query = session.query(model.LocalProduct)
340 # filter query
341 criteria = []
342 for word in term.split():
343 criteria.append(sa.or_(
344 model.LocalProduct.brand_name.ilike(f'%{word}%'),
345 model.LocalProduct.description.ilike(f'%{word}%')))
346 query = query.filter(sa.and_(*criteria))
348 # sort query
349 query = query.order_by(model.LocalProduct.brand_name,
350 model.LocalProduct.description)
352 # get data
353 # TODO: need max_results option
354 products = query.all()
356 # get results
357 def result(product):
358 return {'value': product.uuid.hex,
359 'label': product.full_description}
360 return [result(c) for c in products]
362 def get_product_info_external(self, session, product_id, user=None):
363 """
364 Returns basic info for an :term:`external product` as pertains
365 to ordering.
367 When user has located a product via search, and must then
368 choose order quantity and UOM based on case size, pricing
369 etc., this method is called to retrieve the product info.
371 There is no default logic here; subclass must implement.
373 :param session: Current app :term:`db session`.
375 :param product_id: Product ID string for which to retrieve
376 info.
378 :param user:
379 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
380 is performing the action, if known.
382 :returns: Dict of product info. Should raise error instead of
383 returning ``None`` if product not found.
385 This method should only be called after a product has been
386 identified via autocomplete/search lookup; therefore the
387 ``product_id`` should be valid, and the caller can expect this
388 method to *always* return a dict. If for some reason the
389 product cannot be found here, an error should be raised.
391 The dict should contain as much product info as is available
392 and needed; if some are missing it should not cause too much
393 trouble in the app. Here is a basic example::
395 def get_product_info_external(self, session, product_id, user=None):
396 ext_model = get_external_model()
397 ext_session = make_external_session()
399 ext_product = ext_session.get(ext_model.Product, product_id)
400 if not ext_product:
401 ext_session.close()
402 raise ValueError(f"external product not found: {product_id}")
404 info = {
405 'product_id': product_id,
406 'scancode': product.scancode,
407 'brand_name': product.brand_name,
408 'description': product.description,
409 'size': product.size,
410 'weighed': product.sold_by_weight,
411 'special_order': False,
412 'department_id': str(product.department_number),
413 'department_name': product.department_name,
414 'case_size': product.case_size,
415 'unit_price_reg': product.unit_price_reg,
416 'vendor_name': product.vendor_name,
417 'vendor_item_code': product.vendor_item_code,
418 }
420 ext_session.close()
421 return info
422 """
423 raise NotImplementedError
425 def get_product_info_local(self, session, uuid, user=None):
426 """
427 Returns basic info for a
428 :class:`~sideshow.db.model.products.LocalProduct` as pertains
429 to ordering.
431 When user has located a product via search, and must then
432 choose order quantity and UOM based on case size, pricing
433 etc., this method is called to retrieve the product info.
435 See :meth:`get_product_info_external()` for more explanation.
436 """
437 model = self.app.model
438 product = session.get(model.LocalProduct, uuid)
439 if not product:
440 raise ValueError(f"Local Product not found: {uuid}")
442 return {
443 'product_id': product.uuid.hex,
444 'scancode': product.scancode,
445 'brand_name': product.brand_name,
446 'description': product.description,
447 'size': product.size,
448 'full_description': product.full_description,
449 'weighed': product.weighed,
450 'special_order': product.special_order,
451 'department_id': product.department_id,
452 'department_name': product.department_name,
453 'case_size': product.case_size,
454 'unit_price_reg': product.unit_price_reg,
455 'vendor_name': product.vendor_name,
456 'vendor_item_code': product.vendor_item_code,
457 }
459 def add_item(self, batch, product_info, order_qty, order_uom,
460 discount_percent=None, user=None):
461 """
462 Add a new item/row to the batch, for given product and quantity.
464 See also :meth:`update_item()`.
466 :param batch:
467 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
468 update.
470 :param product_info: Product ID string, or dict of
471 :class:`~sideshow.db.model.products.PendingProduct` data.
473 :param order_qty:
474 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty`
475 value for the new row.
477 :param order_uom:
478 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
479 value for the new row.
481 :param discount_percent: Sets the
482 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent`
483 for the row, if allowed.
485 :param user:
486 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
487 is performing the action. This is used to set
488 :attr:`~sideshow.db.model.products.PendingProduct.created_by`
489 on the pending product, if applicable. If not specified,
490 the batch creator is assumed.
492 :returns:
493 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
494 instance.
495 """
496 model = self.app.model
497 enum = self.app.enum
498 session = self.app.get_session(batch)
499 use_local = self.use_local_products()
500 row = self.make_row()
502 # set product info
503 if isinstance(product_info, str):
504 if use_local:
506 # local_product
507 local = session.get(model.LocalProduct, product_info)
508 if not local:
509 raise ValueError("local product not found")
510 row.local_product = local
512 else: # external product_id
513 row.product_id = product_info
515 else:
516 # pending_product
517 if not self.allow_unknown_products():
518 raise TypeError("unknown/pending product not allowed for new orders")
519 row.product_id = None
520 row.local_product = None
521 pending = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
522 created_by=user or batch.created_by)
523 fields = [
524 'scancode',
525 'brand_name',
526 'description',
527 'size',
528 'weighed',
529 'department_id',
530 'department_name',
531 'special_order',
532 'vendor_name',
533 'vendor_item_code',
534 'case_size',
535 'unit_cost',
536 'unit_price_reg',
537 'notes',
538 ]
539 for key in fields:
540 setattr(pending, key, product_info.get(key))
542 # nb. this may convert float to decimal etc.
543 session.add(pending)
544 session.flush()
545 session.refresh(pending)
546 row.pending_product = pending
548 # set order info
549 row.order_qty = order_qty
550 row.order_uom = order_uom
552 # discount
553 if self.allow_item_discounts():
554 row.discount_percent = discount_percent or 0
556 # add row to batch
557 self.add_row(batch, row)
558 session.flush()
559 return row
561 def update_item(self, row, product_info, order_qty, order_uom,
562 discount_percent=None, user=None):
563 """
564 Update an item/row, per given product and quantity.
566 See also :meth:`add_item()`.
568 :param row:
569 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
570 to update.
572 :param product_info: Product ID string, or dict of
573 :class:`~sideshow.db.model.products.PendingProduct` data.
575 :param order_qty: New
576 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty`
577 value for the row.
579 :param order_uom: New
580 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
581 value for the row.
583 :param discount_percent: Sets the
584 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent`
585 for the row, if allowed.
587 :param user:
588 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
589 is performing the action. This is used to set
590 :attr:`~sideshow.db.model.products.PendingProduct.created_by`
591 on the pending product, if applicable. If not specified,
592 the batch creator is assumed.
593 """
594 model = self.app.model
595 enum = self.app.enum
596 session = self.app.get_session(row)
597 use_local = self.use_local_products()
599 # set product info
600 if isinstance(product_info, str):
601 if use_local:
603 # local_product
604 local = session.get(model.LocalProduct, product_info)
605 if not local:
606 raise ValueError("local product not found")
607 row.local_product = local
609 else: # external product_id
610 row.product_id = product_info
612 else:
613 # pending_product
614 if not self.allow_unknown_products():
615 raise TypeError("unknown/pending product not allowed for new orders")
616 row.product_id = None
617 row.local_product = None
618 pending = row.pending_product
619 if not pending:
620 pending = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
621 created_by=user or row.batch.created_by)
622 session.add(pending)
623 row.pending_product = pending
624 fields = [
625 'scancode',
626 'brand_name',
627 'description',
628 'size',
629 'weighed',
630 'department_id',
631 'department_name',
632 'special_order',
633 'vendor_name',
634 'vendor_item_code',
635 'case_size',
636 'unit_cost',
637 'unit_price_reg',
638 'notes',
639 ]
640 for key in fields:
641 setattr(pending, key, product_info.get(key))
643 # nb. this may convert float to decimal etc.
644 session.flush()
645 session.refresh(pending)
647 # set order info
648 row.order_qty = order_qty
649 row.order_uom = order_uom
651 # discount
652 if self.allow_item_discounts():
653 row.discount_percent = discount_percent or 0
655 # nb. this may convert float to decimal etc.
656 session.flush()
657 session.refresh(row)
659 # refresh per new info
660 self.refresh_row(row)
662 def refresh_row(self, row):
663 """
664 Refresh data for the row. This is called when adding a new
665 row to the batch, or anytime the row is updated (e.g. when
666 changing order quantity).
668 This calls one of the following to update product-related
669 attributes:
671 * :meth:`refresh_row_from_external_product()`
672 * :meth:`refresh_row_from_local_product()`
673 * :meth:`refresh_row_from_pending_product()`
675 It then re-calculates the row's
676 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price`
677 and updates the batch accordingly.
679 It also sets the row
680 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.status_code`.
681 """
682 enum = self.app.enum
683 row.status_code = None
684 row.status_text = None
686 # ensure product
687 if not row.product_id and not row.local_product and not row.pending_product:
688 row.status_code = row.STATUS_MISSING_PRODUCT
689 return
691 # ensure order qty/uom
692 if not row.order_qty or not row.order_uom:
693 row.status_code = row.STATUS_MISSING_ORDER_QTY
694 return
696 # update product attrs on row
697 if row.product_id:
698 self.refresh_row_from_external_product(row)
699 elif row.local_product:
700 self.refresh_row_from_local_product(row)
701 else:
702 self.refresh_row_from_pending_product(row)
704 # we need to know if total price changes
705 old_total = row.total_price
707 # update quoted price
708 row.unit_price_quoted = None
709 row.case_price_quoted = None
710 if row.unit_price_sale is not None and (
711 not row.sale_ends
712 or row.sale_ends > datetime.datetime.now()):
713 row.unit_price_quoted = row.unit_price_sale
714 else:
715 row.unit_price_quoted = row.unit_price_reg
716 if row.unit_price_quoted is not None and row.case_size:
717 row.case_price_quoted = row.unit_price_quoted * row.case_size
719 # update row total price
720 row.total_price = None
721 if row.order_uom == enum.ORDER_UOM_CASE:
722 # TODO: why are we not using case price again?
723 # if row.case_price_quoted:
724 # row.total_price = row.case_price_quoted * row.order_qty
725 if row.unit_price_quoted is not None and row.case_size is not None:
726 row.total_price = row.unit_price_quoted * row.case_size * row.order_qty
727 else: # ORDER_UOM_UNIT (or similar)
728 if row.unit_price_quoted is not None:
729 row.total_price = row.unit_price_quoted * row.order_qty
730 if row.total_price is not None:
731 if row.discount_percent and self.allow_item_discounts():
732 row.total_price = (float(row.total_price)
733 * (100 - float(row.discount_percent))
734 / 100.0)
735 row.total_price = decimal.Decimal(f'{row.total_price:0.2f}')
737 # update batch if total price changed
738 if row.total_price != old_total:
739 batch = row.batch
740 batch.total_price = ((batch.total_price or 0)
741 + (row.total_price or 0)
742 - (old_total or 0))
744 # all ok
745 row.status_code = row.STATUS_OK
747 def refresh_row_from_local_product(self, row):
748 """
749 Update product-related attributes on the row, from its
750 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.local_product`
751 record.
753 This is called automatically from :meth:`refresh_row()`.
754 """
755 product = row.local_product
756 row.product_scancode = product.scancode
757 row.product_brand = product.brand_name
758 row.product_description = product.description
759 row.product_size = product.size
760 row.product_weighed = product.weighed
761 row.department_id = product.department_id
762 row.department_name = product.department_name
763 row.special_order = product.special_order
764 row.case_size = product.case_size
765 row.unit_cost = product.unit_cost
766 row.unit_price_reg = product.unit_price_reg
768 def refresh_row_from_pending_product(self, row):
769 """
770 Update product-related attributes on the row, from its
771 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`
772 record.
774 This is called automatically from :meth:`refresh_row()`.
775 """
776 product = row.pending_product
777 row.product_scancode = product.scancode
778 row.product_brand = product.brand_name
779 row.product_description = product.description
780 row.product_size = product.size
781 row.product_weighed = product.weighed
782 row.department_id = product.department_id
783 row.department_name = product.department_name
784 row.special_order = product.special_order
785 row.case_size = product.case_size
786 row.unit_cost = product.unit_cost
787 row.unit_price_reg = product.unit_price_reg
789 def refresh_row_from_external_product(self, row):
790 """
791 Update product-related attributes on the row, from its
792 :term:`external product` record indicated by
793 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`.
795 This is called automatically from :meth:`refresh_row()`.
797 There is no default logic here; subclass must implement as
798 needed.
799 """
800 raise NotImplementedError
802 def remove_row(self, row):
803 """
804 Remove a row from its batch.
806 This also will update the batch
807 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.total_price`
808 accordingly.
809 """
810 if row.total_price:
811 batch = row.batch
812 batch.total_price = (batch.total_price or 0) - row.total_price
814 super().remove_row(row)
816 def do_delete(self, batch, user, **kwargs):
817 """
818 Delete a batch completely.
820 If the batch has :term:`pending customer` or :term:`pending
821 product` records, they are also deleted - unless still
822 referenced by some order(s).
823 """
824 session = self.app.get_session(batch)
826 # maybe delete pending customer
827 customer = batch.pending_customer
828 if customer and not customer.orders:
829 session.delete(customer)
831 # maybe delete pending products
832 for row in batch.rows:
833 product = row.pending_product
834 if product and not product.order_items:
835 session.delete(product)
837 # continue with normal deletion
838 super().do_delete(batch, user, **kwargs)
840 def why_not_execute(self, batch, **kwargs):
841 """
842 By default this checks to ensure the batch has a customer with
843 phone number, and at least one item.
844 """
845 if not batch.customer_name:
846 return "Must assign the customer"
848 if not batch.phone_number:
849 return "Customer phone number is required"
851 rows = self.get_effective_rows(batch)
852 if not rows:
853 return "Must add at least one valid item"
855 def get_effective_rows(self, batch):
856 """
857 Only rows with
858 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.STATUS_OK`
859 are "effective" - i.e. rows with other status codes will not
860 be created as proper order items.
861 """
862 return [row for row in batch.rows
863 if row.status_code == row.STATUS_OK]
865 def execute(self, batch, user=None, progress=None, **kwargs):
866 """
867 Execute the batch; this should make a proper :term:`order`.
869 By default, this will call:
871 * :meth:`make_local_customer()`
872 * :meth:`make_local_products()`
873 * :meth:`make_new_order()`
875 And will return the new
876 :class:`~sideshow.db.model.orders.Order` instance.
878 Note that callers should use
879 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
880 instead, which calls this method automatically.
881 """
882 rows = self.get_effective_rows(batch)
883 self.make_local_customer(batch)
884 self.make_local_products(batch, rows)
885 order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
886 return order
888 def make_local_customer(self, batch):
889 """
890 If applicable, this converts the batch :term:`pending
891 customer` into a :term:`local customer`.
893 This is called automatically from :meth:`execute()`.
895 This logic will happen only if :meth:`use_local_customers()`
896 returns true, and the batch has pending instead of local
897 customer (so far).
899 It will create a new
900 :class:`~sideshow.db.model.customers.LocalCustomer` record and
901 populate it from the batch
902 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`.
903 The latter is then deleted.
904 """
905 if not self.use_local_customers():
906 return
908 # nothing to do if no pending customer
909 pending = batch.pending_customer
910 if not pending:
911 return
913 session = self.app.get_session(batch)
915 # maybe convert pending to local customer
916 if not batch.local_customer:
917 model = self.app.model
918 inspector = sa.inspect(model.LocalCustomer)
919 local = model.LocalCustomer()
920 for prop in inspector.column_attrs:
921 if hasattr(pending, prop.key):
922 setattr(local, prop.key, getattr(pending, prop.key))
923 session.add(local)
924 batch.local_customer = local
926 # remove pending customer
927 batch.pending_customer = None
928 session.delete(pending)
929 session.flush()
931 def make_local_products(self, batch, rows):
932 """
933 If applicable, this converts all :term:`pending products
934 <pending product>` into :term:`local products <local
935 product>`.
937 This is called automatically from :meth:`execute()`.
939 This logic will happen only if :meth:`use_local_products()`
940 returns true, and the batch has pending instead of local items
941 (so far).
943 For each affected row, it will create a new
944 :class:`~sideshow.db.model.products.LocalProduct` record and
945 populate it from the row
946 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`.
947 The latter is then deleted.
948 """
949 if not self.use_local_products():
950 return
952 model = self.app.model
953 session = self.app.get_session(batch)
954 inspector = sa.inspect(model.LocalProduct)
955 for row in rows:
957 if row.local_product or not row.pending_product:
958 continue
960 pending = row.pending_product
961 local = model.LocalProduct()
963 for prop in inspector.column_attrs:
964 if hasattr(pending, prop.key):
965 setattr(local, prop.key, getattr(pending, prop.key))
966 session.add(local)
968 row.local_product = local
969 row.pending_product = None
970 session.delete(pending)
972 session.flush()
974 def make_new_order(self, batch, rows, user=None, progress=None, **kwargs):
975 """
976 Create a new :term:`order` from the batch data.
978 This is called automatically from :meth:`execute()`.
980 :param batch:
981 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
982 instance.
984 :param rows: List of effective rows for the batch, i.e. which
985 rows should be converted to :term:`order items <order
986 item>`.
988 :returns: :class:`~sideshow.db.model.orders.Order` instance.
989 """
990 model = self.app.model
991 enum = self.app.enum
992 session = self.app.get_session(batch)
994 batch_fields = [
995 'store_id',
996 'customer_id',
997 'local_customer',
998 'pending_customer',
999 'customer_name',
1000 'phone_number',
1001 'email_address',
1002 'total_price',
1003 ]
1005 row_fields = [
1006 'product_id',
1007 'local_product',
1008 'pending_product',
1009 'product_scancode',
1010 'product_brand',
1011 'product_description',
1012 'product_size',
1013 'product_weighed',
1014 'department_id',
1015 'department_name',
1016 'case_size',
1017 'order_qty',
1018 'order_uom',
1019 'unit_cost',
1020 'unit_price_quoted',
1021 'case_price_quoted',
1022 'unit_price_reg',
1023 'unit_price_sale',
1024 'sale_ends',
1025 'discount_percent',
1026 'total_price',
1027 'special_order',
1028 ]
1030 # make order
1031 kw = dict([(field, getattr(batch, field))
1032 for field in batch_fields])
1033 kw['order_id'] = batch.id
1034 kw['created_by'] = user
1035 order = model.Order(**kw)
1036 session.add(order)
1037 session.flush()
1039 def convert(row, i):
1041 # make order item
1042 kw = dict([(field, getattr(row, field))
1043 for field in row_fields])
1044 item = model.OrderItem(**kw)
1045 order.items.append(item)
1047 # set item status
1048 self.set_initial_item_status(item, user)
1050 self.app.progress_loop(convert, rows, progress,
1051 message="Converting batch rows to order items")
1052 session.flush()
1053 return order
1055 def set_initial_item_status(self, item, user, **kwargs):
1056 """
1057 Set the initial status and attach event(s) for the given item.
1059 This is called from :meth:`make_new_order()` for each item
1060 after it is added to the order.
1062 Default logic will set status to
1063 :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY` and attach 2
1064 events:
1066 * :data:`~sideshow.enum.ORDER_ITEM_EVENT_INITIATED`
1067 * :data:`~sideshow.enum.ORDER_ITEM_EVENT_READY`
1069 :param item: :class:`~sideshow.db.model.orders.OrderItem`
1070 being added to the new order.
1072 :param user:
1073 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
1074 is performing the action.
1075 """
1076 enum = self.app.enum
1077 item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user)
1078 item.add_event(enum.ORDER_ITEM_EVENT_READY, user)
1079 item.status_code = enum.ORDER_ITEM_STATUS_READY