From d522c0a9b07dc8ebbd76b90ae8c069ab689a4ee7 Mon Sep 17 00:00:00 2001 From: colaftc Date: Mon, 1 Dec 2025 22:55:52 +0800 Subject: [PATCH] feat: statement for supplier and customer --- api_v1/tests.py | 97 ++++++ api_v1/urls.py | 4 + api_v1/views/business/balance/views.py | 26 ++ api_v1/views/business/statements/__init__.py | 7 + .../views/business/statements/serializers.py | 37 ++ api_v1/views/business/statements/views.py | 325 ++++++++++++++++++ api_v1/views/stock_change_views/offset.py | 21 +- business/admin.py | 69 ++++ business/services.py | 2 +- business/tests.py | 55 +++ docs/business_api_reference.md | 108 +++++- ...0251128-stock.md => stock_api_overview.md} | 59 +++- stock/models.py | 3 + stock/services.py | 203 ++++++++++- stock/tests.py | 121 ++++++- 15 files changed, 1085 insertions(+), 52 deletions(-) create mode 100644 api_v1/views/business/statements/__init__.py create mode 100644 api_v1/views/business/statements/serializers.py create mode 100644 api_v1/views/business/statements/views.py rename docs/{20251128-stock.md => stock_api_overview.md} (89%) diff --git a/api_v1/tests.py b/api_v1/tests.py index 484dd3f..1209a39 100644 --- a/api_v1/tests.py +++ b/api_v1/tests.py @@ -3,6 +3,7 @@ import shutil import tempfile from pathlib import Path from unittest.mock import patch +from decimal import Decimal from django.test import TestCase, override_settings from django.contrib.auth.models import User, Permission @@ -24,6 +25,7 @@ from basic_info.models import ( ) from business import models as business_models, services from stock import models as stock_models +from stock import services as stock_services from api_v1 import tasks @@ -689,6 +691,81 @@ class SalesReturnOrderAPITestCase(TestCase): self.assertEqual(order.status, business_models.SalesReturnStatusEnum.PENDING) +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CELERY_TASK_EAGER_PROPAGATES=True, +) +class StockChangeOffsetAPITestCase(TestCase): + def setUp(self): + self.merchant = Merchant.objects.create(name='库存商户', type=MerchantTypeEnum.FACTORY) + self.user = User.objects.create_user(username='stock_user', password='pass123') + self.employee = Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='库存员', + ) + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + self.warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='库存仓', + mode=WareHouseModeEnum.RESTRICT_IN, + ) + category = ProductCategory.objects.create( + merchant=self.merchant, + name='库存品类', + product_prefix='STK', + ) + self.product = Product.objects.create( + merchant=self.merchant, + category=category, + name='库存产品', + human_id='STK-001', + unit=ProductUnitEnum.METER, + ) + + self.stock_record, _, _ = stock_services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=None, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse_id=self.warehouse.id, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + source_id=5001, + products=[{'product': self.product.id, 'quantity': [Decimal('12')]}], + ) + stock_services.make_stock_change_completed(self.stock_record) + + def test_offset_stock_change_success(self): + response = self.client.post( + f'/api/v1/stock-change/{self.stock_record.id}/offset/', + {'reason': '数据修正'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['status'], 'success') + self.assertEqual( + stock_models.StockSnapshot.objects.filter( + stock_change_record=self.stock_record, + cancelled=True, + ).count(), + 1, + ) + + def test_offset_stock_change_duplicate(self): + self.client.post( + f'/api/v1/stock-change/{self.stock_record.id}/offset/', + {'reason': '首次'}, + format='json', + ) + response = self.client.post( + f'/api/v1/stock-change/{self.stock_record.id}/offset/', + {'reason': '再次'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('已执行红冲', response.data['error']) + class PaymentOrderAPITestCase(TestCase): def setUp(self): self.merchant = Merchant.objects.create(name='付款商户', type=MerchantTypeEnum.FACTORY) @@ -879,6 +956,26 @@ class CustomerBalanceAPITestCase(TestCase): ) response = self.client.get(f'/api/v1/customers/{self.customer.id}/balance/') self.assertEqual(response.data['balance'], '30.00') + + +class SupplierBalanceAPITestCase(TestCase): + def setUp(self): + self.merchant = Merchant.objects.create(name='供应商余额商户', type=MerchantTypeEnum.FACTORY) + self.supplier = Supplier.objects.create(merchant=self.merchant, name='供应商余额') + self.user = User.objects.create_user(username='supplier-balance', 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) + + def test_supplier_balance_api(self): + response = self.client.get(f'/api/v1/suppliers/{self.supplier.id}/balance/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['supplier'], self.supplier.id) @override_settings( CELERY_TASK_ALWAYS_EAGER=True, CELERY_TASK_EAGER_PROPAGATES=True, diff --git a/api_v1/urls.py b/api_v1/urls.py index 9c6b6b7..51c6452 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -17,6 +17,7 @@ from .views.business.receipt import views as receipt_views from .views.business.balance import views as balance_views from .views.business.purchase_return import views as purchase_return_views from .views.business.sales_return import views as sales_return_views +from .views.business.statements import views as statement_views from .views.stock_change_views.snapshot import StockSnapshotListView from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet from .views.upload import UploadFileViewSet @@ -77,6 +78,9 @@ urlpatterns = [ path('receipt-orders/', receipt_views.ReceiptOrderView.as_view(), name='receipt_orders'), path('receipt-orders//review/', receipt_views.ReceiptOrderReviewView.as_view(), name='receipt_order_review'), path('customers//balance/', balance_views.CustomerBalanceView.as_view(), name='customer_balance'), + path('suppliers//balance/', balance_views.SupplierBalanceView.as_view(), name='supplier_balance'), + path('customers//statements/', statement_views.CustomerStatementView.as_view(), name='customer_statements'), + path('suppliers//statements/', statement_views.SupplierStatementView.as_view(), name='supplier_statements'), path('health/', healthy.HealthCheckView.as_view(), name='health_check'), path('print-count/delta/', print_count.adjust_print_count, name='print_count_delta'), diff --git a/api_v1/views/business/balance/views.py b/api_v1/views/business/balance/views.py index 0a24c6e..930f15b 100644 --- a/api_v1/views/business/balance/views.py +++ b/api_v1/views/business/balance/views.py @@ -34,3 +34,29 @@ class CustomerBalanceView(StockChangeViewMixin, views.APIView): } ) + +class SupplierBalanceView(StockChangeViewMixin, views.APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, supplier_id: int): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + try: + supplier = basic_models.Supplier.objects.get(id=supplier_id, merchant=merchant) + except basic_models.Supplier.DoesNotExist: + return self.not_found_response('供应商不存在') + + balance: Decimal = business_services.BalanceService.get_supplier_balance( + merchant=merchant, + supplier=supplier, + ) + return Response( + { + 'supplier': supplier.id, + 'supplier_name': supplier.name, + 'balance': str(balance), + } + ) + diff --git a/api_v1/views/business/statements/__init__.py b/api_v1/views/business/statements/__init__.py new file mode 100644 index 0000000..785b767 --- /dev/null +++ b/api_v1/views/business/statements/__init__.py @@ -0,0 +1,7 @@ +""" +对账单(statement)相关的 API 视图。 + +该子包目前提供客户/供应商的对账单查询接口。 +""" + + diff --git a/api_v1/views/business/statements/serializers.py b/api_v1/views/business/statements/serializers.py new file mode 100644 index 0000000..7b976dc --- /dev/null +++ b/api_v1/views/business/statements/serializers.py @@ -0,0 +1,37 @@ +from rest_framework import serializers + + +class StatementRecordSerializer(serializers.Serializer): + source_type = serializers.CharField() + source_label = serializers.CharField() + source_id = serializers.IntegerField() + occurred_at = serializers.DateField() + recorded_at = serializers.DateTimeField() + status = serializers.IntegerField() + status_label = serializers.CharField() + counterparty = serializers.IntegerField() + counterparty_name = serializers.CharField() + positive_amount = serializers.DecimalField(max_digits=15, decimal_places=2) + negative_amount = serializers.DecimalField(max_digits=15, decimal_places=2) + extra = serializers.DictField(required=False) + + +class StatementResponseSerializer(serializers.Serializer): + counterparty = serializers.IntegerField() + counterparty_name = serializers.CharField() + records = StatementRecordSerializer(many=True) + summary = serializers.SerializerMethodField() + + def get_summary(self, obj): + summary_builder = self.context.get('summary_builder') + if callable(summary_builder): + return summary_builder(obj) + return self.context.get('summary') + + def to_representation(self, instance): + data = super().to_representation(instance) + if data.get('summary') is None: + data.pop('summary', None) + return data + + diff --git a/api_v1/views/business/statements/views.py b/api_v1/views/business/statements/views.py new file mode 100644 index 0000000..a8f8a93 --- /dev/null +++ b/api_v1/views/business/statements/views.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +from decimal import Decimal, ROUND_HALF_UP +from typing import Iterable, List + +from rest_framework import views +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from basic_info import models as basic_models +from business import models as business_models +from api_v1.views.stock_change_views.mixins import StockChangeViewMixin +from . import serializers as statement_serializers + +TWO_PLACES = Decimal('0.01') +ZERO = Decimal('0') + + +class StatementViewBase(StockChangeViewMixin, views.APIView): + """ + 提供对账单视图的公共实现:权限校验、序列化上下文及记录构建方法。 + """ + + permission_classes = [IsAuthenticated] + serializer_class = statement_serializers.StatementResponseSerializer + + def get_serializer_context(self): + return { + 'request': self.request, + 'view': self, + 'summary_builder': self._build_summary, + } + + def _build_response(self, counterparty, records: List[dict]): + serializer = self.serializer_class( + instance={ + 'counterparty': counterparty.id, + 'counterparty_name': counterparty.name, + 'records': records, + }, + context=self.get_serializer_context(), + ) + return Response(serializer.data) + + def _build_record( + self, + *, + counterparty_id: int, + counterparty_name: str, + source_type: str, + source_label: str, + source_id: int, + occurred_at, + recorded_at, + status: int, + status_label: str, + positive_amount, + negative_amount, + extra: dict | None = None, + ) -> dict: + record = { + 'counterparty': counterparty_id, + 'counterparty_name': counterparty_name, + 'source_type': source_type, + 'source_label': source_label, + 'source_id': source_id, + 'occurred_at': occurred_at, + 'recorded_at': recorded_at, + 'status': status, + 'status_label': status_label, + 'positive_amount': self._normalize_amount(positive_amount), + 'negative_amount': self._normalize_amount(negative_amount), + } + if extra: + record['extra'] = extra + return record + + def _sort_records(self, records: Iterable[dict]) -> List[dict]: + return sorted( + records, + key=lambda item: (item['occurred_at'], item['recorded_at'], item['source_id']), + reverse=True, + ) + + def _normalize_amount(self, value) -> Decimal: + if isinstance(value, Decimal): + decimal_value = value + else: + decimal_value = Decimal(str(value)) + return decimal_value.quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + + def _decimal_to_string(self, value: Decimal) -> str: + normalized = self._normalize_amount(value) + return format(normalized, 'f') + + def _build_summary(self, payload: dict): + records = payload.get('records', []) + total_positive = sum((record.get('positive_amount', ZERO) for record in records), ZERO) + total_negative = sum((record.get('negative_amount', ZERO) for record in records), ZERO) + return { + 'positive_total': self._decimal_to_string(total_positive), + 'negative_total': self._decimal_to_string(total_negative), + } + + +class CustomerStatementView(StatementViewBase): + """ + 返回指定客户的业务单据对账单,包含销售单、销售退货单与收款单。 + """ + + def get(self, request, customer_id: int): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + try: + customer = basic_models.Customer.objects.get(id=customer_id, merchant=merchant) + except basic_models.Customer.DoesNotExist: + return self.not_found_response('客户不存在') + + records = self._collect_customer_records(merchant, customer) + return self._build_response(customer, records) + + def _collect_customer_records(self, merchant, customer) -> List[dict]: + records: List[dict] = [] + records.extend(self._build_sales_records(merchant, customer)) + records.extend(self._build_sales_return_records(merchant, customer)) + records.extend(self._build_receipt_records(merchant, customer)) + return self._sort_records(records) + + def _build_sales_records(self, merchant, customer) -> List[dict]: + qs = ( + business_models.SalesOrder.objects.filter( + merchant=merchant, + customer=customer, + status=business_models.SalesOrderStatusEnum.APPROVED, + ) + .select_related('customer') + ) + records = [] + for order in qs: + records.append( + self._build_record( + counterparty_id=order.customer_id, + counterparty_name=order.customer.name, + source_type='sales_order', + source_label='销售单', + source_id=order.id, + occurred_at=order.sales_date, + recorded_at=order.created_at, + status=order.status, + status_label=order.get_status_display(), + positive_amount=order.get_total_amount(), + negative_amount=ZERO, + ) + ) + return records + + def _build_sales_return_records(self, merchant, customer) -> List[dict]: + qs = ( + business_models.SalesReturnOrder.objects.filter( + merchant=merchant, + customer=customer, + status=business_models.SalesReturnStatusEnum.APPROVED, + ) + .select_related('customer') + ) + records = [] + for order in qs: + records.append( + self._build_record( + counterparty_id=order.customer_id, + counterparty_name=order.customer.name, + source_type='sales_return_order', + source_label='销售退货单', + source_id=order.id, + occurred_at=order.return_date, + recorded_at=order.created_at, + status=order.status, + status_label=order.get_status_display(), + positive_amount=ZERO, + negative_amount=order.get_total_amount(), + ) + ) + return records + + def _build_receipt_records(self, merchant, customer) -> List[dict]: + qs = ( + business_models.ReceiptOrder.objects.filter( + merchant=merchant, + customer=customer, + status=business_models.ReceiptOrderStatusEnum.APPROVED, + ) + .select_related('customer') + ) + records = [] + for order in qs: + records.append( + self._build_record( + counterparty_id=order.customer_id, + counterparty_name=order.customer.name, + source_type='receipt_order', + source_label='收款单', + source_id=order.id, + occurred_at=order.receipt_date, + recorded_at=order.created_at, + status=order.status, + status_label=order.get_status_display(), + positive_amount=ZERO, + negative_amount=order.get_total_amount(), + ) + ) + return records + + +class SupplierStatementView(StatementViewBase): + """ + 返回指定供应商的业务单据对账单,包含采购单、采购退货单与付款单。 + """ + + def get(self, request, supplier_id: int): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + try: + supplier = basic_models.Supplier.objects.get(id=supplier_id, merchant=merchant) + except basic_models.Supplier.DoesNotExist: + return self.not_found_response('供应商不存在') + + records = self._collect_supplier_records(merchant, supplier) + return self._build_response(supplier, records) + + def _collect_supplier_records(self, merchant, supplier) -> List[dict]: + records: List[dict] = [] + records.extend(self._build_purchase_records(merchant, supplier)) + records.extend(self._build_purchase_return_records(merchant, supplier)) + records.extend(self._build_payment_records(merchant, supplier)) + return self._sort_records(records) + + def _build_purchase_records(self, merchant, supplier) -> List[dict]: + qs = ( + business_models.PurchaseOrder.objects.filter( + merchant=merchant, + supplier=supplier, + status=business_models.PurchaseOrderStatusEnum.APPROVED, + ) + .select_related('supplier') + ) + records = [] + for order in qs: + records.append( + self._build_record( + counterparty_id=order.supplier_id, + counterparty_name=order.supplier.name, + source_type='purchase_order', + source_label='采购单', + source_id=order.id, + occurred_at=order.purchase_date, + recorded_at=order.created_at, + status=order.status, + status_label=order.get_status_display(), + positive_amount=order.get_total_amount(), + negative_amount=ZERO, + ) + ) + return records + + def _build_purchase_return_records(self, merchant, supplier) -> List[dict]: + qs = ( + business_models.PurchaseReturnOrder.objects.filter( + merchant=merchant, + supplier=supplier, + status=business_models.PurchaseReturnStatusEnum.APPROVED, + ) + .select_related('supplier') + ) + records = [] + for order in qs: + records.append( + self._build_record( + counterparty_id=order.supplier_id, + counterparty_name=order.supplier.name, + source_type='purchase_return_order', + source_label='采购退货单', + source_id=order.id, + occurred_at=order.return_date, + recorded_at=order.created_at, + status=order.status, + status_label=order.get_status_display(), + positive_amount=ZERO, + negative_amount=order.get_total_amount(), + ) + ) + return records + + def _build_payment_records(self, merchant, supplier) -> List[dict]: + qs = ( + business_models.PaymentOrder.objects.filter( + merchant=merchant, + supplier=supplier, + status=business_models.PaymentOrderStatusEnum.APPROVED, + ) + .select_related('supplier') + ) + records = [] + for order in qs: + records.append( + self._build_record( + counterparty_id=order.supplier_id, + counterparty_name=order.supplier.name, + source_type='payment_order', + source_label='付款单', + source_id=order.id, + occurred_at=order.payment_date, + recorded_at=order.created_at, + status=order.status, + status_label=order.get_status_display(), + positive_amount=ZERO, + negative_amount=order.get_total_amount(), + ) + ) + return records + + diff --git a/api_v1/views/stock_change_views/offset.py b/api_v1/views/stock_change_views/offset.py index 5b25f0a..5d2ea44 100644 --- a/api_v1/views/stock_change_views/offset.py +++ b/api_v1/views/stock_change_views/offset.py @@ -32,20 +32,14 @@ class OffsetStockChangeView(StockChangeViewMixin, views.APIView): @extend_schema( tags=['库存红冲'], - summary='发起库存红冲(占位)', - description=( - '对指定库存变动记录创建红冲请求。当前仅提供占位实现,' - '会返回 501 Not Implemented,待库存红冲逻辑完善后启用。' - ), + summary='发起库存红冲', + description='对指定库存变动记录创建反向库存记录,并标记原库存快照已被红冲。', request=StockChangeOffsetSerializer, responses={ - 201: OpenApiResponse( - description='红冲成功(未来实现后返回)' - ), + 201: OpenApiResponse(description='红冲成功'), 400: OpenApiResponse(description='参数或业务校验失败'), 403: OpenApiResponse(description='无权访问对应出入库记录'), 404: OpenApiResponse(description='库存变动记录不存在'), - 501: OpenApiResponse(description='功能占位,尚未实现'), }, ) def post(self, request, record_id: int): @@ -81,15 +75,6 @@ class OffsetStockChangeView(StockChangeViewMixin, views.APIView): request_id=payload.get('request_id'), extra_meta=payload.get('extra_meta'), ) - except NotImplementedError as exc: - return Response( - { - 'error': 'stock_offset_not_ready', - 'message': str(exc), - 'record_id': stock_change_record.id, - }, - status=status.HTTP_501_NOT_IMPLEMENTED, - ) except ValueError as exc: return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) diff --git a/business/admin.py b/business/admin.py index da0e21f..fdb73ee 100644 --- a/business/admin.py +++ b/business/admin.py @@ -125,6 +125,68 @@ class SalesReturnOrderItemAdmin(admin.ModelAdmin): return obj.total_amount() +@admin.register(models.SalesOrder) +class SalesOrderAdmin(admin.ModelAdmin): + list_display = ('id', 'customer', 'sales_date', 'operator', 'status', '_total_amount', '_diff_quantity', '_total_quantity', 'created_at') + search_fields = ('customer__name',) + list_filter = ('operator', 'warehouse', 'status', 'created_at') + ordering = ('-created_at',) + + @admin.display(description='总价') + def _total_amount(self, obj: models.SalesOrder): + return obj.get_total_amount() + + @admin.display(description='差异数量') + def _diff_quantity(self, obj: models.SalesOrder): + return obj.get_total_diff_quantity() + + @admin.display(description='总数量') + def _total_quantity(self, obj: models.SalesOrder): + return obj.get_total_quantity() + + +@admin.register(models.SalesOrderItem) +class SalesOrderItemAdmin(admin.ModelAdmin): + list_display = ( + 'id', 'sales_order', 'product', + 'quantity', 'unit', 'price', 'batch_number', + '_total_amount', 'empty_diff_percent', + '_real_quantity', '_diff_quantity', + 'num_of_rolls', + ) + search_fields = ('sales_order__id', 'product__name') + list_filter = ('sales_order__operator', 'sales_order__warehouse', 'created_at') + ordering = ('-created_at',) + + @admin.display(description='实际数量') + def _real_quantity(self, obj: models.SalesOrderItem): + return obj.real_quantity() + + @admin.display(description='差异数量') + def _diff_quantity(self, obj: models.SalesOrderItem): + return obj.diff_quantity() + + @admin.display(description='总金额') + def _total_amount(self, obj: models.SalesOrderItem): + return obj.total_amount() + + +@admin.register(models.PaymentOrder) +class PaymentOrderAdmin(admin.ModelAdmin): + list_display = ('id', 'supplier', 'payment_date', 'amount', 'operator', 'status', 'created_at') + search_fields = ('supplier__name',) + list_filter = ('operator', 'status', 'created_at') + ordering = ('-created_at',) + + +@admin.register(models.ReceiptOrder) +class ReceiptOrderAdmin(admin.ModelAdmin): + list_display = ('id', 'customer', 'receipt_date', 'amount', 'operator', 'status', 'created_at') + search_fields = ('customer__name',) + list_filter = ('operator', 'status', 'created_at') + ordering = ('-created_at',) + + @admin.register(models.CustomerBalance) class CustomerBalancemAdmin(admin.ModelAdmin): list_display = ('id', 'customer', 'balance', 'created_at') @@ -132,6 +194,13 @@ class CustomerBalancemAdmin(admin.ModelAdmin): ordering = ('-created_at',) +@admin.register(models.SupplierBalance) +class SupplierBalanceAdmin(admin.ModelAdmin): + list_display = ('id', 'supplier', 'balance', 'created_at') + search_fields = ('supplier__name',) + ordering = ('-created_at',) + + @admin.register(models.BalanceChangeRecord) class BalanceChangeRecordAdmin(admin.ModelAdmin): list_display = ('id', 'merchant', 'target_type', 'source_type', 'source_id', 'delta', 'balance_before', 'balance_after', 'direction', 'created_at') diff --git a/business/services.py b/business/services.py index 405a903..795dd9a 100644 --- a/business/services.py +++ b/business/services.py @@ -1246,7 +1246,7 @@ def _build_stock_flow_items_from_order(order) -> List[Dict[str, Any]]: items_payload.append({ 'product_id': item.product_id, 'value': str(item.quantity), - 'num_of_rolls': item.num_of_rolls, + 'num_of_rolls': item.num_of_rolls or 1, }) return items_payload diff --git a/business/tests.py b/business/tests.py index e502284..a3ac606 100644 --- a/business/tests.py +++ b/business/tests.py @@ -276,6 +276,27 @@ class PurchaseOrderServiceTestCase(TestCase): created_by_id=self.user.id, ) + def test_purchase_order_relaxed_mode_even_split(self): + purchase_order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': 99, 'num_of_rolls': 2, 'price': '10.0', 'unit': '米'}], + created_by=self.user, + ) + stock_flow_items = services._build_stock_flow_items_from_order(purchase_order) + payload = services.create_purchase_order_stock_entries_sync( + purchase_order_id=purchase_order.id, + warehouse_id=self.warehouse_relaxed.id, + items=stock_flow_items, + created_by_id=self.user.id, + ) + record = stock_models.StockChangeRecord.objects.get(id=payload['stock_change_record_id']) + quantities = sorted([detail.quantity for detail in record.details.order_by('id')], reverse=True) + self.assertEqual(quantities, [Decimal('49.50'), Decimal('49.50')]) + class SalesOrderServiceTestCase(TestCase): def setUp(self): @@ -633,6 +654,40 @@ class SalesReturnServiceTestCase(TestCase): target_status=business_models.SalesReturnStatusEnum.CANCELLED, ) + def test_sales_return_relaxed_mode_stock_payload_preserves_rolls(self): + order = services.create_sales_return_order( + merchant=self.merchant, + customer=self.customer, + return_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': 100, 'num_of_rolls': 1, 'price': '22.0'}], + ) + stock_flow_items = services._build_stock_flow_items_from_order(order) + self.assertEqual(len(stock_flow_items), 1) + self.assertEqual(stock_flow_items[0]['num_of_rolls'], 1) + + def test_sales_return_relaxed_mode_even_split(self): + order = services.create_sales_return_order( + merchant=self.merchant, + customer=self.customer, + return_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': 100, 'num_of_rolls': 3, 'price': '22.0'}], + created_by=self.user, + ) + stock_flow_items = services._build_stock_flow_items_from_order(order) + payload = services.create_sales_return_order_stock_entries_sync( + sales_return_order_id=order.id, + warehouse_id=self.warehouse_relaxed.id, + items=stock_flow_items, + created_by_id=self.user.id, + ) + record = stock_models.StockChangeRecord.objects.get(id=payload['stock_change_record_id']) + quantities = sorted([detail.quantity for detail in record.details.order_by('id')], reverse=True) + self.assertEqual(quantities, [Decimal('33.34'), Decimal('33.33'), Decimal('33.33')]) + class PaymentReceiptServiceTestCase(TestCase): def setUp(self): ( diff --git a/docs/business_api_reference.md b/docs/business_api_reference.md index fd7b679..2049971 100644 --- a/docs/business_api_reference.md +++ b/docs/business_api_reference.md @@ -31,6 +31,17 @@ ### 2.1 创建请求体 +`items` 数组支持以下字段: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `product_id` | 是 | 产品 ID | +| `price` | 是 | 单价 | +| `unit` | 否 | 单位,默认使用产品单位 | +| `numbers` / `quantity` + `num_of_rolls` | 仓库模式相关 | 严进提供 `numbers`,宽进提供 `quantity`/`num_of_rolls` | +| `empty_diff_percent` | 否 | **空差百分比**,用于计算实际数量与空差(默认 `0`) | +| `color` / `spec` / `batch_number` / `remarks` | 否 | 可选信息 | + ```json { "supplier": 12, @@ -83,6 +94,8 @@ ### 3.1 创建请求体 +销售单 `items` 字段同样支持 `empty_diff_percent`、`color`、`spec`、`batch_number`、`remarks` 等信息,用途与采购单一致;仓库模式决定使用 `numbers`、`quantity + num_of_rolls` 或 `consume_detail_ids`。 + ```json { "customer": 6, @@ -212,7 +225,22 @@ ### 6.2 供应商余额 -目前仅内部使用(审批写入),如需对外查询可在此基础上新增 `/suppliers//balance/`,逻辑与客户一致:采购单审批增加余额、付款单审批减少余额。 +| API | 方法 | 描述 | +|-----|------|------| +| `/suppliers//balance/` | GET | 查询指定供应商待付余额。 | + +响应示例: + +```json +{ + "supplier": 3, + "supplier_name": "桐乡面料商", + "balance": "1850.00" +} +``` + +- 正数表示仍需支付给供应商的金额;负数表示预付或多付。 +- 若供应商无记录返回 `"0"`。 ### 6.3 余额变动记录(BalanceChangeRecord) @@ -225,9 +253,83 @@ - `request_id / extra_meta`:用于幂等和记录审批上下文(操作者、触发渠道等) - **用途**:对账、审计、未来的余额红冲。目前未开放对外查询 API,可在内部管理端或报表服务中直接访问;若后续开放,请提供分页、时间范围与 `source_type` 过滤能力。 +## 7. 对账单(Statements) + +对账单 API 汇总客户/供应商所有 **已审批通过** 的相关业务单据,并提供统一的金额正负视图,字段后续可通过 serializer context 继续扩展统计信息。 + +### 7.1 客户对账单 + +| API | 方法 | 描述 | +|-----|------|------| +| `/customers//statements/` | GET | 返回该客户的销售单、销售退货单、收款单对账记录。 | + +```json +{ + "customer": 6, + "customer_name": "杭州零售商", + "records": [ + { + "source_type": "sales_order", + "source_label": "销售单", + "source_id": 1024, + "occurred_at": "2025-11-30", + "recorded_at": "2025-12-01T03:26:18.815992Z", + "status": 2, + "status_label": "审批通过", + "counterparty": 6, + "counterparty_name": "杭州零售商", + "positive_amount": "3200.00", + "negative_amount": "0.00" + }, + { + "source_type": "receipt_order", + "source_label": "收款单", + "source_id": 2001, + "occurred_at": "2025-12-05", + "recorded_at": "2025-12-05T02:11:07.441982Z", + "status": 2, + "status_label": "审批通过", + "counterparty": 6, + "counterparty_name": "杭州零售商", + "positive_amount": "0.00", + "negative_amount": "1500.00" + } + ], + "summary": { + "positive_total": "3200.00", + "negative_total": "1500.00" + } +} +``` + +- `records` 依照 `occurred_at -> recorded_at -> source_id` 倒序排列。 +- `positive_amount` 始终代表应收增加:销售单为正,其余(销售退货、收款)为负。 +- `summary` 通过 serializer context 生成,如需扩展其他统计字段可在视图中向 context 注入。 + +### 7.2 供应商对账单 + +| API | 方法 | 描述 | +|-----|------|------| +| `/suppliers//statements/` | GET | 返回该供应商的采购单、采购退货单、付款单对账记录。 | + +- 采购单为正向金额,采购退货与付款单为负向金额。 +- 其余字段与客户对账单完全一致。 + +### 7.3 记录字段 + +| 字段 | 说明 | +|------|------| +| `source_type` / `source_label` | 业务来源与可读名称(`sales_order`、`payment_order` 等)。 | +| `source_id` | 原始单据 ID。 | +| `occurred_at` / `recorded_at` | 业务日期(如 `sales_date`)与系统写入时间。 | +| `status` / `status_label` | 当前单据状态。 | +| `counterparty` / `counterparty_name` | 客户或供应商。 | +| `positive_amount` / `negative_amount` | 金额正负值,字符串形式的 `Decimal`。 | +| `extra` | 预留字典字段,后续可承载额外统计信息。 | + --- -## 7. 错误码与常见响应 +## 8. 错误码与常见响应 | 场景 | HTTP | 返回 | |------|------|------| @@ -239,7 +341,7 @@ --- -## 8. 参考文档 +## 9. 参考文档 - `docs/purchase_order_approval_and_red_flush.md`:采购单审批及未来红冲方案。 - `docs/sales_order_approval_and_red_flush.md`:销售单审批与严出模式说明。 diff --git a/docs/20251128-stock.md b/docs/stock_api_overview.md similarity index 89% rename from docs/20251128-stock.md rename to docs/stock_api_overview.md index 359387e..62978df 100644 --- a/docs/20251128-stock.md +++ b/docs/stock_api_overview.md @@ -71,6 +71,7 @@ The system supports various source types for stock changes: - `3`: Transport In (调入) - `4`: Recheck Addition (盘盈) - `5`: Combine (合并) +- `11`: Offset (红冲,反向抵销入库) **Outgoing (出库) Sources**: - `6`: Sales (销售) @@ -78,6 +79,7 @@ The system supports various source types for stock changes: - `8`: Transport Out (调出) - `9`: Recheck Removal (盘亏) - `10`: Explode (拆卷) +- `11`: Offset (红冲,反向抵销出库) ### Warehouse Modes @@ -359,7 +361,62 @@ Warehouses can operate in different modes: } ``` -### 4. Stock Snapshot List +### 4. 库存红冲(Stock Change Offset) + +- **URL**: `POST /api/v1/stock-change//offset/` +- **权限**:登录员工,且仓库必须属于当前商户。 +- **描述**:针对已完成的库存变动记录生成反向库存记录,恢复库存数量并标记原快照 `cancelled=true`。 + +**Request Body** +```json +{ + "reason": "采购单作废,冲销库存", + "request_id": "rcf-20251201-0001", // 可选,幂等键 + "extra_meta": { "source": "purchase_order", "operator": 18 } +} +``` + +> **注意**:当前版本仅支持“全量红冲”,`items` 字段请留空;未来版本会按需要开放部分明细红冲能力。 + +**Response** +```json +{ + "status": "success", + "message": "红冲记录已创建", + "stock_change_record": { + "id": 602, + "type": 2, + "source_type": 11, + "source_id": 498, + "warehouse": 3, + "is_finished": true + }, + "details": [ + { + "id": 1880, + "product": 15, + "quantity": "100.00", + "unit": 1 + } + ], + "created_details_count": 1 +} +``` + +**错误返回** + +| HTTP | 状态 | 说明 | +|------|------|------| +| 400 | `{"error": "仅允许对已完成的库存变动执行红冲"}` | 原记录未完成或仍在处理中 | +| 400 | `{"error": "该库存变动记录已执行红冲"}` | 阻止重复红冲 | +| 403 | `{"error": "无权访问该库存变动记录"}` | 仓库不属于当前商户 | +| 404 | `{"error": "库存变动记录ID xxx 不存在"}` | 记录不存在 | + +红冲成功后,会生成 `source_type=OFFSET` 的库存记录,`source_id` 指向原库存记录 ID;原 `StockSnapshot` 会设置 `cancelled=true`、`offset_id=<新快照ID>`,便于审计追踪。 + +--- + +### 5. Stock Snapshot List - **URL**: `GET /api/v1/stock-snapshots/` - **Description**: Read-only list of `StockSnapshot` entries. Supports `limit/offset` pagination,搜索与过滤。 diff --git a/stock/models.py b/stock/models.py index 46f05dc..548c635 100644 --- a/stock/models.py +++ b/stock/models.py @@ -29,6 +29,7 @@ class StockChangeSourceEnum(models.IntegerChoices): TRANSPORT_OUT = 8, '调出' RECHECK_REMOVE = 9, '盘亏' EXPLODE = 10, '拆卷' + OFFSET = 11, '红冲' @classmethod def incoming_values(cls) -> set[int]: @@ -39,6 +40,7 @@ class StockChangeSourceEnum(models.IntegerChoices): cls.TRANSPORT_IN, cls.RECHECK_ADD, cls.COMBINE, + cls.OFFSET, } @classmethod @@ -50,6 +52,7 @@ class StockChangeSourceEnum(models.IntegerChoices): cls.TRANSPORT_OUT, cls.RECHECK_REMOVE, cls.EXPLODE, + cls.OFFSET, } diff --git a/stock/services.py b/stock/services.py index 7fa8451..34dc216 100644 --- a/stock/services.py +++ b/stock/services.py @@ -4,7 +4,7 @@ from django.db.models import Sum from django.utils import timezone from sse.services import push_simple_message_with_object_id from basic_info import models as basic_models -from decimal import Decimal, InvalidOperation +from decimal import Decimal, InvalidOperation, ROUND_DOWN import logging from typing import List, Dict, Any, Tuple @@ -224,6 +224,17 @@ def _to_decimal(value, field_name: str) -> Decimal: raise ValueError(f'{field_name} 必须是合法的数值') +def _to_positive_int(value, field_name: str) -> int: + if value is None: + raise ValueError(f'{field_name} 不能为空') + decimal_value = _to_decimal(value, field_name) + if decimal_value <= 0: + raise ValueError(f'{field_name} 必须大于 0') + if decimal_value != decimal_value.to_integral_value(): + raise ValueError(f'{field_name} 必须为整数') + return int(decimal_value) + + def _split_quantities(total: Decimal, unit_size: Decimal) -> List[Decimal]: if total <= 0: raise ValueError('quantity.value 必须大于 0') @@ -244,6 +255,31 @@ def _split_quantities(total: Decimal, unit_size: Decimal) -> List[Decimal]: return quantities +def _split_quantities_evenly(total: Decimal, rolls: int) -> List[Decimal]: + if total <= 0: + raise ValueError('quantity.value 必须大于 0') + if rolls <= 0: + raise ValueError('quantity.num_of_rolls 必须大于 0') + + quantum = Decimal('0.01') + base = (total / Decimal(rolls)).quantize(quantum, rounding=ROUND_DOWN) + if base == 0 and total < quantum: + return [total] + + quantities: List[Decimal] = [base for _ in range(rolls)] + distributed = base * rolls + remainder = (total - distributed).quantize(quantum) + + idx = 0 + while remainder > 0 and quantities: + increment = min(quantum, remainder) + quantities[idx] += increment + remainder -= increment + idx = (idx + 1) % rolls + + return [qty for qty in quantities if qty > 0] + + def create_stock_change_record_relaxed( *, merchant: basic_models.Merchant, @@ -294,12 +330,10 @@ def create_stock_change_record_relaxed( if not product_id: raise ValueError('产品ID不能为空') - total_value = quantity_data.get('value') + total_value = _to_decimal(quantity_data.get('value'), 'quantity.value') + roll_count = quantity_data.get('num_of_rolls') unit_size = quantity_data.get('unit_count', 1) - total_value = _to_decimal(total_value, 'quantity.value') - unit_size = _to_decimal(unit_size, 'quantity.unit_count') - try: product = basic_models.Product.objects.get(id=product_id) except basic_models.Product.DoesNotExist: @@ -308,7 +342,12 @@ def create_stock_change_record_relaxed( if product.merchant_id != merchant.id: raise ValueError(f'产品ID {product_id} 不属于当前商户') - quantities = _split_quantities(total_value, unit_size) + if roll_count is not None: + roll_count = _to_positive_int(roll_count, 'quantity.num_of_rolls') + quantities = _split_quantities_evenly(total_value, roll_count) + else: + unit_size = _to_decimal(unit_size, 'quantity.unit_count') + quantities = _split_quantities(total_value, unit_size) for quantity in quantities: detail = models.StockChangeDetail.objects.create( @@ -403,7 +442,137 @@ class StockFlowService: Returns: 新生成的反向 `StockChangeRecord`、其明细列表以及明细数量。 """ - raise NotImplementedError('库存红冲功能尚未实现') + if not reason or not reason.strip(): + raise ValueError('reason 不能为空') + if items: + raise ValueError('当前版本暂不支持部分红冲,请省略 items 参数') + + offset_record, created_details = self._perform_stock_offset( + source_record_id=source_record_id, + reason=reason.strip(), + request_id=request_id, + extra_meta=extra_meta or {}, + ) + return offset_record, created_details, len(created_details) + + def _perform_stock_offset( + self, + *, + source_record_id: int, + reason: str, + request_id: str | None, + extra_meta: Dict[str, Any], + ) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail]]: + with transaction.atomic(): + source_record = ( + models.StockChangeRecord.objects.select_related('warehouse', 'merchant') + .prefetch_related('details__product', 'details__consume_with') + .select_for_update() + .get(id=source_record_id) + ) + + if source_record.merchant_id != self.merchant.id: + raise ValueError('库存变动记录不属于当前商户') + if not source_record.is_finished: + raise ValueError('仅允许对已完成的库存变动执行红冲') + + if models.StockSnapshot.objects.filter( + stock_change_record=source_record, + offset_id__isnull=False, + ).exists(): + raise ValueError('该库存变动记录已执行红冲') + + source_details = list(source_record.details.all().order_by('id')) + if not source_details: + raise ValueError('库存变动记录缺少明细,无法红冲') + + reverse_type = ( + models.StockChangeTypeEnum.ADD + if source_record.is_outgoing + else models.StockChangeTypeEnum.REMOVE + ) + + remarks = f'红冲原记录 {source_record.id}: {reason}' + if request_id: + remarks = f'[{request_id}] {remarks}' + + offset_record = models.StockChangeRecord.objects.create( + merchant=self.merchant, + type=reverse_type, + warehouse=source_record.warehouse, + source_type=models.StockChangeSourceEnum.OFFSET, + source_id=source_record.id, + created_by=self.created_by, + remarks=remarks[:500], + ) + + created_details: List[models.StockChangeDetail] = [] + for detail in source_details: + new_detail = models.StockChangeDetail.objects.create( + merchant=self.merchant, + stock_change_record=offset_record, + product=detail.product, + quantity=detail.quantity, + unit=detail.unit, + ) + created_details.append(new_detail) + + if source_record.is_outgoing: + consume_ids = [detail.consume_with_id for detail in source_details if detail.consume_with_id] + if consume_ids: + models.StockChangeDetail.objects.filter(id__in=consume_ids).update(is_consumed=False) + + if not make_stock_change_completed(offset_record): + raise ValueError('红冲库存记录创建失败') + + self._link_offset_snapshots( + source_record=source_record, + offset_record=offset_record, + ) + + logger.info('库存变动记录 %s 已红冲,生成记录 %s', source_record_id, offset_record.id) + return offset_record, created_details + + @staticmethod + def _link_offset_snapshots( + *, + source_record: models.StockChangeRecord, + offset_record: models.StockChangeRecord, + ): + original_snapshots = list( + models.StockSnapshot.objects.filter(stock_change_record=source_record).order_by('id') + ) + if not original_snapshots: + return + + new_snapshots = list( + models.StockSnapshot.objects.filter(stock_change_record=offset_record).order_by('id') + ) + if not new_snapshots: + raise ValueError('红冲记录未生成任何库存快照') + + now = timezone.now() + pair_count = min(len(original_snapshots), len(new_snapshots)) + + for index in range(pair_count): + original_snapshot = original_snapshots[index] + offset_snapshot = new_snapshots[index] + + original_snapshot.cancelled = True + original_snapshot.cancelled_at = now + original_snapshot.offset_id = offset_snapshot.id + original_snapshot.save(update_fields=['cancelled', 'cancelled_at', 'offset_id']) + + offset_snapshot.offset_to = original_snapshot.id + offset_snapshot.offset_at = now + offset_snapshot.save(update_fields=['offset_to', 'offset_at']) + + if len(original_snapshots) != len(new_snapshots): + logger.warning( + '红冲快照数量不一致:原始 %s 条,新快照 %s 条', + len(original_snapshots), + len(new_snapshots), + ) def _get_and_validate_warehouse(self, warehouse_id: int) -> basic_models.WareHouse: if not warehouse_id: @@ -478,16 +647,20 @@ class StockFlowService: for item in items: product_id = item.get('product_id') total_value = item.get('value') - unit_size = item.get('num_of_rolls', 1) + roll_count = item.get('num_of_rolls') + unit_size = item.get('unit_size') or item.get('unit_count') if not product_id or total_value is None: raise ValueError('宽进宽出需要提供 product_id 与 value') - payload.append({ - 'product': product_id, - 'quantity': { - 'value': total_value, - 'unit_count': unit_size, - } - }) + + quantity_payload: Dict[str, Any] = {'value': total_value} + if roll_count is not None: + quantity_payload['num_of_rolls'] = roll_count + elif unit_size is not None: + quantity_payload['unit_count'] = unit_size + else: + quantity_payload['unit_count'] = 1 + + payload.append({'product': product_id, 'quantity': quantity_payload}) return payload @staticmethod diff --git a/stock/tests.py b/stock/tests.py index a1da2cb..f2cbfe2 100644 --- a/stock/tests.py +++ b/stock/tests.py @@ -328,35 +328,128 @@ class MakeStockChangeCompletedTestCase(StockServicesTestCase): product_id=self.product_fabric_a.id, warehouse_id=self.warehouse_main.id ) - + def test_complete_inbound_record_existing_inventory(self): """测试完成入库记录(更新现有库存)""" - # 创建现有库存 existing_inventory = models.Inventory.objects.create( merchant=self.merchant, product=self.product_fabric_a, warehouse=self.warehouse_main, quantity=50, - num_of_rolls=5 + num_of_rolls=5, ) - + with patch('stock.services.create_stock_snapshot') as mock_snapshot: mock_snapshot.return_value = MagicMock() - result = services.make_stock_change_completed(self.stock_change_in) - + self.assertTrue(result) - - # 检查库存更新 existing_inventory.refresh_from_db() - self.assertEqual(existing_inventory.quantity, 150) # 50 + 100 - self.assertEqual(existing_inventory.num_of_rolls, 6) # 5 + 1 - - # 验证记录状态 + self.assertEqual(existing_inventory.quantity, 150) + self.assertEqual(existing_inventory.num_of_rolls, 6) + self.stock_change_in.refresh_from_db() self.assertTrue(self.stock_change_in.is_finished) +class StockOffsetServiceTestCase(TestCase): + """测试库存红冲逻辑""" + + def setUp(self): + self.merchant = Merchant.objects.create( + name='红冲商户', + type=MerchantTypeEnum.STORE, + ) + self.warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='红冲仓', + mode=WareHouseModeEnum.RESTRICT_IN, + ) + category = ProductCategory.objects.create( + merchant=self.merchant, + name='红冲品类', + product_prefix='RC', + ) + self.product = Product.objects.create( + merchant=self.merchant, + category=category, + name='红冲产品', + human_id='RC-001', + unit=ProductUnitEnum.METER, + ) + self.stock_service = services.StockFlowService(merchant=self.merchant, created_by=None) + + self.source_record, _, _ = services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=None, + type=models.StockChangeTypeEnum.ADD, + warehouse_id=self.warehouse.id, + source_type=models.StockChangeSourceEnum.PURCHASE, + source_id=101, + products=[{'product': self.product.id, 'quantity': [Decimal('10')]}], + ) + services.make_stock_change_completed(self.source_record) + + def test_offset_stock_change_creates_reverse_record(self): + offset_record, details, detail_count = self.stock_service.offset_stock_change( + source_record_id=self.source_record.id, + reason='测试红冲', + ) + self.assertEqual(offset_record.type, models.StockChangeTypeEnum.REMOVE) + self.assertEqual(offset_record.source_type, models.StockChangeSourceEnum.OFFSET) + self.assertEqual(detail_count, 1) + self.assertEqual(len(details), 1) + + inventory = models.Inventory.objects.get(product=self.product, warehouse=self.warehouse) + self.assertEqual(inventory.quantity, 0) + + self.assertTrue( + models.StockSnapshot.objects.filter( + stock_change_record=self.source_record, + cancelled=True, + offset_id__isnull=False, + ).exists() + ) + + def test_offset_cannot_repeat(self): + self.stock_service.offset_stock_change( + source_record_id=self.source_record.id, + reason='第一次红冲', + ) + with self.assertRaises(ValueError): + self.stock_service.offset_stock_change( + source_record_id=self.source_record.id, + reason='再次红冲', + ) + + def test_offset_requires_finished_record(self): + record, _, _ = services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=None, + type=models.StockChangeTypeEnum.ADD, + warehouse_id=self.warehouse.id, + source_type=models.StockChangeSourceEnum.PURCHASE, + source_id=202, + products=[{'product': self.product.id, 'quantity': [Decimal('8')]}], + ) + with self.assertRaises(ValueError): + self.stock_service.offset_stock_change( + source_record_id=record.id, + reason='未完成记录红冲', + ) + + def test_offset_checks_merchant(self): + other_merchant = Merchant.objects.create( + name='外部商户', + type=MerchantTypeEnum.STORE, + ) + other_service = services.StockFlowService(merchant=other_merchant, created_by=None) + with self.assertRaises(ValueError): + other_service.offset_stock_change( + source_record_id=self.source_record.id, + reason='跨商户红冲', + ) + class CreateStockChangeRecordWithDetailsTestCase(StockServicesTestCase): """测试 create_stock_change_record_with_details 服务""" @@ -893,8 +986,8 @@ class StockFlowServiceTestCase(TestCase): source_id=11, items=[{'product_id': self.product.id, 'value': '9', 'num_of_rolls': 4}], ) - self.assertEqual(count, 3) # 4 + 4 + 1 - self.assertEqual(len(details), 3) + self.assertEqual(count, 4) + self.assertEqual(len(details), 4) def test_stock_out_restrict_mode_consumes_details(self): _, inbound_details, _ = self.service.stock_in(