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

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

24New Order Batch Handler 

25""" 

26# pylint: disable=too-many-lines 

27 

28import decimal 

29from collections import OrderedDict 

30 

31import sqlalchemy as sa 

32 

33from wuttjamaican.batch import BatchHandler 

34 

35from sideshow.db.model import NewOrderBatch 

36 

37 

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

39 """ 

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

41 batch>`. 

42 

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

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

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

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

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

48 

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

50 responsibility for the rest of the order lifecycle. 

51 """ 

52 

53 model_class = NewOrderBatch 

54 

55 def get_default_store_id(self): 

56 """ 

57 Returns the configured default value for 

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

59 or ``None``. 

60 """ 

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

62 

63 def use_local_customers(self): 

64 """ 

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

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

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

68 """ 

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

70 

71 def use_local_products(self): 

72 """ 

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

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

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

76 """ 

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

78 

79 def allow_unknown_products(self): 

80 """ 

81 Returns boolean indicating whether :term:`pending products 

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

83 

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

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

86 to choose existing local/external product. 

87 """ 

88 return self.config.get_bool( 

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

90 ) 

91 

92 def allow_item_discounts(self): 

93 """ 

94 Returns boolean indicating whether per-item discounts are 

95 allowed when creating an order. 

96 """ 

97 return self.config.get_bool( 

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

99 ) 

100 

101 def allow_item_discounts_if_on_sale(self): 

102 """ 

103 Returns boolean indicating whether per-item discounts are 

104 allowed even when the item is already on sale. 

105 """ 

106 return self.config.get_bool( 

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

108 ) 

109 

110 def get_default_item_discount(self): 

111 """ 

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

113 

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

115 """ 

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

117 if discount: 

118 return decimal.Decimal(discount) 

119 return None 

120 

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

122 """ 

123 Return autocomplete search results for :term:`external 

124 customer` records. 

125 

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

127 

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

129 

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

131 

132 :param user: 

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

134 is doing the search, if known. 

135 

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

137 ``value`` and ``label`` keys. 

138 """ 

139 raise NotImplementedError 

140 

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

142 self, session, term, user=None 

143 ): 

144 """ 

145 Return autocomplete search results for 

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

147 

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

149 

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

151 

152 :param user: 

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

154 is doing the search, if known. 

155 

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

157 ``value`` and ``label`` keys. 

158 """ 

159 model = self.app.model 

160 

161 # base query 

162 query = session.query(model.LocalCustomer) 

163 

164 # filter query 

165 criteria = [ 

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

167 ] 

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

169 

170 # sort query 

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

172 

173 # get data 

174 # TODO: need max_results option 

175 customers = query.all() 

176 

177 # get results 

178 def result(customer): 

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

180 

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

182 

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

184 """ 

185 Initialize a new batch. 

186 

187 This sets the 

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

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

190 configured. 

191 """ 

192 if not batch.store_id: 

193 batch.store_id = self.get_default_store_id() 

194 

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

196 """ 

197 Set/update customer info for the batch. 

198 

199 This will first set one of the following: 

200 

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

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

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

204 

205 Note that a new 

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

207 is created if necessary. 

208 

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

210 

211 * :meth:`refresh_batch_from_external_customer()` 

212 * :meth:`refresh_batch_from_local_customer()` 

213 * :meth:`refresh_batch_from_pending_customer()` 

214 

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

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

217 

218 :param batch: 

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

220 update. 

221 

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

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

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

225 

226 :param user: 

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

228 is performing the action. This is used to set 

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

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

231 the batch creator is assumed. 

