Coverage for src/backend/InvenTree/order/models.py: 36%

1381 statements  

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

1"""Order model definitions.""" 

2 

3from decimal import Decimal 

4from typing import Any, Optional, TypedDict 

5 

6from django.contrib.auth.models import User 

7from django.core.exceptions import ValidationError 

8from django.core.validators import MinValueValidator 

9from django.db import models, transaction 

10from django.db.models import F, Q, QuerySet, Sum 

11from django.db.models.functions import Coalesce 

12from django.db.models.signals import post_delete, post_save 

13from django.dispatch.dispatcher import receiver 

14from django.urls import reverse 

15from django.utils.translation import gettext_lazy as _ 

16 

17import structlog 

18from djmoney.contrib.exchange.exceptions import MissingRate 

19from djmoney.contrib.exchange.models import convert_money 

20from djmoney.money import Money 

21from mptt.models import TreeForeignKey 

22 

23import common.models as common_models 

24import InvenTree.helpers 

25import InvenTree.models 

26import InvenTree.ready 

27import InvenTree.tasks 

28import InvenTree.validators 

29import order.validators 

30import report.mixins 

31import stock.models 

32import users.models as UserModels 

33from build.status_codes import BuildStatus 

34from common.currency import currency_code_default 

35from common.notifications import InvenTreeNotificationBodies 

36from common.settings import get_global_setting 

37from company.models import Address, Company, Contact, SupplierPart 

38from generic.states import StateTransitionMixin, StatusCodeMixin 

39from generic.states.fields import InvenTreeCustomStatusModelField 

40from InvenTree.exceptions import log_error 

41from InvenTree.fields import ( 

42 InvenTreeModelMoneyField, 

43 InvenTreeURLField, 

44 RoundingDecimalField, 

45) 

46from InvenTree.helpers import decimal2string, pui_url 

47from InvenTree.helpers_model import notify_responsible 

48from order.events import ( 

49 PurchaseOrderEvents, 

50 ReturnOrderEvents, 

51 SalesOrderEvents, 

52 TransferOrderEvents, 

53) 

54from order.status_codes import ( 

55 PurchaseOrderStatus, 

56 PurchaseOrderStatusGroups, 

57 ReturnOrderLineStatus, 

58 ReturnOrderStatus, 

59 ReturnOrderStatusGroups, 

60 SalesOrderStatus, 

61 SalesOrderStatusGroups, 

62 TransferOrderStatus, 

63 TransferOrderStatusGroups, 

64) 

65from part import models as PartModels 

66from plugin.events import trigger_event 

67from stock.status_codes import StockHistoryCode, StockStatus 

68 

69logger = structlog.get_logger('inventree') 

70 

71 

72class TotalPriceMixin(models.Model): 

73 """Mixin which provides 'total_price' field for an order.""" 

74 

75 class Meta: 

76 """Meta for MetadataMixin.""" 

77 

78 abstract = True 

79 

80 def save(self, *args, **kwargs): 

81 """Update the total_price field when saved.""" 

82 # Recalculate total_price for this order 

83 self.update_total_price(commit=False) 

84 

85 if hasattr(self, '_SAVING_TOTAL_PRICE') and self._SAVING_TOTAL_PRICE: 

86 # Avoid recursion on save 

87 return super().save(*args, **kwargs) 

88 self._SAVING_TOTAL_PRICE = True 

89 

90 # Save the object as we can not access foreign/m2m fields before saving 

91 self.update_total_price(commit=True) 

92 

93 total_price = InvenTreeModelMoneyField( 

94 null=True, 

95 blank=True, 

96 allow_negative=False, 

97 verbose_name=_('Total Price'), 

98 help_text=_('Total price for this order'), 

99 ) 

100 

101 order_currency = models.CharField( 

102 max_length=3, 

103 verbose_name=_('Order Currency'), 

104 blank=True, 

105 null=True, 

106 help_text=_('Currency for this order (leave blank to use company default)'), 

107 validators=[InvenTree.validators.validate_currency_code], 

108 ) 

109 

110 @property 

111 def currency(self): 

112 """Return the currency associated with this order instance. 

113 

114 Rules: 

115 - If the order_currency field is set, return that 

116 - Otherwise, return the currency associated with the company 

117 - Finally, return the default currency code 

118 """ 

119 if self.order_currency: 

120 return self.order_currency 

121 

122 if self.company: 

123 return self.company.currency_code 

124 

125 # Return default currency code 

126 return currency_code_default() 

127 

128 def update_total_price(self, commit=True): 

129 """Recalculate and save the total_price for this order.""" 

130 self.total_price = self.calculate_total_price(target_currency=self.currency) 

131 

132 if commit: 

133 self.save() 

134 

135 def calculate_total_price(self, target_currency=None): 

136 """Calculates the total price of all order lines, and converts to the specified target currency. 

137 

138 If not specified, the default system currency is used. 

139 

140 If currency conversion fails (e.g. there are no valid conversion rates), 

141 then we simply return zero, rather than attempting some other calculation. 

142 """ 

143 # Set default - see B008 

144 if target_currency is None: 

145 target_currency = currency_code_default() 

146 

147 total = Money(0, target_currency) 

148 

149 # Check if the order has been saved (otherwise we can't calculate the total price) 

150 if self.pk is None: 

151 return total 

152 

153 # order items 

154 for line in self.lines.all(): 

155 if not line.price: 

156 continue 

157 

158 try: 

159 total += line.quantity * convert_money(line.price, target_currency) 

160 except MissingRate: 

161 log_error('order.calculate_total_price') 

162 logger.exception("Missing exchange rate for '%s'", target_currency) 

163 

164 # Return None to indicate the calculated price is invalid 

165 return None 

166 

167 # extra items 

168 for line in self.extra_lines.all(): 

169 if not line.price: 

170 continue 

171 

172 try: 

173 total += line.quantity * convert_money(line.price, target_currency) 

174 except MissingRate: 

175 # Record the error, try to press on 

176 

177 log_error('order.calculate_total_price') 

178 logger.exception("Missing exchange rate for '%s'", target_currency) 

179 

180 # Return None to indicate the calculated price is invalid 

181 return None 

182 

183 # set decimal-places 

184 total.decimal_places = 4 

185 

186 return total 

187 

188 

189class BaseOrderReportContext(report.mixins.BaseReportContext, TypedDict): 

190 """Base context for all order models. 

191 

192 Attributes: 

193 description: The description field of the order 

194 extra_lines: Query set of all extra lines associated with the order 

195 lines: Query set of all line items associated with the order 

196 order: The order instance itself 

197 reference: The reference field of the order 

198 title: The title (string representation) of the order 

199 """ 

200 

201 description: str 

202 extra_lines: Any 

203 lines: Any 

204 order: Any 

205 reference: str 

206 title: str 

207 

208 

209class PurchaseOrderReportContext(report.mixins.BaseReportContext, TypedDict): 

210 """Context for the purchase order model. 

211 

212 Attributes: 

213 description: The description field of the PurchaseOrder 

214 reference: The reference field of the PurchaseOrder 

215 title: The title (string representation) of the PurchaseOrder 

216 extra_lines: Query set of all extra lines associated with the PurchaseOrder 

217 lines: Query set of all line items associated with the PurchaseOrder 

218 order: The PurchaseOrder instance itself 

219 supplier: The supplier object associated with the PurchaseOrder 

220 """ 

221 

222 description: str 

223 reference: str 

224 title: str 

225 extra_lines: report.mixins.QuerySet['PurchaseOrderExtraLine'] 

226 lines: report.mixins.QuerySet['PurchaseOrderLineItem'] 

227 order: 'PurchaseOrder' 

228 supplier: Optional[Company] 

229 

230 

231class SalesOrderReportContext(report.mixins.BaseReportContext, TypedDict): 

232 """Context for the sales order model. 

233 

234 Attributes: 

235 description: The description field of the SalesOrder 

236 reference: The reference field of the SalesOrder 

237 title: The title (string representation) of the SalesOrder 

238 extra_lines: Query set of all extra lines associated with the SalesOrder 

239 lines: Query set of all line items associated with the SalesOrder 

240 order: The SalesOrder instance itself 

241 customer: The customer object associated with the SalesOrder 

242 """ 

243 

244 description: str 

245 reference: str 

246 title: str 

247 extra_lines: report.mixins.QuerySet['SalesOrderExtraLine'] 

248 lines: report.mixins.QuerySet['SalesOrderLineItem'] 

249 order: 'SalesOrder' 

250 customer: Optional[Company] 

251 

252 

253class ReturnOrderReportContext(report.mixins.BaseReportContext, TypedDict): 

254 """Context for the return order model. 

255 

256 Attributes: 

257 description: The description field of the ReturnOrder 

258 reference: The reference field of the ReturnOrder 

259 title: The title (string representation) of the ReturnOrder 

260 extra_lines: Query set of all extra lines associated with the ReturnOrder 

261 lines: Query set of all line items associated with the ReturnOrder 

262 order: The ReturnOrder instance itself 

263 customer: The customer object associated with the ReturnOrder 

264 """ 

265 

266 description: str 

267 reference: str 

268 title: str 

269 extra_lines: report.mixins.QuerySet['ReturnOrderExtraLine'] 

270 lines: report.mixins.QuerySet['ReturnOrderLineItem'] 

271 order: 'ReturnOrder' 

272 customer: Optional[Company] 

273 

274 

275class TransferOrderReportContext(BaseOrderReportContext, TypedDict): 

276 """Context for the transfer order model. 

277 

278 Attributes: 

279 description: The description field of the TransferOrder 

280 reference: The reference field of the TransferOrder 

281 title: The title (string representation) of the TransferOrder 

282 lines: Query set of all line items associated with the TransferOrder 

283 order: The TransferOrder instance itself 

284 """ 

285 

286 description: str 

287 reference: str 

288 title: str 

289 lines: report.mixins.QuerySet['TransferOrderLineItem'] 

290 order: 'TransferOrder' 

291 take_from: 'stock.models.StockLocation' 

292 destination: 'stock.models.StockLocation' 

293 consume: bool 

294 

295 

296class Order( 

297 StatusCodeMixin, 

298 StateTransitionMixin, 

299 InvenTree.models.InvenTreeParameterMixin, 

300 InvenTree.models.InvenTreeAttachmentMixin, 

301 InvenTree.models.InvenTreeBarcodeMixin, 

302 InvenTree.models.InvenTreeNotesMixin, 

303 InvenTree.models.InvenTreeTagsMixin, 

304 report.mixins.InvenTreeReportMixin, 

305 InvenTree.models.MetadataMixin, 

306 InvenTree.models.ReferenceIndexingMixin, 

307 InvenTree.models.InvenTreeModel, 

308): 

309 """Abstract model for an order. 

310 

311 Instances of this class: 

312 

313 - PurchaseOrder 

314 - SalesOrder 

315 

316 Attributes: 

317 reference: Unique order number / reference / code 

318 description: Long form description (required) 

319 notes: Extra note field (optional) 

320 creation_date: Automatic date of order creation 

321 created_by: User who created this order (automatically captured) 

322 issue_date: Date the order was issued 

323 start_date: Date the order is scheduled to be started 

324 target_date: Expected or desired completion date 

325 complete_date: Date the order was completed 

326 responsible: User (or group) responsible for managing the order 

327 """ 

328 

329 REQUIRE_RESPONSIBLE_SETTING = None 

330 UNLOCK_SETTING = None 

331 IMPORT_ID_FIELDS = ['reference'] 

332 

333 class Meta: 

334 """Metaclass options. Abstract ensures no database table is created.""" 

335 

336 abstract = True 

337 

338 def save(self, *args, **kwargs): 

339 """Custom save method for the order models. 

340 

341 Enforces various business logics: 

342 - Ensures the object is not locked 

343 - Ensures that the reference field is rebuilt whenever the instance is saved. 

344 """ 

345 # check if we are updating the model, not adding it 

346 update = self.pk is not None 

347 

348 # Locking 

349 if update and self.check_locked(True): 349 ↛ 351line 349 didn't jump to line 351 because the condition on line 349 was never true

350 # Ensure that order status can be changed still 

351 if self.get_db_instance().status != self.status: 

352 pass 

353 else: 

354 raise ValidationError({ 

355 'reference': _('This order is locked and cannot be modified') 

356 }) 

357 

358 # Reference calculations 

359 self.reference_int = self.rebuild_reference_field(self.reference) 

360 if not self.creation_date: 

361 self.creation_date = InvenTree.helpers.current_date() 

362 

363 self.updated_at = InvenTree.helpers.current_time() 

364 

365 super().save(*args, **kwargs) 

366 

367 def check_locked(self, db: bool = False) -> bool: 

368 """Check if this order is 'locked'. 

369 

370 A locked order cannot be modified after it has been completed. 

371 

372 Arguments: 

373 db: If True, check with the database. If False, check the instance (default False). 

374 """ 

375 if not self.check_complete(db=db): 375 ↛ 379line 375 didn't jump to line 379 because the condition on line 375 was always true

376 # If the order is not complete, it is not locked 

377 return False 

378 

379 if self.UNLOCK_SETTING: 

380 return get_global_setting(self.UNLOCK_SETTING, backup_value=False) is False 

381 

382 return False 

383 

384 def check_complete(self, db: bool = False) -> bool: 

385 """Check if this order is 'complete'. 

386 

387 Arguments: 

388 db: If True, check with the database. If False, check the instance (default False). 

389 """ 

390 status = self.get_db_instance().status if db else self.status 

391 return status in self.get_status_class().COMPLETE 

392 

393 def clean(self): 

394 """Custom clean method for the generic order class.""" 

395 super().clean() 

396 

397 # Check if a responsible owner is required for this order type 

398 if self.REQUIRE_RESPONSIBLE_SETTING: 398 ↛ 406line 398 didn't jump to line 406 because the condition on line 398 was always true

399 if get_global_setting(self.REQUIRE_RESPONSIBLE_SETTING, backup_value=False): 399 ↛ 400line 399 didn't jump to line 400 because the condition on line 399 was never true

400 if not self.responsible: 

401 raise ValidationError({ 

402 'responsible': _('Responsible user or group must be specified') 

403 }) 

404 

405 # Check that the referenced 'contact' matches the correct 'company' 

406 if ( 406 ↛ 413line 406 didn't jump to line 413 because the condition on line 406 was never true

407 hasattr(self, 'company') 

408 and hasattr(self, 'contact') 

409 and self.company 

410 and self.contact 

411 and (self.contact.company != self.company) 

412 ): 

413 raise ValidationError({ 

414 'contact': _('Contact does not match selected company') 

415 }) 

416 

417 # Target date should be *after* the start date 

418 if self.start_date and self.target_date and self.start_date > self.target_date: 418 ↛ 419line 418 didn't jump to line 419 because the condition on line 418 was never true

419 raise ValidationError({ 

420 'target_date': _('Target date must be after start date'), 

421 'start_date': _('Start date must be before target date'), 

422 }) 

423 

424 # Check that the referenced 'address' matches the correct 'company' 

425 if ( 425 ↛ 431line 425 didn't jump to line 431 because the condition on line 425 was never true

426 hasattr(self, 'company') 

427 and self.company 

428 and self.address 

429 and (self.address.company != self.company) 

430 ): 

431 raise ValidationError({ 

432 'address': _('Address does not match selected company') 

433 }) 

434 

435 def clean_line_item(self, line): 

436 """Clean a line item for this order. 

437 

438 Used when duplicating an existing line item, 

439 to ensure it is 'fresh'. 

440 """ 

441 line.pk = None 

442 line.target_date = None 

443 line.order = self 

444 

445 def report_context(self) -> BaseOrderReportContext: 

446 """Generate context data for the reporting interface.""" 

447 return { 

448 'description': self.description, 

449 'extra_lines': getattr( 

450 self, 'extra_lines', None 

451 ), # Transfer Order doesn't have extra lines 

452 'lines': self.lines, 

453 'order': self, 

454 'reference': self.reference, 

455 'title': str(self), 

456 } 

457 

458 @classmethod 

459 def overdue_filter(cls): 

460 """A generic implementation of an 'overdue' filter for the Model class. 

461 

462 It requires any subclasses to implement the get_status_class() class method 

463 """ 

464 today = InvenTree.helpers.current_date() 

465 return ( 

466 Q(status__in=cls.get_status_class().OPEN) 

467 & ~Q(target_date=None) 

468 & Q(target_date__lt=today) 

469 ) 

470 

471 @property 

472 def is_overdue(self): 

473 """Method to determine if this order is overdue. 

474 

475 Makes use of the overdue_filter() method to avoid code duplication 

476 """ 

477 return ( 

478 self.__class__.objects 

479 .filter(pk=self.pk) 

480 .filter(self.__class__.overdue_filter()) 

481 .exists() 

482 ) 

483 

484 description = models.CharField( 

485 max_length=250, 

486 blank=True, 

487 verbose_name=_('Description'), 

488 help_text=_('Order description (optional)'), 

489 ) 

490 

491 project_code = models.ForeignKey( 

492 common_models.ProjectCode, 

493 on_delete=models.SET_NULL, 

494 blank=True, 

495 null=True, 

496 verbose_name=_('Project Code'), 

497 help_text=_('Select project code for this order'), 

498 ) 

499 

500 link = InvenTreeURLField( 

501 blank=True, 

502 verbose_name=_('Link'), 

503 help_text=_('Link to external page'), 

504 max_length=2000, 

505 ) 

506 

507 start_date = models.DateField( 

508 null=True, 

509 blank=True, 

510 verbose_name=_('Start date'), 

511 help_text=_('Scheduled start date for this order'), 

512 ) 

513 

514 target_date = models.DateField( 

515 blank=True, 

516 null=True, 

517 verbose_name=_('Target Date'), 

518 help_text=_( 

519 'Expected date for order delivery. Order will be overdue after this date.' 

520 ), 

521 ) 

522 

523 creation_date = models.DateField( 

524 blank=True, null=True, verbose_name=_('Creation Date') 

525 ) 

526 

527 created_by = models.ForeignKey( 

528 User, 

529 on_delete=models.SET_NULL, 

530 blank=True, 

531 null=True, 

532 related_name='+', 

533 verbose_name=_('Created By'), 

534 ) 

535 

536 issue_date = models.DateField( 

537 blank=True, 

538 null=True, 

539 verbose_name=_('Issue Date'), 

540 help_text=_('Date order was issued'), 

541 ) 

542 

