Coverage for .tox/coverage/lib/python3.11/site-packages/sideshow/db/model/orders.py: 100%

84 statements  

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

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

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

3# 

4# Sideshow -- Case/Special Order Tracker 

5# Copyright © 2024-2025 Lance Edgar 

6# 

7# This file is part of Sideshow. 

8# 

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

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# General Public License for more details. 

18# 

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

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

21# 

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

23""" 

24Data models for Orders 

25""" 

26 

27import datetime 

28 

29import sqlalchemy as sa 

30from sqlalchemy import orm 

31from sqlalchemy.ext.orderinglist import ordering_list 

32 

33from wuttjamaican.db import model 

34 

35 

36class Order(model.Base): 

37 """ 

38 Represents an :term:`order` for a customer. Each order has one or 

39 more :attr:`items`. 

40 

41 Usually, orders are created by way of a 

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

43 """ 

44 __tablename__ = 'sideshow_order' 

45 

46 # TODO: this feels a bit hacky yet but it does avoid problems 

47 # showing the Orders grid for a PendingCustomer 

48 __colanderalchemy_config__ = { 

49 'excludes': ['items'], 

50 } 

51 

52 uuid = model.uuid_column() 

53 

54 order_id = sa.Column(sa.Integer(), nullable=False, doc=""" 

55 Unique ID for the order. 

56 

57 When the order is created from New Order Batch, this order ID will 

58 match the batch ID. 

59 """) 

60 

61 store_id = sa.Column(sa.String(length=10), nullable=True, doc=""" 

62 ID of the store to which the order pertains, if applicable. 

63 """) 

64 

65 store = orm.relationship( 

66 'Store', 

67 primaryjoin='Store.store_id == Order.store_id', 

68 foreign_keys='Order.store_id', 

69 doc=""" 

70 Reference to the :class:`~sideshow.db.model.stores.Store` 

71 record, if applicable. 

72 """) 

73 

74 customer_id = sa.Column(sa.String(length=20), nullable=True, doc=""" 

75 Proper account ID for the :term:`external customer` to which the 

76 order pertains, if applicable. 

77 

78 See also :attr:`local_customer` and :attr:`pending_customer`. 

79 """) 

80 

81 local_customer_uuid = model.uuid_fk_column('sideshow_customer_local.uuid', nullable=True) 

82 local_customer = orm.relationship( 

83 'LocalCustomer', 

84 cascade_backrefs=False, 

85 back_populates='orders', 

86 doc=""" 

87 Reference to the 

88 :class:`~sideshow.db.model.customers.LocalCustomer` record 

89 for the order, if applicable. 

90 

91 See also :attr:`customer_id` and :attr:`pending_customer`. 

92 """) 

93 

94 pending_customer_uuid = model.uuid_fk_column('sideshow_customer_pending.uuid', nullable=True) 

95 pending_customer = orm.relationship( 

96 'PendingCustomer', 

97 cascade_backrefs=False, 

98 back_populates='orders', 

99 doc=""" 

100 Reference to the 

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

102 for the order, if applicable. 

103 

104 See also :attr:`customer_id` and :attr:`local_customer`. 

105 """) 

106 

107 customer_name = sa.Column(sa.String(length=100), nullable=True, doc=""" 

108 Name for the customer account. 

109 """) 

110 

111 phone_number = sa.Column(sa.String(length=20), nullable=True, doc=""" 

112 Phone number for the customer. 

113 """) 

114 

115 email_address = sa.Column(sa.String(length=255), nullable=True, doc=""" 

116 Email address for the customer. 

117 """) 

118 

119 total_price = sa.Column(sa.Numeric(precision=10, scale=3), nullable=True, doc=""" 

120 Full price (not including tax etc.) for all items on the order. 

121 """) 

122 

123 created = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc=""" 

124 Timestamp when the order was created. 

125 

126 If the order is created via New Order Batch, this will match the 

127 batch execution timestamp. 

128 """) 

129 

130 created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False) 

131 created_by = orm.relationship( 

132 model.User, 

133 cascade_backrefs=False, 

134 doc=""" 

135 Reference to the 

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

137 created the order. 

138 """) 

139 

140 items = orm.relationship( 

141 'OrderItem', 

142 collection_class=ordering_list('sequence', count_from=1), 

143 cascade='all, delete-orphan', 

144 cascade_backrefs=False, 

145 back_populates='order', 

146 doc=""" 

147 List of :class:`OrderItem` records belonging to the order. 

148 """) 

149 

150 def __str__(self): 

151 return str(self.order_id) 

152 

153 

154class OrderItem(model.Base): 

155 """ 

156 Represents an :term:`order item` within an :class:`Order`. 

157 

158 Usually these are created from 

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

160 records. 

161 """ 

