Coverage for src/sideshow/batch/neworder.py: 100%
262 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-09 12:06 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-09 12:06 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# Sideshow -- Case/Special Order Tracker
5# Copyright © 2024 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.
47 """
48 model_class = NewOrderBatch
50 def use_local_customers(self):
51 """
52 Returns boolean indicating whether :term:`local customer`
53 accounts should be used. This is true by default, but may be
54 false for :term:`external customer` lookups.
55 """
56 return self.config.get_bool('sideshow.orders.use_local_customers',
57 default=True)
59 def use_local_products(self):
60 """
61 Returns boolean indicating whether :term:`local product`
62 records should be used. This is true by default, but may be
63 false for :term:`external product` lookups.
64 """
65 return self.config.get_bool('sideshow.orders.use_local_products',
66 default=True)
68 def allow_unknown_products(self):
69 """
70 Returns boolean indicating whether :term:`pending products
71 <pending product>` are allowed when creating an order.
73 This is true by default, so user can enter new/unknown product
74 when creating an order. This can be disabled, to force user
75 to choose existing local/external product.
76 """
77 return self.config.get_bool('sideshow.orders.allow_unknown_products',
78 default=True)
80 def set_customer(self, batch, customer_info, user=None):
81 """
82 Set/update customer info for the batch.
84 This will first set one of the following:
86 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id`
87 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.local_customer`
88 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
90 Note that a new
91 :class:`~sideshow.db.model.customers.PendingCustomer` record
92 is created if necessary.
94 And then it will update these accordingly:
96 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_name`
97 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.phone_number`
98 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.email_address`
100 Note that ``customer_info`` may be ``None``, which will cause
101 all the above to be set to ``None`` also.
103 :param batch:
104 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
105 update.
107 :param customer_info: Customer ID string, or dict of
108 :class:`~sideshow.db.model.customers.PendingCustomer` data,
109 or ``None`` to clear the customer info.
111 :param user:
112 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
113 is performing the action. This is used to set
114 :attr:`~sideshow.db.model.customers.PendingCustomer.created_by`
115 on the pending customer, if applicable. If not specified,
116 the batch creator is assumed.
117 """
118 model = self.app.model
119 enum = self.app.enum
120 session = self.app.get_session(batch)
121 use_local = self.use_local_customers()
123 # set customer info
124 if isinstance(customer_info, str):
125 if use_local:
127 # local_customer
128 customer = session.get(model.LocalCustomer, customer_info)
129 if not customer:
130 raise ValueError("local customer not found")
131 batch.local_customer = customer
132 batch.customer_name = customer.full_name
133 batch.phone_number = customer.phone_number
134 batch.email_address = customer.email_address
136 else: # external customer_id
137 #batch.customer_id = customer_info
138 raise NotImplementedError
140 elif customer_info:
142 # pending_customer
143 batch.customer_id = None
144 batch.local_customer = None
145 customer = batch.pending_customer
146 if not customer:
147 customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
148 created_by=user or batch.created_by)
149 session.add(customer)
150 batch.pending_customer = customer
151 fields = [
152 'full_name',
153 'first_name',
154 'last_name',
155 'phone_number',
156 'email_address',
157 ]
158 for key in fields:
159 setattr(customer, key, customer_info.get(key))
160 if 'full_name' not in customer_info:
161 customer.full_name = self.app.make_full_name(customer.first_name,
162 customer.last_name)
163 batch.customer_name = customer.full_name
164 batch.phone_number = customer.phone_number
165 batch.email_address = customer.email_address
167 else:
169 # null
170 batch.customer_id = None
171 batch.local_customer = None
172 batch.customer_name = None
173 batch.phone_number = None
174 batch.email_address = None
176 session.flush()
178 def add_item(self, batch, product_info, order_qty, order_uom, user=None):
179 """
180 Add a new item/row to the batch, for given product and quantity.
182 See also :meth:`update_item()`.
184 :param batch:
185 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
186 update.
188 :param product_info: Product ID string, or dict of
189 :class:`~sideshow.db.model.products.PendingProduct` data.
191 :param order_qty:
192 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty`
193 value for the new row.
195 :param order_uom:
196 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
197 value for the new row.
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.products.PendingProduct.created_by`
203 on the pending product, if applicable. If not specified,
204 the batch creator is assumed.
206 :returns:
207 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
208 instance.
209 """
210 model = self.app.model
211 enum = self.app.enum
212 session = self.app.get_session(batch)
213 use_local = self.use_local_products()
214 row = self.make_row()
216 # set product info
217 if isinstance(product_info, str):
218 if use_local:
220 # local_product
221 local = session.get(model.LocalProduct, product_info)
222 if not local:
223 raise ValueError("local product not found")
224 row.local_product = local
226 else: # external product_id
227 #row.product_id = product_info
228 raise NotImplementedError
230 else:
231 # pending_product
232 if not self.allow_unknown_products():
233 raise TypeError("unknown/pending product not allowed for new orders")
234 row.product_id = None
235 row.local_product = None
236 pending = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
237 created_by=user or batch.created_by)
238 fields = [
239 'scancode',
240 'brand_name',
241 'description',
242 'size',
243 'weighed',
244 'department_id',
245 'department_name',
246 'special_order',
247 'vendor_name',
248 'vendor_item_code',
249 'case_size',
250 'unit_cost',
251 'unit_price_reg',
252 'notes',
253 ]
254 for key in fields:
255 setattr(pending, key, product_info.get(key))
257 # nb. this may convert float to decimal etc.
258 session.add(pending)
259 session.flush()
260 session.refresh(pending)
261 row.pending_product = pending
263 # set order info
264 row.order_qty = order_qty
265 row.order_uom = order_uom
267 # add row to batch
268 self.add_row(batch, row)
269 session.flush()
270 return row
272 def update_item(self, row, product_info, order_qty, order_uom, user=None):
273 """
274 Update an item/row, per given product and quantity.
276 See also :meth:`add_item()`.
278 :param row:
279 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
280 to update.
282 :param product_info: Product ID string, or dict of
283 :class:`~sideshow.db.model.products.PendingProduct` data.
285 :param order_qty: New
286 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty`
287 value for the row.
289 :param order_uom: New
290 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
291 value for the row.
293 :param user:
294 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
295 is performing the action. This is used to set
296 :attr:`~sideshow.db.model.products.PendingProduct.created_by`
297 on the pending product, if applicable. If not specified,
298 the batch creator is assumed.
299 """
300 model = self.app.model
301 enum = self.app.enum
302 session = self.app.get_session(row)
303 use_local = self.use_local_products()
305 # set product info
306 if isinstance(product_info, str):
307 if use_local:
309 # local_product
310 local = session.get(model.LocalProduct, product_info)
311 if not local:
312 raise ValueError("local product not found")
313 row.local_product = local
315 else: # external product_id
316 #row.product_id = product_info
317 raise NotImplementedError
319 else:
320 # pending_product
321 if not self.allow_unknown_products():
322 raise TypeError("unknown/pending product not allowed for new orders")
323 row.product_id = None
324 row.local_product = None
325 pending = row.pending_product
326 if not pending:
327 pending = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
328 created_by=user or row.batch.created_by)
329 session.add(pending)
330 row.pending_product = pending
331 fields = [
332 'scancode',
333 'brand_name',
334 'description',
335 'size',
336 'weighed',
337 'department_id',
338 'department_name',
339 'special_order',
340 'vendor_name',
341 'vendor_item_code',
342 'case_size',
343 'unit_cost',
344 'unit_price_reg',
345 'notes',
346 ]
347 for key in fields:
348 setattr(pending, key, product_info.get(key))
350 # nb. this may convert float to decimal etc.
351 session.flush()
352 session.refresh(pending)
354 # set order info
355 row.order_qty = order_qty
356 row.order_uom = order_uom
358 # nb. this may convert float to decimal etc.
359 session.flush()
360 session.refresh(row)
362 # refresh per new info
363 self.refresh_row(row)
365 def refresh_row(self, row):
366 """
367 Refresh data for the row. This is called when adding a new
368 row to the batch, or anytime the row is updated (e.g. when
369 changing order quantity).
371 This calls one of the following to update product-related
372 attributes:
374 * :meth:`refresh_row_from_external_product()`
375 * :meth:`refresh_row_from_local_product()`
376 * :meth:`refresh_row_from_pending_product()`
378 It then re-calculates the row's
379 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price`
380 and updates the batch accordingly.
382 It also sets the row
383 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.status_code`.
384 """
385 enum = self.app.enum
386 row.status_code = None
387 row.status_text = None
389 # ensure product
390 if not row.product_id and not row.local_product and not row.pending_product:
391 row.status_code = row.STATUS_MISSING_PRODUCT
392 return
394 # ensure order qty/uom
395 if not row.order_qty or not row.order_uom:
396 row.status_code = row.STATUS_MISSING_ORDER_QTY
397 return
399 # update product attrs on row
400 if row.product_id:
401 self.refresh_row_from_external_product(row)
402 elif row.local_product:
403 self.refresh_row_from_local_product(row)
404 else:
405 self.refresh_row_from_pending_product(row)
407 # we need to know if total price changes
408 old_total = row.total_price
410 # update quoted price
411 row.unit_price_quoted = None
412 row.case_price_quoted = None
413 if row.unit_price_sale is not None and (
414 not row.sale_ends
415 or row.sale_ends > datetime.datetime.now()):
416 row.unit_price_quoted = row.unit_price_sale
417 else:
418 row.unit_price_quoted = row.unit_price_reg
419 if row.unit_price_quoted is not None and row.case_size:
420 row.case_price_quoted = row.unit_price_quoted * row.case_size
422 # update row total price
423 row.total_price = None
424 if row.order_uom == enum.ORDER_UOM_CASE:
425 if row.unit_price_quoted is not None and row.case_size is not None:
426 row.total_price = row.unit_price_quoted * row.case_size * row.order_qty
427 else: # ORDER_UOM_UNIT (or similar)
428 if row.unit_price_quoted is not None:
429 row.total_price = row.unit_price_quoted * row.order_qty
430 if row.total_price is not None:
431 row.total_price = decimal.Decimal(f'{row.total_price:0.2f}')
433 # update batch if total price changed
434 if row.total_price != old_total:
435 batch = row.batch
436 batch.total_price = ((batch.total_price or 0)
437 + (row.total_price or 0)
438 - (old_total or 0))
440 # all ok
441 row.status_code = row.STATUS_OK
443 def refresh_row_from_local_product(self, row):
444 """
445 Update product-related attributes on the row, from its
446 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.local_product`
447 record.
449 This is called automatically from :meth:`refresh_row()`.
450 """
451 product = row.local_product
452 row.product_scancode = product.scancode
453 row.product_brand = product.brand_name
454 row.product_description = product.description
455 row.product_size = product.size
456 row.product_weighed = product.weighed
457 row.department_id = product.department_id
458 row.department_name = product.department_name
459 row.special_order = product.special_order
460 row.case_size = product.case_size
461 row.unit_cost = product.unit_cost
462 row.unit_price_reg = product.unit_price_reg
464 def refresh_row_from_pending_product(self, row):
465 """
466 Update product-related attributes on the row, from its
467 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`
468 record.
470 This is called automatically from :meth:`refresh_row()`.
471 """
472 product = row.pending_product
473 row.product_scancode = product.scancode
474 row.product_brand = product.brand_name
475 row.product_description = product.description
476 row.product_size = product.size
477 row.product_weighed = product.weighed
478 row.department_id = product.department_id
479 row.department_name = product.department_name
480 row.special_order = product.special_order
481 row.case_size = product.case_size
482 row.unit_cost = product.unit_cost
483 row.unit_price_reg = product.unit_price_reg
485 def refresh_row_from_external_product(self, row):
486 """
487 Update product-related attributes on the row, from its
488 :term:`external product` record indicated by
489 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`.
491 This is called automatically from :meth:`refresh_row()`.
493 There is no default logic here; subclass must implement as
494 needed.
495 """
497 def remove_row(self, row):
498 """
499 Remove a row from its batch.
501 This also will update the batch
502 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.total_price`
503 accordingly.
504 """
505 if row.total_price:
506 batch = row.batch
507 batch.total_price = (batch.total_price or 0) - row.total_price
509 super().remove_row(row)
511 def do_delete(self, batch, user, **kwargs):
512 """
513 Delete a batch completely.
515 If the batch has :term:`pending customer` or :term:`pending
516 product` records, they are also deleted - unless still
517 referenced by some order(s).
518 """
519 session = self.app.get_session(batch)
521 # maybe delete pending customer
522 customer = batch.pending_customer
523 if customer and not customer.orders:
524 session.delete(customer)
526 # maybe delete pending products
527 for row in batch.rows:
528 product = row.pending_product
529 if product and not product.order_items:
530 session.delete(product)
532 # continue with normal deletion
533 super().do_delete(batch, user, **kwargs)
535 def why_not_execute(self, batch, **kwargs):
536 """
537 By default this checks to ensure the batch has a customer with
538 phone number, and at least one item.
539 """
540 if not batch.customer_name:
541 return "Must assign the customer"
543 if not batch.phone_number:
544 return "Customer phone number is required"
546 rows = self.get_effective_rows(batch)
547 if not rows:
548 return "Must add at least one valid item"
550 def get_effective_rows(self, batch):
551 """
552 Only rows with
553 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.STATUS_OK`
554 are "effective" - i.e. rows with other status codes will not
555 be created as proper order items.
556 """
557 return [row for row in batch.rows
558 if row.status_code == row.STATUS_OK]
560 def execute(self, batch, user=None, progress=None, **kwargs):
561 """
562 Execute the batch; this should make a proper :term:`order`.
564 By default, this will call:
566 * :meth:`make_local_customer()`
567 * :meth:`make_local_products()`
568 * :meth:`make_new_order()`
570 And will return the new
571 :class:`~sideshow.db.model.orders.Order` instance.
573 Note that callers should use
574 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
575 instead, which calls this method automatically.
576 """
577 rows = self.get_effective_rows(batch)
578 self.make_local_customer(batch)
579 self.make_local_products(batch, rows)
580 order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
581 return order
583 def make_local_customer(self, batch):
584 """
585 If applicable, this converts the batch :term:`pending
586 customer` into a :term:`local customer`.
588 This is called automatically from :meth:`execute()`.
590 This logic will happen only if :meth:`use_local_customers()`
591 returns true, and the batch has pending instead of local
592 customer (so far).
594 It will create a new
595 :class:`~sideshow.db.model.customers.LocalCustomer` record and
596 populate it from the batch
597 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`.
598 The latter is then deleted.
599 """
600 if not self.use_local_customers():
601 return
603 # nothing to do if no pending customer
604 pending = batch.pending_customer
605 if not pending:
606 return
608 session = self.app.get_session(batch)
610 # maybe convert pending to local customer
611 if not batch.local_customer:
612 model = self.app.model
613 inspector = sa.inspect(model.LocalCustomer)
614 local = model.LocalCustomer()
615 for prop in inspector.column_attrs:
616 if hasattr(pending, prop.key):
617 setattr(local, prop.key, getattr(pending, prop.key))
618 session.add(local)
619 batch.local_customer = local
621 # remove pending customer
622 batch.pending_customer = None
623 session.delete(pending)
624 session.flush()
626 def make_local_products(self, batch, rows):
627 """
628 If applicable, this converts all :term:`pending products
629 <pending product>` into :term:`local products <local
630 product>`.
632 This is called automatically from :meth:`execute()`.
634 This logic will happen only if :meth:`use_local_products()`
635 returns true, and the batch has pending instead of local items
636 (so far).
638 For each affected row, it will create a new
639 :class:`~sideshow.db.model.products.LocalProduct` record and
640 populate it from the row
641 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`.
642 The latter is then deleted.
643 """
644 if not self.use_local_products():
645 return
647 model = self.app.model
648 session = self.app.get_session(batch)
649 inspector = sa.inspect(model.LocalProduct)
650 for row in rows:
652 if row.local_product or not row.pending_product:
653 continue
655 pending = row.pending_product
656 local = model.LocalProduct()
658 for prop in inspector.column_attrs:
659 if hasattr(pending, prop.key):
660 setattr(local, prop.key, getattr(pending, prop.key))
661 session.add(local)
663 row.local_product = local
664 row.pending_product = None
665 session.delete(pending)
667 session.flush()
669 def make_new_order(self, batch, rows, user=None, progress=None, **kwargs):
670 """
671 Create a new :term:`order` from the batch data.
673 This is called automatically from :meth:`execute()`.
675 :param batch:
676 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
677 instance.
679 :param rows: List of effective rows for the batch, i.e. which
680 rows should be converted to :term:`order items <order
681 item>`.
683 :returns: :class:`~sideshow.db.model.orders.Order` instance.
684 """
685 model = self.app.model
686 enum = self.app.enum
687 session = self.app.get_session(batch)
689 batch_fields = [
690 'store_id',
691 'customer_id',
692 'local_customer',
693 'pending_customer',
694 'customer_name',
695 'phone_number',
696 'email_address',
697 'total_price',
698 ]
700 row_fields = [
701 'product_id',
702 'local_product',
703 'pending_product',
704 'product_scancode',
705 'product_brand',
706 'product_description',
707 'product_size',
708 'product_weighed',
709 'department_id',
710 'department_name',
711 'case_size',
712 'order_qty',
713 'order_uom',
714 'unit_cost',
715 'unit_price_quoted',
716 'case_price_quoted',
717 'unit_price_reg',
718 'unit_price_sale',
719 'sale_ends',
720 # 'discount_percent',
721 'total_price',
722 'special_order',
723 ]
725 # make order
726 kw = dict([(field, getattr(batch, field))
727 for field in batch_fields])
728 kw['order_id'] = batch.id
729 kw['created_by'] = user
730 order = model.Order(**kw)
731 session.add(order)
732 session.flush()
734 def convert(row, i):
736 # make order item
737 kw = dict([(field, getattr(row, field))
738 for field in row_fields])
739 item = model.OrderItem(**kw)
740 order.items.append(item)
742 # set item status
743 item.status_code = enum.ORDER_ITEM_STATUS_INITIATED
745 self.app.progress_loop(convert, rows, progress,
746 message="Converting batch rows to order items")
747 session.flush()
748 return order