1
0
forked from erp-dev/erp

feat: statement for supplier and customer

This commit is contained in:
2025-12-01 22:55:52 +08:00
parent fae33809ac
commit d522c0a9b0
15 changed files with 1085 additions and 52 deletions

View File

@@ -3,6 +3,7 @@ import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from decimal import Decimal
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.contrib.auth.models import User, Permission 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 business import models as business_models, services
from stock import models as stock_models from stock import models as stock_models
from stock import services as stock_services
from api_v1 import tasks from api_v1 import tasks
@@ -689,6 +691,81 @@ class SalesReturnOrderAPITestCase(TestCase):
self.assertEqual(order.status, business_models.SalesReturnStatusEnum.PENDING) 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): class PaymentOrderAPITestCase(TestCase):
def setUp(self): def setUp(self):
self.merchant = Merchant.objects.create(name='付款商户', type=MerchantTypeEnum.FACTORY) 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/') response = self.client.get(f'/api/v1/customers/{self.customer.id}/balance/')
self.assertEqual(response.data['balance'], '30.00') 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( @override_settings(
CELERY_TASK_ALWAYS_EAGER=True, CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPAGATES=True, CELERY_TASK_EAGER_PROPAGATES=True,

View File

@@ -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.balance import views as balance_views
from .views.business.purchase_return import views as purchase_return_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.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.stock_change_views.snapshot import StockSnapshotListView
from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet
from .views.upload import UploadFileViewSet from .views.upload import UploadFileViewSet
@@ -77,6 +78,9 @@ urlpatterns = [
path('receipt-orders/', receipt_views.ReceiptOrderView.as_view(), name='receipt_orders'), path('receipt-orders/', receipt_views.ReceiptOrderView.as_view(), name='receipt_orders'),
path('receipt-orders/<int:pk>/review/', receipt_views.ReceiptOrderReviewView.as_view(), name='receipt_order_review'), path('receipt-orders/<int:pk>/review/', receipt_views.ReceiptOrderReviewView.as_view(), name='receipt_order_review'),
path('customers/<int:customer_id>/balance/', balance_views.CustomerBalanceView.as_view(), name='customer_balance'), path('customers/<int:customer_id>/balance/', balance_views.CustomerBalanceView.as_view(), name='customer_balance'),
path('suppliers/<int:supplier_id>/balance/', balance_views.SupplierBalanceView.as_view(), name='supplier_balance'),
path('customers/<int:customer_id>/statements/', statement_views.CustomerStatementView.as_view(), name='customer_statements'),
path('suppliers/<int:supplier_id>/statements/', statement_views.SupplierStatementView.as_view(), name='supplier_statements'),
path('health/', healthy.HealthCheckView.as_view(), name='health_check'), path('health/', healthy.HealthCheckView.as_view(), name='health_check'),
path('print-count/delta/', print_count.adjust_print_count, name='print_count_delta'), path('print-count/delta/', print_count.adjust_print_count, name='print_count_delta'),

View File

@@ -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),
}
)

View File

@@ -0,0 +1,7 @@
"""
对账单statement相关的 API 视图。
该子包目前提供客户/供应商的对账单查询接口。
"""

View File

@@ -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

View File

@@ -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

View File

