Coverage for .tox / coverage / lib / python3.11 / site-packages / sideshow / db / model / orders.py: 100%
86 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"""
24Data models for Orders
25"""
27import sqlalchemy as sa
28from sqlalchemy import orm
29from sqlalchemy.ext.orderinglist import ordering_list
31from wuttjamaican.db import model
32from wuttjamaican.util import make_utc
35class OrderMixin: # pylint: disable=too-few-public-methods
36 """
37 Mixin class providing common columns for orders and new order
38 batches.
39 """
41 store_id = sa.Column(
42 sa.String(length=10),
43 nullable=True,
44 doc="""
45 ID of the store to which the order pertains, if applicable.
46 """,
47 )
49 customer_id = sa.Column(
50 sa.String(length=20),
51 nullable=True,
52 doc="""
53 Proper account ID for the :term:`external customer` to which the
54 order pertains, if applicable.
56 See also :attr:`local_customer` and :attr:`pending_customer`.
57 """,
58 )
60 customer_name = sa.Column(
61 sa.String(length=100),
62 nullable=True,
63 doc="""
64 Name for the customer account.
65 """,
66 )
68 phone_number = sa.Column(
69 sa.String(length=20),
70 nullable=True,
71 doc="""
72 Phone number for the customer.
73 """,
74 )
76 email_address = sa.Column(
77 sa.String(length=255),
78 nullable=True,
79 doc="""
80 Email address for the customer.
81 """,
82 )
84 total_price = sa.Column(
85 sa.Numeric(precision=10, scale=3),
86 nullable=True,
87 doc="""
88 Full price (not including tax etc.) for all items on the order.
89 """,
90 )
93class OrderItemMixin: # pylint: disable=too-few-public-methods
94 """
95 Mixin class providing common columns for order items and new order
96 batch rows.
97 """
99 product_id = sa.Column(
100 sa.String(length=20),
101 nullable=True,
102 doc="""
103 Proper ID for the :term:`external product` which the order item
104 represents, if applicable.
106 See also :attr:`local_product` and :attr:`pending_product`.
107 """,
108 )
110 product_scancode = sa.Column(
111 sa.String(length=14),
112 nullable=True,
113 doc="""
114 Scancode for the product, as string.
116 .. note::
118 This column allows 14 chars, so can store a full GPC with check
119 digit. However as of writing the actual format used here does
120 not matter to Sideshow logic; "anything" should work.
122 That may change eventually, depending on POS integration
123 scenarios that come up. Maybe a config option to declare
124 whether check digit should be included or not, etc.
125 """,
126 )
128 product_brand = sa.Column(
129 sa.String(length=100),
130 nullable=True,
131 doc="""
132 Brand name for the product - up to 100 chars.
133 """,
134 )
136 product_description = sa.Column(
137 sa.String(length=255),
138 nullable=True,
139 doc="""
140 Description for the product - up to 255 chars.
141 """,
142 )
144 product_size = sa.Column(
145 sa.String(length=30),
146 nullable=True,
147 doc="""
148 Size of the product, as string - up to 30 chars.
149 """,
150 )
152 product_weighed = sa.Column(
153 sa.Boolean(),
154 nullable=True,
155 doc="""
156 Flag indicating the product is sold by weight; default is null.
157 """,
158 )
160 department_id = sa.Column(
161 sa.String(length=10),
162 nullable=True,
163 doc="""
164 ID of the department to which the product belongs, if known.
165 """,
166 )
168 department_name = sa.Column(
169 sa.String(length=30),
170 nullable=True,
171 doc="""
172 Name of the department to which the product belongs, if known.
173 """,
174 )
176 special_order = sa.Column(
177 sa.Boolean(),
178 nullable=True,
179 doc="""
180 Flag indicating the item is a "special order" - e.g. something not
181 normally carried by the store. Default is null.
182 """,
183 )
185 vendor_name = sa.Column(
186 sa.String(length=50),
187 nullable=True,
188 doc="""
189 Name of vendor from which product may be purchased, if known. See
190 also :attr:`vendor_item_code`.
191 """,
192 )
194 vendor_item_code = sa.Column(
195 sa.String(length=20),
196 nullable=True,
197 doc="""
198 Item code (SKU) to use when ordering this product from the vendor
199 identified by :attr:`vendor_name`, if known.
200 """,
201 )
203 case_size = sa.Column(
204 sa.Numeric(precision=10, scale=4),
205 nullable=True,
206 doc="""
207 Case pack count for the product, if known.
209 If this is not set, then customer cannot order a "case" of the item.
210 """,
211 )
213 order_qty = sa.Column(
214 sa.Numeric(precision=10, scale=4),
215 nullable=False,
216 doc="""
217 Quantity (as decimal) of product being ordered.
219 This must be interpreted along with :attr:`order_uom` to determine
220 the *complete* order quantity, e.g. "2 cases".
221 """,
222 )
224 order_uom = sa.Column(
225 sa.String(length=10),
226 nullable=False,
227 doc="""
228 Code indicating the unit of measure for product being ordered.
230 This should be one of the codes from
231 :data:`~sideshow.enum.ORDER_UOM`.
233 Sideshow will treat :data:`~sideshow.enum.ORDER_UOM_CASE`
234 differently but :data:`~sideshow.enum.ORDER_UOM_UNIT` and others
235 are all treated the same (i.e. "unit" is assumed).
236 """,
237 )
239 unit_cost = sa.Column(
240 sa.Numeric(precision=9, scale=5),
241 nullable=True,
242 doc="""
243 Cost of goods amount for one "unit" (not "case") of the product,
244 as decimal to 4 places.
245 """,
246 )
248 unit_price_reg = sa.Column(
249 sa.Numeric(precision=8, scale=3),
250 nullable=True,
251 doc="""
252 Regular price for the item unit. Unless a sale is in effect,
253 :attr:`unit_price_quoted` will typically match this value.
254 """,
255 )
257 unit_price_sale = sa.Column(
258 sa.Numeric(precision=8, scale=3),
259 nullable=True,
260 doc="""
261 Sale price for the item unit, if applicable. If set, then
262 :attr:`unit_price_quoted` will typically match this value. See
263 also :attr:`sale_ends`.
264 """,
265 )
267 sale_ends = sa.Column(
268 sa.DateTime(),
269 nullable=True,
270 doc="""
271 End date/time for the sale in effect, if any.
273 This is only relevant if :attr:`unit_price_sale` is set.
274 """,
275 )
277 unit_price_quoted = sa.Column(
278 sa.Numeric(precision=8, scale=3),
279 nullable=True,
280 doc="""
281 Quoted price for the item unit. This is the "effective" unit
282 price, which is used to calculate :attr:`total_price`.
284 This price does *not* reflect the :attr:`discount_percent`. It
285 normally should match either :attr:`unit_price_reg` or
286 :attr:`unit_price_sale`.
288 See also :attr:`case_price_quoted`, if applicable.
289 """,
290 )
292 case_price_quoted = sa.Column(
293 sa.Numeric(precision=8, scale=3),
294 nullable=True,
295 doc="""
296 Quoted price for a "case" of the item, if applicable.
298 This is mostly for display purposes; :attr:`unit_price_quoted` is
299 used for calculations.
300 """,
301 )
303 discount_percent = sa.Column(
304 sa.Numeric(precision=5, scale=3),
305 nullable=True,
306 doc="""
307 Discount percent to apply when calculating :attr:`total_price`, if
308 applicable.
309 """,
310 )
312 total_price = sa.Column(
313 sa.Numeric(precision=8, scale=3),
314 nullable=True,
315 doc="""
316 Full price (not including tax etc.) which the customer is quoted
317 for the order item.
319 This is calculated using values from:
321 * :attr:`unit_price_quoted`
322 * :attr:`order_qty`
323 * :attr:`order_uom`
324 * :attr:`case_size`
325 * :attr:`discount_percent`
326 """,
327 )
330class Order( # pylint: disable=too-few-public-methods,duplicate-code
331 OrderMixin, model.Base
332):
333 """
334 Represents an :term:`order` for a customer. Each order has one or
335 more :attr:`items`.
337 Usually, orders are created by way of a
338 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`.
339 """
341 __tablename__ = "sideshow_order"
343 # TODO: this feels a bit hacky yet but it does avoid problems
344 # showing the Orders grid for a PendingCustomer
345 __colanderalchemy_config__ = {
346 "excludes": ["items"],
347 }
349 uuid = model.uuid_column()
351 order_id = sa.Column(
352 sa.Integer(),
353 nullable=False,
354 doc="""
355 Unique ID for the order.
357 When the order is created from New Order Batch, this order ID will
358 match the batch ID.
359 """,
360 )
362 store = orm.relationship(
363 "Store",
364 primaryjoin="Store.store_id == Order.store_id",
365 foreign_keys="Order.store_id",
366 doc="""
367 Reference to the :class:`~sideshow.db.model.stores.Store`
368 record, if applicable.
369 """,
370 )
372 local_customer_uuid = model.uuid_fk_column(
373 "sideshow_customer_local.uuid", nullable=True
374 )
375 local_customer = orm.relationship(
376 "LocalCustomer",
377 cascade_backrefs=False,
378 back_populates="orders",
379 doc="""
380 Reference to the
381 :class:`~sideshow.db.model.customers.LocalCustomer` record
382 for the order, if applicable.
384 See also :attr:`customer_id` and :attr:`pending_customer`.
385 """,
386 )
388 pending_customer_uuid = model.uuid_fk_column(
389 "sideshow_customer_pending.uuid", nullable=True
390 )
391 pending_customer = orm.relationship(
392 "PendingCustomer",
393 cascade_backrefs=False,
394 back_populates="orders",
395 doc="""
396 Reference to the
397 :class:`~sideshow.db.model.customers.PendingCustomer` record
398 for the order, if applicable.
400 See also :attr:`customer_id` and :attr:`local_customer`.
401 """,
402 )
404 created = sa.Column(
405 sa.DateTime(),
406 nullable=False,
407 default=make_utc,
408 doc="""
409 Timestamp when the order was created.
411 If the order is created via New Order Batch, this will match the
412 batch execution timestamp.
413 """,
414 )
416 created_by_uuid = model.uuid_fk_column("user.uuid", nullable=False)
417 created_by = orm.relationship(
418 model.User,
419 cascade_backrefs=False,
420 doc="""
421 Reference to the
422 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
423 created the order.
424 """,
425 )
427 items = orm.relationship(
428 "OrderItem",
429 collection_class=ordering_list("sequence", count_from=1),
430 cascade="all, delete-orphan",
431 cascade_backrefs=False,
432 back_populates="order",
433 doc="""
434 List of :class:`OrderItem` records belonging to the order.
435 """,
436 )
438 def __str__(self):
439 return str(self.order_id)
442class OrderItem(OrderItemMixin, model.Base):
443 """
444 Represents an :term:`order item` within an :class:`Order`.
446 Usually these are created from
447 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
448 records.
449 """
451 __tablename__ = "sideshow_order_item"
453 uuid = model.uuid_column()
455 order_uuid = model.uuid_fk_column("sideshow_order.uuid", nullable=False)
456 order = orm.relationship(
457 Order,
458 cascade_backrefs=False,
459 back_populates="items",
460 doc="""
461 Reference to the :class:`Order` to which the item belongs.
462 """,
463 )
465 sequence = sa.Column(
466 sa.Integer(),
467 nullable=False,
468 doc="""
469 1-based numeric sequence for the item, i.e. its line number within
470 the order.
471 """,
472 )
474 local_product_uuid = model.uuid_fk_column(
475 "sideshow_product_local.uuid", nullable=True
476 )
477 local_product = orm.relationship(
478 "LocalProduct",
479 cascade_backrefs=False,
480 back_populates="order_items",
481 doc="""
482 Reference to the
483 :class:`~sideshow.db.model.products.LocalProduct` record for
484 the order item, if applicable.
486 See also :attr:`product_id` and :attr:`pending_product`.
487 """,
488 )
490 pending_product_uuid = model.uuid_fk_column(
491 "sideshow_product_pending.uuid", nullable=True
492 )
493 pending_product = orm.relationship(
494 "PendingProduct",
495 cascade_backrefs=False,
496 back_populates="order_items",
497 doc="""
498 Reference to the
499 :class:`~sideshow.db.model.products.PendingProduct` record for
500 the order item, if applicable.
502 See also :attr:`product_id` and :attr:`local_product`.
503 """,
504 )
506 status_code = sa.Column(
507 sa.Integer(),
508 nullable=False,
509 doc="""
510 Code indicating current status for the order item.
511 """,
512 )
514 paid_amount = sa.Column(
515 sa.Numeric(precision=8, scale=3),
516 nullable=False,
517 default=0,
518 doc="""
519 Amount which the customer has paid toward the :attr:`total_price`
520 of the item.
521 """,
522 )
524 payment_transaction_number = sa.Column(
525 sa.String(length=20),
526 nullable=True,
527 doc="""
528 Transaction number in which payment for the order was taken, if
529 applicable/known.
530 """,
531 )
533 events = orm.relationship(
534 "OrderItemEvent",
535 order_by="OrderItemEvent.occurred, OrderItemEvent.uuid",
536 cascade="all, delete-orphan",
537 cascade_backrefs=False,
538 back_populates="item",
539 doc="""
540 List of :class:`OrderItemEvent` records for the item.
541 """,
542 )
544 @property
545 def full_description(self): # pylint: disable=empty-docstring
546 """ """
547 fields = [
548 self.product_brand or "",
549 self.product_description or "",
550 self.product_size or "",
551 ]
552 fields = [f.strip() for f in fields if f.strip()]
553 return " ".join(fields)
555 def __str__(self):
556 return self.full_description
558 def add_event(self, type_code, user, **kwargs):
559 """
560 Convenience method to add a new :class:`OrderItemEvent` for
561 the item.
562 """
563 kwargs["type_code"] = type_code
564 kwargs["actor"] = user
565 self.events.append(OrderItemEvent(**kwargs))
568class OrderItemEvent(model.Base): # pylint: disable=too-few-public-methods
569 """
570 An event in the life of an :term:`order item`.
571 """
573 __tablename__ = "sideshow_order_item_event"
575 uuid = model.uuid_column()
577 item_uuid = model.uuid_fk_column("sideshow_order_item.uuid", nullable=False)
578 item = orm.relationship(
579 OrderItem,
580 cascade_backrefs=False,
581 back_populates="events",
582 doc="""
583 Reference to the :class:`OrderItem` to which the event
584 pertains.
585 """,
586 )
588 type_code = sa.Column(
589 sa.Integer,
590 nullable=False,
591 doc="""
592 Code indicating the type of event; values must be defined in
593 :data:`~sideshow.enum.ORDER_ITEM_EVENT`.
594 """,
595 )
597 occurred = sa.Column(
598 sa.DateTime(),
599 nullable=False,
600 default=make_utc,
601 doc="""
602 Date and time when the event occurred.
603 """,
604 )
606 actor_uuid = model.uuid_fk_column("user.uuid", nullable=False)
607 actor = orm.relationship(
608 model.User,
609 doc="""
610 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
611 performed the action.
612 """,
613 )
615 note = sa.Column(
616 sa.Text(),
617 nullable=True,
618 doc="""
619 Optional note recorded for the event.
620 """,
621 )