232 """ 

233 model = self.app.model 

234 enum = self.app.enum 

235 session = self.app.get_session(batch) 

236 use_local = self.use_local_customers() 

237 

238 # set customer info 

239 if isinstance(customer_info, str): 

240 if use_local: 

241 

242 # local_customer 

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

244 if not customer: 

245 raise ValueError("local customer not found") 

246 batch.local_customer = customer 

247 self.refresh_batch_from_local_customer(batch) 

248 

249 else: # external customer_id 

250 batch.customer_id = customer_info 

251 self.refresh_batch_from_external_customer(batch) 

252 

253 elif customer_info: 

254 

255 # pending_customer 

256 batch.customer_id = None 

257 batch.local_customer = None 

258 customer = batch.pending_customer 

259 if not customer: 

260 customer = model.PendingCustomer( 

261 status=enum.PendingCustomerStatus.PENDING, 

262 created_by=user or batch.created_by, 

263 ) 

264 session.add(customer) 

265 batch.pending_customer = customer 

266 fields = [ 

267 "full_name", 

268 "first_name", 

269 "last_name", 

270 "phone_number", 

271 "email_address", 

272 ] 

273 for key in fields: 

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

275 if "full_name" not in customer_info: 

276 customer.full_name = self.app.make_full_name( 

277 customer.first_name, customer.last_name 

278 ) 

279 self.refresh_batch_from_pending_customer(batch) 

280 

281 else: 

282 

283 # null 

284 batch.customer_id = None 

285 batch.local_customer = None 

286 batch.customer_name = None 

287 batch.phone_number = None 

288 batch.email_address = None 

289 

290 session.flush() 

291 

292 def refresh_batch_from_external_customer(self, batch): 

293 """ 

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

295 :term:`external customer` record. 

296 

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

298 

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

300 """ 

301 raise NotImplementedError 

302 

303 def refresh_batch_from_local_customer(self, batch): 

304 """ 

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

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

307 record. 

308 

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

310 """ 

311 customer = batch.local_customer 

312 batch.customer_name = customer.full_name 

313 batch.phone_number = customer.phone_number 

314 batch.email_address = customer.email_address 

315 

316 def refresh_batch_from_pending_customer(self, batch): 

317 """ 

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

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

320 record. 

321 

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

323 """ 

324 customer = batch.pending_customer 

325 batch.customer_name = customer.full_name 

326 batch.phone_number = customer.phone_number 

327 batch.email_address = customer.email_address 

328 

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

330 """ 

331 Return autocomplete search results for :term:`external 

332 product` records. 

333 

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

335 

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

337 

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

339 

340 :param user: 

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

342 is doing the search, if known. 

343 

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

345 ``value`` and ``label`` keys. 

346 """ 

347 raise NotImplementedError 

348 

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

350 self, session, term, user=None 

351 ): 

352 """ 

353 Return autocomplete search results for 

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

355 

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

357 

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

359 

360 :param user: 

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

362 is doing the search, if known. 

363 

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

365 ``value`` and ``label`` keys. 

366 """ 

367 model = self.app.model 

368 

369 # base query 

370 query = session.query(model.LocalProduct) 

371 

372 # filter query 

373 criteria = [] 

374 for word in term.split(): 

375 criteria.append( 

376 sa.or_( 

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

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

379 ) 

380 ) 

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

382 

383 # sort query 

384 query = query.order_by( 

385 model.LocalProduct.brand_name, model.LocalProduct.description 

386 ) 

387 

388 # get data 

389 # TODO: need max_results option 

390 products = query.all() 

391 

392 # get results 

393 def result(product): 

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

395 

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

397 

398 def get_default_uom_choices(self): 

399 """ 

400 Returns a list of ordering UOM choices which should be 

401 presented to the user by default. 

402 

403 The built-in logic here will return everything from 

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

405 

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

407 corresponding to the UOM code and label, respectively. 

408 """ 

409 enum = self.app.enum 

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

411 

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

413 """ 

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

415 to ordering. 

416 

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

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

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

420 

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

422 also :meth:`get_product_info_local()`. 

423 

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

425 

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

427 info. 

428 

429 :param user: 

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

431 is performing the action, if known. 

432 

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

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

435 

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

437 identified via autocomplete/search lookup; therefore the 

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

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

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

441 

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

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

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

