Coverage for .tox/coverage/lib/python3.11/site-packages/sideshow/batch/neworder.py: 100%

386 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-22 12:21 -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""" 

24New Order Batch Handler 

25""" 

26 

27# pylint: disable=too-many-lines 

28 

29import decimal 

30from collections import OrderedDict 

31 

32import sqlalchemy as sa 

33 

34from wuttjamaican.batch import BatchHandler 

35 

36from sideshow.db.model import NewOrderBatch 

37 

38 

39class NewOrderBatchHandler(BatchHandler): # pylint: disable=too-many-public-methods 

40 """ 

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

42 batch>`. 

43 

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

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

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

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

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

49 

50 After the batch has executed the :term:`order handler` takes over 

51 responsibility for the rest of the order lifecycle. 

52 """ 

53 

54 model_class = NewOrderBatch 

55 

56 def get_default_store_id(self): 

57 """ 

58 Returns the configured default value for 

59 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`, 

60 or ``None``. 

61 """ 

62 return self.config.get("sideshow.orders.default_store_id") 

63 

64 def use_local_customers(self): 

65 """ 

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

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

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

69 """ 

70 return self.config.get_bool("sideshow.orders.use_local_customers", default=True) 

71 

72 def use_local_products(self): 

73 """ 

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

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

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

77 """ 

78 return self.config.get_bool("sideshow.orders.use_local_products", default=True) 

79 

80 def allow_unknown_products(self): 

81 """ 

82 Returns boolean indicating whether :term:`pending products 

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

84 

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

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

87 to choose existing local/external product. 

88 """ 

89 return self.config.get_bool( 

90 "sideshow.orders.allow_unknown_products", default=True 

91 ) 

92 

93 def allow_item_discounts(self): 

94 """ 

95 Returns boolean indicating whether per-item discounts are 

96 allowed when creating an order. 

97 """ 

98 return self.config.get_bool( 

99 "sideshow.orders.allow_item_discounts", default=False 

100 ) 

101 

102 def allow_item_discounts_if_on_sale(self): 

103 """ 

104 Returns boolean indicating whether per-item discounts are 

105 allowed even when the item is already on sale. 

106 """ 

107 return self.config.get_bool( 

108 "sideshow.orders.allow_item_discounts_if_on_sale", default=False 

109 ) 

110 

111 def get_default_item_discount(self): 

112 """ 

113 Returns the default item discount percentage, e.g. 15. 

114 

115 :rtype: :class:`~python:decimal.Decimal` or ``None`` 

116 """ 

117 discount = self.config.get("sideshow.orders.default_item_discount") 

118 if discount: 

119 return decimal.Decimal(discount) 

120 return None 

121 

122 def autocomplete_customers_external(self, session, term, user=None): 

123 """ 

124 Return autocomplete search results for :term:`external 

125 customer` records. 

126 

127 There is no default logic here; subclass must implement. 

128 

129 :param session: Current app :term:`db session`. 

130 

131 :param term: Search term string from user input. 

132 

133 :param user: 

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

135 is doing the search, if known. 

136 

137 :returns: List of search results; each should be a dict with 

138 ``value`` and ``label`` keys. 

139 """ 

140 raise NotImplementedError 

141 

142 def autocomplete_customers_local( # pylint: disable=unused-argument 

143 self, session, term, user=None 

144 ): 

145 """ 

146 Return autocomplete search results for 

147 :class:`~sideshow.db.model.customers.LocalCustomer` records. 

148 

149 :param session: Current app :term:`db session`. 

150 

151 :param term: Search term string from user input. 

152 

153 :param user: 

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

155 is doing the search, if known. 

156 

157 :returns: List of search results; each should be a dict with 

158 ``value`` and ``label`` keys. 

159 """ 

160 model = self.app.model 

161 

162 # base query 

163 query = session.query(model.LocalCustomer) 

164 

165 # filter query 

166 criteria = [ 

167 model.LocalCustomer.full_name.ilike(f"%{word}%") for word in term.split() 

168 ] 

169 query = query.filter(sa.and_(*criteria)) 

170 

171 # sort query 

172 query = query.order_by(model.LocalCustomer.full_name) 

173 

174 # get data 

175 # TODO: need max_results option 

176 customers = query.all() 

177 

178 # get results 

179 def result(customer): 

180 return {"value": customer.uuid.hex, "label": customer.full_name} 

181 

182 return [result(c) for c in customers] 

183 

184 def init_batch(self, batch, session=None, progress=None, **kwargs): 

