From f2243a02f4d8985a1175f9a1587897de4631036a Mon Sep 17 00:00:00 2001 From: colaftc Date: Tue, 3 Feb 2026 18:48:44 +0800 Subject: [PATCH] feat: added filter param for stock record list api. Support purchase order bind stock change record that is already exists --- api_v1/tests.py | 26 ++++++ api_v1/urls.py | 5 ++ api_v1/views/business/purchase/views.py | 44 ++++++++++ api_v1/views/stock_change_views/list.py | 31 +++++++ .../test_stock_change_api.py | 87 ++++++++++++++++++- business/services.py | 61 ++++++++++++- business/tests/test_purchase_order.py | 58 +++++++++++++ docs/api_v1_pre_order_api.md | 3 + ...api_v1_purchase_order_bind_stock_change.md | 66 ++++++++++++++ 9 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 docs/api_v1_purchase_order_bind_stock_change.md diff --git a/api_v1/tests.py b/api_v1/tests.py index e6ea098..b93b7ed 100644 --- a/api_v1/tests.py +++ b/api_v1/tests.py @@ -17,6 +17,8 @@ from basic_info.models import ( EmployeeStatusEnum, Merchant, MerchantTypeEnum, + MerchantSetting, + MerchantSettingKeyEnum, Product, ProductCategory, ProductUnitEnum, @@ -228,6 +230,30 @@ class PurchaseOrderAPITestCase(TestCase): self.assertEqual(response.data['human_id'], expected_human_id) mock_delay.assert_not_called() + def test_bind_purchase_order_stock_change_record(self): + MerchantSetting.objects.filter( + merchant=self.merchant, + key=MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, + ).update(val_bool=False) + + order_id = self._create_purchase_order(self.strict_payload) + record = stock_models.StockChangeRecord.objects.create( + merchant=self.merchant, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse=self.warehouse_strict, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + created_by=self.user, + ) + + resp = self.client.post( + f'/api/v1/purchase-orders/{order_id}/bind-stock-change/', + {'stock_change_record_id': record.id}, + format='json', + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + record.refresh_from_db() + self.assertEqual(record.source_id, order_id) + def test_create_purchase_order_invalid_supplier(self): payload = {**self.strict_payload, 'supplier': 999} response = self.client.post('/api/v1/purchase-orders/', payload, format='json') diff --git a/api_v1/urls.py b/api_v1/urls.py index a45dfe1..30b3bb4 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -80,6 +80,11 @@ urlpatterns = [ path('purchase-orders/', purchase_views.PurchaseOrderView.as_view(), name='purchase_orders'), path('purchase-orders//', purchase_views.PurchaseOrderDetailView.as_view(), name='purchase_order_detail'), path('purchase-orders//review/', purchase_views.PurchaseOrderReviewView.as_view(), name='purchase_order_review'), + path( + 'purchase-orders//bind-stock-change/', + purchase_views.PurchaseOrderBindStockChangeView.as_view(), + name='purchase_order_bind_stock_change', + ), path('purchase-return-orders/', purchase_return_views.PurchaseReturnOrderView.as_view(), name='purchase_return_orders'), path('purchase-return-orders//', purchase_return_views.PurchaseReturnOrderDetailView.as_view(), name='purchase_return_order_detail'), path('purchase-return-orders//review/', purchase_return_views.PurchaseReturnOrderReviewView.as_view(), name='purchase_return_order_review'), diff --git a/api_v1/views/business/purchase/views.py b/api_v1/views/business/purchase/views.py index 08bf93b..5d665b8 100644 --- a/api_v1/views/business/purchase/views.py +++ b/api_v1/views/business/purchase/views.py @@ -253,3 +253,47 @@ class PurchaseOrderReviewView(StockChangeViewMixin, views.APIView): ).prefetch_related('items').get(id=purchase_order.id) return Response(PurchaseOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) + +class PurchaseOrderBindStockChangeSerializer(serializers.Serializer): + stock_change_record_id = serializers.IntegerField(min_value=1) + + +class PurchaseOrderBindStockChangeView(StockChangeViewMixin, views.APIView): + """采购单绑定库存记录""" + + permission_classes = [IsAuthenticated] + + def post(self, request, pk: int): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + try: + purchase_order = business_models.PurchaseOrder.objects.select_related( + 'supplier', 'operator', 'warehouse' + ).get(id=pk, merchant=merchant) + except business_models.PurchaseOrder.DoesNotExist: + return self.not_found_response('采购单不存在') + + serializer = PurchaseOrderBindStockChangeSerializer(data=request.data or {}) + serializer.is_valid(raise_exception=True) + stock_change_record_id = serializer.validated_data['stock_change_record_id'] + + try: + record = business_services.bind_purchase_order_stock_change_record( + purchase_order_id=purchase_order.id, + stock_change_record_id=stock_change_record_id, + operator=request.user, + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + return Response( + { + 'purchase_order_id': purchase_order.id, + 'stock_change_record_id': record.id, + 'message': '采购单已绑定库存记录', + }, + status=status.HTTP_200_OK, + ) + diff --git a/api_v1/views/stock_change_views/list.py b/api_v1/views/stock_change_views/list.py index 728cc7b..188c2a1 100644 --- a/api_v1/views/stock_change_views/list.py +++ b/api_v1/views/stock_change_views/list.py @@ -28,6 +28,9 @@ class ListStockChangesView(StockChangeViewMixin, views.APIView): OpenApiParameter(name='start', type=OpenApiTypes.DATE, description='开始日期 (YYYY-MM-DD),默认为昨天'), OpenApiParameter(name='end', type=OpenApiTypes.DATE, description='结束日期 (YYYY-MM-DD),默认为昨天'), OpenApiParameter(name='type', type=OpenApiTypes.INT, description='变动类型 (1=入库, 2=出库)'), + OpenApiParameter(name='source_type', type=OpenApiTypes.INT, description='来源类型 (如采购=1)'), + OpenApiParameter(name='source_id', type=OpenApiTypes.INT, description='来源业务单据ID'), + OpenApiParameter(name='source_id_isnull', type=OpenApiTypes.BOOL, description='来源业务单据ID是否为空'), OpenApiParameter(name='warehouse', type=OpenApiTypes.INT, description='仓库ID'), OpenApiParameter(name='product', type=OpenApiTypes.INT, description='产品ID'), OpenApiParameter(name='is_finished', type=OpenApiTypes.BOOL, description='是否已完成'), @@ -45,6 +48,9 @@ class ListStockChangesView(StockChangeViewMixin, views.APIView): - start: 开始日期 (YYYY-MM-DD),可选,默认为昨天 - end: 结束日期 (YYYY-MM-DD),可选,默认为昨天 - type: 变动类型 (1=入库, 2=出库),可选 + - source_type: 来源类型(可选) + - source_id: 来源业务单据ID(可选) + - source_id_isnull: 来源业务单据ID是否为空(可选,true/false) - warehouse: 仓库ID,可选 - product: 产品ID,可选 - is_finished: 是否已完成 (true/false),可选 @@ -106,6 +112,31 @@ class ListStockChangesView(StockChangeViewMixin, views.APIView): return Response({ 'error': 'type 参数必须为整数' }, status=status.HTTP_400_BAD_REQUEST) + + source_type = request.GET.get('source_type') + if source_type: + try: + queryset = queryset.filter(source_type=int(source_type)) + except ValueError: + return Response({ + 'error': 'source_type 参数必须为整数' + }, status=status.HTTP_400_BAD_REQUEST) + + source_id = request.GET.get('source_id') + if source_id: + try: + queryset = queryset.filter(source_id=int(source_id)) + except ValueError: + return Response({ + 'error': 'source_id 参数必须为整数' + }, status=status.HTTP_400_BAD_REQUEST) + else: + source_id_isnull = request.GET.get('source_id_isnull') + if source_id_isnull is not None: + if source_id_isnull.lower() == 'true': + queryset = queryset.filter(source_id__isnull=True) + elif source_id_isnull.lower() == 'false': + queryset = queryset.filter(source_id__isnull=False) warehouse_id = request.GET.get('warehouse') if warehouse_id: diff --git a/api_v1/views/stock_change_views/test_stock_change_api.py b/api_v1/views/stock_change_views/test_stock_change_api.py index 3b84016..4c627e3 100644 --- a/api_v1/views/stock_change_views/test_stock_change_api.py +++ b/api_v1/views/stock_change_views/test_stock_change_api.py @@ -1,4 +1,5 @@ from decimal import Decimal +import datetime import logging import unittest @@ -541,7 +542,7 @@ class ListStockChangeDetailsAPITestCase(TestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('缺少必要参数', response.data['error']) - self.assertIn('warehouse_id', response.data['message']) + def test_list_stock_change_details_missing_product_id(self): """测试缺少product_id参数""" @@ -665,6 +666,90 @@ class ListStockChangeDetailsAPITestCase(TestCase): self.assertEqual(result['record_type'], stock_models.StockChangeTypeEnum.REMOVE) self.assertEqual(result['record_type_display'], '出库') + +class ListStockChangeRecordsAPITestCase(TestCase): + """测试库存变动记录查询 API 的新增筛选参数""" + + def setUp(self): + self.client = APIClient() + + self.merchant = basic_models.Merchant.objects.create( + name='记录商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + self.category = basic_models.ProductCategory.objects.create( + merchant=self.merchant, + name='布料', + product_prefix='FAB', + ) + self.product = basic_models.Product.objects.create( + merchant=self.merchant, + category=self.category, + name='测试布料', + human_id='FAB-100', + unit=basic_models.ProductUnitEnum.METER, + ) + self.warehouse = basic_models.WareHouse.objects.create( + merchant=self.merchant, + name='记录仓库', + mode=basic_models.WareHouseModeEnum.RESTRICT_IN, + ) + self.user = User.objects.create_user(username='record_user', password='pass123') + self.employee = basic_models.Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='仓管员', + mobile='13800138009', + status=basic_models.EmployeeStatusEnum.ACTIVE, + ) + self.client.force_authenticate(user=self.user) + + self.record_unbound = stock_models.StockChangeRecord.objects.create( + merchant=self.merchant, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse=self.warehouse, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + source_id=None, + created_by=self.user, + ) + stock_models.StockChangeDetail.objects.create( + merchant=self.merchant, + stock_change_record=self.record_unbound, + product=self.product, + quantity='10', + unit=self.product.unit, + ) + + self.record_bound = stock_models.StockChangeRecord.objects.create( + merchant=self.merchant, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse=self.warehouse, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + source_id=99, + created_by=self.user, + ) + stock_models.StockChangeDetail.objects.create( + merchant=self.merchant, + stock_change_record=self.record_bound, + product=self.product, + quantity='5', + unit=self.product.unit, + ) + + def test_list_stock_change_records_filter_source_type_and_source_id_isnull(self): + today = datetime.date.today().strftime('%Y-%m-%d') + response = self.client.get( + f'/api/v1/stock-changes/?start={today}&end={today}' + f'&type={stock_models.StockChangeTypeEnum.ADD}' + f'&source_type={stock_models.StockChangeSourceEnum.PURCHASE}' + f'&source_id_isnull=true' + f'&warehouse={self.warehouse.id}' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + result_ids = [item['id'] for item in response.data['results']] + self.assertIn(self.record_unbound.id, result_ids) + self.assertNotIn(self.record_bound.id, result_ids) + def test_list_stock_change_details_with_invalid_direction(self): """测试无效的direction参数""" response = self.client.get( diff --git a/business/services.py b/business/services.py index 0f8be72..d0ee3d4 100644 --- a/business/services.py +++ b/business/services.py @@ -1220,7 +1220,7 @@ def _approve_purchase_order( created_by_id = getattr(reviewed_by, 'id', None) if _auto_stock_task_enabled(locked_order.merchant): logger.info('审批通过采购单 %s,触发入库任务', locked_order.id) - create_purchase_order_stock_entries.delay( + create_purchase_order_stock_entries.delay( purchase_order_id=locked_order.id, warehouse_id=locked_order.warehouse_id, items=stock_flow_items, @@ -1659,6 +1659,65 @@ def _build_stock_flow_items_from_order(order) -> List[Dict[str, Any]]: return items_payload +def can_bind_source_id(record: stock_models.StockChangeRecord) -> bool: + """ + 判断库存记录是否允许绑定 source_id。 + 目前默认允许,后续可按业务需要扩展。 + """ + return True + + +def bind_purchase_order_stock_change_record( + *, + purchase_order_id: int, + stock_change_record_id: int, + operator=None, +) -> stock_models.StockChangeRecord: + purchase_order = models.PurchaseOrder.objects.select_related( + 'merchant', 'warehouse' + ).get(id=purchase_order_id) + + if _auto_stock_task_enabled(purchase_order.merchant): + raise ValueError('自动入库任务已开启,无法手动绑定库存记录') + + 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(): + record = stock_models.StockChangeRecord.objects.select_for_update().get( + id=stock_change_record_id + ) + + if record.source_id is not None: + raise ValueError('库存记录已绑定业务单据') + if record.merchant_id != purchase_order.merchant_id: + raise ValueError('库存记录不属于当前商户') + if record.warehouse_id != purchase_order.warehouse_id: + raise ValueError('库存记录仓库不匹配') + if record.type != stock_models.StockChangeTypeEnum.ADD: + raise ValueError('仅允许绑定入库类型库存记录') + if record.source_type != stock_models.StockChangeSourceEnum.PURCHASE: + raise ValueError('仅允许绑定采购来源的库存记录') + if not can_bind_source_id(record): + raise ValueError('库存记录当前状态不允许绑定') + + record.source_id = purchase_order.id + record.save(update_fields=['source_id', 'updated_at']) + + operator_id = getattr(operator, 'id', None) + logger.info( + '采购单 %s 绑定库存记录 %s, operator=%s', + purchase_order.id, + record.id, + operator_id, + ) + return record + + def _auto_stock_task_enabled(merchant: basic_info_models.Merchant) -> bool: try: setting = MerchantSettingService.get_setting( diff --git a/business/tests/test_purchase_order.py b/business/tests/test_purchase_order.py index 78716ba..5d40a2d 100644 --- a/business/tests/test_purchase_order.py +++ b/business/tests/test_purchase_order.py @@ -7,6 +7,7 @@ from django.utils import timezone from unittest.mock import patch from stock import models as stock_models +from basic_info import models as basic_models from business import models as business_models, services from .fixtures import create_basic_fixtures @@ -108,6 +109,63 @@ class PurchaseOrderServiceTestCase(TestCase): self.assertEqual(record.balance_after, balance.balance) self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.INCREASE) + def test_review_purchase_order_approval_no_task_when_auto_disabled(self): + basic_models.MerchantSetting.objects.filter( + merchant=self.merchant, + key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, + ).update(val_bool=False) + + purchase_order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + + with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay: + reviewed = services.review_purchase_order( + purchase_order=purchase_order, + target_status=business_models.PurchaseOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + + self.assertEqual(reviewed.status, business_models.PurchaseOrderStatusEnum.APPROVED) + mock_delay.assert_not_called() + + def test_bind_purchase_order_stock_change_record(self): + basic_models.MerchantSetting.objects.filter( + merchant=self.merchant, + key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, + ).update(val_bool=False) + + purchase_order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + + record = stock_models.StockChangeRecord.objects.create( + merchant=self.merchant, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse=self.warehouse_strict, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + created_by=self.user, + ) + + bound = services.bind_purchase_order_stock_change_record( + purchase_order_id=purchase_order.id, + stock_change_record_id=record.id, + operator=self.user, + ) + self.assertEqual(bound.source_id, purchase_order.id) + def test_create_purchase_order_without_items_raises(self): with self.assertRaises(ValueError): services.create_purchase_order( diff --git a/docs/api_v1_pre_order_api.md b/docs/api_v1_pre_order_api.md index 1e3d962..f6eb700 100644 --- a/docs/api_v1_pre_order_api.md +++ b/docs/api_v1_pre_order_api.md @@ -48,6 +48,9 @@ - `created_at_from`(datetime 或 date) - `created_at_to`(datetime 或 date) +--- + + --- # 预销售单 PreSalesOrder diff --git a/docs/api_v1_purchase_order_bind_stock_change.md b/docs/api_v1_purchase_order_bind_stock_change.md new file mode 100644 index 0000000..93e57f0 --- /dev/null +++ b/docs/api_v1_purchase_order_bind_stock_change.md @@ -0,0 +1,66 @@ +# API v1:采购单绑定库存记录(独立功能) + +本文档描述采购单绑定库存记录的接口与约束,并包含库存记录查询接口新增筛选参数说明。 + +- API 前缀:`/api/v1/` +- 认证:接口均要求登录(`IsAuthenticated`)。 +- 权限:要求当前用户绑定员工(`request.user.employee`),否则返回 `403`。 + +--- + +## 1) 采购单绑定库存记录 + +- `POST /api/v1/purchase-orders/{purchase_order_id}/bind-stock-change/` + +### 请求体参数(JSON) + +- `stock_change_record_id`(必填,int):库存变动记录 ID + +### 成功响应 + +- `200 OK` + +响应字段: + +- `purchase_order_id`:采购单 ID +- `stock_change_record_id`:库存记录 ID +- `message`:绑定结果 + +### 约束与限制 + +- 仅当 `auto_create_stock_change_tasks = False` 才允许绑定 +- 只能绑定 `source_id` 为空的库存记录(不允许覆盖) +- 库存记录必须满足: + - `type = 入库` + - `source_type = 采购` +- 采购单与库存记录必须 **同商户、同仓库** +- 采购单仅允许绑定 1 条库存记录(已绑定则拒绝) +- `is_finished` 不影响绑定行为(已完成也允许) +- 绑定操作会记录服务日志(便于审计追踪) + +--- + +## 2) 库存记录查询(用于绑定前筛选) + +> 该接口支持筛选“可绑定库存记录”所需条件,例如:`source_type=采购` 且 `source_id 为空`。 + +- `GET /api/v1/stock-changes/` + +### 新增查询参数 + +- `source_type`(int,可选):库存来源类型 +- `source_id`(int,可选):来源业务单据 ID +- `source_id_isnull`(bool,可选):是否仅返回 `source_id` 为空的记录(`true/false`) + - 若同时提供 `source_id`,以 `source_id` 为准,忽略 `source_id_isnull` + +### 绑定场景示例 + +查询“可绑定的采购入库记录(source_id 为空)”: + +- `GET /api/v1/stock-changes/?type=1&source_type=1&source_id_isnull=true&warehouse={warehouse_id}` + +说明: +- `type=1` 表示入库 +- `source_type=1` 表示采购 +- `source_id_isnull=true` 表示未绑定业务单据 +- `warehouse` 可选,用于约束同仓库