diff --git a/api_v1/tests.py b/api_v1/tests.py index 243e00e..e4211a6 100644 --- a/api_v1/tests.py +++ b/api_v1/tests.py @@ -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( diff --git a/api_v1/views/business/statements/serializers.py b/api_v1/views/business/statements/serializers.py index 0029d7d..59fab22 100644 --- a/api_v1/views/business/statements/serializers.py +++ b/api_v1/views/business/statements/serializers.py @@ -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() diff --git a/business/services.py b/business/services.py index d9669d0..f3c24da 100644 --- a/business/services.py +++ b/business/services.py @@ -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, diff --git a/celerybeat-schedule-shm b/celerybeat-schedule-shm index db8ea77..dbac8c1 100644 Binary files a/celerybeat-schedule-shm and b/celerybeat-schedule-shm differ diff --git a/celerybeat-schedule-wal b/celerybeat-schedule-wal index 525ecff..a8fb542 100644 Binary files a/celerybeat-schedule-wal and b/celerybeat-schedule-wal differ diff --git a/docs/printing_fields_20251212.md b/docs/printing_fields_20251212.md index 458ed47..a2a0c8a 100644 --- a/docs/printing_fields_20251212.md +++ b/docs/printing_fields_20251212.md @@ -60,3 +60,4 @@ PlateOrder 模型当前 **没有 `work_state` 字段**。主要字段如下( 由于 PlateOrder 未包含 `work_state`,前端无需提交该字段;若后续业务需要,可在模型与序列化器中新增字段并同步更新本文档。 + diff --git a/stateflow/tests/test_business_object_crud_and_filters_api.py b/stateflow/tests/test_business_object_crud_and_filters_api.py new file mode 100644 index 0000000..4eff67b --- /dev/null +++ b/stateflow/tests/test_business_object_crud_and_filters_api.py @@ -0,0 +1,231 @@ +""" +BusinessObject CRUD + 过滤器 API 测试 + +现有测试主要覆盖 BusinessObject 的 action(advance/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) + + diff --git a/stateflow/tests/test_process_list_node_count_api.py b/stateflow/tests/test_process_list_node_count_api.py new file mode 100644 index 0000000..a21f48e --- /dev/null +++ b/stateflow/tests/test_process_list_node_count_api.py @@ -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) + +