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

311 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-23 16:52 -0600

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

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

3# 

4# Sideshow -- Case/Special Order Tracker 

5# Copyright © 2024 Lance Edgar 

6# 

7# This file is part of Sideshow. 

8# 

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

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# General Public License for more details. 

18# 

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

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

21# 

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

23""" 

24New Order Batch Handler 

25""" 

26 

27import datetime 

28import decimal 

29 

30import sqlalchemy as sa 

31 

32from wuttjamaican.batch import BatchHandler 

33 

34from sideshow.db.model import NewOrderBatch 

35 

36 

37class NewOrderBatchHandler(BatchHandler): 

38 """ 

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

40 batch>`. 

41 

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

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

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

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

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

47 

48 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 autocomplete_customers_external(self, session, term, user=None): 

84 """ 

85 Return autocomplete search results for :term:`external 

86 customer` records. 

87 

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

89 

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

91 

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

93 

94 :param user: 

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

96 is doing the search, if known. 

97 

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

99 ``value`` and ``label`` keys. 

100 """ 

101 raise NotImplementedError 

102 

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

104 """ 

105 Return autocomplete search results for 

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

107 

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

109 

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

111 

112 :param user: 

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

114 is doing the search, if known. 

115 

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

117 ``value`` and ``label`` keys. 

118 """ 

119 model = self.app.model 

120 

121 # base query 

122 query = session.query(model.LocalCustomer) 

123 

124 # filter query 

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

126 for word in term.split()] 

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

128 

129 # sort query 

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

131 

132 # get data 

133 # TODO: need max_results option 

134 customers = query.all() 

135 

136 # get results 

137 def result(customer): 

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

139 'label': customer.full_name} 

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

141 

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

143 """ 

144 Set/update customer info for the batch. 

145 

146 This will first set one of the following: 

147 

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

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

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

151 

152 Note that a new 

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

154 is created if necessary. 

155 

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

157 

158 * :meth:`refresh_batch_from_external_customer()` 

159 * :meth:`refresh_batch_from_local_customer()` 

160 * :meth:`refresh_batch_from_pending_customer()` 

161 

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

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

164 

165 :param batch: 

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

167 update. 

168 

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

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

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

172 

173 :param user: 

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

175 is performing the action. This is used to set 

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

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

178 the batch creator is assumed. 

179 """ 

180 model = self.app.model 

181 enum = self.app.enum 

182 session = self.app.get_session(batch) 

183 use_local = self.use_local_customers() 

184 

185 # set customer info 

186 if isinstance(customer_info, str): 

187 if use_local: 

188 

189 # local_customer 

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

191 if not customer: 

192 raise ValueError("local customer not found") 

193 batch.local_customer = customer 

194 self.refresh_batch_from_local_customer(batch) 

195 

196 else: # external customer_id 

197 batch.customer_id = customer_info 

198 self.refresh_batch_from_external_customer(batch) 

199 

200 elif customer_info: 

201 

202 # pending_customer 

203 batch.customer_id = None 

204 batch.local_customer = None 

205 customer = batch.pending_customer 

206 if not customer: 

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

208 created_by=user or batch.created_by) 

209 session.add(customer) 

210 batch.pending_customer = customer 

211 fields = [ 

212 'full_name', 

213 'first_name', 

214 'last_name', 

215 'phone_number', 

216 'email_address', 

217 ] 

218 for key in fields: 

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

220 if 'full_name' not in customer_info: 

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

222 customer.last_name) 

223 self.refresh_batch_from_pending_customer(batch) 

224 

225 else: 

226 

227 # null 

228 batch.customer_id = None 

229 batch.local_customer = None 

230 batch.customer_name = None 

231 batch.phone_number = None 

232 batch.email_address = None 

233 

234 session.flush() 

235 

236 def refresh_batch_from_external_customer(self, batch): 

237 """ 

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

239 :term:`external customer` record. 

240 

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

242 

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

244 """ 

245 raise NotImplementedError 

246 

247 def refresh_batch_from_local_customer(self, batch): 

248 """ 

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

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

251 record. 

252 

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

