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

387 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-16 07:16 -0500

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# Sideshow -- Case/Special Order Tracker 

5# Copyright © 2024-2025 Lance Edgar 

6# 

7# This file is part of Sideshow. 

8# 

9# Sideshow is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# Sideshow is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 

17# General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with Sideshow. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24New Order Batch Handler 

25""" 

26 

27import datetime 

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

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 model_class = NewOrderBatch 

53 

54 def get_default_store_id(self): 

55 """ 

56 Returns the configured default value for 

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

58 or ``None``. 

59 """ 

60 return self.config.get('sideshow.orders.default_store_id') 

61 

62 def use_local_customers(self): 

63 """ 

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

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

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

67 """ 

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

69 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', 

78 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('sideshow.orders.allow_unknown_products', 

90 default=True) 

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('sideshow.orders.allow_item_discounts', 

98 default=False) 

99 

100 def allow_item_discounts_if_on_sale(self): 

101 """ 

102 Returns boolean indicating whether per-item discounts are 

103 allowed even when the item is already on sale. 

104 """ 

105 return self.config.get_bool('sideshow.orders.allow_item_discounts_if_on_sale', 

106 default=False) 

107 

108 def get_default_item_discount(self): 

109 """ 

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

111 

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

113 """ 

114 discount = self.config.get('sideshow.orders.default_item_discount') 

115 if discount: 

116 return decimal.Decimal(discount) 

117 

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

119 """ 

120 Return autocomplete search results for :term:`external 

121 customer` records. 

122 

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

124 

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

126 

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

128 

129 :param user: 

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

131 is doing the search, if known. 

132 

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

134 ``value`` and ``label`` keys. 

135 """ 

136 raise NotImplementedError 

137 

138 def autocomplete_customers_local(self, session, term, user=None): 

139 """ 

140 Return autocomplete search results for 

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

142 

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

144 

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

146 

147 :param user: 

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

149 is doing the search, if known. 

150 

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

152 ``value`` and ``label`` keys. 

153 """ 

154 model = self.app.model 

155 

156 # base query 

157 query = session.query(model.LocalCustomer) 

158 

159 # filter query 

160 criteria = [model.LocalCustomer.full_name.ilike(f'%{word}%') 

161 for word in term.split()] 

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

163 

164 # sort query 

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

166 

167 # get data 

168 # TODO: need max_results option 

169 customers = query.all() 

170 

171 # get results 

172 def result(customer): 

173 return {'value': customer.uuid.hex, 

174 'label': customer.full_name} 

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

176 

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

178 """ 

179 Initialize a new batch. 

180 

181 This sets the 

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

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

184 configured. 

185 """ 

186 if not batch.store_id: 

187 batch.store_id = self.get_default_store_id() 

188 

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

190 """ 

191 Set/update customer info for the batch. 

192 

193 This will first set one of the following: 

194 

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

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

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

198 

199 Note that a new 

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

201 is created if necessary. 

202 

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

204 

205 * :meth:`refresh_batch_from_external_customer()` 

206 * :meth:`refresh_batch_from_local_customer()` 

207 * :meth:`refresh_batch_from_pending_customer()` 

208 

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

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

211 

212 :param batch: 

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

214 update. 

215 

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

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

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

219 

220 :param user: 

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

222 is performing the action. This is used to set 

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

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

225 the batch creator is assumed. 