185 """ 

186 Initialize a new batch. 

187 

188 This sets the 

189 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`, 

190 if the batch does not yet have one and a default is 

191 configured. 

192 """ 

193 if not batch.store_id: 

194 batch.store_id = self.get_default_store_id() 

195 

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

197 """ 

198 Set/update customer info for the batch. 

199 

200 This will first set one of the following: 

201 

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

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

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

205 

206 Note that a new 

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

208 is created if necessary. 

209 

210 And then it will update customer-related attributes via one of: 

211 

212 * :meth:`refresh_batch_from_external_customer()` 

213 * :meth:`refresh_batch_from_local_customer()` 

214 * :meth:`refresh_batch_from_pending_customer()` 

215 

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

217 customer attributes to be set to ``None`` also. 

218 

219 :param batch: 

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

221 update. 

222 

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

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

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

226 

227 :param user: 

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

229 is performing the action. This is used to set 

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

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

232 the batch creator is assumed. 

233 """ 

234 model = self.app.model 

235 enum = self.app.enum 

236 session = self.app.get_session(batch) 

237 use_local = self.use_local_customers() 

238 

239 # set customer info 

240 if isinstance(customer_info, str): 

241 if use_local: 

242 

243 # local_customer 

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

245 if not customer: 

246 raise ValueError("local customer not found") 

247 batch.local_customer = customer 

248 self.refresh_batch_from_local_customer(batch) 

249 

250 else: # external customer_id 

251 batch.customer_id = customer_info 

252 self.refresh_batch_from_external_customer(batch) 

253 

254 elif customer_info: 

255 

256 # pending_customer 

257 batch.customer_id = None 

258 batch.local_customer = None 

259 customer = batch.pending_customer 

260 if not customer: 

261 customer = model.PendingCustomer( 

262 status=enum.PendingCustomerStatus.PENDING, 

263 created_by=user or batch.created_by, 

264 ) 

265 session.add(customer) 

266 batch.pending_customer = customer 

267 fields = [ 

268 "full_name", 

269 "first_name", 

270 "last_name", 

271 "phone_number", 

272 "email_address", 

273 ] 

274 for key in fields: 

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

276 if "full_name" not in customer_info: 

277 customer.full_name = self.app.make_full_name( 

278 customer.first_name, customer.last_name 

279 ) 

280 self.refresh_batch_from_pending_customer(batch) 

281 

282 else: 

283 

284 # null 

285 batch.customer_id = None 

286 batch.local_customer = None 

287 batch.customer_name = None 

288 batch.phone_number = None 

289 batch.email_address = None 

290 

291 session.flush() 

292 

293 def refresh_batch_from_external_customer(self, batch): 

294 """ 

295 Update customer-related attributes on the batch, from its 

296 :term:`external customer` record. 

297 

298 This is called automatically from :meth:`set_customer()`. 

299 

300 There is no default logic here; subclass must implement. 

301 """ 

302 raise NotImplementedError 

303 

304 def refresh_batch_from_local_customer(self, batch): 

305 """ 

306 Update customer-related attributes on the batch, from its 

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

308 record. 

309 

310 This is called automatically from :meth:`set_customer()`. 

311 """ 

312 customer = batch.local_customer 

313 batch.customer_name = customer.full_name 

314 batch.phone_number = customer.phone_number 

315 batch.email_address = customer.email_address 

316 

317 def refresh_batch_from_pending_customer(self, batch): 

318 """ 

319 Update customer-related attributes on the batch, from its 

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

321 record. 

322 

323 This is called automatically from :meth:`set_customer()`. 

324 """ 

325 customer = batch.pending_customer 

326 batch.customer_name = customer.full_name 

327 batch.phone_number = customer.phone_number 

328 batch.email_address = customer.email_address 

329 

330 def autocomplete_products_external(self, session, term, user=None): 

331 """ 

332 Return autocomplete search results for :term:`external 

333 product` records. 

334 

335 There is no default logic here; subclass must implement. 

336 

337 :param session: Current app :term:`db session`. 

338 

339 :param term: Search term string from user input. 

340 

341 :param user: 

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

343 is doing the search, if known. 

344 

345 :returns: List of search results; each should be a dict with 

346 ``value`` and ``label`` keys. 

347 """ 

348 raise NotImplementedError 

349 

350 def autocomplete_products_local( # pylint: disable=unused-argument 

351 self, session, term, user=None 

352 ): 

