1
0
forked from erp-dev/erp

feat: allocation record for pre sales order

This commit is contained in:
2026-02-01 22:57:16 +08:00
parent a633a617d5
commit ac065e115e
14 changed files with 1955 additions and 44 deletions

View File

@@ -0,0 +1,140 @@
from __future__ import annotations
from decimal import Decimal
from typing import Iterable, List
from django.db import transaction
from stock import models as stock_models
from . import models
def _normalize_stock_ids(stock_ids: Iterable[int | str]) -> List[int]:
if not stock_ids:
raise ValueError('stock_ids 不能为空')
normalized: List[int] = []
for raw in stock_ids:
try:
value = int(raw)
except (TypeError, ValueError) as exc:
raise ValueError('stock_ids 必须为整数数组') from exc
if value <= 0:
raise ValueError('stock_ids 必须为正整数数组')
normalized.append(value)
return normalized
def _serialize_stock_ids(stock_ids: Iterable[int]) -> str:
return ','.join(str(value) for value in stock_ids)
def validate_stock_details(*, merchant, stock_ids: Iterable[int | str]) -> List[stock_models.StockChangeDetail]:
normalized_ids = _normalize_stock_ids(stock_ids)
details_by_id = stock_models.StockChangeDetail.objects.filter(
id__in=normalized_ids,
merchant=merchant,
).in_bulk(field_name='id')
missing_ids = [value for value in normalized_ids if value not in details_by_id]
if missing_ids:
raise ValueError(f'库存明细不存在: {missing_ids}')
consumed_ids = [
detail.id
for detail in details_by_id.values()
if getattr(detail, 'is_consumed', False)
]
if consumed_ids:
raise ValueError(f'库存明细已被消费: {consumed_ids}')
frozen_ids = list(
stock_models.StockFreeze.objects.filter(
stock_detail_id__in=normalized_ids,
status=stock_models.StockFreezeStatusEnum.FROZEN,
).values_list('stock_detail_id', flat=True)
)
if frozen_ids:
raise ValueError(f'库存明细已被冻结: {list(frozen_ids)}')
return [details_by_id[value] for value in normalized_ids]
def create_allocation_record(
*,
item: models.PreSalesOrderItem,
stock_ids: Iterable[int | str],
quantity: Decimal | int | str,
unit: str | None,
scanned_by,
remarks: str | None = None,
) -> models.AllocationRecord:
if quantity is None or str(quantity) == '':
raise ValueError('quantity 不能为空')
try:
quantity_decimal = Decimal(str(quantity))
except Exception as exc:
raise ValueError('quantity 格式不正确') from exc
if quantity_decimal <= 0:
raise ValueError('quantity 必须大于 0')
if not unit:
unit = getattr(item, 'unit', None)
unit = (unit or '').strip()
if not unit:
raise ValueError('unit 不能为空')
order = getattr(item, 'pre_sales_order', None)
if order is None:
raise ValueError('明细缺少所属预销售单')
normalized_ids = _normalize_stock_ids(stock_ids)
details = validate_stock_details(merchant=order.merchant, stock_ids=normalized_ids)
item_product_id = getattr(item, 'product_id', None)
if item_product_id:
mismatch_ids = list(
stock_models.StockChangeDetail.objects.filter(
id__in=normalized_ids,
).exclude(product_id=item_product_id).values_list('id', flat=True)
)
if mismatch_ids:
raise ValueError(f'库存明细产品不匹配: {mismatch_ids}')
with transaction.atomic():
record = models.AllocationRecord.objects.create(
pre_sales_order_item=item,
stock_ids=_serialize_stock_ids(normalized_ids),
quantity=quantity_decimal,
unit=unit,
status=models.AllocationRecordStatusEnum.ACTIVE,
scanned_by=scanned_by,
remarks=remarks,
)
stock_models.StockFreeze.objects.bulk_create(
[
stock_models.StockFreeze(
merchant=order.merchant,
product=detail.product,
warehouse=order.warehouse,
stock_detail=detail,
quantity=detail.quantity,
unit=detail.unit,
status=stock_models.StockFreezeStatusEnum.FROZEN,
frozen_by=getattr(scanned_by, 'sys_user', None),
frozen_with=record.id,
)
for detail in details
]
)
return record
def cancel_allocation_record(*, record: models.AllocationRecord) -> models.AllocationRecord:
if record.status == models.AllocationRecordStatusEnum.CANCELLED:
return record
record.status = models.AllocationRecordStatusEnum.CANCELLED
record.save(update_fields=['status', 'updated_at'])
return record

View File