@@ -32,20 +32,14 @@ class OffsetStockChangeView(StockChangeViewMixin, views.APIView):
@extend_schema( @extend_schema(
tags=['库存红冲'], tags=['库存红冲'],
summary='发起库存红冲(占位)', summary='发起库存红冲',
description=( description='对指定库存变动记录创建反向库存记录,并标记原库存快照已被红冲。',
'对指定库存变动记录创建红冲请求。当前仅提供占位实现,'
'会返回 501 Not Implemented待库存红冲逻辑完善后启用。'
),
request=StockChangeOffsetSerializer, request=StockChangeOffsetSerializer,
responses={ responses={
201: OpenApiResponse( 201: OpenApiResponse(description='红冲成功'),
description='红冲成功(未来实现后返回)'
),
400: OpenApiResponse(description='参数或业务校验失败'), 400: OpenApiResponse(description='参数或业务校验失败'),
403: OpenApiResponse(description='无权访问对应出入库记录'), 403: OpenApiResponse(description='无权访问对应出入库记录'),
404: OpenApiResponse(description='库存变动记录不存在'), 404: OpenApiResponse(description='库存变动记录不存在'),
501: OpenApiResponse(description='功能占位,尚未实现'),
}, },
) )
def post(self, request, record_id: int): def post(self, request, record_id: int):
@@ -81,15 +75,6 @@ class OffsetStockChangeView(StockChangeViewMixin, views.APIView):
request_id=payload.get('request_id'), request_id=payload.get('request_id'),
extra_meta=payload.get('extra_meta'), 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: except ValueError as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -125,6 +125,68 @@ class SalesReturnOrderItemAdmin(admin.ModelAdmin):
return obj.total_amount() 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) @admin.register(models.CustomerBalance)
class CustomerBalancemAdmin(admin.ModelAdmin): class CustomerBalancemAdmin(admin.ModelAdmin):
list_display = ('id', 'customer', 'balance', 'created_at') list_display = ('id', 'customer', 'balance', 'created_at')
@@ -132,6 +194,13 @@ class CustomerBalancemAdmin(admin.ModelAdmin):
ordering = ('-created_at',) 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) @admin.register(models.BalanceChangeRecord)
class BalanceChangeRecordAdmin(admin.ModelAdmin): class BalanceChangeRecordAdmin(admin.ModelAdmin):
list_display = ('id', 'merchant', 'target_type', 'source_type', 'source_id', 'delta', 'balance_before', 'balance_after', 'direction', 'created_at') list_display = ('id', 'merchant', 'target_type', 'source_type', 'source_id', 'delta', 'balance_before', 'balance_after', 'direction', 'created_at')

View File

@@ -1246,7 +1246,7 @@ def _build_stock_flow_items_from_order(order) -> List[Dict[str, Any]]:
items_payload.append({ items_payload.append({
'product_id': item.product_id, 'product_id': item.product_id,
'value': str(item.quantity), 'value': str(item.quantity),
'num_of_rolls': item.num_of_rolls, 'num_of_rolls': item.num_of_rolls or 1,
}) })
return items_payload return items_payload

View File