353 """ 

354 Return autocomplete search results for 

355 :class:`~sideshow.db.model.products.LocalProduct` records. 

356 

357 :param session: Current app :term:`db session`. 

358 

359 :param term: Search term string from user input. 

360 

361 :param user: 

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

363 is doing the search, if known. 

364 

365 :returns: List of search results; each should be a dict with 

366 ``value`` and ``label`` keys. 

367 """ 

368 model = self.app.model 

369 

370 # base query 

371 query = session.query(model.LocalProduct) 

372 

373 # filter query 

374 criteria = [] 

375 for word in term.split(): 

376 criteria.append( 

377 sa.or_( 

378 model.LocalProduct.brand_name.ilike(f"%{word}%"), 

379 model.LocalProduct.description.ilike(f"%{word}%"), 

380 ) 

381 ) 

382 query = query.filter(sa.and_(*criteria)) 

383 

384 # sort query 

385 query = query.order_by( 

386 model.LocalProduct.brand_name, model.LocalProduct.description 

387 ) 

388 

389 # get data 

390 # TODO: need max_results option 

391 products = query.all() 

392 

393 # get results 

394 def result(product): 

395 return {"value": product.uuid.hex, "label": product.full_description} 

396 

397 return [result(c) for c in products] 

398 

399 def get_default_uom_choices(self): 

400 """ 

401 Returns a list of ordering UOM choices which should be 

402 presented to the user by default. 

403 

404 The built-in logic here will return everything from 

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

406 

407 :returns: List of dicts, each with ``key`` and ``value`` 

408 corresponding to the UOM code and label, respectively. 

409 """ 

410 enum = self.app.enum 

411 return [{"key": key, "value": val} for key, val in enum.ORDER_UOM.items()] 

412 

413 def get_product_info_external(self, session, product_id, user=None): 

414 """ 

415 Returns basic info for an :term:`external product` as pertains 

416 to ordering. 

417 

418 When user has located a product via search, and must then 

419 choose order quantity and UOM based on case size, pricing 

420 etc., this method is called to retrieve the product info. 

421 

422 There is no default logic here; subclass must implement. See 

423 also :meth:`get_product_info_local()`. 

424 

425 :param session: Current app :term:`db session`. 

426 

427 :param product_id: Product ID string for which to retrieve 

428 info. 

429 

430 :param user: 

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

432 is performing the action, if known. 

433 

434 :returns: Dict of product info. Should raise error instead of 

435 returning ``None`` if product not found. 

436 

437 This method should only be called after a product has been 

438 identified via autocomplete/search lookup; therefore the 

439 ``product_id`` should be valid, and the caller can expect this 

440 method to *always* return a dict. If for some reason the 

441 product cannot be found here, an error should be raised. 

442 

443 The dict should contain as much product info as is available 

444 and needed; if some are missing it should not cause too much 

445 trouble in the app. Here is a basic example:: 

446 

447 def get_product_info_external(self, session, product_id, user=None): 

448 ext_model = get_external_model() 

449 ext_session = make_external_session() 

450 

451 ext_product = ext_session.get(ext_model.Product, product_id) 

452 if not ext_product: 

453 ext_session.close() 

454 raise ValueError(f"external product not found: {product_id}") 

455 

456 info = { 

457 'product_id': product_id, 

458 'scancode': product.scancode, 

459 'brand_name': product.brand_name, 

460 'description': product.description, 

461 'size': product.size, 

462 'weighed': product.sold_by_weight, 

463 'special_order': False, 

464 'department_id': str(product.department_number), 

465 'department_name': product.department_name, 

466 'case_size': product.case_size, 

467 'unit_price_reg': product.unit_price_reg, 

468 'vendor_name': product.vendor_name, 

469 'vendor_item_code': product.vendor_item_code, 

470 } 

471 

472 ext_session.close() 

473 return info 

474 """ 

475 raise NotImplementedError 

476 

477 def get_product_info_local( # pylint: disable=unused-argument 

478 self, session, uuid, user=None 

479 ): 

480 """ 

481 Returns basic info for a :term:`local product` as pertains to 

482 ordering. 

483 

484 When user has located a product via search, and must then 

485 choose order quantity and UOM based on case size, pricing 

486 etc., this method is called to retrieve the product info. 

487 

488 See :meth:`get_product_info_external()` for more explanation. 

489 

490 This method will locate the 

491 :class:`~sideshow.db.model.products.LocalProduct` record, then 

492 (if found) it calls :meth:`normalize_local_product()` and 

493 returns the result. 

494 

495 :param session: Current :term:`db session`. 

496 

497 :param uuid: UUID for the desired 

498 :class:`~sideshow.db.model.products.LocalProduct`. 

499 

500 :param user: 

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

502 is performing the action, if known. 

503 

504 :returns: Dict of product info. 

505 """ 