162 __tablename__ = 'sideshow_order_item' 

163 

164 uuid = model.uuid_column() 

165 

166 order_uuid = model.uuid_fk_column('sideshow_order.uuid', nullable=False) 

167 order = orm.relationship( 

168 Order, 

169 cascade_backrefs=False, 

170 back_populates='items', 

171 doc=""" 

172 Reference to the :class:`Order` to which the item belongs. 

173 """) 

174 

175 sequence = sa.Column(sa.Integer(), nullable=False, doc=""" 

176 1-based numeric sequence for the item, i.e. its line number within 

177 the order. 

178 """) 

179 

180 product_id = sa.Column(sa.String(length=20), nullable=True, doc=""" 

181 Proper ID for the :term:`external product` which the order item 

182 represents, if applicable. 

183 

184 See also :attr:`local_product` and :attr:`pending_product`. 

185 """) 

186 

187 local_product_uuid = model.uuid_fk_column('sideshow_product_local.uuid', nullable=True) 

188 local_product = orm.relationship( 

189 'LocalProduct', 

190 cascade_backrefs=False, 

191 back_populates='order_items', 

192 doc=""" 

193 Reference to the 

194 :class:`~sideshow.db.model.products.LocalProduct` record for 

195 the order item, if applicable. 

196 

197 See also :attr:`product_id` and :attr:`pending_product`. 

198 """) 

199 

200 pending_product_uuid = model.uuid_fk_column('sideshow_product_pending.uuid', nullable=True) 

201 pending_product = orm.relationship( 

202 'PendingProduct', 

203 cascade_backrefs=False, 

204 back_populates='order_items', 

205 doc=""" 

206 Reference to the 

207 :class:`~sideshow.db.model.products.PendingProduct` record for 

208 the order item, if applicable. 

209 

210 See also :attr:`product_id` and :attr:`local_product`. 

211 """) 

212 

213 product_scancode = sa.Column(sa.String(length=14), nullable=True, doc=""" 

214 Scancode for the product, as string. 

215 

216 .. note:: 

217 

218 This column allows 14 chars, so can store a full GPC with check 

219 digit. However as of writing the actual format used here does 

220 not matter to Sideshow logic; "anything" should work. 

221 

222 That may change eventually, depending on POS integration 

223 scenarios that come up. Maybe a config option to declare 

224 whether check digit should be included or not, etc. 

225 """) 

226 

227 product_brand = sa.Column(sa.String(length=100), nullable=True, doc=""" 

228 Brand name for the product - up to 100 chars. 

229 """) 

230 

231 product_description = sa.Column(sa.String(length=255), nullable=True, doc=""" 

232 Description for the product - up to 255 chars. 

233 """) 

234 

235 product_size = sa.Column(sa.String(length=30), nullable=True, doc=""" 

236 Size of the product, as string - up to 30 chars. 

237 """) 

238 

239 product_weighed = sa.Column(sa.Boolean(), nullable=True, doc=""" 

240 Flag indicating the product is sold by weight; default is null. 

241 """) 

242 

243 department_id = sa.Column(sa.String(length=10), nullable=True, doc=""" 

244 ID of the department to which the product belongs, if known. 

245 """) 

246 

247 department_name = sa.Column(sa.String(length=30), nullable=True, doc=""" 

248 Name of the department to which the product belongs, if known. 

249 """) 

250 

251 special_order = sa.Column(sa.Boolean(), nullable=True, doc=""" 

252 Flag indicating the item is a "special order" - e.g. something not 

253 normally carried by the store. Default is null. 

254 """) 

255 

256 vendor_name = sa.Column(sa.String(length=50), nullable=True, doc=""" 

257 Name of vendor from which product may be purchased, if known. See 

258 also :attr:`vendor_item_code`. 

259 """) 

260 

261 vendor_item_code = sa.Column(sa.String(length=20), nullable=True, doc=""" 

262 Item code (SKU) to use when ordering this product from the vendor 

263 identified by :attr:`vendor_name`, if known. 

264 """) 

265 

266 case_size = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc=""" 

267 Case pack count for the product, if known. 

268 """) 

269 

270 order_qty = sa.Column(sa.Numeric(precision=10, scale=4), nullable=False, doc=""" 

271 Quantity (as decimal) of product being ordered. 

272 

273 This must be interpreted along with :attr:`order_uom` to determine 

274 the *complete* order quantity, e.g. "2 cases". 

275 """) 

276 

277 order_uom = sa.Column(sa.String(length=10), nullable=False, doc=""" 

278 Code indicating the unit of measure for product being ordered. 

279 

280 This should be one of the codes from 

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

282 """) 

283 

284 unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc=""" 

285 Cost of goods amount for one "unit" (not "case") of the product, 

286 as decimal to 4 places. 

287 """) 