254 """ 

255 customer = batch.local_customer 

256 batch.customer_name = customer.full_name 

257 batch.phone_number = customer.phone_number 

258 batch.email_address = customer.email_address 

259 

260 def refresh_batch_from_pending_customer(self, batch): 

261 """ 

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

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

264 record. 

265 

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

267 """ 

268 customer = batch.pending_customer 

269 batch.customer_name = customer.full_name 

270 batch.phone_number = customer.phone_number 

271 batch.email_address = customer.email_address 

272 

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

274 """ 

275 Return autocomplete search results for :term:`external 

276 product` records. 

277 

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

279 

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

281 

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

283 

284 :param user: 

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

286 is doing the search, if known. 

287 

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

289 ``value`` and ``label`` keys. 

290 """ 

291 raise NotImplementedError 

292 

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

294 """ 

295 Return autocomplete search results for 

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

297 

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

299 

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

301 

302 :param user: 

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

304 is doing the search, if known. 

305 

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

307 ``value`` and ``label`` keys. 

308 """ 

309 model = self.app.model 

310 

311 # base query 

312 query = session.query(model.LocalProduct) 

313 

314 # filter query 

315 criteria = [] 

316 for word in term.split(): 

317 criteria.append(sa.or_( 

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

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

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

321 

322 # sort query 

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

324 model.LocalProduct.description) 

325 

326 # get data 

327 # TODO: need max_results option 

328 products = query.all() 

329 

330 # get results 

331 def result(product): 

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

333 'label': product.full_description} 

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

335 

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

337 """ 

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

339 to ordering. 

340 

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

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

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

344 

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

346 

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

348 

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

350 info. 

351 

352 :param user: 

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

354 is performing the action, if known. 

355 

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

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

358 

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

360 identified via autocomplete/search lookup; therefore the 

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

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

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

364 

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

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

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

368 

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

370 ext_model = get_external_model() 

371 ext_session = make_external_session() 

372 

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

374 if not ext_product: 

375 ext_session.close() 

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

377 

378 info = { 

379 'product_id': product_id, 

380 'scancode': product.scancode, 

381 'brand_name': product.brand_name, 

382 'description': product.description, 

383 'size': product.size, 

384 'weighed': product.sold_by_weight, 

385 'special_order': False, 

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

387 'department_name': product.department_name, 

388 'case_size': product.case_size, 

389 'unit_price_reg': product.unit_price_reg, 

390 'vendor_name': product.vendor_name, 

391 'vendor_item_code': product.vendor_item_code, 

392 } 

393 

394 ext_session.close() 

395 return info 

396 """ 

397 raise NotImplementedError 

398 

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

400 """ 

401 Returns basic info for a 

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

403 to ordering. 

404 

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

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

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

408 

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

410 """ 

411 model = self.app.model 

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

413 if not product: 

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

415 

416 return { 

417 'product_id': product.uuid.hex, 

418 'scancode': product.scancode, 

419 'brand_name': product.brand_name, 

420 'description': product.description, 

421 'size': product.size, 

422 'full_description': product.full_description, 

423 'weighed': product.weighed, 

424 'special_order': product.special_order, 

425 'department_id': product.department_id, 

426 'department_name': product.department_name, 

427 'case_size': product.case_size, 

428 'unit_price_reg': product.unit_price_reg, 

429 'vendor_name': product.vendor_name, 

430 'vendor_item_code': product.vendor_item_code, 

431 } 

432 

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

434 """ 

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

436 

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

438 

439 :param batch: 

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

441 update. 

442 

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

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

445 

446 :param order_qty: 

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

448 value for the new row. 

449 

450 :param order_uom: 

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

452 value for the new row. 

453 

454 :param user: 

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

456 is performing the action. This is used to set 

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

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

459 the batch creator is assumed. 

460 

461 :returns: 

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

463 instance. 

