forked from erp-dev/erp
feat: statement for supplier and customer
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
7
api_v1/views/business/statements/__init__.py
Normal file
7
api_v1/views/business/statements/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
对账单(statement)相关的 API 视图。
|
||||
|
||||
该子包目前提供客户/供应商的对账单查询接口。
|
||||
"""
|
||||
|
||||
|
||||
37
api_v1/views/business/statements/serializers.py
Normal file
37
api_v1/views/business/statements/serializers.py
Normal 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
|
||||
|
||||
|
||||
325
api_v1/views/business/statements/views.py
Normal file
325
api_v1/views/business/statements/views.py
Normal 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
(
|
||||
|
||||
@@ -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`:销售单审批与严出模式说明。
|
||||
|
||||
@@ -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,搜索与过滤。
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
121
stock/tests.py
121
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(
|
||||
|
||||
Reference in New Issue
Block a user