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

325 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-26 13:16 -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 

27import datetime 

28import decimal 

29 

30import sqlalchemy as sa 

31 

32from wuttjamaican.batch import BatchHandler 

33 

34from sideshow.db.model import NewOrderBatch 

35 

36 

37class NewOrderBatchHandler(BatchHandler): 

38 """ 

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

40 batch>`. 

41 

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

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

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

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

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

47 

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

49 responsibility for the rest of the order lifecycle. 

50 """ 

51 model_class = NewOrderBatch 

52 

53 def use_local_customers(self): 

54 """ 

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

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

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

58 """ 

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

60 default=True) 

61 

62 def use_local_products(self): 

63 """ 

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

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

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

67 """ 

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

69 default=True) 

70 

71 def allow_unknown_products(self): 

72 """ 

73 Returns boolean indicating whether :term:`pending products 

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

75 

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

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

78 to choose existing local/external product. 

79 """ 

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

81 default=True) 

82 

83 def allow_item_discounts(self): 

84 """ 

85 Returns boolean indicating whether per-item discounts are 

86 allowed when creating an order. 

87 """ 

88 return self.config.get_bool('sideshow.orders.allow_item_discounts', 

89 default=False) 

90 

91 def allow_item_discounts_if_on_sale(self): 

92 """ 

93 Returns boolean indicating whether per-item discounts are 

94 allowed even when the item is already on sale. 

95 """ 

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

97 default=False) 

98 

99 def get_default_item_discount(self): 

100 """ 

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

102 

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

104 """ 

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

106 if discount: 

107 return decimal.Decimal(discount) 

108 

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

110 """ 

111 Return autocomplete search results for :term:`external 

112 customer` records. 

113 

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

115 

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

117 

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

119 

120 :param user: 

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

122 is doing the search, if known. 

123 

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

125 ``value`` and ``label`` keys. 

126 """ 

127 raise NotImplementedError 

128 

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

130 """ 

131 Return autocomplete search results for 

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

133 

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

135 

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

137 

138 :param user: 

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

140 is doing the search, if known. 

141 

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

143 ``value`` and ``label`` keys. 

144 """ 

145 model = self.app.model 

146 

147 # base query 

148 query = session.query(model.LocalCustomer) 

149 

150 # filter query 

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

152 for word in term.split()] 

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

154 

155 # sort query 

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

157 

158 # get data 

159 # TODO: need max_results option 

160 customers = query.all() 

161 

162 # get results 

163 def result(customer): 

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

165 'label': customer.full_name} 

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

167 

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

169 """ 

170 Set/update customer info for the batch. 

171 

172 This will first set one of the following: 

173 

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

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

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

177 

178 Note that a new 

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

180 is created if necessary. 

181 

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

183 

184 * :meth:`refresh_batch_from_external_customer()` 

185 * :meth:`refresh_batch_from_local_customer()` 

186 * :meth:`refresh_batch_from_pending_customer()` 

187 

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

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

190 

191 :param batch: 

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

193 update. 

194 

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

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

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

198 

199 :param user: 

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

201 is performing the action. This is used to set 

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

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

204 the batch creator is assumed. 

205 """ 

206 model = self.app.model 

207 enum = self.app.enum 

208 session = self.app.get_session(batch) 

209 use_local = self.use_local_customers() 

210 

211 # set customer info 

212 if isinstance(customer_info, str): 

213 if use_local: 

214 

215 # local_customer 

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

217 if not customer: 

218 raise ValueError("local customer not found") 

219 batch.local_customer = customer 

220 self.refresh_batch_from_local_customer(batch) 

221 

222 else: # external customer_id 

223 batch.customer_id = customer_info 

224 self.refresh_batch_from_external_customer(batch) 

225 

226 elif customer_info: 

227 

228 # pending_customer 

229 batch.customer_id = None 

230 batch.local_customer = None 

231 customer = batch.pending_customer 

232 if not customer: 

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

234 created_by=user or batch.created_by) 

235 session.add(customer) 

236 batch.pending_customer = customer 

237 fields = [ 

238 'full_name', 

239 'first_name', 

240 'last_name', 

241 'phone_number', 

242 'email_address', 

243 ] 

244 for key in fields: 

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

246 if 'full_name' not in customer_info: 

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

248 customer.last_name) 

249 self.refresh_batch_from_pending_customer(batch) 

250 

251 else: 

252 

253 # null 

254 batch.customer_id = None 

255 batch.local_customer = None 