@@ -1,14 +1,24 @@
import logging
from django.conf import settings
from django.db import transaction
from django.utils import timezone
logger = logging.getLogger(__name__)
def on_pre_sales_order_created(sender, **kwargs):
"""Handle PreSalesOrder created domain event.
For now: log only (no side effects).
- 在事务提交后发送企业微信机器人通知markdown
- 测试环境默认跳过,避免单测出网/刷屏
"""
if not getattr(settings, "PRE_SALES_ORDER_CREATED_WECOM_NOTIFY_ENABLED", True):
return
if getattr(settings, "TESTING", False):
return
order = kwargs.get('instance')
created_by = kwargs.get('created_by')
operator = kwargs.get('operator')
@@ -18,17 +28,73 @@ def on_pre_sales_order_created(sender, **kwargs):
logger.warning('[business.handlers] pre_sales_order_created 缺少 instance已跳过')
return
created_by_label = getattr(created_by, 'username', None) if created_by else None
operator_label = getattr(operator, 'name', None) if operator else None
try:
from .pre_order_services import render_pre_sales_order_created_markdown
logger.info(
'[business.handlers] pre_sales_order_created: id=%s human_id=%s merchant_id=%s customer_id=%s '
'created_by=%s operator=%s items_count=%s',
getattr(order, 'id', None),
getattr(order, 'human_id', None),
getattr(order, 'merchant_id', None),
getattr(order, 'customer_id', None),
created_by_label or '-',
operator_label or '-',
items_count if items_count is not None else '-',
)
created_at_dt = getattr(order, 'created_at', None)
created_at = (
timezone.localtime(created_at_dt).strftime('%Y-%m-%d %H:%M:%S')
if created_at_dt is not None
else timezone.localtime(timezone.now()).strftime('%Y-%m-%d %H:%M:%S')
)
operator_label = getattr(operator, 'name', None) if operator else None
emp = getattr(created_by, 'employee', None) if created_by else None
created_by_employee_name = getattr(emp, 'name', None) if emp is not None else None
created_by_username = getattr(created_by, 'username', None) if created_by else None
sender_label = operator_label or created_by_employee_name or created_by_username or '系统自动发送'
try:
customer_name = getattr(getattr(order, 'customer', None), 'name', None)
except Exception:
customer_name = None
try:
warehouse_name = getattr(getattr(order, 'warehouse', None), 'name', None)
except Exception:
warehouse_name = None
try:
kind_label = getattr(order, 'get_kind_display', lambda: None)() or None
except Exception:
kind_label = None
resolved_items_count = items_count
if resolved_items_count is None:
try:
resolved_items_count = int(getattr(order, 'items', []).count())
except Exception:
resolved_items_count = 0
message = render_pre_sales_order_created_markdown(
pre_sales_order_id=str(getattr(order, 'id', '-') or '-'),
human_id=str(getattr(order, 'human_id', '-') or '-'),
created_at=str(created_at),
sender_label=str(sender_label),
customer_name=str(customer_name or '-'),
warehouse_name=str(warehouse_name or '-'),
kind=str(kind_label or '-'),
items_count=int(resolved_items_count or 0),
)
except Exception:
logger.exception('[business.handlers] 渲染企业微信消息失败,跳过通知')
return
def _send_wecom():
try:
from api_v1.utils.wecom_webhook import send_wecom_webhook_message
resp = send_wecom_webhook_message(content=message, msgtype='markdown')
if not resp.ok:
logger.warning(
'[business.handlers] WeCom webhook 返回失败errcode=%s, errmsg=%s, raw=%s',
resp.errcode,
resp.errmsg,
resp.raw,
)
except Exception:
logger.exception('[business.handlers] 发送 WeCom webhook 失败(已忽略,不影响主流程)')
# 在事务提交后再发送,避免事务回滚但通知已发出
transaction.on_commit(_send_wecom)

View File

@@ -0,0 +1,33 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('business', '0024_prepurchaseorder_operator_presalesorder_operator_and_more'),
('basic_info', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='AllocationRecord',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('stock_ids', models.TextField(blank=True, null=True, verbose_name='库存明细ID列表')),
('quantity', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='配货数量')),
('unit', models.CharField(max_length=50, verbose_name='单位')),
('status', models.IntegerField(choices=[(1, '有效'), (2, '已撤销')], default=1, verbose_name='状态')),
('scanned_at', models.DateTimeField(auto_now_add=True, verbose_name='扫码时间')),
('remarks', models.TextField(blank=True, null=True, verbose_name='备注')),
('pre_sales_order_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allocation_records', to='business.presalesorderitem', verbose_name='预销售单明细')),
('scanned_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='allocation_records', to='basic_info.employee', verbose_name='扫码人员')),
],
options={
'verbose_name': '配货记录',
'verbose_name_plural': '配货记录',
},
),
]

