1
0
forked from erp-dev/erp

feat: sales_order api

This commit is contained in:
2025-11-30 17:06:48 +08:00
parent f6f0ec2ae9
commit ce1954ad58
15 changed files with 1426 additions and 44 deletions

61
business/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,61 @@
# business 模块结构与设计说明
本文档记录业务模块当前的结构、关键约定以及未来演进需要遵循的决策背景,防止在迭代中遗失设计意图。
## 1. 模块职责
`business` 负责“业务单据”领域目前包含采购单PurchaseOrder与销售单SalesOrder同时保留可扩展至退货单等更多对象的通用框架。核心职责
- 聚合与校验业务明细数据
- 通过服务层触发库存stock、财务等下游模块
- 统一处理审批、作废等状态流转
## 2. 模型抽象
### 2.1 OrderItemsAggregationMixin
封装订单明细聚合逻辑,默认访问 `items` 关联,可通过 `order_items_accessor` 指定其他关联名。提供:
- `get_total_amount()`
- `get_total_quantity()`
- `get_total_diff_quantity()`
这些方法仅关心明细结构,不关心订单方向。
### 2.2 OrderDirectionMixin
用于获取带方向的金额,约定:
- `get_direction()` 返回 `1`(正向/入库)或 `-1`(负向/出库)
- `get_signed_total_amount()` 在内部调用 `get_total_amount()` 并乘以方向
任何需要“正负金额”的业务(财务统计、库存红冲)都应依赖此能力,而不是重复编写正负逻辑。
### 2.3 OrderCounterpartyMixin
对外暴露统一的 `get_counterparty()` 接口,通过 `get_counterparty_field_name()`(或 `counterparty_field_name` 属性)确定具体业务主体字段。这样采购单/销售单分别返回 `Supplier``Customer`,但调用方只需面对一个接口。
### 2.4 PurchaseOrder / SalesOrder
两类订单模型均继承上述三个 Mixin
- 采购单:`get_direction()` 返回 `1``get_counterparty_field_name()` 返回 `supplier`
- 销售单:`get_direction()` 返回 `-1``get_counterparty_field_name()` 返回 `customer`
- 其余字段(`merchant/warehouse/operator/status/items`)保持一致,便于服务、序列化与统计逻辑复用
通过 mixin两个模型天然具备
- 金额聚合与带方向金额计算
- 统一的业务主体读取接口
- 与库存/财务交互时一致的 `StockFlowService` payload
## 3. 服务层约定
- `business.services` 负责 orchestration与 API 解耦。审批、作废、触发库存等流程必须先在服务层实现,再暴露给 API。
- 任何涉及库存的逻辑都必须通过 `stock.services.StockFlowService`,不得直接操作库存模型,保持模块边界清晰。
- 红冲/对冲等高级动作应由业务模块提供入口(例如 `PurchaseOrder` 红冲),但最终仍调用库存服务完成实际库存变动。
## 4. 未来演进建议
1. **新增单据**:若未来出现调拨单、退货单等,优先继承 `OrderItemsAggregationMixin + OrderDirectionMixin + OrderCounterpartyMixin`,仅通过 `get_direction()` / `get_counterparty_field_name()` 区别方向与主体,减少重复实现。
2. **审批/状态机**:采购与销售如需共享状态流转,可提炼状态机或 service 层 mixin而无需在模型层合并。
3. **统计与报表**:财务/库存统计应依赖 `get_signed_total_amount()` / `get_direction()`,确保采购/销售、退货/正向都能通过统一接口处理。
4. **文档同步**:新增单据或服务时必须更新本文件,描述新增模型如何复用 mixin、如何影响下游模块保持设计透明。
以上约定的目标是:**保持采购单与未来销售单等业务对象的独立性,同时通过 mixin/服务层抽象复用绝大多数公共逻辑**。如需变更此架构(例如重新合并模型或修改核心 mixin 行为),请在评估后更新本文件,明确变化原因与迁移方案。

View File