506 model = self.app.model 

507 product = session.get(model.LocalProduct, uuid) 

508 if not product: 

509 raise ValueError(f"Local Product not found: {uuid}") 

510 

511 return self.normalize_local_product(product) 

512 

513 def normalize_local_product(self, product): 

514 """ 

515 Returns a normalized dict of info for the given :term:`local 

516 product`. 

517 

518 This is called by: 

519 

520 * :meth:`get_product_info_local()` 

521 * :meth:`get_past_products()` 

522 

523 :param product: 

524 :class:`~sideshow.db.model.products.LocalProduct` instance. 

525 

526 :returns: Dict of product info. 

527 

528 The keys for this dict should essentially one-to-one for the 

529 product fields, with one exception: 

530 

531 * ``product_id`` will be set to the product UUID as string 

532 """ 

533 return { 

534 "product_id": product.uuid.hex, 

535 "scancode": product.scancode, 

536 "brand_name": product.brand_name, 

537 "description": product.description, 

538 "size": product.size, 

539 "full_description": product.full_description, 

540 "weighed": product.weighed, 

541 "special_order": product.special_order, 

542 "department_id": product.department_id, 

543 "department_name": product.department_name, 

544 "case_size": product.case_size, 

545 "unit_price_reg": product.unit_price_reg, 

546 "vendor_name": product.vendor_name, 

547 "vendor_item_code": product.vendor_item_code, 

548 } 

549 

550 def get_past_orders(self, batch): 

551 """ 

552 Retrieve a (possibly empty) list of past :term:`orders 

553 <order>` for the batch customer. 

554 

555 This is called by :meth:`get_past_products()`. 

556 

557 :param batch: 

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

559 instance. 

560 

561 :returns: List of :class:`~sideshow.db.model.orders.Order` 

562 records. 

563 """ 

564 model = self.app.model 

565 session = self.app.get_session(batch) 

566 orders = session.query(model.Order) 

567 

568 if batch.customer_id: 

569 orders = orders.filter(model.Order.customer_id == batch.customer_id) 

570 elif batch.local_customer: 

571 orders = orders.filter(model.Order.local_customer == batch.local_customer) 

572 else: 

573 raise ValueError(f"batch has no customer: {batch}") 

574 

575 orders = orders.order_by(model.Order.created.desc()) 

576 return orders.all() 

577 

578 def get_past_products(self, batch, user=None): 

579 """ 

580 Retrieve a (possibly empty) list of products which have been 

581 previously ordered by the batch customer. 

582 

583 Note that this does not return :term:`order items <order 

584 item>`, nor does it return true product records, but rather it 

585 returns a list of dicts. Each will have product info but will 

586 *not* have order quantity etc. 

587 

588 This method calls :meth:`get_past_orders()` and then iterates 

589 through each order item therein. Any duplicated products 

590 encountered will be skipped, so the final list contains unique 

591 products. 

592 

593 Each dict in the result is obtained by calling one of: 

594 

595 * :meth:`normalize_local_product()` 

596 * :meth:`get_product_info_external()` 

597 

598 :param batch: 

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

600 instance. 

601 

602 :param user: 

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

604 is performing the action, if known. 

605 

606 :returns: List of product info dicts. 

607 """ 

608 session = self.app.get_session(batch) 

609 use_local = self.use_local_products() 

610 user = user or batch.created_by 

611 products = OrderedDict() 

612 

613 # track down all order items for batch contact 

614 for order in self.get_past_orders(batch): 

615 for item in order.items: 

616 

617 # nb. we only need the first match for each product 

618 if use_local: 

619 product = item.local_product 

620 if product and product.uuid not in products: 

621 products[product.uuid] = self.normalize_local_product(product) 

622 elif item.product_id and item.product_id not in products: 

623 products[item.product_id] = self.get_product_info_external( 

624 session, item.product_id, user=user 

625 ) 

626 

627 products = list(products.values()) 

628 for product in products: 

629 

630 price = product["unit_price_reg"] 

631 

632 if "unit_price_reg_display" not in product: 

633 product["unit_price_reg_display"] = self.app.render_currency(price) 

634 

635 if "unit_price_quoted" not in product: 

