Coverage for src/sideshow/batch/neworder.py: 100%

262 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-09 12:06 -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""" 

26 

27import datetime 

28import decimal 

29 

30import sqlalchemy as sa 

31 

32from wuttjamaican.batch import BatchHandler 

33 

34from sideshow.db.model import NewOrderBatch 

35 

36 

37class NewOrderBatchHandler(BatchHandler): 

38 """ 

39 The :term:`batch handler` for :term:`new order batches <new order 

40 batch>`. 

41 

42 This is responsible for business logic around the creation of new 

43 :term:`orders <order>`. A 

44 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` tracks 

45 all user input until they "submit" (execute) at which point an 

46 :class:`~sideshow.db.model.orders.Order` is created. 

47 """ 

48 model_class = NewOrderBatch 

49 

50 def use_local_customers(self): 

51 """ 

52 Returns boolean indicating whether :term:`local customer` 

53 accounts should be used. This is true by default, but may be 

54 false for :term:`external customer` lookups. 

55 """ 

56 return self.config.get_bool('sideshow.orders.use_local_customers', 

57 default=True) 

58 

59 def use_local_products(self): 

60 """ 

61 Returns boolean indicating whether :term:`local product` 

62 records should be used. This is true by default, but may be 

63 false for :term:`external product` lookups. 

64 """ 

65 return self.config.get_bool('sideshow.orders.use_local_products', 

66 default=True) 

67 

68 def allow_unknown_products(self): 

69 """ 

70 Returns boolean indicating whether :term:`pending products 

71 <pending product>` are allowed when creating an order. 

72 

73 This is true by default, so user can enter new/unknown product 

74 when creating an order. This can be disabled, to force user 

75 to choose existing local/external product. 

76 """ 

77 return self.config.get_bool('sideshow.orders.allow_unknown_products', 

78 default=True) 

79 

80 def set_customer(self, batch, customer_info, user=None): 

81 """ 

82 Set/update customer info for the batch. 

83 

84 This will first set one of the following: 

85 

86 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id` 

87 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.local_customer` 

88 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer` 

89 

90 Note that a new 

91 :class:`~sideshow.db.model.customers.PendingCustomer` record 

92 is created if necessary. 

93 

94 And then it will update these accordingly: 

95 

96 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_name` 

97 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.phone_number` 

98 * :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.email_address` 

99 

100 Note that ``customer_info`` may be ``None``, which will cause 

101 all the above to be set to ``None`` also. 

102 

103 :param batch: 

104 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to 

105 update. 

106 

107 :param customer_info: Customer ID string, or dict of 

108 :class:`~sideshow.db.model.customers.PendingCustomer` data, 

109 or ``None`` to clear the customer info. 

110 

111 :param user: 

112 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who 

113 is performing the action. This is used to set 

114 :attr:`~sideshow.db.model.customers.PendingCustomer.created_by` 

115 on the pending customer, if applicable. If not specified, 

116 the batch creator is assumed. 

117 """ 

118 model = self.app.model 

119 enum = self.app.enum 

120 session = self.app.get_session(batch) 

121 use_local = self.use_local_customers() 

122 

123 # set customer info 

124 if isinstance(customer_info, str): 

125 if use_local: 

126 

127 # local_customer 

128 customer = session.get(model.LocalCustomer, customer_info) 

129 if not customer: 

130 raise ValueError("local customer not found") 

131 batch.local_customer = customer 

132 batch.customer_name = customer.full_name 

133 batch.phone_number = customer.phone_number 

134 batch.email_address = customer.email_address 

135 

136 else: # external customer_id 

137 #batch.customer_id = customer_info 

138 raise NotImplementedError 

139 

140 elif customer_info: 

141 

142 # pending_customer 

143 batch.customer_id = None 

144 batch.local_customer = None 

145 customer = batch.pending_customer 

146 if not customer: 

147 customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING, 

148 created_by=user or batch.created_by) 

149 session.add(customer) 

150 batch.pending_customer = customer 

151 fields = [ 

152 'full_name', 

153 'first_name', 

154 'last_name', 

155 'phone_number', 

156 'email_address', 

157 ] 

158 for key in fields: 

159 setattr(customer, key, customer_info.get(key)) 

160 if 'full_name' not in customer_info: 

161 customer.full_name = self.app.make_full_name(customer.first_name, 

162 customer.last_name) 

163 batch.customer_name = customer.full_name 

164 batch.phone_number = customer.phone_number 

165 batch.email_address = customer.email_address 

166 

167 else: 

168 

169 # null 

170 batch.customer_id = None 

171 batch.local_customer = None 

172 batch.customer_name = None 

173 batch.phone_number = None 

174 batch.email_address = None 

175 

176 session.flush() 

177 

178 def add_item(self, batch, product_info, order_qty, order_uom, user=None): 

179 """ 

180 Add a new item/row to the batch, for given product and quantity. 

181 

182 See also :meth:`update_item()`. 

183 

184 :param batch: 

185 :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to 

186 update. 

187 

188 :param product_info: Product ID string, or dict of 

189 :class:`~sideshow.db.model.products.PendingProduct` data. 

190 

191 :param order_qty: 

192 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty` 