543 updated_at = models.DateTimeField( 

544 null=True, 

545 blank=True, 

546 verbose_name=_('Updated At'), 

547 help_text=_('Timestamp of last update'), 

548 ) 

549 

550 responsible = models.ForeignKey( 

551 UserModels.Owner, 

552 on_delete=models.SET_NULL, 

553 blank=True, 

554 null=True, 

555 help_text=_('User or group responsible for this order'), 

556 verbose_name=_('Responsible'), 

557 related_name='+', 

558 ) 

559 

560 contact = models.ForeignKey( 

561 Contact, 

562 on_delete=models.SET_NULL, 

563 blank=True, 

564 null=True, 

565 verbose_name=_('Contact'), 

566 help_text=_('Point of contact for this order'), 

567 related_name='+', 

568 ) 

569 

570 address = models.ForeignKey( 

571 Address, 

572 on_delete=models.SET_NULL, 

573 blank=True, 

574 null=True, 

575 verbose_name=_('Address'), 

576 help_text=_('Company address for this order'), 

577 related_name='+', 

578 ) 

579 

580 @property 

581 def company(self): 

582 """Return the company associated with this order. 

583 

584 This method must be implemented by any subclass, as the 'company' field may be named differently for different order types (e.g. supplier vs customer). 

585 """ 

586 raise NotImplementedError(f'company() method not implemented for {__class__}') 

587 

588 @property 

589 def order_address(self): 

590 """Return the Address associated with this order.""" 

591 return self.address or self.company.primary_address 

592 

593 @property 

594 def status_text(self): 

595 """Return the text representation of the current status. This will consider any custom status.""" 

596 if self.get_custom_status() is not None: 

597 from generic.states.custom import ( 

598 get_logical_value as get_custom_state_logical_value, 

599 ) 

600 

601 custom_status = get_custom_state_logical_value( 

602 self.get_custom_status(), model=self._meta.model_name 

603 ) 

604 return custom_status.label 

605 else: 

606 return self.status_class.label(self.get_status()) 

607 

608 @classmethod 

609 def get_status_class(cls): 

610 """Return the enumeration class which represents the 'status' field for this model.""" 

611 raise NotImplementedError(f'get_status_class() not implemented for {__class__}') 

612 

613 

614class PurchaseOrder(TotalPriceMixin, Order): 

615 """A PurchaseOrder represents goods shipped inwards from an external supplier. 

616 

617 Attributes: 

618 supplier: Reference to the company supplying the goods in the order 

619 supplier_reference: Optional field for supplier order reference code 

620 received_by: User that received the goods 

621 target_date: Expected delivery target date for PurchaseOrder completion (optional) 

622 """ 

623 

624 REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN' 

625 REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE' 

626 STATUS_CLASS = PurchaseOrderStatus 

627 UNLOCK_SETTING = 'PURCHASEORDER_EDIT_COMPLETED_ORDERS' 

628 

629 class Meta: 

630 """Model meta options.""" 

631 

632 verbose_name = _('Purchase Order') 

633 

634 def clean_line_item(self, line): 

635 """Clean a line item for this PurchaseOrder.""" 

636 super().clean_line_item(line) 

637 line.received = 0 

638 

639 def report_context(self) -> PurchaseOrderReportContext: 

640 """Return report context data for this PurchaseOrder.""" 

641 return_ctx = super().report_context() 

642 

643 return_ctx.update({'supplier': self.supplier}) # ty:ignore[invalid-key] 

644 return return_ctx 

645 

646 def get_absolute_url(self) -> str: 

647 """Get the 'web' URL for this order.""" 

648 return pui_url(f'/purchasing/purchase-order/{self.pk}') 

649 

650 @staticmethod 

651 def get_api_url() -> str: 

652 """Return the API URL associated with the PurchaseOrder model.""" 

653 return reverse('api-po-list') 

654 

655 @classmethod 

656 def get_status_class(cls): 

657 """Return the PurchaseOrderStatus class.""" 

658 return PurchaseOrderStatusGroups 

659 

660 @classmethod 

661 def api_defaults(cls, request=None): 

662 """Return default values for this model when issuing an API OPTIONS request.""" 

663 defaults = { 

664 'reference': order.validators.generate_next_purchase_order_reference() 

665 } 

666 

667 return defaults 

668 

669 @classmethod 

670 def barcode_model_type_code(cls) -> str: 

671 """Return the associated barcode model type code for this model.""" 

672 return 'PO' 

673 

674 def subscribed_users(self) -> list[User]: 

675 """Return a list of users subscribed to this PurchaseOrder. 

676 

677 By this, we mean users to are interested in any of the parts associated with this order. 

678 """ 

679 subscribed_users = set() 

680 

681 for line in self.lines.all(): 

682 if line.part and line.part.part: 

683 # Add the part to the list of subscribed users 

684 for user in line.part.part.get_subscribers(): 

685 subscribed_users.add(user) 

686 

687 return list(subscribed_users) 

688 

689 def __str__(self): 

690 """Render a string representation of this PurchaseOrder.""" 

691 return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}' 

692 

693 reference = models.CharField( 

694 unique=True, 

695 max_length=64, 

696 blank=False, 

697 verbose_name=_('Reference'), 

698 help_text=_('Order reference'), 

699 default=order.validators.generate_next_purchase_order_reference, 

700 validators=[order.validators.validate_purchase_order_reference], 

701 ) 

702 

703 status = InvenTreeCustomStatusModelField( 

704 default=PurchaseOrderStatus.PENDING.value, 

705 choices=PurchaseOrderStatus.items(), 

706 status_class=PurchaseOrderStatus, 

707 verbose_name=_('Status'), 

708 help_text=_('Purchase order status'), 

709 ) 

710 

711 supplier = models.ForeignKey( 

712 Company, 

713 on_delete=models.SET_NULL, 

714 null=True, 

715 limit_choices_to={'is_supplier': True}, 

716 related_name='purchase_orders', 

717 verbose_name=_('Supplier'), 

718 help_text=_('Company from which the items are being ordered'), 

719 ) 

720 

721 @property 

722 def company(self): 

723 """Accessor helper for Order base class.""" 

724 return self.supplier 

725 

726 supplier_reference = models.CharField( 

727 max_length=64, 

728 blank=True, 

729 verbose_name=_('Supplier Reference'), 

730 help_text=_('Supplier order reference code'), 

731 ) 

732 

733 received_by = models.ForeignKey( 

734 User, 

735 on_delete=models.SET_NULL, 

736 blank=True, 

737 null=True, 

738 related_name='+', 

739 verbose_name=_('received by'), 

740 ) 

741 

742 complete_date = models.DateField( 

743 blank=True, 

744 null=True, 

745 verbose_name=_('Completion Date'), 

746 help_text=_('Date order was completed'), 

747 ) 

748 

749 destination = TreeForeignKey( 

750 'stock.StockLocation', 

751 on_delete=models.SET_NULL, 

752 related_name='purchase_orders', 

753 blank=True, 

754 null=True, 

755 verbose_name=_('Destination'), 

756 help_text=_('Destination for received items'), 

757 ) 

758 

759 @transaction.atomic 

760 def add_line_item( 

761 self, 

762 supplier_part, 

763 quantity, 

764 group: bool = True, 

765 reference: str = '', 

766 purchase_price=None, 

767 destination=None, 

768 ): 

769 """Add a new line item to this purchase order. 

770 

771 This function will check that: 

772 * The supplier part matches the supplier specified for this purchase order 

773 * The quantity is greater than zero 

774 

775 Arguments: 

776 supplier_part: The supplier_part to add 

777 quantity : The number of items to add 

778 group (bool, optional): If True, this new quantity will be added to an existing line item for the same supplier_part (if it exists). Defaults to True. 

779 reference (str, optional): Reference to item. Defaults to ''. 

780 purchase_price (optional): Price of item. Defaults to None. 

781 destination (optional): Destination for item. Defaults to None. 

782 

783 Returns: 

784 The newly created PurchaseOrderLineItem instance 

785 

786 Raises: 

787 ValidationError: quantity is smaller than 0 

788 ValidationError: quantity is not type int 

789 ValidationError: supplier is not supplier of purchase order 

790 """ 

791 try: 

792 quantity = int(quantity) 

793 if quantity <= 0: 

794 raise ValidationError({ 

795 'quantity': _('Quantity must be greater than zero') 

796 }) 

797 except ValueError: 

798 raise ValidationError({'quantity': _('Invalid quantity provided')}) 

799 

800 if supplier_part.supplier != self.supplier: 

801 raise ValidationError({ 

802 'supplier': _('Part supplier must match PO supplier') 

803 }) 

804 

805 if group: 

806 # Check if there is already a matching line item (for this PurchaseOrder) 

807 # Lock the matching row, so concurrent additions cannot both read 

808 # the same starting quantity (lost update) 

809 matches = self.lines.select_for_update().filter(part=supplier_part) 

810 

811 if matches.count() > 0: 

812 line = matches.first() 

813 

814 # update quantity and price 

815 quantity_new = line.quantity + quantity 

816 line.quantity = quantity_new 

817 supplier_price = supplier_part.get_price(quantity_new) 

818 

819 if line.purchase_price and supplier_price: 

820 line.purchase_price = supplier_price / quantity_new 

821 

822 line.save() 

823 

824 return line 

825 

826 line = PurchaseOrderLineItem( 

827 order=self, 

828 part=supplier_part, 

829 quantity=quantity, 

830 reference=reference, 

831 purchase_price=purchase_price, 

832 destination=destination, 

833 ) 

834 

835 line.save() 

836 

837 return line 

838 

839 # region state changes 

840 def _action_place(self, *args, **kwargs): 

841 """Marks the PurchaseOrder as PLACED. 

842 

843 Order must be currently PENDING. 

844 """ 

845 if self.can_issue: 

846 self.status = PurchaseOrderStatus.PLACED.value 

847 self.issue_date = InvenTree.helpers.current_date() 

848 self.save() 

849 

850 trigger_event(PurchaseOrderEvents.PLACED, id=self.pk) 

851 

852 # Notify users that the order has been placed 

853 notify_responsible( 

854 self, 

855 PurchaseOrder, 

856 exclude=self.created_by, 

857 content=InvenTreeNotificationBodies.NewOrder, 

858 extra_users=self.subscribed_users(), 

859 ) 

860 

861 def _action_complete(self, *args, **kwargs): 

862 """Marks the PurchaseOrder as COMPLETE. 

863 

864 Order must be currently PLACED. 

865 """ 

866 if self.status == PurchaseOrderStatus.PLACED: 

867 self.status = PurchaseOrderStatus.COMPLETE.value 

868 self.complete_date = InvenTree.helpers.current_date() 

869 

870 self.save() 

871 

872 unique_parts = set() 

873 

874 # Schedule pricing update for any referenced parts 

875 for line in self.lines.all().prefetch_related('part__part'): 

876 # Ensure we only check 'unique' parts 

877 if line.part and line.part.part: 

878 unique_parts.add(line.part.part) 

879 

880 for part in unique_parts: 

881 part.schedule_pricing_update(create=True, refresh=False) 

882 

883 trigger_event(PurchaseOrderEvents.COMPLETED, id=self.pk) 

884 

885 @transaction.atomic 

886 def issue_order(self): 

887 """Equivalent to 'place_order'.""" 

888 return self.place_order() 

889 

890 @property 

891 def can_issue(self) -> bool: 

892 """Return True if this order can be issued.""" 

893 return self.status in [ 

894 PurchaseOrderStatus.PENDING.value, 

895 PurchaseOrderStatus.ON_HOLD.value, 

896 ] 

897 

898 @transaction.atomic 

899 def place_order(self): 

900 """Attempt to transition to PLACED status.""" 

901 return self.handle_transition( 

902 self.status, PurchaseOrderStatus.PLACED.value, self, self._action_place 

903 ) 

904 

905 @transaction.atomic 

906 def complete_order(self): 

907 """Attempt to transition to COMPLETE status.""" 

908 return self.handle_transition( 

909 self.status, PurchaseOrderStatus.COMPLETE.value, self, self._action_complete 

910 ) 

911 

912 @transaction.atomic 

913 def hold_order(self): 

914 """Attempt to transition to ON_HOLD status.""" 

915 return self.handle_transition( 

916 self.status, PurchaseOrderStatus.ON_HOLD.value, self, self._action_hold 

917 ) 

918 

919 @transaction.atomic 

920 def cancel_order(self): 

921 """Attempt to transition to CANCELLED status.""" 

922 return self.handle_transition( 

923 self.status, PurchaseOrderStatus.CANCELLED.value, self, self._action_cancel 

924 ) 

925 

926 @property 

927 def is_pending(self) -> bool: 

928 """Return True if the PurchaseOrder is 'pending'.""" 

929 return self.status == PurchaseOrderStatus.PENDING.value 

930 

931 @property 

932 def is_open(self) -> bool: 

933 """Return True if the PurchaseOrder is 'open'.""" 

934 return self.status in PurchaseOrderStatusGroups.OPEN 

935 

936 @property 

937 def can_cancel(self) -> bool: 

938 """A PurchaseOrder can only be cancelled under the following circumstances. 

939 

940 - Status is PLACED 

941 - Status is PENDING (or ON_HOLD) 

942 """ 

943 return self.status in PurchaseOrderStatusGroups.OPEN 

944 

945 def _action_cancel(self, *args, **kwargs): 

946 """Marks the PurchaseOrder as CANCELLED.""" 

947 if self.can_cancel: 

948 self.status = PurchaseOrderStatus.CANCELLED.value 

949 self.save() 

950 

951 trigger_event(PurchaseOrderEvents.CANCELLED, id=self.pk) 

952 

953 # Notify users that the order has been canceled 

954 notify_responsible( 

955 self, 

956 PurchaseOrder, 

957 exclude=self.created_by, 

958 content=InvenTreeNotificationBodies.OrderCanceled, 

959 extra_users=self.subscribed_users(), 

960 ) 

961 

962 @property 

963 def can_hold(self) -> bool: 

964 """Return True if this order can be placed on hold.""" 

965 return self.status in [ 

966 PurchaseOrderStatus.PENDING.value, 

967 PurchaseOrderStatus.PLACED.value, 

968 ] 

969 

970 def _action_hold(self, *args, **kwargs): 

971 """Mark this purchase order as 'on hold'.""" 

972 if self.can_hold: 

973 self.status = PurchaseOrderStatus.ON_HOLD.value 

974 self.save() 

975 

976 trigger_event(PurchaseOrderEvents.HOLD, id=self.pk) 

977 

978 # endregion 

979 

980 def pending_line_items(self) -> QuerySet: 

981 """Return a list of pending line items for this order. 

982 

983 Any line item where 'received' < 'quantity' will be returned. 

984 """ 

985 return self.lines.filter(quantity__gt=F('received')) 

986 

987 def completed_line_items(self) -> QuerySet: 

988 """Return a list of completed line items against this order.""" 

989 return self.lines.filter(quantity__lte=F('received')) 

990 

991 @property 

992 def line_count(self) -> int: 

993 """Return the total number of line items associated with this order.""" 

994 return self.lines.count() 

995 

996 @property 

997 def completed_line_count(self) -> int: 

998 """Return the number of complete line items associated with this order.""" 

999 return self.completed_line_items().count() 

1000 

1001 @property 

1002 def pending_line_count(self) -> int: 

1003 """Return the number of pending line items associated with this order.""" 

1004 return self.pending_line_items().count() 

1005 

1006 @property 

1007 def is_complete(self) -> bool: 

1008 """Return True if all line items have been received.""" 

1009 return self.pending_line_items().count() == 0 

1010 

1011 @transaction.atomic 

1012 def receive_line_items( 

1013 self, location, items: list, user: User, **kwargs 

1014 ) -> QuerySet: 

1015 """Receive multiple line items against this PurchaseOrder. 

1016 

1017 Arguments: 

1018 location: The StockLocation to receive the items into 

1019 items: A list of line item IDs and quantities to receive 

1020 user: The User performing the action 

1021 

1022 Returns: 

1023 A QuerySet of the newly created StockItem objects 

1024 

1025 The 'items' list values contain: 

1026 line_item: The PurchaseOrderLineItem instance 

1027 quantity: The quantity of items to receive 

1028 location: The location to receive the item into (optional) 

1029 status: The 'status' of the item 

1030 barcode: Optional barcode for the item (optional) 

1031 batch_code: Optional batch code for the item (optional) 

1032 expiry_date: Optional expiry date for the item (optional) 

1033 serials: Optional list of serial numbers (optional) 

1034 note: Optional notes for the item (optional) 

1035 """ 

1036 if self.status != PurchaseOrderStatus.PLACED: 

1037 raise ValidationError( 

1038 "Lines can only be received against an order marked as 'PLACED'" 

1039 ) 

1040 

1041 # List of stock items which have been created 

1042 stock_items: list[stock.models.StockItem] = [] 

1043 

1044 # List of stock items to bulk create 

1045 bulk_create_items: list[stock.models.StockItem] = [] 

1046 

1047 # List of tracking entries to create 

1048 tracking_entries: list[stock.models.StockItemTracking] = [] 

1049 

1050 # List of line items to update 

1051 line_items_to_update: list[PurchaseOrderLineItem] = [] 

1052 

1053 # Set of users to notify (subscribers to any received part) 

1054 notify_users = set() 

1055 

1056 # Cache of subscribers per part, to avoid repeated queries for the same part 

1057 part_subscribers_cache: dict[int, list] = {} 

1058 

1059 convert_purchase_price = get_global_setting('PURCHASEORDER_CONVERT_CURRENCY') 

1060 default_currency = currency_code_default() 

1061 

1062 # Prefetch line item objects for DB efficiency 

1063 line_items_ids = [item['line_item'].pk for item in items] 

1064 

1065 # Cache the custom status options for the StockItem model 

1066 custom_stock_status_values = stock.models.StockItem.STATUS_CLASS.custom_values() 

1067 

1068 # Lock the line item rows, so that concurrent receipts against the same 

1069 # lines cannot both read the same 'received' value (lost update) 

1070 line_items = ( 

1071 PurchaseOrderLineItem.objects 

1072 .select_for_update() 

1073 .filter(pk__in=line_items_ids) 

1074 .prefetch_related('part', 'part__part', 'order') 

1075 ) 

1076 

1077 # Map order line items to their corresponding stock items 

1078 line_item_map = {line.pk: line for line in line_items} 

