Coverage for src/backend/InvenTree/order/tasks.py: 24%

123 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 17:47 +0000

1"""Background tasks for the 'order' app.""" 

2 

3from datetime import datetime, timedelta 

4from typing import Optional 

5 

6from django.contrib.auth.models import Group, User 

7from django.db import transaction 

8from django.db.models import F 

9from django.utils.translation import gettext_lazy as _ 

10 

11import structlog 

12from opentelemetry import trace 

13 

14import common.notifications 

15import InvenTree.helpers_model 

16import order.models 

17import stock.models as stock_models 

18from InvenTree.tasks import ScheduledTask, scheduled_task 

19from order.events import PurchaseOrderEvents, SalesOrderEvents 

20from order.status_codes import ( 

21 PurchaseOrderStatusGroups, 

22 ReturnOrderStatusGroups, 

23 SalesOrderStatusGroups, 

24) 

25from plugin.events import trigger_event 

26from users.models import Owner 

27 

28tracer = trace.get_tracer(__name__) 

29logger = structlog.get_logger('inventree') 

30 

31 

32@tracer.start_as_current_span('notify_overdue_purchase_order') 

33def notify_overdue_purchase_order(po: order.models.PurchaseOrder) -> None: 

34 """Notify users that a PurchaseOrder has just become 'overdue'. 

35 

36 Arguments: 

37 po: The PurchaseOrder object that is overdue. 

38 """ 

39 targets: list[User | Group | Owner] = [] 

40 

41 if po.created_by: 

42 targets.append(po.created_by) 

43 

44 if po.responsible: 

45 targets.append(po.responsible) 

46 

47 targets.extend(po.subscribed_users()) 

48 

49 name = _('Overdue Purchase Order') 

50 

51 context = { 

52 'order': po, 

53 'name': name, 

54 'message': _(f'Purchase order {po} is now overdue'), 

55 'link': InvenTree.helpers_model.construct_absolute_url(po.get_absolute_url()), 

56 'template': {'html': 'email/overdue_purchase_order.html', 'subject': name}, 

57 } 

58 

59 event_name = PurchaseOrderEvents.OVERDUE 

60 

61 # Send a notification to the appropriate users 

62 common.notifications.trigger_notification( 

63 po, event_name, targets=targets, context=context 

64 ) 

65 

66 # Register a matching event to the plugin system 

67 trigger_event(event_name, purchase_order=po.pk) 

68 

69 

70@tracer.start_as_current_span('scheduled_task') 

71@scheduled_task(ScheduledTask.DAILY) 

72def check_overdue_purchase_orders(): 

73 """Check if any outstanding PurchaseOrders have just become overdue. 

74 

75 Rules: 

76 - This check is performed daily 

77 - Look at the 'target_date' of any outstanding PurchaseOrder objects 

78 - If the 'target_date' expired *yesterday* then the order is just out of date 

79 """ 

80 yesterday = datetime.now().date() - timedelta(days=1) 

81 

82 # Check for PurchaseOrder objects that have a target date of yesterday 

83 overdue_orders = order.models.PurchaseOrder.objects.filter( 

84 target_date=yesterday, status__in=PurchaseOrderStatusGroups.OPEN 

85 ) 

86 

87 # Check for individual line items that are overdue 

88 overdue_lines = order.models.PurchaseOrderLineItem.objects.filter( 

89 target_date=yesterday, 

90 order__status__in=PurchaseOrderStatusGroups.OPEN, 

91 received__lt=F('quantity'), 

92 ) 

93 

94 notified_orders = set() 

95 

96 for po in overdue_orders: 

97 notify_overdue_purchase_order(po) 

98 notified_orders.add(po.pk) 

99 

100 for line in overdue_lines: 

101 if line.order.pk not in notified_orders: 

102 notify_overdue_purchase_order(line.order) 

103 notified_orders.add(line.order.pk) 

104 

105 

106@tracer.start_as_current_span('notify_overdue_sales_order') 

107def notify_overdue_sales_order(so: order.models.SalesOrder) -> None: 

108 """Notify appropriate users that a SalesOrder has just become 'overdue'.""" 