193 value for the new row. 

194 

195 :param order_uom: 

196 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom` 

197 value for the new row. 

198 

199 :param user: 

200 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who 

201 is performing the action. This is used to set 

202 :attr:`~sideshow.db.model.products.PendingProduct.created_by` 

203 on the pending product, if applicable. If not specified, 

204 the batch creator is assumed. 

205 

206 :returns: 

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

208 instance. 

209 """ 

210 model = self.app.model 

211 enum = self.app.enum 

212 session = self.app.get_session(batch) 

213 use_local = self.use_local_products() 

214 row = self.make_row() 

215 

216 # set product info 

217 if isinstance(product_info, str): 

218 if use_local: 

219 

220 # local_product 

221 local = session.get(model.LocalProduct, product_info) 

222 if not local: 

223 raise ValueError("local product not found") 

224 row.local_product = local 

225 

226 else: # external product_id 

227 #row.product_id = product_info 

228 raise NotImplementedError 

229 

230 else: 

231 # pending_product 

232 if not self.allow_unknown_products(): 

233 raise TypeError("unknown/pending product not allowed for new orders") 

234 row.product_id = None 

235 row.local_product = None 

236 pending = model.PendingProduct(status=enum.PendingProductStatus.PENDING, 

237 created_by=user or batch.created_by) 

238 fields = [ 

239 'scancode', 

240 'brand_name', 

241 'description', 

242 'size', 

243 'weighed', 

244 'department_id', 

245 'department_name', 

246 'special_order', 

247 'vendor_name', 

248 'vendor_item_code', 

249 'case_size', 

250 'unit_cost', 

251 'unit_price_reg', 

252 'notes', 

253 ] 

254 for key in fields: 

255 setattr(pending, key, product_info.get(key)) 

256 

257 # nb. this may convert float to decimal etc. 

258 session.add(pending) 

259 session.flush() 

260 session.refresh(pending) 

261 row.pending_product = pending 

262 

263 # set order info 

264 row.order_qty = order_qty 

265 row.order_uom = order_uom 

266 

267 # add row to batch 

268 self.add_row(batch, row) 

269 session.flush() 

270 return row 

271 

272 def update_item(self, row, product_info, order_qty, order_uom, user=None): 

273 """ 

274 Update an item/row, per given product and quantity. 

275 

276 See also :meth:`add_item()`. 

277 

278 :param row: 

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

280 to update. 

281 

282 :param product_info: Product ID string, or dict of 

283 :class:`~sideshow.db.model.products.PendingProduct` data. 

284 

285 :param order_qty: New 

286 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_qty` 

287 value for the row. 

288 

289 :param order_uom: New 