1079 

1080 # Before we continue, validate that each line item is valid 

1081 # We validate this here because it is far more efficient, 

1082 # after we have fetched *all* line items in a single DB query 

1083 for line_item in line_item_map.values(): 

1084 if line_item.order != self: 

1085 raise ValidationError({_('Line item does not match purchase order')}) 

1086 

1087 if not line_item.part or not line_item.part.part: 

1088 raise ValidationError({_('Line item is missing a linked part')}) 

1089 

1090 for item in items: 

1091 # Extract required information 

1092 line_item_id = item['line_item'].pk 

1093 

1094 line = line_item_map[line_item_id] 

1095 

1096 quantity = item['quantity'] 

1097 barcode = item.get('barcode', '') 

1098 

1099 try: 

1100 if quantity < 0: 

1101 raise ValidationError({ 

1102 'quantity': _('Quantity must be a positive number') 

1103 }) 

1104 quantity = InvenTree.helpers.clean_decimal(quantity) 

1105 except TypeError: 

1106 raise ValidationError({'quantity': _('Invalid quantity provided')}) 

1107 

1108 supplier_part = line.part 

1109 

1110 if not supplier_part: 

1111 logger.warning( 

1112 'Line item %s is missing a linked supplier part', line.pk 

1113 ) 

1114 continue 

1115 

1116 base_part = supplier_part.part 

1117 

1118 # Update the line item quantity 

1119 line.received += quantity 

1120 line_items_to_update.append(line) 

1121 

1122 # Track subscribers to this part, to notify them later 

1123 # (cache the result per-part, as multiple lines may reference the same part) 

1124 if base_part.pk not in part_subscribers_cache: 

1125 part_subscribers_cache[base_part.pk] = base_part.get_subscribers() 

1126 

1127 notify_users.update(part_subscribers_cache[base_part.pk]) 

1128 

1129 # Extract optional serial numbers 

1130 serials = item.get('serials', None) 

1131 

1132 if serials and type(serials) is list and len(serials) > 0: 

1133 serialize = True 

1134 else: 

1135 serialize = False 

1136 serials = [None] 

1137 

1138 if base_part.virtual: 

1139 # Virtual parts are not received into stock, so skip the rest of the loop 

1140 

1141 if serialize: 

1142 raise ValidationError( 

1143 _('Serial numbers cannot be assigned to virtual parts') 

1144 ) 

1145 

1146 continue 

1147 

1148 stock_location = item.get('location', location) or line.get_destination() 

1149 

1150 # Calculate the received quantity in base part units 

1151 stock_quantity = supplier_part.base_quantity(quantity) 

1152 

1153 # Calculate unit purchase price (in base units) 

1154 if line.purchase_price: 

1155 purchase_price = line.purchase_price / supplier_part.base_quantity(1) 

1156 

1157 if convert_purchase_price: 

1158 purchase_price = convert_money(purchase_price, default_currency) 

1159 else: 

1160 purchase_price = None 

1161 

1162 # Construct dataset for creating a new StockItem instances 

1163 stock_data = { 

1164 'part': supplier_part.part, 

1165 'supplier_part': supplier_part, 

1166 'purchase_order': self, 

1167 'purchase_price': purchase_price, 

1168 'location': stock_location, 

1169 'quantity': 1 if serialize else stock_quantity, 

1170 'batch': item.get('batch_code', ''), 

1171 'expiry_date': item.get('expiry_date', None), 

1172 'notes': item.get('note', '') or item.get('notes', ''), 

1173 'packaging': item.get('packaging') or supplier_part.packaging, 

1174 } 

1175 

1176 # Extract the "status" field 

1177 status = item.get('status', StockStatus.OK.value) 

1178 

1179 # Check linked build order 

1180 # This is for receiving against an *external* build order 

1181 if build_order := line.build_order: 

1182 if not build_order.external: 

1183 raise ValidationError( 

1184 'Cannot receive items against an internal build order' 

1185 ) 

1186 

1187 if build_order.part != base_part: 

1188 raise ValidationError( 

1189 'Cannot receive items against a build order for a different part' 

1190 ) 

1191 

1192 if not stock_location and build_order.destination: 

1193 # Override with the build order destination (if not specified) 

1194 stock_data['location'] = stock_location = build_order.destination 

1195 

1196 if build_order.active: 

1197 # An 'active' build order marks the items as "in production" 

1198 stock_data['build'] = build_order 

1199 stock_data['is_building'] = True 

1200 elif build_order.status == BuildStatus.COMPLETE: 

1201 # A 'completed' build order marks the items as "completed" 

1202 stock_data['build'] = build_order 

1203 stock_data['is_building'] = False 

1204 

1205 # Increase the 'completed' quantity for the build order 

1206 # Increment at the database level to prevent lost updates 

1207 build_order.completed = F('completed') + stock_quantity 

1208 build_order.save(update_fields=['completed']) 

1209 build_order.refresh_from_db(fields=['completed']) 

1210 elif build_order.status == BuildStatus.CANCELLED: 

1211 # A 'cancelled' build order is ignored 

1212 pass 

1213 else: 

1214 # Un-handled state - raise an error 

1215 raise ValidationError( 

1216 "Cannot receive items against a build order in state '{build_order.status}'" 

1217 ) 

1218 

1219 # Now, create the new stock items 

1220 if serialize: 

1221 new_items = stock.models.StockItem._create_serial_numbers( 

1222 serials=serials, **stock_data 

1223 ) 

1224 

1225 for new_item in new_items: 

1226 new_item.set_status( 

1227 status, custom_values=custom_stock_status_values 

1228 ) 

1229 # run validation for serialized items plugin.validate_batch_code 

1230 new_item.validate_batch_code() 

1231 # run validation for serialized items plugin.validate_model_instance 

1232 new_item.run_plugin_validation() 

1233 stock_items.append(new_item) 

1234 

1235 else: 

1236 new_item = stock.models.StockItem( 

1237 **stock_data, 

1238 serial='', 

1239 tree_id=stock.models.StockItem.getNextTreeID(), 

1240 parent=None, 

1241 level=0, 

1242 lft=1, 

1243 rght=2, 

1244 ) 

1245 

1246 new_item.set_status(status, custom_values=custom_stock_status_values) 

1247 

1248 if barcode: 

1249 new_item.assign_barcode(barcode_data=barcode, save=False) 

1250 

1251 bulk_create_items.append(new_item) 

1252 

1253 # Bulk create new stock items 

1254 if len(bulk_create_items) > 0: 

1255 # bulk_create() bypasses save()/clean() methods, so manual validation is required for each item 

1256 for item in bulk_create_items: 

1257 # run validation for items plugin.validate_batch_code 

1258 item.validate_batch_code() 

1259 # run validation for items plugin.validate_model_instance 

1260 item.run_plugin_validation() 

1261 

1262 stock.models.StockItem.objects.bulk_create( 

1263 bulk_create_items, batch_size=250 

1264 ) 

1265 

1266 # Fetch them back again 

1267 tree_ids = [item.tree_id for item in bulk_create_items] 

1268 

1269 created_items = stock.models.StockItem.objects.filter( 

1270 tree_id__in=tree_ids, level=0, lft=1, rght=2, purchase_order=self 

1271 ).prefetch_related('location') 

1272 

1273 stock_items.extend(created_items) 

1274 

1275 # Generate a new tracking entry for each stock item 

1276 for item in stock_items: 

1277 tracking_entries.append( 

1278 item.add_tracking_entry( 

1279 StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, 

1280 user, 

1281 deltas={ 

1282 'location': item.location.pk if item.location else None, 

1283 'purchaseorder': self.pk, 

1284 'quantity': float(item.quantity), 

1285 }, 

1286 commit=False, 

1287 ) 

1288 ) 

1289 

1290 # Bulk create new tracking entries for each item 

1291 stock.models.StockItemTracking.objects.bulk_create( 

1292 tracking_entries, batch_size=250 

1293 ) 

1294 

1295 # Update received quantity for each line item 

1296 PurchaseOrderLineItem.objects.bulk_update(line_items_to_update, ['received']) 

1297 

1298 # Trigger an event for any interested plugins 

1299 trigger_event( 

1300 PurchaseOrderEvents.ITEM_RECEIVED, 

1301 order_id=self.pk, 

1302 item_ids=[item.pk for item in stock_items], 

1303 ) 

1304 

1305 # Check to auto-complete the PurchaseOrder 

1306 if ( 

1307 get_global_setting('PURCHASEORDER_AUTO_COMPLETE', True) 

1308 and self.pending_line_count == 0 

1309 ): 

1310 self.received_by = user 

1311 self.complete_order() 

1312 

1313 # Send notification 

1314 notify_responsible( 

1315 self, 

1316 PurchaseOrder, 

1317 exclude=user, 

1318 content=InvenTreeNotificationBodies.ItemsReceived, 

1319 extra_users=notify_users, 

1320 ) 

1321 

1322 # Return a list of the created stock items 

1323 return stock.models.StockItem.objects.filter( 

1324 pk__in=[item.pk for item in stock_items] 

1325 ) 

1326 

1327 @transaction.atomic 

1328 def receive_line_item( 

1329 self, line, location, quantity, user, status=StockStatus.OK.value, **kwargs 

1330 ): 

1331 """Receive a line item (or partial line item) against this PurchaseOrder. 

1332 

1333 Arguments: 

1334 line: The PurchaseOrderLineItem to receive against 

1335 location: The StockLocation to receive the item into 

1336 quantity: The quantity to receive 

1337 user: The User performing the action 

1338 status: The StockStatus to assign to the item (default: StockStatus.OK) 

1339 

1340 Keyword Arguments: 

1341 batch_code: Optional batch code for the new StockItem 

1342 serials: Optional list of serial numbers to assign to the new StockItem(s) 

1343 notes: Optional notes field for the StockItem 

1344 packaging: Optional packaging field for the StockItem 

1345 barcode: Optional barcode field for the StockItem 

1346 notify: If true, notify users of received items 

1347 

1348 Raises: 

1349 ValidationError: If the quantity is negative or otherwise invalid 

1350 ValidationError: If the order is not in the 'PLACED' state 

1351 """ 

1352 self.receive_line_items( 

1353 location, 

1354 [ 

1355 { 

1356 'line_item': line, 

1357 'quantity': quantity, 

1358 'location': location, 

1359 'status': status, 

1360 **kwargs, 

1361 } 

1362 ], 

1363 user, 

1364 ) 

1365 

1366 

1367STOCK_SORT_CHOICES = stock.models.STOCK_SORT_CHOICES 

1368STOCK_SORT_DEFAULT = stock.models.STOCK_SORT_DEFAULT 

1369 

1370SERIALIZED_STOCK_CHOICES = [ 

1371 ('any', _('Allow any stock (serialized or unserialized)')), 

1372 ('serialized', _('Serialized stock only')), 

1373 ('unserialized', _('Unserialized stock only')), 

1374] 

1375 

1376SERIALIZED_STOCK_DEFAULT = 'any' 

1377 

1378 

1379class SalesOrder(TotalPriceMixin, Order): 

1380 """A SalesOrder represents a list of goods shipped outwards to a customer.""" 

1381 

1382 REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN' 

1383 REQUIRE_RESPONSIBLE_SETTING = 'SALESORDER_REQUIRE_RESPONSIBLE' 

1384 STATUS_CLASS = SalesOrderStatus 

1385 UNLOCK_SETTING = 'SALESORDER_EDIT_COMPLETED_ORDERS' 

1386 

1387 class Meta: 

1388 """Model meta options.""" 

1389 

1390 verbose_name = _('Sales Order') 

1391 

1392 def clean_line_item(self, line): 

1393 """Clean a line item for this SalesOrder.""" 

1394 super().clean_line_item(line) 

1395 line.shipped = 0 

1396 

1397 def report_context(self) -> SalesOrderReportContext: 

1398 """Generate report context data for this SalesOrder.""" 

1399 return_ctx = super().report_context() 

1400 

1401 return_ctx.update({'customer': self.customer}) # ty:ignore[invalid-key] 

1402 return return_ctx 

1403 

1404 def get_absolute_url(self) -> str: 

1405 """Get the 'web' URL for this order.""" 

1406 return pui_url(f'/sales/sales-order/{self.pk}') 

1407 

1408 @staticmethod 

1409 def get_api_url() -> str: 

1410 """Return the API URL associated with the SalesOrder model.""" 

1411 return reverse('api-so-list') 

1412 

1413 @classmethod 

1414 def get_status_class(cls): 

1415 """Return the SalesOrderStatus class.""" 

1416 return SalesOrderStatusGroups 

1417 

1418 @classmethod 

1419 def api_defaults(cls, request=None) -> dict: 

1420 """Return default values for this model when issuing an API OPTIONS request.""" 

1421 defaults = {'reference': order.validators.generate_next_sales_order_reference()} 

1422 

1423 return defaults 

1424 

1425 @classmethod 

1426 def barcode_model_type_code(cls) -> str: 

1427 """Return the associated barcode model type code for this model.""" 

1428 return 'SO' 

1429 

1430 def subscribed_users(self) -> list[User]: 

1431 """Return a list of users subscribed to this SalesOrder. 

1432 

1433 By this, we mean users to are interested in any of the parts associated with this order. 

1434 """ 

1435 subscribed_users = set() 

1436 

1437 for line in self.lines.all(): 

1438 if line.part: 

1439 # Add the part to the list of subscribed users 

1440 for user in line.part.get_subscribers(): 

1441 subscribed_users.add(user) 

1442 

1443 return list(subscribed_users) 

1444 

1445 def __str__(self): 

1446 """Render a string representation of this SalesOrder.""" 

1447 return f'{self.reference} - {self.customer.name if self.customer else _("deleted")}' 

1448 

1449 reference = models.CharField( 

1450 unique=True, 

1451 max_length=64, 

1452 blank=False, 

1453 verbose_name=_('Reference'), 

1454 help_text=_('Order reference'), 

1455 default=order.validators.generate_next_sales_order_reference, 

1456 validators=[order.validators.validate_sales_order_reference], 

1457 ) 

1458 

1459 customer = models.ForeignKey( 

1460 Company, 

1461 on_delete=models.SET_NULL, 

1462 null=True, 

1463 limit_choices_to={'is_customer': True}, 

1464 related_name='return_orders', 

1465 verbose_name=_('Customer'), 

1466 help_text=_('Company to which the items are being sold'), 

1467 ) 

1468 

1469 @property 

1470 def company(self): 

1471 """Accessor helper for Order base.""" 

1472 return self.customer 

1473 

1474 status = InvenTreeCustomStatusModelField( 

1475 default=SalesOrderStatus.PENDING.value, 

1476 choices=SalesOrderStatus.items(), 

1477 status_class=SalesOrderStatus, 

1478 verbose_name=_('Status'), 

1479 help_text=_('Sales order status'), 

1480 ) 

1481 

1482 customer_reference = models.CharField( 

1483 max_length=64, 

1484 blank=True, 

1485 verbose_name=_('Customer Reference '), 

1486 help_text=_('Customer order reference code'), 

1487 ) 

1488 

1489 shipment_date = models.DateField( 

1490 blank=True, null=True, verbose_name=_('Shipment Date') 

1491 ) 

1492 

1493 shipped_by = models.ForeignKey( 

1494 User, 

1495 on_delete=models.SET_NULL, 

1496 blank=True, 

1497 null=True, 

1498 related_name='+', 

1499 verbose_name=_('shipped by'), 

1500 ) 

1501 

1502 @property 

1503 def is_pending(self) -> bool: 

1504 """Return True if this order is 'pending'.""" 

1505 return self.status == SalesOrderStatus.PENDING 

1506 

1507 @property 

1508 def is_open(self) -> bool: 

1509 """Return True if this order is 'open' (either 'pending' or 'in_progress').""" 

1510 return self.status in SalesOrderStatusGroups.OPEN 

1511 

1512 @property 

1513 def stock_allocations(self) -> QuerySet: 

1514 """Return a queryset containing all allocations for this order.""" 

1515 return SalesOrderAllocation.objects.filter( 

1516 line__in=[line.pk for line in self.lines.all()] 

1517 ) 

1518 

1519 def is_fully_allocated(self) -> bool: 

1520 """Return True if all line items are fully allocated.""" 

1521 return all(line.is_fully_allocated() for line in self.lines.all()) 

1522 

1523 def is_overallocated(self) -> bool: 

1524 """Return true if any lines in the order are over-allocated.""" 

1525 return any(line.is_overallocated() for line in self.lines.all()) 

1526 

1527 @transaction.atomic 

1528 def auto_allocate_stock( 

1529 self, 

1530 location: Optional[stock.models.StockLocation] = None, 

1531 exclude_location: Optional[stock.models.StockLocation] = None, 

1532 shipment: Optional['SalesOrderShipment'] = None, 

1533 line_ids: Optional[list] = None, 

1534 **kwargs, 

1535 ): 

1536 """Automatically allocate stock items against this SalesOrder. 

1537 

1538 For each unallocated line item, finds available stock for 

1539 the line's part, filtered and sorted according to the supplied kwargs, then 

1540 creates SalesOrderAllocation records in bulk. 

1541 

1542 Arguments: 

1543 location: If provided, only consider stock within this location tree. 

1544 exclude_location: If provided, exclude stock within this location tree. 

1545 shipment: Optional shipment to assign allocations to. 

1546 line_ids: If provided, only allocate against these specific line item PKs. 

1547 

1548 Kwargs: 

1549 interchangeable (bool): If True (default), consume stock from multiple 

1550 items/locations to satisfy a line. If False, only allocate when a 

1551 single item can cover the full remaining quantity. 

1552 """ 

1553 stock_sort_by = kwargs.get('stock_sort_by', STOCK_SORT_DEFAULT) 

1554 interchangeable = kwargs.get('interchangeable', True) 

1555 serialized_stock = kwargs.get('serialized_stock', SERIALIZED_STOCK_DEFAULT) 

1556 

1557 new_allocations = [] 

1558 

1559 lines = self.lines.all() 

1560 if line_ids: 

1561 lines = lines.filter(pk__in=line_ids) 

1562 

1563 for line_item in lines: 

1564 if not line_item.part: 

1565 continue 

1566 

1567 if line_item.part.virtual: 

1568 continue 

1569 

1570 unallocated = line_item.quantity - line_item.allocated_quantity() 

1571 

1572 if unallocated <= 0: 

1573 continue 

1574 

