Coverage for src/sideshow/batch/neworder.py: 100%
156 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-06 15:40 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-06 15:40 -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
30from wuttjamaican.batch import BatchHandler
32from sideshow.db.model import NewOrderBatch
35class NewOrderBatchHandler(BatchHandler):
36 """
37 The :term:`batch handler` for New Order Batches.
39 This is responsible for business logic around the creation of new
40 :term:`orders <order>`. A
41 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` tracks
42 all user input until they "submit" (execute) at which point an
43 :class:`~sideshow.db.model.orders.Order` is created.
44 """
45 model_class = NewOrderBatch
47 def set_pending_customer(self, batch, data):
48 """
49 Set (add or update) pending customer info for the batch.
51 This will clear the
52 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id`
53 and set the
54 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`,
55 creating a new record if needed. It then updates the pending
56 customer record per the given ``data``.
58 :param batch:
59 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
60 to be updated.
62 :param data: Dict of field data for the
63 :class:`~sideshow.db.model.customers.PendingCustomer`
64 record.
65 """
66 model = self.app.model
67 enum = self.app.enum
69 # remove customer account if set
70 batch.customer_id = None
72 # create pending customer if needed
73 pending = batch.pending_customer
74 if not pending:
75 kw = dict(data)
76 kw.setdefault('status', enum.PendingCustomerStatus.PENDING)
77 pending = model.PendingCustomer(**kw)
78 batch.pending_customer = pending
80 # update pending customer
81 if 'first_name' in data:
82 pending.first_name = data['first_name']
83 if 'last_name' in data:
84 pending.last_name = data['last_name']
85 if 'full_name' in data:
86 pending.full_name = data['full_name']
87 elif 'first_name' in data or 'last_name' in data:
88 pending.full_name = self.app.make_full_name(data.get('first_name'),
89 data.get('last_name'))
90 if 'phone_number' in data:
91 pending.phone_number = data['phone_number']
92 if 'email_address' in data:
93 pending.email_address = data['email_address']
95 # update batch per pending customer
96 batch.customer_name = pending.full_name
97 batch.phone_number = pending.phone_number
98 batch.email_address = pending.email_address
100 def add_pending_product(self, batch, pending_info,
101 order_qty, order_uom):
102 """
103 Add a new row to the batch, for the given "pending" product
104 and order quantity.
106 See also :meth:`set_pending_product()` to update an existing row.
108 :param batch:
109 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
110 which the row should be added.
112 :param pending_info: Dict of kwargs to use when constructing a
113 new :class:`~sideshow.db.model.products.PendingProduct`.
115 :param order_qty: Quantity of the product to be added to the
116 order.
118 :param order_uom: UOM for the order quantity; must be a code
119 from :data:`~sideshow.enum.ORDER_UOM`.
121 :returns:
122 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
123 which was added to the batch.
124 """
125 model = self.app.model
126 enum = self.app.enum
127 session = self.app.get_session(batch)
129 # make new pending product
130 kw = dict(pending_info)
131 kw.setdefault('status', enum.PendingProductStatus.PENDING)
132 product = model.PendingProduct(**kw)
133 session.add(product)
134 session.flush()
135 # nb. this may convert float to decimal etc.
136 session.refresh(product)
138 # make/add new row, w/ pending product
139 row = self.make_row(pending_product=product,
140 order_qty=order_qty, order_uom=order_uom)
141 self.add_row(batch, row)
142 session.add(row)
143 session.flush()
144 return row
146 def set_pending_product(self, row, data):
147 """
148 Set (add or update) pending product info for the given batch row.
150 This will clear the
151 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`
152 and set the
153 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`,
154 creating a new record if needed. It then updates the pending
155 product record per the given ``data``, and finally calls
156 :meth:`refresh_row()`.
158 Note that this does not update order quantity for the item.
160 See also :meth:`add_pending_product()` to add a new row
161 instead of updating.
163 :param row:
164 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
165 to be updated.
167 :param data: Dict of field data for the
168 :class:`~sideshow.db.model.products.PendingProduct` record.
169 """
170 model = self.app.model
171 enum = self.app.enum
172 session = self.app.get_session(row)
174 # values for these fields can be used as-is
175 simple_fields = [
176 'scancode',
177 'brand_name',
178 'description',
179 'size',
180 'weighed',
181 'department_id',
182 'department_name',
183 'special_order',
184 'vendor_name',
185 'vendor_item_code',
186 'notes',
187 'unit_cost',
188 'case_size',
189 'case_cost',
190 'unit_price_reg',
191 ]
193 # clear true product id
194 row.product_id = None
196 # make pending product if needed
197 product = row.pending_product
198 if not product:
199 kw = dict(data)
200 kw.setdefault('status', enum.PendingProductStatus.PENDING)
201 product = model.PendingProduct(**kw)
202 session.add(product)
203 row.pending_product = product
204 session.flush()
206 # update pending product
207 for field in simple_fields:
208 if field in data:
209 setattr(product, field, data[field])
211 # nb. this may convert float to decimal etc.
212 session.flush()
213 session.refresh(product)
215 # refresh per new info
216 self.refresh_row(row)
218 def refresh_row(self, row, now=None):
219 """
220 Refresh all data for the row. This is called when adding a
221 new row to the batch, or anytime the row is updated (e.g. when
222 changing order quantity).
224 This calls one of the following to update product-related
225 attributes for the row:
227 * :meth:`refresh_row_from_pending_product()`
228 * :meth:`refresh_row_from_true_product()`
230 It then re-calculates the row's
231 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price`
232 and updates the batch accordingly.
234 It also sets the row
235 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.status_code`.
236 """
237 enum = self.app.enum
238 row.status_code = None
239 row.status_text = None
241 # ensure product
242 if not row.product_id and not row.pending_product:
243 row.status_code = row.STATUS_MISSING_PRODUCT
244 return
246 # ensure order qty/uom
247 if not row.order_qty or not row.order_uom:
248 row.status_code = row.STATUS_MISSING_ORDER_QTY
249 return
251 # update product attrs on row
252 if row.product_id:
253 self.refresh_row_from_true_product(row)
254 else:
255 self.refresh_row_from_pending_product(row)
257 # we need to know if total price changes
258 old_total = row.total_price
260 # update quoted price
261 row.unit_price_quoted = None
262 row.case_price_quoted = None
263 if row.unit_price_sale is not None and (
264 not row.sale_ends
265 or row.sale_ends > (now or datetime.datetime.now())):
266 row.unit_price_quoted = row.unit_price_sale
267 else:
268 row.unit_price_quoted = row.unit_price_reg
269 if row.unit_price_quoted is not None and row.case_size:
270 row.case_price_quoted = row.unit_price_quoted * row.case_size
272 # update row total price
273 row.total_price = None
274 if row.order_uom == enum.ORDER_UOM_CASE:
275 if row.unit_price_quoted is not None and row.case_size is not None:
276 row.total_price = row.unit_price_quoted * row.case_size * row.order_qty
277 else: # ORDER_UOM_UNIT (or similar)
278 if row.unit_price_quoted is not None:
279 row.total_price = row.unit_price_quoted * row.order_qty
280 if row.total_price is not None:
281 row.total_price = decimal.Decimal(f'{row.total_price:0.2f}')
283 # update batch if total price changed
284 if row.total_price != old_total:
285 batch = row.batch
286 batch.total_price = ((batch.total_price or 0)
287 + (row.total_price or 0)
288 - (old_total or 0))
290 # all ok
291 row.status_code = row.STATUS_OK
293 def refresh_row_from_pending_product(self, row):
294 """
295 Update product-related attributes on the row, from its
296 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`
297 record.
299 This is called automatically from :meth:`refresh_row()`.
300 """
301 product = row.pending_product
303 row.product_scancode = product.scancode
304 row.product_brand = product.brand_name
305 row.product_description = product.description
306 row.product_size = product.size
307 row.product_weighed = product.weighed
308 row.department_id = product.department_id
309 row.department_name = product.department_name
310 row.case_size = product.case_size
311 row.unit_cost = product.unit_cost
312 row.unit_price_reg = product.unit_price_reg
314 # row.unit_price_quoted = row.unit_price_reg
315 # print(repr(row.unit_price_quoted))
316 # if isinstance(row.unit_price_quoted, float):
317 # row.unit_price_quoted = decimal.Decimal(f'{row.unit_price_quoted:0.2f}')
319 # if row.unit_price_quoted and row.case_size:
320 # row.case_price_quoted = row.unit_price_quoted * row.case_size
321 # else:
322 # row.case_price_quoted = None
324 # row.unit_price_sale = None
325 # row.sale_ends = None
327 row.special_order = product.special_order
329 def refresh_row_from_true_product(self, row):
330 """
331 Update product-related attributes on the row, from its "true"
332 product record indicated by
333 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`.
335 This is called automatically from :meth:`refresh_row()`.
337 There is no default logic here; subclass must implement as
338 needed.
339 """
341 def remove_row(self, row):
342 """
343 Remove a row from its batch.
345 This also will update the batch
346 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.total_price`
347 accordingly.
348 """
349 if row.total_price:
350 batch = row.batch
351 batch.total_price = (batch.total_price or 0) - row.total_price
353 super().remove_row(row)
355 def do_delete(self, batch, user, **kwargs):
356 """
357 Delete the given batch entirely.
359 If the batch has a
360 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
361 record, that is deleted also.
362 """
363 # maybe delete pending customer record, if it only exists for
364 # sake of this batch
365 if batch.pending_customer:
366 if len(batch.pending_customer.new_order_batches) == 1:
367 # TODO: check for past orders too
368 session = self.app.get_session(batch)
369 session.delete(batch.pending_customer)
371 # continue with normal deletion
372 super().do_delete(batch, user, **kwargs)
374 def why_not_execute(self, batch, **kwargs):
375 """
376 By default this checks to ensure the batch has a customer and
377 at least one item.
378 """
379 if not batch.customer_id and not batch.pending_customer:
380 return "Must assign the customer"
382 rows = self.get_effective_rows(batch)
383 if not rows:
384 return "Must add at least one valid item"
386 def get_effective_rows(self, batch):
387 """
388 Only rows with
389 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.STATUS_OK`
390 are "effective" - i.e. rows with other status codes will not
391 be created as proper order items.
392 """
393 return [row for row in batch.rows
394 if row.status_code == row.STATUS_OK]
396 def execute(self, batch, user=None, progress=None, **kwargs):
397 """
398 By default, this will call :meth:`make_new_order()` and return
399 the new :class:`~sideshow.db.model.orders.Order` instance.
401 Note that callers should use
402 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
403 instead, which calls this method automatically.
404 """
405 rows = self.get_effective_rows(batch)
406 order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
407 return order
409 def make_new_order(self, batch, rows, user=None, progress=None, **kwargs):
410 """
411 Create a new :term:`order` from the batch data.
413 This is called automatically from :meth:`execute()`.
415 :param batch:
416 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
417 instance.
419 :param rows: List of effective rows for the batch, i.e. which
420 rows should be converted to :term:`order items <order
421 item>`.
423 :returns: :class:`~sideshow.db.model.orders.Order` instance.
424 """
425 model = self.app.model
426 enum = self.app.enum
427 session = self.app.get_session(batch)
429 batch_fields = [
430 'store_id',
431 'customer_id',
432 'pending_customer',
433 'customer_name',
434 'phone_number',
435 'email_address',
436 'total_price',
437 ]
439 row_fields = [
440 'pending_product_uuid',
441 'product_scancode',
442 'product_brand',
443 'product_description',
444 'product_size',
445 'product_weighed',
446 'department_id',
447 'department_name',
448 'case_size',
449 'order_qty',
450 'order_uom',
451 'unit_cost',
452 'unit_price_quoted',
453 'case_price_quoted',
454 'unit_price_reg',
455 'unit_price_sale',
456 'sale_ends',
457 # 'discount_percent',
458 'total_price',
459 'special_order',
460 ]
462 # make order
463 kw = dict([(field, getattr(batch, field))
464 for field in batch_fields])
465 kw['order_id'] = batch.id
466 kw['created_by'] = user
467 order = model.Order(**kw)
468 session.add(order)
469 session.flush()
471 def convert(row, i):
473 # make order item
474 kw = dict([(field, getattr(row, field))
475 for field in row_fields])
476 item = model.OrderItem(**kw)
477 order.items.append(item)
479 # set item status
480 item.status_code = enum.ORDER_ITEM_STATUS_INITIATED
482 self.app.progress_loop(convert, rows, progress,
483 message="Converting batch rows to order items")
484 session.flush()
485 return order