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,154 @@
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from rest_framework import status
from rest_framework.test import APIClient
from basic_info.models import (
Merchant,
MerchantTypeEnum,
Customer,
WareHouse,
WareHouseModeEnum,
ProductCategory,
Product,
ProductUnitEnum,
Employee,
EmployeeStatusEnum,
)
from business import models as business_models
from stock import models as stock_models
@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPAGATES=True,
)
class PreSalesAllocationAPITestCase(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,
)
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.client = APIClient()
self.client.force_authenticate(user=self.user)
self.order_payload = {
'customer': self.customer.id,
'warehouse': self.warehouse.id,
'kind': business_models.SalesOrderKindEnum.WHOLESALE,
'remarks': '预销售单备注',
'items': [
{
'product_id': self.product.id,
'quantity': '12.5',
'unit': '',
'order_quantity': 7,
'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 _create_order(self):
resp = self.client.post('/api/v1/pre-sales-orders/', self.order_payload, format='json')
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
return resp.data
def test_create_allocation_record(self):
order = self._create_order()
item_id = order['items'][0]['id']
payload = {
'stock_ids': [self.stock_detail.id],
'quantity': '5.5',
'unit': '',
}
resp = self.client.post(
f'/api/v1/pre-sales-order-items/{item_id}/allocations/',
payload,
format='json',
)
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
self.assertEqual(resp.data['stock_ids'], [self.stock_detail.id])
self.assertEqual(str(resp.data['quantity']), '5.50')
def test_cancel_allocation_record(self):
order = self._create_order()
item_id = order['items'][0]['id']
create_resp = self.client.post(
f'/api/v1/pre-sales-order-items/{item_id}/allocations/',
{
'stock_ids': [self.stock_detail.id],
'quantity': '5.5',
'unit': '',
},
format='json',
)
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
record_id = create_resp.data['id']
delete_resp = self.client.delete(
f'/api/v1/pre-sales-order-items/{item_id}/allocations/{record_id}/'
)
self.assertEqual(delete_resp.status_code, status.HTTP_200_OK)
self.assertEqual(delete_resp.data['status'], business_models.AllocationRecordStatusEnum.CANCELLED)
def test_create_allocation_with_invalid_stock(self):
order = self._create_order()
item_id = order['items'][0]['id']
resp = self.client.post(
f'/api/v1/pre-sales-order-items/{item_id}/allocations/',
{
'stock_ids': [999999],
'quantity': '5.5',
'unit': '',
},
format='json',
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('库存明细不存在', resp.data['error'])

View File

@@ -91,6 +91,16 @@ urlpatterns = [
path('sales-return-orders/<int:pk>/review/', sales_return_views.SalesReturnOrderReviewView.as_view(), name='sales_return_order_review'), path('sales-return-orders/<int:pk>/review/', sales_return_views.SalesReturnOrderReviewView.as_view(), name='sales_return_order_review'),
path('pre-sales-orders/', pre_sales_views.PreSalesOrderView.as_view(), name='pre_sales_orders'), path('pre-sales-orders/', pre_sales_views.PreSalesOrderView.as_view(), name='pre_sales_orders'),
path('pre-sales-orders/<int:pk>/', pre_sales_views.PreSalesOrderDetailView.as_view(), name='pre_sales_order_detail'), path('pre-sales-orders/<int:pk>/', pre_sales_views.PreSalesOrderDetailView.as_view(), name='pre_sales_order_detail'),
path(
'pre-sales-order-items/<int:item_id>/allocations/',
pre_sales_views.PreSalesOrderItemAllocationView.as_view(),
name='pre_sales_order_item_allocations',
),
path(
'pre-sales-order-items/<int:item_id>/allocations/<int:pk>/',
pre_sales_views.PreSalesOrderItemAllocationDetailView.as_view(),
name='pre_sales_order_item_allocation_detail',
),
path('pre-purchase-orders/', pre_purchase_views.PrePurchaseOrderView.as_view(), name='pre_purchase_orders'), path('pre-purchase-orders/', pre_purchase_views.PrePurchaseOrderView.as_view(), name='pre_purchase_orders'),
path('pre-purchase-orders/<int:pk>/', pre_purchase_views.PrePurchaseOrderDetailView.as_view(), name='pre_purchase_order_detail'), path('pre-purchase-orders/<int:pk>/', pre_purchase_views.PrePurchaseOrderDetailView.as_view(), name='pre_purchase_order_detail'),
path('payment-orders/', payment_views.PaymentOrderView.as_view(), name='payment_orders'), path('payment-orders/', payment_views.PaymentOrderView.as_view(), name='payment_orders'),

View File

@@ -1,6 +1,7 @@
from rest_framework import status, views, serializers, pagination from rest_framework import status, views, serializers, pagination
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from django.utils import dateparse, timezone
from api_v1.views.stock_change_views.mixins import StockChangeViewMixin from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
from business import models as business_models from business import models as business_models
@@ -18,22 +19,15 @@ class PrePurchaseOrderItemSerializer(serializers.ModelSerializer):
'quantity', 'quantity',
'unit', 'unit',
'spec', 'spec',
'quantity_of_rolls',
'num_of_rolls',
'order_quantity',
'remarks', 'remarks',
'created_at',
'updated_at',
] ]
read_only_fields = ['id', 'created_at', 'updated_at'] read_only_fields = ['id']
class PrePurchaseOrderSerializer(serializers.ModelSerializer): class PrePurchaseOrderSerializer(serializers.ModelSerializer):
human_id = serializers.CharField(read_only=True) human_id = serializers.CharField(read_only=True)
merchant_name = serializers.CharField(source='merchant.name', read_only=True)
supplier_name = serializers.CharField(source='supplier.name', read_only=True) supplier_name = serializers.CharField(source='supplier.name', read_only=True)
warehouse_name = serializers.CharField(source='warehouse.name', read_only=True) warehouse_name = serializers.CharField(source='warehouse.name', read_only=True)
created_by_username = serializers.CharField(source='created_by.username', read_only=True)
operator_name = serializers.CharField(source='operator.name', read_only=True) operator_name = serializers.CharField(source='operator.name', read_only=True)
items = PrePurchaseOrderItemSerializer(many=True, read_only=True) items = PrePurchaseOrderItemSerializer(many=True, read_only=True)
@@ -43,30 +37,23 @@ class PrePurchaseOrderSerializer(serializers.ModelSerializer):
'id', 'id',
'human_id', 'human_id',
'merchant', 'merchant',
'merchant_name',
'supplier', 'supplier',
'supplier_name', 'supplier_name',
'warehouse', 'warehouse',
'warehouse_name', 'warehouse_name',
'created_by',
'created_by_username',
'operator', 'operator',
'operator_name', 'operator_name',
'kind', 'kind',
'remarks', 'remarks',
'created_at', 'created_at',
'updated_at',
'items', 'items',
] ]
read_only_fields = [ read_only_fields = [
'id', 'id',
'human_id', 'human_id',
'created_at', 'created_at',
'updated_at',
'merchant_name',
'supplier_name', 'supplier_name',
'warehouse_name', 'warehouse_name',
'created_by_username',
'operator_name', 'operator_name',
'items', 'items',
] ]
@@ -90,6 +77,49 @@ class PrePurchaseOrderView(StockChangeViewMixin, views.APIView):
merchant = request.user.employee.merchant merchant = request.user.employee.merchant
queryset = pre_order_services.list_pre_purchase_orders(merchant=merchant) queryset = pre_order_services.list_pre_purchase_orders(merchant=merchant)
supplier_id = request.query_params.get('supplier') or request.query_params.get('supplier_id')
warehouse_id = request.query_params.get('warehouse') or request.query_params.get('warehouse_id')
kind = request.query_params.get('kind')
human_id_contains = request.query_params.get('human_id__icontains')
created_from = request.query_params.get('created_at_from')
created_to = request.query_params.get('created_at_to')
try:
if supplier_id:
queryset = queryset.filter(supplier_id=int(supplier_id))
if warehouse_id:
queryset = queryset.filter(warehouse_id=int(warehouse_id))
if kind:
queryset = queryset.filter(kind=int(kind))
except (TypeError, ValueError):
return Response({'error': '筛选参数必须为整数'}, status=status.HTTP_400_BAD_REQUEST)
if human_id_contains:
queryset = queryset.filter(human_id__icontains=str(human_id_contains).strip())
def _parse_datetime(value: str):
dt = dateparse.parse_datetime(value)
if dt is None:
d = dateparse.parse_date(value)
if d is None:
return None
dt = timezone.datetime.combine(d, timezone.datetime.min.time())
if timezone.is_naive(dt):
dt = timezone.make_aware(dt, timezone.get_current_timezone())
return dt
if created_from:
dt_from = _parse_datetime(created_from)
if dt_from is None:
return Response({'error': 'created_at_from 格式不正确'}, status=status.HTTP_400_BAD_REQUEST)
queryset = queryset.filter(created_at__gte=dt_from)
if created_to:
dt_to = _parse_datetime(created_to)
if dt_to is None:
return Response({'error': 'created_at_to 格式不正确'}, status=status.HTTP_400_BAD_REQUEST)
queryset = queryset.filter(created_at__lte=dt_to)
paginator = self.pagination_class() paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self) page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self)
serializer = PrePurchaseOrderSerializer(page, many=True) serializer = PrePurchaseOrderSerializer(page, many=True)

