1
0
forked from erp-dev/erp
Files
erpnew/business/services.py
2026-06-22 22:27:28 +08:00

2813 lines
106 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
# 已收只展示 FkJinEorder.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')