445 

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

447 ext_model = get_external_model() 

448 ext_session = make_external_session() 

449 

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

451 if not ext_product: 

452 ext_session.close() 

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

454 

455 info = { 

456 'product_id': product_id, 

457 'scancode': product.scancode, 

458 'brand_name': product.brand_name, 

459 'description': product.description, 

460 'size': product.size, 

461 'weighed': product.sold_by_weight, 

462 'special_order': False, 

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

464 'department_name': product.department_name, 

465 'case_size': product.case_size, 

466 'unit_price_reg': product.unit_price_reg, 

467 'vendor_name': product.vendor_name, 

468 'vendor_item_code': product.vendor_item_code, 

469 } 

470 

471 ext_session.close() 

472 return info 

473 """ 

474 raise NotImplementedError 

475 

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

477 self, session, uuid, user=None 

478 ): 

479 """ 

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

481 ordering. 

482 

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

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

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

486 

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

488 

489 This method will locate the 

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

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

492 returns the result. 

493 

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

495 

496 :param uuid: UUID for the desired 

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

498 

499 :param user: 

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

501 is performing the action, if known. 

502 

503 :returns: Dict of product info. 

504 """ 

505 model = self.app.model 

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

507 if not product: 

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

509 

510 return self.normalize_local_product(product) 

511 

512 def normalize_local_product(self, product): 

513 """ 

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

515 product`. 

516 

517 This is called by: 

518 

519 * :meth:`get_product_info_local()` 

520 * :meth:`get_past_products()` 

521 

522 :param product: 

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

524 

525 :returns: Dict of product info. 

526 

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

528 product fields, with one exception: 

529 

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

531 """ 

532 return { 

533 "product_id": product.uuid.hex, 

534 "scancode": product.scancode, 

535 "brand_name": product.brand_name, 

536 "description": product.description, 

537 "size": product.size, 

538 "full_description": product.full_description, 

539 "weighed": product.weighed, 

540 "special_order": product.special_order, 

541 "department_id": product.department_id, 

542 "department_name": product.department_name, 

543 "case_size": product.case_size, 

544 "unit_price_reg": product.unit_price_reg, 

545 "vendor_name": product.vendor_name, 

546 "vendor_item_code": product.vendor_item_code, 

547 } 

548 

549 def get_past_orders(self, batch): 

550 """ 

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

552 <order>` for the batch customer. 

553 

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

555 

556 :param batch: 

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

558 instance. 

559 

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

561 records. 

562 """ 

563 model = self.app.model 

564 session = self.app.get_session(batch) 

565 orders = session.query(model.Order) 

566 

567 if batch.customer_id: 

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

569 elif batch.local_customer: 

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

571 else: 

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

573 

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

575 return orders.all() 

576 

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

578 """ 

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

580 previously ordered by the batch customer. 

581 

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

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

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

585 *not* have order quantity etc. 

586 

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

588 through each order item therein. Any duplicated products 

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

590 products. 

591 

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

593 

594 * :meth:`normalize_local_product()` 

595 * :meth:`get_product_info_external()` 

596 

597 :param batch: 

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

599 instance. 

600 

601 :param user: 

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

603 is performing the action, if known. 

604 

605 :returns: List of product info dicts. 

