1
0
forked from erp-dev/erp

feat: added filter param for stock record list api. Support purchase order bind stock change record that is already exists

This commit is contained in:
2026-02-03 18:48:44 +08:00
parent b2d252875e
commit f2243a02f4
9 changed files with 379 additions and 2 deletions

View File

@@ -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')

View File

@@ -80,6 +80,11 @@ urlpatterns = [
path('purchase-orders/', purchase_views.PurchaseOrderView.as_view(), name='purchase_orders'),
path('purchase-orders/<int:pk>/', purchase_views.PurchaseOrderDetailView.as_view(), name='purchase_order_detail'),
path('purchase-orders/<int:pk>/review/', purchase_views.PurchaseOrderReviewView.as_view(), name='purchase_order_review'),
path(
'purchase-orders/<int:pk>/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/<int:pk>/', purchase_return_views.PurchaseReturnOrderDetailView.as_view(), name='purchase_return_order_detail'),
path('purchase-return-orders/<int:pk>/review/', purchase_return_views.PurchaseReturnOrderReviewView.as_view(), name='purchase_return_order_review'),

View File

@@ -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,
)

View File

@@ -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),可选
@@ -107,6 +113,31 @@ class ListStockChangesView(StockChangeViewMixin, views.APIView):
'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:
try:

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -48,6 +48,9 @@
- `created_at_from`datetime 或 date
- `created_at_to`datetime 或 date
---
---
# 预销售单 PreSalesOrder

View File

@@ -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` 可选,用于约束同仓库