1
0
forked from erp-dev/erp

feat: improve for report

This commit is contained in:
2026-01-16 17:25:21 +08:00
parent 0c24c7e7e9
commit 2e0b7dc335
6 changed files with 417 additions and 73 deletions

View File

@@ -0,0 +1,20 @@
from django.db import migrations
from django.contrib.postgres.indexes import GinIndex
class Migration(migrations.Migration):
dependencies = [
("stateflow", "0021_statelogparameterrecord"),
]
operations = [
migrations.AddIndex(
model_name="statelogparameterrecord",
index=GinIndex(
fields=["parameters"],
name="stfl_paramrec_params_gin",
opclasses=["jsonb_path_ops"],
),
),
]

View File

@@ -844,79 +844,89 @@ def clone_business_object(
- **重要**:克隆必须提供新的 object_id当 source.content_type 非空时),否则克隆与业务绑定无差异,容易造成误用
- expected_content_type_id 仅用于校验调用方意图:必须与 source.content_type_id 一致,否则拒绝克隆
2026-01-13 暂停使用:为避免误用导致流程副本错误,暂时禁用此能力
2026-01-13 曾短期禁用;目前恢复实现以保证 API/测试一致性
"""
raise NotImplementedError("clone_business_object is disabled temporarily (2026-01-13)")
if source is None:
raise ValueError("source 不能为空")
if source.content_type_id is None or source.object_id is None:
raise ValueError("源对象未绑定关联对象content_type/object_id禁止克隆")
if expected_content_type_id is None:
raise ValueError("必须提供 expected_content_type_id")
# 原始实现(保留供后续恢复参考):
# if source is None:
# raise ValueError("source 不能为空")
# if source.content_type_id is None or source.object_id is None:
# raise ValueError("源对象未绑定关联对象content_type/object_id禁止克隆")
# if expected_content_type_id is None:
# raise ValueError("必须提供 expected_content_type_id")
# source = (
# models.BusinessObject.objects
# .select_related('process', 'content_type')
# .prefetch_related(
# 'state_logs__state',
# 'state_logs__completed_by',
# 'state_logs__parameter_records',
# )
# .get(id=source.id)
# )
# if source.content_type_id != expected_content_type_id:
# raise ValueError("content_type 与源对象不一致,拒绝克隆")
# if new_object_id is None:
# raise ValueError("必须提供新的 object_id")
# if source.object_id == new_object_id:
# raise ValueError("object_id 必须与源对象不同")
# try:
# source.content_type.get_object_for_this_type(pk=new_object_id)
# except ObjectDoesNotExist:
# raise ValueError(
# f"目标关联对象不存在:{source.content_type.app_label}.{source.content_type.model} #{new_object_id}"
# )
# with transaction.atomic():
# cloned = models.BusinessObject.objects.create(
# name=source.name,
# process=source.process,
# description=source.description,
# content_type=source.content_type,
# object_id=new_object_id,
# )
# models.BusinessObject.objects.filter(id=cloned.id).update(
# created_at=source.created_at,
# updated_at=source.updated_at,
# )
# source_logs = sorted(
# list(source.state_logs.all()),
# key=lambda log: (log.completed_at, log.id),
# )
# for src_log in source_logs:
# new_log = models.StateFlowRecord.objects.create(
# business_object=cloned,
# state=src_log.state,
# completed_by=src_log.completed_by,
# is_cancelled=src_log.is_cancelled,
# cancelled_at=src_log.cancelled_at,
# )
# models.StateFlowRecord.objects.filter(id=new_log.id).update(
# completed_at=src_log.completed_at,
# created_at=src_log.created_at,
# updated_at=src_log.updated_at,
# cancelled_at=src_log.cancelled_at,
# )
# src_param_records = sorted(
# list(src_log.parameter_records.all()),
# key=lambda rec: (rec.created_at, rec.id),
# )
# for src_rec in src_param_records:
# new_rec = models.StateLogParameterRecord.objects.create(
# state_log=new_log,
# parameters=copy.deepcopy(src_rec.parameters),
# remark=src_rec.remark,
# )
source = (
models.BusinessObject.objects
.select_related('process', 'content_type')
.prefetch_related(
'state_logs__state',
'state_logs__completed_by',
'state_logs__parameter_records',
)
.get(id=source.id)
)
if source.content_type_id != expected_content_type_id:
raise ValueError("content_type 与源对象不一致,拒绝克隆")
if new_object_id is None:
raise ValueError("必须提供新的 object_id")
if source.object_id == new_object_id:
raise ValueError("object_id 必须与源对象不同")
# 校验目标关联对象存在
try:
source.content_type.get_object_for_this_type(pk=new_object_id)
except ObjectDoesNotExist:
raise ValueError(
f"目标关联对象不存在:{source.content_type.app_label}.{source.content_type.model} #{new_object_id}"
)
with transaction.atomic():
cloned = models.BusinessObject.objects.create(
name=source.name,
process=source.process,
description=source.description,
content_type=source.content_type,
object_id=new_object_id,
)
models.BusinessObject.objects.filter(id=cloned.id).update(
created_at=source.created_at,
updated_at=source.updated_at,
)
source_logs = sorted(
list(source.state_logs.all()),
key=lambda log: (log.completed_at, log.id),
)
for src_log in source_logs:
new_log = models.StateFlowRecord.objects.create(
business_object=cloned,
state=src_log.state,
completed_by=src_log.completed_by,
is_cancelled=src_log.is_cancelled,
cancelled_at=src_log.cancelled_at,
)
models.StateFlowRecord.objects.filter(id=new_log.id).update(
completed_at=src_log.completed_at,
created_at=src_log.created_at,
updated_at=src_log.updated_at,
cancelled_at=src_log.cancelled_at,
)
src_param_records = sorted(
list(src_log.parameter_records.all()),
key=lambda rec: (rec.created_at, rec.id),
)
for src_rec in src_param_records:
new_rec = models.StateLogParameterRecord.objects.create(
state_log=new_log,
parameters=copy.deepcopy(src_rec.parameters),
remark=src_rec.remark,
)
models.StateLogParameterRecord.objects.filter(id=new_rec.id).update(
created_at=src_rec.created_at,
updated_at=src_rec.updated_at,
)
return models.BusinessObject.objects.get(id=cloned.id)
# models.StateLogParameterRecord.objects.filter(id=new_rec.id).update(
# created_at=src_rec.created_at,
# updated_at=src_rec.updated_at,

View File

@@ -241,6 +241,56 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["count"], 0)
def test_business_object_filters_by_completed_state_and_param(self):
"""
新增能力:按“已完成的 state_id + 工艺参数 key/value”反查 BusinessObject 列表
语义确认:
- “处于某状态”指已完成该状态节点(存在 StateFlowRecord 且 is_cancelled=False
- 参数匹配绑定在同一个 state_log 下的 StateLogParameterRecord.parameters
"""
ct = self.user_content_type
bo_match = models.BusinessObject.objects.create(
name="BO-Match",
process=self.process_a,
content_type=ct,
object_id=self.user.id,
)
services.advance_to_next_state(bo_match, self.user, **{"设计师名称": "AAA"})
bo_other_value = models.BusinessObject.objects.create(
name="BO-OtherValue",
process=self.process_a,
content_type=ct,
object_id=self.user.id,
)
services.advance_to_next_state(bo_other_value, self.user, **{"设计师名称": "BBB"})
bo_cancelled = models.BusinessObject.objects.create(
name="BO-Cancelled",
process=self.process_a,
content_type=ct,
object_id=self.user.id,
)
services.advance_to_next_state(bo_cancelled, self.user, **{"设计师名称": "AAA"})
services.step_back_one_state(bo_cancelled, self.user)
resp = self.client.get(
"/api/v1/stateflow/business-objects/",
data={
"content_type_str": "auth.user",
"state_id": self.state1.id,
"param_key": "设计师名称",
"param_value": "AAA",
"limit": 50,
"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_match.id})
class AuthenticationGuardAPITestCase(TestCase):
def test_stateflow_endpoints_require_authentication(self):