View File

@@ -1,13 +1,55 @@
from decimal import Decimal
from rest_framework import status, views, serializers, pagination from rest_framework import status, views, serializers, pagination
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from django.db.models import Sum
from django.utils import dateparse, timezone
from api_v1.views.stock_change_views.mixins import StockChangeViewMixin from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
from business import models as business_models from business import models as business_models
from business import pre_order_services from business import pre_order_services
from business import allocation_services
class AllocationRecordSerializer(serializers.ModelSerializer):
stock_ids = serializers.SerializerMethodField(read_only=True)
scanned_by_name = serializers.CharField(source='scanned_by.name', read_only=True)
class Meta:
model = business_models.AllocationRecord
fields = [
'id',
'pre_sales_order_item',
'stock_ids',
'quantity',
'unit',
'status',
'scanned_by',
'scanned_by_name',
'scanned_at',
'remarks',
'created_at',
]
read_only_fields = [
'id',
'pre_sales_order_item',
'scanned_by',
'scanned_by_name',
'scanned_at',
'created_at',
]
def get_stock_ids(self, obj: business_models.AllocationRecord):
return obj.stock_id_list
class PreSalesOrderItemSerializer(serializers.ModelSerializer): class PreSalesOrderItemSerializer(serializers.ModelSerializer):
allocation_records = AllocationRecordSerializer(many=True, read_only=True)
allocated_quantity = serializers.SerializerMethodField(read_only=True)
progress = serializers.SerializerMethodField(read_only=True)
allocation_status = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = business_models.PreSalesOrderItem model = business_models.PreSalesOrderItem
fields = [ fields = [
@@ -18,22 +60,44 @@ class PreSalesOrderItemSerializer(serializers.ModelSerializer):
'quantity', 'quantity',
'unit', 'unit',
'spec', 'spec',
'quantity_of_rolls',
'num_of_rolls',
'order_quantity',
'remarks', 'remarks',
'created_at', 'allocated_quantity',
'updated_at', 'progress',
'allocation_status',
'allocation_records',
] ]
read_only_fields = ['id', 'created_at', 'updated_at'] read_only_fields = ['id']
def _get_allocated_quantity(self, obj: business_models.PreSalesOrderItem) -> Decimal:
result = obj.allocation_records.filter(
status=business_models.AllocationRecordStatusEnum.ACTIVE
).aggregate(total=Sum('quantity'))
return result.get('total') or Decimal('0')
def get_allocated_quantity(self, obj: business_models.PreSalesOrderItem) -> str:
return str(self._get_allocated_quantity(obj))
def get_progress(self, obj: business_models.PreSalesOrderItem) -> str:
required = obj.quantity or Decimal('0')
allocated = self._get_allocated_quantity(obj)
if required <= 0:
return '0'
return str((allocated / required).quantize(Decimal('0.0001')))
def get_allocation_status(self, obj: business_models.PreSalesOrderItem) -> int:
required = obj.quantity or Decimal('0')
allocated = self._get_allocated_quantity(obj)
if allocated <= 0:
return 1
if allocated < required:
return 2
return 3
class PreSalesOrderSerializer(serializers.ModelSerializer): class PreSalesOrderSerializer(serializers.ModelSerializer):
human_id = serializers.CharField(read_only=True) human_id = serializers.CharField(read_only=True)
merchant_name = serializers.CharField(source='merchant.name', read_only=True)
customer_name = serializers.CharField(source='customer.name', read_only=True) customer_name = serializers.CharField(source='customer.name', read_only=True)
warehouse_name = serializers.CharField(source='warehouse.name', read_only=True) warehouse_name = serializers.CharField(source='warehouse.name', read_only=True)
created_by_username = serializers.CharField(source='created_by.username', read_only=True)
operator_name = serializers.CharField(source='operator.name', read_only=True) operator_name = serializers.CharField(source='operator.name', read_only=True)
items = PreSalesOrderItemSerializer(many=True, read_only=True) items = PreSalesOrderItemSerializer(many=True, read_only=True)
@@ -43,30 +107,23 @@ class PreSalesOrderSerializer(serializers.ModelSerializer):
'id', 'id',
'human_id', 'human_id',
'merchant', 'merchant',
'merchant_name',
'customer', 'customer',
'customer_name', 'customer_name',
'warehouse', 'warehouse',
'warehouse_name', 'warehouse_name',
'created_by',
'created_by_username',
'operator', 'operator',
'operator_name', 'operator_name',
'kind', 'kind',
'remarks', 'remarks',
'created_at', 'created_at',
'updated_at',
'items', 'items',
] ]
read_only_fields = [ read_only_fields = [
'id', 'id',
'human_id', 'human_id',
'created_at', 'created_at',
'updated_at',
'merchant_name',
'customer_name', 'customer_name',
'warehouse_name', 'warehouse_name',
'created_by_username',
'operator_name', 'operator_name',
'items', 'items',
] ]
@@ -90,6 +147,49 @@ class PreSalesOrderView(StockChangeViewMixin, views.APIView):
merchant = request.user.employee.merchant merchant = request.user.employee.merchant
queryset = pre_order_services.list_pre_sales_orders(merchant=merchant) queryset = pre_order_services.list_pre_sales_orders(merchant=merchant)
customer_id = request.query_params.get('customer') or request.query_params.get('customer_id')
warehouse_id = request.query_params.get('warehouse') or request.query_params.get('warehouse_id')
kind = request.query_params.get('kind')
human_id_contains = request.query_params.get('human_id__icontains')
created_from = request.query_params.get('created_at_from')
created_to = request.query_params.get('created_at_to')
try:
if customer_id:
queryset = queryset.filter(customer_id=int(customer_id))
if warehouse_id:
queryset = queryset.filter(warehouse_id=int(warehouse_id))
if kind:
queryset = queryset.filter(kind=int(kind))
except (TypeError, ValueError):
return Response({'error': '筛选参数必须为整数'}, status=status.HTTP_400_BAD_REQUEST)
if human_id_contains:
queryset = queryset.filter(human_id__icontains=str(human_id_contains).strip())
def _parse_datetime(value: str):
dt = dateparse.parse_datetime(value)
if dt is None:
d = dateparse.parse_date(value)
if d is None:
return None
dt = timezone.datetime.combine(d, timezone.datetime.min.time())
if timezone.is_naive(dt):
dt = timezone.make_aware(dt, timezone.get_current_timezone())
return dt
if created_from:
dt_from = _parse_datetime(created_from)
if dt_from is None:
return Response({'error': 'created_at_from 格式不正确'}, status=status.HTTP_400_BAD_REQUEST)
queryset = queryset.filter(created_at__gte=dt_from)
if created_to:
dt_to = _parse_datetime(created_to)
if dt_to is None:
return Response({'error': 'created_at_to 格式不正确'}, status=status.HTTP_400_BAD_REQUEST)
queryset = queryset.filter(created_at__lte=dt_to)
paginator = self.pagination_class() paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self) page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self)
serializer = PreSalesOrderSerializer(page, many=True) serializer = PreSalesOrderSerializer(page, many=True)
@@ -177,3 +277,64 @@ class PreSalesOrderDetailView(StockChangeViewMixin, views.APIView):
pre_order_services.delete_pre_sales_order(pre_sales_order=order) pre_order_services.delete_pre_sales_order(pre_sales_order=order)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class PreSalesOrderItemAllocationView(StockChangeViewMixin, views.APIView):
permission_classes = [IsAuthenticated]
def _get_item(self, request, item_id: int):
if not self.check_employee_permission(request):
return None, self.permission_error_response('无权限访问')
merchant = request.user.employee.merchant
try:
item = business_models.PreSalesOrderItem.objects.select_related(
'pre_sales_order',
).get(id=item_id, pre_sales_order__merchant=merchant)
return item, None
except business_models.PreSalesOrderItem.DoesNotExist:
return None, self.not_found_response('预销售单明细不存在')
def post(self, request, item_id: int):
item, error_response = self._get_item(request, item_id)
if error_response:
return error_response
data = request.data or {}
try:
record = allocation_services.create_allocation_record(
item=item,
stock_ids=data.get('stock_ids') or [],
quantity=data.get('quantity'),
unit=data.get('unit'),
scanned_by=request.user.employee,
remarks=data.get('remarks'),
)
except ValueError as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(AllocationRecordSerializer(record).data, status=status.HTTP_201_CREATED)
class PreSalesOrderItemAllocationDetailView(StockChangeViewMixin, views.APIView):
permission_classes = [IsAuthenticated]
def delete(self, request, item_id: int, pk: int):
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
merchant = request.user.employee.merchant
try:
record = business_models.AllocationRecord.objects.select_related(
'pre_sales_order_item',
'pre_sales_order_item__pre_sales_order',
).get(
id=pk,
pre_sales_order_item_id=item_id,
pre_sales_order_item__pre_sales_order__merchant=merchant,
)
except business_models.AllocationRecord.DoesNotExist:
return self.not_found_response('配货记录不存在')
updated = allocation_services.cancel_allocation_record(record=record)
return Response(AllocationRecordSerializer(updated).data, status=status.HTTP_200_OK)

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 import logging
from django.conf import settings
from django.db import transaction
from django.utils import timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def on_pre_sales_order_created(sender, **kwargs): def on_pre_sales_order_created(sender, **kwargs):
"""Handle PreSalesOrder created domain event. """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') order = kwargs.get('instance')
created_by = kwargs.get('created_by') created_by = kwargs.get('created_by')
operator = kwargs.get('operator') 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已跳过') logger.warning('[business.handlers] pre_sales_order_created 缺少 instance已跳过')
return return
created_by_label = getattr(created_by, 'username', None) if created_by else None try:
from .pre_order_services import render_pre_sales_order_created_markdown
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 operator_label = getattr(operator, 'name', None) if operator else None
logger.info( emp = getattr(created_by, 'employee', None) if created_by else None
'[business.handlers] pre_sales_order_created: id=%s human_id=%s merchant_id=%s customer_id=%s ' created_by_employee_name = getattr(emp, 'name', None) if emp is not None else None
'created_by=%s operator=%s items_count=%s', created_by_username = getattr(created_by, 'username', None) if created_by else None
getattr(order, 'id', None), sender_label = operator_label or created_by_employee_name or created_by_username or '系统自动发送'
getattr(order, 'human_id', None),
getattr(order, 'merchant_id', None), try:
getattr(order, 'customer_id', None), customer_name = getattr(getattr(order, 'customer', None), 'name', None)
created_by_label or '-', except Exception:
operator_label or '-', customer_name = None
items_count if items_count is not None else '-',
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, '作废' CANCELLED = 3, '作废'
class AllocationRecordStatusEnum(models.IntegerChoices):
"""配货记录状态"""
ACTIVE = 1, '有效'
CANCELLED = 2, '已撤销'
class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
"""销售单模型""" """销售单模型"""
@@ -516,6 +523,57 @@ class PreSalesOrderItem(ModelBase):
verbose_name_plural = '预销售单明细' 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): class PrePurchaseOrder(ModelBase):
"""预采购单 """预采购单