636 product["unit_price_quoted"] = price 

637 

638 if "unit_price_quoted_display" not in product: 

639 product["unit_price_quoted_display"] = product["unit_price_reg_display"] 

640 

641 if ( 

642 "case_price_quoted" not in product 

643 and product.get("unit_price_quoted") is not None 

644 and product.get("case_size") is not None 

645 ): 

646 product["case_price_quoted"] = ( 

647 product["unit_price_quoted"] * product["case_size"] 

648 ) 

649 

650 if ( 

651 "case_price_quoted_display" not in product 

652 and "case_price_quoted" in product 

653 ): 

654 product["case_price_quoted_display"] = self.app.render_currency( 

655 product["case_price_quoted"] 

656 ) 

657 

658 return products 

659 

660 def add_item( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals 

661 self, 

662 batch, 

663 product_info, 

664 order_qty, 

665 order_uom, 

666 discount_percent=None, 

667 user=None, 

668 ): 

669 """ 

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

671 

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

673 

674 :param batch: 

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

676 update. 

677 

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

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

680 

681 :param order_qty: 

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

683 value for the new row. 

684 

685 :param order_uom: 

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

687 value for the new row. 

688 

689 :param discount_percent: Sets the 

690 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent` 

691 for the row, if allowed. 

692 

693 :param user: 

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

695 is performing the action. This is used to set 

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

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

698 the batch creator is assumed. 

699 

700 :returns: 

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

702 instance. 

703 """ 

704 model = self.app.model 

705 enum = self.app.enum 

706 session = self.app.get_session(batch) 

707 use_local = self.use_local_products() 

708 row = self.make_row() 

709 

710 # set product info 

711 if isinstance(product_info, str): 

712 if use_local: 

713 

714 # local_product 

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

716 if not local: 

717 raise ValueError("local product not found") 

718 row.local_product = local 

719 

720 else: # external product_id 

721 row.product_id = product_info 

722 

723 else: 

724 # pending_product 

725 if not self.allow_unknown_products(): 

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

727 row.product_id = None 

728 row.local_product = None 

729 pending = model.PendingProduct( 

730 status=enum.PendingProductStatus.PENDING, 

731 created_by=user or batch.created_by, 

732 ) 

733 fields = [ 

734 "scancode", 

735 "brand_name", 

736 "description", 

737 "size", 

738 "weighed", 

739 "department_id", 

740 "department_name", 

741 "special_order", 

742 "vendor_name", 

743 "vendor_item_code", 

744 "case_size", 

745 "unit_cost", 

746 "unit_price_reg", 

747 "notes", 

748 ] 

749 for key in fields: 

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

751 

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

753 session.add(pending) 

754 session.flush() 

755 session.refresh(pending) 

756 row.pending_product = pending 

757 

758 # set order info 

759 row.order_qty = order_qty 

760 row.order_uom = order_uom 

761 

762 # discount 

763 if self.allow_item_discounts(): 

764 row.discount_percent = discount_percent or 0 

765 

766 # add row to batch 

767 self.add_row(batch, row) 

768 session.flush() 

769 return row 

770 

771 def update_item( # pylint: disable=too-many-arguments,too-many-positional-arguments 

772 self, row, product_info, order_qty, order_uom, discount_percent=None, user=None 

773 ): 

774 """ 

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

776 

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

778 

779 :param row: 

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

781 to update. 

782 

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

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

785 

786 :param order_qty: New 

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

788 value for the row. 

789 

790 :param order_uom: New 

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

792 value for the row. 

793 

794 :param discount_percent: Sets the 

795 :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent` 

796 for the row, if allowed. 

797 

798 :param user: 

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

800 is performing the action. This is used to set 

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

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

803 the batch creator is assumed. 

804 """ 

805 model = self.app.model 

806 enum = self.app.enum 

807 session = self.app.get_session(row) 

808 use_local = self.use_local_products() 

809 

810 # set product info 

811 if isinstance(product_info, str): 

812 if use_local: 

813 

814 # local_product 

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

816 if not local: 

817 raise ValueError("local product not found") 

818 row.local_product = local 

819 

820 else: # external product_id 

821 row.product_id = product_info 

822 

823 else: 

824 # pending_product 

825 if not self.allow_unknown_products(): 

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

827 row.product_id = None 

828 row.local_product = None 

829 pending = row.pending_product 

830 if not pending: 

831 pending = model.PendingProduct( 

832 status=enum.PendingProductStatus.PENDING, 

833 created_by=user or row.batch.created_by, 

834 ) 

