1
0
forked from erp-dev/erp

fix: added some fields to business.statements api

This commit is contained in:
2025-12-12 17:06:17 +08:00
parent 9e1f994416
commit 4c9b8e6a42
8 changed files with 351 additions and 5 deletions

View File

@@ -1283,9 +1283,13 @@ class StatementRecordAPITestCase(TestCase):
sales_order=self.sales_order,
product=self.product,
price=Decimal('12.50'),
color='',
quantity=Decimal('5'),
unit='',
empty_diff_percent=Decimal('0'),
quantity_of_rolls='2,3',
num_of_rolls=2,
spec='32S',
)
self.purchase_order = business_models.PurchaseOrder.objects.create(
@@ -1300,9 +1304,13 @@ class StatementRecordAPITestCase(TestCase):
purchase_order=self.purchase_order,
product=self.product,
price=Decimal('8.30'),
color='',
quantity=Decimal('10'),
unit='',
empty_diff_percent=Decimal('0'),
quantity_of_rolls='6,4',
num_of_rolls=2,
spec='40S',
)
def test_get_customer_sales_order_record(self):
@@ -1323,6 +1331,14 @@ class StatementRecordAPITestCase(TestCase):
self.assertEqual(record['source_type'], 'sales_order')
self.assertEqual(record['source_id'], self.sales_order.id)
self.assertEqual(record['counterparty'], self.customer.id)
self.assertIsNotNone(record.get('warehouse'))
self.assertEqual(record['warehouse']['id'], self.warehouse.id)
self.assertEqual(record['warehouse']['name'], self.warehouse.name)
self.assertTrue(record['items'])
self.assertEqual(record['items'][0]['color'], '')
self.assertEqual(record['items'][0]['spec'], '32S')
self.assertEqual(record['items'][0]['quantity_of_rolls'], [2, 3])
self.assertEqual(record['items'][0]['num_of_rolls'], 2)
def test_get_supplier_purchase_order_record(self):
response = self.client.get(
@@ -1339,6 +1355,14 @@ class StatementRecordAPITestCase(TestCase):
self.assertEqual(record['source_type'], 'purchase_order')
self.assertEqual(record['source_id'], self.purchase_order.id)
self.assertEqual(record['counterparty'], self.supplier.id)
self.assertIsNotNone(record.get('warehouse'))
self.assertEqual(record['warehouse']['id'], self.warehouse.id)
self.assertEqual(record['warehouse']['name'], self.warehouse.name)
self.assertTrue(record['items'])
self.assertEqual(record['items'][0]['color'], '')
self.assertEqual(record['items'][0]['spec'], '40S')
self.assertEqual(record['items'][0]['quantity_of_rolls'], [6, 4])
self.assertEqual(record['items'][0]['num_of_rolls'], 2)
def test_statement_record_not_found(self):
response = self.client.get(

View File

@@ -1,8 +1,20 @@
from rest_framework import serializers
from basic_info import models as basic_models
from business import services as business_services
class StatementWarehouseSerializer(serializers.ModelSerializer):
"""对账单中使用的仓库序列化器(输出完整仓库数据)"""
type_label = serializers.CharField(source='get_type_display', read_only=True)
mode_label = serializers.CharField(source='get_mode_display', read_only=True)
class Meta:
model = basic_models.WareHouse
fields = '__all__'
class StatementRecordSerializer(serializers.Serializer):
source_type = serializers.CharField()
source_label = serializers.CharField()
@@ -13,6 +25,7 @@ class StatementRecordSerializer(serializers.Serializer):
status_label = serializers.CharField()
counterparty = serializers.IntegerField()
counterparty_name = serializers.CharField()
warehouse = StatementWarehouseSerializer(allow_null=True)
positive_amount = serializers.DecimalField(max_digits=15, decimal_places=2)
negative_amount = serializers.DecimalField(max_digits=15, decimal_places=2)
cumulative_amount = serializers.CharField()

View File

@@ -1869,6 +1869,7 @@ class _StatementBuilder:
recorded_at,
status: int,
status_label: str,
warehouse: basic_info_models.WareHouse | None = None,
positive_amount,
negative_amount,
items: List[dict] | None = None,
@@ -1885,6 +1886,8 @@ class _StatementBuilder:
'recorded_at': recorded_at,
'status': status,
'status_label': status_label,
# 注意:收款单/付款单等资金类单据无仓库概念warehouse 将为 None
'warehouse': warehouse,
'positive_amount': _normalize_statement_amount(positive_amount),
'negative_amount': _normalize_statement_amount(negative_amount),
'items': items,
@@ -1901,7 +1904,11 @@ class _StatementBuilder:
product_name = getattr(product, 'name', '')
unit = getattr(item, 'unit', '')
price = getattr(item, 'price', Decimal('0'))
key = (product_id, product_name, unit, price)
# 这些字段均来自订单明细(*OrderItem用于在对账单中对 items 进行合理聚合:
# - 同一产品不同颜色/规格应拆分展示,避免合并后信息丢失
color = getattr(item, 'color', None) or ''
spec = getattr(item, 'spec', None) or ''
key = (product_id, product_name, unit, price, color, spec)
if key not in aggregated:
aggregated[key] = {
'product_id': product_id,
@@ -1909,9 +1916,36 @@ class _StatementBuilder:
'quantity': Decimal('0'),
'price': price,
'unit': unit,
'color': color,
'spec': spec,
# 统一输出各条数数量(严格模式下为每条数量列表;非严格模式/未填则为空列表)
# 来源于各 *OrderItem 模型新增的 split_quantity_of_rolls() 方法
'quantity_of_rolls': [],
# 条数:来自各 *OrderItem.num_of_rolls。聚合时对相同产品/单价的多行明细累加。
'num_of_rolls': 0,
}
quantity_value = getattr(item, 'quantity', 0) or 0
aggregated[key]['quantity'] += Decimal(str(quantity_value))
num_of_rolls_value = getattr(item, 'num_of_rolls', None)
try:
num_of_rolls_int = int(num_of_rolls_value) if num_of_rolls_value is not None else 0
except (TypeError, ValueError): # pragma: no cover - defensive
num_of_rolls_int = 0
split_quantity_of_rolls = getattr(item, 'split_quantity_of_rolls', None)
if callable(split_quantity_of_rolls):
try:
rolls = split_quantity_of_rolls() or []
except Exception: # pragma: no cover - defensive
rolls = []
if rolls:
aggregated[key]['quantity_of_rolls'].extend(list(rolls))
# 若无 num_of_rolls或为 0兜底使用 rolls 长度
if not num_of_rolls_int:
num_of_rolls_int = len(rolls)
aggregated[key]['num_of_rolls'] += num_of_rolls_int
return list(aggregated.values())
def _sort_records(self, records: Iterable[dict]) -> List[dict]:
@@ -1951,7 +1985,7 @@ class _CustomerStatementBuilder(_StatementBuilder):
customer=customer,
status=models.SalesOrderStatusEnum.APPROVED,
)
.select_related('customer')
.select_related('customer', 'warehouse')
.prefetch_related('items__product')
)
records = []
@@ -1968,6 +2002,7 @@ class _CustomerStatementBuilder(_StatementBuilder):
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
warehouse=order.warehouse,
positive_amount=order.get_total_amount(),
negative_amount=_STATEMENT_ZERO,
items=items,
@@ -1982,7 +2017,7 @@ class _CustomerStatementBuilder(_StatementBuilder):
customer=customer,
status=models.SalesReturnStatusEnum.APPROVED,
)
.select_related('customer')
.select_related('customer', 'warehouse')
.prefetch_related('items__product')
)
records = []
@@ -1999,6 +2034,7 @@ class _CustomerStatementBuilder(_StatementBuilder):
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
warehouse=order.warehouse,
positive_amount=_STATEMENT_ZERO,
negative_amount=order.get_total_amount(),
items=items,
@@ -2050,7 +2086,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
supplier=supplier,
status=models.PurchaseOrderStatusEnum.APPROVED,
)
.select_related('supplier')
.select_related('supplier', 'warehouse')
.prefetch_related('items__product')
)
records = []
@@ -2067,6 +2103,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
warehouse=order.warehouse,
positive_amount=order.get_total_amount(),
negative_amount=_STATEMENT_ZERO,
items=items,
@@ -2081,7 +2118,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
supplier=supplier,
status=models.PurchaseReturnStatusEnum.APPROVED,
)
.select_related('supplier')
.select_related('supplier', 'warehouse')
.prefetch_related('items__product')
)
records = []
@@ -2098,6 +2135,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
warehouse=order.warehouse,
positive_amount=_STATEMENT_ZERO,
negative_amount=order.get_total_amount(),
items=items,