View File

@@ -3,14 +3,60 @@ from __future__ import annotations
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from django.conf import settings
from django.db import transaction from django.db import transaction
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils import timezone
from basic_info import models as basic_info_models from basic_info import models as basic_info_models
from . import 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: def _to_decimal(value: Any, *, field_name: str) -> Decimal:
if value is None or value == '': if value is None or value == '':
raise ValueError(f'{field_name} 不能为空') 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)

View File

@@ -0,0 +1,102 @@
# 预销售单配货记录方案(需求背景与决策)
生成日期2026-02-02
## 一、需求背景
业务场景为“预销售单配货记录”。预销售单已存在且包含多个明细PreSalesOrderItem。配货行为需要被记录且允许同一明细多次配货拆分执行、分批扫码。配货记录的核心价值是对现实行为的如实记录与进度汇总而非强约束生产流程。
## 二、已确认的关键决策
### 1. 数据关系
- PreSalesOrder 1 — N PreSalesOrderItem
- PreSalesOrderItem 1 — N AllocationRecord配货记录
- 外键在 AllocationRecord 侧
### 2. 模型命名
- 配货记录模型英文名AllocationRecord
### 3. 配货记录字段设计(核心)
- pre_sales_order_itemFK
- stock_ids库存明细 ID 数组)
- 这些 ID 对应 stock.StockChangeDetail
- 由于系统已有一致做法consume_detail_ids 为字符串存储),建议使用字符串持久化,展示层转换为 List[int]
- quantitydecimal执行数量
- unitstring
- statusint1=有效2=撤销)
- scanned_byEmployee
- scanned_atdatetime
- remarks可选
### 4. 派生字段(必须是计算字段,不落库)
- allocated_quantity = Σ AllocationRecord.quantity仅统计 status=有效)
- required_quantity = PreSalesOrderItem.quantity
- progress = allocated_quantity / required_quantity
- allocation_status计算状态
- pendingallocated_quantity == 0
- allocating0 < allocated_quantity < required_quantity
- completedallocated_quantity >= required_quantity
### 5. 业务规则
- 允许超配:配货记录是事实记录,不限制现实生产过程。
- 允许撤销:撤销后不计入配货进度。
- stock_ids 的合法性与库存状态校验必须在 service 层完成:
- 必须存在
- 未冻结/锁定/消费
- 不允许将校验逻辑耦合在 model 层
- 必须通过独立函数实现,避免重复代码
### 6. 排除项(当前阶段不做)
- 任务指派与接收功能不做(前端想法不成熟)
- 任务主表TaskAllocation暂不落地
- 转销售单属于下一阶段任务
- 价格由前端在转销售单时传入,目前不考虑
## 三、与前端需求的匹配情况
当前方案满足前端“扫码录入、按明细多次执行、进度汇总”的核心需求。
暂不覆盖“任务指派、接收、完成、转单”流程,此部分已明确不在本阶段实施。
## 四、实施计划(仅规划,不含代码)
1. 新增 AllocationRecord 模型
- 外键到 PreSalesOrderItem
- stock_ids 使用字符串持久化(与 consume_detail_ids 一致)
2. 在 service 层新增库存明细校验函数
- 校验 stock_ids 存在且未冻结/锁定/消费
- 作为通用工具复用,避免重复逻辑
3. 新增配货记录创建与撤销流程service
- 允许超配
- 撤销不计入进度
4. 在 API/Serializer 层增加派生字段输出
- allocated_quantity、progress、allocation_status
5. 补充相关测试
- stock_ids 校验
- 进度计算与状态派生
- 撤销与超配逻辑
## 五、实施逻辑细节service 层校验)
### 1. 校验库存明细存在
- 传入 stock_ids数组先做整数化与正整数校验
- 使用 `StockChangeDetail``id__in` + `merchant` 拉取
- 若有缺失 ID直接报错并终止
### 2. 校验“未被消费”
- 依据 stock 模块字段 `StockChangeDetail.is_consumed`
- 若任一明细 `is_consumed=True`,视为已消费,拒绝写入配货记录
### 3. 校验“未冻结/未锁定”
- 查询 `StockFreeze`,条件:
- `stock_detail_id__in = stock_ids`
- `status = StockFreezeStatusEnum.FROZEN`
- 若存在冻结记录,拒绝写入配货记录
### 4. 约束校验位置
- 上述校验必须在 **service 层独立函数** 中完成
- 不写入 model 层,不引入重复代码
## 六、待后续阶段处理
- 转销售单流程(确认并转单)
- 任务指派/接收/完成等任务主表流程