109 targets: list[User | Group | Owner] = [] 

110 

111 if so.created_by: 

112 targets.append(so.created_by) 

113 

114 if so.responsible: 

115 targets.append(so.responsible) 

116 

117 targets.extend(so.subscribed_users()) 

118 

119 name = _('Overdue Sales Order') 

120 

121 context = { 

122 'order': so, 

123 'name': name, 

124 'message': _(f'Sales order {so} is now overdue'), 

125 'link': InvenTree.helpers_model.construct_absolute_url(so.get_absolute_url()), 

126 'template': {'html': 'email/overdue_sales_order.html', 'subject': name}, 

127 } 

128 

129 event_name = SalesOrderEvents.OVERDUE 

130 

131 # Send a notification to the appropriate users 

132 common.notifications.trigger_notification( 

133 so, event_name, targets=targets, context=context 

134 ) 

135 

136 # Register a matching event to the plugin system 

137 trigger_event(event_name, sales_order=so.pk) 

138 

139 

140@tracer.start_as_current_span('scheduled_task') 

141@scheduled_task(ScheduledTask.DAILY) 

142def check_overdue_sales_orders(): 

143 """Check if any outstanding SalesOrders have just become overdue. 

144 

145 - This check is performed daily 

146 - Look at the 'target_date' of any outstanding SalesOrder objects 

147 - If the 'target_date' expired *yesterday* then the order is just out of date 

148 """ 

149 yesterday = datetime.now().date() - timedelta(days=1) 

150 

151 overdue_orders = order.models.SalesOrder.objects.filter( 

152 target_date=yesterday, status__in=SalesOrderStatusGroups.OPEN 

153 ) 

154 

155 overdue_lines = order.models.SalesOrderLineItem.objects.filter( 

156 target_date=yesterday, 

157 order__status__in=SalesOrderStatusGroups.OPEN, 

158 shipped__lt=F('quantity'), 

159 ) 

160 

161 notified_orders = set() 

162 

163 for po in overdue_orders: 

164 notify_overdue_sales_order(po) 

165 notified_orders.add(po.pk) 

166 

167 for line in overdue_lines: 

168 if line.order.pk not in notified_orders: 

169 notify_overdue_sales_order(line.order) 

170 notified_orders.add(line.order.pk) 

171 

172 

173@tracer.start_as_current_span('notify_overdue_return_order') 

174def notify_overdue_return_order(ro: order.models.ReturnOrder) -> None: 

175 """Notify appropriate users that a ReturnOrder has just become 'overdue'.""" 

176 targets: list[User | Group | Owner] = [] 

177 

178 if ro.created_by: 

179 targets.append(ro.created_by) 

180 

181 if ro.responsible: 

182 targets.append(ro.responsible) 

183 

184 targets.extend(ro.subscribed_users()) 

185 

186 name = _('Overdue Return Order') 

187 

188 context = { 

189 'order': ro, 

190 'name': name, 

191 'message': _(f'Return order {ro} is now overdue'), 

192 'link': InvenTree.helpers_model.construct_absolute_url(ro.get_absolute_url()), 

193 'template': {'html': 'email/overdue_return_order.html', 'subject': name}, 

194 } 

195 

196 event_name = SalesOrderEvents.OVERDUE 

197 

198 # Send a notification to the appropriate users 

199 common.notifications.trigger_notification( 

200 ro, event_name, targets=targets, context=context 

201 ) 

202 

203 # Register a matching event to the plugin system 

204 trigger_event(event_name, return_order=ro.pk) 

205 

206 

207@tracer.start_as_current_span('check_overdue_return_orders') 

208@scheduled_task(ScheduledTask.DAILY) 

209def check_overdue_return_orders(): 

210 """Check if any outstanding return orders have just become overdue. 

211 

212 - This check is performed daily 

213 - Look at the 'target_date' of any outstanding return order objects 

214 - If the 'target_date' expired *yesterday* then the order is just out of date 

215 """ 

216 yesterday = datetime.now().date() - timedelta(days=1) 

217 