1575 available_stock = stock.models.StockItem.objects.filter( 

1576 stock.models.StockItem.IN_STOCK_FILTER, part=line_item.part 

1577 ) 

1578 

1579 if location: 

1580 sublocations = location.get_descendants(include_self=True) 

1581 available_stock = available_stock.filter( 

1582 location__in=list(sublocations) 

1583 ) 

1584 

1585 if exclude_location: 

1586 sublocations = exclude_location.get_descendants(include_self=True) 

1587 available_stock = available_stock.exclude( 

1588 location__in=list(sublocations) 

1589 ) 

1590 

1591 if serialized_stock == 'serialized': 

1592 available_stock = available_stock.filter( 

1593 serial__isnull=False, quantity=1 

1594 ).exclude(serial='') 

1595 elif serialized_stock == 'unserialized': 

1596 available_stock = available_stock.filter( 

1597 Q(serial__isnull=True) | Q(serial='') 

1598 ) 

1599 

1600 # Handle NULL expiry_date last when sorting by expiry. 

1601 if stock_sort_by == stock.models.StockSortOrder.EXPIRY_SOONEST: 

1602 available_stock = available_stock.order_by( 

1603 F('expiry_date').asc(nulls_last=True) 

1604 ) 

1605 else: 

1606 available_stock = available_stock.order_by(stock_sort_by) 

1607 

1608 stock_count = available_stock.count() 

1609 

1610 if stock_count == 0: 

1611 continue 

1612 

1613 if not interchangeable and stock_count > 1: 

1614 # Only allocate when a single item can fully cover the requirement. 

1615 single = next( 

1616 ( 

1617 s 

1618 for s in available_stock 

1619 if s.unallocated_quantity() >= unallocated 

1620 ), 

1621 None, 

1622 ) 

1623 if single is None: 

1624 continue 

1625 available_stock = [single] 

1626 

1627 for stock_item in available_stock: 

1628 available_qty = stock_item.unallocated_quantity() 

1629 

1630 if available_qty <= 0: 

1631 continue 

1632 

1633 quantity = min(unallocated, available_qty) 

1634 

1635 new_allocations.append( 

1636 SalesOrderAllocation( 

1637 line=line_item, 

1638 item=stock_item, 

1639 quantity=quantity, 

1640 shipment=shipment, 

1641 ) 

1642 ) 

1643 

1644 unallocated -= quantity 

1645 

1646 if unallocated <= 0: 

1647 break 

1648 

1649 SalesOrderAllocation.objects.bulk_create(new_allocations, batch_size=250) 

1650 

1651 def is_completed(self) -> bool: 

1652 """Check if this order is "shipped" (all line items delivered). 

1653 

1654 Note: Any "virtual" parts are ignored in this calculation. 

1655 """ 

1656 lines = self.lines.all().filter(part__virtual=False) 

1657 

1658 return all(line.is_completed() for line in lines) 

1659 

1660 def can_complete( 

1661 self, raise_error: bool = False, allow_incomplete_lines: bool = False 

1662 ) -> bool: 

1663 """Test if this SalesOrder can be completed. 

1664 

1665 Arguments: 

1666 raise_error: If True, raise ValidationError if the order cannot be completed 

1667 allow_incomplete_lines: If True, allow incomplete line items when completing the order 

1668 

1669 Raises: 

1670 ValidationError: If the order cannot be completed, and raise_error is True 

1671 """ 

1672 try: 

1673 if self.status == SalesOrderStatus.COMPLETE.value: 

1674 raise ValidationError(_('Order is already complete')) 

1675 

1676 if self.status == SalesOrderStatus.CANCELLED.value: 

1677 raise ValidationError(_('Order is already cancelled')) 

1678 

1679 # Only an open order can be marked as shipped 

1680 if self.is_open and not self.is_completed: 

1681 raise ValidationError(_('Only an open order can be marked as complete')) 

1682 

1683 if self.pending_shipment_count > 0: 

1684 raise ValidationError( 

1685 _('Order cannot be completed as there are incomplete shipments') 

1686 ) 

1687 

1688 if self.pending_allocation_count > 0: 

1689 raise ValidationError( 

1690 _('Order cannot be completed as there are incomplete allocations') 

1691 ) 

1692 

1693 if not allow_incomplete_lines: 

1694 pending_lines = self.pending_line_items().exclude(part__virtual=True) 

1695 

1696 if pending_lines.count() > 0: 

1697 raise ValidationError( 

1698 _( 

1699 'Order cannot be completed as there are incomplete line items' 

1700 ) 

1701 ) 

1702 

1703 except ValidationError as e: 

1704 if raise_error: 

1705 raise e 

1706 else: 

1707 return False 

1708 

1709 return True 

1710 

1711 # region state changes 

1712 def place_order(self): 

1713 """Deprecated version of 'issue_order'.""" 

1714 self.issue_order() 

1715 

1716 @property 

1717 def can_issue(self) -> bool: 

1718 """Return True if this order can be issued.""" 

1719 return self.status in [ 

1720 SalesOrderStatus.PENDING.value, 

1721 SalesOrderStatus.ON_HOLD.value, 

1722 ] 

1723 

1724 def _action_place(self, *args, **kwargs): 

1725 """Change this order from 'PENDING' to 'IN_PROGRESS'.""" 

1726 if self.can_issue: 

1727 self.status = SalesOrderStatus.IN_PROGRESS.value 

1728 self.issue_date = InvenTree.helpers.current_date() 

1729 self.save() 

1730 

1731 trigger_event(SalesOrderEvents.ISSUED, id=self.pk) 

1732 

1733 # Notify users that the order has been placed 

1734 notify_responsible( 

1735 self, 

1736 SalesOrder, 

1737 exclude=self.created_by, 

1738 content=InvenTreeNotificationBodies.NewOrder, 

1739 extra_users=self.subscribed_users(), 

1740 ) 

1741 

1742 @property 

1743 def can_hold(self) -> bool: 

1744 """Return True if this order can be placed on hold.""" 

1745 return self.status in [ 

1746 SalesOrderStatus.PENDING.value, 

1747 SalesOrderStatus.IN_PROGRESS.value, 

1748 ] 

1749 

1750 def _action_hold(self, *args, **kwargs): 

1751 """Mark this sales order as 'on hold'.""" 

1752 if self.can_hold: 

1753 self.status = SalesOrderStatus.ON_HOLD.value 

1754 self.save() 

1755 

1756 trigger_event(SalesOrderEvents.HOLD, id=self.pk) 

1757 

1758 @transaction.atomic 

1759 def _action_complete(self, *args, **kwargs): 

1760 """Mark this order as "complete.""" 

1761 user = kwargs.pop('user', None) 

1762 

1763 if not self.can_complete(**kwargs): 

1764 return False 

1765 

1766 bypass_shipped = InvenTree.helpers.str2bool( 

1767 get_global_setting('SALESORDER_SHIP_COMPLETE') 

1768 ) 

1769 

1770 # Update line items 

1771 for line in self.lines.all(): 

1772 # Mark any "virtual" parts as shipped at this point 

1773 if line.part and line.part.virtual and line.shipped != line.quantity: 

1774 line.shipped = line.quantity 

1775 line.save() 

1776 

1777 if line.part: 

1778 line.part.schedule_pricing_update(create=True) 

1779 

1780 if bypass_shipped or self.status == SalesOrderStatus.SHIPPED: 

1781 self.status = SalesOrderStatus.COMPLETE.value 

1782 else: 

1783 self.status = SalesOrderStatus.SHIPPED.value 

1784 

1785 if self.shipment_date is None: 

1786 self.shipped_by = user 

1787 self.shipment_date = InvenTree.helpers.current_date() 

1788 

1789 self.save() 

1790 

1791 trigger_event(SalesOrderEvents.COMPLETED, id=self.pk) 

1792 

1793 return True 

1794 

1795 @property 

1796 def can_cancel(self) -> bool: 

1797 """Return True if this order can be cancelled.""" 

1798 return self.is_open 

1799 

1800 def _action_cancel(self, *args, **kwargs): 

1801 """Cancel this order (only if it is "open"). 

1802 

1803 Executes: 

1804 - Mark the order as 'cancelled' 

1805 - Delete any StockItems which have been allocated 

1806 """ 

1807 if not self.can_cancel: 

1808 return False 

1809 

1810 self.status = SalesOrderStatus.CANCELLED.value 

1811 self.save() 

1812 

1813 for line in self.lines.all(): 

1814 for allocation in line.allocations.all(): 

1815 allocation.delete() 

1816 

1817 trigger_event(SalesOrderEvents.CANCELLED, id=self.pk) 

1818 

1819 # Notify users that the order has been canceled 

1820 notify_responsible( 

1821 self, 

1822 SalesOrder, 

1823 exclude=self.created_by, 

1824 content=InvenTreeNotificationBodies.OrderCanceled, 

1825 extra_users=self.subscribed_users(), 

1826 ) 

1827 

1828 return True 

1829 

1830 @transaction.atomic 

1831 def issue_order(self): 

1832 """Attempt to transition to IN_PROGRESS status.""" 

1833 return self.handle_transition( 

1834 self.status, SalesOrderStatus.IN_PROGRESS.value, self, self._action_place 

1835 ) 

1836 

1837 @transaction.atomic 

1838 def ship_order(self, user, **kwargs): 

1839 """Attempt to transition to SHIPPED status.""" 

1840 return self.handle_transition( 

1841 self.status, 

1842 SalesOrderStatus.SHIPPED.value, 

1843 self, 

1844 self._action_complete, 

1845 user=user, 

1846 **kwargs, 

1847 ) 

1848 

1849 @transaction.atomic 

1850 def complete_order(self, user, **kwargs): 

1851 """Attempt to transition to COMPLETED status.""" 

1852 return self.handle_transition( 

1853 self.status, 

1854 SalesOrderStatus.COMPLETE.value, 

1855 self, 

1856 self._action_complete, 

1857 user=user, 

1858 **kwargs, 

1859 ) 

1860 

1861 @transaction.atomic 

1862 def hold_order(self): 

1863 """Attempt to transition to ON_HOLD status.""" 

1864 return self.handle_transition( 

1865 self.status, SalesOrderStatus.ON_HOLD.value, self, self._action_hold 

1866 ) 

1867 

1868 @transaction.atomic 

1869 def cancel_order(self): 

1870 """Attempt to transition to CANCELLED status.""" 

1871 return self.handle_transition( 

1872 self.status, SalesOrderStatus.CANCELLED.value, self, self._action_cancel 

1873 ) 

1874 

1875 # endregion 

1876 

1877 @property 

1878 def line_count(self) -> int: 

1879 """Return the total number of lines associated with this order.""" 

1880 return self.lines.count() 

1881 

1882 def completed_line_items(self) -> QuerySet: 

1883 """Return a queryset of the completed line items for this order.""" 

1884 return self.lines.filter(shipped__gte=F('quantity')) 

1885 

1886 def pending_line_items(self) -> QuerySet: 

1887 """Return a queryset of the pending line items for this order. 

1888 

1889 Note: We exclude "virtual" parts here, as they do not get allocated 

1890 """ 

1891 return self.lines.filter(shipped__lt=F('quantity')).exclude(part__virtual=True) 

1892 

1893 @property 

1894 def completed_line_count(self) -> int: 

1895 """Return the number of completed lines for this order.""" 

1896 return self.completed_line_items().count() 

1897 

1898 @property 

1899 def pending_line_count(self) -> int: 

1900 """Return the number of pending (incomplete) lines associated with this order.""" 

1901 return self.pending_line_items().count() 

1902 

1903 def completed_shipments(self) -> QuerySet: 

1904 """Return a queryset of the completed shipments for this order.""" 

1905 return self.shipments.exclude(shipment_date=None) 

1906 

1907 def pending_shipments(self) -> QuerySet: 

1908 """Return a queryset of the pending shipments for this order.""" 

1909 return self.shipments.filter(shipment_date=None) 

1910 

1911 def allocations(self) -> QuerySet: 

1912 """Return a queryset of all allocations for this order.""" 

1913 return SalesOrderAllocation.objects.filter(line__order=self) 

1914 

1915 def pending_allocations(self) -> QuerySet: 

1916 """Return a queryset of any pending allocations for this order. 

1917 

1918 Allocations are pending if: 

1919 

1920 a) They are not associated with a SalesOrderShipment 

1921 b) The linked SalesOrderShipment has not been shipped 

1922 """ 

1923 Q1 = Q(shipment=None) 

1924 Q2 = Q(shipment__shipment_date=None) 

1925 

1926 return self.allocations().filter(Q1 | Q2).distinct() 

1927 

1928 @property 

1929 def shipment_count(self) -> int: 

1930 """Return the total number of shipments associated with this order.""" 

1931 return self.shipments.count() 

1932 

1933 @property 

1934 def completed_shipment_count(self) -> int: 

1935 """Return the number of completed shipments associated with this order.""" 

1936 return self.completed_shipments().count() 

1937 

1938 @property 

1939 def pending_shipment_count(self) -> int: 

1940 """Return the number of pending shipments associated with this order.""" 

1941 return self.pending_shipments().count() 

1942 

1943 @property 

1944 def pending_allocation_count(self) -> int: 

1945 """Return the number of pending (non-shipped) allocations.""" 

1946 return self.pending_allocations().count() 

1947 

1948 

1949@receiver(post_save, sender=SalesOrder, dispatch_uid='sales_order_post_save') 

1950def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs): 

1951 """Callback function to be executed after a SalesOrder is saved. 

1952 

1953 - If the SALESORDER_DEFAULT_SHIPMENT setting is enabled, create a default shipment 

1954 - Ignore if the database is not ready for access 

1955 - Ignore if data import is active 

1956 """ 

1957 if ( 

1958 not InvenTree.ready.canAppAccessDatabase(allow_test=True) 

1959 or InvenTree.ready.isImportingData() 

1960 ): 

1961 return 

1962 

1963 if created: 

1964 # A new SalesOrder has just been created 

1965 

1966 if get_global_setting('SALESORDER_DEFAULT_SHIPMENT'): 

1967 # Create default shipment 

1968 SalesOrderShipment.objects.create(order=instance, reference='1') 

1969 

1970 

1971class OrderLineItem(InvenTree.models.InvenTreeMetadataModel): 

1972 """Abstract model for an order line item. 

1973 

1974 Attributes: 

1975 quantity: Number of items 

1976 line: The line number for this item (optional) 

1977 line_int: An integer line number for this item (optional - used for natural sorting) 

1978 reference: Reference text (e.g. customer reference) for this line item 

1979 project_code: Project code associated with this line item (optional) 

1980 note: Annotation for the item 

1981 target_date: An (optional) date for expected shipment of this line item. 

1982 """ 

1983 

1984 class Meta: 

1985 """Metaclass options. Abstract ensures no database table is created.""" 

1986 

1987 abstract = True 

1988 

1989 def save(self, *args, **kwargs): 

1990 """Custom save method for the OrderLineItem model. 

1991 

1992 Calls save method on the linked order 

1993 """ 

1994 if self.order and self.order.check_locked(): 

1995 raise ValidationError({ 

1996 'non_field_errors': _('The order is locked and cannot be modified') 

1997 }) 

1998 

1999 update_order = kwargs.pop('update_order', True) 

2000 

2001 # Update the integer representation of the line number (for natural sorting) 

2002 if self.line: 

2003 try: 

2004 self.line_int = int(self.line) 

2005 except (TypeError, ValueError): 

2006 self.line_int = 0 

2007 else: 

2008 self.line_int = 0 

2009 

2010 super().save(*args, **kwargs) 

2011 if update_order and self.order: 

2012 self.order.save() 

2013 

2014 def delete(self, *args, **kwargs): 

2015 """Custom delete method for the OrderLineItem model. 

2016 

2017 Calls save method on the linked order 

2018 """ 

2019 if self.order and self.order.check_locked(): 

2020 raise ValidationError({ 

2021 'non_field_errors': _('The order is locked and cannot be modified') 

2022 }) 

2023 

2024 super().delete(*args, **kwargs) 

2025 self.order.save() 

2026 

2027 quantity = RoundingDecimalField( 

2028 verbose_name=_('Quantity'), 

2029 help_text=_('Item quantity'), 

2030 default=1, 

2031 max_digits=15, 

2032 decimal_places=5, 

2033 validators=[MinValueValidator(0)], 

2034 ) 

2035 

2036 @property 

2037 def total_line_price(self): 

2038 """Return the total price for this line item.""" 

2039 if self.price: 

2040 return self.quantity * self.price 

2041 

2042 line = models.CharField( 

2043 max_length=20, 

2044 blank=True, 

2045 default='', 

2046 null=False, 

2047 verbose_name=_('Line Number'), 

2048 help_text=_('Line number for this item (optional)'), 

2049 ) 

2050 

2051 line_int = models.IntegerField(default=0, blank=False, null=False) 

2052 

2053 reference = models.CharField( 

2054 max_length=100, 

2055 blank=True, 

2056 verbose_name=_('Reference'), 

2057 help_text=_('Line item reference'), 

2058 ) 

2059 

2060 notes = models.CharField( 

2061 max_length=500, 

2062 blank=True, 

2063 verbose_name=_('Notes'), 

2064 help_text=_('Line item notes'), 

2065 ) 

2066 

2067 link = InvenTreeURLField( 

2068 blank=True, 

2069 verbose_name=_('Link'), 

2070 help_text=_('Link to external page'), 

2071 max_length=2000, 

2072 ) 

2073 

2074 target_date = models.DateField( 

2075 blank=True, 

2076 null=True, 

2077 verbose_name=_('Target Date'), 

2078 help_text=_( 

2079 'Target date for this line item (leave blank to use the target date from the order)' 

2080 ), 

2081 ) 

2082 

2083 project_code = models.ForeignKey( 

2084 common_models.ProjectCode, 

2085 on_delete=models.SET_NULL, 

2086 blank=True, 

2087 null=True, 

2088 verbose_name=_('Project Code'), 

2089 help_text=_('Select project code for this order'), 

2090 ) 

2091 

2092 

2093class OrderExtraLine(OrderLineItem): 

2094 """Abstract Model for a single ExtraLine in a Order. 

2095 

2096 Attributes: 

2097 price: The unit sale price for this OrderLineItem 

2098 """ 

2099 

2100 class Meta: 

2101 """Metaclass options. Abstract ensures no database table is created.""" 

2102 

2103 abstract = True 

2104 