View File

@@ -0,0 +1,489 @@
# 任务配货功能 API 需求文档
> **业务背景:** 布行预销售单场景客户预定布料如5000米需要仓库人员配货、扫码录入每条布的米数确认后转为正式销售单。
>
> **前端项目:** app-ui移动端 Web 应用)
>
> **生成日期:** 2026-02-01
---
## 一、业务流程概述
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 创建预销售单 │ -> │ 生成任务配货 │ -> │ 仓库扫码录入 │ -> │ 确认转销售单 │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │ │
▼ ▼ ▼ ▼
客户预定 任务分配给 扫码录入每条 客户确认数量
5000米布料 仓库配货人员 布料的实际米数 转为正式销售单
```
### 角色说明
| 角色 | 操作 |
|------|------|
| 销售人员 | 创建预销售单,确认配货结果,转销售单 |
| 仓库人员 | 接收任务配货,扫码录入每条布的米数 |
| 客户 | 确认最终数量(可选,由销售人员代为确认) |
### 状态流转
```
预销售单状态:
待配货(pending) → 配货中(allocating) → 待确认(confirming) → 已完成(completed) / 已取消(cancelled)
任务配货状态:
待接收(pending) → 进行中(in_progress) → 已完成(completed) / 已取消(cancelled)
```
---
## 二、需要新增的 API
### 2.1 预销售单 API移动端
> antd-demo 已有预销售单 API需确认 app-ui 是否复用同一套接口
**复用现有接口:**
- `POST /api/v1/pre-sales-orders/` - 创建预销售单
- `GET /api/v1/pre-sales-orders/` - 预销售单列表
- `GET /api/v1/pre-sales-orders/{id}/` - 预销售单详情
**需要新增/调整:**
- 创建预销售单时**自动创建任务配货**(后端处理,或前端调用两个接口)
- 预销售单需要新增 `status` 字段表示配货状态
---
### 2.2 任务配货 API新增
#### 2.2.1 创建任务配货
> 可选择在创建预销售单时由后端自动创建,或由前端单独调用
- **路径:** `POST /api/v1/task-allocations/`
- **权限:** 需登录,需员工权限
**请求参数:**
```typescript
{
pre_sales_order_id: number; // 必填,关联的预销售单 ID
assignee_id?: number; // 可选,指派给哪个仓库员工(不填则待分配)
priority?: number; // 可选优先级1=普通2=加急默认1
expected_date?: string; // 可选期望完成日期ISO 8601
remarks?: string; // 可选,备注
}
```
**响应数据:**
```typescript
{
code: 200,
message: "success",
data: {
id: number; // 任务配货 ID
task_no: string; // 任务编号(如 PH20260201000001
pre_sales_order_id: number; // 关联预销售单 ID
pre_sales_order_no: string; // 预销售单编号(只读)
customer_id: number; // 客户 ID从预销售单继承
customer_name: string; // 客户名称(只读)
warehouse_id: number; // 仓库 ID从预销售单继承
warehouse_name: string; // 仓库名称(只读)
assignee_id: number | null; // 被指派的员工 ID
assignee_name: string | null; // 被指派的员工名称(只读)
status: number; // 状态1=待接收2=进行中3=已完成4=已取消
priority: number; // 优先级
expected_date: string | null; // 期望完成日期
remarks: string | null; // 备注
created_by: number; // 创建者 ID
created_by_name: string; // 创建者名称(只读)
created_at: string; // 创建时间
// 配货需求(从预销售单明细复制)
items: TaskAllocationItem[];
}
}
```
**任务配货明细 `TaskAllocationItem`**
```typescript
{
id: number; // 明细 ID
product_id: number; // 产品 ID
product_name: string; // 产品名称
spec: string | null; // 规格
color: string | null; // 颜色
required_quantity: string; // 需求数量(如 "5000.00"
allocated_quantity: string; // 已配货数量(如 "4800.00"
unit: string; // 单位
status: number; // 明细状态1=待配货2=配货中3=已完成
// 扫码录入的布条记录
rolls: TaskAllocationRoll[];
}
```
**布条记录 `TaskAllocationRoll`**
```typescript
{
id: number; // 记录 ID
roll_code: string; // 布条码/二维码内容
quantity: string; // 该条的米数(如 "50.00"
scanned_by: number; // 扫码人员 ID
scanned_by_name: string; // 扫码人员名称(只读)
scanned_at: string; // 扫码时间
}
```
---
#### 2.2.2 任务配货列表
- **路径:** `GET /api/v1/task-allocations/`
- **权限:** 需登录,需员工权限
**查询参数:**
```typescript
{
limit?: number; // 分页默认20最大100
offset?: number; // 偏移量默认0
status?: number; // 按状态筛选1/2/3/4
assignee_id?: number; // 按被指派人筛选(仓库人员查自己的任务)
warehouse_id?: number; // 按仓库筛选
priority?: number; // 按优先级筛选
date_from?: string; // 创建时间起始ISO 8601
date_to?: string; // 创建时间结束
}
```
**响应数据:**
```typescript
{
count: number;
next: string | null;
previous: string | null;
results: TaskAllocation[]; // 任务配货列表(不含 rolls 明细)
}
```
---
#### 2.2.3 任务配货详情
- **路径:** `GET /api/v1/task-allocations/{id}/`
- **权限:** 需登录
**响应数据:**
```typescript
{
code: 200,
data: TaskAllocation // 完整数据,包含 items 和 rolls
}
```
---
#### 2.2.4 接收任务
> 仓库人员接收任务,状态从"待接收"变为"进行中"
- **路径:** `POST /api/v1/task-allocations/{id}/accept/`
- **权限:** 需登录,需仓库员工权限
**请求参数:** 无(后端自动设置当前用户为 assignee
**响应数据:**
```typescript
{
code: 200,
message: "任务已接收",
data: TaskAllocation
}
```
---
#### 2.2.5 扫码录入布条 ⭐
> 仓库人员扫码录入每条布的米数
- **路径:** `POST /api/v1/task-allocations/{id}/scan/`
- **权限:** 需登录,需仓库员工权限
**请求参数:**
```typescript
{
item_id: number; // 哪个产品明细
roll_code: string; // 布条码/二维码内容
quantity: string | number; // 该条的米数
}
```
**响应数据:**
```typescript
{
code: 200,
message: "录入成功",
data: {
roll: TaskAllocationRoll; // 新增的布条记录
item: TaskAllocationItem; // 更新后的明细(含最新 allocated_quantity
task: {
id: number;
total_allocated: string; // 总已配货数量
total_required: string; // 总需求数量
progress: number; // 进度百分比0-100
}
}
}
```
**错误响应:**
```typescript
// 布条码重复
{
code: 400,
message: "该布条已录入",
data: {
existing_roll: TaskAllocationRoll // 已存在的记录
}
}
// 超出需求数量
{
code: 400,
message: "配货数量已超出需求,是否继续?",
data: {
required: "5000.00",
allocated: "5050.00",
overflow: "50.00"
}
}
```
---
#### 2.2.6 删除布条记录
> 录错了,删除某条扫码记录
- **路径:** `DELETE /api/v1/task-allocations/{id}/rolls/{roll_id}/`
- **权限:** 需登录,需仓库员工权限
**响应数据:**
```typescript
{
code: 200,
message: "删除成功",
data: {
item: TaskAllocationItem; // 更新后的明细
}
}
```
---
#### 2.2.7 完成配货
> 仓库人员完成配货,状态变为"已完成"
- **路径:** `POST /api/v1/task-allocations/{id}/complete/`
- **权限:** 需登录,需仓库员工权限
**请求参数:**
```typescript
{
remarks?: string; // 可选,完成备注
}
```
**响应数据:**
```typescript
{
code: 200,
message: "配货完成",
data: TaskAllocation
}
```
**校验规则:**
- 所有明细的 `allocated_quantity` 必须 > 0
- 如果某明细未配货,返回 400 错误
---
#### 2.2.8 确认配货结果并转销售单 ⭐
> 销售人员/客户确认配货数量,将预销售单转为正式销售单
- **路径:** `POST /api/v1/task-allocations/{id}/confirm-and-convert/`
- **权限:** 需登录,需销售员工权限
**请求参数:**
```typescript
{
confirmed_items: Array<{
item_id: number; // 明细 ID
confirmed_quantity: string; // 确认的数量(可能与实际配货数量不同)
}>;
remarks?: string; // 可选,确认备注
}
```
**响应数据:**
```typescript
{
code: 200,
message: "已转为销售单",
data: {
task_allocation: TaskAllocation; // 更新后的任务配货(状态变更)
sales_order: {
id: number;
human_id: string; // 销售单编号(如 XS20260201000001
// ... 其他销售单字段
}
}
}
```
---
### 2.3 预销售单状态扩展
现有预销售单 API 需要新增以下字段:
```typescript
// 响应中新增
{
// ... 原有字段 ...
allocation_status: number; // 配货状态1=待配货2=配货中3=待确认4=已完成
allocation_status_display: string; // 配货状态文字(只读)
task_allocation_id: number | null; // 关联的任务配货 ID
converted_sales_order_id: number | null; // 转换后的销售单 ID
}
```
---
## 三、状态枚举值
### 3.1 任务配货状态
| 值 | 名称 | 说明 |
|----|------|------|
| 1 | pending | 待接收 |
| 2 | in_progress | 进行中 |
| 3 | completed | 已完成 |
| 4 | cancelled | 已取消 |
### 3.2 任务配货明细状态
| 值 | 名称 | 说明 |
|----|------|------|
| 1 | pending | 待配货 |
| 2 | allocating | 配货中 |
| 3 | completed | 已完成 |
### 3.3 预销售单配货状态
| 值 | 名称 | 说明 |
|----|------|------|
| 1 | pending | 待配货 |
| 2 | allocating | 配货中 |
| 3 | confirming | 待确认 |
| 4 | completed | 已完成 |
### 3.4 优先级
| 值 | 名称 | 说明 |
|----|------|------|
| 1 | normal | 普通 |
| 2 | urgent | 加急 |
---
## 四、API 路径汇总
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/task-allocations/` | 创建任务配货 |
| GET | `/api/v1/task-allocations/` | 任务配货列表 |
| GET | `/api/v1/task-allocations/{id}/` | 任务配货详情 |
| POST | `/api/v1/task-allocations/{id}/accept/` | 接收任务 |
| POST | `/api/v1/task-allocations/{id}/scan/` | 扫码录入布条 |
| DELETE | `/api/v1/task-allocations/{id}/rolls/{roll_id}/` | 删除布条记录 |
| POST | `/api/v1/task-allocations/{id}/complete/` | 完成配货 |
| POST | `/api/v1/task-allocations/{id}/confirm-and-convert/` | 确认并转销售单 |
---
## 五、前端页面规划
### 5.1 页面路由
| 路由 | 页面 | 角色 |
|------|------|------|
| `/workstation/sales/pre-order` | 预销售单列表 | 销售 |
| `/workstation/sales/pre-order/create` | 创建预销售单 | 销售 |
| `/workstation/sales/pre-order/:id` | 预销售单详情 | 销售 |
| `/workstation/warehouse/task-allocation` | 任务配货列表 | 仓库 |
| `/workstation/warehouse/task-allocation/:id` | 任务配货详情/扫码录入 | 仓库 |
| `/workstation/warehouse/task-allocation/:id/scan` | 扫码录入页面 | 仓库 |
| `/workstation/sales/pre-order/:id/confirm` | 确认配货结果 | 销售 |
### 5.2 页面功能
**仓库人员 - 任务配货扫码页面:**
```
┌─────────────────────────────────┐
│ 任务配货 PH20260201000001 │
│ 客户XXX布业 仓库:主仓库 │
├─────────────────────────────────┤
│ 产品:涤纶面料-红色 │
│ 需求5000.00 米 │
│ 已配4800.00 米 [96%] │
│ ████████████████████░░ │
├─────────────────────────────────┤
│ ┌───────────────────────────┐ │
│ │ [扫码区域/摄像头] │ │
│ │ │ │
│ └───────────────────────────┘ │
│ │
│ 或手动输入: │
│ 布条码:[____________] │
│ 米 数:[____________] │
│ [录入] │
├─────────────────────────────────┤
│ 已录入布条3条
│ ┌───────────────────────────┐ │
│ │ R001 50.00米 张三 10:30 │ │
│ │ R002 48.50米 张三 10:32 │ │
│ │ R003 51.50米 张三 10:35 │ │
│ └───────────────────────────┘ │
├─────────────────────────────────┤
│ [完成配货] │
└─────────────────────────────────┘
```
---
## 六、确认事项
请后端同事确认:
1. [ ] 任务配货是否在创建预销售单时自动创建?还是需要手动创建?
2. [ ] 布条码的格式规范是什么?是否需要校验格式?
3. [ ] 一条布可能属于多个产品吗?还是一对一关系?
4. [ ] 配货超出需求数量时,是警告还是阻止?
5. [ ] 转销售单时,价格如何处理?(预销售单是否有价格字段?)
6. [ ] 是否需要支持"部分配货"后转销售单?
7. [ ] 任务配货是否需要支持"退回"操作(从进行中退回到待接收)?
8. [ ] 预销售单和任务配货是否一对一关系?还是一对多?
---
**前端负责人:** [待填写]
**后端负责人:** [待填写]
**评审日期:** [待填写]

