Coverage for .tox / coverage / lib / python3.11 / site-packages / sideshow / orders.py: 100%
115 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-15 17:10 -0600
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-15 17:10 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# Sideshow -- Case/Special Order Tracker
5# Copyright © 2024-2025 Lance Edgar
6#
7# This file is part of Sideshow.
8#
9# Sideshow is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# Sideshow is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17# General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Sideshow Order Handler
25"""
27from wuttjamaican.app import GenericHandler
30class OrderHandler(GenericHandler):
31 """
32 Base class and default implementation for the :term:`order
33 handler`.
35 This is responsible for business logic involving customer orders
36 after they have been first created. (The :term:`new order batch`
37 handler is responsible for creation logic.)
38 """
40 def expose_store_id(self):
41 """
42 Returns boolean indicating whether the ``store_id`` field
43 should be exposed at all. This is false by default.
44 """
45 return self.config.get_bool("sideshow.orders.expose_store_id", default=False)
47 def get_order_qty_uom_text(self, order_qty, order_uom, case_size=None, html=False):
48 """
49 Return the display text for a given order quantity.
51 Default logic will return something like ``"3 Cases (x 6 = 18
52 Units)"``.
54 :param order_qty: Numeric quantity.
56 :param order_uom: An order UOM constant; should be something
57 from :data:`~sideshow.enum.ORDER_UOM`.
59 :param case_size: Case size for the product, if known.
61 :param html: Whether the return value should include any HTML.
62 If false (the default), it will be plain text only. If
63 true, will replace the ``x`` character with ``×``.
65 :returns: Display text.
66 """
67 enum = self.app.enum
69 if order_uom == enum.ORDER_UOM_CASE:
70 if case_size is None:
71 case_qty = unit_qty = "??"
72 else:
73 case_qty = self.app.render_quantity(case_size)
74 unit_qty = self.app.render_quantity(order_qty * case_size)
75 CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE] # pylint: disable=invalid-name
76 EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] # pylint: disable=invalid-name
77 order_qty = self.app.render_quantity(order_qty)
78 times = "×" if html else "x"
79 return f"{order_qty} {CS} ({times} {case_qty} = {unit_qty} {EA})"
81 # units
82 unit_qty = self.app.render_quantity(order_qty)
83 EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] # pylint: disable=invalid-name
84 return f"{unit_qty} {EA}"
86 def item_status_to_variant(self, status_code):
87 """
88 Return a Buefy style variant for the given status code.
90 Default logic will return ``None`` for "normal" item status,
91 but may return ``'warning'`` for some (e.g. canceled).
93 :param status_code: The status code for an order item.
95 :returns: Style variant string (e.g. ``'warning'``) or
96 ``None``.
97 """
98 enum = self.app.enum
99 if status_code in (
100 enum.ORDER_ITEM_STATUS_CANCELED,
101 enum.ORDER_ITEM_STATUS_REFUND_PENDING,
102 enum.ORDER_ITEM_STATUS_REFUNDED,
103 enum.ORDER_ITEM_STATUS_RESTOCKED,
104 enum.ORDER_ITEM_STATUS_EXPIRED,
105 enum.ORDER_ITEM_STATUS_INACTIVE,
106 ):
107 return "warning"
108 return None
110 def resolve_pending_product(self, pending_product, product_info, user, note=None):
111 """
112 Resolve a :term:`pending product`, to reflect the given
113 product info.
115 At a high level this does 2 things:
117 * update the ``pending_product``
118 * find and update any related :term:`order item(s) <order item>`
120 The first step just sets
121 :attr:`~sideshow.db.model.products.PendingProduct.product_id`
122 from the provided info, and gives it the "resolved" status.
123 Note that it does *not* update the pending product record
124 further, so it will not fully "match" the product info.
126 The second step will fetch all
127 :class:`~sideshow.db.model.orders.OrderItem` records which
128 reference the ``pending_product`` **and** which do not yet
129 have a ``product_id`` value. For each, it then updates the
130 order item to contain all data from ``product_info``. And
131 finally, it adds an event to the item history, indicating who
132 resolved and when. (If ``note`` is specified, a *second*
133 event is added for that.)
135 :param pending_product:
136 :class:`~sideshow.db.model.products.PendingProduct` to be
137 resolved.
139 :param product_info: Dict of product info, as obtained from
140 :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`.
142 :param user:
143 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
144 is performing the action.
146 :param note: Optional note to be added to event history for
147 related order item(s).
148 """
149 enum = self.app.enum
150 model = self.app.model
151 session = self.app.get_session(pending_product)
153 if pending_product.status != enum.PendingProductStatus.READY:
154 raise ValueError("pending product does not have 'ready' status")
156 info = product_info
157 pending_product.product_id = info["product_id"]
158 pending_product.status = enum.PendingProductStatus.RESOLVED
160 items = (
161 session.query(model.OrderItem)
162 .filter(model.OrderItem.pending_product == pending_product)
163 .filter(
164 model.OrderItem.product_id # pylint: disable=singleton-comparison
165 == None
166 )
167 .all()
168 )
170 for item in items:
171 item.product_id = info["product_id"]
172 item.product_scancode = info["scancode"]
173 item.product_brand = info["brand_name"]
174 item.product_description = info["description"]
175 item.product_size = info["size"]
176 item.product_weighed = info["weighed"]
177 item.department_id = info["department_id"]
178 item.department_name = info["department_name"]
179 item.special_order = info["special_order"]
180 item.vendor_name = info["vendor_name"]
181 item.vendor_item_code = info["vendor_item_code"]
182 item.case_size = info["case_size"]
183 item.unit_cost = info["unit_cost"]
184 item.unit_price_reg = info["unit_price_reg"]
186 item.add_event(enum.ORDER_ITEM_EVENT_PRODUCT_RESOLVED, user)
187 if note:
188 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
190 def process_placement( # pylint: disable=too-many-arguments,too-many-positional-arguments
191 self, items, user, vendor_name=None, po_number=None, note=None
192 ):
193 """
194 Process the "placement" step for the given order items.
196 This may eventually do something involving an *actual*
197 purchase order, or at least a minimal representation of one,
198 but for now it does not.
200 Instead, this will simply update each item to indicate its new
201 status. A note will be attached to indicate the vendor and/or
202 PO number, if provided.
204 :param items: Sequence of
205 :class:`~sideshow.db.model.orders.OrderItem` records.
207 :param user:
208 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
209 performing the action.
211 :param vendor_name: Name of the vendor to which purchase order
212 is placed, if known.
214 :param po_number: Purchase order number, if known.
216 :param note: Optional *additional* note to be attached to each
217 order item.
218 """
219 enum = self.app.enum
221 placed = None
222 if vendor_name:
223 placed = f"PO {po_number or ''} for vendor {vendor_name}"
224 elif po_number:
225 placed = f"PO {po_number}"
227 for item in items:
228 item.add_event(enum.ORDER_ITEM_EVENT_PLACED, user, note=placed)
229 if note:
230 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
231 item.status_code = enum.ORDER_ITEM_STATUS_PLACED
233 def process_receiving( # pylint: disable=too-many-arguments,too-many-positional-arguments
234 self,
235 items,
236 user,
237 vendor_name=None,
238 invoice_number=None,
239 po_number=None,
240 note=None,
241 ):
242 """
243 Process the "receiving" step for the given order items.
245 This will update the status for each item, to indicate they
246 are "received".
248 TODO: This also should email the customer notifying their
249 items are ready for pickup etc.
251 :param items: Sequence of
252 :class:`~sideshow.db.model.orders.OrderItem` records.
254 :param user:
255 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
256 performing the action.
258 :param vendor_name: Name of the vendor, if known.
260 :param po_number: Purchase order number, if known.
262 :param invoice_number: Invoice number, if known.
264 :param note: Optional *additional* note to be attached to each
265 order item.
266 """
267 enum = self.app.enum
269 received = None
270 if invoice_number and po_number and vendor_name:
271 received = (
272 f"invoice {invoice_number} (PO {po_number}) from vendor {vendor_name}"
273 )
274 elif invoice_number and vendor_name:
275 received = f"invoice {invoice_number} from vendor {vendor_name}"
276 elif po_number and vendor_name:
277 received = f"PO {po_number} from vendor {vendor_name}"
278 elif vendor_name:
279 received = f"from vendor {vendor_name}"
281 for item in items:
282 item.add_event(enum.ORDER_ITEM_EVENT_RECEIVED, user, note=received)
283 if note:
284 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
285 item.status_code = enum.ORDER_ITEM_STATUS_RECEIVED
287 def process_reorder(self, items, user, note=None):
288 """
289 Process the "reorder" step for the given order items.
291 This will update the status for each item, to indicate they
292 are "ready" (again) for placement.
294 :param items: Sequence of
295 :class:`~sideshow.db.model.orders.OrderItem` records.
297 :param user:
298 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
299 performing the action.
301 :param note: Optional *additional* note to be attached to each
302 order item.
303 """
304 enum = self.app.enum
306 for item in items:
307 item.add_event(enum.ORDER_ITEM_EVENT_REORDER, user)
308 if note:
309 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
310 item.status_code = enum.ORDER_ITEM_STATUS_READY
312 def process_contact_success(self, items, user, note=None):
313 """
314 Process the "successful contact" step for the given order
315 items.
317 This will update the status for each item, to indicate they
318 are "contacted" and awaiting delivery.
320 :param items: Sequence of
321 :class:`~sideshow.db.model.orders.OrderItem` records.
323 :param user:
324 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
325 performing the action.
327 :param note: Optional *additional* note to be attached to each
328 order item.
329 """
330 enum = self.app.enum
332 for item in items:
333 item.add_event(enum.ORDER_ITEM_EVENT_CONTACTED, user)
334 if note:
335 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
336 item.status_code = enum.ORDER_ITEM_STATUS_CONTACTED
338 def process_contact_failure(self, items, user, note=None):
339 """
340 Process the "failed contact" step for the given order items.
342 This will update the status for each item, to indicate
343 "contact failed".
345 :param items: Sequence of
346 :class:`~sideshow.db.model.orders.OrderItem` records.
348 :param user:
349 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
350 performing the action.
352 :param note: Optional *additional* note to be attached to each
353 order item.
354 """
355 enum = self.app.enum
357 for item in items:
358 item.add_event(enum.ORDER_ITEM_EVENT_CONTACT_FAILED, user)
359 if note:
360 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
361 item.status_code = enum.ORDER_ITEM_STATUS_CONTACT_FAILED
363 def process_delivery(self, items, user, note=None):
364 """
365 Process the "delivery" step for the given order items.
367 This will update the status for each item, to indicate they
368 are "delivered".
370 :param items: Sequence of
371 :class:`~sideshow.db.model.orders.OrderItem` records.
373 :param user:
374 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
375 performing the action.
377 :param note: Optional *additional* note to be attached to each
378 order item.
379 """
380 enum = self.app.enum
382 for item in items:
383 item.add_event(enum.ORDER_ITEM_EVENT_DELIVERED, user)
384 if note:
385 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
386 item.status_code = enum.ORDER_ITEM_STATUS_DELIVERED
388 def process_restock(self, items, user, note=None):
389 """
390 Process the "restock" step for the given order items.
392 This will update the status for each item, to indicate they
393 are "restocked".
395 :param items: Sequence of
396 :class:`~sideshow.db.model.orders.OrderItem` records.
398 :param user:
399 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
400 performing the action.
402 :param note: Optional *additional* note to be attached to each
403 order item.
404 """
405 enum = self.app.enum
407 for item in items:
408 item.add_event(enum.ORDER_ITEM_EVENT_RESTOCKED, user)
409 if note:
410 item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
411 item.status_code = enum.ORDER_ITEM_STATUS_RESTOCKED