464 """ 

465 model = self.app.model 

466 enum = self.app.enum 

467 session = self.app.get_session(batch) 

468 use_local = self.use_local_products() 

469 row = self.make_row() 

470 

471 # set product info 

472 if isinstance(product_info, str): 

473 if use_local: 

474 

475 # local_product 

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

477 if not local: 

478 raise ValueError("local product not found") 

479 row.local_product = local 

480 

481 else: # external product_id 

482 row.product_id = product_info 

483 

484 else: 

485 # pending_product 

486 if not self.allow_unknown_products(): 

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

488 row.product_id = None 

489 row.local_product = None 

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

491 created_by=user or batch.created_by) 

492 fields = [ 

493 'scancode', 

494 'brand_name', 

495 'description', 

496 'size', 

497 'weighed', 

498 'department_id', 

499 'department_name', 

500 'special_order', 

501 'vendor_name', 

502 'vendor_item_code', 

503 'case_size', 

504 'unit_cost', 

505 'unit_price_reg', 

506 'notes', 

507 ] 

508 for key in fields: 

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

510 

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

512 session.add(pending) 

513 session.flush() 

514 session.refresh(pending) 

515 row.pending_product = pending 

516 

517 # set order info 

518 row.order_qty = order_qty 

519 row.order_uom = order_uom 

520 

521 # add row to batch 

522 self.add_row(batch, row) 

523 session.flush() 

524 return row 

525 

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

527 """ 

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

529 

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

531 

532 :param row: 

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

534 to update. 

535 

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

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

538 

539 :param order_qty: New 

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

541 value for the row. 

542 

543 :param order_uom: New 

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

545 value for the row. 

546 

547 :param user: 

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

549 is performing the action. This is used to set 

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

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

552 the batch creator is assumed. 

553 """ 

554 model = self.app.model 

555 enum = self.app.enum 

556 session = self.app.get_session(row) 

557 use_local = self.use_local_products() 

558 

559 # set product info 

560 if isinstance(product_info, str): 

561 if use_local: 

562 

563 # local_product 

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

565 if not local: 

566 raise ValueError("local product not found") 

567 row.local_product = local 

568 

569 else: # external product_id 

570 row.product_id = product_info 

571 

572 else: 

573 # pending_product 

574 if not self.allow_unknown_products(): 

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

576 row.product_id = None 

577 row.local_product = None 

578 pending = row.pending_product 

579 if not pending: 

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

581 created_by=user or row.batch.created_by) 

582 session.add(pending) 

583 row.pending_product = pending 

584 fields = [ 

585 'scancode', 

586 'brand_name', 

587 'description', 

588 'size', 

589 'weighed', 

590 'department_id', 

591 'department_name', 

592 'special_order', 

593 'vendor_name', 

594 'vendor_item_code', 

595 'case_size', 

596 'unit_cost', 

597 'unit_price_reg', 

598 'notes', 

599 ] 

600 for key in fields: 

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

602 

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

604 session.flush() 

605 session.refresh(pending) 

606 

607 # set order info 

608 row.order_qty = order_qty 

609 row.order_uom = order_uom 

610 

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

612 session.flush() 

613 session.refresh(row) 

614 

615 # refresh per new info 

616 self.refresh_row(row) 

617 

618 def refresh_row(self, row): 

619 """ 

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

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

622 changing order quantity). 

623 

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

625 attributes: 

626 

627 * :meth:`refresh_row_from_external_product()` 

628 * :meth:`refresh_row_from_local_product()` 

629 * :meth:`refresh_row_from_pending_product()` 

630 

631 It then re-calculates the row's 

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

633 and updates the batch accordingly. 

634 

635 It also sets the row 

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