Binary file not shown.

Binary file not shown.

View File

@@ -60,3 +60,4 @@ PlateOrder 模型当前 **没有 `work_state` 字段**。主要字段如下(
由于 PlateOrder 未包含 `work_state`,前端无需提交该字段;若后续业务需要,可在模型与序列化器中新增字段并同步更新本文档。

View File

@@ -0,0 +1,231 @@
"""
BusinessObject CRUD + 过滤器 API 测试
现有测试主要覆盖 BusinessObject 的 actionadvance/reset/timeline/state-logs/...
这里补齐 list/retrieve/create/update/delete 以及 FilterSet 的关键分支。
"""
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from rest_framework import status
from rest_framework.test import APIClient
from stateflow import models, services
User = get_user_model()
class BusinessObjectCRUDAndFilterAPITestCase(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user(username="testuser", password="testpass")
self.client.force_authenticate(user=self.user)
# 通用状态/流程3个节点
self.state1 = models.State.objects.create(name="状态1")
self.state2 = models.State.objects.create(name="状态2")
self.state3 = models.State.objects.create(name="状态3")
self.process_a = models.Process.objects.create(name="流程A", description="用于测试过滤器A")
models.ProcessNode.objects.create(process=self.process_a, state=self.state1, order=0)
models.ProcessNode.objects.create(process=self.process_a, state=self.state2, order=1)
models.ProcessNode.objects.create(process=self.process_a, state=self.state3, order=2)
self.process_b = models.Process.objects.create(name="流程B", description="用于测试过滤器B")
models.ProcessNode.objects.create(process=self.process_b, state=self.state1, order=0)
def test_business_object_crud(self):
"""覆盖 create/list/retrieve/patch/delete 的基本 happy path"""
# create注意create serializer 不包含 id需要从 DB 获取)
create_payload = {
"name": "BO-CRUD-1",
"process": self.process_a.id,
"description": "测试 CRUD",
}
resp = self.client.post("/api/v1/stateflow/business-objects/", create_payload, format="json")
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
bo = models.BusinessObject.objects.get(name="BO-CRUD-1")
self.assertEqual(bo.process_id, self.process_a.id)
# list
resp = self.client.get("/api/v1/stateflow/business-objects/?limit=10&offset=0")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertGreaterEqual(resp.data["count"], 1)
# list 返回字段校验(挑关键字段)
results = resp.data["results"]
item = next(x for x in results if x["id"] == bo.id)
self.assertEqual(item["name"], "BO-CRUD-1")
self.assertEqual(item["process"], self.process_a.id)
self.assertEqual(item["process_name"], "流程A")
self.assertIn("current_state_name", item)
self.assertIn("overall_status", item)
self.assertIn("progress_percentage", item)
# retrieve详情含 timeline/state_logs 等)
resp = self.client.get(f"/api/v1/stateflow/business-objects/{bo.id}/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["id"], bo.id)
self.assertEqual(resp.data["name"], "BO-CRUD-1")
self.assertIn("process_detail", resp.data)
self.assertIn("timeline", resp.data)
self.assertIn("state_logs", resp.data)
self.assertEqual(len(resp.data["timeline"]), 3)
# patch 更新 name
resp = self.client.patch(
f"/api/v1/stateflow/business-objects/{bo.id}/",
{"name": "BO-CRUD-1-RENAMED"},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
bo.refresh_from_db()
self.assertEqual(bo.name, "BO-CRUD-1-RENAMED")
# delete
resp = self.client.delete(f"/api/v1/stateflow/business-objects/{bo.id}/")
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(models.BusinessObject.objects.filter(id=bo.id).exists())
def test_create_business_object_with_content_type_str(self):
"""覆盖 create serializer 的 content_type_str 解析与一致性校验"""
# 正确:提供 content_type_str + object_id
resp = self.client.post(
"/api/v1/stateflow/business-objects/",
{
"name": "BO-Linked",
"process": self.process_a.id,
"content_type_str": "stateflow.process",
"object_id": self.process_a.id,
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
bo = models.BusinessObject.objects.get(name="BO-Linked")
self.assertIsNotNone(bo.content_type)
self.assertEqual(bo.content_type.app_label, "stateflow")
self.assertEqual(bo.content_type.model, "process")
self.assertEqual(bo.object_id, self.process_a.id)
# 错误content_type_str 格式不合法
resp = self.client.post(
"/api/v1/stateflow/business-objects/",
{
"name": "BO-Bad-CT",
"process": self.process_a.id,
"content_type_str": "badformat",
"object_id": self.process_a.id,
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("content_type_str", resp.data)
# 错误:提供 content_type_str 但缺 object_id
resp = self.client.post(
"/api/v1/stateflow/business-objects/",
{
"name": "BO-Missing-ObjectId",
"process": self.process_a.id,
"content_type_str": "stateflow.process",
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("object_id", resp.data)
def test_business_object_filters(self):
"""覆盖 BusinessObjectFilterSet 的关键过滤条件"""
bo_in_progress = models.BusinessObject.objects.create(
name="BO-InProgress-Alpha",
process=self.process_a,
description="alpha desc",
)
bo_completed = models.BusinessObject.objects.create(
name="BO-Completed-Beta",
process=self.process_a,
description="beta desc",
)
bo_other_process = models.BusinessObject.objects.create(
name="BO-OtherProcess-Gamma",
process=self.process_b,
description="gamma desc",
)
# 让 bo_completed 完成(该流程无必填参数,直接推进即可)
services.advance_to_next_state(bo_completed, self.user)
services.advance_to_next_state(bo_completed, self.user)
services.advance_to_next_state(bo_completed, self.user)
self.assertEqual(services.get_overall_status(bo_completed), "completed")
# name contains
resp = self.client.get("/api/v1/stateflow/business-objects/?name=Alpha&limit=10&offset=0")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["count"], 1)
self.assertEqual(resp.data["results"][0]["id"], bo_in_progress.id)
# process id
resp = self.client.get(f"/api/v1/stateflow/business-objects/?process={self.process_b.id}&limit=10&offset=0")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["count"], 1)
self.assertEqual(resp.data["results"][0]["id"], bo_other_process.id)
# process_name contains
resp = self.client.get("/api/v1/stateflow/business-objects/?process_name=流程A&limit=10&offset=0")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["count"], 2)
returned_ids = {x["id"] for x in resp.data["results"]}
self.assertSetEqual(returned_ids, {bo_in_progress.id, bo_completed.id})
# overall_status=completed
resp = self.client.get("/api/v1/stateflow/business-objects/?overall_status=completed&limit=10&offset=0")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["count"], 1)
self.assertEqual(resp.data["results"][0]["id"], bo_completed.id)
# overall_status=in_progress
resp = self.client.get("/api/v1/stateflow/business-objects/?overall_status=in_progress&limit=10&offset=0")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
returned_ids = {x["id"] for x in resp.data["results"]}
self.assertSetEqual(returned_ids, {bo_in_progress.id, bo_other_process.id})
# content_type_str + has_content_object
ct = ContentType.objects.get_for_model(models.Process)
bo_linked = models.BusinessObject.objects.create(
name="BO-Linked-Process",
process=self.process_a,
content_type=ct,
object_id=self.process_a.id,
)
resp = self.client.get("/api/v1/stateflow/business-objects/?has_content_object=true&limit=10&offset=0")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
returned_ids = {x["id"] for x in resp.data["results"]}
self.assertIn(bo_linked.id, returned_ids)
resp = self.client.get("/api/v1/stateflow/business-objects/?has_content_object=false&limit=10&offset=0")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
returned_ids = {x["id"] for x in resp.data["results"]}
self.assertNotIn(bo_linked.id, returned_ids)
resp = self.client.get("/api/v1/stateflow/business-objects/?content_type_str=stateflow.process&limit=10&offset=0")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
returned_ids = {x["id"] for x in resp.data["results"]}
self.assertSetEqual(returned_ids, {bo_linked.id})
# 无效 content_type_str返回空
resp = self.client.get("/api/v1/stateflow/business-objects/?content_type_str=badformat&limit=10&offset=0")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["count"], 0)
class AuthenticationGuardAPITestCase(TestCase):
def test_stateflow_endpoints_require_authentication(self):
"""默认权限 IsAuthenticated未登录应 401"""
client = APIClient()
resp = client.get("/api/v1/stateflow/states/?limit=1&offset=0")
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)

View File

@@ -0,0 +1,39 @@
"""
补充 Process 列表接口的 node_count 字段覆盖
ProcessViewSet.list() 会对 queryset annotate(node_count),对应 ProcessListSerializer 的只读字段。
"""
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework import status
from rest_framework.test import APIClient
from stateflow import models
User = get_user_model()
class ProcessListNodeCountAPITestCase(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user(username="testuser", password="testpass")
self.client.force_authenticate(user=self.user)
self.state1 = models.State.objects.create(name="状态1")
self.state2 = models.State.objects.create(name="状态2")
def test_process_list_contains_node_count(self):
process = models.Process.objects.create(name="带节点计数的流程")
models.ProcessNode.objects.create(process=process, state=self.state1, order=0)
models.ProcessNode.objects.create(process=process, state=self.state2, order=1)
resp = self.client.get("/api/v1/stateflow/processes/?limit=10&offset=0")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["count"], 1)
item = resp.data["results"][0]
self.assertIn("node_count", item)
self.assertEqual(item["node_count"], 2)