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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Background tasks for the 'order' app."""
3from datetime import datetime, timedelta
4from typing import Optional
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 _
11import structlog
12from opentelemetry import trace
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
28tracer = trace.get_tracer(__name__)
29logger = structlog.get_logger('inventree')
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'.
36 Arguments:
37 po: The PurchaseOrder object that is overdue.
38 """
39 targets: list[User | Group | Owner] = []
41 if po.created_by:
42 targets.append(po.created_by)
44 if po.responsible:
45 targets.append(po.responsible)
47 targets.extend(po.subscribed_users())
49 name = _('Overdue Purchase Order')
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 }
59 event_name = PurchaseOrderEvents.OVERDUE
61 # Send a notification to the appropriate users
62 common.notifications.trigger_notification(
63 po, event_name, targets=targets, context=context
64 )
66 # Register a matching event to the plugin system
67 trigger_event(event_name, purchase_order=po.pk)
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.
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)
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 )
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 )
94 notified_orders = set()
96 for po in overdue_orders:
97 notify_overdue_purchase_order(po)
98 notified_orders.add(po.pk)
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)
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] = []
111 if so.created_by:
112 targets.append(so.created_by)
114 if so.responsible:
115 targets.append(so.responsible)
117 targets.extend(so.subscribed_users())
119 name = _('Overdue Sales Order')
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 }
129 event_name = SalesOrderEvents.OVERDUE
131 # Send a notification to the appropriate users
132 common.notifications.trigger_notification(
133 so, event_name, targets=targets, context=context
134 )
136 # Register a matching event to the plugin system
137 trigger_event(event_name, sales_order=so.pk)
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.
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)
151 overdue_orders = order.models.SalesOrder.objects.filter(
152 target_date=yesterday, status__in=SalesOrderStatusGroups.OPEN
153 )
155 overdue_lines = order.models.SalesOrderLineItem.objects.filter(
156 target_date=yesterday,
157 order__status__in=SalesOrderStatusGroups.OPEN,
158 shipped__lt=F('quantity'),
159 )
161 notified_orders = set()
163 for po in overdue_orders:
164 notify_overdue_sales_order(po)
165 notified_orders.add(po.pk)
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)
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] = []
178 if ro.created_by:
179 targets.append(ro.created_by)
181 if ro.responsible:
182 targets.append(ro.responsible)
184 targets.extend(ro.subscribed_users())
186 name = _('Overdue Return Order')
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 }
196 event_name = SalesOrderEvents.OVERDUE
198 # Send a notification to the appropriate users
199 common.notifications.trigger_notification(
200 ro, event_name, targets=targets, context=context
201 )
203 # Register a matching event to the plugin system
204 trigger_event(event_name, return_order=ro.pk)
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.
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)
218 overdue_orders = order.models.ReturnOrder.objects.filter(
219 target_date=yesterday, status__in=ReturnOrderStatusGroups.OPEN
220 )
222 overdue_lines = order.models.ReturnOrderLineItem.objects.filter(
223 target_date=yesterday,
224 order__status__in=ReturnOrderStatusGroups.OPEN,
225 received_date__isnull=True,
226 )
228 notified_orders = set()
230 for ro in overdue_orders:
231 notify_overdue_return_order(ro)
232 notified_orders.add(ro.pk)
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)
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.
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)
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
260 logger.info('Completing SalesOrderShipment <%s>', shipment_id)
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 )
273 if shipment.is_complete():
274 logger.warning(
275 'SalesOrderShipment <%s> has already been completed - skipping',
276 shipment_id,
277 )
278 return
280 for allocation in shipment.allocations.all():
281 allocation.complete_allocation(user=user)
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()
289 # Trigger event signalling that the shipment has been completed
290 trigger_event(SalesOrderEvents.SHIPMENT_COMPLETE, id=shipment.pk)
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)
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 )
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 )