from __future__ import annotations import logging import uuid from collections import OrderedDict from datetime import date, datetime from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Any, Dict, Iterable, List, Tuple, Optional from django.contrib.auth import get_user_model from django.db import transaction from django.utils import timezone from basic_info import models as basic_info_models from stock import models as stock_models from stock.services import StockFlowService from basic_info.services import MerchantSettingService from . import models from .tasks import ( create_purchase_order_stock_entries, create_sales_order_stock_entries, create_purchase_return_order_stock_entries, create_sales_return_order_stock_entries, ) class BalanceService: @staticmethod def adjust_supplier_balance( *, merchant: basic_info_models.Merchant, supplier: basic_info_models.Supplier, delta: Decimal, source_type: models.BalanceChangeSourceEnum, source_id: int, request_id: str | None = None, remarks: str | None = '', extra_meta: Dict[str, Any] | None = None, red_flush_id=None, ) -> models.BalanceChangeRecord: return BalanceService._adjust_balance( merchant=merchant, counterparty=supplier, balance_model=models.SupplierBalance, balance_field='supplier', delta=delta, target_type=models.BalanceChangeTargetEnum.SUPPLIER, source_type=source_type, source_id=source_id, request_id=request_id, remarks=remarks, extra_meta=extra_meta, red_flush_id=red_flush_id, ) @staticmethod def adjust_customer_balance( *, merchant: basic_info_models.Merchant, customer: basic_info_models.Customer, delta: Decimal, source_type: models.BalanceChangeSourceEnum, source_id: int, request_id: str | None = None, remarks: str | None = '', extra_meta: Dict[str, Any] | None = None, red_flush_id=None, ) -> models.BalanceChangeRecord: return BalanceService._adjust_balance( merchant=merchant, counterparty=customer, balance_model=models.CustomerBalance, balance_field='customer', delta=delta, target_type=models.BalanceChangeTargetEnum.CUSTOMER, source_type=source_type, source_id=source_id, request_id=request_id, remarks=remarks, extra_meta=extra_meta, red_flush_id=red_flush_id, ) @staticmethod def _adjust_balance( *, merchant: basic_info_models.Merchant, counterparty, balance_model, balance_field: str, delta: Decimal, target_type: models.BalanceChangeTargetEnum, source_type: models.BalanceChangeSourceEnum, source_id: int, request_id: str | None, remarks: str | None, extra_meta: Dict[str, Any] | None, red_flush_id, ) -> models.BalanceChangeRecord: meta_payload = extra_meta or {} remarks_value = remarks or '' with transaction.atomic(): balance, _ = balance_model.objects.select_for_update().get_or_create( merchant=merchant, defaults={'balance': Decimal('0')}, **{balance_field: counterparty}, ) before = balance.balance balance.balance += delta balance.save(update_fields=['balance', 'updated_at']) record_kwargs = { 'merchant': merchant, 'target_type': target_type, 'source_type': source_type, 'source_id': source_id, 'delta': delta, 'direction': ( models.BalanceChangeDirectionEnum.INCREASE if delta >= 0 else models.BalanceChangeDirectionEnum.DECREASE ), 'balance_before': before, 'balance_after': balance.balance, 'request_id': request_id, 'remarks': remarks_value, 'extra_meta': meta_payload, 'red_flush_id': red_flush_id, } if target_type == models.BalanceChangeTargetEnum.SUPPLIER: record_kwargs['supplier'] = counterparty else: record_kwargs['customer'] = counterparty return models.BalanceChangeRecord.objects.create(**record_kwargs) @staticmethod def get_customer_balance( *, merchant: basic_info_models.Merchant, customer: basic_info_models.Customer, ) -> Decimal: balance = models.CustomerBalance.objects.filter( merchant=merchant, customer=customer, ).first() if balance is None: return Decimal('0') return balance.balance @staticmethod def get_customer_statement_balance( *, merchant: basic_info_models.Merchant, customer: basic_info_models.Customer, ) -> Decimal: """ 返回客户对账单口径的当前余额。 与 get_customer_balance 不同,这里会叠加 ExternalCustomerStatementOrder 中尚未写入本地 CustomerBalance 的外部销售/退货净额,使 customer balance API 与 statements API 的 current_balance 保持一致。 """ return BalanceService.get_customer_balance( merchant=merchant, customer=customer, ) + BalanceService.get_customer_external_statement_balance_adjustment( merchant=merchant, customer=customer, ) @staticmethod def get_customer_external_statement_balance_adjustment( *, merchant: basic_info_models.Merchant, customer: basic_info_models.Customer, ) -> Decimal: """ 计算外部客户对账单业务单据对余额的净影响。 公式需与 _CustomerStatementBuilder._build_external_statement_records 保持一致: - 外部销售:毛额 - 折扣 - 现场收款 - 外部销售退货:-(退货金额 + 退货折扣) """ adjustment = Decimal('0') qs = models.ExternalCustomerStatementOrder.objects.filter( merchant=merchant, customer=customer, ).only('category', 'total_amount', 'zk_amount', 'sf_amount') for order in qs: sf = order.sf_amount or Decimal('0') zk = order.zk_amount or Decimal('0') if order.category == models.ExternalCustomerStatementCategoryEnum.SALE: adjustment += (order.total_amount or Decimal('0')) - zk - sf else: adjustment -= (order.total_amount or Decimal('0')) + zk return adjustment @staticmethod def get_supplier_balance( *, merchant: basic_info_models.Merchant, supplier: basic_info_models.Supplier, ) -> Decimal: balance = models.SupplierBalance.objects.filter( merchant=merchant, supplier=supplier, ).first() if balance is None: return Decimal('0') return balance.balance logger = logging.getLogger(__name__) def _normalize_sales_order_kind(value) -> int: """ 校验并归一化销售单类型(kind)。 None / 空字符串视为未提供,回退到默认类型(大货 WHOLESALE)。 其余值必须是 SalesOrderKindEnum 中合法的整数枚举。 """ if value in (None, ''): return models.SalesOrderKindEnum.WHOLESALE try: kind_value = int(value) except (TypeError, ValueError) as exc: raise ValueError('kind 必须为合法的销售单类型枚举值') from exc if kind_value not in models.SalesOrderKindEnum.values: raise ValueError('kind 不是合法的销售单类型枚举值') return kind_value def _normalize_order_date(value) -> date: if isinstance(value, date): return value if isinstance(value, datetime): return value.date() if isinstance(value, str): try: return date.fromisoformat(value) except ValueError as exc: raise ValueError('order_date 格式不正确,应为 YYYY-MM-DD') from exc raise ValueError('order_date 格式不正确') def create_purchase_order( *, merchant: basic_info_models.Merchant, supplier: basic_info_models.Supplier, order_date, warehouse: basic_info_models.WareHouse, operator: basic_info_models.Employee, items: List[Dict[str, Any]], remarks: str | None = '', created_by=None, from_pre_purchase_order_id: int | None = None, ) -> models.PurchaseOrder: """ 创建采购订单并触发异步创建入库单任务。 Args: merchant: 采购单所属商户 supplier: 供应商 order_date: 订单日期 (date) warehouse: 入库仓库实例 operator: 经办人 items: 产品明细,字段会根据仓库模式校验 remarks: 备注 created_by: 创建者用户(可选,用于 stock 记录中的 created_by) """ if not items: raise ValueError('items 不能为空') normalized_date = _normalize_order_date(order_date) purchase_items, stock_flow_items = _normalize_order_items( merchant=merchant, warehouse=warehouse, items=items, is_outgoing=False, ) with transaction.atomic(): purchase_order = models.PurchaseOrder.objects.create( merchant=merchant, supplier=supplier, balance_before_snapshot=BalanceService.get_supplier_balance( merchant=merchant, supplier=supplier, ), purchase_date=normalized_date, operator=operator, warehouse=warehouse, remarks=remarks, from_pre_purchase_order_id=from_pre_purchase_order_id, ) bulk_objects = [ models.PurchaseOrderItem( purchase_order=purchase_order, product=item_data['product'], price=item_data['price'], color=item_data.get('color'), quantity=item_data['quantity'], unit=item_data['unit'], spec=item_data.get('spec'), empty_diff_percent=item_data['empty_diff_percent'], quantity_of_rolls=item_data.get('quantity_of_rolls'), num_of_rolls=item_data['num_of_rolls'], batch_number=item_data.get('batch_number'), remarks=item_data.get('remarks'), ) for item_data in purchase_items ] models.PurchaseOrderItem.objects.bulk_create(bulk_objects) purchase_order.refresh_from_db() return purchase_order def update_purchase_order( *, purchase_order: models.PurchaseOrder, supplier: basic_info_models.Supplier | None = None, order_date=None, warehouse: basic_info_models.WareHouse | None = None, items: List[Dict[str, Any]] | None = None, remarks: str | None = '', from_pre_purchase_order_id: int | None = None, ) -> models.PurchaseOrder: """ 更新采购订单(仅限审批中状态)。 """ _ensure_order_pending(purchase_order, models.PurchaseOrderStatusEnum.PENDING, '采购单') merchant = purchase_order.merchant new_supplier = supplier or purchase_order.supplier new_warehouse = warehouse or purchase_order.warehouse new_order_date = _normalize_order_date(order_date or purchase_order.purchase_date) remarks = remarks if remarks is not None else purchase_order.remarks if not items: raise ValueError('items 需要为非空数组') normalized_items, _ = _normalize_order_items( merchant=merchant, warehouse=new_warehouse, items=items, is_outgoing=False, ) with transaction.atomic(): purchase_order.supplier = new_supplier purchase_order.purchase_date = new_order_date purchase_order.warehouse = new_warehouse purchase_order.remarks = remarks if from_pre_purchase_order_id is not None: purchase_order.from_pre_purchase_order_id = from_pre_purchase_order_id purchase_order.save( update_fields=[ 'supplier', 'purchase_date', 'warehouse', 'remarks', 'from_pre_purchase_order_id', 'updated_at', ] ) purchase_order.items.all().delete() bulk_objects = [ models.PurchaseOrderItem( purchase_order=purchase_order, product=item_data['product'], price=item_data['price'], color=item_data.get('color'), quantity=item_data['quantity'], unit=item_data['unit'], spec=item_data.get('spec'), empty_diff_percent=item_data['empty_diff_percent'], quantity_of_rolls=item_data.get('quantity_of_rolls'), num_of_rolls=item_data['num_of_rolls'], batch_number=item_data.get('batch_number'), remarks=item_data.get('remarks'), ) for item_data in normalized_items ] models.PurchaseOrderItem.objects.bulk_create(bulk_objects) purchase_order.refresh_from_db() return purchase_order def create_sales_order( *, merchant: basic_info_models.Merchant, customer: basic_info_models.Customer, order_date, warehouse: basic_info_models.WareHouse, operator: basic_info_models.Employee, items: List[Dict[str, Any]], remarks: str | None = '', created_by=None, from_pre_sales_order_id: int | None = None, kind: int | None = None, ) -> models.SalesOrder: """ 创建销售订单,后续审批通过后会触发出库任务。 """ if not items: raise ValueError('items 不能为空') normalized_kind = _normalize_sales_order_kind(kind) normalized_date = _normalize_order_date(order_date) sales_items, stock_flow_items = _normalize_order_items( merchant=merchant, warehouse=warehouse, items=items, is_outgoing=True, customer=customer, ) with transaction.atomic(): sales_order = models.SalesOrder.objects.create( merchant=merchant, customer=customer, balance_before_snapshot=BalanceService.get_customer_balance( merchant=merchant, customer=customer, ), sales_date=normalized_date, operator=operator, warehouse=warehouse, remarks=remarks, from_pre_sales_order_id=from_pre_sales_order_id, kind=normalized_kind, ) bulk_objects = [ models.SalesOrderItem( sales_order=sales_order, product=item_data['product'], price=item_data['price'], color=item_data.get('color'), quantity=item_data['quantity'], unit=item_data['unit'], spec=item_data.get('spec'), empty_diff_percent=item_data['empty_diff_percent'], quantity_of_rolls=item_data.get('quantity_of_rolls'), num_of_rolls=item_data['num_of_rolls'], consume_detail_ids=item_data.get('consume_detail_ids'), batch_number=item_data.get('batch_number'), order_quantity=item_data.get('order_quantity'), remarks=item_data.get('remarks'), printing_job=item_data.get('printing_job'), ) for item_data in sales_items ] models.SalesOrderItem.objects.bulk_create(bulk_objects) sales_order.refresh_from_db() return sales_order def update_sales_order( *, sales_order: models.SalesOrder, customer: basic_info_models.Customer | None = None, order_date=None, warehouse: basic_info_models.WareHouse | None = None, operator: basic_info_models.Employee | None = None, items: List[Dict[str, Any]] | None = None, remarks: str | None = '', kind: int | None = None, ) -> models.SalesOrder: """ 更新销售订单(仅限审批中状态)。 """ _ensure_order_pending(sales_order, models.SalesOrderStatusEnum.PENDING, '销售单') merchant = sales_order.merchant new_customer = customer or sales_order.customer new_warehouse = warehouse or sales_order.warehouse new_operator = operator or sales_order.operator new_order_date = _normalize_order_date(order_date or sales_order.sales_date) remarks = remarks if remarks is not None else sales_order.remarks # kind 未提供时保持原值;提供则校验后更新 new_kind = sales_order.kind if kind in (None, '') else _normalize_sales_order_kind(kind) if not items: raise ValueError('items 需要为非空数组') sales_items, stock_flow_items = _normalize_order_items( merchant=merchant, warehouse=new_warehouse, items=items, is_outgoing=True, customer=new_customer, ) with transaction.atomic(): sales_order.customer = new_customer sales_order.sales_date = new_order_date sales_order.warehouse = new_warehouse sales_order.operator = new_operator sales_order.remarks = remarks sales_order.kind = new_kind sales_order.save( update_fields=['customer', 'sales_date', 'warehouse', 'operator', 'remarks', 'kind', 'updated_at'] ) sales_order.items.all().delete() bulk_objects = [ models.SalesOrderItem( sales_order=sales_order, product=item_data['product'], price=item_data['price'], color=item_data.get('color'), quantity=item_data['quantity'], unit=item_data['unit'], spec=item_data.get('spec'), empty_diff_percent=item_data['empty_diff_percent'], quantity_of_rolls=item_data.get('quantity_of_rolls'), num_of_rolls=item_data['num_of_rolls'], consume_detail_ids=item_data.get('consume_detail_ids'), batch_number=item_data.get('batch_number'), order_quantity=item_data.get('order_quantity'), remarks=item_data.get('remarks'), printing_job=item_data.get('printing_job'), ) for item_data in sales_items ] models.SalesOrderItem.objects.bulk_create(bulk_objects) sales_order.refresh_from_db() return sales_order def create_purchase_return_order( *, merchant: basic_info_models.Merchant, supplier: basic_info_models.Supplier, return_date, warehouse: basic_info_models.WareHouse, operator: basic_info_models.Employee, items: List[Dict[str, Any]], remarks: str | None = '', purchase_order: models.PurchaseOrder | None = None, purchase_order_id: int | None = None, created_by=None, ) -> models.PurchaseReturnOrder: """ 创建采购退货单,出库并冲减供应商欠款。 """ if not items: raise ValueError('items 不能为空') normalized_date = _normalize_order_date(return_date) normalized_items, _ = _normalize_order_items( merchant=merchant, warehouse=warehouse, items=items, is_outgoing=True, ) resolved_purchase_order = purchase_order if resolved_purchase_order is None and purchase_order_id: resolved_purchase_order = models.PurchaseOrder.objects.filter( id=purchase_order_id, merchant=merchant, ).first() if resolved_purchase_order is None: raise ValueError('关联的采购单不存在或不属于当前商户') if resolved_purchase_order and resolved_purchase_order.supplier_id != supplier.id: raise ValueError('采购退货单的供应商必须与原采购单一致') with transaction.atomic(): return_order = models.PurchaseReturnOrder.objects.create( merchant=merchant, supplier=supplier, balance_before_snapshot=BalanceService.get_supplier_balance( merchant=merchant, supplier=supplier, ), purchase_order=resolved_purchase_order, return_date=normalized_date, operator=operator, warehouse=warehouse, remarks=remarks, ) bulk_objects = [ models.PurchaseReturnOrderItem( purchase_return_order=return_order, product=item_data['product'], price=item_data['price'], color=item_data.get('color'), quantity=item_data['quantity'], unit=item_data['unit'], spec=item_data.get('spec'), empty_diff_percent=item_data['empty_diff_percent'], quantity_of_rolls=item_data.get('quantity_of_rolls'), num_of_rolls=item_data['num_of_rolls'], consume_detail_ids=item_data.get('consume_detail_ids'), batch_number=item_data.get('batch_number'), remarks=item_data.get('remarks'), ) for item_data in normalized_items ] models.PurchaseReturnOrderItem.objects.bulk_create(bulk_objects) return_order.refresh_from_db() return return_order def update_purchase_return_order( *, purchase_return_order: models.PurchaseReturnOrder, supplier: basic_info_models.Supplier | None = None, return_date=None, warehouse: basic_info_models.WareHouse | None = None, items: List[Dict[str, Any]] | None = None, remarks: str | None = '', purchase_order: models.PurchaseOrder | None = None, purchase_order_id: int | None = None, ) -> models.PurchaseReturnOrder: """ 更新采购退货单(仅限审批中状态)。 """ _ensure_order_pending(purchase_return_order, models.PurchaseReturnStatusEnum.PENDING, '采购退货单') merchant = purchase_return_order.merchant new_supplier = supplier or purchase_return_order.supplier new_warehouse = warehouse or purchase_return_order.warehouse new_return_date = _normalize_order_date(return_date or purchase_return_order.return_date) remarks = remarks if remarks is not None else purchase_return_order.remarks if not items: raise ValueError('items 需要为非空数组') resolved_purchase_order = purchase_order or purchase_return_order.purchase_order if resolved_purchase_order is None and purchase_order_id: resolved_purchase_order = models.PurchaseOrder.objects.filter( id=purchase_order_id, merchant=merchant, ).first() if resolved_purchase_order is None: raise ValueError('关联的采购单不存在或不属于当前商户') if resolved_purchase_order and resolved_purchase_order.supplier_id != new_supplier.id: raise ValueError('采购退货单的供应商必须与原采购单一致') normalized_items, _ = _normalize_order_items( merchant=merchant, warehouse=new_warehouse, items=items, is_outgoing=True, ) with transaction.atomic(): purchase_return_order.supplier = new_supplier purchase_return_order.return_date = new_return_date purchase_return_order.warehouse = new_warehouse purchase_return_order.remarks = remarks purchase_return_order.purchase_order = resolved_purchase_order purchase_return_order.save( update_fields=['supplier', 'return_date', 'warehouse', 'remarks', 'purchase_order', 'updated_at'] ) purchase_return_order.items.all().delete() bulk_objects = [ models.PurchaseReturnOrderItem( purchase_return_order=purchase_return_order, product=item_data['product'], price=item_data['price'], color=item_data.get('color'), quantity=item_data['quantity'], unit=item_data['unit'], spec=item_data.get('spec'), empty_diff_percent=item_data['empty_diff_percent'], quantity_of_rolls=item_data.get('quantity_of_rolls'), num_of_rolls=item_data['num_of_rolls'], consume_detail_ids=item_data.get('consume_detail_ids'), batch_number=item_data.get('batch_number'), remarks=item_data.get('remarks'), ) for item_data in normalized_items ] models.PurchaseReturnOrderItem.objects.bulk_create(bulk_objects) purchase_return_order.refresh_from_db() return purchase_return_order def create_sales_return_order( *, merchant: basic_info_models.Merchant, customer: basic_info_models.Customer, return_date, warehouse: basic_info_models.WareHouse, operator: basic_info_models.Employee, items: List[Dict[str, Any]], remarks: str | None = '', sales_order: models.SalesOrder | None = None, sales_order_id: int | None = None, created_by=None, ) -> models.SalesReturnOrder: """ 创建销售退货单,入库并冲减客户欠款。 """ if not items: raise ValueError('items 不能为空') normalized_date = _normalize_order_date(return_date) normalized_items, _ = _normalize_order_items( merchant=merchant, warehouse=warehouse, items=items, is_outgoing=False, ) resolved_sales_order = sales_order if resolved_sales_order is None and sales_order_id: resolved_sales_order = models.SalesOrder.objects.filter( id=sales_order_id, merchant=merchant, ).first() if resolved_sales_order is None: raise ValueError('关联的销售单不存在或不属于当前商户') if resolved_sales_order and resolved_sales_order.customer_id != customer.id: raise ValueError('销售退货单的客户必须与原销售单一致') with transaction.atomic(): return_order = models.SalesReturnOrder.objects.create( merchant=merchant, customer=customer, balance_before_snapshot=BalanceService.get_customer_balance( merchant=merchant, customer=customer, ), sales_order=resolved_sales_order, return_date=normalized_date, operator=operator, warehouse=warehouse, remarks=remarks, ) bulk_objects = [ models.SalesReturnOrderItem( sales_return_order=return_order, product=item_data['product'], price=item_data['price'], color=item_data.get('color'), quantity=item_data['quantity'], unit=item_data['unit'], spec=item_data.get('spec'), empty_diff_percent=item_data['empty_diff_percent'], quantity_of_rolls=item_data.get('quantity_of_rolls'), num_of_rolls=item_data['num_of_rolls'], consume_detail_ids=item_data.get('consume_detail_ids'), batch_number=item_data.get('batch_number'), remarks=item_data.get('remarks'), ) for item_data in normalized_items ] models.SalesReturnOrderItem.objects.bulk_create(bulk_objects) return_order.refresh_from_db() return return_order def update_sales_return_order( *, sales_return_order: models.SalesReturnOrder, customer: basic_info_models.Customer | None = None, return_date=None, warehouse: basic_info_models.WareHouse | None = None, items: List[Dict[str, Any]] | None = None, remarks: str | None = '', sales_order: models.SalesOrder | None = None, sales_order_id: int | None = None, ) -> models.SalesReturnOrder: """ 更新销售退货单(仅限审批中状态)。 """ _ensure_order_pending(sales_return_order, models.SalesReturnStatusEnum.PENDING, '销售退货单') merchant = sales_return_order.merchant new_customer = customer or sales_return_order.customer new_warehouse = warehouse or sales_return_order.warehouse new_return_date = _normalize_order_date(return_date or sales_return_order.return_date) remarks = remarks if remarks is not None else sales_return_order.remarks if not items: raise ValueError('items 需要为非空数组') resolved_sales_order = sales_order or sales_return_order.sales_order if resolved_sales_order is None and sales_order_id: resolved_sales_order = models.SalesOrder.objects.filter( id=sales_order_id, merchant=merchant, ).first() if resolved_sales_order is None: raise ValueError('关联的销售单不存在或不属于当前商户') if resolved_sales_order and resolved_sales_order.customer_id != new_customer.id: raise ValueError('销售退货单的客户必须与原销售单一致') normalized_items, _ = _normalize_order_items( merchant=merchant, warehouse=new_warehouse, items=items, is_outgoing=False, ) with transaction.atomic(): sales_return_order.customer = new_customer sales_return_order.return_date = new_return_date sales_return_order.warehouse = new_warehouse sales_return_order.remarks = remarks sales_return_order.sales_order = resolved_sales_order sales_return_order.save( update_fields=['customer', 'return_date', 'warehouse', 'remarks', 'sales_order', 'updated_at'] ) sales_return_order.items.all().delete() bulk_objects = [ models.SalesReturnOrderItem( sales_return_order=sales_return_order, product=item_data['product'], price=item_data['price'], color=item_data.get('color'), quantity=item_data['quantity'], unit=item_data['unit'], spec=item_data.get('spec'), empty_diff_percent=item_data['empty_diff_percent'], quantity_of_rolls=item_data.get('quantity_of_rolls'), num_of_rolls=item_data['num_of_rolls'], consume_detail_ids=item_data.get('consume_detail_ids'), batch_number=item_data.get('batch_number'), remarks=item_data.get('remarks'), ) for item_data in normalized_items ] models.SalesReturnOrderItem.objects.bulk_create(bulk_objects) sales_return_order.refresh_from_db() return sales_return_order def create_payment_order( *, merchant: basic_info_models.Merchant, supplier: basic_info_models.Supplier, payment_date, amount, operator: basic_info_models.Employee, remarks: str | None = '', bank_account: basic_info_models.BankAccount | None = None, markup: str | None = None, discount_amount=None, ) -> models.PaymentOrder: """ 创建付款单(资金流出)。 """ normalized_date = _normalize_order_date(payment_date) normalized_amount = _ensure_non_zero_amount(amount, 'amount') normalized_discount = _ensure_non_negative_amount(discount_amount, 'discount_amount') if bank_account and bank_account.merchant_id != merchant.id: raise ValueError('银行账户不属于当前商户') with transaction.atomic(): payment_order = models.PaymentOrder.objects.create( merchant=merchant, supplier=supplier, payment_date=normalized_date, amount=normalized_amount, operator=operator, remarks=remarks, bank_account=bank_account, markup=markup or None, discount_amount=normalized_discount, ) payment_order.refresh_from_db() return payment_order def create_receipt_order( *, merchant: basic_info_models.Merchant, customer: basic_info_models.Customer, receipt_date, amount, operator: basic_info_models.Employee, remarks: str | None = '', bank_account: basic_info_models.BankAccount | None = None, markup: str | None = None, discount_amount=None, ) -> models.ReceiptOrder: """ 创建收款单(资金流入)。 """ normalized_date = _normalize_order_date(receipt_date) normalized_amount = _ensure_non_zero_amount(amount, 'amount') normalized_discount = _ensure_non_negative_amount(discount_amount, 'discount_amount') if bank_account and bank_account.merchant_id != merchant.id: raise ValueError('银行账户不属于当前商户') with transaction.atomic(): receipt_order = models.ReceiptOrder.objects.create( merchant=merchant, customer=customer, receipt_date=normalized_date, amount=normalized_amount, operator=operator, remarks=remarks, bank_account=bank_account, markup=markup or None, discount_amount=normalized_discount, ) receipt_order.refresh_from_db() return receipt_order def review_purchase_order( *, purchase_order: models.PurchaseOrder | None = None, purchase_order_id: int | None = None, target_status: models.PurchaseOrderStatusEnum, reviewed_by=None, ) -> models.PurchaseOrder: """ 审批或作废采购单。 当目标状态为 APPROVED 且开启自动入库任务时,将触发入库 Celery 任务; 当目标状态为 CANCELLED 时,会在更新状态前确认未生成任何出入库单。 """ order = _resolve_purchase_order_instance(purchase_order, purchase_order_id) if target_status not in { models.PurchaseOrderStatusEnum.APPROVED, models.PurchaseOrderStatusEnum.CANCELLED, }: raise ValueError('target_status 只能是 APPROVED 或 CANCELLED') if order.status == target_status: return order if target_status == models.PurchaseOrderStatusEnum.APPROVED: if order.status == models.PurchaseOrderStatusEnum.CANCELLED: raise ValueError('作废状态的采购单无法再次审批') return _approve_purchase_order(order, reviewed_by) return _cancel_purchase_order(order) def review_sales_order( *, sales_order: models.SalesOrder | None = None, sales_order_id: int | None = None, target_status: models.SalesOrderStatusEnum, reviewed_by=None, ) -> models.SalesOrder: """ 审批或作废销售单。 """ order = _resolve_sales_order_instance(sales_order, sales_order_id) if target_status not in { models.SalesOrderStatusEnum.APPROVED, models.SalesOrderStatusEnum.CANCELLED, }: raise ValueError('target_status 只能是 APPROVED 或 CANCELLED') if order.status == target_status: return order if target_status == models.SalesOrderStatusEnum.APPROVED: if order.status == models.SalesOrderStatusEnum.CANCELLED: raise ValueError('作废状态的销售单无法再次审批') return _approve_sales_order(order, reviewed_by) return _cancel_sales_order(order) def review_purchase_return_order( *, purchase_return_order: models.PurchaseReturnOrder | None = None, purchase_return_order_id: int | None = None, target_status: models.PurchaseReturnStatusEnum, reviewed_by=None, ) -> models.PurchaseReturnOrder: order = _resolve_purchase_return_order_instance(purchase_return_order, purchase_return_order_id) if target_status not in { models.PurchaseReturnStatusEnum.APPROVED, models.PurchaseReturnStatusEnum.CANCELLED, }: raise ValueError('target_status 只能是 APPROVED 或 CANCELLED') if order.status == target_status: return order if target_status == models.PurchaseReturnStatusEnum.APPROVED: if order.status == models.PurchaseReturnStatusEnum.CANCELLED: raise ValueError('作废状态的采购退货单无法再次审批') return _approve_purchase_return_order(order, reviewed_by) return _cancel_purchase_return_order(order) def review_sales_return_order( *, sales_return_order: models.SalesReturnOrder | None = None, sales_return_order_id: int | None = None, target_status: models.SalesReturnStatusEnum, reviewed_by=None, ) -> models.SalesReturnOrder: order = _resolve_sales_return_order_instance(sales_return_order, sales_return_order_id) if target_status not in { models.SalesReturnStatusEnum.APPROVED, models.SalesReturnStatusEnum.CANCELLED, }: raise ValueError('target_status 只能是 APPROVED 或 CANCELLED') if order.status == target_status: return order if target_status == models.SalesReturnStatusEnum.APPROVED: if order.status == models.SalesReturnStatusEnum.CANCELLED: raise ValueError('作废状态的销售退货单无法再次审批') return _approve_sales_return_order(order, reviewed_by) return _cancel_sales_return_order(order) def review_payment_order( *, payment_order: models.PaymentOrder | None = None, payment_order_id: int | None = None, target_status: models.PaymentOrderStatusEnum, reviewed_by=None, ) -> models.PaymentOrder: order = _resolve_payment_order_instance(payment_order, payment_order_id) if target_status not in { models.PaymentOrderStatusEnum.APPROVED, models.PaymentOrderStatusEnum.CANCELLED, }: raise ValueError('target_status 只能是 APPROVED 或 CANCELLED') if order.status == target_status: return order if target_status == models.PaymentOrderStatusEnum.APPROVED: if order.status == models.PaymentOrderStatusEnum.CANCELLED: raise ValueError('作废状态的付款单无法再次审批') with transaction.atomic(): locked = models.PaymentOrder.objects.select_related( 'merchant', 'supplier' ).select_for_update().get(id=order.id) if locked.status == models.PaymentOrderStatusEnum.APPROVED: return locked if locked.status == models.PaymentOrderStatusEnum.CANCELLED: raise ValueError('作废状态的付款单无法再次审批') locked.status = models.PaymentOrderStatusEnum.APPROVED locked.save(update_fields=['status', 'updated_at']) BalanceService.adjust_supplier_balance( merchant=locked.merchant, supplier=locked.supplier, delta=-locked.settlement_amount, source_type=models.BalanceChangeSourceEnum.PAYMENT_ORDER, source_id=locked.id, ) locked.refresh_from_db(fields=['status', 'updated_at']) return locked return _cancel_order_impl( order=order, order_model_cls=models.PaymentOrder, approved_status=models.PaymentOrderStatusEnum.APPROVED, cancelled_status=models.PaymentOrderStatusEnum.CANCELLED, error_label='付款单', ) def review_receipt_order( *, receipt_order: models.ReceiptOrder | None = None, receipt_order_id: int | None = None, target_status: models.ReceiptOrderStatusEnum, reviewed_by=None, ) -> models.ReceiptOrder: order = _resolve_receipt_order_instance(receipt_order, receipt_order_id) if target_status not in { models.ReceiptOrderStatusEnum.APPROVED, models.ReceiptOrderStatusEnum.CANCELLED, }: raise ValueError('target_status 只能是 APPROVED 或 CANCELLED') if order.status == target_status: return order if target_status == models.ReceiptOrderStatusEnum.APPROVED: if order.status == models.ReceiptOrderStatusEnum.CANCELLED: raise ValueError('作废状态的收款单无法再次审批') with transaction.atomic(): locked = models.ReceiptOrder.objects.select_related( 'merchant', 'customer' ).select_for_update().get(id=order.id) if locked.status == models.ReceiptOrderStatusEnum.APPROVED: return locked if locked.status == models.ReceiptOrderStatusEnum.CANCELLED: raise ValueError('作废状态的收款单无法再次审批') locked.status = models.ReceiptOrderStatusEnum.APPROVED locked.save(update_fields=['status', 'updated_at']) BalanceService.adjust_customer_balance( merchant=locked.merchant, customer=locked.customer, delta=-locked.settlement_amount, source_type=models.BalanceChangeSourceEnum.RECEIPT_ORDER, source_id=locked.id, ) locked.refresh_from_db(fields=['status', 'updated_at']) return locked return _cancel_order_impl( order=order, order_model_cls=models.ReceiptOrder, approved_status=models.ReceiptOrderStatusEnum.APPROVED, cancelled_status=models.ReceiptOrderStatusEnum.CANCELLED, error_label='收款单', ) def red_flush_purchase_order( *, merchant: basic_info_models.Merchant, purchase_order: models.PurchaseOrder | None = None, purchase_order_id: int | None = None, red_flushed_by=None, reason: str | None = '', ) -> models.PurchaseOrder: order = _resolve_purchase_order_instance(purchase_order, purchase_order_id) _ensure_order_merchant(order, merchant, '采购单') return _red_flush_order_impl( order=order, order_model_cls=models.PurchaseOrder, approved_status=models.PurchaseOrderStatusEnum.APPROVED, balance_source_type=models.BalanceChangeSourceEnum.PURCHASE_ORDER, stock_source_type=stock_models.StockChangeSourceEnum.PURCHASE, counterparty_field='supplier', error_label='采购单', red_flushed_by=red_flushed_by, reason=reason, ) def red_flush_sales_order( *, merchant: basic_info_models.Merchant, sales_order: models.SalesOrder | None = None, sales_order_id: int | None = None, red_flushed_by=None, reason: str | None = '', ) -> models.SalesOrder: order = _resolve_sales_order_instance(sales_order, sales_order_id) _ensure_order_merchant(order, merchant, '销售单') return _red_flush_order_impl( order=order, order_model_cls=models.SalesOrder, approved_status=models.SalesOrderStatusEnum.APPROVED, balance_source_type=models.BalanceChangeSourceEnum.SALES_ORDER, stock_source_type=stock_models.StockChangeSourceEnum.SALES, require_stock_records=False, counterparty_field='customer', error_label='销售单', red_flushed_by=red_flushed_by, reason=reason, ) def red_flush_purchase_return_order( *, merchant: basic_info_models.Merchant, purchase_return_order: models.PurchaseReturnOrder | None = None, purchase_return_order_id: int | None = None, red_flushed_by=None, reason: str | None = '', ) -> models.PurchaseReturnOrder: order = _resolve_purchase_return_order_instance(purchase_return_order, purchase_return_order_id) _ensure_order_merchant(order, merchant, '采购退货单') return _red_flush_order_impl( order=order, order_model_cls=models.PurchaseReturnOrder, approved_status=models.PurchaseReturnStatusEnum.APPROVED, balance_source_type=models.BalanceChangeSourceEnum.PURCHASE_RETURN_ORDER, stock_source_type=stock_models.StockChangeSourceEnum.PURCHASE_RETURN, counterparty_field='supplier', error_label='采购退货单', red_flushed_by=red_flushed_by, reason=reason, ) def red_flush_sales_return_order( *, merchant: basic_info_models.Merchant, sales_return_order: models.SalesReturnOrder | None = None, sales_return_order_id: int | None = None, red_flushed_by=None, reason: str | None = '', ) -> models.SalesReturnOrder: order = _resolve_sales_return_order_instance(sales_return_order, sales_return_order_id) _ensure_order_merchant(order, merchant, '销售退货单') return _red_flush_order_impl( order=order, order_model_cls=models.SalesReturnOrder, approved_status=models.SalesReturnStatusEnum.APPROVED, balance_source_type=models.BalanceChangeSourceEnum.SALES_RETURN_ORDER, stock_source_type=stock_models.StockChangeSourceEnum.SALES_RETURN, counterparty_field='customer', error_label='销售退货单', red_flushed_by=red_flushed_by, reason=reason, ) def red_flush_payment_order( *, merchant: basic_info_models.Merchant, payment_order: models.PaymentOrder | None = None, payment_order_id: int | None = None, red_flushed_by=None, reason: str | None = '', ) -> models.PaymentOrder: order = _resolve_payment_order_instance(payment_order, payment_order_id) _ensure_order_merchant(order, merchant, '付款单') return _red_flush_order_impl( order=order, order_model_cls=models.PaymentOrder, approved_status=models.PaymentOrderStatusEnum.APPROVED, balance_source_type=models.BalanceChangeSourceEnum.PAYMENT_ORDER, stock_source_type=None, counterparty_field='supplier', error_label='付款单', red_flushed_by=red_flushed_by, reason=reason, ) def red_flush_receipt_order( *, merchant: basic_info_models.Merchant, receipt_order: models.ReceiptOrder | None = None, receipt_order_id: int | None = None, red_flushed_by=None, reason: str | None = '', ) -> models.ReceiptOrder: order = _resolve_receipt_order_instance(receipt_order, receipt_order_id) _ensure_order_merchant(order, merchant, '收款单') return _red_flush_order_impl( order=order, order_model_cls=models.ReceiptOrder, approved_status=models.ReceiptOrderStatusEnum.APPROVED, balance_source_type=models.BalanceChangeSourceEnum.RECEIPT_ORDER, stock_source_type=None, counterparty_field='customer', error_label='收款单', red_flushed_by=red_flushed_by, reason=reason, ) def _ensure_order_merchant(order, merchant: basic_info_models.Merchant, error_label: str) -> None: if merchant is None: raise ValueError('必须提供 merchant') if order.merchant_id != merchant.id: raise ValueError(f'{error_label}不属于当前商户') def _red_flush_order_impl( *, order, order_model_cls, approved_status, balance_source_type: models.BalanceChangeSourceEnum, stock_source_type: stock_models.StockChangeSourceEnum | None, counterparty_field: str, error_label: str, require_stock_records: bool = True, red_flushed_by=None, reason: str | None = '', ): reason_text = (reason or '').strip() or f'红冲{error_label}' current_red_flush_id = uuid.uuid4() with transaction.atomic(): locked_order = order_model_cls.objects.select_for_update().get(id=order.id) if locked_order.status != approved_status: raise ValueError(f'仅允许红冲已审批的{error_label}') if locked_order.is_red_flushed: raise ValueError(f'{error_label}已红冲,不能重复红冲') _ensure_order_not_external_source(locked_order, error_label) original_balance_record = _get_original_balance_change_record( merchant_id=locked_order.merchant_id, source_type=balance_source_type, source_id=locked_order.id, error_label=error_label, ) reverse_record = _create_reverse_balance_change_record( original_record=original_balance_record, order=locked_order, counterparty_field=counterparty_field, red_flush_id=current_red_flush_id, reason=reason_text, ) now = timezone.now() original_balance_record.cancelled = True original_balance_record.cancelled_at = now original_balance_record.offset_id = reverse_record.id original_balance_record.red_flush_id = current_red_flush_id original_balance_record.save(update_fields=['cancelled', 'cancelled_at', 'offset_id', 'red_flush_id', 'updated_at']) reverse_record.offset_to = original_balance_record.id reverse_record.offset_at = now reverse_record.red_flush_id = current_red_flush_id reverse_record.save(update_fields=['offset_to', 'offset_at', 'red_flush_id', 'updated_at']) if stock_source_type is not None: _red_flush_stock_records_for_order( order=locked_order, stock_source_type=stock_source_type, red_flush_id=current_red_flush_id, red_flushed_by=red_flushed_by, reason=reason_text, error_label=error_label, require_stock_records=require_stock_records, ) locked_order.is_red_flushed = True locked_order.red_flush_id = current_red_flush_id locked_order.red_flushed_at = now locked_order.save(update_fields=['is_red_flushed', 'red_flush_id', 'red_flushed_at', 'updated_at']) locked_order.refresh_from_db() return locked_order def _ensure_order_not_external_source(order, error_label: str) -> None: if getattr(order, 'is_external_source', False) or getattr(order, 'external_source_id', None): raise ValueError(f'外部来源{error_label}不允许红冲') def _get_original_balance_change_record( *, merchant_id: int, source_type: models.BalanceChangeSourceEnum, source_id: int, error_label: str, ) -> models.BalanceChangeRecord: qs = models.BalanceChangeRecord.objects.select_for_update().filter( merchant_id=merchant_id, source_type=source_type, source_id=source_id, offset_to__isnull=True, cancelled=False, ) try: return qs.get() except models.BalanceChangeRecord.DoesNotExist as exc: raise ValueError(f'{error_label}缺少可红冲的余额变动记录') from exc except models.BalanceChangeRecord.MultipleObjectsReturned as exc: raise ValueError(f'{error_label}存在多条可红冲的余额变动记录') from exc def _create_reverse_balance_change_record( *, original_record: models.BalanceChangeRecord, order, counterparty_field: str, red_flush_id, reason: str, ) -> models.BalanceChangeRecord: reverse_delta = -original_record.delta remarks = f'红冲余额记录 {original_record.id}: {reason}' extra_meta = { 'red_flush_of_balance_change_record': original_record.id, } if counterparty_field == 'supplier': return BalanceService.adjust_supplier_balance( merchant=order.merchant, supplier=order.supplier, delta=reverse_delta, source_type=original_record.source_type, source_id=original_record.source_id, remarks=remarks, extra_meta=extra_meta, red_flush_id=red_flush_id, ) if counterparty_field == 'customer': return BalanceService.adjust_customer_balance( merchant=order.merchant, customer=order.customer, delta=reverse_delta, source_type=original_record.source_type, source_id=original_record.source_id, remarks=remarks, extra_meta=extra_meta, red_flush_id=red_flush_id, ) raise ValueError('未知的业务主体类型') def _red_flush_stock_records_for_order( *, order, stock_source_type: stock_models.StockChangeSourceEnum, red_flush_id, red_flushed_by, reason: str, error_label: str, require_stock_records: bool = True, ) -> None: stock_records = list( stock_models.StockChangeRecord.objects.select_for_update().filter( merchant_id=order.merchant_id, source_type=stock_source_type, source_id=order.id, ).order_by('id') ) if not stock_records: if require_stock_records: raise ValueError(f'{error_label}缺少可红冲的库存记录') return stock_service = StockFlowService(merchant=order.merchant, created_by=red_flushed_by) for stock_record in stock_records: stock_service.offset_stock_change( source_record_id=stock_record.id, reason=reason, red_flush_id=red_flush_id, extra_meta={ 'business_order_type': order.__class__.__name__, 'business_order_id': order.id, }, ) def _normalize_order_items( *, merchant: basic_info_models.Merchant, warehouse: basic_info_models.WareHouse, items: List[Dict[str, Any]], is_outgoing: bool, customer: Optional[basic_info_models.Customer] = None, ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """ 根据仓库模式校验订单明细,并返回: - normalized_items: 用于创建订单明细模型 - stock_flow_items: 传递给 StockFlowService 的 items 结构 is_outgoing=True 表示出库(销售等),需要额外校验 consume_detail_ids。 """ normalized_items: List[Dict[str, Any]] = [] stock_flow_items: List[Dict[str, Any]] = [] warehouse_mode = warehouse.mode for index, raw_item in enumerate(items): product_id = raw_item.get('product_id') if not product_id: raise ValueError(f'items[{index}].product_id 不能为空') try: product = basic_info_models.Product.objects.get(id=product_id, merchant=merchant) except basic_info_models.Product.DoesNotExist as exc: raise ValueError(f'产品 {product_id} 不存在或不属于当前商户') from exc price = _to_decimal(raw_item.get('price', '0'), f'items[{index}].price') empty_diff_percent = _to_decimal(raw_item.get('empty_diff_percent', '0'), f'items[{index}].empty_diff_percent') color = raw_item.get('color') batch_number = raw_item.get('batch_number') remarks = raw_item.get('remarks') spec = raw_item.get('spec') order_quantity = _to_non_negative_int_or_none( raw_item.get('order_quantity'), f'items[{index}].order_quantity', ) unit = raw_item.get('unit') or product.get_unit_display() or '米' printing_job = None raw_printing_job_id = raw_item.get('printing_job') or raw_item.get('printing_job_id') if raw_printing_job_id is not None: try: printing_job_id = int(raw_printing_job_id) except (TypeError, ValueError): raise ValueError(f'items[{index}].printing_job 必须为数字') from printing import models as printing_models try: printing_job = printing_models.PrintingJob.objects.select_related( 'product', 'printing_order__customer' ).get(id=printing_job_id) except printing_models.PrintingJob.DoesNotExist as exc: raise ValueError(f'items[{index}].printing_job 不存在或已删除') from exc if printing_job.product_id != product.id: raise ValueError(f'items[{index}].printing_job 对应的产品与当前明细不一致') if customer and printing_job.printing_order and printing_job.printing_order.customer_id != customer.id: raise ValueError(f'items[{index}].printing_job 客户不匹配') quantity = 0 num_of_rolls = 0 quantity_of_rolls = None consume_detail_ids_str = None if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED: if 'numbers' in raw_item and raw_item['numbers']: raise ValueError(f'仓库为宽进/宽出模式,items[{index}] 不应提供 numbers') quantity = _to_positive_int(raw_item.get('quantity'), f'items[{index}].quantity') num_of_rolls = _to_positive_int(raw_item.get('num_of_rolls'), f'items[{index}].num_of_rolls') stock_flow_items.append({ 'product_id': product.id, 'value': str(quantity), 'num_of_rolls': num_of_rolls, }) elif warehouse_mode == basic_info_models.WareHouseModeEnum.RESTRICT_IN: numbers = raw_item.get('numbers') if not numbers or not isinstance(numbers, list): raise ValueError(f'仓库为严进宽出模式,items[{index}] 需要提供 numbers 数组') normalized_numbers = [ str(_to_positive_int(value, f'items[{index}].numbers[{pos}]')) for pos, value in enumerate(numbers) ] num_of_rolls = len(normalized_numbers) quantity = sum(int(val) for val in normalized_numbers) quantity_of_rolls = ','.join(normalized_numbers) stock_flow_items.append({ 'product_id': product.id, 'quantities': normalized_numbers, }) else: # WareHouseModeEnum.RESTRICT_IN_OUT if is_outgoing: consume_ids = raw_item.get('consume_detail_ids') if not consume_ids or not isinstance(consume_ids, list): raise ValueError(f'仓库为严进严出模式,items[{index}] 需要提供 consume_detail_ids 数组') normalized_ids = [ _to_positive_int(value, f'items[{index}].consume_detail_ids[{pos}]') for pos, value in enumerate(consume_ids) ] consume_detail_ids_str = ','.join(str(value) for value in normalized_ids) quantity = _to_positive_int(raw_item.get('quantity'), f'items[{index}].quantity') num_of_rolls = len(normalized_ids) details_by_id = stock_models.StockChangeDetail.objects.select_related( 'stock_change_record' ).filter( id__in=normalized_ids, merchant=merchant, ).in_bulk() if len(details_by_id) == len(normalized_ids): quantities: List[str] = [] for pos, detail_id in enumerate(normalized_ids): detail = details_by_id[detail_id] if detail.product_id != product.id: raise ValueError( f'items[{index}].consume_detail_ids[{pos}] 产品不匹配' ) if detail.stock_change_record.warehouse_id != warehouse.id: raise ValueError( f'items[{index}].consume_detail_ids[{pos}] 仓库不匹配' ) quantities.append(format(detail.quantity.normalize(), 'f')) quantity_of_rolls = ','.join(quantities) stock_flow_items.append({ 'product_id': product.id, 'consume_detail_ids': normalized_ids, }) else: numbers = raw_item.get('numbers') if not numbers or not isinstance(numbers, list): raise ValueError(f'仓库为严进严出模式,items[{index}] 需要提供 numbers 数组') normalized_numbers = [ str(_to_positive_int(value, f'items[{index}].numbers[{pos}]')) for pos, value in enumerate(numbers) ] num_of_rolls = len(normalized_numbers) quantity = sum(int(val) for val in normalized_numbers) quantity_of_rolls = ','.join(normalized_numbers) stock_flow_items.append({ 'product_id': product.id, 'quantities': normalized_numbers, }) normalized_items.append({ 'product': product, 'price': price, 'color': color, 'quantity': quantity, 'unit': unit, 'empty_diff_percent': empty_diff_percent, 'quantity_of_rolls': quantity_of_rolls, 'num_of_rolls': num_of_rolls, 'batch_number': batch_number, 'remarks': remarks, 'spec': spec, 'order_quantity': order_quantity, 'consume_detail_ids': consume_detail_ids_str, 'printing_job': printing_job, }) return normalized_items, stock_flow_items def _approve_purchase_order( purchase_order: models.PurchaseOrder, reviewed_by, ) -> models.PurchaseOrder: with transaction.atomic(): locked_order = models.PurchaseOrder.objects.select_related( 'merchant', 'warehouse', 'supplier' ).prefetch_related('items').select_for_update().get(id=purchase_order.id) if locked_order.status == models.PurchaseOrderStatusEnum.APPROVED: return locked_order if locked_order.status == models.PurchaseOrderStatusEnum.CANCELLED: raise ValueError('作废状态的采购单无法再次审批') stock_flow_items = _build_stock_flow_items_from_order(locked_order) locked_order.status = models.PurchaseOrderStatusEnum.APPROVED locked_order.save(update_fields=['status', 'updated_at']) BalanceService.adjust_supplier_balance( merchant=locked_order.merchant, supplier=locked_order.supplier, delta=locked_order.get_total_amount(), source_type=models.BalanceChangeSourceEnum.PURCHASE_ORDER, source_id=locked_order.id, ) created_by_id = getattr(reviewed_by, 'id', None) if _auto_stock_task_enabled(locked_order.merchant): logger.info('审批通过采购单 %s,触发入库任务', locked_order.id) create_purchase_order_stock_entries.delay( purchase_order_id=locked_order.id, warehouse_id=locked_order.warehouse_id, items=stock_flow_items, created_by_id=created_by_id, ) locked_order.refresh_from_db(fields=['status', 'updated_at']) return locked_order def _cancel_order_impl( *, order, order_model_cls, approved_status, cancelled_status, error_label: str, stock_source_type=None, ): """统一作废实现:禁止作废已审批单据,可选检查出入库记录。 :param order: 待作废的单据实例(需提供 .id 用于加锁) :param order_model_cls: 单据模型类(用于 select_for_update) :param approved_status: 已审批状态枚举值 :param cancelled_status: 作废状态枚举值 :param error_label: 中文错误提示中的单据名称 :param stock_source_type: StockChangeSourceEnum 值;为 None 则不检查出入库记录 :returns: 刷新后的模型实例 """ with transaction.atomic(): locked = order_model_cls.objects.select_for_update().get(id=order.id) if locked.status == approved_status: raise ValueError(f'已审批的{error_label}无法作废') if stock_source_type is not None and _order_has_stock_records( merchant_id=locked.merchant_id, source_type=stock_source_type, source_id=locked.id, ): raise ValueError(f'{error_label}已生成出入库记录,无法作废') locked.status = cancelled_status locked.save(update_fields=['status', 'updated_at']) locked.refresh_from_db(fields=['status', 'updated_at']) return locked def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.PurchaseOrder: return _cancel_order_impl( order=purchase_order, order_model_cls=models.PurchaseOrder, approved_status=models.PurchaseOrderStatusEnum.APPROVED, cancelled_status=models.PurchaseOrderStatusEnum.CANCELLED, error_label='采购单', stock_source_type=stock_models.StockChangeSourceEnum.PURCHASE, ) def _approve_sales_order( sales_order: models.SalesOrder, reviewed_by, ) -> models.SalesOrder: with transaction.atomic(): locked_order = models.SalesOrder.objects.select_related( 'merchant', 'warehouse', 'customer' ).prefetch_related('items').select_for_update().get(id=sales_order.id) if locked_order.status == models.SalesOrderStatusEnum.APPROVED: return locked_order if locked_order.status == models.SalesOrderStatusEnum.CANCELLED: raise ValueError('作废状态的销售单无法再次审批') stock_flow_items = _build_stock_flow_items_from_order(locked_order) locked_order.status = models.SalesOrderStatusEnum.APPROVED locked_order.save(update_fields=['status', 'updated_at']) BalanceService.adjust_customer_balance( merchant=locked_order.merchant, customer=locked_order.customer, delta=locked_order.get_total_amount(), source_type=models.BalanceChangeSourceEnum.SALES_ORDER, source_id=locked_order.id, ) consume_detail_ids: List[int] = [] for item in locked_order.items.all(): raw_ids = (item.consume_detail_ids or '').strip() if not raw_ids: continue consume_detail_ids.extend( int(value) for value in raw_ids.split(',') if value.strip() ) if consume_detail_ids: stock_models.StockFreeze.objects.filter( merchant=locked_order.merchant, stock_detail_id__in=consume_detail_ids, status=stock_models.StockFreezeStatusEnum.FROZEN, ).update( status=stock_models.StockFreezeStatusEnum.COMPLETED, completed_by=reviewed_by, completed_at=timezone.now(), completed_with=locked_order.id, ) created_by_id = getattr(reviewed_by, 'id', None) if _auto_stock_task_enabled(locked_order.merchant): logger.info('审批通过销售单 %s,触发出库任务', locked_order.id) create_sales_order_stock_entries.delay( sales_order_id=locked_order.id, warehouse_id=locked_order.warehouse_id, items=stock_flow_items, created_by_id=created_by_id, ) locked_order.refresh_from_db(fields=['status', 'updated_at']) return locked_order def _cancel_sales_order(sales_order: models.SalesOrder) -> models.SalesOrder: return _cancel_order_impl( order=sales_order, order_model_cls=models.SalesOrder, approved_status=models.SalesOrderStatusEnum.APPROVED, cancelled_status=models.SalesOrderStatusEnum.CANCELLED, error_label='销售单', stock_source_type=stock_models.StockChangeSourceEnum.SALES, ) def _approve_purchase_return_order( purchase_return_order: models.PurchaseReturnOrder, reviewed_by, ) -> models.PurchaseReturnOrder: with transaction.atomic(): locked_order = models.PurchaseReturnOrder.objects.select_related( 'merchant', 'warehouse', 'supplier' ).prefetch_related('items').select_for_update().get(id=purchase_return_order.id) if locked_order.status == models.PurchaseReturnStatusEnum.APPROVED: return locked_order if locked_order.status == models.PurchaseReturnStatusEnum.CANCELLED: raise ValueError('作废状态的采购退货单无法再次审批') stock_flow_items = _build_stock_flow_items_from_order(locked_order) locked_order.status = models.PurchaseReturnStatusEnum.APPROVED locked_order.save(update_fields=['status', 'updated_at']) BalanceService.adjust_supplier_balance( merchant=locked_order.merchant, supplier=locked_order.supplier, delta=-locked_order.get_total_amount(), source_type=models.BalanceChangeSourceEnum.PURCHASE_RETURN_ORDER, source_id=locked_order.id, ) created_by_id = getattr(reviewed_by, 'id', None) if _auto_stock_task_enabled(locked_order.merchant): logger.info('审批通过采购退货单 %s,触发出库任务', locked_order.id) create_purchase_return_order_stock_entries.delay( purchase_return_order_id=locked_order.id, warehouse_id=locked_order.warehouse_id, items=stock_flow_items, created_by_id=created_by_id, ) locked_order.refresh_from_db(fields=['status', 'updated_at']) return locked_order def _cancel_purchase_return_order( purchase_return_order: models.PurchaseReturnOrder, ) -> models.PurchaseReturnOrder: return _cancel_order_impl( order=purchase_return_order, order_model_cls=models.PurchaseReturnOrder, approved_status=models.PurchaseReturnStatusEnum.APPROVED, cancelled_status=models.PurchaseReturnStatusEnum.CANCELLED, error_label='采购退货单', stock_source_type=stock_models.StockChangeSourceEnum.PURCHASE_RETURN, ) def _approve_sales_return_order( sales_return_order: models.SalesReturnOrder, reviewed_by, ) -> models.SalesReturnOrder: with transaction.atomic(): locked_order = models.SalesReturnOrder.objects.select_related( 'merchant', 'warehouse', 'customer' ).prefetch_related('items').select_for_update().get(id=sales_return_order.id) if locked_order.status == models.SalesReturnStatusEnum.APPROVED: return locked_order if locked_order.status == models.SalesReturnStatusEnum.CANCELLED: raise ValueError('作废状态的销售退货单无法再次审批') stock_flow_items = _build_stock_flow_items_from_order(locked_order) locked_order.status = models.SalesReturnStatusEnum.APPROVED locked_order.save(update_fields=['status', 'updated_at']) BalanceService.adjust_customer_balance( merchant=locked_order.merchant, customer=locked_order.customer, delta=-locked_order.get_total_amount(), source_type=models.BalanceChangeSourceEnum.SALES_RETURN_ORDER, source_id=locked_order.id, ) created_by_id = getattr(reviewed_by, 'id', None) if _auto_stock_task_enabled(locked_order.merchant): logger.info('审批通过销售退货单 %s,触发入库任务', locked_order.id) create_sales_return_order_stock_entries.delay( sales_return_order_id=locked_order.id, warehouse_id=locked_order.warehouse_id, items=stock_flow_items, created_by_id=created_by_id, ) locked_order.refresh_from_db(fields=['status', 'updated_at']) return locked_order def _cancel_sales_return_order( sales_return_order: models.SalesReturnOrder, ) -> models.SalesReturnOrder: return _cancel_order_impl( order=sales_return_order, order_model_cls=models.SalesReturnOrder, approved_status=models.SalesReturnStatusEnum.APPROVED, cancelled_status=models.SalesReturnStatusEnum.CANCELLED, error_label='销售退货单', stock_source_type=stock_models.StockChangeSourceEnum.SALES_RETURN, ) def create_purchase_order_stock_entries_sync( *, purchase_order_id: int, warehouse_id: int, items: List[Dict[str, Any]], created_by_id: int | None = None, ) -> Dict[str, Any]: """ 根据采购单生成入库记录。 """ try: purchase_order = models.PurchaseOrder.objects.select_related('merchant').get(id=purchase_order_id) except models.PurchaseOrder.DoesNotExist: logger.error('PurchaseOrder %s 不存在,无法创建入库单', purchase_order_id) return {'error': 'purchase_order_not_found', 'purchase_order_id': purchase_order_id} merchant = purchase_order.merchant created_by = None if created_by_id: UserModel = get_user_model() created_by = UserModel.objects.filter(id=created_by_id).first() service = StockFlowService(merchant=merchant, created_by=created_by) record, details, created_count = service.stock_in( warehouse_id=warehouse_id, source_type=stock_models.StockChangeSourceEnum.PURCHASE, source_id=purchase_order.id, items=items, ) payload = { 'purchase_order_id': purchase_order.id, 'stock_change_record_id': getattr(record, 'id', None), 'created_details_count': created_count, } logger.info('采购单 %s 入库任务完成: %s', purchase_order.id, payload) return payload def create_sales_order_stock_entries_sync( *, sales_order_id: int, warehouse_id: int, items: List[Dict[str, Any]], created_by_id: int | None = None, ) -> Dict[str, Any]: """ 根据销售单生成出库记录。 """ try: sales_order = models.SalesOrder.objects.select_related('merchant').get(id=sales_order_id) except models.SalesOrder.DoesNotExist: logger.error('SalesOrder %s 不存在,无法创建出库单', sales_order_id) return {'error': 'sales_order_not_found', 'sales_order_id': sales_order_id} merchant = sales_order.merchant created_by = None if created_by_id: UserModel = get_user_model() created_by = UserModel.objects.filter(id=created_by_id).first() service = StockFlowService(merchant=merchant, created_by=created_by) record, details, created_count = service.stock_out( warehouse_id=warehouse_id, source_type=stock_models.StockChangeSourceEnum.SALES, source_id=sales_order.id, items=items, ) payload = { 'sales_order_id': sales_order.id, 'stock_change_record_id': getattr(record, 'id', None), 'created_details_count': created_count, } logger.info('销售单 %s 出库任务完成: %s', sales_order.id, payload) return payload def create_purchase_return_order_stock_entries_sync( *, purchase_return_order_id: int, warehouse_id: int, items: List[Dict[str, Any]], created_by_id: int | None = None, ) -> Dict[str, Any]: """ 根据采购退货单生成出库记录。 """ try: purchase_return_order = models.PurchaseReturnOrder.objects.select_related('merchant').get( id=purchase_return_order_id ) except models.PurchaseReturnOrder.DoesNotExist: logger.error('PurchaseReturnOrder %s 不存在,无法创建出库单', purchase_return_order_id) return {'error': 'purchase_return_order_not_found', 'purchase_return_order_id': purchase_return_order_id} merchant = purchase_return_order.merchant created_by = None if created_by_id: UserModel = get_user_model() created_by = UserModel.objects.filter(id=created_by_id).first() service = StockFlowService(merchant=merchant, created_by=created_by) record, details, created_count = service.stock_out( warehouse_id=warehouse_id, source_type=stock_models.StockChangeSourceEnum.PURCHASE_RETURN, source_id=purchase_return_order.id, items=items, ) payload = { 'purchase_return_order_id': purchase_return_order.id, 'stock_change_record_id': getattr(record, 'id', None), 'created_details_count': created_count, } logger.info('采购退货单 %s 出库任务完成: %s', purchase_return_order.id, payload) return payload def create_sales_return_order_stock_entries_sync( *, sales_return_order_id: int, warehouse_id: int, items: List[Dict[str, Any]], created_by_id: int | None = None, ) -> Dict[str, Any]: """ 根据销售退货单生成入库记录。 """ try: sales_return_order = models.SalesReturnOrder.objects.select_related('merchant').get( id=sales_return_order_id ) except models.SalesReturnOrder.DoesNotExist: logger.error('SalesReturnOrder %s 不存在,无法创建入库单', sales_return_order_id) return {'error': 'sales_return_order_not_found', 'sales_return_order_id': sales_return_order_id} merchant = sales_return_order.merchant created_by = None if created_by_id: UserModel = get_user_model() created_by = UserModel.objects.filter(id=created_by_id).first() service = StockFlowService(merchant=merchant, created_by=created_by) record, details, created_count = service.stock_in( warehouse_id=warehouse_id, source_type=stock_models.StockChangeSourceEnum.SALES_RETURN, source_id=sales_return_order.id, items=items, ) payload = { 'sales_return_order_id': sales_return_order.id, 'stock_change_record_id': getattr(record, 'id', None), 'created_details_count': created_count, } logger.info('销售退货单 %s 入库任务完成: %s', sales_return_order.id, payload) return payload def _build_stock_flow_items_from_order(order) -> List[Dict[str, Any]]: """ 根据订单明细还原 StockFlowService 所需的 items 结构。 """ warehouse_mode = order.warehouse.mode items_payload: List[Dict[str, Any]] = [] order_items = order.items.all() if not order_items: raise ValueError('订单没有任何明细,无法生成库存记录') if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED: for item in order_items: items_payload.append({ 'product_id': item.product_id, 'value': str(item.quantity), 'num_of_rolls': item.num_of_rolls or 1, }) return items_payload # 严进 / 严进严出模式 for item in order_items: consume_ids_raw = getattr(item, 'consume_detail_ids', None) if consume_ids_raw: normalized_ids = [value.strip() for value in consume_ids_raw.split(',') if value.strip()] if not normalized_ids: raise ValueError('严进严出订单缺少 consume_detail_ids 数据,无法生成库存记录') items_payload.append({ 'product_id': item.product_id, 'consume_detail_ids': [int(value) for value in normalized_ids], }) continue raw_numbers = (item.quantity_of_rolls or '').split(',') normalized_numbers = [value.strip() for value in raw_numbers if value.strip()] if not normalized_numbers: raise ValueError('严进仓订单缺少 numbers 数据,无法生成库存记录') items_payload.append({ 'product_id': item.product_id, 'quantities': normalized_numbers, }) return items_payload def can_bind_source_id(record: stock_models.StockChangeRecord) -> bool: """ 判断库存记录是否允许绑定 source_id。 目前默认允许,后续可按业务需要扩展。 """ return True def bind_purchase_order_stock_change_record( *, purchase_order_id: int, stock_change_record_id: int, operator=None, ) -> stock_models.StockChangeRecord: purchase_order = models.PurchaseOrder.objects.select_related( 'merchant', 'warehouse' ).get(id=purchase_order_id) if _auto_stock_task_enabled(purchase_order.merchant): raise ValueError('自动入库任务已开启,无法手动绑定库存记录') if _order_has_stock_records( merchant_id=purchase_order.merchant_id, source_type=stock_models.StockChangeSourceEnum.PURCHASE, source_id=purchase_order.id, ): raise ValueError('采购单已绑定库存记录') with transaction.atomic(): record = stock_models.StockChangeRecord.objects.select_for_update().get( id=stock_change_record_id ) if record.source_id is not None: raise ValueError('库存记录已绑定业务单据') if record.merchant_id != purchase_order.merchant_id: raise ValueError('库存记录不属于当前商户') if record.warehouse_id != purchase_order.warehouse_id: raise ValueError('库存记录仓库不匹配') if record.type != stock_models.StockChangeTypeEnum.ADD: raise ValueError('仅允许绑定入库类型库存记录') if record.source_type != stock_models.StockChangeSourceEnum.PURCHASE: raise ValueError('仅允许绑定采购来源的库存记录') if not can_bind_source_id(record): raise ValueError('库存记录当前状态不允许绑定') record.source_id = purchase_order.id record.save(update_fields=['source_id', 'updated_at']) operator_id = getattr(operator, 'id', None) logger.info( '采购单 %s 绑定库存记录 %s, operator=%s', purchase_order.id, record.id, operator_id, ) return record def _auto_stock_task_enabled(merchant: basic_info_models.Merchant) -> bool: try: setting = MerchantSettingService.get_setting( merchant, basic_info_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, ) except basic_info_models.MerchantSetting.DoesNotExist: logger.warning('商户 %s 未配置 auto_create_stock_change_tasks,默认关闭', merchant.id) return False return setting.value is True def _order_has_stock_records( *, merchant_id: int, source_type: stock_models.StockChangeSourceEnum, source_id: int, ) -> bool: return stock_models.StockChangeRecord.objects.filter( merchant_id=merchant_id, source_type=source_type, source_id=source_id, ).exists() def _resolve_purchase_order_instance( purchase_order: models.PurchaseOrder | None, purchase_order_id: int | None, ) -> models.PurchaseOrder: if purchase_order is None and purchase_order_id is None: raise ValueError('必须提供 purchase_order 或 purchase_order_id') if purchase_order is not None: purchase_order_id = purchase_order.id return models.PurchaseOrder.objects.select_related('merchant', 'warehouse').prefetch_related('items').get( id=purchase_order_id ) def _resolve_sales_order_instance( sales_order: models.SalesOrder | None, sales_order_id: int | None, ) -> models.SalesOrder: if sales_order is None and sales_order_id is None: raise ValueError('必须提供 sales_order 或 sales_order_id') if sales_order is not None: sales_order_id = sales_order.id return models.SalesOrder.objects.select_related('merchant', 'warehouse').prefetch_related('items').get( id=sales_order_id ) def _resolve_purchase_return_order_instance( purchase_return_order: models.PurchaseReturnOrder | None, purchase_return_order_id: int | None, ) -> models.PurchaseReturnOrder: if purchase_return_order is None and purchase_return_order_id is None: raise ValueError('必须提供 purchase_return_order 或 purchase_return_order_id') if purchase_return_order is not None: purchase_return_order_id = purchase_return_order.id return models.PurchaseReturnOrder.objects.select_related('merchant', 'warehouse').prefetch_related('items').get( id=purchase_return_order_id ) def _resolve_sales_return_order_instance( sales_return_order: models.SalesReturnOrder | None, sales_return_order_id: int | None, ) -> models.SalesReturnOrder: if sales_return_order is None and sales_return_order_id is None: raise ValueError('必须提供 sales_return_order 或 sales_return_order_id') if sales_return_order is not None: sales_return_order_id = sales_return_order.id return models.SalesReturnOrder.objects.select_related('merchant', 'warehouse').prefetch_related('items').get( id=sales_return_order_id ) def _resolve_payment_order_instance( payment_order: models.PaymentOrder | None, payment_order_id: int | None, ) -> models.PaymentOrder: if payment_order is None and payment_order_id is None: raise ValueError('必须提供 payment_order 或 payment_order_id') if payment_order is not None: payment_order_id = payment_order.id return models.PaymentOrder.objects.select_related('merchant', 'supplier').get(id=payment_order_id) def _resolve_receipt_order_instance( receipt_order: models.ReceiptOrder | None, receipt_order_id: int | None, ) -> models.ReceiptOrder: if receipt_order is None and receipt_order_id is None: raise ValueError('必须提供 receipt_order 或 receipt_order_id') if receipt_order is not None: receipt_order_id = receipt_order.id return models.ReceiptOrder.objects.select_related('merchant', 'customer').get(id=receipt_order_id) def _to_decimal(value, field_name: str) -> Decimal: try: return Decimal(str(value)) except (InvalidOperation, TypeError) as exc: raise ValueError(f'{field_name} 必须是合法数值') from exc def _ensure_non_zero_amount(value, field_name: str) -> Decimal: amount = _to_decimal(value, field_name) # 金额类字段要求非零(允许负数用于处理退款场景) # 付款单负金额 = 供应商退款,收款单负金额 = 退款给客户 if amount == 0: raise ValueError(f'{field_name} 不能为 0') return amount def _ensure_order_pending(order, pending_status, entity_name: str): if getattr(order, 'status', None) != pending_status: raise ValueError(f'{entity_name}当前状态不可修改') def _ensure_non_negative_amount(value, field_name: str) -> Decimal: if value in (None, ''): return Decimal('0') amount = _to_decimal(value, field_name) if amount < 0: raise ValueError(f'{field_name} 不能小于 0') return amount def _to_positive_int(value, field_name: str) -> int: if value is None: raise ValueError(f'{field_name} 不能为空') decimal_value = _to_decimal(value, field_name) if decimal_value <= 0: raise ValueError(f'{field_name} 必须大于 0') if decimal_value != decimal_value.to_integral_value(): raise ValueError(f'{field_name} 必须为整数') return int(decimal_value) def _to_non_negative_int_or_none(value, field_name: str) -> int | None: if value in (None, ''): return None decimal_value = _to_decimal(value, field_name) if decimal_value < 0: raise ValueError(f'{field_name} 不能小于 0') if decimal_value != decimal_value.to_integral_value(): raise ValueError(f'{field_name} 必须为整数') return int(decimal_value) # ==================== Statement Builders ==================== _STATEMENT_TWO_PLACES = Decimal('0.01') _STATEMENT_ZERO = Decimal('0') STATEMENT_COUNTERPARTY_CHOICES = ( ('customer', '客户'), ('supplier', '供应商'), ) STATEMENT_ORDER_TYPE_CHOICES = ( ('sales_order', '销售单'), ('sales_return_order', '销售退货单'), ('receipt_order', '收款单'), ('external_sales_order', '外部销售单'), ('external_sales_return_order', '外部销售退货单'), ('purchase_order', '采购单'), ('purchase_return_order', '采购退货单'), ('payment_order', '付款单'), ) def build_customer_statement( *, merchant: basic_info_models.Merchant, customer: basic_info_models.Customer, ) -> Dict[str, Any]: """ 根据客户历史单据生成对账记录,供多个 API 复用。 """ statement_balance = BalanceService.get_customer_statement_balance(merchant=merchant, customer=customer) builder = _CustomerStatementBuilder(merchant=merchant, current_balance=statement_balance) records = builder.collect_records(customer) return builder.build_payload( counterparty_id=customer.id, counterparty_name=customer.name, records=records, ) def build_supplier_statement( *, merchant: basic_info_models.Merchant, supplier: basic_info_models.Supplier, ) -> Dict[str, Any]: """ 根据供应商历史单据生成对账记录,供多个 API 复用。 """ balance = BalanceService.get_supplier_balance(merchant=merchant, supplier=supplier) builder = _SupplierStatementBuilder(merchant=merchant, current_balance=balance) records = builder.collect_records(supplier) return builder.build_payload( counterparty_id=supplier.id, counterparty_name=supplier.name, records=records, ) def build_statement_summary(payload: Dict[str, Any]) -> Dict[str, str]: """ 通用汇总函数,配合 StatementResponseSerializer 的 summary 字段。 """ records = payload.get('records', []) or [] total_positive = sum( (record.get('positive_amount', _STATEMENT_ZERO) for record in records), _STATEMENT_ZERO, ) total_negative = sum( (record.get('negative_amount', _STATEMENT_ZERO) for record in records), _STATEMENT_ZERO, ) return { 'positive_total': _decimal_to_string(total_positive), 'negative_total': _decimal_to_string(total_negative), } class _StatementBuilder: def __init__( self, *, merchant: basic_info_models.Merchant, current_balance: Decimal, ): self.merchant = merchant self._current_balance_value = _normalize_statement_amount(current_balance) self._current_balance_display = _decimal_to_string(self._current_balance_value) def adjust_current_balance(self, delta: Decimal) -> None: self._current_balance_value += _normalize_statement_amount(delta) self._current_balance_display = _decimal_to_string(self._current_balance_value) def collect_records(self, counterparty) -> List[dict]: # pragma: no cover - interface only raise NotImplementedError def build_payload( self, *, counterparty_id: int, counterparty_name: str, records: List[dict], ) -> Dict[str, Any]: sorted_records = self._sort_records(records) processed_records = self._attach_running_totals(sorted_records) return { 'counterparty': counterparty_id, 'counterparty_name': counterparty_name, 'records': processed_records, } def _build_record( self, *, counterparty_id: int, counterparty_name: str, source_type: str, source_label: str, source_id: int, occurred_at, recorded_at, status: int, status_label: str, warehouse: basic_info_models.WareHouse | None = None, positive_amount, negative_amount, remarks: str | None = '', items: List[dict] | None = None, extra: dict | None = None, ) -> dict: items = items or [] record = { 'counterparty': counterparty_id, 'counterparty_name': counterparty_name, 'source_type': source_type, 'source_label': source_label, 'source_id': source_id, 'occurred_at': occurred_at, 'recorded_at': recorded_at, 'status': status, 'status_label': status_label, # 注意:收款单/付款单等资金类单据无仓库概念,warehouse 将为 None 'warehouse': warehouse, 'positive_amount': _normalize_statement_amount(positive_amount), 'negative_amount': _normalize_statement_amount(negative_amount), 'remarks': remarks or '', 'items': items, } if extra: record['extra'] = extra return record def _aggregate_items(self, order_items) -> List[dict]: aggregated: OrderedDict[tuple, dict] = OrderedDict() for item in order_items: product = getattr(item, 'product', None) product_id = getattr(item, 'product_id', None) product_name = getattr(product, 'name', '') unit = getattr(item, 'unit', '') price = getattr(item, 'price', Decimal('0')) # 这些字段均来自订单明细(*OrderItem),用于在对账单中对 items 进行合理聚合: # - 同一产品不同颜色/规格应拆分展示,避免合并后信息丢失 color = getattr(item, 'color', None) or '' spec = getattr(item, 'spec', None) or '' key = (product_id, product_name, unit, price, color, spec) if key not in aggregated: aggregated[key] = { 'product_id': product_id, 'product_name': product_name, 'quantity': Decimal('0'), 'price': price, 'unit': unit, 'color': color, 'spec': spec, # 统一输出各条数数量(严格模式下为每条数量列表;非严格模式/未填则为空列表) # 来源于各 *OrderItem 模型新增的 split_quantity_of_rolls() 方法 'quantity_of_rolls': [], # 条数:来自各 *OrderItem.num_of_rolls。聚合时对相同产品/单价的多行明细累加。 'num_of_rolls': 0, } quantity_value = getattr(item, 'quantity', 0) or 0 aggregated[key]['quantity'] += Decimal(str(quantity_value)) num_of_rolls_value = getattr(item, 'num_of_rolls', None) try: num_of_rolls_int = int(num_of_rolls_value) if num_of_rolls_value is not None else 0 except (TypeError, ValueError): # pragma: no cover - defensive num_of_rolls_int = 0 split_quantity_of_rolls = getattr(item, 'split_quantity_of_rolls', None) if callable(split_quantity_of_rolls): try: rolls = split_quantity_of_rolls() or [] except Exception: # pragma: no cover - defensive rolls = [] if rolls: aggregated[key]['quantity_of_rolls'].extend(list(rolls)) # 若无 num_of_rolls(或为 0),兜底使用 rolls 长度 if not num_of_rolls_int: num_of_rolls_int = len(rolls) aggregated[key]['num_of_rolls'] += num_of_rolls_int return list(aggregated.values()) def _sort_records(self, records: Iterable[dict]) -> List[dict]: return sorted( records, key=lambda item: (item['occurred_at'], item['recorded_at'], item['source_id']), reverse=True, ) def _attach_running_totals(self, records: List[dict]) -> List[dict]: running_total = _STATEMENT_ZERO processed: List[dict] = [] for record in records: record_copy = dict(record) record_copy['cumulative_amount'] = _decimal_to_string(running_total) record_copy['current_balance'] = self._current_balance_display arrears_amount = self._current_balance_value - running_total record_copy['arrears_amount'] = _decimal_to_string(arrears_amount) delta = record_copy['positive_amount'] - record_copy['negative_amount'] running_total += delta processed.append(record_copy) return processed class _CustomerStatementBuilder(_StatementBuilder): def __init__(self, *, merchant: basic_info_models.Merchant, current_balance: Decimal): super().__init__(merchant=merchant, current_balance=current_balance) self.external_balance_adjustment = Decimal('0') def collect_records(self, customer: basic_info_models.Customer) -> List[dict]: records: List[dict] = [] records.extend(self._build_sales_records(customer)) records.extend(self._build_sales_return_records(customer)) records.extend(self._build_receipt_records(customer)) records.extend(self._build_external_statement_records(customer)) return records def _build_sales_records(self, customer: basic_info_models.Customer) -> List[dict]: qs = ( models.SalesOrder.objects.filter( merchant=self.merchant, customer=customer, status=models.SalesOrderStatusEnum.APPROVED, is_red_flushed=False, ) .select_related('customer', 'warehouse') .prefetch_related('items__product') ) records = [] for order in qs: items = self._aggregate_items(order.items.all()) records.append( self._build_record( counterparty_id=order.customer_id, counterparty_name=order.customer.name, source_type='sales_order', source_label='销售单', source_id=order.id, occurred_at=order.sales_date, recorded_at=order.created_at, status=order.status, status_label=order.get_status_display(), warehouse=order.warehouse, positive_amount=order.get_total_amount(), negative_amount=_STATEMENT_ZERO, remarks=order.remarks, items=items, ) ) return records def _build_sales_return_records(self, customer: basic_info_models.Customer) -> List[dict]: qs = ( models.SalesReturnOrder.objects.filter( merchant=self.merchant, customer=customer, status=models.SalesReturnStatusEnum.APPROVED, is_red_flushed=False, ) .select_related('customer', 'warehouse') .prefetch_related('items__product') ) records = [] for order in qs: items = self._aggregate_items(order.items.all()) records.append( self._build_record( counterparty_id=order.customer_id, counterparty_name=order.customer.name, source_type='sales_return_order', source_label='销售退货单', source_id=order.id, occurred_at=order.return_date, recorded_at=order.created_at, status=order.status, status_label=order.get_status_display(), warehouse=order.warehouse, positive_amount=_STATEMENT_ZERO, negative_amount=order.get_total_amount(), remarks=order.remarks, items=items, ) ) return records def _build_receipt_records(self, customer: basic_info_models.Customer) -> List[dict]: qs = ( models.ReceiptOrder.objects.filter( merchant=self.merchant, customer=customer, status=models.ReceiptOrderStatusEnum.APPROVED, is_red_flushed=False, ) .select_related('customer') ) records = [] for order in qs: # ERP 口径:收款单的 ZkJinE 从应收侧扣减(负的 positive_amount), # 已收只展示 FkJinE(order.amount)。净效果与 settlement_amount 一致。 discount = order.discount_amount or Decimal('0') records.append( self._build_record( counterparty_id=order.customer_id, counterparty_name=order.customer.name, source_type='receipt_order', source_label='收款单', source_id=order.id, occurred_at=order.receipt_date, recorded_at=order.created_at, status=order.status, status_label=order.get_status_display(), positive_amount=-discount, negative_amount=order.amount, remarks=order.remarks, ) ) return records def _build_external_statement_records(self, customer: basic_info_models.Customer) -> List[dict]: qs = models.ExternalCustomerStatementOrder.objects.filter( merchant=self.merchant, customer=customer, ).order_by('occurred_at', 'recorded_at', 'id') records = [] for order in qs: sf = order.sf_amount or Decimal('0') zk = order.zk_amount or Decimal('0') if order.category == models.ExternalCustomerStatementCategoryEnum.SALE: source_type = 'external_sales_order' source_label = '外部销售单' # 净应收 = 毛额 - 折扣;现场收款作为 negative_amount 减少欠款 positive_amount = order.total_amount - zk negative_amount = sf else: source_type = 'external_sales_return_order' source_label = '外部销售退货单' # 退货减少应收:退货金额 + 退货折扣都减少欠款 positive_amount = _STATEMENT_ZERO negative_amount = order.total_amount + zk self.external_balance_adjustment += positive_amount - negative_amount records.append( self._build_record( counterparty_id=order.customer_id, counterparty_name=order.customer.name, source_type=source_type, source_label=source_label, source_id=order.id, occurred_at=order.occurred_at, recorded_at=order.recorded_at or order.created_at, status=models.ReceiptOrderStatusEnum.APPROVED, status_label='已同步', positive_amount=positive_amount, negative_amount=negative_amount, remarks=order.remarks, items=list(order.items_payload or []), extra={ 'external_source_id': order.external_source_id, 'external_customer_id': order.external_customer_id, 'settlement_method': order.settlement_method, }, ) ) return records class _SupplierStatementBuilder(_StatementBuilder): def collect_records(self, supplier: basic_info_models.Supplier) -> List[dict]: records: List[dict] = [] records.extend(self._build_purchase_records(supplier)) records.extend(self._build_purchase_return_records(supplier)) records.extend(self._build_payment_records(supplier)) return records def _build_purchase_records(self, supplier: basic_info_models.Supplier) -> List[dict]: qs = ( models.PurchaseOrder.objects.filter( merchant=self.merchant, supplier=supplier, status=models.PurchaseOrderStatusEnum.APPROVED, is_red_flushed=False, ) .select_related('supplier', 'warehouse') .prefetch_related('items__product') ) records = [] for order in qs: items = self._aggregate_items(order.items.all()) records.append( self._build_record( counterparty_id=order.supplier_id, counterparty_name=order.supplier.name, source_type='purchase_order', source_label='采购单', source_id=order.id, occurred_at=order.purchase_date, recorded_at=order.created_at, status=order.status, status_label=order.get_status_display(), warehouse=order.warehouse, positive_amount=order.get_total_amount(), negative_amount=_STATEMENT_ZERO, remarks=order.remarks, items=items, ) ) return records def _build_purchase_return_records(self, supplier: basic_info_models.Supplier) -> List[dict]: qs = ( models.PurchaseReturnOrder.objects.filter( merchant=self.merchant, supplier=supplier, status=models.PurchaseReturnStatusEnum.APPROVED, is_red_flushed=False, ) .select_related('supplier', 'warehouse') .prefetch_related('items__product') ) records = [] for order in qs: items = self._aggregate_items(order.items.all()) records.append( self._build_record( counterparty_id=order.supplier_id, counterparty_name=order.supplier.name, source_type='purchase_return_order', source_label='采购退货单', source_id=order.id, occurred_at=order.return_date, recorded_at=order.created_at, status=order.status, status_label=order.get_status_display(), warehouse=order.warehouse, positive_amount=_STATEMENT_ZERO, negative_amount=order.get_total_amount(), remarks=order.remarks, items=items, ) ) return records def _build_payment_records(self, supplier: basic_info_models.Supplier) -> List[dict]: qs = ( models.PaymentOrder.objects.filter( merchant=self.merchant, supplier=supplier, status=models.PaymentOrderStatusEnum.APPROVED, is_red_flushed=False, ) .select_related('supplier') ) records = [] for order in qs: records.append( self._build_record( counterparty_id=order.supplier_id, counterparty_name=order.supplier.name, source_type='payment_order', source_label='付款单', source_id=order.id, occurred_at=order.payment_date, recorded_at=order.created_at, status=order.status, status_label=order.get_status_display(), positive_amount=_STATEMENT_ZERO, negative_amount=order.get_total_amount(), remarks=order.remarks, ) ) return records def _normalize_statement_amount(value) -> Decimal: if isinstance(value, Decimal): decimal_value = value else: decimal_value = Decimal(str(value)) return decimal_value.quantize(_STATEMENT_TWO_PLACES, rounding=ROUND_HALF_UP) def _decimal_to_string(value: Decimal) -> str: normalized = _normalize_statement_amount(value) return format(normalized, 'f')