256 batch.customer_name = None 

257 batch.phone_number = None 

258 batch.email_address = None 

259 

260 session.flush() 

261 

262 def refresh_batch_from_external_customer(self, batch): 

263 """ 

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

265 :term:`external customer` record. 

266 

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

268 

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

270 """ 

271 raise NotImplementedError 

272 

273 def refresh_batch_from_local_customer(self, batch): 

274 """ 

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

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

277 record. 

278 

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

280 """ 

281 customer = batch.local_customer 

282 batch.customer_name = customer.full_name 

283 batch.phone_number = customer.phone_number 

284 batch.email_address = customer.email_address 

285 

286 def refresh_batch_from_pending_customer(self, batch): 

287 """ 

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

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

290 record. 

291 

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

293 """ 

294 customer = batch.pending_customer 

295 batch.customer_name = customer.full_name 

296 batch.phone_number = customer.phone_number 

297 batch.email_address = customer.email_address 

298 

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

300 """ 

301 Return autocomplete search results for :term:`external 

302 product` records. 

303 

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

305 

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

307 

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

309 

310 :param user: 

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

312 is doing the search, if known. 

313 

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

315 ``value`` and ``label`` keys. 

316 """ 

317 raise NotImplementedError 

318 

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

320 """ 

321 Return autocomplete search results for 

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

323 

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

325 

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

327 

328 :param user: 

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

330 is doing the search, if known. 

331 

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

333 ``value`` and ``label`` keys. 

334 """ 

335 model = self.app.model 

336 

337 # base query 

338 query = session.query(model.LocalProduct) 

339 

340 # filter query 

341 criteria = [] 

342 for word in term.split(): 

343 criteria.append(sa.or_( 

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

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

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

347 

348 # sort query 

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

350 model.LocalProduct.description) 

351 

352 # get data 

353 # TODO: need max_results option 

354 products = query.all() 

355 

356 # get results 

357 def result(product): 

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

359 'label': product.full_description} 

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

361 

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

363 """ 

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

365 to ordering. 

366 

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

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

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

370 

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

372 

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

374 

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

376 info. 

377 

378 :param user: 

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

380 is performing the action, if known. 

381 

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

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

384 

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

386 identified via autocomplete/search lookup; therefore the 

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

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

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

390 

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

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

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

394 

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

396 ext_model = get_external_model() 

397 ext_session = make_external_session() 

398 

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

400 if not ext_product: 

401 ext_session.close() 

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

403 

404 info = { 

405 'product_id': product_id, 

406 'scancode': product.scancode, 

407 'brand_name': product.brand_name, 

408 'description': product.description, 

409 'size': product.size, 

410 'weighed': product.sold_by_weight, 

411 'special_order': False, 

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

413 'department_name': product.department_name, 

414 'case_size': product.case_size, 

415 'unit_price_reg': product.unit_price_reg, 

416 'vendor_name': product.vendor_name, 

417 'vendor_item_code': product.vendor_item_code, 

418 } 

419 

420 ext_session.close() 

421 return info 

422 """ 

423 raise NotImplementedError 

424 

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

426 """ 

427 Returns basic info for a 

428 :class:`~sideshow.db.model.products.LocalProduct` as pertains 

429 to ordering. 

430 

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

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

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

434 

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

436 """ 

437 model = self.app.model 

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

439 if not product: 

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

441 

442 return { 

443 'product_id': product.uuid.hex, 

444 'scancode': product.scancode, 

445 'brand_name': product.brand_name, 

446 'description': product.description, 

447 'size': product.size, 

448 'full_description': product.full_description, 

449 'weighed': product.weighed, 

450 'special_order': product.special_order, 

451 'department_id': product.department_id, 

452 'department_name': product.department_name, 

453 'case_size': product.case_size, 

454 'unit_price_reg': product.unit_price_reg, 

455 'vendor_name': product.vendor_name, 

456 'vendor_item_code': product.vendor_item_code, 

457 } 

458 

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

460 discount_percent=None, user=None): 

461 """ 

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

463 

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

465 

466 :param batch: 

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

468 update. 

469 

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

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

472 

473 :param order_qty: 

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

475 value for the new row. 

476 

477 :param order_uom: 

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

479 value for the new row. 

480 

481 :param discount_percent: Sets the 

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

483 for the row, if allowed. 

484 

485 :param user: 

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

487 is performing the action. This is used to set 

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

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

490 the batch creator is assumed. 

491 

492 :returns: 

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

494 instance. 

495 """ 

