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

306 statements  

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

49 

50 def use_local_customers(self): 

51 """ 

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

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

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

55 """ 

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

57 default=True) 

58 

59 def use_local_products(self): 

60 """ 

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

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

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

64 """ 

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

66 default=True) 

67 

68 def allow_unknown_products(self): 

69 """ 

70 Returns boolean indicating whether :term:`pending products 

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

72 

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

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

75 to choose existing local/external product. 

76 """ 

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

78 default=True) 

79 

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

81 """ 

82 Return autocomplete search results for :term:`external 

83 customer` records. 

84 

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

86 

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

88 

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

90 

91 :param user: 

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

93 is doing the search, if known. 

94 

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

96 ``value`` and ``label`` keys. 

97 """ 

98 raise NotImplementedError 

99 

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

101 """ 

102 Return autocomplete search results for 

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

104 

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

106 

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

108 

109 :param user: 

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

111 is doing the search, if known. 

112 

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

114 ``value`` and ``label`` keys. 

115 """ 

116 model = self.app.model 

117 

118 # base query 

119 query = session.query(model.LocalCustomer) 

120 

121 # filter query 

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

123 for word in term.split()] 

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

125 

126 # sort query 

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

128 

129 # get data 

130 # TODO: need max_results option 

131 customers = query.all() 

132 

133 # get results 

134 def result(customer): 

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

136 'label': customer.full_name} 

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

138 

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

140 """ 

141 Set/update customer info for the batch. 

142 

143 This will first set one of the following: 

144 

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

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

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

148 

149 Note that a new 

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

151 is created if necessary. 

152 

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

154 

155 * :meth:`refresh_batch_from_external_customer()` 

156 * :meth:`refresh_batch_from_local_customer()` 

157 * :meth:`refresh_batch_from_pending_customer()` 

158 

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

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

161 

162 :param batch: 

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

164 update. 

165 

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

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

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

169 

170 :param user: 

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

172 is performing the action. This is used to set 

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

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

175 the batch creator is assumed. 

176 """ 

177 model = self.app.model 

178 enum = self.app.enum 

179 session = self.app.get_session(batch) 

180 use_local = self.use_local_customers() 

181 

182 # set customer info 

183 if isinstance(customer_info, str): 

184 if use_local: 

185 

186 # local_customer 

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

188 if not customer: 

189 raise ValueError("local customer not found") 

190 batch.local_customer = customer 

191 self.refresh_batch_from_local_customer(batch) 

192 

193 else: # external customer_id 

194 batch.customer_id = customer_info 

195 self.refresh_batch_from_external_customer(batch) 

196 

197 elif customer_info: 

198 

199 # pending_customer 

200 batch.customer_id = None 

201 batch.local_customer = None 

202 customer = batch.pending_customer 

203 if not customer: 

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

205 created_by=user or batch.created_by) 

206 session.add(customer) 

207 batch.pending_customer = customer 

208 fields = [ 

209 'full_name', 

210 'first_name', 

211 'last_name', 

212 'phone_number', 

213 'email_address', 

214 ] 

215 for key in fields: 

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

217 if 'full_name' not in customer_info: 

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

219 customer.last_name) 

220 self.refresh_batch_from_pending_customer(batch) 

221 

222 else: 

223 

224 # null 

225 batch.customer_id = None 

226 batch.local_customer = None 

227 batch.customer_name = None 

228 batch.phone_number = None 

229 batch.email_address = None 

230 

231 session.flush() 

232 

233 def refresh_batch_from_external_customer(self, batch): 

234 """ 

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

236 :term:`external customer` record. 

237 

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

239 

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

241 """ 

242 raise NotImplementedError 

243 

244 def refresh_batch_from_local_customer(self, batch): 

245 """ 

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

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

248 record. 

249 

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

251 """ 

252 customer = batch.local_customer 

253 batch.customer_name = customer.full_name 

254 batch.phone_number = customer.phone_number 

255 batch.email_address = customer.email_address 

256 

