forked from erp-dev/erp
feat: allocation record for pre sales order
This commit is contained in:
154
api_v1/test_pre_sales_allocation_api.py
Normal file
154
api_v1/test_pre_sales_allocation_api.py
Normal 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'])
|
||||
@@ -91,6 +91,16 @@ urlpatterns = [
|
||||
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/<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/<int:pk>/', pre_purchase_views.PrePurchaseOrderDetailView.as_view(), name='pre_purchase_order_detail'),
|
||||
path('payment-orders/', payment_views.PaymentOrderView.as_view(), name='payment_orders'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from rest_framework import status, views, serializers, pagination
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.utils import dateparse, timezone
|
||||
|
||||
from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
|
||||
from business import models as business_models
|
||||
@@ -18,22 +19,15 @@ class PrePurchaseOrderItemSerializer(serializers.ModelSerializer):
|
||||
'quantity',
|
||||
'unit',
|
||||
'spec',
|
||||
'quantity_of_rolls',
|
||||
'num_of_rolls',
|
||||
'order_quantity',
|
||||
'remarks',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id']
|
||||
|
||||
|
||||
class PrePurchaseOrderSerializer(serializers.ModelSerializer):
|
||||
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)
|
||||
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)
|
||||
items = PrePurchaseOrderItemSerializer(many=True, read_only=True)
|
||||
|
||||
@@ -43,30 +37,23 @@ class PrePurchaseOrderSerializer(serializers.ModelSerializer):
|
||||
'id',
|
||||
'human_id',
|
||||
'merchant',
|
||||
'merchant_name',
|
||||
'supplier',
|
||||
'supplier_name',
|
||||
'warehouse',
|
||||
'warehouse_name',
|
||||
'created_by',
|
||||
'created_by_username',
|
||||
'operator',
|
||||
'operator_name',
|
||||
'kind',
|
||||
'remarks',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'items',
|
||||
]
|
||||
read_only_fields = [
|
||||
'id',
|
||||
'human_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'merchant_name',
|
||||
'supplier_name',
|
||||
'warehouse_name',
|
||||
'created_by_username',
|
||||
'operator_name',
|
||||
'items',
|
||||
]
|
||||
@@ -90,6 +77,49 @@ class PrePurchaseOrderView(StockChangeViewMixin, views.APIView):
|
||||
merchant = request.user.employee.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()
|
||||
page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self)
|
||||
serializer = PrePurchaseOrderSerializer(page, many=True)
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import status, views, serializers, pagination
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
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 business import models as business_models
|
||||
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):
|
||||
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:
|
||||
model = business_models.PreSalesOrderItem
|
||||
fields = [
|
||||
@@ -18,22 +60,44 @@ class PreSalesOrderItemSerializer(serializers.ModelSerializer):
|
||||
'quantity',
|
||||
'unit',
|
||||
'spec',
|
||||
'quantity_of_rolls',
|
||||
'num_of_rolls',
|
||||
'order_quantity',
|
||||
'remarks',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'allocated_quantity',
|
||||
'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):
|
||||
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)
|
||||
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)
|
||||
items = PreSalesOrderItemSerializer(many=True, read_only=True)
|
||||
|
||||
@@ -43,30 +107,23 @@ class PreSalesOrderSerializer(serializers.ModelSerializer):
|
||||
'id',
|
||||
'human_id',
|
||||
'merchant',
|
||||
'merchant_name',
|
||||
'customer',
|
||||
'customer_name',
|
||||
'warehouse',
|
||||
'warehouse_name',
|
||||
'created_by',
|
||||
'created_by_username',
|
||||
'operator',
|
||||
'operator_name',
|
||||
'kind',
|
||||
'remarks',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'items',
|
||||
]
|
||||
read_only_fields = [
|
||||
'id',
|
||||
'human_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'merchant_name',
|
||||
'customer_name',
|
||||
'warehouse_name',
|
||||
'created_by_username',
|
||||
'operator_name',
|
||||
'items',
|
||||
]
|
||||
@@ -90,6 +147,49 @@ class PreSalesOrderView(StockChangeViewMixin, views.APIView):
|
||||
merchant = request.user.employee.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()
|
||||
page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self)
|
||||
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)
|
||||
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)
|
||||
|
||||
140
business/allocation_services.py
Normal file
140
business/allocation_services.py
Normal 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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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 '-',
|
||||
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)
|
||||
|
||||
33
business/migrations/0025_allocationrecord.py
Normal file
33
business/migrations/0025_allocationrecord.py
Normal 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': '配货记录',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
"""预采购单
|
||||
|
||||
|
||||
@@ -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} 不能为空')
|
||||
|
||||
185
business/tests/test_allocation_services.py
Normal file
185
business/tests/test_allocation_services.py
Normal 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)
|
||||
102
docs/2026-02-02_pre_sales_order_and_allocation_design_reply.md
Normal file
102
docs/2026-02-02_pre_sales_order_and_allocation_design_reply.md
Normal 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_item(FK)
|
||||
- stock_ids(库存明细 ID 数组)
|
||||
- 这些 ID 对应 stock.StockChangeDetail
|
||||
- 由于系统已有一致做法(consume_detail_ids 为字符串存储),建议使用字符串持久化,展示层转换为 List[int]
|
||||
- quantity(decimal,执行数量)
|
||||
- unit(string)
|
||||
- status(int,1=有效,2=撤销)
|
||||
- scanned_by(Employee)
|
||||
- scanned_at(datetime)
|
||||
- remarks(可选)
|
||||
|
||||
### 4. 派生字段(必须是计算字段,不落库)
|
||||
- allocated_quantity = Σ AllocationRecord.quantity(仅统计 status=有效)
|
||||
- required_quantity = PreSalesOrderItem.quantity
|
||||
- progress = allocated_quantity / required_quantity
|
||||
- allocation_status(计算状态)
|
||||
- pending:allocated_quantity == 0
|
||||
- allocating:0 < allocated_quantity < required_quantity
|
||||
- completed:allocated_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 层,不引入重复代码
|
||||
|
||||
## 六、待后续阶段处理
|
||||
|
||||
- 转销售单流程(确认并转单)
|
||||
- 任务指派/接收/完成等任务主表流程
|
||||
489
docs/api_task_allocation_proposal.md
Normal file
489
docs/api_task_allocation_proposal.md
Normal 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. [ ] 预销售单和任务配货是否一对一关系?还是一对多?
|
||||
|
||||
---
|
||||
|
||||
**前端负责人:** [待填写]
|
||||
**后端负责人:** [待填写]
|
||||
**评审日期:** [待填写]
|
||||
273
docs/api_v1_pre_order_api.md
Normal file
273
docs/api_v1_pre_order_api.md
Normal 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`(int,1/2)
|
||||
- `remarks`(string|null)
|
||||
- `created_at`
|
||||
- `items`(array,只读)
|
||||
|
||||
`items[]` 字段同预销售明细响应。
|
||||
|
||||
---
|
||||
|
||||
# 错误响应约定(两类接口通用)
|
||||
|
||||
- `400 BAD REQUEST`:参数校验失败
|
||||
- 示例:`{"error": "items 需要为非空数组"}`
|
||||
- `401 UNAUTHORIZED`:未登录
|
||||
- `403 FORBIDDEN`:无员工信息/无权限
|
||||
- 示例:`{"error": "无权限访问"}`
|
||||
- `404 NOT FOUND`:订单不存在
|
||||
- 示例:`{"error": "预销售单不存在"}` / `{"error": "预采购单不存在"}`
|
||||
164
docs/pre_order_api_field_analysis.md
Normal file
164
docs/pre_order_api_field_analysis.md
Normal 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. [ ] 如果有其他依赖,是否采用方案 B(fields 参数)?
|
||||
4. [ ] `quantity_of_rolls`、`num_of_rolls`、`order_quantity` 这三个明细字段是否有业务场景需要?如果暂时不用,前端类型定义中会保留但标记为可选。
|
||||
|
||||
---
|
||||
|
||||
**前端负责人:** [待填写]
|
||||
**后端负责人:** [待填写]
|
||||
**预计完成:** [待填写]
|
||||
Reference in New Issue
Block a user