496 model = self.app.model 

497 enum = self.app.enum 

498 session = self.app.get_session(batch) 

499 use_local = self.use_local_products() 

500 row = self.make_row() 

501 

502 # set product info 

503 if isinstance(product_info, str): 

504 if use_local: 

505 

506 # local_product 

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

508 if not local: 

509 raise ValueError("local product not found") 

510 row.local_product = local 

511 

512 else: # external product_id 

513 row.product_id = product_info 

514 

515 else: 

516 # pending_product 

517 if not self.allow_unknown_products(): 

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

519 row.product_id = None 

520 row.local_product = None 

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

522 created_by=user or batch.created_by) 

523 fields = [ 

524 'scancode', 

525 'brand_name', 

526 'description', 

527 'size', 

528 'weighed', 

529 'department_id', 

530 'department_name', 

531 'special_order', 

532 'vendor_name', 

533 'vendor_item_code', 

534 'case_size', 

535 'unit_cost', 

536 'unit_price_reg', 

537 'notes', 

538 ] 

539 for key in fields: 

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

541 

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

543 session.add(pending) 

544 session.flush() 

545 session.refresh(pending) 

546 row.pending_product = pending 

547 

548 # set order info 

549 row.order_qty = order_qty 

550 row.order_uom = order_uom 

551 

552 # discount 

553 if self.allow_item_discounts(): 

554 row.discount_percent = discount_percent or 0 

555 

556 # add row to batch 

557 self.add_row(batch, row) 

558 session.flush() 

559 return row 

560 

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

562 discount_percent=None, user=None): 

563 """ 

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

565 

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

567 

568 :param row: 

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

570 to update. 

571 

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

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

574 

575 :param order_qty: New 

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

577 value for the row. 

578 

579 :param order_uom: New 

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

581 value for the row. 

582 

583 :param discount_percent: Sets the 

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

585 for the row, if allowed. 

586 

587 :param user: 

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

589 is performing the action. This is used to set 

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

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

592 the batch creator is assumed. 

593 """ 

594 model = self.app.model 

595 enum = self.app.enum 

596 session = self.app.get_session(row) 

597 use_local = self.use_local_products() 

598 

599 # set product info 

600 if isinstance(product_info, str): 

601 if use_local: 

602 

603 # local_product 

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

605 if not local: 

606 raise ValueError("local product not found") 

607 row.local_product = local 

608 

609 else: # external product_id 

610 row.product_id = product_info 

611 

612 else: 

613 # pending_product 

614 if not self.allow_unknown_products(): 

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

616 row.product_id = None 

617 row.local_product = None 

618 pending = row.pending_product 

619 if not pending: 

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

621 created_by=user or row.batch.created_by) 

622 session.add(pending) 

623 row.pending_product = pending 

624 fields = [ 

625 'scancode', 

626 'brand_name', 

627 'description', 

628 'size', 

629 'weighed', 

630 'department_id', 

631 'department_name', 

632 'special_order', 

633 'vendor_name', 

634 'vendor_item_code', 

635 'case_size', 

636 'unit_cost', 

637 'unit_price_reg', 

638 'notes', 

639 ] 

640 for key in fields: 

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

642 

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

644 session.flush() 

645 session.refresh(pending) 

646 

647 # set order info 

648 row.order_qty = order_qty 

649 row.order_uom = order_uom 

650 

651 # discount 

652 if self.allow_item_discounts(): 

653 row.discount_percent = discount_percent or 0 

654 

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

656 session.flush() 

657 session.refresh(row) 

658 

659 # refresh per new info 

660 self.refresh_row(row) 

661 

662 def refresh_row(self, row): 

663 """ 

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

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

666 changing order quantity). 

667 

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

669 attributes: 

670 

671 * :meth:`refresh_row_from_external_product()` 

672 * :meth:`refresh_row_from_local_product()` 

673 * :meth:`refresh_row_from_pending_product()` 

674 

675 It then re-calculates the row's 

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

677 and updates the batch accordingly. 

678 

679 It also sets the row 

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

681 """ 

682 enum = self.app.enum 

683 row.status_code = None 

684 row.status_text = None 

685 

686 # ensure product 

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

688 row.status_code = row.STATUS_MISSING_PRODUCT 

689 return 

690 

691 # ensure order qty/uom 

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

693 row.status_code = row.STATUS_MISSING_ORDER_QTY 

694 return 

695 

696 # update product attrs on row 

697 if row.product_id: 