257 def refresh_batch_from_pending_customer(self, batch): 

258 """ 

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

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

261 record. 

262 

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

264 """ 

265 customer = batch.pending_customer 

266 batch.customer_name = customer.full_name 

267 batch.phone_number = customer.phone_number 

268 batch.email_address = customer.email_address 

269 

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

271 """ 

272 Return autocomplete search results for :term:`external 

273 product` records. 

274 

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

276 

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

278 

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

280 

281 :param user: 

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

283 is doing the search, if known. 

284 

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

286 ``value`` and ``label`` keys. 

287 """ 

288 raise NotImplementedError 

289 

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

291 """ 

292 Return autocomplete search results for 

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

294 

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

296 

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

298 

299 :param user: 

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

301 is doing the search, if known. 

302 

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

304 ``value`` and ``label`` keys. 

305 """ 

306 model = self.app.model 

307 

308 # base query 

309 query = session.query(model.LocalProduct) 

310 

311 # filter query 

312 criteria = [] 

313 for word in term.split(): 

314 criteria.append(sa.or_( 

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

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

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

318 

319 # sort query 

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

321 model.LocalProduct.description) 

322 

323 # get data 

324 # TODO: need max_results option 

325 products = query.all() 

326 

327 # get results 

328 def result(product): 

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

330 'label': product.full_description} 

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

332 

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

334 """ 

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

336 to ordering. 

337 

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

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

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

341 

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

343 

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

345 

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

347 info. 

348 

349 :param user: 

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

351 is performing the action, if known. 

352 

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

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

355 

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

357 identified via autocomplete/search lookup; therefore the 

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

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

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

361 

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

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

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

365 

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

367 ext_model = get_external_model() 

368 ext_session = make_external_session() 

369 

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

371 if not ext_product: 

372 ext_session.close() 

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

374 

375 info = { 

376 'product_id': product_id, 

377 'scancode': product.scancode, 

378 'brand_name': product.brand_name, 

379 'description': product.description, 

380 'size': product.size, 

381 'weighed': product.sold_by_weight, 

382 'special_order': False, 

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

384 'department_name': product.department_name, 

385 'case_size': product.case_size, 

386 'unit_price_reg': product.unit_price_reg, 

387 'vendor_name': product.vendor_name, 

388 'vendor_item_code': product.vendor_item_code, 

389 } 

390 

391 ext_session.close() 

392 return info 

393 """ 

394 raise NotImplementedError 

395 

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

397 """ 

398 Returns basic info for a 

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

400 to ordering. 

401 

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

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

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

405 

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

407 """ 

408 model = self.app.model 

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

410 if not product: 

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

412 

413 return { 

414 'product_id': product.uuid.hex, 

415 'scancode': product.scancode, 

416 'brand_name': product.brand_name, 

417 'description': product.description, 

418 'size': product.size, 

419 'full_description': product.full_description, 

420 'weighed': product.weighed, 

421 'special_order': product.special_order, 

422 'department_id': product.department_id, 

423 'department_name': product.department_name, 

424 'case_size': product.case_size, 

425 'unit_price_reg': product.unit_price_reg, 

426 'vendor_name': product.vendor_name, 

427 'vendor_item_code': product.vendor_item_code, 

428 } 

429 

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

431 """ 

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

433 

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

435 

436 :param batch: 

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

438 update. 

439 

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

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

442 

443 :param order_qty: 

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

445 value for the new row. 

446 

447 :param order_uom: 

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

449 value for the new row. 

450 

451 :param user: 

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

453 is performing the action. This is used to set 

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

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

456 the batch creator is assumed. 

457 

458 :returns: 

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

460 instance. 

461 """ 

462 model = self.app.model 

463 enum = self.app.enum 

464 session = self.app.get_session(batch) 

465 use_local = self.use_local_products() 

466 row = self.make_row() 

467 

468 # set product info 

469 if isinstance(product_info, str): 

470 if use_local: 

471 

472 # local_product 

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

474 if not local: 

