from __future__ import annotations from decimal import Decimal from typing import List from django.db import models from django.conf import settings from django.core.exceptions import ValidationError from flower.common import ModelBase from basic_info import models as basic_info_models def build_business_human_id(prefix: str, created_at, object_id) -> str: """生成业务单据的人类可读编号(虚拟编号,不落库)。""" if object_id is None or created_at is None: return '' return f"{prefix}{created_at.year}{created_at.month:02d}{created_at.day:02d}{object_id:06d}" class PurchaseOrderKindEnum(models.IntegerChoices): """采购单类型""" WHOLESALE = 1, '大货' SAMPLE = 2, '样板' class PurchaseOrderStatusEnum(models.IntegerChoices): """采购单状态""" PENDING = 1, '审批中' APPROVED = 2, '审批通过' CANCELLED = 3, '作废' class OrderItemsAggregationMixin: """ 提供订单金额/数量聚合的通用实现。 默认读取 `items` 关联管理器,可在子类通过 `order_items_accessor` 覆盖以适配不同的关联名称。 """ order_items_accessor = 'items' def _iter_order_items(self): return getattr(self, self.order_items_accessor).all() def get_total_amount(self) -> Decimal: total = Decimal('0') for item in self._iter_order_items(): total += Decimal(item.total_amount()) return total def get_total_quantity(self) -> Decimal: total = Decimal('0') for item in self._iter_order_items(): total += Decimal(item.quantity) return total def get_total_diff_quantity(self) -> Decimal: total = Decimal('0') for item in self._iter_order_items(): total += Decimal(item.diff_quantity()) return total class OrderDirectionMixin: """提供带方向金额的通用实现,子类只需要覆盖方向。""" def get_direction(self) -> int: """ 返回订单方向: 入库 / 正向金额: 1 出库 / 负向金额: -1 """ def get_signed_total_amount(self) -> Decimal: return self.get_total_amount() * Decimal(self.get_direction()) class OrderCounterpartyMixin: """ 抽象出“订单关联业务主体”的能力,子类只需指出字段名称, 即可通过统一接口获取 Supplier / Customer 等对象。 """ counterparty_field_name = '' def get_counterparty_field_name(self) -> str: """返回用于存放业务主体的字段名,如 supplier / customer。""" def get_counterparty(self) -> basic_info_models.Supplier | basic_info_models.Customer: field_name = self.get_counterparty_field_name() or self.counterparty_field_name if not field_name: raise AttributeError('未定义 counterparty 字段名称') try: counterparty = getattr(self, field_name) except AttributeError as exc: raise AttributeError(f'找不到字段 {field_name}') from exc if counterparty is None: raise ValueError(f'{field_name} 未设置,无法获取业务主体') return counterparty class PurchaseOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): """采购单模型(业务模块)""" id = models.BigAutoField(primary_key=True) merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, related_name='purchase_orders', verbose_name='所属商户', ) supplier = models.ForeignKey( basic_info_models.Supplier, on_delete=models.PROTECT, related_name='purchase_orders', verbose_name='供应商', ) purchase_date = models.DateField(verbose_name='采购日期') kind = models.IntegerField( choices=PurchaseOrderKindEnum.choices, default=PurchaseOrderKindEnum.WHOLESALE, verbose_name='采购单类型', ) operator = models.ForeignKey( basic_info_models.Employee, on_delete=models.PROTECT, related_name='purchase_orders', verbose_name='经办人', ) warehouse = models.ForeignKey( basic_info_models.WareHouse, on_delete=models.PROTECT, related_name='purchase_orders', verbose_name='仓库', ) status = models.IntegerField( choices=PurchaseOrderStatusEnum.choices, default=PurchaseOrderStatusEnum.PENDING, verbose_name='状态', ) is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲') red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间') balance_before_snapshot = models.DecimalField( max_digits=15, decimal_places=2, null=True, blank=True, verbose_name='创建前供应商余额快照', ) from_pre_purchase_order_id = models.BigIntegerField( null=True, blank=True, verbose_name='关联预采购单ID', ) remarks = models.TextField(blank=True, null=True, verbose_name='备注') def __str__(self): return f'采购订单 {self.id} - {self.supplier.name}' @property def human_id(self) -> str: """ 人类可读编号(虚拟编号,不落库)。 规则与 SalesOrder.human_id 保持一致:CG + YYYYMMDD{6位id} """ return build_business_human_id('CG', self.created_at, self.id) class Meta: verbose_name = '采购单' verbose_name_plural = '采购单' def get_direction(self) -> int: return 1 def get_counterparty_field_name(self) -> str: return 'supplier' class PurchaseOrderItem(ModelBase): """采购单明细模型""" id = models.BigAutoField(primary_key=True) purchase_order = models.ForeignKey( PurchaseOrder, on_delete=models.CASCADE, related_name='items', verbose_name='采购单', ) product = models.ForeignKey( basic_info_models.Product, on_delete=models.PROTECT, related_name='purchase_order_items', verbose_name='产品', ) price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='单价') color = models.CharField(max_length=50, null=True, blank=True, verbose_name='颜色') quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量') unit = models.CharField(max_length=50, verbose_name='单位') spec = models.CharField(max_length=100, null=True, blank=True, verbose_name='规格') empty_diff_percent = models.DecimalField( max_digits=15, decimal_places=2, verbose_name='空差百分比', ) # if purchase_order.warehouse.mode != WarehouseModeEnum.UNRESTRICTED, then quantity_of_rolls is required quantity_of_rolls = models.CharField( max_length=255, null=True, blank=True, verbose_name='各条数数量', ) num_of_rolls = models.PositiveIntegerField(default=1, verbose_name='条数') batch_number = models.CharField(null=True, blank=True, max_length=100, verbose_name='批次号') remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: verbose_name = '采购单明细' verbose_name_plural = '采购单明细' def real_quantity(self): return round(self.quantity * (1 - self.empty_diff_percent / 100), 2) def diff_quantity(self): return round(self.quantity * (self.empty_diff_percent / 100), 2) def total_amount(self): return round(self.price * self.real_quantity(), 2) def split_quantity_of_rolls(self) -> List[int]: if self.quantity_of_rolls: return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()] return [] class SalesOrderKindEnum(models.IntegerChoices): """销售单类型""" WHOLESALE = 1, '大货' SAMPLE = 2, '样板' class SalesOrderStatusEnum(models.IntegerChoices): """销售单状态""" PENDING = 1, '审批中' APPROVED = 2, '审批通过' CANCELLED = 3, '作废' class AllocationRecordStatusEnum(models.IntegerChoices): """配货记录状态""" ACTIVE = 1, '有效' CANCELLED = 2, '已撤销' class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): """销售单模型""" id = models.BigAutoField(primary_key=True) merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, related_name='sales_orders', verbose_name='所属商户', ) customer = models.ForeignKey( basic_info_models.Customer, on_delete=models.PROTECT, related_name='sales_orders', verbose_name='客户', ) sales_date = models.DateField(verbose_name='销售日期') kind = models.IntegerField( choices=SalesOrderKindEnum.choices, default=SalesOrderKindEnum.WHOLESALE, verbose_name='销售单类型', ) operator = models.ForeignKey( basic_info_models.Employee, on_delete=models.PROTECT, related_name='sales_orders', verbose_name='经办人', ) warehouse = models.ForeignKey( basic_info_models.WareHouse, on_delete=models.PROTECT, related_name='sales_orders', verbose_name='仓库', ) status = models.IntegerField( choices=SalesOrderStatusEnum.choices, default=SalesOrderStatusEnum.PENDING, verbose_name='状态', ) is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲') red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间') balance_before_snapshot = models.DecimalField( max_digits=15, decimal_places=2, null=True, blank=True, verbose_name='创建前客户余额快照', ) remarks = models.TextField(blank=True, null=True, verbose_name='备注') from_pre_sales_order_id = models.BigIntegerField( blank=True, null=True, verbose_name='来源预销售单ID', ) def __str__(self): order_id = self.human_id or str(self.id) return f'销售订单 {order_id} - {self.customer.name}' @property def human_id(self) -> str: """ 人类可读编号(虚拟编号,不落库)。 规则:XS + YYYYMMDD{6位id} """ return build_business_human_id('XS', self.created_at, self.id) class Meta: verbose_name = '销售单' verbose_name_plural = '销售单' def get_direction(self) -> int: return -1 def get_counterparty_field_name(self) -> str: return 'customer' class SalesOrderItem(ModelBase): """销售单明细模型""" id = models.BigAutoField(primary_key=True) sales_order = models.ForeignKey( SalesOrder, on_delete=models.CASCADE, related_name='items', verbose_name='销售单', ) product = models.ForeignKey( basic_info_models.Product, on_delete=models.PROTECT, related_name='sales_order_items', verbose_name='产品', ) printing_job = models.ForeignKey( 'printing.PrintingJob', on_delete=models.PROTECT, related_name='sales_order_items', null=True, blank=True, verbose_name='关联印染任务', ) price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='单价') color = models.CharField(max_length=50, null=True, blank=True, verbose_name='颜色') quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量') unit = models.CharField(max_length=50, verbose_name='单位') spec = models.CharField(max_length=100, null=True, blank=True, verbose_name='规格') empty_diff_percent = models.DecimalField( max_digits=15, decimal_places=2, verbose_name='空差百分比', ) quantity_of_rolls = models.TextField( null=True, blank=True, verbose_name='各条数数量', ) num_of_rolls = models.PositiveIntegerField(default=1, verbose_name='条数') consume_detail_ids = models.CharField( max_length=255, null=True, blank=True, verbose_name='消耗入库明细ID列表', ) batch_number = models.CharField(null=True, blank=True, max_length=100, verbose_name='批次号') order_quantity = models.IntegerField(null=True, blank=True, verbose_name='下单数量') remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: verbose_name = '销售单明细' verbose_name_plural = '销售单明细' def split_quantity_of_rolls(self) -> List[int]: if self.quantity_of_rolls: return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()] return [] def real_quantity(self): return round(self.quantity * (1 - self.empty_diff_percent / 100), 2) def diff_quantity(self): return round(self.quantity * (self.empty_diff_percent / 100), 2) def total_amount(self): return round(self.price * self.real_quantity(), 2) class PreSalesOrder(ModelBase): """预销售单 预销售单用于在客户下达采购/要货指令时,提前通知仓库进行库存核对与调货。 目前阶段:仅定义 PreSalesOrder 与 PreSalesOrderItem 的一对多关系, 并关联 customer 与 created_by;其他业务对象(如 Warehouse / StockChangeRecord / StockFreeze 等) 暂不在此模型中建立关联。 """ id = models.BigAutoField(primary_key=True) merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, related_name='pre_sales_orders', verbose_name='所属商户', ) customer = models.ForeignKey( basic_info_models.Customer, on_delete=models.PROTECT, related_name='pre_sales_orders', verbose_name='客户', ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_pre_sales_orders', verbose_name='创建者用户', ) operator = models.ForeignKey( basic_info_models.Employee, on_delete=models.PROTECT, related_name='operated_pre_sales_orders', null=True, blank=True, verbose_name='经办人', ) warehouse = models.ForeignKey( basic_info_models.WareHouse, on_delete=models.PROTECT, related_name='pre_sales_orders', verbose_name='仓库', ) kind = models.IntegerField( choices=SalesOrderKindEnum.choices, default=SalesOrderKindEnum.WHOLESALE, verbose_name='预销售单类型', ) remarks = models.TextField(blank=True, null=True, verbose_name='备注') def __str__(self): return f'预销售单 {self.human_id or self.id}' def clean(self): super().clean() errors = {} if getattr(self, 'merchant_id', None) and getattr(self, 'customer_id', None): if self.customer.merchant_id != self.merchant_id: errors['customer'] = '客户所属商户与预销售单所属商户不一致' if getattr(self, 'merchant_id', None) and getattr(self, 'warehouse_id', None): if self.warehouse.merchant_id != self.merchant_id: errors['warehouse'] = '仓库所属商户与预销售单所属商户不一致' if getattr(self, 'operator_id', None) and getattr(self, 'merchant_id', None): if self.operator.merchant_id != self.merchant_id: errors['operator'] = '经办人所属商户与预销售单所属商户不一致' if errors: raise ValidationError(errors) @property def customer_name(self) -> str: if getattr(self, 'customer_id', None): return self.customer.name return '' @property def merchant_name(self) -> str: if getattr(self, 'merchant_id', None): return self.merchant.name return '' @property def created_by_username(self) -> str: if getattr(self, 'created_by_id', None): return getattr(self.created_by, 'username', '') or '' return '' @property def operator_name(self) -> str: if getattr(self, 'operator_id', None): return self.operator.name return '' @property def warehouse_name(self) -> str: if getattr(self, 'warehouse_id', None): return self.warehouse.name return '' @property def human_id(self) -> str: """人类可读编号(虚拟编号,不落库)。 规则:YS + YYYYMMDD{6位id} """ return build_business_human_id('YS', self.created_at, self.id) class Meta: verbose_name = '预销售单' verbose_name_plural = '预销售单' class PreSalesOrderItem(ModelBase): """预销售单明细""" id = models.BigAutoField(primary_key=True) pre_sales_order = models.ForeignKey( PreSalesOrder, on_delete=models.CASCADE, related_name='items', verbose_name='预销售单', ) # 暂不与 Product 建立外键,先用弱关联字段承载。 product_id = models.BigIntegerField(null=True, blank=True, verbose_name='产品ID') product_name = models.CharField(max_length=100, blank=True, default='', verbose_name='产品名称') color = models.CharField(max_length=50, null=True, blank=True, verbose_name='颜色') quantity = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name='数量') unit = models.CharField(max_length=50, blank=True, default='', verbose_name='单位') spec = models.CharField(max_length=100, null=True, blank=True, verbose_name='规格') quantity_of_rolls = models.TextField( null=True, blank=True, verbose_name='各条数数量', ) num_of_rolls = models.PositiveIntegerField(default=1, verbose_name='条数') order_quantity = models.IntegerField(null=True, blank=True, verbose_name='下单数量') remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: verbose_name = '预销售单明细' verbose_name_plural = '预销售单明细' class AllocationRecord(ModelBase): """配货记录(执行记录)""" id = models.BigAutoField(primary_key=True) pre_sales_order_item = models.ForeignKey( PreSalesOrderItem, on_delete=models.CASCADE, related_name='allocation_records', verbose_name='预销售单明细', ) stock_ids = models.TextField(blank=True, null=True, verbose_name='库存明细ID列表') quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='配货数量') unit = models.CharField(max_length=50, verbose_name='单位') status = models.IntegerField( choices=AllocationRecordStatusEnum.choices, default=AllocationRecordStatusEnum.ACTIVE, verbose_name='状态', ) scanned_by = models.ForeignKey( basic_info_models.Employee, on_delete=models.PROTECT, related_name='allocation_records', verbose_name='扫码人员', ) scanned_at = models.DateTimeField(auto_now_add=True, verbose_name='扫码时间') remarks = models.TextField(blank=True, null=True, verbose_name='备注') def __str__(self): return f'配货记录 {self.id} - 明细 {self.pre_sales_order_item_id}' @property def stock_id_list(self) -> List[int]: raw = (self.stock_ids or '').strip() if not raw: return [] result: List[int] = [] for value in raw.split(','): value = value.strip() if not value: continue try: result.append(int(value)) except ValueError: continue return result class Meta: verbose_name = '配货记录' verbose_name_plural = '配货记录' class PrePurchaseOrder(ModelBase): """预采购单 预采购单用于在向供应商下达正式采购单之前,用于沟通与确认货品、数量、仓库等信息。 当前阶段:仅定义 PrePurchaseOrder 与 PrePurchaseOrderItem 的一对多关系, 并关联 supplier / warehouse / created_by。 """ id = models.BigAutoField(primary_key=True) merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, related_name='pre_purchase_orders', verbose_name='所属商户', ) supplier = models.ForeignKey( basic_info_models.Supplier, on_delete=models.PROTECT, related_name='pre_purchase_orders', verbose_name='供应商', ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_pre_purchase_orders', verbose_name='创建者用户', ) operator = models.ForeignKey( basic_info_models.Employee, on_delete=models.PROTECT, related_name='operated_pre_purchase_orders', null=True, blank=True, verbose_name='经办人', ) warehouse = models.ForeignKey( basic_info_models.WareHouse, on_delete=models.PROTECT, related_name='pre_purchase_orders', verbose_name='仓库', ) kind = models.IntegerField( choices=PurchaseOrderKindEnum.choices, default=PurchaseOrderKindEnum.WHOLESALE, verbose_name='预采购单类型', ) remarks = models.TextField(blank=True, null=True, verbose_name='备注') def __str__(self): return f'预采购单 {self.human_id or self.id}' def clean(self): super().clean() errors = {} if getattr(self, 'merchant_id', None) and getattr(self, 'supplier_id', None): if self.supplier.merchant_id != self.merchant_id: errors['supplier'] = '供应商所属商户与预采购单所属商户不一致' if getattr(self, 'merchant_id', None) and getattr(self, 'warehouse_id', None): if self.warehouse.merchant_id != self.merchant_id: errors['warehouse'] = '仓库所属商户与预采购单所属商户不一致' if getattr(self, 'operator_id', None) and getattr(self, 'merchant_id', None): if self.operator.merchant_id != self.merchant_id: errors['operator'] = '经办人所属商户与预采购单所属商户不一致' if errors: raise ValidationError(errors) @property def supplier_name(self) -> str: if getattr(self, 'supplier_id', None): return self.supplier.name return '' @property def merchant_name(self) -> str: if getattr(self, 'merchant_id', None): return self.merchant.name return '' @property def created_by_username(self) -> str: if getattr(self, 'created_by_id', None): return getattr(self.created_by, 'username', '') or '' return '' @property def operator_name(self) -> str: if getattr(self, 'operator_id', None): return self.operator.name return '' @property def warehouse_name(self) -> str: if getattr(self, 'warehouse_id', None): return self.warehouse.name return '' @property def human_id(self) -> str: """人类可读编号(虚拟编号,不落库)。 规则:YC + YYYYMMDD{6位id} """ return build_business_human_id('YC', self.created_at, self.id) class Meta: verbose_name = '预采购单' verbose_name_plural = '预采购单' class PrePurchaseOrderItem(ModelBase): """预采购单明细""" id = models.BigAutoField(primary_key=True) pre_purchase_order = models.ForeignKey( PrePurchaseOrder, on_delete=models.CASCADE, related_name='items', verbose_name='预采购单', ) # 暂不与 Product 建立外键,先用弱关联字段承载。 product_id = models.BigIntegerField(null=True, blank=True, verbose_name='产品ID') product_name = models.CharField(max_length=100, blank=True, default='', verbose_name='产品名称') color = models.CharField(max_length=50, null=True, blank=True, verbose_name='颜色') quantity = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name='数量') unit = models.CharField(max_length=50, blank=True, default='', verbose_name='单位') spec = models.CharField(max_length=100, null=True, blank=True, verbose_name='规格') quantity_of_rolls = models.TextField( null=True, blank=True, verbose_name='各条数数量', ) num_of_rolls = models.PositiveIntegerField(default=1, verbose_name='条数') order_quantity = models.IntegerField(null=True, blank=True, verbose_name='下单数量') remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: verbose_name = '预采购单明细' verbose_name_plural = '预采购单明细' class PurchaseReturnStatusEnum(models.IntegerChoices): PENDING = 1, '审批中' APPROVED = 2, '审批通过' CANCELLED = 3, '作废' class PurchaseReturnOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): """采购退货单(库存出库 + 供应商应付减少)""" id = models.BigAutoField(primary_key=True) merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, related_name='purchase_return_orders', verbose_name='所属商户', ) supplier = models.ForeignKey( basic_info_models.Supplier, on_delete=models.PROTECT, related_name='purchase_return_orders', verbose_name='供应商', ) purchase_order = models.ForeignKey( 'PurchaseOrder', on_delete=models.SET_NULL, related_name='return_orders', null=True, blank=True, verbose_name='原采购单', ) return_date = models.DateField(verbose_name='退货日期') operator = models.ForeignKey( basic_info_models.Employee, on_delete=models.PROTECT, related_name='purchase_return_orders', verbose_name='经办人', ) warehouse = models.ForeignKey( basic_info_models.WareHouse, on_delete=models.PROTECT, related_name='purchase_return_orders', verbose_name='仓库', ) status = models.IntegerField( choices=PurchaseReturnStatusEnum.choices, default=PurchaseReturnStatusEnum.PENDING, verbose_name='状态', ) is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲') red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间') balance_before_snapshot = models.DecimalField( max_digits=15, decimal_places=2, null=True, blank=True, verbose_name='创建前供应商余额快照', ) remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: verbose_name = '采购退货单' verbose_name_plural = '采购退货单' def __str__(self): return f'采购退货 {self.id} - {self.supplier.name}' @property def human_id(self) -> str: """ 人类可读编号(虚拟编号,不落库)。 规则与 SalesOrder.human_id 保持一致:CT + YYYYMMDD{6位id} """ return build_business_human_id('CT', self.created_at, self.id) def get_direction(self) -> int: return -1 def get_counterparty_field_name(self) -> str: return 'supplier' class PurchaseReturnOrderItem(ModelBase): id = models.BigAutoField(primary_key=True) purchase_return_order = models.ForeignKey( PurchaseReturnOrder, on_delete=models.CASCADE, related_name='items', verbose_name='采购退货单', ) product = models.ForeignKey( basic_info_models.Product, on_delete=models.PROTECT, related_name='purchase_return_order_items', verbose_name='产品', ) price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='单价') color = models.CharField(max_length=50, null=True, blank=True, verbose_name='颜色') quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量') unit = models.CharField(max_length=50, verbose_name='单位') spec = models.CharField(max_length=100, null=True, blank=True, verbose_name='规格') empty_diff_percent = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='空差百分比') quantity_of_rolls = models.CharField(max_length=255, null=True, blank=True, verbose_name='各条数数量') num_of_rolls = models.PositiveIntegerField(default=1, verbose_name='条数') consume_detail_ids = models.CharField( max_length=255, null=True, blank=True, verbose_name='关联入库明细ID', ) batch_number = models.CharField(null=True, blank=True, max_length=100, verbose_name='批次号') remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: verbose_name = '采购退货明细' verbose_name_plural = '采购退货明细' def split_quantity_of_rolls(self) -> List[int]: if self.quantity_of_rolls: return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()] return [] def real_quantity(self): return round(self.quantity * (1 - self.empty_diff_percent / 100), 2) def diff_quantity(self): return round(self.quantity * (self.empty_diff_percent / 100), 2) def total_amount(self): return round(self.price * self.real_quantity(), 2) class SalesReturnStatusEnum(models.IntegerChoices): PENDING = 1, '审批中' APPROVED = 2, '审批通过' CANCELLED = 3, '作废' class SalesReturnOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): """销售退货单(库存入库 + 客户应收减少)""" id = models.BigAutoField(primary_key=True) merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, related_name='sales_return_orders', verbose_name='所属商户', ) customer = models.ForeignKey( basic_info_models.Customer, on_delete=models.PROTECT, related_name='sales_return_orders', verbose_name='客户', ) sales_order = models.ForeignKey( 'SalesOrder', on_delete=models.SET_NULL, related_name='return_orders', null=True, blank=True, verbose_name='原销售单', ) return_date = models.DateField(verbose_name='退货日期') operator = models.ForeignKey( basic_info_models.Employee, on_delete=models.PROTECT, related_name='sales_return_orders', verbose_name='经办人', ) warehouse = models.ForeignKey( basic_info_models.WareHouse, on_delete=models.PROTECT, related_name='sales_return_orders', verbose_name='仓库', ) status = models.IntegerField( choices=SalesReturnStatusEnum.choices, default=SalesReturnStatusEnum.PENDING, verbose_name='状态', ) is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲') red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间') balance_before_snapshot = models.DecimalField( max_digits=15, decimal_places=2, null=True, blank=True, verbose_name='创建前客户余额快照', ) remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: verbose_name = '销售退货单' verbose_name_plural = '销售退货单' def __str__(self): return f'销售退货 {self.id} - {self.customer.name}' @property def human_id(self) -> str: """ 人类可读编号(虚拟编号,不落库)。 规则与 SalesOrder.human_id 保持一致:XT + YYYYMMDD{6位id} """ return build_business_human_id('XT', self.created_at, self.id) def get_direction(self) -> int: return 1 def get_counterparty_field_name(self) -> str: return 'customer' class SalesReturnOrderItem(ModelBase): id = models.BigAutoField(primary_key=True) sales_return_order = models.ForeignKey( SalesReturnOrder, on_delete=models.CASCADE, related_name='items', verbose_name='销售退货单', ) product = models.ForeignKey( basic_info_models.Product, on_delete=models.PROTECT, related_name='sales_return_order_items', verbose_name='产品', ) price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='单价') color = models.CharField(max_length=50, null=True, blank=True, verbose_name='颜色') quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量') unit = models.CharField(max_length=50, verbose_name='单位') spec = models.CharField(max_length=100, null=True, blank=True, verbose_name='规格') empty_diff_percent = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='空差百分比') quantity_of_rolls = models.CharField(max_length=255, null=True, blank=True, verbose_name='各条数数量') num_of_rolls = models.PositiveIntegerField(default=1, verbose_name='条数') consume_detail_ids = models.CharField( max_length=255, null=True, blank=True, verbose_name='关联入库明细ID', ) batch_number = models.CharField(null=True, blank=True, max_length=100, verbose_name='批次号') remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: verbose_name = '销售退货明细' verbose_name_plural = '销售退货明细' def split_quantity_of_rolls(self) -> List[int]: if self.quantity_of_rolls: return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()] return [] def real_quantity(self): return round(self.quantity * (1 - self.empty_diff_percent / 100), 2) def diff_quantity(self): return round(self.quantity * (self.empty_diff_percent / 100), 2) def total_amount(self): return round(self.price * self.real_quantity(), 2) class ExternalCustomerStatementCategoryEnum(models.TextChoices): SALE = 'sale', '外部销售单' SALE_RETURN = 'sale_return', '外部销售退货单' class ExternalCustomerStatementOrder(ModelBase): """仅用于对账单计算的外部业务来源单据。""" id = models.BigAutoField(primary_key=True) merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, related_name='external_customer_statement_orders', verbose_name='所属商户', ) customer = models.ForeignKey( basic_info_models.Customer, on_delete=models.PROTECT, related_name='external_statement_orders', verbose_name='客户', ) category = models.CharField( max_length=20, choices=ExternalCustomerStatementCategoryEnum.choices, verbose_name='外部业务分类', ) external_customer_id = models.CharField(max_length=100, blank=True, default='', verbose_name='外部客户ID') external_source_id = models.CharField(max_length=100, db_index=True, verbose_name='外部单号') occurred_at = models.DateField(verbose_name='业务日期') recorded_at = models.DateTimeField(blank=True, null=True, verbose_name='录单时间') settlement_method = models.CharField(max_length=100, blank=True, default='', verbose_name='结算方式') total_amount = models.DecimalField(max_digits=15, decimal_places=2, default=Decimal('0'), verbose_name='总金额') sf_amount = models.DecimalField( max_digits=15, decimal_places=2, default=Decimal('0'), verbose_name='现场收款金额', help_text='销售单上的现场收款(SfJinE)合计,仅 sale 类型有值', ) zk_amount = models.DecimalField( max_digits=15, decimal_places=2, default=Decimal('0'), verbose_name='折扣金额', help_text='该单据的折扣(ZkJinE)合计', ) remarks = models.TextField(blank=True, null=True, verbose_name='备注') items_payload = models.JSONField(default=list, blank=True, verbose_name='明细快照') extra_payload = models.JSONField(default=dict, blank=True, verbose_name='扩展数据') class Meta: verbose_name = '外部客户对账来源单' verbose_name_plural = '外部客户对账来源单' constraints = [ models.UniqueConstraint( fields=['merchant', 'category', 'external_source_id'], name='uniq_external_customer_statement_order_source', ) ] def __str__(self): return f'外部对账来源 {self.external_source_id} ({self.category})' class PaymentOrderStatusEnum(models.IntegerChoices): PENDING = 1, '审批中' APPROVED = 2, '审批通过' CANCELLED = 3, '作废' class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): id = models.BigAutoField(primary_key=True) merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, related_name='payment_orders', verbose_name='所属商户', ) supplier = models.ForeignKey( basic_info_models.Supplier, on_delete=models.PROTECT, related_name='payment_orders', verbose_name='供应商', ) payment_date = models.DateField(verbose_name='付款日期') amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额') discount_amount = models.DecimalField( max_digits=15, decimal_places=2, default=Decimal('0'), verbose_name='折扣金额', ) bank_account = models.ForeignKey( basic_info_models.BankAccount, on_delete=models.PROTECT, related_name='payment_orders', verbose_name='银行账户', blank=True, null=True, ) markup = models.CharField(max_length=255, blank=True, null=True, verbose_name='附言') operator = models.ForeignKey( basic_info_models.Employee, on_delete=models.PROTECT, related_name='payment_orders', verbose_name='经办人', ) status = models.IntegerField( choices=PaymentOrderStatusEnum.choices, default=PaymentOrderStatusEnum.PENDING, verbose_name='状态', ) is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲') red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间') is_external_source = models.BooleanField( default=False, verbose_name='是否外部来源', ) external_source_id = models.CharField( max_length=100, null=True, blank=True, db_index=True, verbose_name='外部来源ID', ) remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: verbose_name = '付款单' verbose_name_plural = '付款单' def __str__(self): return f'付款单 {self.id} - {self.supplier.name}' @property def human_id(self) -> str: """人类可读编号(虚拟编号,不落库):FK + YYYYMMDD{6位id}。""" return build_business_human_id('FK', self.created_at, self.id) @property def settlement_amount(self) -> Decimal: discount = self.discount_amount or Decimal('0') return self.amount + discount def get_total_amount(self) -> Decimal: return self.settlement_amount def get_direction(self) -> int: return -1 def get_counterparty_field_name(self) -> str: return 'supplier' class ReceiptOrderStatusEnum(models.IntegerChoices): PENDING = 1, '审批中' APPROVED = 2, '审批通过' CANCELLED = 3, '作废' class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): id = models.BigAutoField(primary_key=True) merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, related_name='receipt_orders', verbose_name='所属商户', ) customer = models.ForeignKey( basic_info_models.Customer, on_delete=models.PROTECT, related_name='receipt_orders', verbose_name='客户', ) receipt_date = models.DateField(verbose_name='收款日期') amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额') discount_amount = models.DecimalField( max_digits=15, decimal_places=2, default=Decimal('0'), verbose_name='折扣金额', ) bank_account = models.ForeignKey( basic_info_models.BankAccount, on_delete=models.PROTECT, related_name='receipt_orders', verbose_name='银行账户', blank=True, null=True, ) markup = models.CharField(max_length=255, blank=True, null=True, verbose_name='附言') operator = models.ForeignKey( basic_info_models.Employee, on_delete=models.PROTECT, related_name='receipt_orders', verbose_name='经办人', ) status = models.IntegerField( choices=ReceiptOrderStatusEnum.choices, default=ReceiptOrderStatusEnum.PENDING, verbose_name='状态', ) is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲') red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间') is_external_source = models.BooleanField( default=False, verbose_name='是否外部来源', ) external_source_id = models.CharField( max_length=100, null=True, blank=True, db_index=True, verbose_name='外部来源ID', ) remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: verbose_name = '收款单' verbose_name_plural = '收款单' def __str__(self): return f'收款单 {self.id} - {self.customer.name}' @property def human_id(self) -> str: """人类可读编号(虚拟编号,不落库):SK + YYYYMMDD{6位id}。""" return build_business_human_id('SK', self.created_at, self.id) @property def settlement_amount(self) -> Decimal: discount = self.discount_amount or Decimal('0') return self.amount + discount def get_total_amount(self) -> Decimal: return self.settlement_amount def get_direction(self) -> int: return -1 def get_counterparty_field_name(self) -> str: return 'customer' class SupplierBalance(ModelBase): merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, related_name='supplier_balances', verbose_name='所属商户', ) supplier = models.ForeignKey( basic_info_models.Supplier, on_delete=models.PROTECT, related_name='balances', verbose_name='供应商', ) balance = models.DecimalField(max_digits=15, decimal_places=2, default=Decimal('0'), verbose_name='应付余额') class Meta: verbose_name = '供应商余额' verbose_name_plural = '供应商余额' unique_together = ('merchant', 'supplier') class CustomerBalance(ModelBase): merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, related_name='customer_balances', verbose_name='所属商户', ) customer = models.ForeignKey( basic_info_models.Customer, on_delete=models.PROTECT, related_name='balances', verbose_name='客户', ) balance = models.DecimalField(max_digits=15, decimal_places=2, default=Decimal('0'), verbose_name='应收余额') class Meta: verbose_name = '客户余额' verbose_name_plural = '客户余额' unique_together = ('merchant', 'customer') class BalanceChangeTargetEnum(models.IntegerChoices): SUPPLIER = 1, '供应商' CUSTOMER = 2, '客户' class BalanceChangeSourceEnum(models.IntegerChoices): PURCHASE_ORDER = 1, '采购单' SALES_ORDER = 2, '销售单' PAYMENT_ORDER = 3, '付款单' RECEIPT_ORDER = 4, '收款单' PURCHASE_RETURN_ORDER = 5, '采购退货单' SALES_RETURN_ORDER = 6, '销售退货单' class BalanceChangeDirectionEnum(models.IntegerChoices): INCREASE = 1, '增加' DECREASE = 2, '减少' class BalanceChangeRecord(ModelBase): merchant = models.ForeignKey( basic_info_models.Merchant, on_delete=models.PROTECT, related_name='balance_change_records', verbose_name='所属商户', ) supplier = models.ForeignKey( basic_info_models.Supplier, on_delete=models.PROTECT, related_name='balance_change_records', verbose_name='供应商', null=True, blank=True, ) customer = models.ForeignKey( basic_info_models.Customer, on_delete=models.PROTECT, related_name='balance_change_records', verbose_name='客户', null=True, blank=True, ) target_type = models.IntegerField( choices=BalanceChangeTargetEnum.choices, verbose_name='业务主体类型', ) source_type = models.IntegerField( choices=BalanceChangeSourceEnum.choices, verbose_name='来源业务类型', ) source_id = models.BigIntegerField(verbose_name='来源业务ID') delta = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='变动金额') direction = models.IntegerField( choices=BalanceChangeDirectionEnum.choices, verbose_name='方向', ) balance_before = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='变动前余额') balance_after = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='变动后余额') request_id = models.CharField(max_length=64, null=True, blank=True, verbose_name='幂等请求ID') remarks = models.TextField(blank=True, null=True, verbose_name='备注') extra_meta = models.JSONField(default=dict, blank=True, verbose_name='扩展信息') offset_to = models.BigIntegerField(null=True, blank=True, verbose_name='冲抵目标ID') offset_at = models.DateTimeField(null=True, blank=True, verbose_name='冲抵时间') offset_id = models.BigIntegerField(null=True, blank=True, verbose_name='冲抵来源ID') cancelled = models.BooleanField(default=False, verbose_name='已被冲抵') cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name='被冲抵时间') red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') class Meta: verbose_name = '余额变动记录' verbose_name_plural = '余额变动记录' indexes = [ models.Index(fields=['merchant', 'source_type', 'source_id'], name='balance_change_source_idx'), ] constraints = [ models.CheckConstraint( check=( (models.Q(supplier__isnull=False, customer__isnull=True)) | (models.Q(supplier__isnull=True, customer__isnull=False)) ), name='balance_change_single_counterparty', ), ] def __str__(self): counterparty = self.supplier or self.customer return f'余额变动 {self.id} - {counterparty}'