606 """ 

607 session = self.app.get_session(batch) 

608 use_local = self.use_local_products() 

609 user = user or batch.created_by 

610 products = OrderedDict() 

611 

612 # track down all order items for batch contact 

613 for order in self.get_past_orders(batch): 

614 for item in order.items: 

615 

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

617 if use_local: 

618 product = item.local_product 

619 if product and product.uuid not in products: 

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

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

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

623 session, item.product_id, user=user 

624 ) 

625 

626 products = list(products.values()) 

627 for product in products: 

628 

629 price = product["unit_price_reg"] 

630 

631 if "unit_price_reg_display" not in product: 

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

633 

634 if "unit_price_quoted" not in product: 

635 product["unit_price_quoted"] = price 

636 

637 if "unit_price_quoted_display" not in product: 

638 product["unit_price_quoted_display"] = product["unit_price_reg_display"] 

639 

640 if ( 

641 "case_price_quoted" not in product 

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

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

644 ): 

645 product["case_price_quoted"] = ( 

646 product["unit_price_quoted"] * product["case_size"] 

647 ) 

648 

649 if ( 

650 "case_price_quoted_display" not in product 

651 and "case_price_quoted" in product 

652 ): 

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

654 product["case_price_quoted"] 

655 ) 

656 

657 return products 

658 

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

660 self, 

661 batch, 

662 product_info, 

663 order_qty, 

664 order_uom, 

665 discount_percent=None, 

666 user=None, 

667 ): 

668 """ 

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

670 

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

672 

673 :param batch: 

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

675 update. 

676 

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

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

679 

680 :param order_qty: 

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

682 value for the new row. 

683 

684 :param order_uom: 

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

686 value for the new row. 

687 

688 :param discount_percent: Sets the 

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

690 for the row, if allowed. 

691 

692 :param user: 

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

694 is performing the action. This is used to set 

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

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

697 the batch creator is assumed. 

698 

699 :returns: 

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

701 instance. 

702 """ 

703 model = self.app.model 

704 enum = self.app.enum 

705 session = self.app.get_session(batch) 

706 use_local = self.use_local_products() 

707 row = self.make_row() 

708 

709 # set product info 

710 if isinstance(product_info, str): 

711 if use_local: 

712 

713 # local_product 

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

715 if not local: 

716 raise ValueError("local product not found") 

717 row.local_product = local 

718 

719 else: # external product_id 

720 row.product_id = product_info 

721 

722 else: 

723 # pending_product 

724 if not self.allow_unknown_products(): 

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

726 row.product_id = None 

727 row.local_product = None 

728 pending = model.PendingProduct( 

729 status=enum.PendingProductStatus.PENDING, 

730 created_by=user or batch.created_by, 

731 ) 

732 fields = [ 

733 "scancode", 

734 "brand_name", 

735 "description", 

736 "size", 

737 "weighed", 

738 "department_id", 

739 "department_name", 

740 "special_order", 

741 "vendor_name", 

742 "vendor_item_code", 

743 "case_size", 

744 "unit_cost", 

745 "unit_price_reg", 

746 "notes", 

747 ] 

748 for key in fields: 

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

750 

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

752 session.add(pending) 

753 session.flush() 

754 session.refresh(pending) 

755 row.pending_product = pending 

756 

757 # set order info 

758 row.order_qty = order_qty 

759 row.order_uom = order_uom 

760 

761 # discount 

762 if self.allow_item_discounts(): 

763 row.discount_percent = discount_percent or 0 

764 

765 # add row to batch 

766 self.add_row(batch, row) 

767 session.flush() 

768 return row 

769 

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

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

772 ): 

773 """ 

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

775 

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

777 

778 :param row: 

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

780 to update. 

781 

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

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

784 

785 :param order_qty: New 

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

787 value for the row. 

788 

789 :param order_uom: New 

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

791 value for the row. 

792 

793 :param discount_percent: Sets the 

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

795 for the row, if allowed. 

796 

797 :param user: 

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

799 is performing the action. This is used to set 

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

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

802 the batch creator is assumed. 