475 raise ValueError("local product not found") 

476 row.local_product = local 

477 

478 else: # external product_id 

479 row.product_id = product_info 

480 

481 else: 

482 # pending_product 

483 if not self.allow_unknown_products(): 

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

485 row.product_id = None 

486 row.local_product = None 

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

488 created_by=user or batch.created_by) 

489 fields = [ 

490 'scancode', 

491 'brand_name', 

492 'description', 

493 'size', 

494 'weighed', 

495 'department_id', 

496 'department_name', 

497 'special_order', 

498 'vendor_name', 

499 'vendor_item_code', 

500 'case_size', 

501 'unit_cost', 

502 'unit_price_reg', 

503 'notes', 

504 ] 

505 for key in fields: 

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

507 

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

509 session.add(pending) 

510 session.flush() 

511 session.refresh(pending) 

512 row.pending_product = pending 

513 

514 # set order info 

515 row.order_qty = order_qty 

516 row.order_uom = order_uom 

517 

518 # add row to batch 

519 self.add_row(batch, row) 

520 session.flush() 

521 return row 

522 

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

524 """ 

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

526 

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

528 

529 :param row: 

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

531 to update. 

532 

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

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

535 

536 :param order_qty: New 

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

538 value for the row. 

539 

540 :param order_uom: New 

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

542 value for the row. 

543 

544 :param user: 

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

546 is performing the action. This is used to set 

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

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

549 the batch creator is assumed. 

550 """ 

551 model = self.app.model 

552 enum = self.app.enum 

553 session = self.app.get_session(row) 

554 use_local = self.use_local_products() 

555 

556 # set product info 

557 if isinstance(product_info, str): 

558 if use_local: 

559 

560 # local_product 

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

562 if not local: 

563 raise ValueError("local product not found") 

564 row.local_product = local 

565 

566 else: # external product_id 

567 row.product_id = product_info 

568 

569 else: 

570 # pending_product 

571 if not self.allow_unknown_products(): 

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

573 row.product_id = None 

574 row.local_product = None 

575 pending = row.pending_product 

576 if not pending: 

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

578 created_by=user or row.batch.created_by) 

579 session.add(pending) 

580 row.pending_product = pending 

581 fields = [ 

582 'scancode', 

583 'brand_name', 

584 'description', 

585 'size', 

586 'weighed', 

587 'department_id', 

588 'department_name', 

589 'special_order', 

590 'vendor_name', 

591 'vendor_item_code', 

592 'case_size', 

593 'unit_cost', 

594 'unit_price_reg', 

595 'notes', 

596 ] 

597 for key in fields: 

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

599 

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

601 session.flush() 

602 session.refresh(pending) 

603 

604 # set order info 

605 row.order_qty = order_qty 

606 row.order_uom = order_uom 

607 

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

609 session.flush() 

610 session.refresh(row) 

611 

612 # refresh per new info 

613 self.refresh_row(row) 

614 

615 def refresh_row(self, row): 

616 """ 

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

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

619 changing order quantity). 

620 

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

622 attributes: 

623 

624 * :meth:`refresh_row_from_external_product()` 

625 * :meth:`refresh_row_from_local_product()` 

626 * :meth:`refresh_row_from_pending_product()` 

627 

628 It then re-calculates the row's 

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

630 and updates the batch accordingly. 

631 

632 It also sets the row 

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

634 """ 

635 enum = self.app.enum 

636 row.status_code = None 

637 row.status_text = None 

638 

639 # ensure product 

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

641 row.status_code = row.STATUS_MISSING_PRODUCT 

642 return 

643 

644 # ensure order qty/uom 

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

646 row.status_code = row.STATUS_MISSING_ORDER_QTY 

647 return 

648 

649 # update product attrs on row 

650 if row.product_id: 

651 self.refresh_row_from_external_product(row) 

652 elif row.local_product: 

653 self.refresh_row_from_local_product(row) 

654 else: 

655 self.refresh_row_from_pending_product(row) 