835 session.add(pending) 

836 row.pending_product = pending 

837 fields = [ 

838 "scancode", 

839 "brand_name", 

840 "description", 

841 "size", 

842 "weighed", 

843 "department_id", 

844 "department_name", 

845 "special_order", 

846 "vendor_name", 

847 "vendor_item_code", 

848 "case_size", 

849 "unit_cost", 

850 "unit_price_reg", 

851 "notes", 

852 ] 

853 for key in fields: 

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

855 

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

857 session.flush() 

858 session.refresh(pending) 

859 

860 # set order info 

861 row.order_qty = order_qty 

862 row.order_uom = order_uom 

863 

864 # discount 

865 if self.allow_item_discounts(): 

866 row.discount_percent = discount_percent or 0 

867 

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

869 session.flush() 

870 session.refresh(row) 

871 

872 # refresh per new info 

873 self.refresh_row(row) 

874 

875 def refresh_row(self, row): # pylint: disable=too-many-branches 

876 """ 

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

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

879 changing order quantity). 

880 

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

882 attributes: 

883 

884 * :meth:`refresh_row_from_external_product()` 

885 * :meth:`refresh_row_from_local_product()` 

886 * :meth:`refresh_row_from_pending_product()` 

887 

888 It then re-calculates the row's 

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

890 and updates the batch accordingly. 

891 

892 It also sets the row 

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

894 """ 

895 enum = self.app.enum 

896 row.status_code = None 

897 row.status_text = None 

898 

899 # ensure product 

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

901 row.status_code = row.STATUS_MISSING_PRODUCT 

902 return 

903 

904 # ensure order qty/uom 

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

906 row.status_code = row.STATUS_MISSING_ORDER_QTY 

907 return 

908 

909 # update product attrs on row 

910 if row.product_id: 

911 self.refresh_row_from_external_product(row) 

912 elif row.local_product: 

913 self.refresh_row_from_local_product(row) 

914 else: 

915 self.refresh_row_from_pending_product(row) 

916 

917 # we need to know if total price changes 

918 old_total = row.total_price 

919 

920 # update quoted price 

921 row.unit_price_quoted = None 

922 row.case_price_quoted = None 

923 if row.unit_price_sale is not None and ( 

924 not row.sale_ends or row.sale_ends > self.app.make_utc() 

925 ): 

926 row.unit_price_quoted = row.unit_price_sale 

927 else: 

928 row.unit_price_quoted = row.unit_price_reg 

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

930 row.case_price_quoted = row.unit_price_quoted * row.case_size 

931 

932 # update row total price 

933 row.total_price = None 

934 if row.order_uom == enum.ORDER_UOM_CASE: 

935 # TODO: why are we not using case price again? 

936 # if row.case_price_quoted: 

937 # row.total_price = row.case_price_quoted * row.order_qty 

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

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

940 else: # ORDER_UOM_UNIT (or similar) 

941 if row.unit_price_quoted is not None: 

942 row.total_price = row.unit_price_quoted * row.order_qty 

943 if row.total_price is not None: 

944 if row.discount_percent and self.allow_item_discounts(): 

945 row.total_price = ( 

946 float(row.total_price) * (100 - float(row.discount_percent)) / 100.0 

947 ) 

948 row.total_price = decimal.Decimal(f"{row.total_price:0.2f}") 

949 

950 # update batch if total price changed 

951 if row.total_price != old_total: 

952 batch = row.batch 

953 batch.total_price = ( 

954 (batch.total_price or 0) + (row.total_price or 0) - (old_total or 0) 

955 ) 

956 

957 # all ok 

958 row.status_code = row.STATUS_OK 

959 

960 def refresh_row_from_local_product(self, row): 

961 """ 

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

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

964 record. 

965 

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

967 """ 

968 product = row.local_product 

969 row.product_scancode = product.scancode 

970 row.product_brand = product.brand_name 

971 row.product_description = product.description 

972 row.product_size = product.size 

973 row.product_weighed = product.weighed 

974 row.department_id = product.department_id 

975 row.department_name = product.department_name 

976 row.special_order = product.special_order 

977 row.vendor_name = product.vendor_name 

978 row.vendor_item_code = product.vendor_item_code 

979 row.case_size = product.case_size 

980 row.unit_cost = product.unit_cost 

981 row.unit_price_reg = product.unit_price_reg 

982 

983 def refresh_row_from_pending_product(self, row): 

