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

156 statements  

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

30from wuttjamaican.batch import BatchHandler 

31 

32from sideshow.db.model import NewOrderBatch 

33 

34 

35class NewOrderBatchHandler(BatchHandler): 

36 """ 

37 The :term:`batch handler` for New Order Batches. 

38 

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

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

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

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

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

44 """ 

45 model_class = NewOrderBatch 

46 

47 def set_pending_customer(self, batch, data): 

48 """ 

49 Set (add or update) pending customer info for the batch. 

50 

51 This will clear the 

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

53 and set the 

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

55 creating a new record if needed. It then updates the pending 

56 customer record per the given ``data``. 

57 

58 :param batch: 

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

60 to be updated. 

61 

62 :param data: Dict of field data for the 

63 :class:`~sideshow.db.model.customers.PendingCustomer` 

64 record. 

65 """ 

66 model = self.app.model 

67 enum = self.app.enum 

68 

69 # remove customer account if set 

70 batch.customer_id = None 

71 

72 # create pending customer if needed 

73 pending = batch.pending_customer 

74 if not pending: 

75 kw = dict(data) 

76 kw.setdefault('status', enum.PendingCustomerStatus.PENDING) 

77 pending = model.PendingCustomer(**kw) 

78 batch.pending_customer = pending 

79 

80 # update pending customer 

81 if 'first_name' in data: 

82 pending.first_name = data['first_name'] 

83 if 'last_name' in data: 

84 pending.last_name = data['last_name'] 

85 if 'full_name' in data: 

86 pending.full_name = data['full_name'] 

87 elif 'first_name' in data or 'last_name' in data: 

88 pending.full_name = self.app.make_full_name(data.get('first_name'), 

89 data.get('last_name')) 

90 if 'phone_number' in data: 

91 pending.phone_number = data['phone_number'] 

92 if 'email_address' in data: 

93 pending.email_address = data['email_address'] 

94 

95 # update batch per pending customer 

96 batch.customer_name = pending.full_name 

97 batch.phone_number = pending.phone_number 

98 batch.email_address = pending.email_address 

99 

100 def add_pending_product(self, batch, pending_info, 

101 order_qty, order_uom): 

102 """ 

103 Add a new row to the batch, for the given "pending" product 

104 and order quantity. 

105 

106 See also :meth:`set_pending_product()` to update an existing row. 

107 

108 :param batch: 

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

110 which the row should be added. 

111 

112 :param pending_info: Dict of kwargs to use when constructing a 

113 new :class:`~sideshow.db.model.products.PendingProduct`. 

114 

115 :param order_qty: Quantity of the product to be added to the 

116 order. 

117 

118 :param order_uom: UOM for the order quantity; must be a code 

119 from :data:`~sideshow.enum.ORDER_UOM`. 

120 

121 :returns: 

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

123 which was added to the batch. 

124 """ 

125 model = self.app.model 

126 enum = self.app.enum 

127 session = self.app.get_session(batch) 

128 

129 # make new pending product 

130 kw = dict(pending_info) 

131 kw.setdefault('status', enum.PendingProductStatus.PENDING) 

132 product = model.PendingProduct(**kw) 

133 session.add(product) 

134 session.flush() 

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

136 session.refresh(product) 

137 

138 # make/add new row, w/ pending product 

139 row = self.make_row(pending_product=product, 

140 order_qty=order_qty, order_uom=order_uom) 

141 self.add_row(batch, row) 

142 session.add(row) 

143 session.flush() 

144 return row 

145 

146 def set_pending_product(self, row, data): 

147 """ 

148 Set (add or update) pending product info for the given batch row. 

149 

150 This will clear the 

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

152 and set the 

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

154 creating a new record if needed. It then updates the pending 

155 product record per the given ``data``, and finally calls 

156 :meth:`refresh_row()`. 

157 

158 Note that this does not update order quantity for the item. 

159 

160 See also :meth:`add_pending_product()` to add a new row 

161 instead of updating. 

162 

163 :param row: 

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

165 to be updated. 

166 

167 :param data: Dict of field data for the 

168 :class:`~sideshow.db.model.products.PendingProduct` record. 

169 """ 

