diff --git a/api_v1/test_pre_sales_allocation_api.py b/api_v1/test_pre_sales_allocation_api.py new file mode 100644 index 0000000..4481902 --- /dev/null +++ b/api_v1/test_pre_sales_allocation_api.py @@ -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']) diff --git a/api_v1/urls.py b/api_v1/urls.py index 7d55224..7494dd6 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -91,6 +91,16 @@ urlpatterns = [ path('sales-return-orders//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.PreSalesOrderDetailView.as_view(), name='pre_sales_order_detail'), + path( + 'pre-sales-order-items//allocations/', + pre_sales_views.PreSalesOrderItemAllocationView.as_view(), + name='pre_sales_order_item_allocations', + ), + path( + 'pre-sales-order-items//allocations//', + 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.PrePurchaseOrderDetailView.as_view(), name='pre_purchase_order_detail'), path('payment-orders/', payment_views.PaymentOrderView.as_view(), name='payment_orders'), diff --git a/api_v1/views/business/pre_purchase/views.py b/api_v1/views/business/pre_purchase/views.py index 9e6d3eb..4ce9ae1 100644 --- a/api_v1/views/business/pre_purchase/views.py +++ b/api_v1/views/business/pre_purchase/views.py @@ -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) diff --git a/api_v1/views/business/pre_sales/views.py b/api_v1/views/business/pre_sales/views.py index a9a9e22..35f5c77 100644 --- a/api_v1/views/business/pre_sales/views.py +++ b/api_v1/views/business/pre_sales/views.py @@ -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) diff --git a/business/allocation_services.py b/business/allocation_services.py new file mode 100644 index 0000000..7ce4be1 --- /dev/null +++ b/business/allocation_services.py @@ -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 diff --git a/business/handlers.py b/business/handlers.py index 02e5db8..b992e09 100644 --- a/business/handlers.py +++ b/business/handlers.py @@ -1,14 +1,24 @@ import logging +from django.conf import settings +from django.db import transaction +from django.utils import timezone + logger = logging.getLogger(__name__) def on_pre_sales_order_created(sender, **kwargs): """Handle PreSalesOrder created domain event. - For now: log only (no side effects). + - 在事务提交后发送企业微信机器人通知(markdown) + - 测试环境默认跳过,避免单测出网/刷屏 """ + if not getattr(settings, "PRE_SALES_ORDER_CREATED_WECOM_NOTIFY_ENABLED", True): + return + if getattr(settings, "TESTING", False): + return + order = kwargs.get('instance') created_by = kwargs.get('created_by') operator = kwargs.get('operator') @@ -18,17 +28,73 @@ def on_pre_sales_order_created(sender, **kwargs): logger.warning('[business.handlers] pre_sales_order_created 缺少 instance,已跳过') return - created_by_label = getattr(created_by, 'username', None) if created_by else None - operator_label = getattr(operator, 'name', None) if operator else None + try: + from .pre_order_services import render_pre_sales_order_created_markdown - logger.info( - '[business.handlers] pre_sales_order_created: id=%s human_id=%s merchant_id=%s customer_id=%s ' - 'created_by=%s operator=%s items_count=%s', - getattr(order, 'id', None), - getattr(order, 'human_id', None), - getattr(order, 'merchant_id', None), - getattr(order, 'customer_id', None), - created_by_label or '-', - operator_label or '-', - items_count if items_count is not None else '-', - ) + created_at_dt = getattr(order, 'created_at', None) + created_at = ( + timezone.localtime(created_at_dt).strftime('%Y-%m-%d %H:%M:%S') + if created_at_dt is not None + else timezone.localtime(timezone.now()).strftime('%Y-%m-%d %H:%M:%S') + ) + + operator_label = getattr(operator, 'name', None) if operator else None + + emp = getattr(created_by, 'employee', None) if created_by else None + created_by_employee_name = getattr(emp, 'name', None) if emp is not None else None + created_by_username = getattr(created_by, 'username', None) if created_by else None + sender_label = operator_label or created_by_employee_name or created_by_username or '系统自动发送' + + try: + customer_name = getattr(getattr(order, 'customer', None), 'name', None) + except Exception: + customer_name = None + + try: + warehouse_name = getattr(getattr(order, 'warehouse', None), 'name', None) + except Exception: + warehouse_name = None + + try: + kind_label = getattr(order, 'get_kind_display', lambda: None)() or None + except Exception: + kind_label = None + + resolved_items_count = items_count + if resolved_items_count is None: + try: + resolved_items_count = int(getattr(order, 'items', []).count()) + except Exception: + resolved_items_count = 0 + + message = render_pre_sales_order_created_markdown( + pre_sales_order_id=str(getattr(order, 'id', '-') or '-'), + human_id=str(getattr(order, 'human_id', '-') or '-'), + created_at=str(created_at), + sender_label=str(sender_label), + customer_name=str(customer_name or '-'), + warehouse_name=str(warehouse_name or '-'), + kind=str(kind_label or '-'), + items_count=int(resolved_items_count or 0), + ) + except Exception: + logger.exception('[business.handlers] 渲染企业微信消息失败,跳过通知') + return + + def _send_wecom(): + try: + from api_v1.utils.wecom_webhook import send_wecom_webhook_message + + resp = send_wecom_webhook_message(content=message, msgtype='markdown') + if not resp.ok: + logger.warning( + '[business.handlers] WeCom webhook 返回失败:errcode=%s, errmsg=%s, raw=%s', + resp.errcode, + resp.errmsg, + resp.raw, + ) + except Exception: + logger.exception('[business.handlers] 发送 WeCom webhook 失败(已忽略,不影响主流程)') + + # 在事务提交后再发送,避免事务回滚但通知已发出 + transaction.on_commit(_send_wecom) diff --git a/business/migrations/0025_allocationrecord.py b/business/migrations/0025_allocationrecord.py new file mode 100644 index 0000000..ba09879 --- /dev/null +++ b/business/migrations/0025_allocationrecord.py @@ -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': '配货记录', + }, + ), + ] diff --git a/business/models.py b/business/models.py index 3767f65..634af03 100644 --- a/business/models.py +++ b/business/models.py @@ -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): """预采购单 diff --git a/business/pre_order_services.py b/business/pre_order_services.py index 64eccb5..f58c9b0 100644 --- a/business/pre_order_services.py +++ b/business/pre_order_services.py @@ -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} 不能为空') diff --git a/business/tests/test_allocation_services.py b/business/tests/test_allocation_services.py new file mode 100644 index 0000000..0f8a11d --- /dev/null +++ b/business/tests/test_allocation_services.py @@ -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) diff --git a/docs/2026-02-02_pre_sales_order_and_allocation_design_reply.md b/docs/2026-02-02_pre_sales_order_and_allocation_design_reply.md new file mode 100644 index 0000000..d7d178e --- /dev/null +++ b/docs/2026-02-02_pre_sales_order_and_allocation_design_reply.md @@ -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 层,不引入重复代码 + +## 六、待后续阶段处理 + +- 转销售单流程(确认并转单) +- 任务指派/接收/完成等任务主表流程 diff --git a/docs/api_task_allocation_proposal.md b/docs/api_task_allocation_proposal.md new file mode 100644 index 0000000..a13eac7 --- /dev/null +++ b/docs/api_task_allocation_proposal.md @@ -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. [ ] 预销售单和任务配货是否一对一关系?还是一对多? + +--- + +**前端负责人:** [待填写] +**后端负责人:** [待填写] +**评审日期:** [待填写] diff --git a/docs/api_v1_pre_order_api.md b/docs/api_v1_pre_order_api.md new file mode 100644 index 0000000..b9e7339 --- /dev/null +++ b/docs/api_v1_pre_order_api.md @@ -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": "预采购单不存在"}` diff --git a/docs/pre_order_api_field_analysis.md b/docs/pre_order_api_field_analysis.md new file mode 100644 index 0000000..c27c9fb --- /dev/null +++ b/docs/pre_order_api_field_analysis.md @@ -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` 这三个明细字段是否有业务场景需要?如果暂时不用,前端类型定义中会保留但标记为可选。 + +--- + +**前端负责人:** [待填写] +**后端负责人:** [待填写] +**预计完成:** [待填写]