984 """ 

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

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

987 record. 

988 

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

990 """ 

991 product = row.pending_product 

992 row.product_scancode = product.scancode 

993 row.product_brand = product.brand_name 

994 row.product_description = product.description 

995 row.product_size = product.size 

996 row.product_weighed = product.weighed 

997 row.department_id = product.department_id 

998 row.department_name = product.department_name 

999 row.special_order = product.special_order 

1000 row.vendor_name = product.vendor_name 

1001 row.vendor_item_code = product.vendor_item_code 

1002 row.case_size = product.case_size 

1003 row.unit_cost = product.unit_cost 

1004 row.unit_price_reg = product.unit_price_reg 

1005 

1006 def refresh_row_from_external_product(self, row): 

1007 """ 

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

1009 :term:`external product` record indicated by 

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

1011 

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

1013 

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

1015 needed. 

1016 """ 

1017 raise NotImplementedError 

1018 

1019 def remove_row(self, row): 

1020 """ 

1021 Remove a row from its batch. 

1022 

1023 This also will update the batch 

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

1025 accordingly. 

1026 """ 

1027 if row.total_price: 

1028 batch = row.batch 

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

1030 

1031 super().remove_row(row) 

1032 

1033 def do_delete(self, batch, user, **kwargs): # pylint: disable=arguments-differ 

1034 """ 

1035 Delete a batch completely. 

1036 

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

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

1039 referenced by some order(s). 

1040 """ 

1041 session = self.app.get_session(batch) 

1042 

1043 # maybe delete pending customer 

1044 customer = batch.pending_customer 

1045 if customer and not customer.orders: 

1046 session.delete(customer) 

1047 

1048 # maybe delete pending products 

1049 for row in batch.rows: 

1050 product = row.pending_product 

1051 if product and not product.order_items: 

1052 session.delete(product) 

1053 

1054 # continue with normal deletion 

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

1056 

1057 def why_not_execute(self, batch, **kwargs): # pylint: disable=arguments-differ 

1058 """ 

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

1060 phone number, and at least one item. It also may check to 

1061 ensure the store is assigned, if applicable. 

1062 """ 

1063 if not batch.store_id: 

1064 order_handler = self.app.get_order_handler() 

1065 if order_handler.expose_store_id(): 

1066 return "Must assign the store" 

1067 

1068 if not batch.customer_name: 

1069 return "Must assign the customer" 

1070 

1071 if not batch.phone_number: 

1072 return "Customer phone number is required" 

1073 

1074 rows = self.get_effective_rows(batch) 

1075 if not rows: 

1076 return "Must add at least one valid item" 

1077 

1078 return None 

1079 

1080 def get_effective_rows(self, batch): 

1081 """ 

1082 Only rows with 

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

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

1085 be created as proper order items. 

1086 """ 

1087 return [row for row in batch.rows if row.status_code == row.STATUS_OK] 

1088 

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

1090 """ 

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

1092 

1093 By default, this will call: 

1094 

1095 * :meth:`make_local_customer()` 

1096 * :meth:`process_pending_products()` 

1097 * :meth:`make_new_order()` 

1098 

1099 And will return the new 

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

1101 

1102 Note that callers should use 

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

1104 instead, which calls this method automatically. 

1105 """ 

1106 rows = self.get_effective_rows(batch) 

1107 self.make_local_customer(batch) 

1108 self.process_pending_products(batch, rows) 

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

1110 return order 

1111 

1112 def make_local_customer(self, batch): 

1113 """ 

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

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

1116 

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

1118 

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

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

1121 customer (so far). 

1122 

1123 It will create a new 

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

1125 populate it from the batch 

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

1127 The latter is then deleted. 

1128 """ 

1129 if not self.use_local_customers(): 

1130 return 

1131 

1132 # nothing to do if no pending customer 

1133 pending = batch.pending_customer 

1134 if not pending: 

1135 return 

1136 

1137 session = self.app.get_session(batch) 

1138 

1139 # maybe convert pending to local customer 

1140 if not batch.local_customer: 

1141 model = self.app.model 

1142 inspector = sa.inspect(model.LocalCustomer) 

1143 local = model.LocalCustomer() 

1144 for prop in inspector.column_attrs: 

1145 if hasattr(pending, prop.key): 

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

1147 session.add(local) 

1148 batch.local_customer = local 

1149 

1150 # remove pending customer 

1151 batch.pending_customer = None 

1152 session.delete(pending) 

1153 session.flush() 