656 

657 # we need to know if total price changes 

658 old_total = row.total_price 

659 

660 # update quoted price 

661 row.unit_price_quoted = None 

662 row.case_price_quoted = None 

663 if row.unit_price_sale is not None and ( 

664 not row.sale_ends 

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

666 row.unit_price_quoted = row.unit_price_sale 

667 else: 

668 row.unit_price_quoted = row.unit_price_reg 

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

670 row.case_price_quoted = row.unit_price_quoted * row.case_size 

671 

672 # update row total price 

673 row.total_price = None 

674 if row.order_uom == enum.ORDER_UOM_CASE: 

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

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

677 else: # ORDER_UOM_UNIT (or similar) 

678 if row.unit_price_quoted is not None: 

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

680 if row.total_price is not None: 

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

682 

683 # update batch if total price changed 

684 if row.total_price != old_total: 

685 batch = row.batch 

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

687 + (row.total_price or 0) 

688 - (old_total or 0)) 

689 

690 # all ok 

691 row.status_code = row.STATUS_OK 

692 

693 def refresh_row_from_local_product(self, row): 

694 """ 

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

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

697 record. 

698 

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

700 """ 

701 product = row.local_product 

702 row.product_scancode = product.scancode 

703 row.product_brand = product.brand_name 

704 row.product_description = product.description 

705 row.product_size = product.size 

706 row.product_weighed = product.weighed 

707 row.department_id = product.department_id 

708 row.department_name = product.department_name 

709 row.special_order = product.special_order 

710 row.case_size = product.case_size 

711 row.unit_cost = product.unit_cost 

712 row.unit_price_reg = product.unit_price_reg 

713 

714 def refresh_row_from_pending_product(self, row): 

715 """ 

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

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

718 record. 

719 

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

721 """ 

722 product = row.pending_product 

723 row.product_scancode = product.scancode 

724 row.product_brand = product.brand_name 

725 row.product_description = product.description 

726 row.product_size = product.size 

727 row.product_weighed = product.weighed 

728 row.department_id = product.department_id 

729 row.department_name = product.department_name 

730 row.special_order = product.special_order 

731 row.case_size = product.case_size 

732 row.unit_cost = product.unit_cost 

733 row.unit_price_reg = product.unit_price_reg 

734 

735 def refresh_row_from_external_product(self, row): 

736 """ 

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

738 :term:`external product` record indicated by 

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

740 

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

742 

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

744 needed. 

745 """ 

746 raise NotImplementedError 

747 

748 def remove_row(self, row): 

749 """ 

750 Remove a row from its batch. 

751 

752 This also will update the batch 

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

754 accordingly. 

755 """ 

756 if row.total_price: 

757 batch = row.batch 

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

759 

760 super().remove_row(row) 

761 

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

763 """ 

764 Delete a batch completely. 

765 

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

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

768 referenced by some order(s). 

769 """ 

770 session = self.app.get_session(batch) 

771 

772 # maybe delete pending customer 

773 customer = batch.pending_customer 

774 if customer and not customer.orders: 

775 session.delete(customer) 

776 

777 # maybe delete pending products 

778 for row in batch.rows: 

779 product = row.pending_product 

780 if product and not product.order_items: 

781 session.delete(product) 

782 

783 # continue with normal deletion 

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

785 

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

787 """ 

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

789 phone number, and at least one item. 

790 """ 

791 if not batch.customer_name: 

792 return "Must assign the customer" 

793 

794 if not batch.phone_number: 

795 return "Customer phone number is required" 

796 

797 rows = self.get_effective_rows(batch) 

798 if not rows: 

799 return "Must add at least one valid item" 

800 

801 def get_effective_rows(self, batch): 

802 """ 

803 Only rows with 

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

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

806 be created as proper order items. 

807 """ 

808 return [row for row in batch.rows 

809 if row.status_code == row.STATUS_OK] 

810 

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

