Coverage for .tox/coverage/lib/python3.11/site-packages/sideshow/db/model/orders.py: 100%
84 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 07:16 -0500
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 07:16 -0500
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 datetime
29import sqlalchemy as sa
30from sqlalchemy import orm
31from sqlalchemy.ext.orderinglist import ordering_list
33from wuttjamaican.db import model
36class Order(model.Base):
37 """
38 Represents an :term:`order` for a customer. Each order has one or
39 more :attr:`items`.
41 Usually, orders are created by way of a
42 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`.
43 """
44 __tablename__ = 'sideshow_order'
46 # TODO: this feels a bit hacky yet but it does avoid problems
47 # showing the Orders grid for a PendingCustomer
48 __colanderalchemy_config__ = {
49 'excludes': ['items'],
50 }
52 uuid = model.uuid_column()
54 order_id = sa.Column(sa.Integer(), nullable=False, doc="""
55 Unique ID for the order.
57 When the order is created from New Order Batch, this order ID will
58 match the batch ID.
59 """)
61 store_id = sa.Column(sa.String(length=10), nullable=True, doc="""
62 ID of the store to which the order pertains, if applicable.
63 """)
65 store = orm.relationship(
66 'Store',
67 primaryjoin='Store.store_id == Order.store_id',
68 foreign_keys='Order.store_id',
69 doc="""
70 Reference to the :class:`~sideshow.db.model.stores.Store`
71 record, if applicable.
72 """)
74 customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
75 Proper account ID for the :term:`external customer` to which the
76 order pertains, if applicable.
78 See also :attr:`local_customer` and :attr:`pending_customer`.
79 """)
81 local_customer_uuid = model.uuid_fk_column('sideshow_customer_local.uuid', nullable=True)
82 local_customer = orm.relationship(
83 'LocalCustomer',
84 cascade_backrefs=False,
85 back_populates='orders',
86 doc="""
87 Reference to the
88 :class:`~sideshow.db.model.customers.LocalCustomer` record
89 for the order, if applicable.
91 See also :attr:`customer_id` and :attr:`pending_customer`.
92 """)
94 pending_customer_uuid = model.uuid_fk_column('sideshow_customer_pending.uuid', nullable=True)
95 pending_customer = orm.relationship(
96 'PendingCustomer',
97 cascade_backrefs=False,
98 back_populates='orders',
99 doc="""
100 Reference to the
101 :class:`~sideshow.db.model.customers.PendingCustomer` record
102 for the order, if applicable.
104 See also :attr:`customer_id` and :attr:`local_customer`.
105 """)
107 customer_name = sa.Column(sa.String(length=100), nullable=True, doc="""
108 Name for the customer account.
109 """)
111 phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
112 Phone number for the customer.
113 """)
115 email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
116 Email address for the customer.
117 """)
119 total_price = sa.Column(sa.Numeric(precision=10, scale=3), nullable=True, doc="""
120 Full price (not including tax etc.) for all items on the order.
121 """)
123 created = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc="""
124 Timestamp when the order was created.
126 If the order is created via New Order Batch, this will match the
127 batch execution timestamp.
128 """)
130 created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
131 created_by = orm.relationship(
132 model.User,
133 cascade_backrefs=False,
134 doc="""
135 Reference to the
136 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
137 created the order.
138 """)
140 items = orm.relationship(
141 'OrderItem',
142 collection_class=ordering_list('sequence', count_from=1),
143 cascade='all, delete-orphan',
144 cascade_backrefs=False,
145 back_populates='order',
146 doc="""
147 List of :class:`OrderItem` records belonging to the order.
148 """)
150 def __str__(self):
151 return str(self.order_id)
154class OrderItem(model.Base):
155 """
156 Represents an :term:`order item` within an :class:`Order`.
158 Usually these are created from
159 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
160 records.
161 """
162 __tablename__ = 'sideshow_order_item'
164 uuid = model.uuid_column()
166 order_uuid = model.uuid_fk_column('sideshow_order.uuid', nullable=False)
167 order = orm.relationship(
168 Order,
169 cascade_backrefs=False,
170 back_populates='items',
171 doc="""
172 Reference to the :class:`Order` to which the item belongs.
173 """)
175 sequence = sa.Column(sa.Integer(), nullable=False, doc="""
176 1-based numeric sequence for the item, i.e. its line number within
177 the order.
178 """)
180 product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
181 Proper ID for the :term:`external product` which the order item
182 represents, if applicable.
184 See also :attr:`local_product` and :attr:`pending_product`.
185 """)
187 local_product_uuid = model.uuid_fk_column('sideshow_product_local.uuid', nullable=True)
188 local_product = orm.relationship(
189 'LocalProduct',
190 cascade_backrefs=False,
191 back_populates='order_items',
192 doc="""
193 Reference to the
194 :class:`~sideshow.db.model.products.LocalProduct` record for
195 the order item, if applicable.
197 See also :attr:`product_id` and :attr:`pending_product`.
198 """)
200 pending_product_uuid = model.uuid_fk_column('sideshow_product_pending.uuid', nullable=True)
201 pending_product = orm.relationship(
202 'PendingProduct',
203 cascade_backrefs=False,
204 back_populates='order_items',
205 doc="""
206 Reference to the
207 :class:`~sideshow.db.model.products.PendingProduct` record for
208 the order item, if applicable.
210 See also :attr:`product_id` and :attr:`local_product`.
211 """)
213 product_scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
214 Scancode for the product, as string.
216 .. note::
218 This column allows 14 chars, so can store a full GPC with check
219 digit. However as of writing the actual format used here does
220 not matter to Sideshow logic; "anything" should work.
222 That may change eventually, depending on POS integration
223 scenarios that come up. Maybe a config option to declare
224 whether check digit should be included or not, etc.
225 """)
227 product_brand = sa.Column(sa.String(length=100), nullable=True, doc="""
228 Brand name for the product - up to 100 chars.
229 """)
231 product_description = sa.Column(sa.String(length=255), nullable=True, doc="""
232 Description for the product - up to 255 chars.
233 """)
235 product_size = sa.Column(sa.String(length=30), nullable=True, doc="""
236 Size of the product, as string - up to 30 chars.
237 """)
239 product_weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
240 Flag indicating the product is sold by weight; default is null.
241 """)
243 department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
244 ID of the department to which the product belongs, if known.
245 """)
247 department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
248 Name of the department to which the product belongs, if known.
249 """)
251 special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
252 Flag indicating the item is a "special order" - e.g. something not
253 normally carried by the store. Default is null.
254 """)
256 vendor_name = sa.Column(sa.String(length=50), nullable=True, doc="""
257 Name of vendor from which product may be purchased, if known. See
258 also :attr:`vendor_item_code`.
259 """)
261 vendor_item_code = sa.Column(sa.String(length=20), nullable=True, doc="""
262 Item code (SKU) to use when ordering this product from the vendor
263 identified by :attr:`vendor_name`, if known.
264 """)
266 case_size = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
267 Case pack count for the product, if known.
268 """)
270 order_qty = sa.Column(sa.Numeric(precision=10, scale=4), nullable=False, doc="""
271 Quantity (as decimal) of product being ordered.
273 This must be interpreted along with :attr:`order_uom` to determine
274 the *complete* order quantity, e.g. "2 cases".
275 """)
277 order_uom = sa.Column(sa.String(length=10), nullable=False, doc="""
278 Code indicating the unit of measure for product being ordered.
280 This should be one of the codes from
281 :data:`~sideshow.enum.ORDER_UOM`.
282 """)
284 unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
285 Cost of goods amount for one "unit" (not "case") of the product,
286 as decimal to 4 places.
287 """)
289 unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
290 Regular price for the item unit. Unless a sale is in effect,
291 :attr:`unit_price_quoted` will typically match this value.
292 """)
294 unit_price_sale = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
295 Sale price for the item unit, if applicable. If set, then
296 :attr:`unit_price_quoted` will typically match this value. See
297 also :attr:`sale_ends`.
298 """)
300 sale_ends = sa.Column(sa.DateTime(timezone=True), nullable=True, doc="""
301 End date/time for the sale in effect, if any.
303 This is only relevant if :attr:`unit_price_sale` is set.
304 """)
306 unit_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
307 Quoted price for the item unit. This is the "effective" unit
308 price, which is used to calculate :attr:`total_price`.
310 This price does *not* reflect the :attr:`discount_percent`. It
311 normally should match either :attr:`unit_price_reg` or
312 :attr:`unit_price_sale`.
313 """)
315 case_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
316 Quoted price for a "case" of the item, if applicable.
318 This is mostly for display purposes; :attr:`unit_price_quoted` is
319 used for calculations.
320 """)
322 discount_percent = sa.Column(sa.Numeric(precision=5, scale=3), nullable=True, doc="""
323 Discount percent to apply when calculating :attr:`total_price`, if
324 applicable.
325 """)
327 total_price = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
328 Full price (not including tax etc.) which the customer is quoted
329 for the order item.
331 This is calculated using values from:
333 * :attr:`unit_price_quoted`
334 * :attr:`order_qty`
335 * :attr:`order_uom`
336 * :attr:`case_size`
337 * :attr:`discount_percent`
338 """)
340 status_code = sa.Column(sa.Integer(), nullable=False, doc="""
341 Code indicating current status for the order item.
342 """)
344 paid_amount = sa.Column(sa.Numeric(precision=8, scale=3), nullable=False, default=0, doc="""
345 Amount which the customer has paid toward the :attr:`total_price`
346 of the item.
347 """)
349 payment_transaction_number = sa.Column(sa.String(length=20), nullable=True, doc="""
350 Transaction number in which payment for the order was taken, if
351 applicable/known.
352 """)
354 events = orm.relationship(
355 'OrderItemEvent',
356 order_by='OrderItemEvent.occurred, OrderItemEvent.uuid',
357 cascade='all, delete-orphan',
358 cascade_backrefs=False,
359 back_populates='item',
360 doc="""
361 List of :class:`OrderItemEvent` records for the item.
362 """)
364 @property
365 def full_description(self):
366 """ """
367 fields = [
368 self.product_brand or '',
369 self.product_description or '',
370 self.product_size or '']
371 fields = [f.strip() for f in fields if f.strip()]
372 return ' '.join(fields)
374 def __str__(self):
375 return self.full_description
377 def add_event(self, type_code, user, **kwargs):
378 """
379 Convenience method to add a new :class:`OrderItemEvent` for
380 the item.
381 """
382 kwargs['type_code'] = type_code
383 kwargs['actor'] = user
384 self.events.append(OrderItemEvent(**kwargs))
387class OrderItemEvent(model.Base):
388 """
389 An event in the life of an :term:`order item`.
390 """
391 __tablename__ = 'sideshow_order_item_event'
393 uuid = model.uuid_column()
395 item_uuid = model.uuid_fk_column('sideshow_order_item.uuid', nullable=False)
396 item = orm.relationship(
397 OrderItem,
398 cascade_backrefs=False,
399 back_populates='events',
400 doc="""
401 Reference to the :class:`OrderItem` to which the event
402 pertains.
403 """)
405 type_code = sa.Column(sa.Integer, nullable=False, doc="""
406 Code indicating the type of event; values must be defined in
407 :data:`~sideshow.enum.ORDER_ITEM_EVENT`.
408 """)
410 occurred = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc="""
411 Date and time when the event occurred.
412 """)
414 actor_uuid = model.uuid_fk_column('user.uuid', nullable=False)
415 actor = orm.relationship(
416 model.User,
417 doc="""
418 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
419 performed the action.
420 """)
422 note = sa.Column(sa.Text(), nullable=True, doc="""
423 Optional note recorded for the event.
424 """)