1154 

1155 def process_pending_products(self, batch, rows): 

1156 """ 

1157 Process any :term:`pending products <pending product>` which 

1158 are present in the batch. 

1159 

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

1161 

1162 If :term:`local products <local product>` are used, this will 

1163 convert the pending products to local products. 

1164 

1165 If :term:`external products <external product>` are used, this 

1166 will update the pending product records' status to indicate 

1167 they are ready to be resolved. 

1168 """ 

1169 enum = self.app.enum 

1170 model = self.app.model 

1171 session = self.app.get_session(batch) 

1172 

1173 if self.use_local_products(): 

1174 inspector = sa.inspect(model.LocalProduct) 

1175 for row in rows: 

1176 

1177 if row.local_product or not row.pending_product: 

1178 continue 

1179 

1180 pending = row.pending_product 

1181 local = model.LocalProduct() 

1182 

1183 for prop in inspector.column_attrs: 

1184 if hasattr(pending, prop.key): 

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

1186 session.add(local) 

1187 

1188 row.local_product = local 

1189 row.pending_product = None 

1190 session.delete(pending) 

1191 

1192 else: # external products; pending should be marked 'ready' 

1193 for row in rows: 

1194 pending = row.pending_product 

1195 if pending: 

1196 pending.status = enum.PendingProductStatus.READY 

1197 

1198 session.flush() 

1199 

1200 def make_new_order(self, batch, rows, user=None, progress=None): 

1201 """ 

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

1203 

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

1205 

1206 :param batch: 

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

1208 instance. 

1209 

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

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

1212 item>`. 

1213 

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

1215 """ 

1216 model = self.app.model 

1217 session = self.app.get_session(batch) 

1218 

1219 batch_fields = [ 

1220 "store_id", 

1221 "customer_id", 

1222 "local_customer", 

1223 "pending_customer", 

1224 "customer_name", 

1225 "phone_number", 

1226 "email_address", 

1227 "total_price", 

1228 ] 

1229 

1230 row_fields = [ 

1231 "product_id", 

1232 "local_product", 

1233 "pending_product", 

1234 "product_scancode", 

1235 "product_brand", 

1236 "product_description", 

1237 "product_size", 

1238 "product_weighed", 

1239 "department_id", 

1240 "department_name", 

1241 "vendor_name", 

1242 "vendor_item_code", 

1243 "case_size", 

1244 "order_qty", 

1245 "order_uom", 

1246 "unit_cost", 

1247 "unit_price_quoted", 

1248 "case_price_quoted", 

1249 "unit_price_reg", 

1250 "unit_price_sale", 

1251 "sale_ends", 

1252 "discount_percent", 

1253 "total_price", 

1254 "special_order", 

1255 ] 

1256 

1257 # make order 

1258 kw = {field: getattr(batch, field) for field in batch_fields} 

1259 kw["order_id"] = batch.id 

1260 kw["created_by"] = user 

1261 order = model.Order(**kw) 

1262 session.add(order) 

1263 session.flush() 

1264 

1265 def convert(row, i): # pylint: disable=unused-argument 

1266 

1267 # make order item 

1268 kw = {field: getattr(row, field) for field in row_fields} 

1269 item = model.OrderItem(**kw) 

1270 order.items.append(item) 

1271 

1272 # set item status 

1273 self.set_initial_item_status(item, user) 

1274 

1275 self.app.progress_loop( 

1276 convert, rows, progress, message="Converting batch rows to order items" 

1277 ) 

1278 session.flush() 

1279 return order 

1280 

1281 def set_initial_item_status(self, item, user): 

1282 """ 

1283 Set the initial status and attach event(s) for the given item. 

1284 

1285 This is called from :meth:`make_new_order()` for each item 

1286 after it is added to the order. 

1287 

1288 Default logic will set status to 

1289 :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY` and attach 2 

1290 events: 

1291 

1292 * :data:`~sideshow.enum.ORDER_ITEM_EVENT_INITIATED` 

1293 * :data:`~sideshow.enum.ORDER_ITEM_EVENT_READY` 

1294 

1295 :param item: :class:`~sideshow.db.model.orders.OrderItem` 

1296 being added to the new order. 

1297 

1298 :param user: 

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

1300 is performing the action. 

1301 """ 

1302 enum = self.app.enum 

1303 item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user) 

1304 item.add_event(enum.ORDER_ITEM_EVENT_READY, user) 

1305 item.status_code = enum.ORDER_ITEM_STATUS_READY