226 """ 

227 model = self.app.model 

228 enum = self.app.enum 

229 session = self.app.get_session(batch) 

230 use_local = self.use_local_customers() 

231 

232 # set customer info 

233 if isinstance(customer_info, str): 

234 if use_local: 

235 

236 # local_customer 

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

238 if not customer: 

239 raise ValueError("local customer not found") 

240 batch.local_customer = customer 

241 self.refresh_batch_from_local_customer(batch) 

242 

243 else: # external customer_id 

244 batch.customer_id = customer_info 

245 self.refresh_batch_from_external_customer(batch) 

246 

247 elif customer_info: 

248 

249 # pending_customer 

250 batch.customer_id = None 

251 batch.local_customer = None 

252 customer = batch.pending_customer 

253 if not customer: 

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

255 created_by=user or batch.created_by) 

256 session.add(customer) 

257 batch.pending_customer = customer 

258 fields = [ 

259 'full_name', 

260 'first_name', 

261 'last_name', 

262 'phone_number', 

263 'email_address', 

264 ] 

265 for key in fields: 

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

267 if 'full_name' not in customer_info: 

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

269 customer.last_name) 

270 self.refresh_batch_from_pending_customer(batch) 

271 

272 else: 

273 

274 # null 

275 batch.customer_id = None 

276 batch.local_customer = None 

277 batch.customer_name = None 

278 batch.phone_number = None 

279 batch.email_address = None 

280 

281 session.flush() 

282 

283 def refresh_batch_from_external_customer(self, batch): 

284 """ 

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

286 :term:`external customer` record. 

287 

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

289 

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

291 """ 

292 raise NotImplementedError 

293 

294 def refresh_batch_from_local_customer(self, batch): 

295 """ 

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

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

298 record. 

299 

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

301 """ 

302 customer = batch.local_customer 

303 batch.customer_name = customer.full_name 

304 batch.phone_number = customer.phone_number 

305 batch.email_address = customer.email_address 

306 

307 def refresh_batch_from_pending_customer(self, batch): 

308 """ 

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

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

311 record. 

312 

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

314 """ 

315 customer = batch.pending_customer 

316 batch.customer_name = customer.full_name 

317 batch.phone_number = customer.phone_number 

318 batch.email_address = customer.email_address 

319 

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

321 """ 

322 Return autocomplete search results for :term:`external 

323 product` records. 

324 

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

326 

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

328 

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

330 

331 :param user: 

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

333 is doing the search, if known. 

334 

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

336 ``value`` and ``label`` keys. 

337 """ 

338 raise NotImplementedError 

339 

340 def autocomplete_products_local(self, session, term, user=None): 

341 """ 

342 Return autocomplete search results for 

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

344 

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

346 

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

348 

349 :param user: 

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

351 is doing the search, if known. 

352 

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

354 ``value`` and ``label`` keys. 

355 """ 

356 model = self.app.model 

357 

358 # base query 

359 query = session.query(model.LocalProduct) 

360 

361 # filter query 

362 criteria = [] 

363 for word in term.split(): 

364 criteria.append(sa.or_( 

365 model.LocalProduct.brand_name.ilike(f'%{word}%'), 

366 model.LocalProduct.description.ilike(f'%{word}%'))) 

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

368 

369 # sort query 

370 query = query.order_by(model.LocalProduct.brand_name, 

371 model.LocalProduct.description) 

372 

373 # get data 

374 # TODO: need max_results option 

375 products = query.all() 

376 

377 # get results 

378 def result(product): 

379 return {'value': product.uuid.hex, 

380 'label': product.full_description} 

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

382 

383 def get_default_uom_choices(self): 

384 """ 

385 Returns a list of ordering UOM choices which should be 

386 presented to the user by default. 

387 

388 The built-in logic here will return everything from 

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

390 

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

392 corresponding to the UOM code and label, respectively. 

393 """ 

394 enum = self.app.enum 

395 return [{'key': key, 'value': val} 

396 for key, val in enum.ORDER_UOM.items()] 

397 

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

399 """ 

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

401 to ordering. 

402 

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

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

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

406 

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

408 also :meth:`get_product_info_local()`. 

409 

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

411 

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

413 info. 

414 

415 :param user: 

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

417 is performing the action, if known. 

418 

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

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

421 

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

423 identified via autocomplete/search lookup; therefore the 

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

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

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

427 

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

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

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

431 

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

433 ext_model = get_external_model() 

434 ext_session = make_external_session() 

435 

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

437 if not ext_product: 

438 ext_session.close() 

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