637 """ 

638 enum = self.app.enum 

639 row.status_code = None 

640 row.status_text = None 

641 

642 # ensure product 

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

644 row.status_code = row.STATUS_MISSING_PRODUCT 

645 return 

646 

647 # ensure order qty/uom 

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

649 row.status_code = row.STATUS_MISSING_ORDER_QTY 

650 return 

651 

652 # update product attrs on row 

653 if row.product_id: 

654 self.refresh_row_from_external_product(row) 

655 elif row.local_product: 

656 self.refresh_row_from_local_product(row) 

657 else: 

658 self.refresh_row_from_pending_product(row) 

659 

660 # we need to know if total price changes 

661 old_total = row.total_price 

662 

663 # update quoted price 

664 row.unit_price_quoted = None 

665 row.case_price_quoted = None 

666 if row.unit_price_sale is not None and ( 

667 not row.sale_ends 

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

669 row.unit_price_quoted = row.unit_price_sale 

670 else: 

671 row.unit_price_quoted = row.unit_price_reg 

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

673 row.case_price_quoted = row.unit_price_quoted * row.case_size 

674 

675 # update row total price 

676 row.total_price = None 

677 if row.order_uom == enum.ORDER_UOM_CASE: 

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

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

680 else: # ORDER_UOM_UNIT (or similar) 

681 if row.unit_price_quoted is not None: 

682 row.total_price = row.unit_price_quoted * row.order_qty 

683 if row.total_price is not None: 

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

685 

686 # update batch if total price changed 

687 if row.total_price != old_total: 

688 batch = row.batch 

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

690 + (row.total_price or 0) 

691 - (old_total or 0)) 

692 

693 # all ok 

694 row.status_code = row.STATUS_OK 

695 

696 def refresh_row_from_local_product(self, row): 

697 """ 

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

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

700 record. 

701 

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

703 """ 

704 product = row.local_product 

705 row.product_scancode = product.scancode 

706 row.product_brand = product.brand_name 

707 row.product_description = product.description 

708 row.product_size = product.size 

709 row.product_weighed = product.weighed 

710 row.department_id = product.department_id 

711 row.department_name = product.department_name 

712 row.special_order = product.special_order 

713 row.case_size = product.case_size 

714 row.unit_cost = product.unit_cost 

715 row.unit_price_reg = product.unit_price_reg 

716 

717 def refresh_row_from_pending_product(self, row): 

718 """ 

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

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

721 record. 

722 

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

724 """ 

725 product = row.pending_product 

726 row.product_scancode = product.scancode 

727 row.product_brand = product.brand_name 

728 row.product_description = product.description 

729 row.product_size = product.size 

730 row.product_weighed = product.weighed 

731 row.department_id = product.department_id 

732 row.department_name = product.department_name 

733 row.special_order = product.special_order 

734 row.case_size = product.case_size 

735 row.unit_cost = product.unit_cost 

736 row.unit_price_reg = product.unit_price_reg 

737 

738 def refresh_row_from_external_product(self, row): 

739 """ 

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

741 :term:`external product` record indicated by 

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

743 

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

745 

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

747 needed. 

748 """ 

749 raise NotImplementedError 

750 

751 def remove_row(self, row): 

752 """ 

753 Remove a row from its batch. 

754 

755 This also will update the batch 

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

757 accordingly. 

758 """ 

759 if row.total_price: 

760 batch = row.batch 

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

762 

763 super().remove_row(row) 

764 

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

766 """ 

767 Delete a batch completely. 

768 

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

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

771 referenced by some order(s). 

772 """ 

773 session = self.app.get_session(batch) 

774 

775 # maybe delete pending customer 

776 customer = batch.pending_customer 

777 if customer and not customer.orders: 

778 session.delete(customer) 

779 

780 # maybe delete pending products 

781 for row in batch.rows: 

782 product = row.pending_product 

783 if product and not product.order_items: 

784 session.delete(product) 

785 

786 # continue with normal deletion 

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

788 

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

790 """ 

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

792 phone number, and at least one item. 

793 """ 

794 if not batch.customer_name: 

795 return "Must assign the customer" 

796 

797 if not batch.phone_number: 

798 return "Customer phone number is required" 

799 

800 rows = self.get_effective_rows(batch) 

801 if not rows: 

802 return "Must add at least one valid item" 

803 

804 def get_effective_rows(self, batch): 

805 """ 

806 Only rows with 

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

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

809 be created as proper order items. 

810 """ 

811 return [row for row in batch.rows 

812 if row.status_code == row.STATUS_OK] 

813 

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

815 """ 

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

817 

818 By default, this will call: 

819 

820 * :meth:`make_local_customer()` 

821 * :meth:`make_local_products()` 

822 * :meth:`make_new_order()` 

823 

824 And will return the new 

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

826 

827 Note that callers should use 

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

829 instead, which calls this method automatically. 