2105 description = models.CharField( 

2106 max_length=250, 

2107 blank=True, 

2108 verbose_name=_('Description'), 

2109 help_text=_('Line item description (optional)'), 

2110 ) 

2111 

2112 context = models.JSONField( 

2113 blank=True, 

2114 null=True, 

2115 verbose_name=_('Context'), 

2116 help_text=_('Additional context for this line'), 

2117 ) 

2118 

2119 price = InvenTreeModelMoneyField( 

2120 max_digits=19, 

2121 decimal_places=6, 

2122 null=True, 

2123 blank=True, 

2124 allow_negative=True, 

2125 verbose_name=_('Price'), 

2126 help_text=_('Unit price'), 

2127 ) 

2128 

2129 

2130class PurchaseOrderLineItem(OrderLineItem): 

2131 """Model for a purchase order line item. 

2132 

2133 Attributes: 

2134 order: Reference to a PurchaseOrder object 

2135 part: Reference to a SupplierPart object 

2136 received: Number of items received 

2137 purchase_price: Unit purchase price for this line item 

2138 build_order: Link to an external BuildOrder to be fulfilled by this line item 

2139 destination: Destination for received items 

2140 """ 

2141 

2142 class Meta: 

2143 """Model meta options.""" 

2144 

2145 verbose_name = _('Purchase Order Line Item') 

2146 

2147 @classmethod 

2148 def get_overdue_filter(cls): 

2149 """Filter for determining if a particular PurchaseOrderLineItem is overdue.""" 

2150 return ( 

2151 Q(received__lt=F('quantity')) 

2152 & ~Q(target_date=None) 

2153 & Q(target_date__lt=InvenTree.helpers.current_date()) 

2154 ) 

2155 

2156 @staticmethod 

2157 def get_api_url() -> str: 

2158 """Return the API URL associated with the PurchaseOrderLineItem model.""" 

2159 return reverse('api-po-line-list') 

2160 

2161 def clean(self) -> None: 

2162 """Custom clean method for the PurchaseOrderLineItem model. 

2163 

2164 Raises: 

2165 ValidationError: If the SupplierPart does not match the PurchaseOrder supplier 

2166 ValidationError: If the linked BuildOrder is not marked as external 

2167 ValidationError: If the linked BuildOrder part does not match the line item part 

2168 """ 

2169 super().clean() 

2170 

2171 if self.order.supplier and self.part: 

2172 # Supplier part *must* point to the same supplier! 

2173 if self.part.supplier != self.order.supplier: 

2174 raise ValidationError({'part': _('Supplier part must match supplier')}) 

2175 

2176 # Link to the base part 

2177 part = self.part.part 

2178 

2179 if self.build_order: 

2180 if not self.build_order.external: 

2181 raise ValidationError({ 

2182 'build_order': _('Build order must be marked as external') 

2183 }) 

2184 

2185 if part: 

2186 if not part.assembly: 

2187 raise ValidationError({ 

2188 'build_order': _( 

2189 'Build orders can only be linked to assembly parts' 

2190 ) 

2191 }) 

2192 

2193 if self.build_order.part != self.part.part: 

2194 raise ValidationError({ 

2195 'build_order': _('Build order part must match line item part') 

2196 }) 

2197 

2198 # Extra checks for external builds 

2199 if part and part.assembly and get_global_setting('BUILDORDER_EXTERNAL_BUILDS'): 

2200 if not self.build_order and get_global_setting( 

2201 'BUILDORDER_EXTERNAL_REQUIRED' 

2202 ): 

2203 raise ValidationError({ 

2204 'build_order': _( 

2205 'An external build order is required for assembly parts' 

2206 ) 

2207 }) 

2208 

2209 def __str__(self): 

2210 """Render a string representation of a PurchaseOrderLineItem instance.""" 

2211 return '{n} x {part} - {po}'.format( 

2212 n=decimal2string(self.quantity), 

2213 part=self.part.SKU if self.part else 'unknown part', 

2214 po=self.order, 

2215 ) 

2216 

2217 order = models.ForeignKey( 

2218 PurchaseOrder, 

2219 on_delete=models.CASCADE, 

2220 related_name='lines', 

2221 verbose_name=_('Order'), 

2222 help_text=_('Purchase Order'), 

2223 ) 

2224 

2225 def get_base_part(self): 

2226 """Return the base part.Part object for the line item. 

2227 

2228 Note: Returns None if the SupplierPart is not set! 

2229 """ 

2230 if self.part is None: 

2231 return None 

2232 return self.part.part 

2233 

2234 part = models.ForeignKey( 

2235 SupplierPart, 

2236 on_delete=models.SET_NULL, 

2237 blank=False, 

2238 null=True, 

2239 related_name='purchase_order_line_items', 

2240 verbose_name=_('Part'), 

2241 help_text=_('Supplier part'), 

2242 ) 

2243 

2244 received = models.DecimalField( 

2245 decimal_places=5, 

2246 max_digits=15, 

2247 default=0, 

2248 verbose_name=_('Received'), 

2249 help_text=_('Number of items received'), 

2250 ) 

2251 

2252 purchase_price = InvenTreeModelMoneyField( 

2253 max_digits=19, 

2254 decimal_places=6, 

2255 null=True, 

2256 blank=True, 

2257 verbose_name=_('Purchase Price'), 

2258 help_text=_('Unit purchase price'), 

2259 ) 

2260 

2261 @property 

2262 def price(self): 

2263 """Return the 'purchase_price' field as 'price'.""" 

2264 return self.purchase_price 

2265 

2266 build_order = models.ForeignKey( 

2267 'build.Build', 

2268 on_delete=models.SET_NULL, 

2269 blank=True, 

2270 related_name='external_line_items', 

2271 limit_choices_to={'external': True}, 

2272 null=True, 

2273 verbose_name=_('Build Order'), 

2274 help_text=_('External Build Order to be fulfilled by this line item'), 

2275 ) 

2276 

2277 destination = TreeForeignKey( 

2278 'stock.StockLocation', 

2279 on_delete=models.SET_NULL, 

2280 verbose_name=_('Destination'), 

2281 related_name='po_lines', 

2282 blank=True, 

2283 null=True, 

2284 help_text=_('Destination for received items'), 

2285 ) 

2286 

2287 def get_destination(self): 

2288 """Show where the line item is or should be placed. 

2289 

2290 1. If a destination is specified against this line item, return that. 

2291 2. If a destination is specified against the PurchaseOrderPart, return that. 

2292 3. If a default location is specified against the linked Part, return that. 

2293 """ 

2294 if self.destination: 

2295 return self.destination 

2296 

2297 if self.order.destination: 

2298 return self.order.destination 

2299 

2300 if self.part and self.part.part and self.part.part.default_location: 

2301 return self.part.part.default_location 

2302 

2303 def remaining(self): 

2304 """Calculate the number of items remaining to be received.""" 

2305 r = self.quantity - self.received 

2306 return max(r, 0) 

2307 

2308 def is_completed(self) -> bool: 

2309 """Determine if this line item has been fully received.""" 

2310 return self.received >= self.quantity 

2311 

2312 def update_pricing(self) -> None: 

2313 """Update pricing information based on the supplier part data.""" 

2314 if self.part: 

2315 price = self.part.get_price( 

2316 self.quantity, currency=self.purchase_price_currency 

2317 ) 

2318 

2319 if price is None or self.quantity == 0: 

2320 return 

2321 

2322 self.purchase_price = Decimal(price) / Decimal(self.quantity) 

2323 self.save() 

2324 

2325 

2326class PurchaseOrderExtraLine(OrderExtraLine): 

2327 """Model for a single ExtraLine in a PurchaseOrder. 

2328 

2329 Attributes: 

2330 order: Link to the PurchaseOrder that this line belongs to 

2331 """ 

2332 

2333 class Meta: 

2334 """Model meta options.""" 

2335 

2336 verbose_name = _('Purchase Order Extra Line') 

2337 

2338 @staticmethod 

2339 def get_api_url() -> str: 

2340 """Return the API URL associated with the PurchaseOrderExtraLine model.""" 

2341 return reverse('api-po-extra-line-list') 

2342 

2343 order = models.ForeignKey( 

2344 PurchaseOrder, 

2345 on_delete=models.CASCADE, 

2346 related_name='extra_lines', 

2347 verbose_name=_('Order'), 

2348 help_text=_('Purchase Order'), 

2349 ) 

2350 

2351 

2352class SalesOrderLineItem(OrderLineItem): 

2353 """Model for a single LineItem in a SalesOrder. 

2354 

2355 Attributes: 

2356 order: Link to the SalesOrder that this line item belongs to 

2357 part: Link to a Part object (may be null) 

2358 sale_price: The unit sale price for this OrderLineItem 

2359 shipped: The number of items which have actually shipped against this line item 

2360 """ 

2361 

2362 class Meta: 

2363 """Model meta options.""" 

2364 

2365 verbose_name = _('Sales Order Line Item') 

2366 

2367 @classmethod 

2368 def get_overdue_filter(cls): 

2369 """Filter for determining if a particular SalesOrderLineItem is overdue.""" 

2370 return ( 

2371 Q(shipped__lt=F('quantity')) 

2372 & ~Q(target_date=None) 

2373 & Q(target_date__lt=InvenTree.helpers.current_date()) 

2374 ) 

2375 

2376 @staticmethod 

2377 def get_api_url(): 

2378 """Return the API URL associated with the SalesOrderLineItem model.""" 

2379 return reverse('api-so-line-list') 

2380 

2381 def clean(self) -> None: 

2382 """Perform extra validation steps for this SalesOrderLineItem instance. 

2383 

2384 Raises: 

2385 ValidationError: If the linked part is not salable 

2386 """ 

2387 super().clean() 

2388 

2389 if self.part: 

2390 if not self.part.salable: 

2391 raise ValidationError({ 

2392 'part': _('Only salable parts can be assigned to a sales order') 

2393 }) 

2394 

2395 order = models.ForeignKey( 

2396 SalesOrder, 

2397 on_delete=models.CASCADE, 

2398 related_name='lines', 

2399 verbose_name=_('Order'), 

2400 help_text=_('Sales Order'), 

2401 ) 

2402 

2403 part = models.ForeignKey( 

2404 'part.Part', 

2405 on_delete=models.SET_NULL, 

2406 related_name='sales_order_line_items', 

2407 null=True, 

2408 verbose_name=_('Part'), 

2409 help_text=_('Part'), 

2410 limit_choices_to={'salable': True}, 

2411 ) 

2412 

2413 sale_price = InvenTreeModelMoneyField( 

2414 max_digits=19, 

2415 decimal_places=6, 

2416 null=True, 

2417 blank=True, 

2418 verbose_name=_('Sale Price'), 

2419 help_text=_('Unit sale price'), 

2420 ) 

2421 

2422 @property 

2423 def price(self): 

2424 """Return the 'sale_price' field as 'price'.""" 

2425 return self.sale_price 

2426 

2427 shipped = RoundingDecimalField( 

2428 verbose_name=_('Shipped'), 

2429 help_text=_('Shipped quantity'), 

2430 default=0, 

2431 max_digits=15, 

2432 decimal_places=5, 

2433 validators=[MinValueValidator(0)], 

2434 ) 

2435 

2436 def fulfilled_quantity(self): 

2437 """Return the total stock quantity fulfilled against this line item.""" 

2438 if not self.pk: 

2439 return 0 

2440 

2441 query = self.order.stock_items.filter(part=self.part).aggregate( 

2442 fulfilled=Coalesce(Sum('quantity'), Decimal(0)) 

2443 ) 

2444 

2445 return query['fulfilled'] 

2446 

2447 def allocated_quantity(self): 

2448 """Return the total stock quantity allocated to this LineItem. 

2449 

2450 This is a summation of the quantity of each attached StockItem 

2451 """ 

2452 if not self.pk: 

2453 return 0 

2454 

2455 query = self.allocations.aggregate( 

2456 allocated=Coalesce(Sum('quantity'), Decimal(0)) 

2457 ) 

2458 

2459 return query['allocated'] 

2460 

2461 def is_fully_allocated(self) -> bool: 

2462 """Return True if this line item is fully allocated.""" 

2463 # If the linked part is "virtual", then we cannot allocate stock against it 

2464 if self.part and self.part.virtual: 

2465 return True 

2466 

2467 if self.order.status == SalesOrderStatus.SHIPPED: 

2468 return self.fulfilled_quantity() >= self.quantity 

2469 

2470 return self.allocated_quantity() >= self.quantity 

2471 

2472 def is_overallocated(self) -> bool: 

2473 """Return True if this line item is over allocated.""" 

2474 return self.allocated_quantity() > self.quantity 

2475 

2476 def is_completed(self) -> bool: 

2477 """Return True if this line item is completed (has been fully shipped).""" 

2478 # A "virtual" part is always considered to be "completed" 

2479 if self.part and self.part.virtual: 

2480 return True 

2481 

2482 return self.shipped >= self.quantity 

2483 

2484 

2485class SalesOrderShipmentReportContext(report.mixins.BaseReportContext, TypedDict): 

2486 """Context for the SalesOrderShipment model. 

2487 

2488 Attributes: 

2489 allocations: QuerySet of SalesOrderAllocation objects 

2490 address: The shipping address for this shipment (or order) 

2491 order: The associated SalesOrder object 

2492 reference: Shipment reference string 

2493 shipment: The SalesOrderShipment object itself 

2494 tracking_number: Shipment tracking number string 

2495 title: Title for the report 

2496 """ 

2497 

2498 allocations: report.mixins.QuerySet['SalesOrderAllocation'] 

2499 order: 'SalesOrder' 

2500 reference: str 

2501 address: 'Address' 

2502 shipment: 'SalesOrderShipment' 

2503 tracking_number: str 

2504 title: str 

2505 

2506 

2507class SalesOrderShipment( 

2508 InvenTree.models.InvenTreeParameterMixin, 

2509 InvenTree.models.InvenTreeAttachmentMixin, 

2510 InvenTree.models.InvenTreeBarcodeMixin, 

2511 InvenTree.models.InvenTreeTagsMixin, 

2512 InvenTree.models.InvenTreeNotesMixin, 

2513 report.mixins.InvenTreeReportMixin, 

2514 InvenTree.models.MetadataMixin, 

2515 InvenTree.models.InvenTreeModel, 

2516): 

2517 """The SalesOrderShipment model represents a physical shipment made against a SalesOrder. 

2518 

2519 - Points to a single SalesOrder object 

2520 - Multiple SalesOrderAllocation objects point to a particular SalesOrderShipment 

2521 - When a given SalesOrderShipment is "shipped", stock items are removed from stock 

2522 

2523 Attributes: 

2524 order: SalesOrder reference 

2525 shipment_address: Shipping address for this shipment (optional) 

2526 shipment_date: Date this shipment was "shipped" (or null) 

2527 checked_by: User reference field indicating who checked this order 

2528 reference: Custom reference text for this shipment (e.g. consignment number?) 

2529 notes: Custom notes field for this shipment 

2530 """ 

2531 

2532 @classmethod 

2533 def barcode_model_type_code(cls): 

2534 """Return the associated barcode model type code for this model.""" 

2535 return 'SS' 

2536 

2537 class Meta: 

2538 """Metaclass defines extra model options.""" 

2539 

2540 # Shipment reference must be unique for a given sales order 

2541 unique_together = ['order', 'reference'] 

2542 verbose_name = _('Sales Order Shipment') 

2543 

2544 def clean(self) -> None: 

2545 """Custom clean method for the SalesOrderShipment class. 

2546 

2547 Raises: 

2548 ValidationError: If the shipment address does not match the customer 

2549 """ 

2550 super().clean() 

2551 

2552 if self.order and self.shipment_address: 

2553 if self.shipment_address.company != self.order.customer: 

2554 raise ValidationError({ 

2555 'shipment_address': _('Shipment address must match the customer') 

2556 }) 

2557 

2558 @staticmethod 

2559 def get_api_url() -> str: 

2560 """Return the API URL associated with the SalesOrderShipment model.""" 

2561 return reverse('api-so-shipment-list') 

2562 

2563 def report_context(self) -> SalesOrderShipmentReportContext: 

2564 """Generate context data for the reporting interface.""" 

2565 return { 

2566 'allocations': self.allocations, 

2567 'order': self.order, 

2568 'address': self.address, 

2569 'reference': self.reference, 

2570 'shipment': self, 

2571 'tracking_number': self.tracking_number, 

2572 'title': str(self), 

2573 } 

2574 

2575 order = models.ForeignKey( 

2576 SalesOrder, 

2577 on_delete=models.CASCADE, 

2578 blank=False, 

2579 null=False, 

2580 related_name='shipments', 

2581 verbose_name=_('Order'), 

2582 help_text=_('Sales Order'), 

2583 ) 

2584 

2585 shipment_address = models.ForeignKey( 

2586 Address, 

2587 on_delete=models.SET_NULL, 

2588 blank=True, 

2589 null=True, 

2590 verbose_name=_('Address'), 

2591 help_text=_('Shipping address for this shipment'), 

2592 related_name='+', 

2593 ) 

2594 

2595 shipment_date = models.DateField( 

2596 null=True, 

2597 blank=True, 

2598 verbose_name=_('Shipment Date'), 

2599 help_text=_('Date of shipment'), 

2600 ) 

2601 

2602 delivery_date = models.DateField( 

2603 null=True, 

2604 blank=True, 

2605 verbose_name=_('Delivery Date'), 

2606 help_text=_('Date of delivery of shipment'), 

2607 ) 

2608 

2609 checked_by = models.ForeignKey( 

2610 User, 

2611 on_delete=models.SET_NULL, 

2612 blank=True, 

2613 null=True, 

2614 verbose_name=_('Checked By'), 

2615 help_text=_('User who checked this shipment'), 

2616 related_name='+', 

2617 ) 

2618 

2619 reference = models.CharField( 

2620 max_length=100, 

2621 blank=False, 

2622 verbose_name=_('Shipment'), 

2623 help_text=_('Shipment number'), 

2624 default='1', 

2625 ) 

2626 

2627 tracking_number = models.CharField( 

2628 max_length=100, 

2629 blank=True, 

2630 unique=False, 

2631 verbose_name=_('Tracking Number'), 

2632 help_text=_('Shipment tracking information'), 

2633 ) 

2634 