440 

441 info = { 

442 'product_id': product_id, 

443 'scancode': product.scancode, 

444 'brand_name': product.brand_name, 

445 'description': product.description, 

446 'size': product.size, 

447 'weighed': product.sold_by_weight, 

448 'special_order': False, 

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

450 'department_name': product.department_name, 

451 'case_size': product.case_size, 

452 'unit_price_reg': product.unit_price_reg, 

453 'vendor_name': product.vendor_name, 

454 'vendor_item_code': product.vendor_item_code, 

455 } 

456 

457 ext_session.close() 

458 return info 

459 """ 

460 raise NotImplementedError 

461 

462 def get_product_info_local(self, session, uuid, user=None): 

463 """ 

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

465 ordering. 

466 

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

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

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

470 

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

472 

473 This method will locate the 

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

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

476 returns the result. 

477 

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

479 

480 :param uuid: UUID for the desired 

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

482 

483 :param user: 

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

485 is performing the action, if known. 

486 

487 :returns: Dict of product info. 

488 """ 

489 model = self.app.model 

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

491 if not product: 

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

493 

494 return self.normalize_local_product(product) 

495 

496 def normalize_local_product(self, product): 

497 """ 

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

499 product`. 

500 

501 This is called by: 

502 

503 * :meth:`get_product_info_local()` 

504 * :meth:`get_past_products()` 

505 

506 :param product: 

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

508 

509 :returns: Dict of product info. 

510 

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

512 product fields, with one exception: 

513 

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

515 """ 

516 return { 

517 'product_id': product.uuid.hex, 

518 'scancode': product.scancode, 

519 'brand_name': product.brand_name, 

520 'description': product.description, 

521 'size': product.size, 

522 'full_description': product.full_description, 

523 'weighed': product.weighed, 

524 'special_order': product.special_order, 

525 'department_id': product.department_id, 

526 'department_name': product.department_name, 

527 'case_size': product.case_size, 

528 'unit_price_reg': product.unit_price_reg, 

529 'vendor_name': product.vendor_name, 

530 'vendor_item_code': product.vendor_item_code, 

531 } 

532 

533 def get_past_orders(self, batch): 

534 """ 

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

536 <order>` for the batch customer. 

537 

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

539 

540 :param batch: 

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

542 instance. 

543 

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

545 records. 

546 """ 

547 model = self.app.model 

548 session = self.app.get_session(batch) 

549 orders = session.query(model.Order) 

550 

551 if batch.customer_id: 

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

553 elif batch.local_customer: 

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

555 else: 

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

557 

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

559 return orders.all() 

560 

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

562 """ 

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

564 previously ordered by the batch customer. 

565 

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

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

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

569 *not* have order quantity etc. 

570 

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

572 through each order item therein. Any duplicated products 

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

574 products. 

575 

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

577 

578 * :meth:`normalize_local_product()` 

579 * :meth:`get_product_info_external()` 

580 

581 :param batch: 

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

583 instance. 

584 

585 :param user: 

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

587 is performing the action, if known. 

588 

589 :returns: List of product info dicts. 

590 """ 

591 model = self.app.model 

592 session = self.app.get_session(batch) 

593 use_local = self.use_local_products() 

594 user = user or batch.created_by 

595 products = OrderedDict() 

596 

597 # track down all order items for batch contact 

598 for order in self.get_past_orders(batch): 

599 for item in order.items: 

600 

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

602 if use_local: 

603 product = item.local_product 

604 if product and product.uuid not in products: 

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

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

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

608 session, item.product_id, user=user) 

609 

610 products = list(products.values()) 

611 for product in products: 

612 

613 price = product['unit_price_reg'] 

614 

615 if 'unit_price_reg_display' not in product: 

616 product['unit_price_reg_display'] = self.app.render_currency(price) 

617 

618 if 'unit_price_quoted' not in product: 

619 product['unit_price_quoted'] = price 

620 

621 if 'unit_price_quoted_display' not in product: 

622 product['unit_price_quoted_display'] = product['unit_price_reg_display'] 

623 

624 if ('case_price_quoted' not in product 

625 and product.get('unit_price_quoted') is not None 

626 and product.get('case_size') is not None): 

627 product['case_price_quoted'] = product['unit_price_quoted'] * product['case_size'] 

628 

629 if ('case_price_quoted_display' not in product 

630 and 'case_price_quoted' in product): 

631 product['case_price_quoted_display'] = self.app.render_currency( 

632 product['case_price_quoted']) 

633 

634 return products 

635 

636 def add_item(self, batch, product_info, order_qty, order_uom, 

637 discount_percent=None, user=None): 

638 """ 

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