290 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom` 

291 value for the row. 

292 

293 :param user: 

294 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who 

295 is performing the action. This is used to set 

296 :attr:`~sideshow.db.model.products.PendingProduct.created_by` 

297 on the pending product, if applicable. If not specified, 

298 the batch creator is assumed. 

299 """ 

300 model = self.app.model 

301 enum = self.app.enum 

302 session = self.app.get_session(row) 

303 use_local = self.use_local_products() 

304 

305 # set product info 

306 if isinstance(product_info, str): 

307 if use_local: 

308 

309 # local_product 

310 local = session.get(model.LocalProduct, product_info) 

311 if not local: 

312 raise ValueError("local product not found") 

313 row.local_product = local 

314 

315 else: # external product_id 

316 #row.product_id = product_info 

317 raise NotImplementedError 

318 

319 else: 

320 # pending_product 

321 if not self.allow_unknown_products(): 

322 raise TypeError("unknown/pending product not allowed for new orders") 

323 row.product_id = None 

324 row.local_product = None 

325 pending = row.pending_product 

326 if not pending: 

327 pending = model.PendingProduct(status=enum.PendingProductStatus.PENDING, 

328 created_by=user or row.batch.created_by) 

329 session.add(pending) 

330 row.pending_product = pending 

331 fields = [ 

332 'scancode', 

333 'brand_name', 

334 'description', 

335 'size', 

336 'weighed', 

337 'department_id', 

338 'department_name', 

339 'special_order', 

340 'vendor_name', 

341 'vendor_item_code', 

342 'case_size', 

343 'unit_cost', 

344 'unit_price_reg', 

345 'notes', 

346 ] 

347 for key in fields: 

348 setattr(pending, key, product_info.get(key)) 

349 

350 # nb. this may convert float to decimal etc. 

351 session.flush() 

352 session.refresh(pending) 

353 

354 # set order info 

355 row.order_qty = order_qty 

356 row.order_uom = order_uom 

357 

358 # nb. this may convert float to decimal etc. 

359 session.flush() 

360 session.refresh(row) 

361 

362 # refresh per new info 

363 self.refresh_row(row) 

364 

365 def refresh_row(self, row): 

366 """ 

367 Refresh data for the row. This is called when adding a new 

368 row to the batch, or anytime the row is updated (e.g. when 

369 changing order quantity). 

370 

371 This calls one of the following to update product-related 

372 attributes: 

373 

374 * :meth:`refresh_row_from_external_product()` 

375 * :meth:`refresh_row_from_local_product()` 

376 * :meth:`refresh_row_from_pending_product()` 

377 

378 It then re-calculates the row's 

379 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price` 

380 and updates the batch accordingly. 

381 

382 It also sets the row 

383 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.status_code`. 

384 """ 

385 enum = self.app.enum 

386 row.status_code = None 

387 row.status_text = None 

388 

389 # ensure product 

390 if not row.product_id and not row.local_product and not row.pending_product: 

391 row.status_code = row.STATUS_MISSING_PRODUCT 

392 return 

393 

394 # ensure order qty/uom 

395 if not row.order_qty or not row.order_uom: 

396 row.status_code = row.STATUS_MISSING_ORDER_QTY 

397 return 

398 

399 # update product attrs on row 

400 if row.product_id: 

401 self.refresh_row_from_external_product(row) 

402 elif row.local_product: 

403 self.refresh_row_from_local_product(row) 

404 else: 

405 self.refresh_row_from_pending_product(row) 

406 

407 # we need to know if total price changes 

408 old_total = row.total_price 

409 

410 # update quoted price 

411 row.unit_price_quoted = None 

412 row.case_price_quoted = None 

413 if row.unit_price_sale is not None and ( 

414 not row.sale_ends 

415 or row.sale_ends > datetime.datetime.now()): 

416 row.unit_price_quoted = row.unit_price_sale 

417 else: 

418 row.unit_price_quoted = row.unit_price_reg 

419 if row.unit_price_quoted is not None and row.case_size: 

420 row.case_price_quoted = row.unit_price_quoted * row.case_size 

421 

422 # update row total price 

423 row.total_price = None 

424 if row.order_uom == enum.ORDER_UOM_CASE: 

425 if row.unit_price_quoted is not None and row.case_size is not None: 

426 row.total_price = row.unit_price_quoted * row.case_size * row.order_qty 

427 else: # ORDER_UOM_UNIT (or similar) 

428 if row.unit_price_quoted is not None: 

429 row.total_price = row.unit_price_quoted * row.order_qty 

430 if row.total_price is not None: 

431 row.total_price = decimal.Decimal(f'{row.total_price:0.2f}') 

432 

433 # update batch if total price changed 

434 if row.total_price != old_total: 

435 batch = row.batch 

436 batch.total_price = ((batch.total_price or 0) 

437 + (row.total_price or 0) 

438 - (old_total or 0)) 

439 

440 # all ok 

441 row.status_code = row.STATUS_OK 

442 

443 def refresh_row_from_local_product(self, row): 

444 """ 

445 Update product-related attributes on the row, from its 

446 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.local_product` 

447 record. 

448 

449 This is called automatically from :meth:`refresh_row()`. 

