diff --git a/.coverage b/.coverage index cef0228..f66a8fe 100644 Binary files a/.coverage and b/.coverage differ diff --git a/api_v1/tests.py b/api_v1/tests.py index a0b027c..6b5a16a 100644 --- a/api_v1/tests.py +++ b/api_v1/tests.py @@ -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, diff --git a/api_v1/urls.py b/api_v1/urls.py index abc275b..04fdce7 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -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//review/', payment_views.PaymentOrderReviewView.as_view(), name='payment_order_review'), path('receipt-orders/', receipt_views.ReceiptOrderView.as_view(), name='receipt_orders'), path('receipt-orders//review/', receipt_views.ReceiptOrderReviewView.as_view(), name='receipt_order_review'), + path('customers//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'), diff --git a/api_v1/business/README.md b/api_v1/views/business/README.md similarity index 92% rename from api_v1/business/README.md rename to api_v1/views/business/README.md index f41b45d..5acaea4 100644 --- a/api_v1/business/README.md +++ b/api_v1/views/business/README.md @@ -150,6 +150,13 @@ --- +## 7. 客户欠款查询 + +- **GET** `/api/v1/customers//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` diff --git a/api_v1/business/__init__.py b/api_v1/views/business/__init__.py similarity index 100% rename from api_v1/business/__init__.py rename to api_v1/views/business/__init__.py diff --git a/api_v1/views/business/balance/__init__.py b/api_v1/views/business/balance/__init__.py new file mode 100644 index 0000000..43fc167 --- /dev/null +++ b/api_v1/views/business/balance/__init__.py @@ -0,0 +1,4 @@ +""" +余额相关 API 视图。 +""" + diff --git a/api_v1/views/business/balance/views.py b/api_v1/views/business/balance/views.py new file mode 100644 index 0000000..0a24c6e --- /dev/null +++ b/api_v1/views/business/balance/views.py @@ -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), + } + ) + diff --git a/api_v1/business/payment/views.py b/api_v1/views/business/payment/views.py similarity index 100% rename from api_v1/business/payment/views.py rename to api_v1/views/business/payment/views.py diff --git a/api_v1/business/purchase/__init__.py b/api_v1/views/business/purchase/__init__.py similarity index 100% rename from api_v1/business/purchase/__init__.py rename to api_v1/views/business/purchase/__init__.py diff --git a/api_v1/business/purchase/views.py b/api_v1/views/business/purchase/views.py similarity index 100% rename from api_v1/business/purchase/views.py rename to api_v1/views/business/purchase/views.py diff --git a/api_v1/business/receipt/views.py b/api_v1/views/business/receipt/views.py similarity index 100% rename from api_v1/business/receipt/views.py rename to api_v1/views/business/receipt/views.py diff --git a/api_v1/business/sales/views.py b/api_v1/views/business/sales/views.py similarity index 100% rename from api_v1/business/sales/views.py rename to api_v1/views/business/sales/views.py diff --git a/business/ARCHITECTURE.md b/business/ARCHITECTURE.md index 0e74187..9fa136c 100644 --- a/business/ARCHITECTURE.md +++ b/business/ARCHITECTURE.md @@ -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()`) diff --git a/business/migrations/0012_customerbalance_supplierbalance.py b/business/migrations/0012_customerbalance_supplierbalance.py new file mode 100644 index 0000000..c3f79d3 --- /dev/null +++ b/business/migrations/0012_customerbalance_supplierbalance.py @@ -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')}, + }, + ), + ] diff --git a/business/models.py b/business/models.py index 4edabe9..929a5cf 100644 --- a/business/models.py +++ b/business/models.py @@ -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') diff --git a/business/services.py b/business/services.py index b730d28..a67b8fe 100644 --- a/business/services.py +++ b/business/services.py @@ -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 - with transaction.atomic(): - order.status = target_status - order.save(update_fields=['status', 'updated_at']) + if target_status == models.PaymentOrderStatusEnum.APPROVED: + if order.status == models.PaymentOrderStatusEnum.CANCELLED: + 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']) - 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 - with transaction.atomic(): - order.status = target_status - order.save(update_fields=['status', 'updated_at']) + if target_status == models.ReceiptOrderStatusEnum.APPROVED: + if order.status == models.ReceiptOrderStatusEnum.CANCELLED: + 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']) - 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: - 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(): - 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 = 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=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( 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: - 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(): - sales_order.status = models.SalesOrderStatusEnum.CANCELLED - sales_order.save(update_fields=['status', 'updated_at']) + 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=locked.merchant_id, + source_type=stock_models.StockChangeSourceEnum.SALES, + source_id=locked.id, + ): + raise ValueError('销售单已生成出入库记录,无法作废') - sales_order.refresh_from_db(fields=['status', 'updated_at']) - return sales_order + locked.status = models.SalesOrderStatusEnum.CANCELLED + locked.save(update_fields=['status', 'updated_at']) + + locked.refresh_from_db(fields=['status', 'updated_at']) + return locked def create_purchase_order_stock_entries_sync( diff --git a/business/tests.py b/business/tests.py index 1503bf2..d1893ba 100644 --- a/business/tests.py +++ b/business/tests.py @@ -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, diff --git a/docs/business_api_reference.md b/docs/business_api_reference.md new file mode 100644 index 0000000..7779b6a --- /dev/null +++ b/docs/business_api_reference.md @@ -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 结构 | 受仓库模式影响:
• 严进/严进严出:`numbers: [int,…]` 表示条数明细
• 宽进宽出:`quantity` + `num_of_rolls`
• 严出:`consume_detail_ids: [detail_id,…]` | +| 审批副作用 | 采购/销售审批通过后根据商户设置触发 Celery 入/出库任务;付款/收款审批通过将同步写入余额表。 | + +错误响应统一为 `{"error": "代码", "message": "描述"}`,字段可能因场景扩展(如 `record_id`、`fields` 等)。 + +--- + +## 2. 采购单(PurchaseOrder) + +| API | 方法 | 描述 | +|-----|------|------| +| `/purchase-orders/` | GET | 分页列表。 | +| `/purchase-orders/` | POST | 创建采购单。 | +| `/purchase-orders//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//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//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//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//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//balance/` | GET | 查询指定客户应收余额。 | + +响应示例: + +```json +{ + "customer": 6, + "customer_name": "杭州零售商", + "balance": "3200.00" +} +``` + +- 正数表示客户仍欠款;负数表示已收超额。 +- 若客户无记录返回 `"0"`。 + +### 6.2 供应商余额 + +目前仅内部使用(审批写入),如需对外查询可在此基础上新增 `/suppliers//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`:资金类单据与余额表写入逻辑。 + +本汇总会随着业务扩展同步更新,如需新增接口请补充到此文件以保持前端“单文档”体验。*** + diff --git a/docs/payment_receipt_workflow.md b/docs/payment_receipt_workflow.md index fb1f2b0..67339de 100644 --- a/docs/payment_receipt_workflow.md +++ b/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. 创建流程 | 字段 | 付款单 | 收款单 | diff --git a/docs/purchase_order_approval_and_red_flush.md b/docs/purchase_order_approval_and_red_flush.md index 8a9dcc5..5dfa21e 100644 --- a/docs/purchase_order_approval_and_red_flush.md +++ b/docs/purchase_order_approval_and_red_flush.md @@ -4,7 +4,7 @@ - 采购单在创建时默认进入 `PENDING`(审批中)状态,不再立即创建出入库记录。 - `business.services.review_purchase_order` 统一处理审批通过 (`APPROVED`) 与作废 (`CANCELLED`) 的业务规则。 -- `api_v1/business/purchase/views.py` 暴露 `POST /api/v1/purchase-orders//review/` 接口作为唯一入口,便于前端、自动化流程和第三方系统统一调用。 +- `api_v1/views/business/purchase/views.py` 暴露 `POST /api/v1/purchase-orders//review/` 接口作为唯一入口,便于前端、自动化流程和第三方系统统一调用。 ## 2. 审批流程总览 diff --git a/docs/sales_order_approval_and_red_flush.md b/docs/sales_order_approval_and_red_flush.md index 29e9a96..bf6c861 100644 --- a/docs/sales_order_approval_and_red_flush.md +++ b/docs/sales_order_approval_and_red_flush.md @@ -4,7 +4,7 @@ - 销售单在创建时同样进入 `PENDING` 状态,审批通过后才会触发出库流程。 - `business.services.review_sales_order` 统一处理审批通过 (`APPROVED`) 与作废 (`CANCELLED`) 的业务规则。 -- `api_v1/business/sales/views.py` 暴露 `POST /api/v1/sales-orders//review/` 接口,供前端、自动化和第三方系统一致调用。 +- `api_v1/views/business/sales/views.py` 暴露 `POST /api/v1/sales-orders//review/` 接口,供前端、自动化和第三方系统一致调用。 ## 2. 审批流程总览