812 """ 

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

814 

815 By default, this will call: 

816 

817 * :meth:`make_local_customer()` 

818 * :meth:`make_local_products()` 

819 * :meth:`make_new_order()` 

820 

821 And will return the new 

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

823 

824 Note that callers should use 

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

826 instead, which calls this method automatically. 

827 """ 

828 rows = self.get_effective_rows(batch) 

829 self.make_local_customer(batch) 

830 self.make_local_products(batch, rows) 

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

832 return order 

833 

834 def make_local_customer(self, batch): 

835 """ 

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

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

838 

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

840 

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

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

843 customer (so far). 

844 

845 It will create a new 

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

847 populate it from the batch 

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

849 The latter is then deleted. 

850 """ 

851 if not self.use_local_customers(): 

852 return 

853 

854 # nothing to do if no pending customer 

855 pending = batch.pending_customer 

856 if not pending: 

857 return 

858 

859 session = self.app.get_session(batch) 

860 

861 # maybe convert pending to local customer 

862 if not batch.local_customer: 

863 model = self.app.model 

864 inspector = sa.inspect(model.LocalCustomer) 

865 local = model.LocalCustomer() 

866 for prop in inspector.column_attrs: 

867 if hasattr(pending, prop.key): 

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

869 session.add(local) 

870 batch.local_customer = local 

871 

872 # remove pending customer 

873 batch.pending_customer = None 

874 session.delete(pending) 

875 session.flush() 

876 

877 def make_local_products(self, batch, rows): 

878 """ 

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

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

881 product>`. 

882 

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

884 

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

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

887 (so far). 

888 

889 For each affected row, it will create a new 

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

891 populate it from the row 

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

893 The latter is then deleted. 

894 """ 

895 if not self.use_local_products(): 

896 return 

897 

898 model = self.app.model 

899 session = self.app.get_session(batch) 

900 inspector = sa.inspect(model.LocalProduct) 

901 for row in rows: 

902 

903 if row.local_product or not row.pending_product: 

904 continue 

905 

906 pending = row.pending_product 

907 local = model.LocalProduct() 

908 

909 for prop in inspector.column_attrs: 

910 if hasattr(pending, prop.key): 

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

912 session.add(local) 

913 

914 row.local_product = local 

915 row.pending_product = None 

916 session.delete(pending) 

917 

918 session.flush() 

919 

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

921 """ 

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

923 

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

925 

926 :param batch: 

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

928 instance. 

929 

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

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

932 item>`. 

933 

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

935 """ 

936 model = self.app.model 

937 enum = self.app.enum 

938 session = self.app.get_session(batch) 

939 

940 batch_fields = [ 

941 'store_id', 

942 'customer_id', 

943 'local_customer', 

944 'pending_customer', 

945 'customer_name', 

946 'phone_number', 

947 'email_address', 

948 'total_price', 

949 ] 

950 

951 row_fields = [ 

952 'product_id', 

953 'local_product', 

954 'pending_product', 

955 'product_scancode', 

956 'product_brand', 

957 'product_description', 

958 'product_size', 

959 'product_weighed', 

960 'department_id', 

961 'department_name', 

962 'case_size', 

963 'order_qty', 

964 'order_uom', 

965 'unit_cost', 

966 'unit_price_quoted', 

967 'case_price_quoted', 

968 'unit_price_reg', 

969 'unit_price_sale', 

970 'sale_ends', 

971 # 'discount_percent', 

972 'total_price', 

973 'special_order', 

974 ] 

975 

976 # make order 

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

978 for field in batch_fields]) 

979 kw['order_id'] = batch.id 

980 kw['created_by'] = user 

981 order = model.Order(**kw) 

982 session.add(order) 

983 session.flush() 

984 

985 def convert(row, i): 

986 

987 # make order item 

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

989 for field in row_fields]) 

990 item = model.OrderItem(**kw) 

991 order.items.append(item) 

992 

993 # set item status 

994 item.status_code = enum.ORDER_ITEM_STATUS_INITIATED 

995 

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

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

998 session.flush() 

999 return order