2635 invoice_number = models.CharField( 

2636 max_length=100, 

2637 blank=True, 

2638 unique=False, 

2639 verbose_name=_('Invoice Number'), 

2640 help_text=_('Reference number for associated invoice'), 

2641 ) 

2642 

2643 link = InvenTreeURLField( 

2644 blank=True, 

2645 verbose_name=_('Link'), 

2646 help_text=_('Link to external page'), 

2647 max_length=2000, 

2648 ) 

2649 

2650 @property 

2651 def address(self) -> Address: 

2652 """Return the shipping address for this shipment. 

2653 

2654 Lookup priority: 

2655 - Specific address assigned to this shipment 

2656 - Address assigned to the order 

2657 - Primary address of the customer 

2658 """ 

2659 return ( 

2660 self.shipment_address 

2661 or self.order.address 

2662 or self.order.customer.primary_address 

2663 ) 

2664 

2665 def is_checked(self) -> bool: 

2666 """Return True if this shipment has been checked.""" 

2667 return self.checked_by is not None 

2668 

2669 def is_complete(self) -> bool: 

2670 """Return True if this shipment has already been completed.""" 

2671 return self.shipment_date is not None 

2672 

2673 def is_delivered(self) -> bool: 

2674 """Return True if this shipment has already been delivered.""" 

2675 return self.delivery_date is not None 

2676 

2677 def check_can_complete(self, raise_error: bool = True) -> bool: 

2678 """Check if this shipment is able to be completed. 

2679 

2680 Arguments: 

2681 raise_error: If True, raise ValidationError if cannot complete 

2682 """ 

2683 try: 

2684 if self.shipment_date: 

2685 # Shipment has already been sent! 

2686 raise ValidationError(_('Shipment has already been sent')) 

2687 

2688 if self.allocations.count() == 0: 

2689 raise ValidationError(_('Shipment has no allocated stock items')) 

2690 

2691 if ( 

2692 get_global_setting('SALESORDER_SHIPMENT_REQUIRES_CHECK') 

2693 and not self.is_checked() 

2694 ): 

2695 raise ValidationError( 

2696 _('Shipment must be checked before it can be completed') 

2697 ) 

2698 

2699 except ValidationError as e: 

2700 if raise_error: 

2701 raise e 

2702 else: 

2703 return False 

2704 

2705 return True 

2706 

2707 @transaction.atomic 

2708 def complete_shipment(self, user, **kwargs): 

2709 """Complete this particular shipment. 

2710 

2711 Executes: 

2712 1. Update any stock items associated with this shipment 

2713 2. Update the "shipped" quantity of all associated line items 

2714 3. Set the "shipment_date" to now 

2715 

2716 Arguments: 

2717 user: The user who is completing this shipment 

2718 

2719 Returns: 

2720 task_id: The ID of the background task which is processing this shipment 

2721 """ 

2722 import order.tasks 

2723 

2724 # Check if the shipment can be completed (throw error if not) 

2725 self.check_can_complete() 

2726 

2727 if tracking_number := kwargs.get('tracking_number'): 

2728 self.tracking_number = tracking_number 

2729 

2730 if invoice_number := kwargs.get('invoice_number'): 

2731 self.invoice_number = invoice_number 

2732 

2733 if link := kwargs.get('link'): 

2734 self.link = link 

2735 

2736 self.save() 

2737 

2738 # Extract shipment date and delivery date from kwargs (if provided) 

2739 shipment_date = kwargs.get('shipment_date', InvenTree.helpers.current_date()) 

2740 delivery_date = kwargs.get('delivery_date') 

2741 

2742 # Offload the "completion" of each line item to the background worker 

2743 # This may take some time, and we don't want to block the main thread 

2744 task_id = InvenTree.tasks.offload_task( 

2745 order.tasks.complete_sales_order_shipment, 

2746 self.pk, 

2747 user.pk if user else None, 

2748 shipment_date, 

2749 delivery_date=delivery_date, 

2750 group='sales_order', 

2751 ) 

2752 

2753 return task_id 

2754 

2755 

2756class SalesOrderExtraLine(OrderExtraLine): 

2757 """Model for a single ExtraLine in a SalesOrder. 

2758 

2759 Attributes: 

2760 order: Link to the SalesOrder that this line belongs to 

2761 """ 

2762 

2763 class Meta: 

2764 """Model meta options.""" 

2765 

2766 verbose_name = _('Sales Order Extra Line') 

2767 

2768 @staticmethod 

2769 def get_api_url(): 

2770 """Return the API URL associated with the SalesOrderExtraLine model.""" 

2771 return reverse('api-so-extra-line-list') 

2772 

2773 order = models.ForeignKey( 

2774 SalesOrder, 

2775 on_delete=models.CASCADE, 

2776 related_name='extra_lines', 

2777 verbose_name=_('Order'), 

2778 help_text=_('Sales Order'), 

2779 ) 

2780 

2781 

2782class SalesOrderAllocation(models.Model): 

2783 """This model is used to 'allocate' stock items to a SalesOrder. Items that are "allocated" to a SalesOrder are not yet "attached" to the order, but they will be once the order is fulfilled. 

2784 

2785 Attributes: 

2786 line: SalesOrderLineItem reference 

2787 shipment: SalesOrderShipment reference 

2788 item: StockItem reference 

2789 quantity: Quantity to take from the StockItem 

2790 """ 

2791 

2792 class Meta: 

2793 """Model meta options.""" 

2794 

2795 verbose_name = _('Sales Order Allocation') 

2796 

2797 @staticmethod 

2798 def get_api_url(): 

2799 """Return the API URL associated with the SalesOrderAllocation model.""" 

2800 return reverse('api-so-allocation-list') 

2801 

2802 def clean(self): 

2803 """Validate the SalesOrderAllocation object. 

2804 

2805 Executes: 

2806 - Cannot allocate stock to a line item without a part reference 

2807 - The referenced part must match the part associated with the line item 

2808 - Allocated quantity cannot exceed the quantity of the stock item 

2809 - Allocation quantity must be "1" if the StockItem is serialized 

2810 - Allocation quantity cannot be zero 

2811 """ 

2812 super().clean() 

2813 

2814 errors = {} 

2815 

2816 try: 

2817 if not self.item: 

2818 raise ValidationError({'item': _('Stock item has not been assigned')}) 

2819 except stock.models.StockItem.DoesNotExist: 

2820 raise ValidationError({'item': _('Stock item has not been assigned')}) 

2821 

2822 try: 

2823 if self.line.part != self.item.part: 

2824 variants = self.line.part.get_descendants(include_self=True) 

2825 if self.line.part not in variants: 

2826 errors['item'] = _( 

2827 'Cannot allocate stock item to a line with a different part' 

2828 ) 

2829 except PartModels.Part.DoesNotExist: 

2830 errors['line'] = _('Cannot allocate stock to a line without a part') 

2831 

2832 if self.quantity > self.item.quantity: 

2833 errors['quantity'] = _('Allocation quantity cannot exceed stock quantity') 

2834 

2835 # Ensure that we do not 'over allocate' a stock item 

2836 build_allocation_count = self.item.build_allocation_count() 

2837 sales_allocation_count = self.item.sales_order_allocation_count( 

2838 exclude_allocations={'pk': self.pk} 

2839 ) 

2840 

2841 total_allocation = ( 

2842 build_allocation_count + sales_allocation_count + self.quantity 

2843 ) 

2844 

2845 if total_allocation > self.item.quantity: 

2846 errors['quantity'] = _('Stock item is over-allocated') 

2847 

2848 if self.quantity <= 0: 

2849 errors['quantity'] = _('Allocation quantity must be greater than zero') 

2850 

2851 if self.item.serial and self.quantity != 1: 

2852 errors['quantity'] = _('Quantity must be 1 for serialized stock item') 

2853 

2854 if self.shipment and self.line.order != self.shipment.order: 

2855 errors['line'] = _('Sales order does not match shipment') 

2856 errors['shipment'] = _('Shipment does not match sales order') 

2857 

2858 if len(errors) > 0: 

2859 raise ValidationError(errors) 

2860 

2861 line = models.ForeignKey( 

2862 SalesOrderLineItem, 

2863 on_delete=models.CASCADE, 

2864 verbose_name=_('Line'), 

2865 related_name='allocations', 

2866 ) 

2867 

2868 shipment = models.ForeignKey( 

2869 SalesOrderShipment, 

2870 on_delete=models.CASCADE, 

2871 null=True, 

2872 blank=True, 

2873 related_name='allocations', 

2874 verbose_name=_('Shipment'), 

2875 help_text=_('Sales order shipment reference'), 

2876 ) 

2877 

2878 item = models.ForeignKey( 

2879 'stock.StockItem', 

2880 on_delete=models.CASCADE, 

2881 related_name='sales_order_allocations', 

2882 limit_choices_to={ 

2883 'part__salable': True, 

2884 'part__virtual': False, 

2885 'belongs_to': None, 

2886 'sales_order': None, 

2887 }, 

2888 verbose_name=_('Item'), 

2889 help_text=_('Select stock item to allocate'), 

2890 ) 

2891 

2892 quantity = RoundingDecimalField( 

2893 max_digits=15, 

2894 decimal_places=5, 

2895 validators=[MinValueValidator(0)], 

2896 default=1, 

2897 verbose_name=_('Quantity'), 

2898 help_text=_('Enter stock allocation quantity'), 

2899 ) 

2900 

2901 def get_location(self): 

2902 """Return the <pk> value of the location associated with this allocation.""" 

2903 return self.item.location.id if self.item.location else None 

2904 

2905 def get_po(self): 

2906 """Return the PurchaseOrder associated with this allocation.""" 

2907 return self.item.purchase_order 

2908 

2909 def complete_allocation(self, user): 

2910 """Complete this allocation (called when the parent SalesOrder is marked as "shipped"). 

2911 

2912 Executes: 

2913 - Determine if the referenced StockItem needs to be "split" (if allocated quantity != stock quantity) 

2914 - Mark the StockItem as belonging to the Customer (this will remove it from stock) 

2915 """ 

2916 order = self.line.order 

2917 

2918 item = self.item.allocateToCustomer( 

2919 order.customer, quantity=self.quantity, order=order, user=user 

2920 ) 

2921 

2922 # Update the 'shipped' quantity 

2923 # Increment at the database level to prevent lost updates 

2924 self.line.shipped = F('shipped') + self.quantity 

2925 self.line.save(update_fields=['shipped']) 

2926 self.line.refresh_from_db(fields=['shipped']) 

2927 

2928 # Update our own reference to the StockItem 

2929 # (It may have changed if the stock was split) 

2930 self.item = item 

2931 self.save() 

2932 

2933 

2934class ReturnOrder(TotalPriceMixin, Order): 

2935 """A ReturnOrder represents goods returned from a customer, e.g. an RMA or warranty. 

2936 

2937 Attributes: 

2938 customer: Reference to the customer 

2939 sales_order: Reference to an existing SalesOrder (optional) 

2940 status: The status of the order (refer to status_codes.ReturnOrderStatus) 

2941 """ 

2942 

2943 REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN' 

2944 REQUIRE_RESPONSIBLE_SETTING = 'RETURNORDER_REQUIRE_RESPONSIBLE' 

2945 STATUS_CLASS = ReturnOrderStatus 

2946 UNLOCK_SETTING = 'RETURNORDER_EDIT_COMPLETED_ORDERS' 

2947 

2948 class Meta: 

2949 """Model meta options.""" 

2950 

2951 verbose_name = _('Return Order') 

2952 

2953 def clean_line_item(self, line): 

2954 """Clean a line item for this ReturnOrder.""" 

2955 super().clean_line_item(line) 

2956 line.received_date = None 

2957 line.outcome = ReturnOrderLineStatus.PENDING.value 

2958 

2959 def report_context(self) -> ReturnOrderReportContext: 

2960 """Generate report context data for this ReturnOrder.""" 

2961 return_ctx = super().report_context() 

2962 

2963 return_ctx.update({'customer': self.customer}) # ty:ignore[invalid-key] 

2964 return return_ctx 

2965 

2966 def get_absolute_url(self): 

2967 """Get the 'web' URL for this order.""" 

2968 return pui_url(f'/sales/return-order/{self.pk}') 

2969 

2970 @staticmethod 

2971 def get_api_url(): 

2972 """Return the API URL associated with the ReturnOrder model.""" 

2973 return reverse('api-return-order-list') 

2974 

2975 @classmethod 

2976 def get_status_class(cls): 

2977 """Return the ReturnOrderStatus class.""" 

2978 return ReturnOrderStatusGroups 

2979 

2980 @classmethod 

2981 def api_defaults(cls, request=None): 

2982 """Return default values for this model when issuing an API OPTIONS request.""" 

2983 defaults = { 

2984 'reference': order.validators.generate_next_return_order_reference() 

2985 } 

2986 

2987 return defaults 

2988 

2989 @classmethod 

2990 def barcode_model_type_code(cls): 

2991 """Return the associated barcode model type code for this model.""" 

2992 return 'RO' 

2993 

2994 def subscribed_users(self) -> list[User]: 

2995 """Return a list of users subscribed to this ReturnOrder. 

2996 

2997 By this, we mean users to are interested in any of the parts associated with this order. 

2998 """ 

2999 subscribed_users = set() 

3000 

3001 for line in self.lines.all(): 

3002 if line.item and line.item.part: 

3003 # Add the part to the list of subscribed users 

3004 for user in line.item.part.get_subscribers(): 

3005 subscribed_users.add(user) 

3006 

3007 return list(subscribed_users) 

3008 

3009 def __str__(self): 

3010 """Render a string representation of this ReturnOrder.""" 

3011 return f'{self.reference} - {self.customer.name if self.customer else _("no customer")}' 

3012 

3013 reference = models.CharField( 

3014 unique=True, 

3015 max_length=64, 

3016 blank=False, 

3017 verbose_name=_('Reference'), 

3018 help_text=_('Return Order reference'), 

3019 default=order.validators.generate_next_return_order_reference, 

3020 validators=[order.validators.validate_return_order_reference], 

3021 ) 

3022 

3023 customer = models.ForeignKey( 

3024 Company, 

3025 on_delete=models.SET_NULL, 

3026 null=True, 

3027 limit_choices_to={'is_customer': True}, 

3028 related_name='sales_orders', 

3029 verbose_name=_('Customer'), 

3030 help_text=_('Company from which items are being returned'), 

3031 ) 

3032 

3033 @property 

3034 def company(self): 

3035 """Accessor helper for Order base class.""" 

3036 return self.customer 

3037 

3038 status = InvenTreeCustomStatusModelField( 

3039 default=ReturnOrderStatus.PENDING.value, 

3040 choices=ReturnOrderStatus.items(), 

3041 status_class=ReturnOrderStatus, 

3042 verbose_name=_('Status'), 

3043 help_text=_('Return order status'), 

3044 ) 

3045 

3046 customer_reference = models.CharField( 

3047 max_length=64, 

3048 blank=True, 

3049 verbose_name=_('Customer Reference '), 

3050 help_text=_('Customer order reference code'), 

3051 ) 

3052 

3053 complete_date = models.DateField( 

3054 blank=True, 

3055 null=True, 

3056 verbose_name=_('Completion Date'), 

3057 help_text=_('Date order was completed'), 

3058 ) 

3059 

3060 # region state changes 

3061 @property 

3062 def is_pending(self): 

3063 """Return True if this order is pending.""" 

3064 return self.status == ReturnOrderStatus.PENDING 

3065 

3066 @property 

3067 def is_open(self): 

3068 """Return True if this order is outstanding.""" 

3069 return self.status in ReturnOrderStatusGroups.OPEN 

3070 

3071 @property 

3072 def is_received(self): 

3073 """Return True if this order is fully received.""" 

3074 return not self.lines.filter(received_date=None).exists() 

3075 

3076 @property 

3077 def can_hold(self): 

3078 """Return True if this order can be placed on hold.""" 

3079 return self.status in [ 

3080 ReturnOrderStatus.PENDING.value, 

3081 ReturnOrderStatus.IN_PROGRESS.value, 

3082 ] 

3083 

3084 def _action_hold(self, *args, **kwargs): 

3085 """Mark this order as 'on hold' (if allowed).""" 

3086 if self.can_hold: 

3087 self.status = ReturnOrderStatus.ON_HOLD.value 

3088 self.save() 

3089 

3090 trigger_event(ReturnOrderEvents.HOLD, id=self.pk) 

3091 

3092 @property 

3093 def can_cancel(self): 

3094 """Return True if this order can be cancelled.""" 

3095 return self.status in ReturnOrderStatusGroups.OPEN 

3096 

3097 def _action_cancel(self, *args, **kwargs): 

3098 """Cancel this ReturnOrder (if not already cancelled).""" 

3099 if self.can_cancel: 

3100 self.status = ReturnOrderStatus.CANCELLED.value 

3101 self.save() 

3102 

3103 trigger_event(ReturnOrderEvents.CANCELLED, id=self.pk) 

3104 

3105 # Notify users that the order has been canceled 

3106 notify_responsible( 

3107 self, 

3108 ReturnOrder, 

3109 exclude=self.created_by, 

3110 content=InvenTreeNotificationBodies.OrderCanceled, 

3111 extra_users=self.subscribed_users(), 

3112 ) 

3113 

3114 def _action_complete(self, *args, **kwargs): 

3115 """Complete this ReturnOrder (if not already completed).""" 

3116 # Lock this order against concurrent completion, and re-read the status 

3117 # from the database. Without this, two simultaneous completion requests 

3118 # can both observe status=IN_PROGRESS, and each would run the completion 

3119 # side effects (duplicate events and notifications). 

3120 self.status = ReturnOrder.objects.select_for_update().get(pk=self.pk).status 

3121 

3122 if self.status == ReturnOrderStatus.IN_PROGRESS.value: 

3123 self.status = ReturnOrderStatus.COMPLETE.value 

3124 self.complete_date = InvenTree.helpers.current_date() 

3125 self.save() 

3126 

3127 trigger_event(ReturnOrderEvents.COMPLETED, id=self.pk) 

3128 

3129 def place_order(self): 

3130 """Deprecated version of 'issue_order.""" 

3131 self.issue_order() 

3132 

3133 @property 

3134 def can_issue(self): 

3135 """Return True if this order can be issued.""" 

3136 return self.status in [ 

3137 ReturnOrderStatus.PENDING.value, 

3138 ReturnOrderStatus.ON_HOLD.value, 

3139 ] 

3140 

3141 def _action_place(self, *args, **kwargs): 