288 

289 unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc=""" 

290 Regular price for the item unit. Unless a sale is in effect, 

291 :attr:`unit_price_quoted` will typically match this value. 

292 """) 

293 

294 unit_price_sale = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc=""" 

295 Sale price for the item unit, if applicable. If set, then 

296 :attr:`unit_price_quoted` will typically match this value. See 

297 also :attr:`sale_ends`. 

298 """) 

299 

300 sale_ends = sa.Column(sa.DateTime(timezone=True), nullable=True, doc=""" 

301 End date/time for the sale in effect, if any. 

302 

303 This is only relevant if :attr:`unit_price_sale` is set. 

304 """) 

305 

306 unit_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc=""" 

307 Quoted price for the item unit. This is the "effective" unit 

308 price, which is used to calculate :attr:`total_price`. 

309 

310 This price does *not* reflect the :attr:`discount_percent`. It 

311 normally should match either :attr:`unit_price_reg` or 

312 :attr:`unit_price_sale`. 

313 """) 

314 

315 case_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc=""" 

316 Quoted price for a "case" of the item, if applicable. 

317 

318 This is mostly for display purposes; :attr:`unit_price_quoted` is 

319 used for calculations. 

320 """) 

321 

322 discount_percent = sa.Column(sa.Numeric(precision=5, scale=3), nullable=True, doc=""" 

323 Discount percent to apply when calculating :attr:`total_price`, if 

324 applicable. 

325 """) 

326 

327 total_price = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc=""" 

328 Full price (not including tax etc.) which the customer is quoted 

329 for the order item. 

330 

331 This is calculated using values from: 

332 

333 * :attr:`unit_price_quoted` 

334 * :attr:`order_qty` 

335 * :attr:`order_uom` 

336 * :attr:`case_size` 

337 * :attr:`discount_percent` 

338 """) 

339 

340 status_code = sa.Column(sa.Integer(), nullable=False, doc=""" 

341 Code indicating current status for the order item. 

342 """) 

343 

344 paid_amount = sa.Column(sa.Numeric(precision=8, scale=3), nullable=False, default=0, doc=""" 

345 Amount which the customer has paid toward the :attr:`total_price` 

346 of the item. 

347 """) 

348 

349 payment_transaction_number = sa.Column(sa.String(length=20), nullable=True, doc=""" 

350 Transaction number in which payment for the order was taken, if 

351 applicable/known. 

352 """) 

353 

354 events = orm.relationship( 

355 'OrderItemEvent', 

356 order_by='OrderItemEvent.occurred, OrderItemEvent.uuid', 

357 cascade='all, delete-orphan', 

358 cascade_backrefs=False, 

359 back_populates='item', 

360 doc=""" 

361 List of :class:`OrderItemEvent` records for the item. 

362 """) 

363 

364 @property 

365 def full_description(self): 

366 """ """ 

367 fields = [ 

368 self.product_brand or '', 

369 self.product_description or '', 

370 self.product_size or ''] 

371 fields = [f.strip() for f in fields if f.strip()] 

372 return ' '.join(fields) 

373 

374 def __str__(self): 

375 return self.full_description 

376 

377 def add_event(self, type_code, user, **kwargs): 

378 """ 

379 Convenience method to add a new :class:`OrderItemEvent` for 

380 the item. 

381 """ 

382 kwargs['type_code'] = type_code 

383 kwargs['actor'] = user 

384 self.events.append(OrderItemEvent(**kwargs)) 

385 

386 

387class OrderItemEvent(model.Base): 

388 """ 

389 An event in the life of an :term:`order item`. 

390 """ 

391 __tablename__ = 'sideshow_order_item_event' 

392 

393 uuid = model.uuid_column() 

394 

395 item_uuid = model.uuid_fk_column('sideshow_order_item.uuid', nullable=False) 

396 item = orm.relationship( 

397 OrderItem, 

398 cascade_backrefs=False, 

399 back_populates='events', 

400 doc=""" 

401 Reference to the :class:`OrderItem` to which the event 

402 pertains. 

403 """) 

404 

405 type_code = sa.Column(sa.Integer, nullable=False, doc=""" 

406 Code indicating the type of event; values must be defined in 

407 :data:`~sideshow.enum.ORDER_ITEM_EVENT`. 

408 """) 

409 

410 occurred = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc=""" 

411 Date and time when the event occurred. 

412 """) 

413 

414 actor_uuid = model.uuid_fk_column('user.uuid', nullable=False) 

415 actor = orm.relationship( 

416 model.User, 

417 doc=""" 

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

419 performed the action. 

420 """) 

421 

422 note = sa.Column(sa.Text(), nullable=True, doc=""" 

423 Optional note recorded for the event. 

424 """)