830 """ 

831 rows = self.get_effective_rows(batch) 

832 self.make_local_customer(batch) 

833 self.make_local_products(batch, rows) 

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

835 return order 

836 

837 def make_local_customer(self, batch): 

838 """ 

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

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

841 

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

843 

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

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

846 customer (so far). 

847 

848 It will create a new 

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

850 populate it from the batch 

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

852 The latter is then deleted. 

853 """ 

854 if not self.use_local_customers(): 

855 return 

856 

857 # nothing to do if no pending customer 

858 pending = batch.pending_customer 

859 if not pending: 

860 return 

861 

862 session = self.app.get_session(batch) 

863 

864 # maybe convert pending to local customer 

865 if not batch.local_customer: 

866 model = self.app.model 

867 inspector = sa.inspect(model.LocalCustomer) 

868 local = model.LocalCustomer() 

869 for prop in inspector.column_attrs: 

870 if hasattr(pending, prop.key): 

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

872 session.add(local) 

873 batch.local_customer = local 

874 

875 # remove pending customer 

876 batch.pending_customer = None 

877 session.delete(pending) 

878 session.flush() 

879 

880 def make_local_products(self, batch, rows): 

881 """ 

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

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

884 product>`. 

885 

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

887 

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

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

890 (so far). 

891 

892 For each affected row, it will create a new 

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

894 populate it from the row 

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

896 The latter is then deleted. 

897 """ 

898 if not self.use_local_products(): 

899 return 

900 

901 model = self.app.model 

902 session = self.app.get_session(batch) 

903 inspector = sa.inspect(model.LocalProduct) 

904 for row in rows: 

905 

906 if row.local_product or not row.pending_product: 

907 continue 

908 

909 pending = row.pending_product 

910 local = model.LocalProduct() 

911 

912 for prop in inspector.column_attrs: 

913 if hasattr(pending, prop.key): 

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

915 session.add(local) 

916 

917 row.local_product = local 

918 row.pending_product = None 

919 session.delete(pending) 

920 

921 session.flush() 

922 

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

924 """ 

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

926 

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

928 

929 :param batch: 

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

931 instance. 

932 

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

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

935 item>`. 

936 

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

938 """ 

939 model = self.app.model 

940 enum = self.app.enum 

941 session = self.app.get_session(batch) 

942 

943 batch_fields = [ 

944 'store_id', 

945 'customer_id', 

946 'local_customer', 

947 'pending_customer', 

948 'customer_name', 

949 'phone_number', 

950 'email_address', 

951 'total_price', 

952 ] 

953 

954 row_fields = [ 

955 'product_id', 

956 'local_product', 

957 'pending_product', 

958 'product_scancode', 

959 'product_brand', 

960 'product_description', 

961 'product_size', 

962 'product_weighed', 

963 'department_id', 

964 'department_name', 

965 'case_size', 

966 'order_qty', 

967 'order_uom', 

968 'unit_cost', 

969 'unit_price_quoted', 

970 'case_price_quoted', 

971 'unit_price_reg', 

972 'unit_price_sale', 

973 'sale_ends', 

974 # 'discount_percent', 

975 'total_price', 

976 'special_order', 

977 ] 

978 

979 # make order 

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

981 for field in batch_fields]) 

982 kw['order_id'] = batch.id 

983 kw['created_by'] = user 

984 order = model.Order(**kw) 

985 session.add(order) 

986 session.flush() 

987 

988 def convert(row, i): 

989 

990 # make order item 

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

992 for field in row_fields]) 

993 item = model.OrderItem(**kw) 

994 order.items.append(item) 

995 

996 # set item status 

997 self.set_initial_item_status(item, user) 

998 

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

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

1001 session.flush() 

1002 return order 

1003 

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

1005 """ 

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

1007 

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

1009 after it is added to the order. 

1010 

1011 Default logic will set status to 

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

1013 events: 

1014 

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

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

1017 

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

1019 being added to the new order. 

1020 

1021 :param user: 

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

1023 is performing the action. 

1024 """ 

1025 enum = self.app.enum 

1026 item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user) 

1027 item.add_event(enum.ORDER_ITEM_EVENT_READY, user) 

1028 item.status_code = enum.ORDER_ITEM_STATUS_READY