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

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""" 

26 

27import sqlalchemy as sa 

28from sqlalchemy import orm 

29from sqlalchemy.ext.orderinglist import ordering_list 

30 

31from wuttjamaican.db import model 

32from wuttjamaican.util import make_utc 

33 

34 

35class OrderMixin: # pylint: disable=too-few-public-methods 

36 """ 

37 Mixin class providing common columns for orders and new order 

38 batches. 

39 """ 

40 

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 ) 

48 

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. 

55 

56 See also :attr:`local_customer` and :attr:`pending_customer`. 

57 """, 

58 ) 

59 

60 customer_name = sa.Column( 

61 sa.String(length=100), 

62 nullable=True, 

63 doc=""" 

64 Name for the customer account. 

65 """, 

66 ) 

67 

68 phone_number = sa.Column( 

69 sa.String(length=20), 

70 nullable=True, 

71 doc=""" 

72 Phone number for the customer. 

73 """, 

74 ) 

75 

76 email_address = sa.Column( 

77 sa.String(length=255), 

78 nullable=True, 

79 doc=""" 

80 Email address for the customer. 

81 """, 

82 ) 

83 

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 ) 

91 

92 

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 """ 

98 

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. 

105 

106 See also :attr:`local_product` and :attr:`pending_product`. 

107 """, 

108 ) 

109 

110 product_scancode = sa.Column( 

111 sa.String(length=14), 

112 nullable=True, 

113 doc=""" 

114 Scancode for the product, as string. 

115 

116 .. note:: 

117 

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. 

121 

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 ) 

127 

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 ) 

135 

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 ) 

143 

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 ) 

151 

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 ) 

159 

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 ) 

167 

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 ) 

175 

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 ) 

184 

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 ) 

193 

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 ) 

202 

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. 

208 

209 If this is not set, then customer cannot order a "case" of the item. 

210 """, 

211 ) 

212 

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. 

218 

219 This must be interpreted along with :attr:`order_uom` to determine 

220 the *complete* order quantity, e.g. "2 cases". 

221 """, 

222 ) 

223 

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. 

229 

230 This should be one of the codes from 

231 :data:`~sideshow.enum.ORDER_UOM`. 

232 

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 ) 

238 

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 ) 

247 

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 ) 

256 

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 ) 

266 

267 sale_ends = sa.Column( 

268 sa.DateTime(), 

269 nullable=True, 

270 doc=""" 

271 End date/time for the sale in effect, if any. 

272 

273 This is only relevant if :attr:`unit_price_sale` is set. 

274 """, 

275 ) 

276 

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`. 

283 

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`. 

287 

288 See also :attr:`case_price_quoted`, if applicable. 

289 """, 

290 ) 

291 

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. 

297 

298 This is mostly for display purposes; :attr:`unit_price_quoted` is 

299 used for calculations. 

300 """, 

301 ) 

302 

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 ) 

311 

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. 

318 

319 This is calculated using values from: 

320 

321 * :attr:`unit_price_quoted` 

322 * :attr:`order_qty` 

323 * :attr:`order_uom` 

324 * :attr:`case_size` 

325 * :attr:`discount_percent` 

326 """, 

327 ) 

328 

329 

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`. 

336 

337 Usually, orders are created by way of a 

338 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`. 

339 """ 

340 

341 __tablename__ = "sideshow_order" 

342 

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 } 

348 

349 uuid = model.uuid_column() 

350 

351 order_id = sa.Column( 

352 sa.Integer(), 

353 nullable=False, 

354 doc=""" 

355 Unique ID for the order. 

356 

357 When the order is created from New Order Batch, this order ID will 

358 match the batch ID. 

359 """, 

360 ) 

361 

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 ) 

371 

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. 

383 

384 See also :attr:`customer_id` and :attr:`pending_customer`. 

385 """, 

386 ) 

387 

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. 

399 

400 See also :attr:`customer_id` and :attr:`local_customer`. 

401 """, 

402 ) 

403 

404 created = sa.Column( 

405 sa.DateTime(), 

406 nullable=False, 

407 default=make_utc, 

408 doc=""" 

409 Timestamp when the order was created. 

410 

411 If the order is created via New Order Batch, this will match the 

412 batch execution timestamp. 

413 """, 

414 ) 

415 

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 ) 

426 

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 ) 

437 

438 def __str__(self): 

439 return str(self.order_id) 

440 

441 

442class OrderItem(OrderItemMixin, model.Base): 

443 """ 

444 Represents an :term:`order item` within an :class:`Order`. 

445 

446 Usually these are created from 

447 :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow` 

448 records. 

449 """ 

450 

451 __tablename__ = "sideshow_order_item" 

452 

453 uuid = model.uuid_column() 

454 

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 ) 

464 

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 ) 

473 

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. 

485 

486 See also :attr:`product_id` and :attr:`pending_product`. 

487 """, 

488 ) 

489 

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. 

501 

502 See also :attr:`product_id` and :attr:`local_product`. 

503 """, 

504 ) 

505 

506 status_code = sa.Column( 

507 sa.Integer(), 

508 nullable=False, 

509 doc=""" 

510 Code indicating current status for the order item. 

511 """, 

512 ) 

513 

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 ) 

523 

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 ) 

532 

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 ) 

543 

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) 

554 

555 def __str__(self): 

556 return self.full_description 

557 

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)) 

566 

567 

568class OrderItemEvent(model.Base): # pylint: disable=too-few-public-methods 

569 """ 

570 An event in the life of an :term:`order item`. 

571 """ 

572 

573 __tablename__ = "sideshow_order_item_event" 

574 

575 uuid = model.uuid_column() 

576 

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 ) 

587 

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 ) 

596 

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 ) 

605 

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 ) 

614 

615 note = sa.Column( 

616 sa.Text(), 

617 nullable=True, 

618 doc=""" 

619 Optional note recorded for the event. 

620 """, 

621 )