from __future__ import annotations from typing import TYPE_CHECKING from django.db import models from django.contrib.auth import get_user_model from flower.common import ModelBase from basic_info import models as basic_models if TYPE_CHECKING: from printing.models import PrintingJob User = get_user_model() class UnitChoices(models.IntegerChoices): """销售品单位""" METER = 1, '米' PIECE = 2, '件' YARD = 3, '码' UNIT = 4, '个' class ShipmentStatus(models.IntegerChoices): """出货单状态""" DRAFT = 1, '草稿(未发布)' PUBLISHED = 2, '已发布' CANCELLED = 3, '已取消' REJECTED = 4, '已驳回' APPROVED = 5, '已审核' class ShipmentDeliveryStatus(models.IntegerChoices): """送货单状态""" PENDING = 1, '待送货' IN_TRANSIT = 2, '送货中' DELIVERED = 3, '已送达' CANCELLED = 4, '已取消' class ExternalFinishedProduct(ModelBase): """ 外部成品表 用于兼容外部遗留系统的数据映射(可被多个出货单引用)。 """ style_name = models.CharField( max_length=200, verbose_name='款式名称', ) num_of_rolls = models.PositiveIntegerField( verbose_name='卷数', ) remark = models.CharField( max_length=200, null=True, blank=True, verbose_name='备注', ) created_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, related_name='created_external_finished_products', verbose_name='创建人', ) shipment = models.ForeignKey( 'Shipment', on_delete=models.CASCADE, null=True, blank=True, related_name='external_finished_products', verbose_name='出货单', help_text='关联的出货单(可空,兼容历史数据/逐步迁移)', ) class Meta: db_table = 'external_finished_product' verbose_name = '外部成品表' verbose_name_plural = '外部成品表' ordering = ['-created_at'] def __str__(self) -> str: return f'{self.style_name} ({self.num_of_rolls}卷)' class Shipment(ModelBase): """ 出货单 记录一次出货操作,包含多个销售品。 """ status = models.IntegerField( choices=ShipmentStatus.choices, default=ShipmentStatus.DRAFT, verbose_name='状态', ) status_modified_at = models.DateTimeField( null=True, blank=True, verbose_name='状态修改时间', ) cancelled_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='cancelled_shipments', verbose_name='取消人', ) approved_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='approved_shipments', verbose_name='审核人', ) delivery = models.ForeignKey( 'ShipmentDelivery', on_delete=models.SET_NULL, null=True, blank=True, related_name='shipments', verbose_name='送货单', ) external_id = models.CharField( max_length=120, null=True, blank=True, verbose_name='外部订单号', help_text='用于兼容外部遗留系统的订单号(可空)', ) merchant = models.ForeignKey( basic_models.Merchant, on_delete=models.PROTECT, related_name='shipments', verbose_name='所属商户', ) customer = models.ForeignKey( basic_models.Customer, on_delete=models.PROTECT, related_name='shipments', verbose_name='客户' ) customer_address = models.ForeignKey( basic_models.CustomerAddress, on_delete=models.SET_NULL, null=True, blank=True, related_name='shipments', verbose_name='客户地址', ) shipment_date = models.DateField( verbose_name='出货日期', help_text='实际出货日期' ) address = models.CharField( max_length=255, blank=True, default='', verbose_name='地址', ) contact_name = models.CharField( max_length=100, blank=True, default='', verbose_name='联系人', ) contact_phone = models.CharField( max_length=50, blank=True, default='', verbose_name='联系电话', ) area = models.CharField( max_length=100, blank=True, default='', verbose_name='地区', help_text='出货地区(可空字符串)', ) coordinates = models.CharField( max_length=100, null=True, blank=True, verbose_name='经纬度', help_text='经纬度字符串,格式由调用方决定', ) remark = models.TextField( blank=True, default='', verbose_name='备注' ) created_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, related_name='created_shipments', verbose_name='创建人' ) geo_coordinates = models.JSONField( null=True, blank=True, verbose_name='地理坐标', help_text='由 Geo 系统返回的坐标信息,结构由调用方决定,后端不作校验', ) extra = models.JSONField( null=True, blank=True, verbose_name='扩展信息', ) rejected_sales_item_ids = models.JSONField( default=list, blank=True, verbose_name='驳回前销售品ID快照', help_text='仅用于审计,记录出货单驳回前所绑定的销售品ID列表', ) class Meta: db_table = 'shipment' verbose_name = '出货单' verbose_name_plural = '出货单' ordering = ['-created_at'] def __str__(self): return f'出货单 #{self.id} - {self.customer.name}' def cancel(self, operator: User | None) -> None: """ 取消出货单(委托给 service 处理状态机与时间记录) Args: operator: 操作者(通常为 request.user,可为空) """ from shipment.services import modify_status modify_status( self, target_status=ShipmentStatus.CANCELLED, operator=operator, ) class SalesItem(ModelBase): """ 销售品 出货单中的具体商品明细。 销售品可以先创建(待分配状态),后续再关联到出货单。 """ shipment = models.ForeignKey( Shipment, on_delete=models.CASCADE, null=True, blank=True, related_name='items', verbose_name='出货单', help_text='为空表示待分配' ) merchant = models.ForeignKey( basic_models.Merchant, on_delete=models.PROTECT, related_name='sales_items', verbose_name='所属商户', ) name = models.CharField( max_length=200, verbose_name='名称' ) quantity = models.DecimalField( max_digits=12, decimal_places=2, verbose_name='数量' ) unit = models.IntegerField( choices=UnitChoices.choices, default=UnitChoices.PIECE, verbose_name='单位' ) created_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, related_name='created_sales_items', verbose_name='创建人' ) # 关联的生产明细订单(可空) # 使用 PositiveIntegerField 而非 ForeignKey,避免模块间强依赖 printing_job_id = models.PositiveIntegerField( null=True, blank=True, verbose_name='关联生产任务ID', help_text='关联的 PrintingJob ID(可空)' ) # 客户ID(可空,用于销售品级别的客户关联) customer_id = models.PositiveIntegerField( null=True, blank=True, verbose_name='客户ID', help_text='销售品级别的客户ID(可空)' ) position = models.CharField( max_length=200, blank=True, default='', verbose_name='货位' ) remark = models.CharField( max_length=200, blank=True, default='', verbose_name='备注' ) delete_at = models.DateTimeField( null=True, blank=True, verbose_name='删除时间', ) delete_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='deleted_sales_items', verbose_name='删除人', ) merge_remark = models.JSONField( null=True, blank=True, verbose_name='合卷备注', help_text='合卷信息JSON,包含 merge_type, jobs, main_job, quantity, unit, job_count', ) class Meta: db_table = 'sales_item' verbose_name = '销售品' verbose_name_plural = '销售品' ordering = ['id'] permissions = [ ('soft_delete_salesitem', 'Can soft delete sales item'), ] def __str__(self): return f'{self.name} x {self.quantity} {self.get_unit_display()}' def get_printing_job(self) -> PrintingJob | None: """ 获取关联的 PrintingJob 实例 Returns: PrintingJob 实例,如果不存在则返回 None """ if not self.printing_job_id: return None try: from printing.models import PrintingJob return PrintingJob.objects.get(id=self.printing_job_id) except PrintingJob.DoesNotExist: return None def get_customer(self) -> basic_models.Customer | None: """ 获取关联的 Customer 实例 Returns: Customer 实例,如果不存在则返回 None """ if not self.customer_id: return None try: return basic_models.Customer.objects.get(id=self.customer_id) except basic_models.Customer.DoesNotExist: return None class SalesItemChangeRecord(ModelBase): """ 销售品修改记录。 记录允许修改字段的变更前后值,以及操作者与操作时间。 """ sales_item = models.ForeignKey( SalesItem, on_delete=models.CASCADE, related_name="change_records", verbose_name="销售品", ) operator = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name="sales_item_change_records", verbose_name="操作者", ) operated_at = models.DateTimeField( verbose_name="操作时间", ) before_values = models.JSONField( default=dict, blank=True, verbose_name="修改前的值", ) after_values = models.JSONField( default=dict, blank=True, verbose_name="修改目标值", ) class Meta: db_table = "sales_item_change_record" verbose_name = "销售品修改记录" verbose_name_plural = "销售品修改记录" ordering = ["-operated_at", "-id"] def __str__(self) -> str: return f"SalesItemChangeRecord #{self.id} - sales_item={self.sales_item_id}" class SalesItemRebuildRecord(ModelBase): """ 销售品重建记录。 用于审计“软删除旧销售品并基于新 printing_job 重建销售品”的动作。 """ old_sales_item = models.ForeignKey( SalesItem, on_delete=models.SET_NULL, null=True, blank=True, related_name="rebuild_source_records", verbose_name="旧销售品", ) new_sales_item = models.ForeignKey( SalesItem, on_delete=models.SET_NULL, null=True, blank=True, related_name="rebuild_target_records", verbose_name="新销售品", ) operator = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name="sales_item_rebuild_records", verbose_name="操作者", ) rebuilt_at = models.DateTimeField( verbose_name="重建时间", ) old_printing_job_id = models.PositiveIntegerField( null=True, blank=True, verbose_name="旧生产任务ID", ) new_printing_job_id = models.PositiveIntegerField( verbose_name="新生产任务ID", ) old_quantity = models.DecimalField( max_digits=12, decimal_places=2, verbose_name="旧数量", ) new_quantity = models.DecimalField( max_digits=12, decimal_places=2, verbose_name="新数量", ) class Meta: db_table = "sales_item_rebuild_record" verbose_name = "销售品重建记录" verbose_name_plural = "销售品重建记录" ordering = ["-rebuilt_at", "-id"] def __str__(self) -> str: return f"SalesItemRebuildRecord #{self.id} - old={self.old_sales_item_id}, new={self.new_sales_item_id}" class ShipmentDelivery(ModelBase): """ 送货单 记录一次具体送货行为,可关联多个出货单。 """ status = models.IntegerField( choices=ShipmentDeliveryStatus.choices, default=ShipmentDeliveryStatus.PENDING, db_index=True, verbose_name='状态', ) merchant = models.ForeignKey( basic_models.Merchant, on_delete=models.PROTECT, related_name='shipment_deliveries', verbose_name='所属商户', ) driver_name = models.CharField( max_length=100, db_index=True, verbose_name='司机名', ) vehicle_trip = models.CharField( max_length=100, db_index=True, verbose_name='车次', ) contact_phone = models.CharField( max_length=50, blank=True, default='', verbose_name='联系电话', ) vehicle_capacity = models.CharField( max_length=100, blank=True, default='', verbose_name='车辆容量', ) remark = models.CharField( max_length=200, blank=True, default='', verbose_name='备注', ) internal_remark = models.CharField( max_length=200, blank=True, default='', verbose_name='内部备注', ) shipment_order_ids = models.JSONField( null=True, blank=True, default=list, verbose_name='出货单顺序ID列表', help_text='前端自管的出货单展示顺序,后端不据此重排 shipments 关系', ) started_at = models.DateTimeField( null=True, blank=True, verbose_name='开始送货时间', ) delivered_at = models.DateTimeField( null=True, blank=True, verbose_name='送达时间', ) cancelled_at = models.DateTimeField( null=True, blank=True, verbose_name='取消时间', ) created_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, related_name='created_shipment_deliveries', verbose_name='创建人', ) operator = models.ForeignKey( basic_models.Employee, on_delete=models.SET_NULL, null=True, blank=True, related_name='operated_shipment_deliveries', verbose_name='操作人', ) cancelled_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='cancelled_shipment_deliveries', verbose_name='取消人', ) class Meta: db_table = 'shipment_delivery' verbose_name = '送货单' verbose_name_plural = '送货单' ordering = ['-created_at', '-id'] class ShipmentDeliveryPhoto(ModelBase): """司机送达照片,按出货单上传并冗余记录所属送货单。""" shipment = models.ForeignKey( Shipment, on_delete=models.CASCADE, related_name='delivery_photos', verbose_name='出货单', ) delivery = models.ForeignKey( ShipmentDelivery, on_delete=models.CASCADE, related_name='photos', verbose_name='送货单', ) photo = models.ImageField( upload_to='shipment_delivery_photos/', verbose_name='送达照片', ) remark = models.CharField( max_length=200, blank=True, default='', verbose_name='备注', ) created_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, related_name='created_shipment_delivery_photos', verbose_name='创建人', ) class Meta: db_table = 'shipment_delivery_photo' verbose_name = '送达照片' verbose_name_plural = '送达照片' ordering = ['-created_at', '-id'] indexes = [ models.Index(fields=['shipment', 'delivery']), ] permissions = [ ('cancel_shipmentdelivery', 'Can cancel shipment delivery'), ] def __str__(self) -> str: return f'送货单 #{self.id} - {self.driver_name} / {self.vehicle_trip}'