640 

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

642 

643 :param batch: 

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

645 update. 

646 

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

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

649 

650 :param order_qty: 

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

652 value for the new row. 

653 

654 :param order_uom: 

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

656 value for the new row. 

657 

658 :param discount_percent: Sets the 

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

660 for the row, if allowed. 

661 

662 :param user: 

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

664 is performing the action. This is used to set 

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

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

667 the batch creator is assumed. 

668 

669 :returns: 

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

671 instance. 

672 """ 

673 model = self.app.model 

674 enum = self.app.enum 

675 session = self.app.get_session(batch) 

676 use_local = self.use_local_products() 

677 row = self.make_row() 

678 

679 # set product info 

680 if isinstance(product_info, str): 

681 if use_local: 

682 

683 # local_product 

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

685 if not local: 

686 raise ValueError("local product not found") 

687 row.local_product = local 

688 

689 else: # external product_id 

690 row.product_id = product_info 

691 

692 else: 

693 # pending_product 

694 if not self.allow_unknown_products(): 

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

696 row.product_id = None 

697 row.local_product = None 

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

699 created_by=user or batch.created_by) 

700 fields = [ 

701 'scancode', 

702 'brand_name', 

703 'description', 

704 'size', 

705 'weighed', 

706 'department_id', 

707 'department_name', 

708 'special_order', 

709 'vendor_name', 

710 'vendor_item_code', 

711 'case_size', 

712 'unit_cost', 

713 'unit_price_reg', 

714 'notes', 

715 ] 

716 for key in fields: 

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

718 

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

720 session.add(pending) 

721 session.flush() 

722 session.refresh(pending) 

723 row.pending_product = pending 

724 

725 # set order info 

726 row.order_qty = order_qty 

727 row.order_uom = order_uom 

728 

729 # discount 

730 if self.allow_item_discounts(): 

731 row.discount_percent = discount_percent or 0 

732 

733 # add row to batch 

734 self.add_row(batch, row) 

735 session.flush() 

736 return row 

737 

738 def update_item(self, row, product_info, order_qty, order_uom, 

739 discount_percent=None, user=None): 

740 """ 

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

742 

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

744 

745 :param row: 

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

747 to update. 

748 

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

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

751 

752 :param order_qty: New 

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

754 value for the row. 

755 

756 :param order_uom: New 

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

758 value for the row. 

759 

760 :param discount_percent: Sets the 

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

762 for the row, if allowed. 

763 

764 :param user: 

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

766 is performing the action. This is used to set 

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

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

769 the batch creator is assumed. 