170 model = self.app.model 

171 enum = self.app.enum 

172 session = self.app.get_session(row) 

173 

174 # values for these fields can be used as-is 

175 simple_fields = [ 

176 'scancode', 

177 'brand_name', 

178 'description', 

179 'size', 

180 'weighed', 

181 'department_id', 

182 'department_name', 

183 'special_order', 

184 'vendor_name', 

185 'vendor_item_code', 

186 'notes', 

187 'unit_cost', 

188 'case_size', 

189 'case_cost', 

190 'unit_price_reg', 

191 ] 

192 

193 # clear true product id 

194 row.product_id = None 

195 

196 # make pending product if needed 

197 product = row.pending_product 

198 if not product: 

199 kw = dict(data) 

200 kw.setdefault('status', enum.PendingProductStatus.PENDING) 

201 product = model.PendingProduct(**kw) 

202 session.add(product) 

203 row.pending_product = product 

204 session.flush() 

205 

206 # update pending product 

207 for field in simple_fields: 

208 if field in data: 

209 setattr(product, field, data[field]) 

210 

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

212 session.flush() 

213 session.refresh(product) 

214 

215 # refresh per new info 

216 self.refresh_row(row) 

217 

218 def refresh_row(self, row, now=None): 

219 """ 

220 Refresh all data for the row. This is called when adding a 

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

222 changing order quantity). 

223 

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

225 attributes for the row: 

226 

227 * :meth:`refresh_row_from_pending_product()` 

228 * :meth:`refresh_row_from_true_product()` 

229 

230 It then re-calculates the row's 

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

232 and updates the batch accordingly. 

233 

234 It also sets the row 

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

236 """ 

237 enum = self.app.enum 

238 row.status_code = None 

239 row.status_text = None 

240 

241 # ensure product 

242 if not row.product_id and not row.pending_product: 

243 row.status_code = row.STATUS_MISSING_PRODUCT 

244 return 

245 

246 # ensure order qty/uom 

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

248 row.status_code = row.STATUS_MISSING_ORDER_QTY 

249 return 

250 

251 # update product attrs on row 

252 if row.product_id: 

253 self.refresh_row_from_true_product(row) 

254 else: 

255 self.refresh_row_from_pending_product(row) 

256 

257 # we need to know if total price changes 

258 old_total = row.total_price 

259 

260 # update quoted price 

261 row.unit_price_quoted = None 

262 row.case_price_quoted = None 

263 if row.unit_price_sale is not None and ( 

264 not row.sale_ends 

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

266 row.unit_price_quoted = row.unit_price_sale 

267 else: 

268 row.unit_price_quoted = row.unit_price_reg 

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

270 row.case_price_quoted = row.unit_price_quoted * row.case_size 

271 

272 # update row total price 

273 row.total_price = None 

274 if row.order_uom == enum.ORDER_UOM_CASE: 

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

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

277 else: # ORDER_UOM_UNIT (or similar) 

278 if row.unit_price_quoted is not None: 

279 row.total_price = row.unit_price_quoted * row.order_qty 

280 if row.total_price is not None: 

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

282 

283 # update batch if total price changed 

284 if row.total_price != old_total: 

285 batch = row.batch 

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

287 + (row.total_price or 0) 

288 - (old_total or 0)) 

289 

290 # all ok 

291 row.status_code = row.STATUS_OK 

292 

293 def refresh_row_from_pending_product(self, row): 

294 """ 

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

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

297 record. 

298 

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

300 """ 

301 product = row.pending_product 

302 

303 row.product_scancode = product.scancode 

304 row.product_brand = product.brand_name 

305 row.product_description = product.description 

306 row.product_size = product.size 

307 row.product_weighed = product.weighed 

308 row.department_id = product.department_id 

309 row.department_name = product.department_name 

310 row.case_size = product.case_size 

311 row.unit_cost = product.unit_cost 

312 row.unit_price_reg = product.unit_price_reg 

313 

314 # row.unit_price_quoted = row.unit_price_reg 