3142 """Issue this ReturnOrder (if currently pending).""" 

3143 if self.can_issue: 

3144 self.status = ReturnOrderStatus.IN_PROGRESS.value 

3145 self.issue_date = InvenTree.helpers.current_date() 

3146 self.save() 

3147 

3148 trigger_event(ReturnOrderEvents.ISSUED, id=self.pk) 

3149 

3150 # Notify users that the order has been placed 

3151 notify_responsible( 

3152 self, 

3153 ReturnOrder, 

3154 exclude=self.created_by, 

3155 content=InvenTreeNotificationBodies.NewOrder, 

3156 extra_users=self.subscribed_users(), 

3157 ) 

3158 

3159 @transaction.atomic 

3160 def hold_order(self): 

3161 """Attempt to transition to ON_HOLD status.""" 

3162 return self.handle_transition( 

3163 self.status, ReturnOrderStatus.ON_HOLD.value, self, self._action_hold 

3164 ) 

3165 

3166 @transaction.atomic 

3167 def issue_order(self): 

3168 """Attempt to transition to IN_PROGRESS status.""" 

3169 return self.handle_transition( 

3170 self.status, ReturnOrderStatus.IN_PROGRESS.value, self, self._action_place 

3171 ) 

3172 

3173 @transaction.atomic 

3174 def complete_order(self): 

3175 """Attempt to transition to COMPLETE status.""" 

3176 return self.handle_transition( 

3177 self.status, ReturnOrderStatus.COMPLETE.value, self, self._action_complete 

3178 ) 

3179 

3180 @transaction.atomic 

3181 def cancel_order(self): 

3182 """Attempt to transition to CANCELLED status.""" 

3183 return self.handle_transition( 

3184 self.status, ReturnOrderStatus.CANCELLED.value, self, self._action_cancel 

3185 ) 

3186 

3187 # endregion 

3188 

3189 @transaction.atomic 

3190 def receive_line_item(self, line, location, user, **kwargs): 

3191 """Receive a line item against this ReturnOrder. 

3192 

3193 Arguments: 

3194 line: ReturnOrderLineItem to receive 

3195 location: StockLocation to receive the item to 

3196 user: User performing the action 

3197 

3198 Keyword Arguments: 

3199 note: Additional notes to add to the tracking entry 

3200 status: Status to set the StockItem to (default: StockStatus.QUARANTINED) 

3201 

3202 Performs the following actions: 

3203 - Transfers the StockItem to the specified location 

3204 - Marks the StockItem as "quarantined" 

3205 - Adds a tracking entry to the StockItem 

3206 - Removes the 'customer' reference from the StockItem 

3207 """ 

3208 # Lock the line item row against concurrent receipt, and re-read it 

3209 # from the database. Without this, two simultaneous receipt requests 

3210 # can both observe received_date=None, and each would split / process 

3211 # the associated stock item. 

3212 line = ReturnOrderLineItem.objects.select_for_update().get(pk=line.pk) 

3213 

3214 # Prevent an item from being "received" multiple times 

3215 if line.received_date is not None: 

3216 logger.warning('receive_line_item called with item already returned') 

3217 return 

3218 

3219 stock_item = line.item 

3220 

3221 if not stock_item.serialized and line.quantity < stock_item.quantity: 

3222 # Split the stock item if we are returning less than the full quantity 

3223 stock_item = stock_item.splitStock(line.quantity, user=user) 

3224 

3225 # Update the line item to point to the *new* stock item 

3226 line.item = stock_item 

3227 line.save() 

3228 

3229 status = kwargs.get('status', StockStatus.QUARANTINED.value) 

3230 

3231 if status is None: 

3232 status = StockStatus.QUARANTINED.value 

3233 

3234 deltas = { 

3235 'status': status, 

3236 'returnorder': self.pk, 

3237 'location': location.pk, 

3238 'quantity': float(line.quantity), 

3239 } 

3240 

3241 if stock_item.customer: 

3242 deltas['customer'] = stock_item.customer.pk 

3243 

3244 # Update the StockItem 

3245 stock_item.set_status(status) 

3246 stock_item.location = location 

3247 stock_item.customer = None 

3248 stock_item.sales_order = None 

3249 stock_item.save(add_note=False) 

3250 stock_item.clearAllocations() 

3251 

3252 # Add a tracking entry to the StockItem 

3253 stock_item.add_tracking_entry( 

3254 StockHistoryCode.RETURNED_AGAINST_RETURN_ORDER, 

3255 user, 

3256 notes=kwargs.get('note', ''), 

3257 deltas=deltas, 

3258 location=location, 

3259 returnorder=self, 

3260 ) 

3261 

3262 # Update the LineItem 

3263 line.received_date = InvenTree.helpers.current_date() 

3264 line.save() 

3265 

3266 trigger_event(ReturnOrderEvents.RECEIVED, id=self.pk, line_item_id=line.pk) 

3267 

3268 # Notify responsible users 

3269 notify_responsible( 

3270 self, 

3271 ReturnOrder, 

3272 exclude=user, 

3273 content=InvenTreeNotificationBodies.ReturnOrderItemsReceived, 

3274 extra_users=line.item.part.get_subscribers(), 

3275 ) 

3276 

3277 

3278class ReturnOrderLineItem(StatusCodeMixin, OrderLineItem): 

3279 """Model for a single LineItem in a ReturnOrder.""" 

3280 

3281 STATUS_CLASS = ReturnOrderLineStatus 

3282 STATUS_FIELD = 'outcome' 

3283 

3284 class Meta: 

3285 """Metaclass options for this model.""" 

3286 

3287 verbose_name = _('Return Order Line Item') 

3288 unique_together = [('order', 'item')] 

3289 

3290 @staticmethod 

3291 def get_api_url(): 

3292 """Return the API URL associated with this model.""" 

3293 return reverse('api-return-order-line-list') 

3294 

3295 def clean(self): 

3296 """Perform extra validation steps for the ReturnOrderLineItem model.""" 

3297 super().clean() 

3298 

3299 if not self.item: 

3300 raise ValidationError({'item': _('Stock item must be specified')}) 

3301 

3302 if self.quantity > self.item.quantity: 

3303 raise ValidationError({ 

3304 'quantity': _('Return quantity exceeds stock quantity') 

3305 }) 

3306 

3307 if self.quantity <= 0: 

3308 raise ValidationError({ 

3309 'quantity': _('Return quantity must be greater than zero') 

3310 }) 

3311 

3312 if self.item.serialized and self.quantity != 1: 

3313 raise ValidationError({ 

3314 'quantity': _('Invalid quantity for serialized stock item') 

3315 }) 

3316 

3317 order = models.ForeignKey( 

3318 ReturnOrder, 

3319 on_delete=models.CASCADE, 

3320 related_name='lines', 

3321 verbose_name=_('Order'), 

3322 help_text=_('Return Order'), 

3323 ) 

3324 

3325 item = models.ForeignKey( 

3326 stock.models.StockItem, 

3327 on_delete=models.CASCADE, 

3328 related_name='return_order_lines', 

3329 verbose_name=_('Item'), 

3330 help_text=_('Select item to return from customer'), 

3331 ) 

3332 

3333 quantity = models.DecimalField( 

3334 verbose_name=('Quantity'), 

3335 help_text=('Quantity to return'), 

3336 max_digits=15, 

3337 decimal_places=5, 

3338 validators=[MinValueValidator(0)], 

3339 default=1, 

3340 ) 

3341 

3342 received_date = models.DateField( 

3343 null=True, 

3344 blank=True, 

3345 verbose_name=_('Received Date'), 

3346 help_text=_('The date this return item was received'), 

3347 ) 

3348 

3349 @property 

3350 def received(self): 

3351 """Return True if this item has been received.""" 

3352 return self.received_date is not None 

3353 

3354 outcome = InvenTreeCustomStatusModelField( 

3355 default=ReturnOrderLineStatus.PENDING.value, 

3356 choices=ReturnOrderLineStatus.items(), 

3357 status_class=ReturnOrderLineStatus, 

3358 verbose_name=_('Outcome'), 

3359 help_text=_('Outcome for this line item'), 

3360 ) 

3361 

3362 price = InvenTreeModelMoneyField( 

3363 null=True, 

3364 blank=True, 

3365 verbose_name=_('Price'), 

3366 help_text=_('Cost associated with return or repair for this line item'), 

3367 ) 

3368 

3369 

3370class ReturnOrderExtraLine(OrderExtraLine): 

3371 """Model for a single ExtraLine in a ReturnOrder.""" 

3372 

3373 class Meta: 

3374 """Metaclass options for this model.""" 

3375 

3376 verbose_name = _('Return Order Extra Line') 

3377 

3378 @staticmethod 

3379 def get_api_url(): 

3380 """Return the API URL associated with the ReturnOrderExtraLine model.""" 

3381 return reverse('api-return-order-extra-line-list') 

3382 

3383 order = models.ForeignKey( 

3384 ReturnOrder, 

3385 on_delete=models.CASCADE, 

3386 related_name='extra_lines', 

3387 verbose_name=_('Order'), 

3388 help_text=_('Return Order'), 

3389 ) 

3390 

3391 

3392class TransferOrder(Order): 

3393 """A Transfer Order represents a request to transfer stock from one location to another. It provides a place to queue and review changes before execution. 

3394 

3395 Attributes: 

3396 take_from: The stock location to source items from (or null to ) 

3397 destination: The stock location to move items to 

3398 consume: Rather than move the stock, "consume" it. Helpful if you want to queue up removing stock from inventory 

3399 """ 

3400 

3401 # Global setting for specifying reference pattern 

3402 REFERENCE_PATTERN_SETTING = 'TRANSFERORDER_REFERENCE_PATTERN' 

3403 REQUIRE_RESPONSIBLE_SETTING = 'TRANSFERORDER_REQUIRE_RESPONSIBLE' 

3404 STATUS_CLASS = TransferOrderStatus 

3405 # UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS' 

3406 

3407 class Meta: 

3408 """Model meta options.""" 

3409 

3410 verbose_name = _('Transfer Order') 

3411 

3412 def report_context(self) -> TransferOrderReportContext: 

3413 """Return report context data for this TransferOrder.""" 

3414 return { 

3415 **super().report_context(), 

3416 'take_from': self.take_from, 

3417 'destination': self.destination, 

3418 'consume': self.consume, 

3419 } 

3420 

3421 def get_absolute_url(self) -> str: 

3422 """Get the 'web' URL for this order.""" 

3423 return pui_url(f'/stock/transfer-order/{self.pk}') 

3424 

3425 @staticmethod 

3426 def get_api_url() -> str: 

3427 """Return the API URL associated with the TransferOrder model.""" 

3428 return reverse('api-transfer-order-list') 

3429 

3430 @classmethod 

3431 def get_status_class(cls): 

3432 """Return the TransferOrderStatus class.""" 

3433 return TransferOrderStatusGroups 

3434 

3435 @classmethod 

3436 def api_defaults(cls, request=None): 

3437 """Return default values for this model when issuing an API OPTIONS request.""" 

3438 defaults = { 

3439 'reference': order.validators.generate_next_transfer_order_reference() 

3440 } 

3441 

3442 return defaults 

3443 

3444 @classmethod 

3445 def barcode_model_type_code(cls): 

3446 """Return the associated barcode model type code for this model.""" 

3447 return 'TO' 

3448 

3449 def subscribed_users(self) -> list[User]: 

3450 """Return a list of users subscribed to this TransferOrder. 

3451 

3452 By this, we mean users to are interested in any of the parts associated with this order. 

3453 """ 

3454 subscribed_users = set() 

3455 

3456 for line in self.lines.all(): 3456 ↛ 3457line 3456 didn't jump to line 3457 because the loop on line 3456 never started

3457 if line.part: 

3458 # Add the part to the list of subscribed users 

3459 for user in line.part.get_subscribers(): 

3460 subscribed_users.add(user) 

3461 

3462 return list(subscribed_users) 

3463 

3464 def clean_line_item(self, line): 

3465 """Clean a line item for this PurchaseOrder.""" 

3466 super().clean_line_item(line) 

3467 line.transferred = 0 

3468 

3469 def __str__(self): 

3470 """Render a string representation of this TransferOrder.""" 

3471 return f'{self.reference} - {self.take_from.name if self.take_from else _("deleted")} --> {self.destination.name if self.destination else _("deleted")}' 

3472 

3473 reference = models.CharField( 

3474 unique=True, 

3475 max_length=64, 

3476 blank=False, 

3477 help_text=_('Transfer Order Reference'), 

3478 verbose_name=_('Reference'), 

3479 default=order.validators.generate_next_transfer_order_reference, 

3480 validators=[order.validators.validate_transfer_order_reference], 

3481 ) 

3482 

3483 status = InvenTreeCustomStatusModelField( 

3484 default=TransferOrderStatus.PENDING.value, 

3485 choices=TransferOrderStatus.items(), 

3486 status_class=TransferOrderStatus, 

3487 verbose_name=_('Status'), 

3488 help_text=_('Transfer order status'), 

3489 ) 

3490 

3491 @property 

3492 def status_text(self): 

3493 """Return the text representation of the status field.""" 

3494 return TransferOrderStatus.text(self.status) 

3495 

3496 take_from = models.ForeignKey( 

3497 'stock.StockLocation', 

3498 verbose_name=_('Source Location'), 

3499 on_delete=models.SET_NULL, 

3500 related_name='sourcing_transfers', 

3501 blank=True, 

3502 null=True, 

3503 help_text=_('Source for transferred items'), 

3504 ) 

3505 

3506 destination = models.ForeignKey( 

3507 'stock.StockLocation', 

3508 verbose_name=_('Destination Location'), 

3509 on_delete=models.SET_NULL, 

3510 related_name='incoming_transfers', 

3511 blank=True, 

3512 null=True, 

3513 help_text=_('Destination for transferred items'), 

3514 ) 

3515 

3516 consume = models.BooleanField( 

3517 default=False, 

3518 verbose_name=_('Consume Stock'), 

3519 help_text=_( 

3520 'Rather than transfer the stock to the destination, "consume" it, by removing transferred quantity from the allocated stock item' 

3521 ), 

3522 ) 

3523 

3524 complete_date = models.DateField( 

3525 blank=True, 

3526 null=True, 

3527 verbose_name=_('Completion Date'), 

3528 help_text=_('Date order was completed'), 

3529 ) 

3530 

3531 @property 

3532 def company(self) -> None: 

3533 """Required accessor helper for Order base class.""" 

3534 return None 

3535 

3536 @property 

3537 def is_pending(self) -> bool: 

3538 """Return True if the TransferOrder is 'pending'.""" 

3539 return self.status == TransferOrderStatus.PENDING.value 

3540 

3541 @property 

3542 def is_open(self) -> bool: 

3543 """Return True if the TransferOrder is 'open'.""" 

3544 return self.status in TransferOrderStatusGroups.OPEN 

3545 

3546 @property 

3547 def stock_allocations(self) -> QuerySet: 

3548 """Return a queryset containing all allocations for this order.""" 

3549 return TransferOrderAllocation.objects.filter( 

3550 line__in=[line.pk for line in self.lines.all()] 

3551 ) 

3552 

3553 def is_fully_allocated(self) -> bool: 

3554 """Return True if all line items are fully allocated.""" 

3555 return all(line.is_fully_allocated() for line in self.lines.all()) 

3556 

3557 def is_overallocated(self) -> bool: 

3558 """Return true if any lines in the order are over-allocated.""" 

3559 return any(line.is_overallocated() for line in self.lines.all()) 

3560 

3561 def is_completed(self) -> bool: 

3562 """Check if this order is "transferred" (all line items transferred).""" 

3563 return all(line.is_completed() for line in self.lines.all()) 

3564 

3565 def can_complete( 

3566 self, raise_error: bool = False, allow_incomplete_lines: bool = False 

3567 ) -> bool: 

3568 """Test if this TransferOrder can be completed.""" 

3569 try: 

3570 if self.status == TransferOrderStatus.COMPLETE.value: 3570 ↛ 3571line 3570 didn't jump to line 3571 because the condition on line 3570 was never true

3571 raise ValidationError(_('Order is already complete')) 

3572 

3573 if self.status == TransferOrderStatus.CANCELLED.value: 3573 ↛ 3576line 3573 didn't jump to line 3576 because the condition on line 3573 was always true

3574 raise ValidationError(_('Order is already cancelled')) 

3575 

3576 if not self.consume and not self.destination: 

3577 raise ValidationError( 

3578 _('Order cannot be completed until a destination location is set') 

3579 ) 

3580 

3581 if not (self.is_fully_allocated() or allow_incomplete_lines): 

3582 raise ValidationError( 

3583 _('Order cannot be completed until it is fully allocated') 

3584 ) 

3585 except ValidationError as e: 

3586 if raise_error: 3586 ↛ 3589line 3586 didn't jump to line 3589 because the condition on line 3586 was always true

3587 raise e 

3588 else: 

3589 return False 

3590 

3591 return True 

3592 

3593 @property 

3594 def can_issue(self) -> bool: 

3595 """Return True if this order can be issued.""" 

3596 return self.status in [ 

3597 TransferOrderStatus.PENDING.value, 

3598 TransferOrderStatus.ON_HOLD.value, 

3599 ] 

3600 

3601 @transaction.atomic 

3602 def issue_order(self): 

3603 """Attempt to transition to PLACED status.""" 

3604 return self.handle_transition( 

3605 self.status, TransferOrderStatus.ISSUED.value, self, self._action_issue 

3606 ) 

3607 

3608 # region state changes 

3609 def _action_issue(self, *args, **kwargs): 

3610 """Marks the TransferOrder as ISSUED. 

3611 

3612 Order must be currently PENDING. 

3613 """ 

3614 if self.can_issue: 3614 ↛ 3615line 3614 didn't jump to line 3615 because the condition on line 3614 was never true

3615 self.status = TransferOrderStatus.ISSUED.value 

3616 self.issue_date = InvenTree.helpers.current_date() 

3617 self.save() 

3618 

3619 trigger_event(TransferOrderEvents.ISSUED, id=self.pk) 

3620 

3621 # Notify users that the order has been issued 

3622 notify_responsible( 

3623 self, 

3624 TransferOrder, 

3625 exclude=self.created_by, 

3626 content=InvenTreeNotificationBodies.NewOrder, 

3627 extra_users=self.subscribed_users(), 

3628 ) 