@@ -0,0 +1,62 @@
# Generated by Django 5.2.7 on 2025-11-30 08:32
import business.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('basic_info', '0016_merchantsetting_type'),
('business', '0009_purchaseorderitem_spec'),
]
operations = [
migrations.CreateModel(
name='SalesOrder',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('sales_date', models.DateField(verbose_name='销售日期')),
('kind', models.IntegerField(choices=[(1, '大货'), (2, '样板')], default=1, verbose_name='销售单类型')),
('status', models.IntegerField(choices=[(1, '审批中'), (2, '审批通过'), (3, '作废')], default=1, verbose_name='状态')),
('remarks', models.TextField(blank=True, null=True, verbose_name='备注')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='basic_info.customer', verbose_name='客户')),
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='basic_info.merchant', verbose_name='所属商户')),
('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='basic_info.employee', verbose_name='经办人')),
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='basic_info.warehouse', verbose_name='仓库')),
],
options={
'verbose_name': '销售单',
'verbose_name_plural': '销售单',
},
bases=(business.models.OrderItemsAggregationMixin, business.models.OrderDirectionMixin, business.models.OrderCounterpartyMixin, models.Model),
),
migrations.CreateModel(
name='SalesOrderItem',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('price', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='单价')),
('color', models.CharField(blank=True, max_length=50, null=True, verbose_name='颜色')),
('quantity', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='数量')),
('unit', models.CharField(max_length=50, verbose_name='单位')),
('spec', models.CharField(blank=True, max_length=100, null=True, verbose_name='规格')),
('empty_diff_percent', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='空差百分比')),
('quantity_of_rolls', models.CharField(blank=True, max_length=255, null=True, verbose_name='各条数数量')),
('num_of_rolls', models.PositiveIntegerField(default=1, verbose_name='条数')),
('consume_detail_ids', models.CharField(blank=True, max_length=255, null=True, verbose_name='消耗入库明细ID列表')),
('batch_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='批次号')),
('remarks', models.TextField(blank=True, null=True, verbose_name='备注')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_order_items', to='basic_info.product', verbose_name='产品')),
('sales_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='business.salesorder', verbose_name='销售单')),
],
options={
'verbose_name': '销售单明细',
'verbose_name_plural': '销售单明细',
},
),
]

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from decimal import Decimal
from django.db import models
from flower.common import ModelBase
@@ -17,7 +19,76 @@ class PurchaseOrderStatusEnum(models.IntegerChoices):
CANCELLED = 3, '作废'
class PurchaseOrder(ModelBase):
class OrderItemsAggregationMixin:
"""
提供订单金额/数量聚合的通用实现。
默认读取 `items` 关联管理器,可在子类通过 `order_items_accessor`
覆盖以适配不同的关联名称。
"""
order_items_accessor = 'items'
def _iter_order_items(self):
return getattr(self, self.order_items_accessor).all()
def get_total_amount(self) -> Decimal:
total = Decimal('0')
for item in self._iter_order_items():
total += Decimal(item.total_amount())
return total
def get_total_quantity(self) -> Decimal:
total = Decimal('0')
for item in self._iter_order_items():
total += Decimal(item.quantity)
return total
def get_total_diff_quantity(self) -> Decimal:
total = Decimal('0')
for item in self._iter_order_items():
total += Decimal(item.diff_quantity())
return total
class OrderDirectionMixin:
"""提供带方向金额的通用实现,子类只需要覆盖方向。"""
def get_direction(self) -> int:
"""
返回订单方向:
入库 / 正向金额: 1
出库 / 负向金额: -1
"""
def get_signed_total_amount(self) -> Decimal:
return self.get_total_amount() * Decimal(self.get_direction())
class OrderCounterpartyMixin:
"""
抽象出“订单关联业务主体”的能力,子类只需指出字段名称,
即可通过统一接口获取 Supplier / Customer 等对象。
"""
counterparty_field_name = ''
def get_counterparty_field_name(self) -> str:
"""返回用于存放业务主体的字段名,如 supplier / customer。"""
def get_counterparty(self) -> basic_info_models.Supplier | basic_info_models.Customer:
field_name = self.get_counterparty_field_name() or self.counterparty_field_name
if not field_name:
raise AttributeError('未定义 counterparty 字段名称')
try:
counterparty = getattr(self, field_name)
except AttributeError as exc:
raise AttributeError(f'找不到字段 {field_name}') from exc
if counterparty is None:
raise ValueError(f'{field_name} 未设置,无法获取业务主体')
return counterparty
class PurchaseOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
"""采购单模型(业务模块)"""
id = models.BigAutoField(primary_key=True)
@@ -65,14 +136,11 @@ class PurchaseOrder(ModelBase):
verbose_name = '采购单'
verbose_name_plural = '采购单'
def get_total_amount(self) -> Decimal:
return sum(item.total_amount() for item in self.items.all())
def get_direction(self) -> int:
return 1
def get_total_quantity(self) -> Decimal:
return sum(item.quantity for item in self.items.all())
def get_total_diff_quantity(self) -> Decimal:
return sum(item.diff_quantity() for item in self.items.all())
def get_counterparty_field_name(self) -> str:
return 'supplier'
class PurchaseOrderItem(ModelBase):
@@ -124,3 +192,127 @@ class PurchaseOrderItem(ModelBase):
def total_amount(self):
return round(self.price * self.real_quantity(), 2)
class SalesOrderKindEnum(models.IntegerChoices):
"""销售单类型"""
WHOLESALE = 1, '大货'
SAMPLE = 2, '样板'
class SalesOrderStatusEnum(models.IntegerChoices):
"""销售单状态"""
PENDING = 1, '审批中'
APPROVED = 2, '审批通过'
CANCELLED = 3, '作废'
class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
"""销售单模型"""
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
basic_info_models.Merchant,
on_delete=models.PROTECT,
related_name='sales_orders',
verbose_name='所属商户',
)
customer = models.ForeignKey(
basic_info_models.Customer,
on_delete=models.PROTECT,
related_name='sales_orders',
verbose_name='客户',
)
sales_date = models.DateField(verbose_name='销售日期')
kind = models.IntegerField(
choices=SalesOrderKindEnum.choices,
default=SalesOrderKindEnum.WHOLESALE,
verbose_name='销售单类型',
)
operator = models.ForeignKey(
basic_info_models.Employee,
on_delete=models.PROTECT,
related_name='sales_orders',
verbose_name='经办人',
)
warehouse = models.ForeignKey(
basic_info_models.WareHouse,
on_delete=models.PROTECT,
related_name='sales_orders',
verbose_name='仓库',
)
status = models.IntegerField(
choices=SalesOrderStatusEnum.choices,
default=SalesOrderStatusEnum.PENDING,
verbose_name='状态',
)
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
def __str__(self):
return f'销售订单 {self.id} - {self.customer.name}'
class Meta:
verbose_name = '销售单'
verbose_name_plural = '销售单'
def get_direction(self) -> int:
return -1
def get_counterparty_field_name(self) -> str:
return 'customer'
class SalesOrderItem(ModelBase):
"""销售单明细模型"""
id = models.BigAutoField(primary_key=True)
sales_order = models.ForeignKey(
SalesOrder,
on_delete=models.CASCADE,
related_name='items',
verbose_name='销售单',
)
product = models.ForeignKey(
basic_info_models.Product,
on_delete=models.PROTECT,
related_name='sales_order_items',
verbose_name='产品',
)
price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='单价')
color = models.CharField(max_length=50, null=True, blank=True, verbose_name='颜色')
quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量')
unit = models.CharField(max_length=50, verbose_name='单位')
spec = models.CharField(max_length=100, null=True, blank=True, verbose_name='规格')
empty_diff_percent = models.DecimalField(
max_digits=15,
decimal_places=2,
verbose_name='空差百分比',
)
quantity_of_rolls = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name='各条数数量',
)
num_of_rolls = models.PositiveIntegerField(default=1, verbose_name='条数')
consume_detail_ids = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name='消耗入库明细ID列表',
)
batch_number = models.CharField(null=True, blank=True, max_length=100, verbose_name='批次号')
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
class Meta:
verbose_name = '销售单明细'
verbose_name_plural = '销售单明细'
def real_quantity(self):
return round(self.quantity * (1 - self.empty_diff_percent / 100), 2)
def diff_quantity(self):
return round(self.quantity * (self.empty_diff_percent / 100), 2)
def total_amount(self):
return round(self.price * self.real_quantity(), 2)