770 """ 

771 model = self.app.model 

772 enum = self.app.enum 

773 session = self.app.get_session(row) 

774 use_local = self.use_local_products() 

775 

776 # set product info 

777 if isinstance(product_info, str): 

778 if use_local: 

779 

780 # local_product 

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

782 if not local: 

783 raise ValueError("local product not found") 

784 row.local_product = local 

785 

786 else: # external product_id 

787 row.product_id = product_info 

788 

789 else: 

790 # pending_product 

791 if not self.allow_unknown_products(): 

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

793 row.product_id = None 

794 row.local_product = None 

795 pending = row.pending_product 

796 if not pending: 

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

798 created_by=user or row.batch.created_by) 

799 session.add(pending) 

800 row.pending_product = pending 

801 fields = [ 

802 'scancode', 

803 'brand_name', 

804 'description', 

805 'size', 

806 'weighed', 

807 'department_id', 

808 'department_name', 

809 'special_order', 

810 'vendor_name', 

811 'vendor_item_code', 

812 'case_size', 

813 'unit_cost', 

814 'unit_price_reg', 

815 'notes', 

816 ] 

817 for key in fields: 

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

819 

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

821 session.flush() 

822 session.refresh(pending) 

823 

824 # set order info 

825 row.order_qty = order_qty 

826 row.order_uom = order_uom 

827 

828 # discount 

829 if self.allow_item_discounts(): 

830 row.discount_percent = discount_percent or 0 

831 

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

833 session.flush() 

834 session.refresh(row) 

835 

836 # refresh per new info 

837 self.refresh_row(row) 

838 

839 def refresh_row(self, row): 

840 """ 

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

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

843 changing order quantity). 

844 

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

846 attributes: 

847 

848 * :meth:`refresh_row_from_external_product()` 

849 * :meth:`refresh_row_from_local_product()` 

850 * :meth:`refresh_row_from_pending_product()` 

851 

852 It then re-calculates the row's 

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

854 and updates the batch accordingly. 

855 

856 It also sets the row 

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

858 """ 

859 enum = self.app.enum 

860 row.status_code = None 

861 row.status_text = None 

862 

863 # ensure product 

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

865 row.status_code = row.STATUS_MISSING_PRODUCT 

866 return 

867 

868 # ensure order qty/uom 

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

870 row.status_code = row.STATUS_MISSING_ORDER_QTY 

871 return 

872 

873 # update product attrs on row 

874 if row.product_id: 

875 self.refresh_row_from_external_product(row) 

876 elif row.local_product: 

877 self.refresh_row_from_local_product(row) 

878 else: 

879 self.refresh_row_from_pending_product(row) 

880 

881 # we need to know if total price changes 

882 old_total = row.total_price 

883 

884 # update quoted price 

885 row.unit_price_quoted = None 

886 row.case_price_quoted = None 

887 if row.unit_price_sale is not None and ( 

888 not row.sale_ends 

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

890 row.unit_price_quoted = row.unit_price_sale 

891 else: 

892 row.unit_price_quoted = row.unit_price_reg 

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

894 row.case_price_quoted = row.unit_price_quoted * row.case_size 

895 

896 # update row total price 

897 row.total_price = None 

898 if row.order_uom == enum.ORDER_UOM_CASE: 

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

900 # if row.case_price_quoted: 

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

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

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

904 else: # ORDER_UOM_UNIT (or similar) 

905 if row.unit_price_quoted is not None: 

906 row.total_price = row.unit_price_quoted * row.order_qty 

907 if row.total_price is not None: 

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

909 row.total_price = (float(row.total_price) 

910 * (100 - float(row.discount_percent)) 

911 / 100.0) 

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

913 

914 # update batch if total price changed 

915 if row.total_price != old_total: 

916 batch = row.batch 

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

918 + (row.total_price or 0) 

919 - (old_total or 0)) 

920 

921 # all ok 

922 row.status_code = row.STATUS_OK 

923 

924 def refresh_row_from_local_product(self, row): 

925 """ 

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

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

928 record. 

929 

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

931 """ 

932 product = row.local_product 

933 row.product_scancode = product.scancode 

934 row.product_brand = product.brand_name 

935 row.product_description = product.description 

936 row.product_size = product.size 

937 row.product_weighed = product.weighed 

938 row.department_id = product.department_id 

939 row.department_name = product.department_name 

940 row.special_order = product.special_order 

941 row.vendor_name = product.vendor_name 

942 row.vendor_item_code = product.vendor_item_code 

943 row.case_size = product.case_size 

944 row.unit_cost = product.unit_cost 

945 row.unit_price_reg = product.unit_price_reg 

946 

947 def refresh_row_from_pending_product(self, row): 

948 """ 

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

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

