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

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.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/<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('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('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(
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)

View File

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

View File

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

View File

@@ -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):
(

View File

@@ -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/<id>/balance/`,逻辑与客户一致:采购单审批增加余额、付款单审批减少余额。
| API | 方法 | 描述 |
|-----|------|------|
| `/suppliers/<id>/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/<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 | 返回 |
|------|------|------|
@@ -239,7 +341,7 @@
---
## 8. 参考文档
## 9. 参考文档
- `docs/purchase_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 (调入)
- `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/<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/`
- **Description**: Read-only list of `StockSnapshot` entries. Supports `limit/offset` pagination,搜索与过滤。

View File

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

View File

@@ -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,6 +342,11 @@ def create_stock_change_record_relaxed(
if product.merchant_id != merchant.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)
for quantity in quantities:
@@ -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

View File

@@ -331,32 +331,125 @@ class MakeStockChangeCompletedTestCase(StockServicesTestCase):
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(