View File

@@ -0,0 +1,273 @@
# API v1预销售单 / 预采购单(前端对接)
本文档面向前端描述预销售单PreSalesOrder与预采购单PrePurchaseOrder的 API、字段与参数。
- API 前缀:`/api/v1/`
- 认证:接口均要求登录(`IsAuthenticated`)。
- 权限:要求当前用户绑定员工(`request.user.employee`),否则返回 `403`
## 分页Limit/Offset
预销售单列表、预采购单列表均使用 DRF `LimitOffsetPagination`
- `limit`:返回条数,默认 `20`,最大 `100`
- `offset`:偏移量,默认 `0`
分页响应结构:
```json
{
"count": 123,
"next": "http://.../api/v1/pre-sales-orders/?limit=20&offset=20",
"previous": null,
"results": [
{ "id": 1, "human_id": "YS...", "items": [] }
]
}
```
## 列表查询参数(已支持)
预销售单列表 `GET /pre-sales-orders/`
- `limit` / `offset`
- `customer` / `customer_id`int
- `warehouse` / `warehouse_id`int
- `kind`int
- `human_id__icontains`string
- `created_at_from`datetime 或 date
- `created_at_to`datetime 或 date
预采购单列表 `GET /pre-purchase-orders/`
- `limit` / `offset`
- `supplier` / `supplier_id`int
- `warehouse` / `warehouse_id`int
- `kind`int
- `human_id__icontains`string
- `created_at_from`datetime 或 date
- `created_at_to`datetime 或 date
---
# 预销售单 PreSalesOrder
## 1) 列表
- `GET /api/v1/pre-sales-orders/?limit=20&offset=0`
- 查询参数:
- `limit`可选int默认 20最大 100
- `offset`可选int默认 0
- 响应:`200 OK`,分页结构,`results` 为预销售单数组
## 2) 创建
- `POST /api/v1/pre-sales-orders/`
请求体参数JSON
- `customer` / `customer_id`必填int客户 ID
- `warehouse` / `warehouse_id`必填int仓库 ID
- `kind`可选int预销售单类型
- `1`:大货
- `2`:样板
- 默认 `1`
- `remarks`可选string
- `items`必填array非空明细列表
`items[]` 字段:
- `product_id` / `product`必填int产品 ID
- `quantity`必填string 或 number数量会被转换为 Decimal
- `unit`可选string单位为空时会尝试使用产品默认单位
- `product_name`可选string产品名称弱关联承载
- `color`可选string
- `spec`可选string
- `quantity_of_rolls`可选string各条数数量原样保存
- `num_of_rolls`可选int条数必须 > 0默认 `1`
- `order_quantity`可选int下单数量必须为非负整数
- `remarks`可选string明细备注
响应:`201 CREATED`,返回预销售单详情(含 `items`)。
示例请求:
```json
{
"customer": 1,
"warehouse": 2,
"kind": 1,
"remarks": "预销售单备注",
"items": [
{
"product_id": 10,
"quantity": "12.5",
"unit": "米",
"order_quantity": 7,
"remarks": "明细备注"
}
]
}
```
## 3) 详情
- `GET /api/v1/pre-sales-orders/{id}/`
- 响应:`200 OK`,返回预销售单详情
## 4) 更新(全量/部分)
- `PUT /api/v1/pre-sales-orders/{id}/`
- `PATCH /api/v1/pre-sales-orders/{id}/`
请求体参数:
- `customer` / `customer_id`可选int
- `warehouse` / `warehouse_id`可选int
- `kind`可选int
- `remarks`可选string
- `items`必填array非空更新时必须提供否则会返回 `400`
响应:`200 OK`,返回更新后的详情。
## 5) 删除
- `DELETE /api/v1/pre-sales-orders/{id}/`
- 响应:`204 NO CONTENT`
## 预销售单字段说明(响应)
预销售单对象字段(`results[]` 与详情一致):
- `id`int主键
- `human_id`string人类可读编号`YSYYYYMMDD000001`
- `merchant`int所属商户 ID
- `merchant`int商户 ID调试字段
- `customer`int客户 ID
- `customer_name`string只读
- `warehouse`int仓库 ID
- `warehouse_name`string只读
- `operator`int|null经办人员工ID创建时由后端从登录用户推导
- `operator_name`string只读
- `kind`int类型1/2
- `remarks`string|null
- `created_at`datetime string
- `items`array明细只读
`items[]` 字段(响应):
- `id`int
- `product_id`int|null
- `product_name`string
- `color`string|null
- `quantity`string|null通常为两位小数`"20.00"`
- `unit`string
- `spec`string|null
- `remarks`string|null
---
# 预采购单 PrePurchaseOrder
## 1) 列表
- `GET /api/v1/pre-purchase-orders/?limit=20&offset=0`
- 查询参数:
- `limit`可选int默认 20最大 100
- `offset`可选int默认 0
- 响应:`200 OK`,分页结构
## 2) 创建
- `POST /api/v1/pre-purchase-orders/`
请求体参数JSON
- `supplier` / `supplier_id`必填int供应商 ID
- `warehouse` / `warehouse_id`必填int仓库 ID
- `kind`可选int预采购单类型
- `1`:大货
- `2`:样板
- 默认 `1`
- `remarks`可选string
- `items`必填array非空明细列表字段同预销售明细
响应:`201 CREATED`,返回预采购单详情(含 `items`)。
示例请求:
```json
{
"supplier": 1,
"warehouse": 2,
"kind": 1,
"remarks": "预采购单备注",
"items": [
{
"product_id": 10,
"quantity": "12.5",
"unit": "米",
"order_quantity": 7,
"remarks": "明细备注"
}
]
}
```
## 3) 详情
- `GET /api/v1/pre-purchase-orders/{id}/`
- 响应:`200 OK`
## 4) 更新(全量/部分)
- `PUT /api/v1/pre-purchase-orders/{id}/`
- `PATCH /api/v1/pre-purchase-orders/{id}/`
请求体参数:
- `supplier` / `supplier_id`可选int
- `warehouse` / `warehouse_id`可选int
- `kind`可选int
- `remarks`可选string
- `items`必填array非空更新时必须提供否则会返回 `400`
响应:`200 OK`
## 5) 删除
- `DELETE /api/v1/pre-purchase-orders/{id}/`
- 响应:`204 NO CONTENT`
## 预采购单字段说明(响应)
预采购单对象字段(`results[]` 与详情一致):
- `id`int
- `human_id`string人类可读编号`YCYYYYMMDD000001`
- `merchant`int
- `merchant`int商户 ID调试字段
- `supplier`int
- `supplier_name`string只读
- `warehouse`int
- `warehouse_name`string只读
- `operator`int|null
- `operator_name`string只读
- `kind`int1/2
- `remarks`string|null
- `created_at`
- `items`array只读
`items[]` 字段同预销售明细响应。
---
# 错误响应约定(两类接口通用)
- `400 BAD REQUEST`:参数校验失败
- 示例:`{"error": "items 需要为非空数组"}`
- `401 UNAUTHORIZED`:未登录
- `403 FORBIDDEN`:无员工信息/无权限
- 示例:`{"error": "无权限访问"}`
- `404 NOT FOUND`:订单不存在
- 示例:`{"error": "预销售单不存在"}` / `{"error": "预采购单不存在"}`