450 """ 

451 product = row.local_product 

452 row.product_scancode = product.scancode 

453 row.product_brand = product.brand_name 

454 row.product_description = product.description 

455 row.product_size = product.size 

456 row.product_weighed = product.weighed 

457 row.department_id = product.department_id 

458 row.department_name = product.department_name 

459 row.special_order = product.special_order 

460 row.case_size = product.case_size 

461 row.unit_cost = product.unit_cost 

462 row.unit_price_reg = product.unit_price_reg 

463 

464 def refresh_row_from_pending_product(self, row): 

465 """ 

466 Update product-related attributes on the row, from its 

467 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product` 

468 record. 

469 

470 This is called automatically from :meth:`refresh_row()`. 

471 """ 

472 product = row.pending_product 

473 row.product_scancode = product.scancode 

474 row.product_brand = product.brand_name 

475 row.product_description = product.description 

476 row.product_size = product.size 

477 row.product_weighed = product.weighed 

478 row.department_id = product.department_id 

479 row.department_name = product.department_name 

480 row.special_order = product.special_order 

481 row.case_size = product.case_size 

482 row.unit_cost = product.unit_cost 

483 row.unit_price_reg = product.unit_price_reg 

484 

485 def refresh_row_from_external_product(self, row): 

486 """ 

487 Update product-related attributes on the row, from its 

488 :term:`external product` record indicated by 

489 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`. 

490 

491 This is called automatically from :meth:`refresh_row()`. 

492 

493 There is no default logic here; subclass must implement as 

494 needed. 

495 """ 

496 

497 def remove_row(self, row): 

498 """ 

499 Remove a row from its batch. 

500 

501 This also will update the batch 

502 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.total_price` 

503 accordingly. 

504 """ 

505 if row.total_price: 

506 batch = row.batch 

507 batch.total_price = (batch.total_price or 0) - row.total_price 

508 

509 super().remove_row(row) 

510 

511 def do_delete(self, batch, user, **kwargs): 

512 """ 

513 Delete a batch completely. 

514 

515 If the batch has :term:`pending customer` or :term:`pending 

516 product` records, they are also deleted - unless still 

517 referenced by some order(s). 

518 """ 

519 session = self.app.get_session(batch) 

520 

521 # maybe delete pending customer 

522 customer = batch.pending_customer 

523 if customer and not customer.orders: 

524 session.delete(customer) 

525 

526 # maybe delete pending products 

527 for row in batch.rows: 

528 product = row.pending_product 

529 if product and not product.order_items: 

530 session.delete(product) 

531 

532 # continue with normal deletion 

533 super().do_delete(batch, user, **kwargs) 

534 

535 def why_not_execute(self, batch, **kwargs): 

536 """ 

537 By default this checks to ensure the batch has a customer with 

538 phone number, and at least one item. 

539 """ 

540 if not batch.customer_name: 

541 return "Must assign the customer" 

542 

543 if not batch.phone_number: 

544 return "Customer phone number is required" 

545 

546 rows = self.get_effective_rows(batch) 

547 if not rows: 

548 return "Must add at least one valid item" 

549 

550 def get_effective_rows(self, batch): 

551 """ 

552 Only rows with 

553 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.STATUS_OK` 

554 are "effective" - i.e. rows with other status codes will not 

555 be created as proper order items. 

556 """ 

557 return [row for row in batch.rows 

558 if row.status_code == row.STATUS_OK] 

559 

560 def execute(self, batch, user=None, progress=None, **kwargs): 

561 """ 

562 Execute the batch; this should make a proper :term:`order`. 

563 

564 By default, this will call: 

565 

566 * :meth:`make_local_customer()` 

567 * :meth:`make_local_products()` 

568 * :meth:`make_new_order()` 

569 

570 And will return the new 

571 :class:`~sideshow.db.model.orders.Order` instance. 

572 

573 Note that callers should use 

574 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()` 

575 instead, which calls this method automatically. 

576 """ 

577 rows = self.get_effective_rows(batch) 

578 self.make_local_customer(batch) 

579 self.make_local_products(batch, rows) 

580 order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs) 

581 return order 

582 

583 def make_local_customer(self, batch): 

584 """ 

585 If applicable, this converts the batch :term:`pending 

586 customer` into a :term:`local customer`. 

587 

588 This is called automatically from :meth:`execute()`. 

589 

590 This logic will happen only if :meth:`use_local_customers()` 

591 returns true, and the batch has pending instead of local 

592 customer (so far). 

593 

594 It will create a new 

595 :class:`~sideshow.db.model.customers.LocalCustomer` record and 

596 populate it from the batch 

597 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`. 