3629 

3630 @property 

3631 def can_hold(self) -> bool: 

3632 """Return True if this order can be placed on hold.""" 

3633 return self.status in [ 

3634 TransferOrderStatus.PENDING.value, 

3635 TransferOrderStatus.ISSUED.value, 

3636 ] 

3637 

3638 def _action_hold(self, *args, **kwargs): 

3639 """Mark this transfer order as 'on hold'.""" 

3640 if self.can_hold: 3640 ↛ 3641line 3640 didn't jump to line 3641 because the condition on line 3640 was never true

3641 self.status = TransferOrderStatus.ON_HOLD.value 

3642 self.save() 

3643 

3644 trigger_event(TransferOrderEvents.HOLD, id=self.pk) 

3645 

3646 @transaction.atomic 

3647 def _action_complete(self, *args, **kwargs): 

3648 """Marks the TransferOrder as COMPLETE. 

3649 

3650 Order must be currently ISSUED. 

3651 """ 

3652 user = kwargs.pop('user', None) 

3653 

3654 # Lock this order against concurrent completion, and re-read the status 

3655 # from the database. Without this, two simultaneous completion requests 

3656 # can both observe status=ISSUED, and each would process every allocation 

3657 # (duplicating all associated stock operations). 

3658 self.status = TransferOrder.objects.select_for_update().get(pk=self.pk).status 

3659 

3660 if not self.can_complete(raise_error=True, **kwargs): 

3661 return False 

3662 

3663 if self.status == TransferOrderStatus.ISSUED: 

3664 for allocation in self.allocations(): 

3665 # execute each transfer 

3666 allocation.complete_allocation(user) 

3667 

3668 self.status = TransferOrderStatus.COMPLETE.value 

3669 self.complete_date = InvenTree.helpers.current_date() 

3670 

3671 self.save() 

3672 

3673 trigger_event(TransferOrderEvents.COMPLETED, id=self.pk) 

3674 

3675 return True 

3676 

3677 @transaction.atomic 

3678 def complete_order(self, user, **kwargs): 

3679 """Attempt to transition to COMPLETE status.""" 

3680 return self.handle_transition( 

3681 self.status, 

3682 TransferOrderStatus.COMPLETE.value, 

3683 self, 

3684 self._action_complete, 

3685 user=user, 

3686 **kwargs, 

3687 ) 

3688 

3689 @transaction.atomic 

3690 def hold_order(self): 

3691 """Attempt to transition to ON_HOLD status.""" 

3692 return self.handle_transition( 

3693 self.status, TransferOrderStatus.ON_HOLD.value, self, self._action_hold 

3694 ) 

3695 

3696 @transaction.atomic 

3697 def cancel_order(self): 

3698 """Attempt to transition to CANCELLED status.""" 

3699 return self.handle_transition( 

3700 self.status, TransferOrderStatus.CANCELLED.value, self, self._action_cancel 

3701 ) 

3702 

3703 @property 

3704 def can_cancel(self) -> bool: 

3705 """A TransferOrder can only be cancelled under the following circumstances. 

3706 

3707 - Status is ISSUED 

3708 - Status is PENDING (or ON_HOLD) 

3709 """ 

3710 return self.status in TransferOrderStatusGroups.OPEN 

3711 

3712 def _action_cancel(self, *args, **kwargs): 

3713 """Cancel this TransferOrder (only if we're allowed to). 

3714 

3715 Executes: 

3716 - Mark the order as 'cancelled' 

3717 - Delete any StockItems which have been allocated 

3718 """ 

3719 if not self.can_cancel: 3719 ↛ 3720line 3719 didn't jump to line 3720 because the condition on line 3719 was never true

3720 return False 

3721 

3722 self.status = TransferOrderStatus.CANCELLED.value 

3723 self.save() 

3724 

3725 # delete allocations 

3726 for line in self.lines.all(): 3726 ↛ 3727line 3726 didn't jump to line 3727 because the loop on line 3726 never started

3727 for allocation in line.allocations.all(): 

3728 allocation.delete() 

3729 

3730 trigger_event(TransferOrderEvents.CANCELLED, id=self.pk) 

3731 

3732 # Notify users that the order has been canceled 

3733 notify_responsible( 

3734 self, 

3735 TransferOrder, 

3736 exclude=self.created_by, 

3737 content=InvenTreeNotificationBodies.OrderCanceled, 

3738 extra_users=self.subscribed_users(), 

3739 ) 

3740 

3741 # endregion 

3742 

3743 @property 

3744 def line_count(self) -> int: 

3745 """Return the total number of lines associated with this order.""" 

3746 return self.lines.count() 

3747 

3748 def completed_line_items(self) -> QuerySet: 

3749 """Return a queryset of the completed line items for this order.""" 

3750 return self.lines.filter(transferred__gte=F('quantity')) 

3751 

3752 def pending_line_items(self) -> QuerySet: 

3753 """Return a queryset of the pending line items for this order.""" 

3754 return self.lines.filter(transferred__lt=F('quantity')) 

3755 

3756 @property 

3757 def completed_line_count(self) -> int: 

3758 """Return the number of completed lines for this order.""" 

3759 return self.completed_line_items().count() 

3760 

3761 @property 

3762 def pending_line_count(self) -> int: 

3763 """Return the number of pending (incomplete) lines associated with this order.""" 

3764 return self.pending_line_items().count() 

3765 

3766 def allocations(self) -> QuerySet: 

3767 """Return a queryset of all allocations for this order.""" 

3768 return TransferOrderAllocation.objects.filter(line__order=self) 

3769 

3770 

3771class TransferOrderLineItem(OrderLineItem): 

3772 """Model for a single LineItem in a TransferOrder. 

3773 

3774 Attributes: 

3775 order: Link to the TransferOrder that this line item belongs to 

3776 part: Link to a Part object (may be null) 

3777 transferred: The number of items which have actually transferred against this line item 

3778 """ 

3779 

3780 class Meta: 

3781 """Model meta options.""" 

3782 

3783 verbose_name = _('Transfer Order Line Item') 

3784 

3785 # Filter for determining if a particular TransferOrderLineItem is overdue 

3786 OVERDUE_FILTER = ( 

3787 Q(transferred__lt=F('quantity')) 

3788 & ~Q(target_date=None) 

3789 & Q(target_date__lt=InvenTree.helpers.current_date()) 

3790 ) 

3791 

3792 @staticmethod 

3793 def get_api_url(): 

3794 """Return the API URL associated with the TransferOrderLineItem model.""" 

3795 return reverse('api-transfer-order-line-list') 

3796 

3797 order = models.ForeignKey( 

3798 TransferOrder, 

3799 on_delete=models.CASCADE, 

3800 related_name='lines', 

3801 verbose_name=_('Order'), 

3802 help_text=_('Transfer Order'), 

3803 ) 

3804 

3805 part = models.ForeignKey( 

3806 'part.Part', 

3807 on_delete=models.SET_NULL, 

3808 related_name='transfer_order_line_items', 

3809 null=True, 

3810 verbose_name=_('Part'), 

3811 help_text=_('Part'), 

3812 # limit_choices_to={'salable': True}, 

3813 ) 

3814 

3815 transferred = RoundingDecimalField( 

3816 verbose_name=_('transferred'), 

3817 help_text=_('transferred quantity'), 

3818 default=0, 

3819 max_digits=15, 

3820 decimal_places=5, 

3821 validators=[MinValueValidator(0)], 

3822 ) 

3823 

3824 def allocated_quantity(self): 

3825 """Return the total stock quantity allocated to this LineItem. 

3826 

3827 This is a summation of the quantity of each attached StockItem 

3828 """ 

3829 if not self.pk: 

3830 return 0 

3831 

3832 query = self.allocations.aggregate( 

3833 allocated=Coalesce(Sum('quantity'), Decimal(0)) 

3834 ) 

3835 

3836 return query['allocated'] 

3837 

3838 def is_fully_allocated(self) -> bool: 

3839 """Return True if this line item is fully allocated.""" 

3840 # If the linked part is "virtual", then we cannot allocate stock against it 

3841 if self.part and self.part.virtual: 

3842 return True 

3843 

3844 return self.allocated_quantity() >= self.quantity 

3845 

3846 def is_overallocated(self) -> bool: 

3847 """Return True if this line item is over allocated.""" 

3848 return self.allocated_quantity() > self.quantity 

3849 

3850 def is_completed(self) -> bool: 

3851 """Return True if this line item is completed (has been fully shipped).""" 

3852 # A "virtual" part is always considered to be "completed" 

3853 if self.part and self.part.virtual: 

3854 return True 

3855 

3856 return self.transferred >= self.quantity 

3857 

3858 

3859class TransferOrderAllocation(models.Model): 

3860 """This model is used to 'allocate' stock items to a TransferOrder. Items that are "allocated" to a TransferOrder are not yet "attached" to the order, but they will be once the order is fulfilled. 

3861 

3862 Attributes: 

3863 line: TransferOrderLineItem reference 

3864 item: StockItem reference 

3865 quantity: Quantity to take from the StockItem 

3866 """ 

3867 

3868 class Meta: 

3869 """Model meta options.""" 

3870 

3871 verbose_name = _('Transfer Order Allocation') 

3872 

3873 @staticmethod 

3874 def get_api_url(): 

3875 """Return the API URL associated with the TransferOrderAllocation model.""" 

3876 return reverse('api-transfer-order-allocation-list') 

3877 

3878 def clean(self): 

3879 """Validate the TransferOrderAllocation object. 

3880 

3881 Executes: 

3882 - Cannot allocate stock to a line item without a part reference 

3883 - The referenced part must match the part associated with the line item 

3884 - Allocated quantity cannot exceed the quantity of the stock item 

3885 - Allocation quantity must be "1" if the StockItem is serialized 

3886 - Allocation quantity cannot be zero 

3887 """ 

3888 super().clean() 

3889 

3890 errors = {} 

3891 

3892 try: 

3893 if not self.item: 

3894 raise ValidationError({'item': _('Stock item has not been assigned')}) 

3895 except stock.models.StockItem.DoesNotExist: 

3896 raise ValidationError({'item': _('Stock item has not been assigned')}) 

3897 

3898 try: 

3899 if self.line.part != self.item.part: 

3900 variants = self.line.part.get_descendants(include_self=True) 

3901 if self.line.part not in variants: 

3902 errors['item'] = _( 

3903 'Cannot allocate stock item to a line with a different part' 

3904 ) 

3905 except PartModels.Part.DoesNotExist: 

3906 errors['line'] = _('Cannot allocate stock to a line without a part') 

3907 

3908 if self.quantity > self.item.quantity: 

3909 errors['quantity'] = _('Allocation quantity cannot exceed stock quantity') 

3910 

3911 # Ensure that we do not 'over allocate' a stock item 

3912 build_allocation_count = self.item.build_allocation_count() 

3913 sales_allocation_count = self.item.sales_order_allocation_count( 

3914 exclude_allocations={'pk': self.pk} 

3915 ) 

3916 

3917 total_allocation = ( 

3918 build_allocation_count + sales_allocation_count + self.quantity 

3919 ) 

3920 

3921 if total_allocation > self.item.quantity: 

3922 errors['quantity'] = _('Stock item is over-allocated') 

3923 

3924 if self.quantity <= 0: 

3925 errors['quantity'] = _('Allocation quantity must be greater than zero') 

3926 

3927 if self.item.serial and self.quantity != 1: 

3928 errors['quantity'] = _('Quantity must be 1 for serialized stock item') 

3929 

3930 if len(errors) > 0: 

3931 raise ValidationError(errors) 

3932 

3933 line = models.ForeignKey( 

3934 TransferOrderLineItem, 

3935 on_delete=models.CASCADE, 

3936 verbose_name=_('Line'), 

3937 related_name='allocations', 

3938 ) 

3939 

3940 item = models.ForeignKey( 

3941 'stock.StockItem', 

3942 on_delete=models.CASCADE, 

3943 related_name='transfer_order_allocations', 

3944 limit_choices_to={ 

3945 'part__virtual': False, 

3946 'belongs_to': None, 

3947 'sales_order': None, 

3948 }, 

3949 verbose_name=_('Item'), 

3950 help_text=_('Select stock item to allocate'), 

3951 ) 

3952 

3953 quantity = RoundingDecimalField( 

3954 max_digits=15, 

3955 decimal_places=5, 

3956 validators=[MinValueValidator(0)], 

3957 default=1, 

3958 verbose_name=_('Quantity'), 

3959 help_text=_('Enter stock allocation quantity'), 

3960 ) 

3961 

3962 def get_location(self): 

3963 """Return the <pk> value of the location associated with this allocation.""" 

3964 return self.item.location.id if self.item.location else None 

3965 

3966 def get_po(self): 

3967 """Return the PurchaseOrder associated with this allocation.""" 

3968 return self.item.purchase_order 

3969 

3970 def complete_allocation(self, user): 

3971 """Complete this allocation (called when the parent TransferOrder is marked as "completed"). 

3972 

3973 Executes: 

3974 - Determine if the referenced StockItem needs to be "split" (if allocated quantity != stock quantity) 

3975 - Move the StockItem to the new location 

3976 - Updates the transferred qty 

3977 - If order is marked as "consume", reduce quantity rather than move 

3978 

3979 Raises: 

3980 ValidationError: If the stock operation fails - the 'transferred' quantity 

3981 is only updated once the stock has actually been moved / consumed. 

3982 """ 

3983 order: TransferOrder = self.line.order 

3984 self.item: stock.models.StockItem # for type hints 

3985 self.line: TransferOrderLineItem # for type hints 

3986 

3987 # The allocation is the only thing linking this stock item to the transfer 

3988 # As a result, we must keep the allocation present even after completion 

3989 # This means allocations to transfer orders don't affect "available" stock 

3990 # (otherwise it would permanently reduce available stock) 

3991 

3992 # The stock item may have been reduced since the allocation was made, 

3993 # so limit the transfer to the quantity which is actually available 

3994 transfer_quantity = min(self.quantity, self.item.quantity) 

3995 

3996 if transfer_quantity <= 0: 

3997 # Nothing available to transfer (e.g. the item has since been depleted) 

3998 return 

3999 

4000 if order.consume: 

4001 # rather than transferring the stock, we simply reduce its quantity to release it from tracked inventory 

4002 # NOTE: if delete_on_deplete is enabled, this will result in the "transferred stock" panel being empty 

4003 # after completion. A more sophisticated immutable tracking that doesn't rely on allocations 

4004 # would be helpful here 

4005 if not self.item.take_stock( 

4006 quantity=transfer_quantity, 

4007 user=user, 

4008 code=StockHistoryCode.STOCK_REMOVE, 

4009 transferorder=order, 

4010 ): 

4011 raise ValidationError( 

4012 _('Failed to consume stock item against transfer order') 

4013 ) 

4014 elif transfer_quantity < self.item.quantity: 

4015 new_item = self.item.splitStock( 

4016 quantity=transfer_quantity, 

4017 location=order.destination, 

4018 user=user, 

4019 transferorder=order, 

4020 ) 

4021 

4022 if new_item is None: 

4023 raise ValidationError( 

4024 _('Failed to transfer stock item against transfer order') 

4025 ) 

4026 

4027 # update our own reference to the StockItem which was split 

4028 self.item = new_item 

4029 self.save() 

4030 else: 

4031 # move item directly, we don't have to split 

4032 if not self.item.move( 

4033 location=order.destination, user=user, transferorder=order, notes='' 

4034 ): 

4035 raise ValidationError( 

4036 _('Failed to transfer stock item against transfer order') 

4037 ) 

4038 

4039 # Update the transferred qty 

4040 # Note: use the quantity which was *actually* transferred 

4041 # Increment at the database level to prevent lost updates 

4042 self.line.transferred = F('transferred') + transfer_quantity 

4043 self.line.save(update_fields=['transferred']) 

4044 self.line.refresh_from_db(fields=['transferred']) 

4045 

4046 

4047def _touch_order_updated_at(instance): 

4048 """Bump updated_at on the parent order without triggering a full save.""" 

4049 if not InvenTree.ready.canAppAccessDatabase(allow_test=True): 

4050 return 

4051 instance.order.__class__.objects.filter(pk=instance.order_id).update( 

4052 updated_at=InvenTree.helpers.current_time() 

4053 ) 

4054 

4055 

4056@receiver(post_save, sender=PurchaseOrderLineItem, dispatch_uid='po_lineitem_post_save') 

4057@receiver( 

4058 post_delete, sender=PurchaseOrderLineItem, dispatch_uid='po_lineitem_post_delete' 

4059) 

4060@receiver( 

4061 post_save, sender=PurchaseOrderExtraLine, dispatch_uid='po_extraline_post_save' 

4062) 

4063@receiver( 

4064 post_delete, sender=PurchaseOrderExtraLine, dispatch_uid='po_extraline_post_delete' 

4065) 

4066@receiver(post_save, sender=SalesOrderLineItem, dispatch_uid='so_lineitem_post_save') 

4067@receiver( 

4068 post_delete, sender=SalesOrderLineItem, dispatch_uid='so_lineitem_post_delete' 

4069) 

4070@receiver(post_save, sender=SalesOrderExtraLine, dispatch_uid='so_extraline_post_save') 

4071@receiver( 

4072 post_delete, sender=SalesOrderExtraLine, dispatch_uid='so_extraline_post_delete' 

4073) 

4074@receiver(post_save, sender=ReturnOrderLineItem, dispatch_uid='ro_lineitem_post_save') 

4075@receiver( 

4076 post_delete, sender=ReturnOrderLineItem, dispatch_uid='ro_lineitem_post_delete' 

4077) 

4078@receiver(post_save, sender=ReturnOrderExtraLine, dispatch_uid='ro_extraline_post_save') 

4079@receiver( 

4080 post_delete, sender=ReturnOrderExtraLine, dispatch_uid='ro_extraline_post_delete' 

4081) 

4082@receiver( 

4083 post_save, 

4084 sender=TransferOrderLineItem, 

4085 dispatch_uid='transfer_order_lineitem_post_save', 

4086) 

4087@receiver( 

4088 post_delete, 

4089 sender=TransferOrderLineItem, 

4090 dispatch_uid='transfer_order_lineitem_post_delete', 

4091) 

4092def update_order_on_lineitem_change(sender, instance, **kwargs): 

4093 """Update parent order updated_at when any line item is saved or deleted.""" 

4094 _touch_order_updated_at(instance)