@@ -276,6 +276,27 @@ class PurchaseOrderServiceTestCase(TestCase):
created_by_id=self.user.id, 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): class SalesOrderServiceTestCase(TestCase):
def setUp(self): def setUp(self):
@@ -633,6 +654,40 @@ class SalesReturnServiceTestCase(TestCase):
target_status=business_models.SalesReturnStatusEnum.CANCELLED, 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): class PaymentReceiptServiceTestCase(TestCase):
def setUp(self): def setUp(self):
( (

View File

@@ -31,6 +31,17 @@
### 2.1 创建请求体 ### 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 ```json
{ {
"supplier": 12, "supplier": 12,
@@ -83,6 +94,8 @@
### 3.1 创建请求体 ### 3.1 创建请求体
销售单 `items` 字段同样支持 `empty_diff_percent``color``spec``batch_number``remarks` 等信息,用途与采购单一致;仓库模式决定使用 `numbers``quantity + num_of_rolls``consume_detail_ids`
```json ```json
{ {
"customer": 6, "customer": 6,
@@ -212,7 +225,22 @@
### 6.2 供应商余额 ### 6.2 供应商余额
目前仅内部使用(审批写入),如需对外查询可在此基础上新增 `/suppliers/<id>/balance/`,逻辑与客户一致:采购单审批增加余额、付款单审批减少余额。 | API | 方法 | 描述 |
|-----|------|------|
| `/suppliers/<id>/balance/` | GET | 查询指定供应商待付余额。 |
响应示例:
```json
{
"supplier": 3,
"supplier_name": "桐乡面料商",
"balance": "1850.00"
}
```
- 正数表示仍需支付给供应商的金额;负数表示预付或多付。
- 若供应商无记录返回 `"0"`
### 6.3 余额变动记录BalanceChangeRecord ### 6.3 余额变动记录BalanceChangeRecord
@@ -225,9 +253,83 @@
- `request_id / extra_meta`:用于幂等和记录审批上下文(操作者、触发渠道等) - `request_id / extra_meta`:用于幂等和记录审批上下文(操作者、触发渠道等)
- **用途**:对账、审计、未来的余额红冲。目前未开放对外查询 API可在内部管理端或报表服务中直接访问若后续开放请提供分页、时间范围与 `source_type` 过滤能力。 - **用途**:对账、审计、未来的余额红冲。目前未开放对外查询 API可在内部管理端或报表服务中直接访问若后续开放请提供分页、时间范围与 `source_type` 过滤能力。
## 7. 对账单Statements
对账单 API 汇总客户/供应商所有 **已审批通过** 的相关业务单据,并提供统一的金额正负视图,字段后续可通过 serializer context 继续扩展统计信息。
### 7.1 客户对账单
| API | 方法 | 描述 |
|-----|------|------|
| `/customers/<id>/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/<id>/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 | 返回 | | 场景 | HTTP | 返回 |
|------|------|------| |------|------|------|
@@ -239,7 +341,7 @@
--- ---
## 8. 参考文档 ## 9. 参考文档
- `docs/purchase_order_approval_and_red_flush.md`:采购单审批及未来红冲方案。 - `docs/purchase_order_approval_and_red_flush.md`:采购单审批及未来红冲方案。
- `docs/sales_order_approval_and_red_flush.md`:销售单审批与严出模式说明。 - `docs/sales_order_approval_and_red_flush.md`:销售单审批与严出模式说明。

View File

@@ -71,6 +71,7 @@ The system supports various source types for stock changes:
- `3`: Transport In (调入) - `3`: Transport In (调入)
- `4`: Recheck Addition (盘盈) - `4`: Recheck Addition (盘盈)
- `5`: Combine (合并) - `5`: Combine (合并)
- `11`: Offset (红冲,反向抵销入库)
**Outgoing (出库) Sources**: **Outgoing (出库) Sources**:
- `6`: Sales (销售) - `6`: Sales (销售)
@@ -78,6 +79,7 @@ The system supports various source types for stock changes:
- `8`: Transport Out (调出) - `8`: Transport Out (调出)
- `9`: Recheck Removal (盘亏) - `9`: Recheck Removal (盘亏)
- `10`: Explode (拆卷) - `10`: Explode (拆卷)
- `11`: Offset (红冲,反向抵销出库)
### Warehouse Modes ### 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/<id>/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/` - **URL**: `GET /api/v1/stock-snapshots/`
- **Description**: Read-only list of `StockSnapshot` entries. Supports `limit/offset` pagination,搜索与过滤。 - **Description**: Read-only list of `StockSnapshot` entries. Supports `limit/offset` pagination,搜索与过滤。

View File

@@ -29,6 +29,7 @@ class StockChangeSourceEnum(models.IntegerChoices):
TRANSPORT_OUT = 8, '调出' TRANSPORT_OUT = 8, '调出'
RECHECK_REMOVE = 9, '盘亏' RECHECK_REMOVE = 9, '盘亏'
EXPLODE = 10, '拆卷' EXPLODE = 10, '拆卷'
OFFSET = 11, '红冲'
@classmethod @classmethod
def incoming_values(cls) -> set[int]: def incoming_values(cls) -> set[int]:
@@ -39,6 +40,7 @@ class StockChangeSourceEnum(models.IntegerChoices):
cls.TRANSPORT_IN, cls.TRANSPORT_IN,
cls.RECHECK_ADD, cls.RECHECK_ADD,
cls.COMBINE, cls.COMBINE,
cls.OFFSET,
} }
@classmethod @classmethod
@@ -50,6 +52,7 @@ class StockChangeSourceEnum(models.IntegerChoices):
cls.TRANSPORT_OUT, cls.TRANSPORT_OUT,
cls.RECHECK_REMOVE, cls.RECHECK_REMOVE,
cls.EXPLODE, cls.EXPLODE,
cls.OFFSET,
} }

View File

@@ -4,7 +4,7 @@ from django.db.models import Sum
from django.utils import timezone from django.utils import timezone
from sse.services import push_simple_message_with_object_id from sse.services import push_simple_message_with_object_id
from basic_info import models as basic_models from basic_info import models as basic_models
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation, ROUND_DOWN
import logging import logging
from typing import List, Dict, Any, Tuple from typing import List, Dict, Any, Tuple
@@ -224,6 +224,17 @@ def _to_decimal(value, field_name: str) -> Decimal:
raise ValueError(f'{field_name} 必须是合法的数值') 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]: def _split_quantities(total: Decimal, unit_size: Decimal) -> List[Decimal]:
if total <= 0: if total <= 0:
raise ValueError('quantity.value 必须大于 0') raise ValueError('quantity.value 必须大于 0')
@@ -244,6 +255,31 @@ def _split_quantities(total: Decimal, unit_size: Decimal) -> List[Decimal]:
return quantities 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( def create_stock_change_record_relaxed(
*, *,
merchant: basic_models.Merchant, merchant: basic_models.Merchant,
@@ -294,12 +330,10 @@ def create_stock_change_record_relaxed(
if not product_id: if not product_id:
raise ValueError('产品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) 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: try:
product = basic_models.Product.objects.get(id=product_id) product = basic_models.Product.objects.get(id=product_id)
except basic_models.Product.DoesNotExist: except basic_models.Product.DoesNotExist:
@@ -308,6 +342,11 @@ def create_stock_change_record_relaxed(
if product.merchant_id != merchant.id: if product.merchant_id != merchant.id:
raise ValueError(f'产品ID {product_id} 不属于当前商户') raise ValueError(f'产品ID {product_id} 不属于当前商户')
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) quantities = _split_quantities(total_value, unit_size)
for quantity in quantities: for quantity in quantities:
@@ -403,7 +442,137 @@ class StockFlowService:
Returns: Returns:
新生成的反向 `StockChangeRecord`、其明细列表以及明细数量。 新生成的反向 `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: def _get_and_validate_warehouse(self, warehouse_id: int) -> basic_models.WareHouse:
if not warehouse_id: if not warehouse_id:
@@ -478,16 +647,20 @@ class StockFlowService:
for item in items: for item in items:
product_id = item.get('product_id') product_id = item.get('product_id')
total_value = item.get('value') 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: if not product_id or total_value is None:
raise ValueError('宽进宽出需要提供 product_id 与 value') raise ValueError('宽进宽出需要提供 product_id 与 value')
payload.append({
'product': product_id, quantity_payload: Dict[str, Any] = {'value': total_value}
'quantity': { if roll_count is not None:
'value': total_value, quantity_payload['num_of_rolls'] = roll_count
'unit_count': unit_size, 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 return payload
@staticmethod @staticmethod

View File

@@ -331,32 +331,125 @@ class MakeStockChangeCompletedTestCase(StockServicesTestCase):
def test_complete_inbound_record_existing_inventory(self): def test_complete_inbound_record_existing_inventory(self):
"""测试完成入库记录(更新现有库存)""" """测试完成入库记录(更新现有库存)"""
# 创建现有库存
existing_inventory = models.Inventory.objects.create( existing_inventory = models.Inventory.objects.create(
merchant=self.merchant, merchant=self.merchant,
product=self.product_fabric_a, product=self.product_fabric_a,
warehouse=self.warehouse_main, warehouse=self.warehouse_main,
quantity=50, quantity=50,
num_of_rolls=5 num_of_rolls=5,
) )
with patch('stock.services.create_stock_snapshot') as mock_snapshot: with patch('stock.services.create_stock_snapshot') as mock_snapshot:
mock_snapshot.return_value = MagicMock() mock_snapshot.return_value = MagicMock()
result = services.make_stock_change_completed(self.stock_change_in) result = services.make_stock_change_completed(self.stock_change_in)
self.assertTrue(result) self.assertTrue(result)
# 检查库存更新
existing_inventory.refresh_from_db() existing_inventory.refresh_from_db()
self.assertEqual(existing_inventory.quantity, 150) # 50 + 100 self.assertEqual(existing_inventory.quantity, 150)
self.assertEqual(existing_inventory.num_of_rolls, 6) # 5 + 1 self.assertEqual(existing_inventory.num_of_rolls, 6)
# 验证记录状态
self.stock_change_in.refresh_from_db() self.stock_change_in.refresh_from_db()
self.assertTrue(self.stock_change_in.is_finished) 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): class CreateStockChangeRecordWithDetailsTestCase(StockServicesTestCase):
"""测试 create_stock_change_record_with_details 服务""" """测试 create_stock_change_record_with_details 服务"""
@@ -893,8 +986,8 @@ class StockFlowServiceTestCase(TestCase):
source_id=11, source_id=11,
items=[{'product_id': self.product.id, 'value': '9', 'num_of_rolls': 4}], items=[{'product_id': self.product.id, 'value': '9', 'num_of_rolls': 4}],
) )
self.assertEqual(count, 3) # 4 + 4 + 1 self.assertEqual(count, 4)
self.assertEqual(len(details), 3) self.assertEqual(len(details), 4)
def test_stock_out_restrict_mode_consumes_details(self): def test_stock_out_restrict_mode_consumes_details(self):
_, inbound_details, _ = self.service.stock_in( _, inbound_details, _ = self.service.stock_in(