803 """ 

804 model = self.app.model 

805 enum = self.app.enum 

806 session = self.app.get_session(row) 

807 use_local = self.use_local_products() 

808 

809 # set product info 

810 if isinstance(product_info, str): 

811 if use_local: 

812 

813 # local_product 

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

815 if not local: 

816 raise ValueError("local product not found") 

817 row.local_product = local 

818 

819 else: # external product_id 

820 row.product_id = product_info 

821 

822 else: 

823 # pending_product 

824 if not self.allow_unknown_products(): 

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

826 row.product_id = None 

827 row.local_product = None 

828 pending = row.pending_product 

829 if not pending: 

830 pending = model.PendingProduct( 

831 status=enum.PendingProductStatus.PENDING, 

832 created_by=user or row.batch.created_by, 

833 ) 

834 session.add(pending) 

835 row.pending_product = pending 

836 fields = [ 

837 "scancode", 

838 "brand_name", 

839 "description", 

840 "size", 

841 "weighed", 

842 "department_id", 

843 "department_name", 

844 "special_order", 

845 "vendor_name", 

846 "vendor_item_code", 

847 "case_size", 

848 "unit_cost", 

849 "unit_price_reg", 

850 "notes", 

851 ] 

852 for key in fields: 

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

854 

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

856 session.flush() 

857 session.refresh(pending) 

858 

859 # set order info 

860 row.order_qty = order_qty 

861 row.order_uom = order_uom 

862 

863 # discount 

864 if self.allow_item_discounts(): 

865 row.discount_percent = discount_percent or 0 

866 

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

868 session.flush() 

869 session.refresh(row) 

870 

871 # refresh per new info 

872 self.refresh_row(row) 

873 

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

875 """ 

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

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

878 changing order quantity). 

879 

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

881 attributes: 

882 

883 * :meth:`refresh_row_from_external_product()` 

884 * :meth:`refresh_row_from_local_product()` 

885 * :meth:`refresh_row_from_pending_product()` 

886 

887 It then re-calculates the row's 

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

889 and updates the batch accordingly. 

890 

891 It also sets the row 

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

893 """ 

894 enum = self.app.enum 

895 row.status_code = None 

896 row.status_text = None 

897 

898 # ensure product 

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

900 row.status_code = row.STATUS_MISSING_PRODUCT 

901 return 

902 

903 # ensure order qty/uom 

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

905 row.status_code = row.STATUS_MISSING_ORDER_QTY 

906 return 

907 

908 # update product attrs on row 

909 if row.product_id: 

910 self.refresh_row_from_external_product(row) 

911 elif row.local_product: 

912 self.refresh_row_from_local_product(row) 

913 else: 

914 self.refresh_row_from_pending_product(row) 

915 

916 # we need to know if total price changes 

917 old_total = row.total_price 

918 

919 # update quoted price 

920 row.unit_price_quoted = None 

921 row.case_price_quoted = None 

922 if row.unit_price_sale is not None and ( 

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

924 ): 

925 row.unit_price_quoted = row.unit_price_sale 

926 else: 

927 row.unit_price_quoted = row.unit_price_reg 

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

929 row.case_price_quoted = row.unit_price_quoted * row.case_size 

930 

931 # update row total price 

932 row.total_price = None 

933 if row.order_uom == enum.ORDER_UOM_CASE: 

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

935 # if row.case_price_quoted: 

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

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

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

939 else: # ORDER_UOM_UNIT (or similar) 

940 if row.unit_price_quoted is not None: 

941 row.total_price = row.unit_price_quoted * row.order_qty 

942 if row.total_price is not None: 

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

944 row.total_price = ( 

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

946 ) 

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

948 

949 # update batch if total price changed 

950 if row.total_price != old_total: 

951 batch = row.batch 

952 batch.total_price = ( 

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

954 ) 

955 

956 # all ok 

957 row.status_code = row.STATUS_OK 

958 

959 def refresh_row_from_local_product(self, row): 

960 """ 

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

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

963 record. 

964 

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

966 """ 

967 product = row.local_product 

968 row.product_scancode = product.scancode 

969 row.product_brand = product.brand_name 

970 row.product_description = product.description 

971 row.product_size = product.size 

972 row.product_weighed = product.weighed 

973 row.department_id = product.department_id 

974 row.department_name = product.department_name 

975 row.special_order = product.special_order 

976 row.vendor_name = product.vendor_name 

977 row.vendor_item_code = product.vendor_item_code 

978 row.case_size = product.case_size 

979 row.unit_cost = product.unit_cost 

980 row.unit_price_reg = product.unit_price_reg 

981 

982 def refresh_row_from_pending_product(self, row): 

983 """ 

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

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

