forked from erp-dev/erp
feat: balance api
This commit is contained in:
@@ -22,7 +22,7 @@ from basic_info.models import (
|
||||
WareHouse,
|
||||
WareHouseModeEnum,
|
||||
)
|
||||
from business import models as business_models
|
||||
from business import models as business_models, services
|
||||
from stock import models as stock_models
|
||||
from api_v1 import tasks
|
||||
|
||||
@@ -494,8 +494,7 @@ class PaymentOrderAPITestCase(TestCase):
|
||||
{'action': 'cancel'},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response_cancel.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response_cancel.data['status'], business_models.PaymentOrderStatusEnum.CANCELLED)
|
||||
self.assertEqual(response_cancel.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_payment_order_requires_amount(self):
|
||||
payload = {**self.payload}
|
||||
@@ -569,6 +568,81 @@ class ReceiptOrderAPITestCase(TestCase):
|
||||
response = self.client.post('/api/v1/receipt-orders/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
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(
|
||||
CELERY_TASK_ALWAYS_EAGER=True,
|
||||
CELERY_TASK_EAGER_PROPAGATES=True,
|
||||
|
||||
@@ -10,10 +10,11 @@ from .views import (
|
||||
users,
|
||||
print_count,
|
||||
)
|
||||
from .business.purchase import views as purchase_views
|
||||
from .business.sales import views as sales_views
|
||||
from .business.payment import views as payment_views
|
||||
from .business.receipt import views as receipt_views
|
||||
from .views.business.purchase import views as purchase_views
|
||||
from .views.business.sales import views as sales_views
|
||||
from .views.business.payment import views as payment_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.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet
|
||||
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('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('health/', healthy.HealthCheckView.as_view(), name='health_check'),
|
||||
path('print-count/delta/', print_count.adjust_print_count, name='print_count_delta'),
|
||||
|
||||
|
||||
@@ -150,6 +150,13 @@
|
||||
|
||||
---
|
||||
|
||||
## 7. 客户欠款查询
|
||||
|
||||
- **GET** `/api/v1/customers/<id>/balance/`
|
||||
- **描述**:返回指定客户当前的应收余额(审批通过的销售单金额累加减去收款单金额),数据来源于余额表,因此查询为 O(1)。
|
||||
- **响应**:`{"customer": 12, "customer_name": "张三", "balance": "1234.50"}`
|
||||
- `balance` 为字符串格式的十进制数,正数表示客户欠款,应收;负数表示已收超额。
|
||||
|
||||
如需对 `items` 结构、仓库模式或审批流程做深入了解,请参阅:
|
||||
- `docs/purchase_order_approval_and_red_flush.md`
|
||||
- `docs/sales_order_approval_and_red_flush.md`
|
||||
4
api_v1/views/business/balance/__init__.py
Normal file
4
api_v1/views/business/balance/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
余额相关 API 视图。
|
||||
"""
|
||||
|
||||
36
api_v1/views/business/balance/views.py
Normal file
36
api_v1/views/business/balance/views.py
Normal 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),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
- **SalesOrder**(销售单):`direction=-1`,`counterparty=customer`,含库存明细;严进严出模式需记录 `consume_detail_ids`。
|
||||
- **PaymentOrder**(付款单):`direction=-1`,`counterparty=supplier`,仅金额字段,不触发库存。
|
||||
- **ReceiptOrder**(收款单):`direction=-1`,`counterparty=customer`,仅金额字段,不触发库存。
|
||||
- **SupplierBalance / CustomerBalance**:实时维护供应商应付、客户应收余额,所有审批通过的带金额单据都会写入,供查询接口和报表使用。
|
||||
|
||||
通过 mixin,所有单据都具备:
|
||||
- 统一的金额聚合与方向计算(资金/库存可共用 `get_signed_total_amount()`)
|
||||
|
||||
48
business/migrations/0012_customerbalance_supplierbalance.py
Normal file
48
business/migrations/0012_customerbalance_supplierbalance.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -420,3 +420,45 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
|
||||
|
||||
def get_counterparty_field_name(self) -> str:
|
||||
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')
|
||||
|
||||
@@ -18,6 +18,66 @@ from .tasks import (
|
||||
create_purchase_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__)
|
||||
@@ -296,12 +356,37 @@ def review_payment_order(
|
||||
if order.status == target_status:
|
||||
return order
|
||||
|
||||
if target_status == models.PaymentOrderStatusEnum.APPROVED:
|
||||
if order.status == models.PaymentOrderStatusEnum.CANCELLED:
|
||||
raise ValueError('作废状态的付款单无法再次审批')
|
||||
with transaction.atomic():
|
||||
order.status = target_status
|
||||
order.save(update_fields=['status', 'updated_at'])
|
||||
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'])
|
||||
return order
|
||||
if order.status == models.PaymentOrderStatusEnum.APPROVED:
|
||||
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(
|
||||
@@ -322,12 +407,37 @@ def review_receipt_order(
|
||||
if order.status == target_status:
|
||||
return order
|
||||
|
||||
if target_status == models.ReceiptOrderStatusEnum.APPROVED:
|
||||
if order.status == models.ReceiptOrderStatusEnum.CANCELLED:
|
||||
raise ValueError('作废状态的收款单无法再次审批')
|
||||
with transaction.atomic():
|
||||
order.status = target_status
|
||||
order.save(update_fields=['status', 'updated_at'])
|
||||
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'])
|
||||
return order
|
||||
if order.status == models.ReceiptOrderStatusEnum.APPROVED:
|
||||
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(
|
||||
@@ -449,80 +559,112 @@ def _approve_purchase_order(
|
||||
purchase_order: models.PurchaseOrder,
|
||||
reviewed_by,
|
||||
) -> models.PurchaseOrder:
|
||||
stock_flow_items = _build_stock_flow_items_from_order(purchase_order)
|
||||
|
||||
with transaction.atomic():
|
||||
purchase_order.status = models.PurchaseOrderStatusEnum.APPROVED
|
||||
purchase_order.save(update_fields=['status', 'updated_at'])
|
||||
locked_order = models.PurchaseOrder.objects.select_related(
|
||||
'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)
|
||||
if _auto_stock_task_enabled(purchase_order.merchant):
|
||||
logger.info('审批通过采购单 %s,触发入库任务', purchase_order.id)
|
||||
if _auto_stock_task_enabled(locked_order.merchant):
|
||||
logger.info('审批通过采购单 %s,触发入库任务', locked_order.id)
|
||||
create_purchase_order_stock_entries.delay(
|
||||
purchase_order_id=purchase_order.id,
|
||||
warehouse_id=purchase_order.warehouse_id,
|
||||
purchase_order_id=locked_order.id,
|
||||
warehouse_id=locked_order.warehouse_id,
|
||||
items=stock_flow_items,
|
||||
created_by_id=created_by_id,
|
||||
)
|
||||
|
||||
purchase_order.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return purchase_order
|
||||
locked_order.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return locked_order
|
||||
|
||||
|
||||
def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.PurchaseOrder:
|
||||
with transaction.atomic():
|
||||
locked = models.PurchaseOrder.objects.select_related('merchant').select_for_update().get(id=purchase_order.id)
|
||||
if locked.status == models.PurchaseOrderStatusEnum.APPROVED:
|
||||
raise ValueError('已审批的采购单无法作废')
|
||||
if _order_has_stock_records(
|
||||
merchant_id=purchase_order.merchant_id,
|
||||
merchant_id=locked.merchant_id,
|
||||
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=purchase_order.id,
|
||||
source_id=locked.id,
|
||||
):
|
||||
raise ValueError('采购单已生成出入库记录,无法作废')
|
||||
|
||||
with transaction.atomic():
|
||||
purchase_order.status = models.PurchaseOrderStatusEnum.CANCELLED
|
||||
purchase_order.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
purchase_order.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return purchase_order
|
||||
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(
|
||||
sales_order: models.SalesOrder,
|
||||
reviewed_by,
|
||||
) -> models.SalesOrder:
|
||||
stock_flow_items = _build_stock_flow_items_from_order(sales_order)
|
||||
|
||||
with transaction.atomic():
|
||||
sales_order.status = models.SalesOrderStatusEnum.APPROVED
|
||||
sales_order.save(update_fields=['status', 'updated_at'])
|
||||
locked_order = models.SalesOrder.objects.select_related(
|
||||
'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)
|
||||
if _auto_stock_task_enabled(sales_order.merchant):
|
||||
logger.info('审批通过销售单 %s,触发出库任务', sales_order.id)
|
||||
if _auto_stock_task_enabled(locked_order.merchant):
|
||||
logger.info('审批通过销售单 %s,触发出库任务', locked_order.id)
|
||||
create_sales_order_stock_entries.delay(
|
||||
sales_order_id=sales_order.id,
|
||||
warehouse_id=sales_order.warehouse_id,
|
||||
sales_order_id=locked_order.id,
|
||||
warehouse_id=locked_order.warehouse_id,
|
||||
items=stock_flow_items,
|
||||
created_by_id=created_by_id,
|
||||
)
|
||||
|
||||
sales_order.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return sales_order
|
||||
locked_order.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return locked_order
|
||||
|
||||
|
||||
def _cancel_sales_order(sales_order: models.SalesOrder) -> models.SalesOrder:
|
||||
with transaction.atomic():
|
||||
locked = models.SalesOrder.objects.select_related('merchant').select_for_update().get(id=sales_order.id)
|
||||
if locked.status == models.SalesOrderStatusEnum.APPROVED:
|
||||
raise ValueError('已审批的销售单无法作废')
|
||||
if _order_has_stock_records(
|
||||
merchant_id=sales_order.merchant_id,
|
||||
merchant_id=locked.merchant_id,
|
||||
source_type=stock_models.StockChangeSourceEnum.SALES,
|
||||
source_id=sales_order.id,
|
||||
source_id=locked.id,
|
||||
):
|
||||
raise ValueError('销售单已生成出入库记录,无法作废')
|
||||
|
||||
with transaction.atomic():
|
||||
sales_order.status = models.SalesOrderStatusEnum.CANCELLED
|
||||
sales_order.save(update_fields=['status', 'updated_at'])
|
||||
locked.status = models.SalesOrderStatusEnum.CANCELLED
|
||||
locked.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
sales_order.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return sales_order
|
||||
locked.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return locked
|
||||
|
||||
|
||||
def create_purchase_order_stock_entries_sync(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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.test import TestCase, override_settings
|
||||
from django.test import TestCase, TransactionTestCase, override_settings
|
||||
from django.db import connections
|
||||
from django.utils import timezone
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
@@ -171,6 +174,11 @@ class PurchaseOrderServiceTestCase(TestCase):
|
||||
items=[{'product_id': self.product.id, 'quantities': ['10', '5']}],
|
||||
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):
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -324,6 +332,11 @@ class SalesOrderServiceTestCase(TestCase):
|
||||
items=[{'product_id': self.product.id, 'quantities': ['6', '4']}],
|
||||
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):
|
||||
sales_order = services.create_sales_order(
|
||||
@@ -392,7 +405,6 @@ class SalesOrderServiceTestCase(TestCase):
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
|
||||
class PaymentReceiptServiceTestCase(TestCase):
|
||||
def setUp(self):
|
||||
(
|
||||
@@ -426,6 +438,17 @@ class PaymentReceiptServiceTestCase(TestCase):
|
||||
reviewed_by=self.operator,
|
||||
)
|
||||
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):
|
||||
order = services.create_receipt_order(
|
||||
@@ -472,6 +495,11 @@ class PaymentReceiptServiceTestCase(TestCase):
|
||||
reviewed_by=self.operator,
|
||||
)
|
||||
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):
|
||||
@@ -593,6 +621,58 @@ class SalesOrderStockServiceTestCase(TestCase):
|
||||
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(
|
||||
CELERY_TASK_ALWAYS_EAGER=True,
|
||||
CELERY_TASK_EAGER_PROPAGATES=True,
|
||||
|
||||
213
docs/business_api_reference.md
Normal file
213
docs/business_api_reference.md
Normal 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`:资金类单据与余额表写入逻辑。
|
||||
|
||||
本汇总会随着业务扩展同步更新,如需新增接口请补充到此文件以保持前端“单文档”体验。***
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
- `ReceiptOrder`:针对客户的资金收入单据,审批通过后代表“确认收款”。
|
||||
- 两者均位于 `business` 模块,API 路径分别为 `/api/v1/payment-orders/`、`/api/v1/receipt-orders/`。
|
||||
- 与采购/销售相比,不涉及产品与库存,仅维护资金方向、对方主体、金额与状态。
|
||||
- 审批通过会同步更新供应商/客户余额表(`SupplierBalance` / `CustomerBalance`),提供 O(1) 的欠款查询。
|
||||
|
||||
## 2. 创建流程
|
||||
| 字段 | 付款单 | 收款单 |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- 采购单在创建时默认进入 `PENDING`(审批中)状态,不再立即创建出入库记录。
|
||||
- `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. 审批流程总览
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- 销售单在创建时同样进入 `PENDING` 状态,审批通过后才会触发出库流程。
|
||||
- `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. 审批流程总览
|
||||
|
||||
|
||||
Reference in New Issue
Block a user