951 record. 

952 

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

954 """ 

955 product = row.pending_product 

956 row.product_scancode = product.scancode 

957 row.product_brand = product.brand_name 

958 row.product_description = product.description 

959 row.product_size = product.size 

960 row.product_weighed = product.weighed 

961 row.department_id = product.department_id 

962 row.department_name = product.department_name 

963 row.special_order = product.special_order 

964 row.vendor_name = product.vendor_name 

965 row.vendor_item_code = product.vendor_item_code 

966 row.case_size = product.case_size 

967 row.unit_cost = product.unit_cost 

968 row.unit_price_reg = product.unit_price_reg 

969 

970 def refresh_row_from_external_product(self, row): 

971 """ 

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

973 :term:`external product` record indicated by 

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

975 

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

977 

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

979 needed. 

980 """ 

981 raise NotImplementedError 

982 

983 def remove_row(self, row): 

984 """ 

985 Remove a row from its batch. 

986 

987 This also will update the batch 

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

989 accordingly. 

990 """ 

991 if row.total_price: 

992 batch = row.batch 

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

994 

995 super().remove_row(row) 

996 

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

998 """ 

999 Delete a batch completely. 

1000 

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

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

1003 referenced by some order(s). 

1004 """ 

1005 session = self.app.get_session(batch) 

1006 

1007 # maybe delete pending customer 

1008 customer = batch.pending_customer 

1009 if customer and not customer.orders: 

1010 session.delete(customer) 

1011 

1012 # maybe delete pending products 

1013 for row in batch.rows: 

1014 product = row.pending_product 

1015 if product and not product.order_items: 

1016 session.delete(product) 

1017 

1018 # continue with normal deletion 

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

1020 

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

1022 """ 

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

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

1025 ensure the store is assigned, if applicable. 

1026 """ 

1027 if not batch.store_id: 

1028 order_handler = self.app.get_order_handler() 

1029 if order_handler.expose_store_id(): 

1030 return "Must assign the store" 

1031 

1032 if not batch.customer_name: 

1033 return "Must assign the customer" 

1034 

1035 if not batch.phone_number: 

1036 return "Customer phone number is required" 

1037 

1038 rows = self.get_effective_rows(batch) 

1039 if not rows: 

1040 return "Must add at least one valid item" 

1041 

1042 def get_effective_rows(self, batch): 

1043 """ 

1044 Only rows with 

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

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

1047 be created as proper order items. 

1048 """ 

1049 return [row for row in batch.rows 

1050 if row.status_code == row.STATUS_OK] 

1051 

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

1053 """ 

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

1055 

1056 By default, this will call: 

1057 

1058 * :meth:`make_local_customer()` 

1059 * :meth:`process_pending_products()` 

1060 * :meth:`make_new_order()` 

1061 

1062 And will return the new 

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

1064 

1065 Note that callers should use 

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

1067 instead, which calls this method automatically. 

1068 """ 

1069 rows = self.get_effective_rows(batch) 

1070 self.make_local_customer(batch) 

1071 self.process_pending_products(batch, rows) 

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

1073 return order 

1074 

1075 def make_local_customer(self, batch): 

1076 """ 

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

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

1079 

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

1081 

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

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

1084 customer (so far). 

1085 

1086 It will create a new 

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

1088 populate it from the batch 

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

1090 The latter is then deleted. 

1091 """ 

1092 if not self.use_local_customers(): 

1093 return 

1094 

1095 # nothing to do if no pending customer 

1096 pending = batch.pending_customer 

1097 if not pending: 

1098 return 

1099 

1100 session = self.app.get_session(batch) 

1101 

1102 # maybe convert pending to local customer 

1103 if not batch.local_customer: 

1104 model = self.app.model 

1105 inspector = sa.inspect(model.LocalCustomer) 

1106 local = model.LocalCustomer() 

1107 for prop in inspector.column_attrs: 

1108 if hasattr(pending, prop.key): 

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

1110 session.add(local) 

1111 batch.local_customer = local 

1112 

1113 # remove pending customer 

1114 batch.pending_customer = None 

1115 session.delete(pending) 

1116 session.flush() 

1117 

1118 def process_pending_products(self, batch, rows): 

1119 """ 

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