986 record. 

987 

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

989 """ 

990 product = row.pending_product 

991 row.product_scancode = product.scancode 

992 row.product_brand = product.brand_name 

993 row.product_description = product.description 

994 row.product_size = product.size 

995 row.product_weighed = product.weighed 

996 row.department_id = product.department_id 

997 row.department_name = product.department_name 

998 row.special_order = product.special_order 

999 row.vendor_name = product.vendor_name 

1000 row.vendor_item_code = product.vendor_item_code 

1001 row.case_size = product.case_size 

1002 row.unit_cost = product.unit_cost 

1003 row.unit_price_reg = product.unit_price_reg 

1004 

1005 def refresh_row_from_external_product(self, row): 

1006 """ 

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

1008 :term:`external product` record indicated by 

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

1010 

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

1012 

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

1014 needed. 

1015 """ 

1016 raise NotImplementedError 

1017 

1018 def remove_row(self, row): 

1019 """ 

1020 Remove a row from its batch. 

1021 

1022 This also will update the batch 

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

1024 accordingly. 

1025 """ 

1026 if row.total_price: 

1027 batch = row.batch 

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

1029 

1030 super().remove_row(row) 

1031 

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

1033 """ 

1034 Delete a batch completely. 

1035 

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

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

1038 referenced by some order(s). 

1039 """ 

1040 session = self.app.get_session(batch) 

1041 

1042 # maybe delete pending customer 

1043 customer = batch.pending_customer 

1044 if customer and not customer.orders: 

1045 session.delete(customer) 

1046 

1047 # maybe delete pending products 

1048 for row in batch.rows: 

1049 product = row.pending_product 

1050 if product and not product.order_items: 

1051 session.delete(product) 

1052 

1053 # continue with normal deletion 

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

1055 

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

1057 """ 

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

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

1060 ensure the store is assigned, if applicable. 

1061 """ 

1062 if not batch.store_id: 

1063 order_handler = self.app.get_order_handler() 

1064 if order_handler.expose_store_id(): 

1065 return "Must assign the store" 

1066 

1067 if not batch.customer_name: 

1068 return "Must assign the customer" 

1069 

1070 if not batch.phone_number: 

1071 return "Customer phone number is required" 

1072 

1073 rows = self.get_effective_rows(batch) 

1074 if not rows: 

1075 return "Must add at least one valid item" 

1076 

1077 return None 

1078 

1079 def get_effective_rows(self, batch): 

1080 """ 

1081 Only rows with 

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

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

1084 be created as proper order items. 

1085 """ 

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

1087 

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

1089 """ 

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

1091 

1092 By default, this will call: 

1093 

1094 * :meth:`make_local_customer()` 

1095 * :meth:`process_pending_products()` 

1096 * :meth:`make_new_order()` 

1097 

1098 And will return the new 

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

1100 

1101 Note that callers should use 

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

1103 instead, which calls this method automatically. 

1104 """ 

1105 rows = self.get_effective_rows(batch) 

1106 self.make_local_customer(batch) 

1107 self.process_pending_products(batch, rows) 

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

1109 return order 

1110 

1111 def make_local_customer(self, batch): 

1112 """ 

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

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

1115 

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

1117 

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

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

1120 customer (so far). 

1121 

1122 It will create a new 

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

1124 populate it from the batch 

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

1126 The latter is then deleted. 