218 overdue_orders = order.models.ReturnOrder.objects.filter( 

219 target_date=yesterday, status__in=ReturnOrderStatusGroups.OPEN 

220 ) 

221 

222 overdue_lines = order.models.ReturnOrderLineItem.objects.filter( 

223 target_date=yesterday, 

224 order__status__in=ReturnOrderStatusGroups.OPEN, 

225 received_date__isnull=True, 

226 ) 

227 

228 notified_orders = set() 

229 

230 for ro in overdue_orders: 

231 notify_overdue_return_order(ro) 

232 notified_orders.add(ro.pk) 

233 

234 for line in overdue_lines: 

235 if line.order.pk not in notified_orders: 

236 notify_overdue_return_order(line.order) 

237 notified_orders.add(line.order.pk) 

238 

239 

240@tracer.start_as_current_span('complete_sales_order_shipment') 

241def complete_sales_order_shipment( 

242 shipment_id: int, 

243 user_id: int, 

244 shipment_date: str, 

245 delivery_date: Optional[str] = None, 

246) -> None: 

247 """Complete allocations for a pending shipment against a SalesOrder. 

248 

249 Arguments: 

250 shipment_id: The ID of the SalesOrderShipment object to complete 

251 user_id: The ID of the user performing the completion action 

252 shipment_date: The date that the shipment was completed (if None, then the current date is used) 

253 delivery_date: The date that the shipment was delivered (optional) 

254 

255 At this stage, the shipment is assumed to be complete, 

256 and we need to perform the required "processing" tasks. 

257 """ 

258 user = User.objects.filter(pk=user_id).first() if user_id else None 

259 

260 logger.info('Completing SalesOrderShipment <%s>', shipment_id) 

261 

262 with transaction.atomic(): 

263 # Lock the shipment row for the duration of the update, 

264 # and re-check that it has not already been completed. 

265 # This guards against duplicate task execution - e.g. if the completion 

266 # request was submitted multiple times before the first task ran. 

267 # Do not handle any lookup errors here: 

268 # If the shipment cannot be found, then we want the task to fail (and retry later) 

269 shipment = order.models.SalesOrderShipment.objects.select_for_update().get( 

270 pk=shipment_id 

271 ) 

272 

273 if shipment.is_complete(): 

274 logger.warning( 

275 'SalesOrderShipment <%s> has already been completed - skipping', 

276 shipment_id, 

277 ) 

278 return 

279 

280 for allocation in shipment.allocations.all(): 

281 allocation.complete_allocation(user=user) 

282 

283 # Once all allocations have been completed, we can mark the shipment as complete 

284 shipment.shipment_date = shipment_date or datetime.now().date() 

285 shipment.delivery_date = delivery_date 

286 shipment.shipped_by = user 

287 shipment.save() 

288 

289 # Trigger event signalling that the shipment has been completed 

290 trigger_event(SalesOrderEvents.SHIPMENT_COMPLETE, id=shipment.pk) 

291 

292 

293@tracer.start_as_current_span('auto_allocate_sales_order') 

294def auto_allocate_sales_order( 

295 order_id: int, 

296 location_id: Optional[int] = None, 

297 exclude_location_id: Optional[int] = None, 

298 shipment_id: Optional[int] = None, 

299 line_ids: Optional[list] = None, 

300 **kwargs, 

301): 

302 """Run auto-allocation for a specified SalesOrder.""" 

303 sales_order = order.models.SalesOrder.objects.get(pk=order_id) 

304 

305 location = ( 

306 stock_models.StockLocation.objects.get(pk=location_id) if location_id else None 

307 ) 

308 exclude_location = ( 

309 stock_models.StockLocation.objects.get(pk=exclude_location_id) 

310 if exclude_location_id 

311 else None 

312 ) 

313 shipment = ( 

314 order.models.SalesOrderShipment.objects.get(pk=shipment_id) 

315 if shipment_id 

316 else None 

317 ) 

318 

319 sales_order.auto_allocate_stock( 

320 location=location, 

321 exclude_location=exclude_location, 

322 shipment=shipment, 

323 line_ids=line_ids or None, 

324 **kwargs, 

325 )