1
0
forked from erp-dev/erp

feat: balance api

This commit is contained in:
2025-11-30 23:04:21 +08:00
parent 9006a530d1
commit 9acc4e14fc
21 changed files with 714 additions and 64 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -22,7 +22,7 @@ from basic_info.models import (
WareHouse, WareHouse,
WareHouseModeEnum, WareHouseModeEnum,
) )
from business import models as business_models from business import models as business_models, services
from stock import models as stock_models from stock import models as stock_models
from api_v1 import tasks from api_v1 import tasks
@@ -494,8 +494,7 @@ class PaymentOrderAPITestCase(TestCase):
{'action': 'cancel'}, {'action': 'cancel'},
format='json', format='json',
) )
self.assertEqual(response_cancel.status_code, status.HTTP_200_OK) self.assertEqual(response_cancel.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response_cancel.data['status'], business_models.PaymentOrderStatusEnum.CANCELLED)
def test_payment_order_requires_amount(self): def test_payment_order_requires_amount(self):
payload = {**self.payload} payload = {**self.payload}
@@ -569,6 +568,81 @@ class ReceiptOrderAPITestCase(TestCase):
response = self.client.post('/api/v1/receipt-orders/', payload, format='json') response = self.client.post('/api/v1/receipt-orders/', payload, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('amount 必须大于 0', response.data['error']) self.assertIn('amount 必须大于 0', response.data['error'])
class CustomerBalanceAPITestCase(TestCase):
def setUp(self):
self.merchant = Merchant.objects.create(name='余额商户', type=MerchantTypeEnum.FACTORY)
self.customer = Customer.objects.create(
merchant=self.merchant,
name='余额客户',
mobile='13800000000',
created_by=None,
)
self.user = User.objects.create_user(username='balance_user', password='pass123')
self.employee = Employee.objects.create(
merchant=self.merchant,
sys_user=self.user,
name='财务查询',
status=EmployeeStatusEnum.ACTIVE,
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
def test_customer_balance_defaults_to_zero(self):
response = self.client.get(f'/api/v1/customers/{self.customer.id}/balance/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['balance'], '0')
def test_customer_balance_reflects_sales_and_receipts(self):
# 创建销售单
product_category = ProductCategory.objects.create(
merchant=self.merchant,
name='余额品类',
product_prefix='BAL',
)
product = Product.objects.create(
merchant=self.merchant,
category=product_category,
name='余额产品',
human_id='BAL-001',
unit=ProductUnitEnum.METER,
)
warehouse = WareHouse.objects.create(
merchant=self.merchant,
name='余额仓',
mode=WareHouseModeEnum.RESTRICT_IN,
)
order = services.create_sales_order(
merchant=self.merchant,
customer=self.customer,
order_date='2025-11-26',
warehouse=warehouse,
operator=self.employee,
items=[{'product_id': product.id, 'numbers': [5], 'price': '10', 'unit': ''}],
)
services.review_sales_order(
sales_order=order,
target_status=business_models.SalesOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
response = self.client.get(f'/api/v1/customers/{self.customer.id}/balance/')
self.assertEqual(response.data['balance'], '50.00')
receipt = services.create_receipt_order(
merchant=self.merchant,
customer=self.customer,
receipt_date='2025-11-27',
amount='20',
operator=self.employee,
)
services.review_receipt_order(
receipt_order=receipt,
target_status=business_models.ReceiptOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
response = self.client.get(f'/api/v1/customers/{self.customer.id}/balance/')
self.assertEqual(response.data['balance'], '30.00')
@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

@@ -10,10 +10,11 @@ from .views import (
users, users,
print_count, print_count,
) )
from .business.purchase import views as purchase_views from .views.business.purchase import views as purchase_views
from .business.sales import views as sales_views from .views.business.sales import views as sales_views
from .business.payment import views as payment_views from .views.business.payment import views as payment_views
from .business.receipt import views as receipt_views from .views.business.receipt import views as receipt_views
from .views.business.balance import views as balance_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
@@ -69,6 +70,7 @@ urlpatterns = [
path('payment-orders/<int:pk>/review/', payment_views.PaymentOrderReviewView.as_view(), name='payment_order_review'), path('payment-orders/<int:pk>/review/', payment_views.PaymentOrderReviewView.as_view(), name='payment_order_review'),
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('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

@@ -150,6 +150,13 @@
--- ---
## 7. 客户欠款查询
- **GET** `/api/v1/customers/<id>/balance/`
- **描述**:返回指定客户当前的应收余额(审批通过的销售单金额累加减去收款单金额),数据来源于余额表,因此查询为 O(1)。
- **响应**`{"customer": 12, "customer_name": "张三", "balance": "1234.50"}`
- `balance` 为字符串格式的十进制数,正数表示客户欠款,应收;负数表示已收超额。
如需对 `items` 结构、仓库模式或审批流程做深入了解,请参阅: 如需对 `items` 结构、仓库模式或审批流程做深入了解,请参阅:
- `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

@@ -0,0 +1,4 @@
"""
余额相关 API 视图。
"""

View File

@@ -0,0 +1,36 @@
from decimal import Decimal
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 services as business_services
from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
class CustomerBalanceView(StockChangeViewMixin, views.APIView):
permission_classes = [IsAuthenticated]
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('客户不存在')
balance: Decimal = business_services.BalanceService.get_customer_balance(
merchant=merchant,
customer=customer,
)
return Response(
{
'customer': customer.id,
'customer_name': customer.name,
'balance': str(balance),
}
)

View File

@@ -39,6 +39,7 @@
- **SalesOrder**(销售单):`direction=-1``counterparty=customer`,含库存明细;严进严出模式需记录 `consume_detail_ids` - **SalesOrder**(销售单):`direction=-1``counterparty=customer`,含库存明细;严进严出模式需记录 `consume_detail_ids`
- **PaymentOrder**(付款单):`direction=-1``counterparty=supplier`,仅金额字段,不触发库存。 - **PaymentOrder**(付款单):`direction=-1``counterparty=supplier`,仅金额字段,不触发库存。
- **ReceiptOrder**(收款单):`direction=-1``counterparty=customer`,仅金额字段,不触发库存。 - **ReceiptOrder**(收款单):`direction=-1``counterparty=customer`,仅金额字段,不触发库存。
- **SupplierBalance / CustomerBalance**:实时维护供应商应付、客户应收余额,所有审批通过的带金额单据都会写入,供查询接口和报表使用。
通过 mixin所有单据都具备 通过 mixin所有单据都具备
- 统一的金额聚合与方向计算(资金/库存可共用 `get_signed_total_amount()` - 统一的金额聚合与方向计算(资金/库存可共用 `get_signed_total_amount()`

View File

@@ -0,0 +1,48 @@
# Generated by Django 5.2.7 on 2025-11-30 14:17
import django.db.models.deletion
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('basic_info', '0016_merchantsetting_type'),
('business', '0011_paymentorder_receiptorder'),
]
operations = [
migrations.CreateModel(
name='CustomerBalance',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('balance', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=15, verbose_name='应收余额')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='balances', to='basic_info.customer', verbose_name='客户')),
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='customer_balances', to='basic_info.merchant', verbose_name='所属商户')),
],
options={
'verbose_name': '客户余额',
'verbose_name_plural': '客户余额',
'unique_together': {('merchant', 'customer')},
},
),
migrations.CreateModel(
name='SupplierBalance',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('balance', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=15, verbose_name='应付余额')),
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='supplier_balances', to='basic_info.merchant', verbose_name='所属商户')),
('supplier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='balances', to='basic_info.supplier', verbose_name='供应商')),
],
options={
'verbose_name': '供应商余额',
'verbose_name_plural': '供应商余额',
'unique_together': {('merchant', 'supplier')},
},
),
]

View File

@@ -420,3 +420,45 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
def get_counterparty_field_name(self) -> str: def get_counterparty_field_name(self) -> str:
return 'customer' return 'customer'
class SupplierBalance(ModelBase):
merchant = models.ForeignKey(
basic_info_models.Merchant,
on_delete=models.PROTECT,
related_name='supplier_balances',
verbose_name='所属商户',
)
supplier = models.ForeignKey(
basic_info_models.Supplier,
on_delete=models.PROTECT,
related_name='balances',
verbose_name='供应商',
)
balance = models.DecimalField(max_digits=15, decimal_places=2, default=Decimal('0'), verbose_name='应付余额')
class Meta:
verbose_name = '供应商余额'
verbose_name_plural = '供应商余额'
unique_together = ('merchant', 'supplier')
class CustomerBalance(ModelBase):
merchant = models.ForeignKey(
basic_info_models.Merchant,
on_delete=models.PROTECT,
related_name='customer_balances',
verbose_name='所属商户',
)
customer = models.ForeignKey(
basic_info_models.Customer,
on_delete=models.PROTECT,
related_name='balances',
verbose_name='客户',
)
balance = models.DecimalField(max_digits=15, decimal_places=2, default=Decimal('0'), verbose_name='应收余额')
class Meta:
verbose_name = '客户余额'
verbose_name_plural = '客户余额'
unique_together = ('merchant', 'customer')

View File

@@ -18,6 +18,66 @@ from .tasks import (
create_purchase_order_stock_entries, create_purchase_order_stock_entries,
create_sales_order_stock_entries, create_sales_order_stock_entries,
) )
class BalanceService:
@staticmethod
def adjust_supplier_balance(
*,
merchant: basic_info_models.Merchant,
supplier: basic_info_models.Supplier,
delta: Decimal,
):
with transaction.atomic():
balance, _ = models.SupplierBalance.objects.select_for_update().get_or_create(
merchant=merchant,
supplier=supplier,
defaults={'balance': Decimal('0')},
)
balance.balance += delta
balance.save(update_fields=['balance', 'updated_at'])
@staticmethod
def adjust_customer_balance(
*,
merchant: basic_info_models.Merchant,
customer: basic_info_models.Customer,
delta: Decimal,
):
with transaction.atomic():
balance, _ = models.CustomerBalance.objects.select_for_update().get_or_create(
merchant=merchant,
customer=customer,
defaults={'balance': Decimal('0')},
)
balance.balance += delta
balance.save(update_fields=['balance', 'updated_at'])
@staticmethod
def get_customer_balance(
*,
merchant: basic_info_models.Merchant,
customer: basic_info_models.Customer,
) -> Decimal:
balance = models.CustomerBalance.objects.filter(
merchant=merchant,
customer=customer,
).first()
if balance is None:
return Decimal('0')
return balance.balance
@staticmethod
def get_supplier_balance(
*,
merchant: basic_info_models.Merchant,
supplier: basic_info_models.Supplier,
) -> Decimal:
balance = models.SupplierBalance.objects.filter(
merchant=merchant,
supplier=supplier,
).first()
if balance is None:
return Decimal('0')
return balance.balance
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -296,12 +356,37 @@ def review_payment_order(
if order.status == target_status: if order.status == target_status:
return order return order
with transaction.atomic(): if target_status == models.PaymentOrderStatusEnum.APPROVED:
order.status = target_status if order.status == models.PaymentOrderStatusEnum.CANCELLED:
order.save(update_fields=['status', 'updated_at']) raise ValueError('作废状态的付款单无法再次审批')
with transaction.atomic():
locked = models.PaymentOrder.objects.select_related(
'merchant', 'supplier'
).select_for_update().get(id=order.id)
if locked.status == models.PaymentOrderStatusEnum.APPROVED:
return locked
if locked.status == models.PaymentOrderStatusEnum.CANCELLED:
raise ValueError('作废状态的付款单无法再次审批')
locked.status = models.PaymentOrderStatusEnum.APPROVED
locked.save(update_fields=['status', 'updated_at'])
BalanceService.adjust_supplier_balance(
merchant=locked.merchant,
supplier=locked.supplier,
delta=-locked.amount,
)
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
order.refresh_from_db(fields=['status', 'updated_at']) if order.status == models.PaymentOrderStatusEnum.APPROVED:
return order raise ValueError('已审批的付款单无法作废')
with transaction.atomic():
locked = models.PaymentOrder.objects.select_for_update().get(id=order.id)
locked.status = models.PaymentOrderStatusEnum.CANCELLED
locked.save(update_fields=['status', 'updated_at'])
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
def review_receipt_order( def review_receipt_order(
@@ -322,12 +407,37 @@ def review_receipt_order(
if order.status == target_status: if order.status == target_status:
return order return order
with transaction.atomic(): if target_status == models.ReceiptOrderStatusEnum.APPROVED:
order.status = target_status if order.status == models.ReceiptOrderStatusEnum.CANCELLED:
order.save(update_fields=['status', 'updated_at']) raise ValueError('作废状态的收款单无法再次审批')
with transaction.atomic():
locked = models.ReceiptOrder.objects.select_related(
'merchant', 'customer'
).select_for_update().get(id=order.id)
if locked.status == models.ReceiptOrderStatusEnum.APPROVED:
return locked
if locked.status == models.ReceiptOrderStatusEnum.CANCELLED:
raise ValueError('作废状态的收款单无法再次审批')
locked.status = models.ReceiptOrderStatusEnum.APPROVED
locked.save(update_fields=['status', 'updated_at'])
BalanceService.adjust_customer_balance(
merchant=locked.merchant,
customer=locked.customer,
delta=-locked.amount,
)
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
order.refresh_from_db(fields=['status', 'updated_at']) if order.status == models.ReceiptOrderStatusEnum.APPROVED:
return order raise ValueError('已审批的收款单无法作废')
with transaction.atomic():
locked = models.ReceiptOrder.objects.select_for_update().get(id=order.id)
locked.status = models.ReceiptOrderStatusEnum.CANCELLED
locked.save(update_fields=['status', 'updated_at'])
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
def _normalize_order_items( def _normalize_order_items(
@@ -449,80 +559,112 @@ def _approve_purchase_order(
purchase_order: models.PurchaseOrder, purchase_order: models.PurchaseOrder,
reviewed_by, reviewed_by,
) -> models.PurchaseOrder: ) -> models.PurchaseOrder:
stock_flow_items = _build_stock_flow_items_from_order(purchase_order)
with transaction.atomic(): with transaction.atomic():
purchase_order.status = models.PurchaseOrderStatusEnum.APPROVED locked_order = models.PurchaseOrder.objects.select_related(
purchase_order.save(update_fields=['status', 'updated_at']) 'merchant', 'warehouse', 'supplier'
).prefetch_related('items').select_for_update().get(id=purchase_order.id)
if locked_order.status == models.PurchaseOrderStatusEnum.APPROVED:
return locked_order
if locked_order.status == models.PurchaseOrderStatusEnum.CANCELLED:
raise ValueError('作废状态的采购单无法再次审批')
stock_flow_items = _build_stock_flow_items_from_order(locked_order)
locked_order.status = models.PurchaseOrderStatusEnum.APPROVED
locked_order.save(update_fields=['status', 'updated_at'])
BalanceService.adjust_supplier_balance(
merchant=locked_order.merchant,
supplier=locked_order.supplier,
delta=locked_order.get_total_amount(),
)
created_by_id = getattr(reviewed_by, 'id', None) created_by_id = getattr(reviewed_by, 'id', None)
if _auto_stock_task_enabled(purchase_order.merchant): if _auto_stock_task_enabled(locked_order.merchant):
logger.info('审批通过采购单 %s,触发入库任务', purchase_order.id) logger.info('审批通过采购单 %s,触发入库任务', locked_order.id)
create_purchase_order_stock_entries.delay( create_purchase_order_stock_entries.delay(
purchase_order_id=purchase_order.id, purchase_order_id=locked_order.id,
warehouse_id=purchase_order.warehouse_id, warehouse_id=locked_order.warehouse_id,
items=stock_flow_items, items=stock_flow_items,
created_by_id=created_by_id, created_by_id=created_by_id,
) )
purchase_order.refresh_from_db(fields=['status', 'updated_at']) locked_order.refresh_from_db(fields=['status', 'updated_at'])
return purchase_order return locked_order
def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.PurchaseOrder: def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.PurchaseOrder:
if _order_has_stock_records(
merchant_id=purchase_order.merchant_id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=purchase_order.id,
):
raise ValueError('采购单已生成出入库记录,无法作废')
with transaction.atomic(): with transaction.atomic():
purchase_order.status = models.PurchaseOrderStatusEnum.CANCELLED locked = models.PurchaseOrder.objects.select_related('merchant').select_for_update().get(id=purchase_order.id)
purchase_order.save(update_fields=['status', 'updated_at']) if locked.status == models.PurchaseOrderStatusEnum.APPROVED:
raise ValueError('已审批的采购单无法作废')
purchase_order.refresh_from_db(fields=['status', 'updated_at']) if _order_has_stock_records(
return purchase_order merchant_id=locked.merchant_id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=locked.id,
):
raise ValueError('采购单已生成出入库记录,无法作废')
locked.status = models.PurchaseOrderStatusEnum.CANCELLED
locked.save(update_fields=['status', 'updated_at'])
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
def _approve_sales_order( def _approve_sales_order(
sales_order: models.SalesOrder, sales_order: models.SalesOrder,
reviewed_by, reviewed_by,
) -> models.SalesOrder: ) -> models.SalesOrder:
stock_flow_items = _build_stock_flow_items_from_order(sales_order)
with transaction.atomic(): with transaction.atomic():
sales_order.status = models.SalesOrderStatusEnum.APPROVED locked_order = models.SalesOrder.objects.select_related(
sales_order.save(update_fields=['status', 'updated_at']) 'merchant', 'warehouse', 'customer'
).prefetch_related('items').select_for_update().get(id=sales_order.id)
if locked_order.status == models.SalesOrderStatusEnum.APPROVED:
return locked_order
if locked_order.status == models.SalesOrderStatusEnum.CANCELLED:
raise ValueError('作废状态的销售单无法再次审批')
stock_flow_items = _build_stock_flow_items_from_order(locked_order)
locked_order.status = models.SalesOrderStatusEnum.APPROVED
locked_order.save(update_fields=['status', 'updated_at'])
BalanceService.adjust_customer_balance(
merchant=locked_order.merchant,
customer=locked_order.customer,
delta=locked_order.get_total_amount(),
)
created_by_id = getattr(reviewed_by, 'id', None) created_by_id = getattr(reviewed_by, 'id', None)
if _auto_stock_task_enabled(sales_order.merchant): if _auto_stock_task_enabled(locked_order.merchant):
logger.info('审批通过销售单 %s,触发出库任务', sales_order.id) logger.info('审批通过销售单 %s,触发出库任务', locked_order.id)
create_sales_order_stock_entries.delay( create_sales_order_stock_entries.delay(
sales_order_id=sales_order.id, sales_order_id=locked_order.id,
warehouse_id=sales_order.warehouse_id, warehouse_id=locked_order.warehouse_id,
items=stock_flow_items, items=stock_flow_items,
created_by_id=created_by_id, created_by_id=created_by_id,
) )
sales_order.refresh_from_db(fields=['status', 'updated_at']) locked_order.refresh_from_db(fields=['status', 'updated_at'])
return sales_order return locked_order
def _cancel_sales_order(sales_order: models.SalesOrder) -> models.SalesOrder: def _cancel_sales_order(sales_order: models.SalesOrder) -> models.SalesOrder:
if _order_has_stock_records(
merchant_id=sales_order.merchant_id,
source_type=stock_models.StockChangeSourceEnum.SALES,
source_id=sales_order.id,
):
raise ValueError('销售单已生成出入库记录,无法作废')
with transaction.atomic(): with transaction.atomic():
sales_order.status = models.SalesOrderStatusEnum.CANCELLED locked = models.SalesOrder.objects.select_related('merchant').select_for_update().get(id=sales_order.id)
sales_order.save(update_fields=['status', 'updated_at']) if locked.status == models.SalesOrderStatusEnum.APPROVED:
raise ValueError('已审批的销售单无法作废')
if _order_has_stock_records(
merchant_id=locked.merchant_id,
source_type=stock_models.StockChangeSourceEnum.SALES,
source_id=locked.id,
):
raise ValueError('销售单已生成出入库记录,无法作废')
sales_order.refresh_from_db(fields=['status', 'updated_at']) locked.status = models.SalesOrderStatusEnum.CANCELLED
return sales_order locked.save(update_fields=['status', 'updated_at'])
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
def create_purchase_order_stock_entries_sync( def create_purchase_order_stock_entries_sync(

View File

@@ -1,7 +1,10 @@
from typing import List, Dict, Any from typing import List, Dict, Any
from decimal import Decimal
from concurrent.futures import ThreadPoolExecutor
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, TransactionTestCase, override_settings
from django.db import connections
from django.utils import timezone from django.utils import timezone
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@@ -171,6 +174,11 @@ class PurchaseOrderServiceTestCase(TestCase):
items=[{'product_id': self.product.id, 'quantities': ['10', '5']}], items=[{'product_id': self.product.id, 'quantities': ['10', '5']}],
created_by_id=self.user.id, created_by_id=self.user.id,
) )
balance = business_models.SupplierBalance.objects.get(
merchant=self.merchant,
supplier=self.supplier,
)
self.assertEqual(balance.balance, purchase_order.get_total_amount())
def test_create_purchase_order_without_items_raises(self): def test_create_purchase_order_without_items_raises(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
@@ -324,6 +332,11 @@ class SalesOrderServiceTestCase(TestCase):
items=[{'product_id': self.product.id, 'quantities': ['6', '4']}], items=[{'product_id': self.product.id, 'quantities': ['6', '4']}],
created_by_id=self.user.id, created_by_id=self.user.id,
) )
balance = business_models.CustomerBalance.objects.get(
merchant=self.merchant,
customer=self.customer,
)
self.assertEqual(balance.balance, sales_order.get_total_amount())
def test_sales_order_cancel_blocked_after_stock_created(self): def test_sales_order_cancel_blocked_after_stock_created(self):
sales_order = services.create_sales_order( sales_order = services.create_sales_order(
@@ -392,7 +405,6 @@ class SalesOrderServiceTestCase(TestCase):
created_by=self.user, created_by=self.user,
) )
class PaymentReceiptServiceTestCase(TestCase): class PaymentReceiptServiceTestCase(TestCase):
def setUp(self): def setUp(self):
( (
@@ -426,6 +438,17 @@ class PaymentReceiptServiceTestCase(TestCase):
reviewed_by=self.operator, reviewed_by=self.operator,
) )
self.assertEqual(reviewed.status, business_models.PaymentOrderStatusEnum.APPROVED) self.assertEqual(reviewed.status, business_models.PaymentOrderStatusEnum.APPROVED)
balance = business_models.SupplierBalance.objects.get(
merchant=self.merchant,
supplier=self.supplier,
)
self.assertEqual(balance.balance, Decimal('-120.50'))
with self.assertRaises(ValueError):
services.review_payment_order(
payment_order=order,
target_status=business_models.PaymentOrderStatusEnum.CANCELLED,
reviewed_by=self.operator,
)
def test_create_receipt_order_and_cancel(self): def test_create_receipt_order_and_cancel(self):
order = services.create_receipt_order( order = services.create_receipt_order(
@@ -472,6 +495,11 @@ class PaymentReceiptServiceTestCase(TestCase):
reviewed_by=self.operator, reviewed_by=self.operator,
) )
self.assertEqual(reviewed_again.status, business_models.ReceiptOrderStatusEnum.APPROVED) self.assertEqual(reviewed_again.status, business_models.ReceiptOrderStatusEnum.APPROVED)
balance = business_models.CustomerBalance.objects.get(
merchant=self.merchant,
customer=self.customer,
)
self.assertEqual(balance.balance, Decimal('-10'))
class PurchaseOrderStockServiceTestCase(TestCase): class PurchaseOrderStockServiceTestCase(TestCase):
@@ -593,6 +621,58 @@ class SalesOrderStockServiceTestCase(TestCase):
self.assertEqual(payload['stock_change_record_id'], 987) self.assertEqual(payload['stock_change_record_id'], 987)
class SalesOrderConcurrencyTestCase(TransactionTestCase):
reset_sequences = True
def setUp(self):
(
self.merchant,
self.customer,
self.warehouse_strict,
self.warehouse_relaxed,
self.warehouse_strict_out,
self.product,
self.operator,
) = create_sales_fixtures()
basic_models.MerchantSetting.objects.filter(
merchant=self.merchant,
key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS,
).update(val_bool=False)
User = get_user_model()
self.user = User.objects.create_user(username='concurrent', password='pass123')
self.sales_order = services.create_sales_order(
merchant=self.merchant,
customer=self.customer,
order_date=timezone.now().date(),
warehouse=self.warehouse_strict,
operator=self.operator,
items=[{'product_id': self.product.id, 'numbers': [5], 'price': '12', 'unit': ''}],
created_by=self.user,
)
def test_concurrent_sales_order_approval_updates_balance_once(self):
def approve():
services.review_sales_order(
sales_order_id=self.sales_order.id,
target_status=business_models.SalesOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [executor.submit(approve) for _ in range(2)]
for future in futures:
future.result()
balance = business_models.CustomerBalance.objects.get(
merchant=self.merchant,
customer=self.customer,
)
self.assertEqual(balance.balance, self.sales_order.get_total_amount())
def tearDown(self):
connections.close_all()
@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

@@ -0,0 +1,213 @@
# Business 模块 API 汇总
本文件汇集 `business` 模块及其在 `api_v1` 下的所有接口,前端只需参考本页即可完成对接。所有接口均位于 `/api/v1/`,除明确说明外均需用户已登录且具备员工身份。
---
## 1. 通用约定
| 项 | 说明 |
|----|------|
| 认证 | Session / Token与项目统一。用户必须关联 `Employee` 且属于目标 `Merchant`。 |
| 多租户 | `request.user.employee.merchant` 自动限定数据范围,接口内部已校验,无需额外参数。 |
| 分页 | 列表接口使用 `limit` / `offset`,默认 `limit=20`,最大 `100`。 |
| 状态枚举 | `1=PENDING``2=APPROVED``3=CANCELLED`。审批接口通过 `action=approve/cancel` 修改状态。 |
| 金额字段 | 字符串形式的十进制数(例如 `"123.45"`),与后端 `Decimal` 精度一致。 |
| items 结构 | 受仓库模式影响:<br>• 严进/严进严出:`numbers: [int,…]` 表示条数明细<br>• 宽进宽出:`quantity` + `num_of_rolls`<br>• 严出:`consume_detail_ids: [detail_id,…]` |
| 审批副作用 | 采购/销售审批通过后根据商户设置触发 Celery 入/出库任务;付款/收款审批通过将同步写入余额表。 |
错误响应统一为 `{"error": "代码", "message": "描述"}`,字段可能因场景扩展(如 `record_id``fields` 等)。
---
## 2. 采购单PurchaseOrder
| API | 方法 | 描述 |
|-----|------|------|
| `/purchase-orders/` | GET | 分页列表。 |
| `/purchase-orders/` | POST | 创建采购单。 |
| `/purchase-orders/<id>/review/` | POST | 审批或作废。 |
### 2.1 创建请求体
```json
{
"supplier": 12,
"warehouse": 8,
"order_date": "2025-11-30",
"items": [
{
"product_id": 1001,
"numbers": [30, 25],
"price": "12.50",
"unit": "米"
}
],
"remarks": "可选"
}
```
宽进仓将 `numbers` 换为 `quantity` + `num_of_rolls`。至少 1 条明细,否则返回 400。
### 2.2 审批
`POST /purchase-orders/<id>/review/`,请求体 `{"action": "approve"}``{"action": "cancel"}`
- `approve`:在事务内将状态置为 `APPROVED`、写入供应商余额(正向金额),并在商户开启自动任务时触发 `create_purchase_order_stock_entries`
- `cancel`:若已存在 `StockChangeRecord`source=`PURCHASE`),返回 400否则置为 `CANCELLED`
成功返回最新的采购单序列化(含 `items``total_amount``status_name` 等)。
---
## 3. 销售单SalesOrder
| API | 方法 | 描述 |
|-----|------|------|
| `/sales-orders/` | GET | 分页列表。 |
| `/sales-orders/` | POST | 创建销售单。 |
| `/sales-orders/<id>/review/` | POST | 审批或作废。 |
### 3.1 创建请求体
```json
{
"customer": 6,
"warehouse": 3,
"order_date": "2025-11-30",
"items": [
{
"product_id": 1001,
"numbers": [20, 18],
"price": "18.80",
"unit": "米"
}
],
"remarks": ""
}
```
仓库为严出(`RESTRICT_IN_OUT`)时必须改用:
```json
{
"product_id": 1001,
"consume_detail_ids": [321, 322],
"quantity": 200,
"price": "20",
"unit": "米"
}
```
### 3.2 审批
与采购单一致,但方向为出库:
- `approve`:写入客户余额(正向欠款),若商户配置自动出库则触发 `create_sales_order_stock_entries`
- `cancel`:如果已存在 `StockChangeRecord`source=`SALES`)则拒绝。
---
## 4. 付款单PaymentOrder
| API | 方法 | 描述 |
|-----|------|------|
| `/payment-orders/` | GET | 分页列表。 |
| `/payment-orders/` | POST | 创建付款单。 |
| `/payment-orders/<id>/review/` | POST | 审批或作废。 |
### 4.1 创建请求体
```json
{
"supplier": 12,
"payment_date": "2025-11-30",
"amount": "5000.00",
"remarks": ""
}
```
`amount` 必须大于 0。返回 201 + 创建的记录。
### 4.2 审批逻辑
- `approve`:在事务内锁定单据,防止重复审批;状态改为 `APPROVED`,并将供应商余额 **减少** 对应金额。
- `cancel`:仅允许从 `PENDING` 作废,且若已审批则返回 400。
---
## 5. 收款单ReceiptOrder
| API | 方法 | 描述 |
|-----|------|------|
| `/receipt-orders/` | GET | 列表。 |
| `/receipt-orders/` | POST | 创建收款单。 |
| `/receipt-orders/<id>/review/` | POST | 审批或作废。 |
### 5.1 创建
```json
{
"customer": 6,
"receipt_date": "2025-11-30",
"amount": "3200.00",
"remarks": ""
}
```
### 5.2 审批
- `approve`:状态改为 `APPROVED`,客户余额 **减少** 对应金额(冲减欠款)。若已取消则拒绝再次审批。
- `cancel`:仅允许从 `PENDING` 作废;当单据已审批时返回 400。
---
## 6. 余额表 / 对账接口
余额数据来自 `SupplierBalance` / `CustomerBalance` 表,所有审批通过的单据均在事务内写入,保证与业务状态一致。
### 6.1 客户余额
| API | 方法 | 描述 |
|-----|------|------|
| `/customers/<id>/balance/` | GET | 查询指定客户应收余额。 |
响应示例:
```json
{
"customer": 6,
"customer_name": "杭州零售商",
"balance": "3200.00"
}
```
- 正数表示客户仍欠款;负数表示已收超额。
- 若客户无记录返回 `"0"`
### 6.2 供应商余额
目前仅内部使用(审批写入),如需对外查询可在此基础上新增 `/suppliers/<id>/balance/`,逻辑与客户一致:采购单审批增加余额、付款单审批减少余额。
---
## 7. 错误码与常见响应
| 场景 | HTTP | 返回 |
|------|------|------|
| 未登录 / 认证失败 | 401 | `{"detail": "Authentication credentials were not provided."}` |
| 非本商户数据 | 403 | `{"error": "forbidden", "message": "无权限访问"}` |
| 单据不存在 | 404 | `{"error": "purchase_order_not_found"}` 等 |
| 审批非法状态 | 400 | 例如 `{"error": "purchase_order_has_stock_records"}``{"error": "receipt_order_already_approved"}` |
| 余额功能未实现 | 501 | 仅限未来拓展,例如库存红冲尚未开放 |
---
## 8. 参考文档
- `docs/purchase_order_approval_and_red_flush.md`:采购单审批及未来红冲方案。
- `docs/sales_order_approval_and_red_flush.md`:销售单审批与严出模式说明。
- `docs/payment_receipt_workflow.md`:资金类单据与余额表写入逻辑。
本汇总会随着业务扩展同步更新,如需新增接口请补充到此文件以保持前端“单文档”体验。***

View File

@@ -5,6 +5,7 @@
- `ReceiptOrder`:针对客户的资金收入单据,审批通过后代表“确认收款”。 - `ReceiptOrder`:针对客户的资金收入单据,审批通过后代表“确认收款”。
- 两者均位于 `business` 模块API 路径分别为 `/api/v1/payment-orders/``/api/v1/receipt-orders/` - 两者均位于 `business` 模块API 路径分别为 `/api/v1/payment-orders/``/api/v1/receipt-orders/`
- 与采购/销售相比,不涉及产品与库存,仅维护资金方向、对方主体、金额与状态。 - 与采购/销售相比,不涉及产品与库存,仅维护资金方向、对方主体、金额与状态。
- 审批通过会同步更新供应商/客户余额表(`SupplierBalance` / `CustomerBalance`),提供 O(1) 的欠款查询。
## 2. 创建流程 ## 2. 创建流程
| 字段 | 付款单 | 收款单 | | 字段 | 付款单 | 收款单 |

View File

@@ -4,7 +4,7 @@
- 采购单在创建时默认进入 `PENDING`(审批中)状态,不再立即创建出入库记录。 - 采购单在创建时默认进入 `PENDING`(审批中)状态,不再立即创建出入库记录。
- `business.services.review_purchase_order` 统一处理审批通过 (`APPROVED`) 与作废 (`CANCELLED`) 的业务规则。 - `business.services.review_purchase_order` 统一处理审批通过 (`APPROVED`) 与作废 (`CANCELLED`) 的业务规则。
- `api_v1/business/purchase/views.py` 暴露 `POST /api/v1/purchase-orders/<id>/review/` 接口作为唯一入口,便于前端、自动化流程和第三方系统统一调用。 - `api_v1/views/business/purchase/views.py` 暴露 `POST /api/v1/purchase-orders/<id>/review/` 接口作为唯一入口,便于前端、自动化流程和第三方系统统一调用。
## 2. 审批流程总览 ## 2. 审批流程总览

View File

@@ -4,7 +4,7 @@
- 销售单在创建时同样进入 `PENDING` 状态,审批通过后才会触发出库流程。 - 销售单在创建时同样进入 `PENDING` 状态,审批通过后才会触发出库流程。
- `business.services.review_sales_order` 统一处理审批通过 (`APPROVED`) 与作废 (`CANCELLED`) 的业务规则。 - `business.services.review_sales_order` 统一处理审批通过 (`APPROVED`) 与作废 (`CANCELLED`) 的业务规则。
- `api_v1/business/sales/views.py` 暴露 `POST /api/v1/sales-orders/<id>/review/` 接口,供前端、自动化和第三方系统一致调用。 - `api_v1/views/business/sales/views.py` 暴露 `POST /api/v1/sales-orders/<id>/review/` 接口,供前端、自动化和第三方系统一致调用。
## 2. 审批流程总览 ## 2. 审批流程总览