1127 """ 

1128 if not self.use_local_customers(): 

1129 return 

1130 

1131 # nothing to do if no pending customer 

1132 pending = batch.pending_customer 

1133 if not pending: 

1134 return 

1135 

1136 session = self.app.get_session(batch) 

1137 

1138 # maybe convert pending to local customer 

1139 if not batch.local_customer: 

1140 model = self.app.model 

1141 inspector = sa.inspect(model.LocalCustomer) 

1142 local = model.LocalCustomer() 

1143 for prop in inspector.column_attrs: 

1144 if hasattr(pending, prop.key): 

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

1146 session.add(local) 

1147 batch.local_customer = local 

1148 

1149 # remove pending customer 

1150 batch.pending_customer = None 

1151 session.delete(pending) 

1152 session.flush() 

1153 

1154 def process_pending_products(self, batch, rows): 

1155 """ 

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

1157 are present in the batch. 

1158 

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

1160 

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

1162 convert the pending products to local products. 

1163 

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

1165 will update the pending product records' status to indicate 

1166 they are ready to be resolved. 

1167 """ 

1168 enum = self.app.enum 

1169 model = self.app.model 

1170 session = self.app.get_session(batch) 

1171 

1172 if self.use_local_products(): 

1173 inspector = sa.inspect(model.LocalProduct) 

1174 for row in rows: 

1175 

1176 if row.local_product or not row.pending_product: 

1177 continue 

1178 

1179 pending = row.pending_product 

1180 local = model.LocalProduct() 

1181 

1182 for prop in inspector.column_attrs: 

1183 if hasattr(pending, prop.key): 

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

1185 session.add(local) 

1186 

1187 row.local_product = local 

1188 row.pending_product = None 

1189 session.delete(pending) 

1190 

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

1192 for row in rows: 

1193 pending = row.pending_product 

1194 if pending: 

1195 pending.status = enum.PendingProductStatus.READY 

1196 

1197 session.flush() 

1198 

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

1200 """ 

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

1202 

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

1204 

1205 :param batch: 

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

1207 instance. 

1208 

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

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

1211 item>`. 

1212 

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

1214 """ 

1215 model = self.app.model 

1216 session = self.app.get_session(batch) 

1217 

1218 batch_fields = [ 

1219 "store_id", 

1220 "customer_id", 

1221 "local_customer", 

1222 "pending_customer", 

1223 "customer_name", 

1224 "phone_number", 

1225 "email_address", 

1226 "total_price", 

1227 ] 

1228 

1229 row_fields = [ 

1230 "product_id", 

1231 "local_product", 

1232 "pending_product", 

1233 "product_scancode", 

1234 "product_brand", 

1235 "product_description", 

1236 "product_size", 

1237 "product_weighed", 

1238 "department_id", 

1239 "department_name", 

1240 "vendor_name", 

1241 "vendor_item_code", 

1242 "case_size", 

1243 "order_qty", 

1244 "order_uom", 

1245 "unit_cost", 

1246 "unit_price_quoted", 

1247 "case_price_quoted", 

1248 "unit_price_reg", 

1249 "unit_price_sale", 

1250 "sale_ends", 

1251 "discount_percent", 

1252 "total_price", 

1253 "special_order", 

1254 ] 

1255 

1256 # make order 

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

1258 kw["order_id"] = batch.id 

1259 kw["created_by"] = user 

1260 order = model.Order(**kw) 

1261 session.add(order) 

1262 session.flush() 

1263 

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

1265 

1266 # make order item 

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

1268 item = model.OrderItem(**kw) 

1269 order.items.append(item) 

1270 

1271 # set item status 

1272 self.set_initial_item_status(item, user) 

1273 

1274 self.app.progress_loop( 

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

1276 ) 

1277 session.flush() 

1278 return order 

1279 

1280 def set_initial_item_status(self, item, user): 

1281 """ 

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

1283 

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

1285 after it is added to the order. 

1286 

1287 Default logic will set status to 

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

1289 events: 

1290 

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

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

1293 

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

1295 being added to the new order. 

1296 

1297 :param user: 

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

1299 is performing the action. 

1300 """ 

1301 enum = self.app.enum 

1302 item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user) 

1303 item.add_event(enum.ORDER_ITEM_EVENT_READY, user) 

1304 item.status_code = enum.ORDER_ITEM_STATUS_READY