698 self.refresh_row_from_external_product(row) 

699 elif row.local_product: 

700 self.refresh_row_from_local_product(row) 

701 else: 

702 self.refresh_row_from_pending_product(row) 

703 

704 # we need to know if total price changes 

705 old_total = row.total_price 

706 

707 # update quoted price 

708 row.unit_price_quoted = None 

709 row.case_price_quoted = None 

710 if row.unit_price_sale is not None and ( 

711 not row.sale_ends 

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

713 row.unit_price_quoted = row.unit_price_sale 

714 else: 

715 row.unit_price_quoted = row.unit_price_reg 

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

717 row.case_price_quoted = row.unit_price_quoted * row.case_size 

718 

719 # update row total price 

720 row.total_price = None 

721 if row.order_uom == enum.ORDER_UOM_CASE: 

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

723 # if row.case_price_quoted: 

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

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

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

727 else: # ORDER_UOM_UNIT (or similar) 

728 if row.unit_price_quoted is not None: 

729 row.total_price = row.unit_price_quoted * row.order_qty 

730 if row.total_price is not None: 

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

732 row.total_price = (float(row.total_price) 

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

734 / 100.0) 

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

736 

737 # update batch if total price changed 

738 if row.total_price != old_total: 

739 batch = row.batch 

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

741 + (row.total_price or 0) 

742 - (old_total or 0)) 

743 

744 # all ok 

745 row.status_code = row.STATUS_OK 

746 

747 def refresh_row_from_local_product(self, row): 

748 """ 

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

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

751 record. 

752 

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

754 """ 

755 product = row.local_product 

756 row.product_scancode = product.scancode 

757 row.product_brand = product.brand_name 

758 row.product_description = product.description 

759 row.product_size = product.size 

760 row.product_weighed = product.weighed 

761 row.department_id = product.department_id 

762 row.department_name = product.department_name 

763 row.special_order = product.special_order 

764 row.case_size = product.case_size 

765 row.unit_cost = product.unit_cost 

766 row.unit_price_reg = product.unit_price_reg 

767 

768 def refresh_row_from_pending_product(self, row): 

769 """ 

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

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

772 record. 

773 

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

775 """ 

776 product = row.pending_product 

777 row.product_scancode = product.scancode 

778 row.product_brand = product.brand_name 

779 row.product_description = product.description 

780 row.product_size = product.size 

781 row.product_weighed = product.weighed 

782 row.department_id = product.department_id 

783 row.department_name = product.department_name 

784 row.special_order = product.special_order 

785 row.case_size = product.case_size 

786 row.unit_cost = product.unit_cost 

787 row.unit_price_reg = product.unit_price_reg 

788 

789 def refresh_row_from_external_product(self, row): 

790 """ 

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

792 :term:`external product` record indicated by 

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

794 

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

796 

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

798 needed. 

799 """ 

800 raise NotImplementedError 

801 

802 def remove_row(self, row): 

803 """ 

804 Remove a row from its batch. 

805 

806 This also will update the batch 

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

808 accordingly. 

809 """ 

810 if row.total_price: 

811 batch = row.batch 

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

813 

814 super().remove_row(row) 

815 

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

817 """ 

818 Delete a batch completely. 

819 

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

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

822 referenced by some order(s). 

823 """ 

824 session = self.app.get_session(batch) 

825 

826 # maybe delete pending customer 

827 customer = batch.pending_customer 

828 if customer and not customer.orders: 

829 session.delete(customer) 

830 

831 # maybe delete pending products 

832 for row in batch.rows: 

833 product = row.pending_product 

834 if product and not product.order_items: 

835 session.delete(product) 

836 

837 # continue with normal deletion 

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

839 

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

841 """ 

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

843 phone number, and at least one item. 

844 """ 

845 if not batch.customer_name: 

846 return "Must assign the customer" 

847 

848 if not batch.phone_number: 

849 return "Customer phone number is required" 

850 

851 rows = self.get_effective_rows(batch) 

852 if not rows: 

853 return "Must add at least one valid item" 

854 

855 def get_effective_rows(self, batch): 

856 """ 

857 Only rows with 

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

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

860 be created as proper order items. 

861 """ 

862 return [row for row in batch.rows 

863 if row.status_code == row.STATUS_OK] 

864 

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

866 """ 

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

868 

869 By default, this will call: 

870 

871 * :meth:`make_local_customer()` 

872 * :meth:`make_local_products()` 

873 * :meth:`make_new_order()` 

874 

875 And will return the new 

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

877 

878 Note that callers should use 

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

880 instead, which calls this method automatically. 

881 """ 