View File

@@ -226,6 +226,13 @@ class SalesOrderStatusEnum(models.IntegerChoices):
CANCELLED = 3, '作废'
class AllocationRecordStatusEnum(models.IntegerChoices):
"""配货记录状态"""
ACTIVE = 1, '有效'
CANCELLED = 2, '已撤销'
class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
"""销售单模型"""
@@ -516,6 +523,57 @@ class PreSalesOrderItem(ModelBase):
verbose_name_plural = '预销售单明细'
class AllocationRecord(ModelBase):
"""配货记录(执行记录)"""
id = models.BigAutoField(primary_key=True)
pre_sales_order_item = models.ForeignKey(
PreSalesOrderItem,
on_delete=models.CASCADE,
related_name='allocation_records',
verbose_name='预销售单明细',
)
stock_ids = models.TextField(blank=True, null=True, verbose_name='库存明细ID列表')
quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='配货数量')
unit = models.CharField(max_length=50, verbose_name='单位')
status = models.IntegerField(
choices=AllocationRecordStatusEnum.choices,
default=AllocationRecordStatusEnum.ACTIVE,
verbose_name='状态',
)
scanned_by = models.ForeignKey(
basic_info_models.Employee,
on_delete=models.PROTECT,
related_name='allocation_records',
verbose_name='扫码人员',
)
scanned_at = models.DateTimeField(auto_now_add=True, verbose_name='扫码时间')
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
def __str__(self):
return f'配货记录 {self.id} - 明细 {self.pre_sales_order_item_id}'
@property
def stock_id_list(self) -> List[int]:
raw = (self.stock_ids or '').strip()
if not raw:
return []
result: List[int] = []
for value in raw.split(','):
value = value.strip()
if not value:
continue
try:
result.append(int(value))
except ValueError:
continue
return result
class Meta:
verbose_name = '配货记录'
verbose_name_plural = '配货记录'
class PrePurchaseOrder(ModelBase):
"""预采购单

View File

@@ -3,14 +3,60 @@ from __future__ import annotations
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Optional
from django.conf import settings
from django.db import transaction
from django.core.exceptions import ValidationError
from django.utils import timezone
from basic_info import models as basic_info_models
from . import models
def render_pre_sales_order_created_markdown(
*,
pre_sales_order_id: str,
human_id: str,
created_at: str,
sender_label: str,
customer_name: str,
warehouse_name: str,
kind: str,
items_count: int,
) -> str:
template = getattr(
settings,
"PRE_SALES_ORDER_CREATED_WECOM_MARKDOWN_TEMPLATE",
(
"### 预销售单创建\n"
"\n"
"- **预销售单ID**`{pre_sales_order_id}`\n"
"- **订单编号**`{human_id}`\n"
"- **创建时间**`{created_at}`\n"
"- **发送者**{sender_label}\n"
"- **客户**{customer_name}\n"
"- **仓库**{warehouse_name}\n"
"- **类型**{kind}\n"
"- **明细条数**`{items_count}`\n"
),
)
created_at_str = (created_at or "").strip()
if not created_at_str:
created_at_str = timezone.localtime(timezone.now()).strftime("%Y-%m-%d %H:%M:%S")
return template.format(
pre_sales_order_id=str(pre_sales_order_id or "-") or "-",
human_id=str(human_id or "-") or "-",
created_at=str(created_at_str),
sender_label=str(sender_label or "系统自动发送"),
customer_name=str(customer_name or "-"),
warehouse_name=str(warehouse_name or "-"),
kind=str(kind or "-"),
items_count=int(items_count) if items_count is not None else 0,
)
def _to_decimal(value: Any, *, field_name: str) -> Decimal:
if value is None or value == '':
raise ValueError(f'{field_name} 不能为空')

View File

@@ -0,0 +1,185 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from basic_info.models import (
Merchant,
MerchantTypeEnum,
Customer,
WareHouse,
WareHouseModeEnum,
ProductCategory,
Product,
ProductUnitEnum,
Employee,
EmployeeStatusEnum,
)
from business import models as business_models
from business import allocation_services
from stock import models as stock_models
@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPAGATES=True,
)
class AllocationServicesTestCase(TestCase):
def setUp(self):
self.merchant = Merchant.objects.create(name='配货商户', type=MerchantTypeEnum.FACTORY)
self.customer = Customer.objects.create(
merchant=self.merchant,
name='配货客户',
created_by=None,
)
self.warehouse = WareHouse.objects.create(
merchant=self.merchant,
name='配货仓库',
mode=WareHouseModeEnum.UNRESTRICTED,
)
category = ProductCategory.objects.create(
merchant=self.merchant,
name='配货品类',
product_prefix='ALC',
)
self.product = Product.objects.create(
merchant=self.merchant,
category=category,
name='配货产品',
human_id='ALC-001',
unit=ProductUnitEnum.METER,
)
self.other_product = Product.objects.create(
merchant=self.merchant,
category=category,
name='其他产品',
human_id='ALC-002',
unit=ProductUnitEnum.METER,
)
User = get_user_model()
self.user = User.objects.create_user(username='allocation_user', password='pass123')
self.employee = Employee.objects.create(
merchant=self.merchant,
sys_user=self.user,
name='配货员',
status=EmployeeStatusEnum.ACTIVE,
)
self.pre_sales_order = business_models.PreSalesOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
warehouse=self.warehouse,
created_by=self.user,
operator=self.employee,
kind=business_models.SalesOrderKindEnum.WHOLESALE,
remarks='预销售单备注',
)
self.item = business_models.PreSalesOrderItem.objects.create(
pre_sales_order=self.pre_sales_order,
product_id=self.product.id,
product_name=self.product.name,
quantity=Decimal('12.5'),
unit='',
remarks='明细备注',
)
self.stock_record = stock_models.StockChangeRecord.objects.create(
merchant=self.merchant,
type=stock_models.StockChangeTypeEnum.ADD,
warehouse=self.warehouse,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
created_by=self.user,
)
self.stock_detail = stock_models.StockChangeDetail.objects.create(
merchant=self.merchant,
product=self.product,
unit=ProductUnitEnum.METER,
stock_change_record=self.stock_record,
quantity='12.5',
)
def test_validate_stock_details_missing(self):
with self.assertRaises(ValueError) as ctx:
allocation_services.validate_stock_details(
merchant=self.merchant,
stock_ids=[999999],
)
self.assertIn('库存明细不存在', str(ctx.exception))
def test_validate_stock_details_consumed(self):
self.stock_detail.is_consumed = True
self.stock_detail.save(update_fields=['is_consumed'])
with self.assertRaises(ValueError) as ctx:
allocation_services.validate_stock_details(
merchant=self.merchant,
stock_ids=[self.stock_detail.id],
)
self.assertIn('库存明细已被消费', str(ctx.exception))
def test_validate_stock_details_frozen(self):
stock_models.StockFreeze.objects.create(
merchant=self.merchant,
product=self.product,
warehouse=self.warehouse,
stock_detail=self.stock_detail,
quantity=Decimal('1'),
unit=ProductUnitEnum.METER,
status=stock_models.StockFreezeStatusEnum.FROZEN,
frozen_by=self.user,
)
with self.assertRaises(ValueError) as ctx:
allocation_services.validate_stock_details(
merchant=self.merchant,
stock_ids=[self.stock_detail.id],
)
self.assertIn('库存明细已被冻结', str(ctx.exception))
def test_create_allocation_record_product_mismatch(self):
other_detail = stock_models.StockChangeDetail.objects.create(
merchant=self.merchant,
product=self.other_product,
unit=ProductUnitEnum.METER,
stock_change_record=self.stock_record,
quantity='3.0',
)
with self.assertRaises(ValueError) as ctx:
allocation_services.create_allocation_record(
item=self.item,
stock_ids=[other_detail.id],
quantity='3.0',
unit='',
scanned_by=self.employee,
)
self.assertIn('库存明细产品不匹配', str(ctx.exception))
def test_create_allocation_record_success(self):
record = allocation_services.create_allocation_record(
item=self.item,
stock_ids=[self.stock_detail.id],
quantity='5.5',
unit='',
scanned_by=self.employee,
remarks='扫码录入',
)
self.assertEqual(record.pre_sales_order_item_id, self.item.id)
self.assertEqual(record.stock_ids, str(self.stock_detail.id))
self.assertEqual(record.stock_id_list, [self.stock_detail.id])
self.assertEqual(record.status, business_models.AllocationRecordStatusEnum.ACTIVE)
freeze_qs = stock_models.StockFreeze.objects.filter(stock_detail=self.stock_detail)
self.assertEqual(freeze_qs.count(), 1)
self.assertEqual(freeze_qs.first().status, stock_models.StockFreezeStatusEnum.FROZEN)
def test_cancel_allocation_record(self):
record = allocation_services.create_allocation_record(
item=self.item,
stock_ids=[self.stock_detail.id],
quantity='2.0',
unit='',
scanned_by=self.employee,
)
updated = allocation_services.cancel_allocation_record(record=record)
self.assertEqual(updated.status, business_models.AllocationRecordStatusEnum.CANCELLED)