1121 are present in the batch. 

1122 

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

1124 

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

1126 convert the pending products to local products. 

1127 

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

1129 will update the pending product records' status to indicate 

1130 they are ready to be resolved. 

1131 """ 

1132 enum = self.app.enum 

1133 model = self.app.model 

1134 session = self.app.get_session(batch) 

1135 

1136 if self.use_local_products(): 

1137 inspector = sa.inspect(model.LocalProduct) 

1138 for row in rows: 

1139 

1140 if row.local_product or not row.pending_product: 

1141 continue 

1142 

1143 pending = row.pending_product 

1144 local = model.LocalProduct() 

1145 

1146 for prop in inspector.column_attrs: 

1147 if hasattr(pending, prop.key): 

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

1149 session.add(local) 

1150 

1151 row.local_product = local 

1152 row.pending_product = None 

1153 session.delete(pending) 

1154 

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

1156 for row in rows: 

1157 pending = row.pending_product 

1158 if pending: 

1159 pending.status = enum.PendingProductStatus.READY 

1160 

1161 session.flush() 

1162 

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

1164 """ 

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

1166 

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

1168 

1169 :param batch: 

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

1171 instance. 

1172 

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

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

1175 item>`. 

1176 

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

1178 """ 

1179 model = self.app.model 

1180 enum = self.app.enum 

1181 session = self.app.get_session(batch) 

1182 

1183 batch_fields = [ 

1184 'store_id', 

1185 'customer_id', 

1186 'local_customer', 

1187 'pending_customer', 

1188 'customer_name', 

1189 'phone_number', 

1190 'email_address', 

1191 'total_price', 

1192 ] 

1193 

1194 row_fields = [ 

1195 'product_id', 

1196 'local_product', 

1197 'pending_product', 

1198 'product_scancode', 

1199 'product_brand', 

1200 'product_description', 

1201 'product_size', 

1202 'product_weighed', 

1203 'department_id', 

1204 'department_name', 

1205 'vendor_name', 

1206 'vendor_item_code', 

1207 'case_size', 

1208 'order_qty', 

1209 'order_uom', 

1210 'unit_cost', 

1211 'unit_price_quoted', 

1212 'case_price_quoted', 

1213 'unit_price_reg', 

1214 'unit_price_sale', 

1215 'sale_ends', 

1216 'discount_percent', 

1217 'total_price', 

1218 'special_order', 

1219 ] 

1220 

1221 # make order 

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

1223 for field in batch_fields]) 

1224 kw['order_id'] = batch.id 

1225 kw['created_by'] = user 

1226 order = model.Order(**kw) 

1227 session.add(order) 

1228 session.flush() 

1229 

1230 def convert(row, i): 

1231 

1232 # make order item 

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

1234 for field in row_fields]) 

1235 item = model.OrderItem(**kw) 

1236 order.items.append(item) 

1237 

1238 # set item status 

1239 self.set_initial_item_status(item, user) 

1240 

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

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

1243 session.flush() 

1244 return order 

1245 

1246 def set_initial_item_status(self, item, user, **kwargs): 

1247 """ 

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

1249 

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

1251 after it is added to the order. 

1252 

1253 Default logic will set status to 

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

1255 events: 

1256 

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

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

1259 

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

1261 being added to the new order. 

1262 

1263 :param user: 

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

1265 is performing the action. 

1266 """ 

1267 enum = self.app.enum 

1268 item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user) 

1269 item.add_event(enum.ORDER_ITEM_EVENT_READY, user) 

1270 item.status_code = enum.ORDER_ITEM_STATUS_READY