598 The latter is then deleted. 

599 """ 

600 if not self.use_local_customers(): 

601 return 

602 

603 # nothing to do if no pending customer 

604 pending = batch.pending_customer 

605 if not pending: 

606 return 

607 

608 session = self.app.get_session(batch) 

609 

610 # maybe convert pending to local customer 

611 if not batch.local_customer: 

612 model = self.app.model 

613 inspector = sa.inspect(model.LocalCustomer) 

614 local = model.LocalCustomer() 

615 for prop in inspector.column_attrs: 

616 if hasattr(pending, prop.key): 

617 setattr(local, prop.key, getattr(pending, prop.key)) 

618 session.add(local) 

619 batch.local_customer = local 

620 

621 # remove pending customer 

622 batch.pending_customer = None 

623 session.delete(pending) 

624 session.flush() 

625 

626 def make_local_products(self, batch, rows): 

627 """ 

628 If applicable, this converts all :term:`pending products 

629 <pending product>` into :term:`local products <local 

630 product>`. 

631 

632 This is called automatically from :meth:`execute()`. 

633 

634 This logic will happen only if :meth:`use_local_products()` 

635 returns true, and the batch has pending instead of local items 

636 (so far). 

637 

638 For each affected row, it will create a new 

639 :class:`~sideshow.db.model.products.LocalProduct` record and 

640 populate it from the row 

641 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`. 

642 The latter is then deleted. 

643 """ 

644 if not self.use_local_products(): 

645 return 

646 

647 model = self.app.model 

648 session = self.app.get_session(batch) 

649 inspector = sa.inspect(model.LocalProduct) 

650 for row in rows: 

651 

652 if row.local_product or not row.pending_product: 

653 continue 

654 

655 pending = row.pending_product 

656 local = model.LocalProduct() 

657 

658 for prop in inspector.column_attrs: 

659 if hasattr(pending, prop.key): 

660 setattr(local, prop.key, getattr(pending, prop.key)) 

661 session.add(local) 

662 

663 row.local_product = local 

664 row.pending_product = None 

665 session.delete(pending) 

666 

667 session.flush() 

668 

669 def make_new_order(self, batch, rows, user=None, progress=None, **kwargs): 

670 """ 

671 Create a new :term:`order` from the batch data. 

672 

673 This is called automatically from :meth:`execute()`. 

674 

675 :param batch: 

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

677 instance. 

678 

679 :param rows: List of effective rows for the batch, i.e. which 

680 rows should be converted to :term:`order items <order 

681 item>`. 

682 

683 :returns: :class:`~sideshow.db.model.orders.Order` instance. 

684 """ 

685 model = self.app.model 

686 enum = self.app.enum 

687 session = self.app.get_session(batch) 

688 

689 batch_fields = [ 

690 'store_id', 

691 'customer_id', 

692 'local_customer', 

693 'pending_customer', 

694 'customer_name', 

695 'phone_number', 

696 'email_address', 

697 'total_price', 

698 ] 

699 

700 row_fields = [ 

701 'product_id', 

702 'local_product', 

703 'pending_product', 

704 'product_scancode', 

705 'product_brand', 

706 'product_description', 

707 'product_size', 

708 'product_weighed', 

709 'department_id', 

710 'department_name', 

711 'case_size', 

712 'order_qty', 

713 'order_uom', 

714 'unit_cost', 

715 'unit_price_quoted', 

716 'case_price_quoted', 

717 'unit_price_reg', 

718 'unit_price_sale', 

719 'sale_ends', 

720 # 'discount_percent', 

721 'total_price', 

722 'special_order', 

723 ] 

724 

725 # make order 

726 kw = dict([(field, getattr(batch, field)) 

727 for field in batch_fields]) 

728 kw['order_id'] = batch.id 

729 kw['created_by'] = user 

730 order = model.Order(**kw) 

731 session.add(order) 

732 session.flush() 

733 

734 def convert(row, i): 

735 

736 # make order item 

737 kw = dict([(field, getattr(row, field)) 

738 for field in row_fields]) 

739 item = model.OrderItem(**kw) 

740 order.items.append(item) 

741 

742 # set item status 

743 item.status_code = enum.ORDER_ITEM_STATUS_INITIATED 

744 

745 self.app.progress_loop(convert, rows, progress, 

746 message="Converting batch rows to order items") 

747 session.flush() 

748 return order