View File

@@ -14,7 +14,10 @@ from stock.services import StockFlowService
from basic_info.services import MerchantSettingService
from . import models
from .tasks import create_purchase_order_stock_entries
from .tasks import (
create_purchase_order_stock_entries,
create_sales_order_stock_entries,
)
logger = logging.getLogger(__name__)
@@ -61,10 +64,11 @@ def create_purchase_order(
raise ValueError('items 不能为空')
normalized_date = _normalize_order_date(order_date)
purchase_items, stock_flow_items = _normalize_purchase_items(
purchase_items, stock_flow_items = _normalize_order_items(
merchant=merchant,
warehouse=warehouse,
items=items,
is_outgoing=False,
)
with transaction.atomic():
@@ -99,6 +103,64 @@ def create_purchase_order(
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,
) -> models.SalesOrder:
"""
创建销售订单,后续审批通过后会触发出库任务。
"""
if not items:
raise ValueError('items 不能为空')
normalized_date = _normalize_order_date(order_date)
sales_items, stock_flow_items = _normalize_order_items(
merchant=merchant,
warehouse=warehouse,
items=items,
is_outgoing=True,
)
with transaction.atomic():
sales_order = models.SalesOrder.objects.create(
merchant=merchant,
customer=customer,
sales_date=normalized_date,
operator=operator,
warehouse=warehouse,
remarks=remarks,
)
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'),
remarks=item_data.get('remarks'),
)
for item_data in sales_items
]
models.SalesOrderItem.objects.bulk_create(bulk_objects)
sales_order.refresh_from_db()
return sales_order
def review_purchase_order(
*,
purchase_order: models.PurchaseOrder | None = None,
@@ -131,18 +193,49 @@ def review_purchase_order(
return _cancel_purchase_order(order)
def _normalize_purchase_items(
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 _normalize_order_items(
*,
merchant: basic_info_models.Merchant,
warehouse: basic_info_models.WareHouse,
items: List[Dict[str, Any]],
is_outgoing: bool,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
根据仓库模式校验采购明细,并返回
- purchase_items: 用于创建 PurchaseOrderItem
根据仓库模式校验订单明细,并返回
- normalized_items: 用于创建订单明细模型
- stock_flow_items: 传递给 StockFlowService 的 items 结构
is_outgoing=True 表示出库(销售等),需要额外校验 consume_detail_ids。
"""
purchase_items: List[Dict[str, Any]] = []
normalized_items: List[Dict[str, Any]] = []
stock_flow_items: List[Dict[str, Any]] = []
warehouse_mode = warehouse.mode
@@ -164,21 +257,25 @@ def _normalize_purchase_items(
spec = raw_item.get('spec')
unit = raw_item.get('unit') or product.get_unit_display() or ''
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')
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')
quantity_of_rolls = None
stock_flow_items.append({
'product_id': product.id,
'value': str(quantity),
'num_of_rolls': num_of_rolls,
})
else:
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 数组')
raise ValueError(f'仓库为严进宽出模式items[{index}] 需要提供 numbers 数组')
normalized_numbers = [
str(_to_positive_int(value, f'items[{index}].numbers[{pos}]'))
for pos, value in enumerate(numbers)
@@ -190,8 +287,39 @@ def _normalize_purchase_items(
'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)
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,
})
purchase_items.append({
normalized_items.append({
'product': product,
'price': price,
'color': color,
@@ -203,9 +331,10 @@ def _normalize_purchase_items(
'batch_number': batch_number,
'remarks': remarks,
'spec': spec,
'consume_detail_ids': consume_detail_ids_str,
})
return purchase_items, stock_flow_items
return normalized_items, stock_flow_items
def _approve_purchase_order(
@@ -233,7 +362,11 @@ def _approve_purchase_order(
def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.PurchaseOrder:
if _purchase_order_has_stock_records(purchase_order):
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():
@@ -244,6 +377,46 @@ def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.Purch
return purchase_order
def _approve_sales_order(
sales_order: models.SalesOrder,
reviewed_by,
) -> models.SalesOrder:
stock_flow_items = _build_stock_flow_items_from_order(sales_order)
with transaction.atomic():
sales_order.status = models.SalesOrderStatusEnum.APPROVED
sales_order.save(update_fields=['status', 'updated_at'])
created_by_id = getattr(reviewed_by, 'id', None)
if _auto_stock_task_enabled(sales_order.merchant):
logger.info('审批通过销售单 %s,触发出库任务', sales_order.id)
create_sales_order_stock_entries.delay(
sales_order_id=sales_order.id,
warehouse_id=sales_order.warehouse_id,
items=stock_flow_items,
created_by_id=created_by_id,
)
sales_order.refresh_from_db(fields=['status', 'updated_at'])
return sales_order
def _cancel_sales_order(sales_order: models.SalesOrder) -> models.SalesOrder:
if _order_has_stock_records(
merchant_id=sales_order.merchant_id,
source_type=stock_models.StockChangeSourceEnum.SALES,
source_id=sales_order.id,
):
raise ValueError('销售单已生成出入库记录,无法作废')
with transaction.atomic():
sales_order.status = models.SalesOrderStatusEnum.CANCELLED
sales_order.save(update_fields=['status', 'updated_at'])
sales_order.refresh_from_db(fields=['status', 'updated_at'])
return sales_order
def create_purchase_order_stock_entries_sync(
*,
purchase_order_id: int,
@@ -284,16 +457,56 @@ def create_purchase_order_stock_entries_sync(
return payload
def _build_stock_flow_items_from_order(purchase_order: models.PurchaseOrder) -> List[Dict[str, Any]]:
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]:
"""
根据采购单明细还原 StockFlowService 所需的 items 结构
根据销售单生成出库记录
"""
warehouse_mode = purchase_order.warehouse.mode
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 _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 = purchase_order.items.all()
order_items = order.items.all()
if not order_items:
raise ValueError('采购单没有任何明细,无法生成库记录')
raise ValueError('单没有任何明细,无法生成库记录')
if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED:
for item in order_items:
@@ -306,13 +519,24 @@ def _build_stock_flow_items_from_order(purchase_order: models.PurchaseOrder) ->
# 严进 / 严进严出模式
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 = [value.strip() for value in raw_numbers if value.strip()]
if not normalized:
raise ValueError('严进仓采购单缺少 numbers 数据,无法生成库记录')
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,
'quantities': normalized_numbers,
})
return items_payload
@@ -329,11 +553,16 @@ def _auto_stock_task_enabled(merchant: basic_info_models.Merchant) -> bool:
return setting.value is True
def _purchase_order_has_stock_records(purchase_order: models.PurchaseOrder) -> bool:
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=purchase_order.merchant_id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=purchase_order.id,
merchant_id=merchant_id,
source_type=source_type,
source_id=source_id,
).exists()
@@ -352,6 +581,21 @@ def _resolve_purchase_order_instance(
)
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 _to_decimal(value, field_name: str) -> Decimal:
try:
return Decimal(str(value))

View File

@@ -23,3 +23,22 @@ def create_purchase_order_stock_entries(
payload['task_id'] = self.request.id
return payload
@shared_task(bind=True)
def create_sales_order_stock_entries(
self,
*,
sales_order_id: int,
warehouse_id: int,
items: List[Dict[str, Any]],
created_by_id: int | None = None,
) -> Dict[str, Any]:
payload = business_services.create_sales_order_stock_entries_sync(
sales_order_id=sales_order_id,
warehouse_id=warehouse_id,
items=items,
created_by_id=created_by_id,
)
payload['task_id'] = self.request.id
return payload

View File

@@ -55,6 +55,58 @@ def create_basic_fixtures():
return merchant, supplier, warehouse_strict, warehouse_relaxed, product, operator
def create_sales_fixtures():
merchant = basic_models.Merchant.objects.create(
name='销售商户',
type=basic_models.MerchantTypeEnum.FACTORY,
)
basic_models.MerchantSetting.objects.create(
merchant=merchant,
key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS,
type=basic_models.MerchantSettingTypeEnum.BOOL,
val_bool=True,
)
customer = basic_models.Customer.objects.create(
merchant=merchant,
name='客户A',
mobile='13800000000',
created_by=None,
)
warehouse_strict = basic_models.WareHouse.objects.create(
merchant=merchant,
name='销售严进仓',
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
)
warehouse_relaxed = basic_models.WareHouse.objects.create(
merchant=merchant,
name='销售宽进仓',
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
)
warehouse_strict_out = basic_models.WareHouse.objects.create(
merchant=merchant,
name='销售严出仓',
mode=basic_models.WareHouseModeEnum.RESTRICT_IN_OUT,
)
category = basic_models.ProductCategory.objects.create(
merchant=merchant,
name='销售品类',
product_prefix='SAL',
)
product = basic_models.Product.objects.create(
merchant=merchant,
category=category,
name='销售面料',
human_id='SAL-001',
unit=basic_models.ProductUnitEnum.METER,
)
operator = basic_models.Employee.objects.create(
merchant=merchant,
name='销售员',
status=basic_models.EmployeeStatusEnum.ACTIVE,
)
return merchant, customer, warehouse_strict, warehouse_relaxed, warehouse_strict_out, product, operator
class PurchaseOrderServiceTestCase(TestCase):
def setUp(self):
(
@@ -201,6 +253,128 @@ class PurchaseOrderServiceTestCase(TestCase):
)
class SalesOrderServiceTestCase(TestCase):
def setUp(self):
(
self.merchant,
self.customer,
self.warehouse_strict,
self.warehouse_relaxed,
self.warehouse_strict_out,
self.product,
self.operator,
) = create_sales_fixtures()
User = get_user_model()
self.user = User.objects.create_user(username='sales-creator', password='pass123')
self.strict_items = [
{'product_id': self.product.id, 'numbers': [6, 4], 'price': '30.5', 'unit': ''}
]
self.relaxed_items = [
{'product_id': self.product.id, 'quantity': 80, 'num_of_rolls': 2, 'price': '28.0', 'unit': ''}
]
self.strict_out_items = [
{
'product_id': self.product.id,
'consume_detail_ids': [1, 2],
'quantity': 40,
'price': '32.0',
'unit': '',
}
]
def test_create_sales_order_success(self):
with patch('business.services.create_sales_order_stock_entries.delay') as mock_delay:
sales_order = services.create_sales_order(
merchant=self.merchant,
customer=self.customer,
order_date=timezone.now().date(),
warehouse=self.warehouse_strict,
operator=self.operator,
items=self.strict_items,
remarks='销售测试',
created_by=self.user,
)
self.assertIsInstance(sales_order, business_models.SalesOrder)
self.assertEqual(sales_order.status, business_models.SalesOrderStatusEnum.PENDING)
self.assertEqual(sales_order.items.count(), 1)
self.assertEqual(sales_order.items.first().quantity_of_rolls, '6,4')
mock_delay.assert_not_called()
def test_review_sales_order_triggers_task(self):
sales_order = services.create_sales_order(
merchant=self.merchant,
customer=self.customer,
order_date=timezone.now().date(),
warehouse=self.warehouse_strict,
operator=self.operator,
items=self.strict_items,
created_by=self.user,
)
with patch('business.services.create_sales_order_stock_entries.delay') as mock_delay:
reviewed = services.review_sales_order(
sales_order=sales_order,
target_status=business_models.SalesOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
self.assertEqual(reviewed.status, business_models.SalesOrderStatusEnum.APPROVED)
mock_delay.assert_called_once_with(
sales_order_id=sales_order.id,
warehouse_id=self.warehouse_strict.id,
items=[{'product_id': self.product.id, 'quantities': ['6', '4']}],
created_by_id=self.user.id,
)
def test_sales_order_cancel_blocked_after_stock_created(self):
sales_order = services.create_sales_order(
merchant=self.merchant,
customer=self.customer,
order_date=timezone.now().date(),
warehouse=self.warehouse_relaxed,
operator=self.operator,
items=self.relaxed_items,
created_by=self.user,
)
stock_models.StockChangeRecord.objects.create(
merchant=self.merchant,
type=stock_models.StockChangeTypeEnum.REMOVE,
warehouse=self.warehouse_relaxed,
source_type=stock_models.StockChangeSourceEnum.SALES,
source_id=sales_order.id,
)
with self.assertRaises(ValueError):
services.review_sales_order(
sales_order=sales_order,
target_status=business_models.SalesOrderStatusEnum.CANCELLED,
reviewed_by=self.user,
)
def test_sales_order_requires_consume_ids_for_strict_out(self):
with self.assertRaises(ValueError):
services.create_sales_order(
merchant=self.merchant,
customer=self.customer,
order_date=timezone.now().date(),
warehouse=self.warehouse_strict_out,
operator=self.operator,
items=[{'product_id': self.product.id, 'quantity': 10, 'price': '20', 'unit': ''}],
created_by=self.user,
)
def test_sales_order_stores_consume_ids(self):
sales_order = services.create_sales_order(
merchant=self.merchant,
customer=self.customer,
order_date=timezone.now().date(),
warehouse=self.warehouse_strict_out,
operator=self.operator,
items=self.strict_out_items,
created_by=self.user,
)
item = sales_order.items.first()
self.assertEqual(item.consume_detail_ids, '1,2')
class PurchaseOrderStockServiceTestCase(TestCase):
def setUp(self):
(
@@ -245,6 +419,51 @@ class PurchaseOrderStockServiceTestCase(TestCase):
self.assertEqual(payload['stock_change_record_id'], 321)
class SalesOrderStockServiceTestCase(TestCase):
def setUp(self):
(
self.merchant,
self.customer,
self.warehouse_strict,
self.warehouse_relaxed,
self.warehouse_strict_out,
self.product,
self.operator,
) = create_sales_fixtures()
User = get_user_model()
self.user = User.objects.create_user(username='sales-svc', password='pass123')
self.sales_order = business_models.SalesOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
sales_date=timezone.now().date(),
operator=self.operator,
warehouse=self.warehouse_strict,
)
self.items = [{'product_id': self.product.id, 'quantities': ['5']}]
def test_service_calls_stock_out(self):
with patch('business.services.StockFlowService') as mock_flow_cls:
mock_instance = mock_flow_cls.return_value
mock_instance.stock_out.return_value = (MagicMock(id=654), [], 1)
payload = services.create_sales_order_stock_entries_sync(
sales_order_id=self.sales_order.id,
warehouse_id=self.warehouse_strict.id,
items=self.items,
created_by_id=self.user.id,
)
mock_flow_cls.assert_called_once_with(merchant=self.merchant, created_by=self.user)
mock_instance.stock_out.assert_called_once_with(
warehouse_id=self.warehouse_strict.id,
source_type=stock_models.StockChangeSourceEnum.SALES,
source_id=self.sales_order.id,
items=[{'product_id': self.product.id, 'quantities': ['5']}],
)
self.assertEqual(payload['sales_order_id'], self.sales_order.id)
self.assertEqual(payload['stock_change_record_id'], 654)
@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPAGATES=True,
@@ -275,6 +494,35 @@ class PurchaseOrderStockTaskTestCase(TestCase):
)
self.assertEqual(payload['purchase_order_id'], self.purchase_order_id)
self.assertIn('task_id', payload)
from django.test import TestCase
# Create your tests here.
@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPAGATES=True,
)
class SalesOrderStockTaskTestCase(TestCase):
def setUp(self):
self.sales_order_id = 321
self.warehouse_id = 654
self.items = [{'product_id': 2, 'quantities': ['4']}]
def test_task_delegates_to_service(self):
with patch('business.tasks.business_services.create_sales_order_stock_entries_sync') as mock_sync:
mock_sync.return_value = {'sales_order_id': self.sales_order_id}
async_result = tasks.create_sales_order_stock_entries.delay(
sales_order_id=self.sales_order_id,
warehouse_id=self.warehouse_id,
items=self.items,
created_by_id=777,
)
payload = async_result.get(timeout=5)
mock_sync.assert_called_once_with(
sales_order_id=self.sales_order_id,
warehouse_id=self.warehouse_id,
items=self.items,
created_by_id=777,
)
self.assertEqual(payload['sales_order_id'], self.sales_order_id)
self.assertIn('task_id', payload)