315 # print(repr(row.unit_price_quoted)) 

316 # if isinstance(row.unit_price_quoted, float): 

317 # row.unit_price_quoted = decimal.Decimal(f'{row.unit_price_quoted:0.2f}') 

318 

319 # if row.unit_price_quoted and row.case_size: 

320 # row.case_price_quoted = row.unit_price_quoted * row.case_size 

321 # else: 

322 # row.case_price_quoted = None 

323 

324 # row.unit_price_sale = None 

325 # row.sale_ends = None 

326 

327 row.special_order = product.special_order 

328 

329 def refresh_row_from_true_product(self, row): 

330 """ 

331 Update product-related attributes on the row, from its "true" 

332 product record indicated by 

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

334 

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

336 

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

338 needed. 

339 """ 

340 

341 def remove_row(self, row): 

342 """ 

343 Remove a row from its batch. 

344 

345 This also will update the batch 

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

347 accordingly. 

348 """ 

349 if row.total_price: 

350 batch = row.batch 

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

352 

353 super().remove_row(row) 

354 

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

356 """ 

357 Delete the given batch entirely. 

358 

359 If the batch has a 

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

361 record, that is deleted also. 

362 """ 

363 # maybe delete pending customer record, if it only exists for 

364 # sake of this batch 

365 if batch.pending_customer: 

366 if len(batch.pending_customer.new_order_batches) == 1: 

367 # TODO: check for past orders too 

368 session = self.app.get_session(batch) 

369 session.delete(batch.pending_customer) 

370 

371 # continue with normal deletion 

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

373 

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

375 """ 

376 By default this checks to ensure the batch has a customer and 

377 at least one item. 

378 """ 

379 if not batch.customer_id and not batch.pending_customer: 

380 return "Must assign the customer" 

381 

382 rows = self.get_effective_rows(batch) 

383 if not rows: 

384 return "Must add at least one valid item" 

385 

386 def get_effective_rows(self, batch): 

387 """ 

388 Only rows with 

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

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

391 be created as proper order items. 

392 """ 

393 return [row for row in batch.rows 

394 if row.status_code == row.STATUS_OK] 

395 

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

397 """ 

398 By default, this will call :meth:`make_new_order()` and return 

399 the new :class:`~sideshow.db.model.orders.Order` instance. 

400 

401 Note that callers should use 

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

403 instead, which calls this method automatically. 

404 """ 

405 rows = self.get_effective_rows(batch) 

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

407 return order 

408 

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

410 """ 

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

412 

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

414 

415 :param batch: 

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

417 instance. 

418 

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

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

421 item>`. 

422 

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

424 """ 

425 model = self.app.model 

426 enum = self.app.enum 

427 session = self.app.get_session(batch) 

428 

429 batch_fields = [ 

430 'store_id', 

431 'customer_id', 

432 'pending_customer', 

433 'customer_name', 

434 'phone_number', 

435 'email_address', 

436 'total_price', 

437 ] 

438 

439 row_fields = [ 

440 'pending_product_uuid', 

441 'product_scancode', 

442 'product_brand', 

443 'product_description', 

444 'product_size', 

445 'product_weighed', 

446 'department_id', 

447 'department_name', 

448 'case_size', 

449 'order_qty', 

450 'order_uom', 

451 'unit_cost', 

452 'unit_price_quoted', 

453 'case_price_quoted', 

454 'unit_price_reg', 

455 'unit_price_sale', 

456 'sale_ends', 

457 # 'discount_percent', 

458 'total_price', 

459 'special_order', 

460 ] 

461 

462 # make order 

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

464 for field in batch_fields]) 

465 kw['order_id'] = batch.id 

466 kw['created_by'] = user 

467 order = model.Order(**kw) 

468 session.add(order) 

469 session.flush() 

470 

471 def convert(row, i): 

472 

473 # make order item 

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

475 for field in row_fields]) 

476 item = model.OrderItem(**kw) 

477 order.items.append(item) 

478 

479 # set item status 

480 item.status_code = enum.ORDER_ITEM_STATUS_INITIATED 

481 

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

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

484 session.flush() 

485 return order