forked from erp-dev/erp
612 lines
15 KiB
Python
612 lines
15 KiB
Python
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='客户'
|
||
)
|
||
|
||
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=30,
|
||
blank=True,
|
||
default='',
|
||
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 系统返回的坐标信息,结构由调用方决定,后端不作校验',
|
||
)
|
||
|
||
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='内部备注',
|
||
)
|
||
|
||
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']
|
||
permissions = [
|
||
('cancel_shipmentdelivery', 'Can cancel shipment delivery'),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f'送货单 #{self.id} - {self.driver_name} / {self.vehicle_trip}'
|