882 rows = self.get_effective_rows(batch) 

883 self.make_local_customer(batch) 

884 self.make_local_products(batch, rows) 

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

886 return order 

887 

888 def make_local_customer(self, batch): 

889 """ 

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

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

892 

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

894 

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

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

897 customer (so far). 

898 

899 It will create a new 

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

901 populate it from the batch 

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

903 The latter is then deleted. 

904 """ 

905 if not self.use_local_customers(): 

906 return 

907 

908 # nothing to do if no pending customer 

909 pending = batch.pending_customer 

910 if not pending: 

911 return 

912 

913 session = self.app.get_session(batch) 

914 

915 # maybe convert pending to local customer 

916 if not batch.local_customer: 

917 model = self.app.model 

918 inspector = sa.inspect(model.LocalCustomer) 

919 local = model.LocalCustomer() 

920 for prop in inspector.column_attrs: 

921 if hasattr(pending, prop.key): 

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

923 session.add(local) 

924 batch.local_customer = local 

925 

926 # remove pending customer 

927 batch.pending_customer = None 

928 session.delete(pending) 

929 session.flush() 

930 

931 def make_local_products(self, batch, rows): 

932 """ 

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

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

935 product>`. 

936 

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

938 

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

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

941 (so far). 

942 

943 For each affected row, it will create a new 

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

945 populate it from the row 

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

947 The latter is then deleted. 

948 """ 

949 if not self.use_local_products(): 

950 return 

951 

952 model = self.app.model 

953 session = self.app.get_session(batch) 

954 inspector = sa.inspect(model.LocalProduct) 

955 for row in rows: 

956 

957 if row.local_product or not row.pending_product: 

958 continue 

959 

960 pending = row.pending_product 

961 local = model.LocalProduct() 

962 

963 for prop in inspector.column_attrs: 

964 if hasattr(pending, prop.key): 

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

966 session.add(local) 

967 

968 row.local_product = local 

969 row.pending_product = None 

970 session.delete(pending) 

971 

972 session.flush() 

973 

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

975 """ 

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

977 

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

979 

980 :param batch: 

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

982 instance. 

983 

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

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

986 item>`. 

987 

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

989 """ 

990 model = self.app.model 

991 enum = self.app.enum 

992 session = self.app.get_session(batch) 

993 

994 batch_fields = [ 

995 'store_id', 

996 'customer_id', 

997 'local_customer', 

998 'pending_customer', 

999 'customer_name', 

1000 'phone_number', 

1001 'email_address', 

1002 'total_price', 

1003 ] 

1004 

1005 row_fields = [ 

1006 'product_id', 

1007 'local_product', 

1008 'pending_product', 

1009 'product_scancode', 

1010 'product_brand', 

1011 'product_description', 

1012 'product_size', 

1013 'product_weighed', 

1014 'department_id', 

1015 'department_name', 

1016 'case_size', 

1017 'order_qty', 

1018 'order_uom', 

1019 'unit_cost', 

1020 'unit_price_quoted', 

1021 'case_price_quoted', 

1022 'unit_price_reg', 

1023 'unit_price_sale', 

1024 'sale_ends', 

1025 'discount_percent', 

1026 'total_price', 

1027 'special_order', 

1028 ] 

1029 

1030 # make order 

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

1032 for field in batch_fields]) 

1033 kw['order_id'] = batch.id 

1034 kw['created_by'] = user 

1035 order = model.Order(**kw) 

1036 session.add(order) 

1037 session.flush() 

1038 

1039 def convert(row, i): 

1040 

1041 # make order item 

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

1043 for field in row_fields]) 

1044 item = model.OrderItem(**kw) 

1045 order.items.append(item) 

1046 

1047 # set item status 

1048 self.set_initial_item_status(item, user) 

1049 

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

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

1052 session.flush() 

1053 return order 

1054 

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

1056 """ 

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

1058 

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

1060 after it is added to the order. 

1061 

1062 Default logic will set status to 

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

1064 events: 

1065 

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

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

1068 

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

1070 being added to the new order. 

1071 

1072 :param user: 

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

1074 is performing the action. 

1075 """ 

1076 enum = self.app.enum 

1077 item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user) 

1078 item.add_event(enum.ORDER_ITEM_EVENT_READY, user) 

1079 item.status_code = enum.ORDER_ITEM_STATUS_READY