View File

@@ -0,0 +1,164 @@
# 预销售单/预采购单 API 字段分析报告
> **文档目的:** 对比 API 响应字段与前端实际使用情况,优化接口返回数据
>
> **生成日期:** 2026-02-01
---
## 一、问题概述
- **业务场景:** 预销售单/预采购单的列表、详情展示
- **涉及 API**
- `GET /api/v1/pre-sales-orders/`
- `GET /api/v1/pre-sales-orders/{id}/`
- `GET /api/v1/pre-purchase-orders/`
- `GET /api/v1/pre-purchase-orders/{id}/`
- **问题类型:** API 返回了前端未使用的冗余字段
---
## 二、单据主体字段分析
### 2.1 API 返回但前端未使用的字段(建议移除或设为可选)
| 字段 | 类型 | 说明 | 前端使用情况 |
|------|------|------|-------------|
| `merchant` | int | 商户 ID | ❌ 未使用,前端用户已在自己商户下 |
| `merchant_name` | string | 商户名称 | ❌ 未使用 |
| `created_by` | int\|null | 创建者用户 ID | ❌ 未使用,只用 username |
| `operator` | int\|null | 经办人 ID | ❌ 未使用,只用 name |
| `updated_at` | datetime | 更新时间 | ❌ 未使用 |
### 2.2 前端实际使用的字段
| 字段 | 使用场景 |
|------|----------|
| `id` | 主键,编辑/删除操作 |
| `human_id` | 列表显示、详情标题 |
| `customer` / `supplier` | 表单回显(编辑时) |
| `customer_name` / `supplier_name` | 列表列、详情显示 |
| `warehouse` | 表单回显(编辑时) |
| `warehouse_name` | 列表列、详情显示 |
| `created_by_username` | 详情抽屉显示 |
| `operator_name` | 列表列、详情显示 |
| `kind` | 列表列(标签)、详情显示 |
| `remarks` | 列表列、详情显示 |
| `created_at` | 列表列(格式化显示) |
| `items` | 详情明细表格 |
---
## 三、明细 items 字段分析
### 3.1 API 返回但前端未使用的字段
| 字段 | 类型 | 说明 | 前端使用情况 |
|------|------|------|-------------|
| `quantity_of_rolls` | string\|null | 各条数数量 | ❌ 页面未显示 |
| `num_of_rolls` | int | 条数 | ❌ 页面未显示 |
| `order_quantity` | int\|null | 下单数量 | ❌ 页面未显示 |
| `created_at` | datetime | 明细创建时间 | ❌ 页面未显示 |
| `updated_at` | datetime | 明细更新时间 | ❌ 页面未显示 |
### 3.2 前端实际使用的字段
| 字段 | 使用场景 |
|------|----------|
| `id` | 表格 row key |
| `product_id` | 编辑时回显 |
| `product_name` | 明细表格列 |
| `spec` | 明细表格列 |
| `color` | 明细表格列 |
| `quantity` | 明细表格列 |
| `unit` | 明细表格列 |
| `remarks` | 明细表格列 |
---
## 四、建议方案
### 方案 A精简默认响应推荐
移除以下字段的默认返回,减少数据传输量:
**单据主体移除:**
```diff
{
"id": 1,
"human_id": "YS20260201000001",
- "merchant": 1,
- "merchant_name": "XX商户",
"customer": 123,
"customer_name": "客户A",
"warehouse": 1,
"warehouse_name": "主仓库",
- "created_by": 10,
"created_by_username": "admin",
- "operator": 5,
"operator_name": "张三",
"kind": 1,
"remarks": "备注",
"created_at": "2026-02-01T10:00:00Z",
- "updated_at": "2026-02-01T10:00:00Z",
"items": [...]
}
```
**明细 items 移除:**
```diff
{
"id": 1,
"product_id": 100,
"product_name": "产品A",
"color": "红色",
"quantity": "20.00",
"unit": "米",
"spec": "规格1",
- "quantity_of_rolls": null,
- "num_of_rolls": 1,
- "order_quantity": null,
"remarks": "明细备注"
- "created_at": "2026-02-01T10:00:00Z",
- "updated_at": "2026-02-01T10:00:00Z"
}
```
### 方案 B支持 fields 参数按需返回
如果其他客户端可能需要这些字段,可以支持 `fields` 查询参数:
```
GET /api/v1/pre-sales-orders/?fields=id,human_id,customer_name,items
```
---
## 五、预估收益
假设每条单据平均 5 个明细项:
| 优化项 | 移除字段数 | 预估节省字节 |
|--------|-----------|-------------|
| 单据主体 | 5 个字段 | ~150 bytes/单据 |
| 明细项 | 5 个字段 × 5 项 | ~250 bytes/单据 |
| **总计** | - | ~400 bytes/单据 |
列表页默认 20 条:**节省约 8KB/请求**
---
## 六、确认事项
请后端同事确认:
1. [ ] 上述"未使用字段"是否可以从默认响应中移除?
2. [ ] 是否有其他客户端(如小程序、管理后台)依赖这些字段?
3. [ ] 如果有其他依赖,是否采用方案 Bfields 参数)?
4. [ ] `quantity_of_rolls``num_of_rolls``order_quantity` 这三个明细字段是否有业务场景需要?如果暂时不用,前端类型定义中会保留但标记为可选。
---
**前端负责人:** [待填写]
**后端负责人:** [待填写]
**预计完成:** [待填写]