1
0
forked from erp-dev/erp

feat: added plate order clone

This commit is contained in:
2025-12-16 08:42:53 +08:00
parent 4c9b8e6a42
commit 0638dd1ad3
25 changed files with 46174 additions and 8 deletions

View File

@@ -1,6 +1,7 @@
"""
Stateflow业务逻辑服务层
"""
import copy
from typing import List, Optional, Tuple
from django.contrib.auth import get_user_model
from django.db import transaction
@@ -570,3 +571,122 @@ def get_process_nodes(business_object: 'models.BusinessObject') -> List[dict]:
}
for node in process_nodes
]
def clone_business_object(
source: 'models.BusinessObject',
*,
new_object_id: int,
expected_content_type_id: int,
) -> 'models.BusinessObject':
"""
克隆一个 BusinessObject含所有状态流转记录与工艺参数记录
目标:
- 不影响原对象
- 克隆出“业务数据完全一致”的新对象process/描述/关联对象/日志/参数等)
- 返回新的 BusinessObject 实例
说明:
- 会克隆 source.state_logs 的所有记录(含已撤销记录)
- 会克隆每条 StateFlowRecord 下的所有 StateLogParameterRecord含参数 JSON 与 remark
- 会尽量保持 completed_at / cancelled_at / 参数记录 created_at 与原对象一致
- created_at/updated_at 等 ModelBase 字段也会被同步(使用 update 绕过 auto_now*
- **重要**:克隆必须提供新的 object_id当 source.content_type 非空时),否则克隆与业务绑定无差异,容易造成误用
- expected_content_type_id 仅用于校验调用方意图:必须与 source.content_type_id 一致,否则拒绝克隆
"""
if source is None:
raise ValueError("source 不能为空")
# 重新加载 source确保拿到完整关系避免调用方未预取导致 N+1
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)
)
# 校验 content_type 一致性(不做额外校验,仅比对 id
if source.content_type_id != expected_content_type_id:
raise ValueError("content_type 与源对象不一致,拒绝克隆")
# 如果源对象未绑定 content_type则拒绝克隆
if source.content_type_id is None:
return ValueError("源对象未绑定 content_type拒绝克隆")
else:
if new_object_id is None:
raise ValueError("必须提供新的 object_id")
if source.object_id == new_object_id:
raise ValueError("object_id 必须与源对象不同")
content_type = models.ContentType.objects.get(id=source.content_type_id)
actual_object = content_type.model_class().objects.get(id=new_object_id)
if actual_object is None:
raise ValueError("实际对象不存在,拒绝克隆")
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,
)
# 同步 BusinessObject 的时间戳字段(保持一致性)
models.BusinessObject.objects.filter(id=cloned.id).update(
created_at=source.created_at,
updated_at=source.updated_at,
)
# 复制 state_logs按完成时间排序保证克隆后的序列稳定
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,
)
# 同步 StateFlowRecord 的时间戳字段(含 completed_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,
)
# 复制参数记录(按 created_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,
)
# 重新加载,确保返回对象的字段与 DB 一致(尤其是时间戳)
cloned.refresh_from_db()
actual_object.refresh_from_db()
actual_object.business_object_id = cloned.id
actual_object.save()
return cloned

View File

@@ -0,0 +1,236 @@
"""
BusinessObject 克隆服务测试
重点验证:
- 克隆后新对象与源对象业务数据一致process/描述/关联对象/日志/参数)
- 时间戳completed_at/cancelled_at/参数记录 created_at 等)保持一致
- 包含已撤销记录
- 克隆对象与原对象互不影响
"""
import datetime
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.utils import timezone
from stateflow import models, services
User = get_user_model()
class CloneBusinessObjectServiceTestCase(TestCase):
def setUp(self):
self.user1 = User.objects.create_user(username='u1', password='pass')
self.user2 = User.objects.create_user(username='u2', password='pass')
self.state1 = models.State.objects.create(name='状态1', description='第一个节点')
self.state2 = models.State.objects.create(name='状态2', description='第二个节点')
self.process = models.Process.objects.create(name='流程A', description='流程描述')
models.ProcessNode.objects.create(process=self.process, state=self.state1, order=0)
models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1)
ct = ContentType.objects.get_for_model(models.Process)
self.business_object = models.BusinessObject.objects.create(
name='BO-1',
process=self.process,
description='BO 描述',
content_type=ct,
object_id=self.process.id,
)
# 固定时间点
tz = timezone.get_default_timezone()
self.t1 = timezone.make_aware(datetime.datetime(2025, 12, 1, 10, 0, 0), tz)
self.t1a = timezone.make_aware(datetime.datetime(2025, 12, 1, 10, 1, 0), tz)
self.t1b = timezone.make_aware(datetime.datetime(2025, 12, 1, 10, 2, 0), tz)
self.t2 = timezone.make_aware(datetime.datetime(2025, 12, 2, 9, 0, 0), tz)
self.t2a = timezone.make_aware(datetime.datetime(2025, 12, 2, 9, 1, 0), tz)
self.t2_cancel = timezone.make_aware(datetime.datetime(2025, 12, 2, 9, 30, 0), tz)
# 创建两条状态日志:一条正常,一条已撤销
log1 = models.StateFlowRecord.objects.create(
business_object=self.business_object,
state=self.state1,
completed_by=self.user1,
is_cancelled=False,
)
models.StateFlowRecord.objects.filter(id=log1.id).update(
completed_at=self.t1,
created_at=self.t1,
updated_at=self.t1,
)
rec1 = models.StateLogParameterRecord.objects.create(
state_log=log1,
parameters={'temperature': '25.5'},
remark='初次测量',
)
models.StateLogParameterRecord.objects.filter(id=rec1.id).update(
created_at=self.t1a,
updated_at=self.t1a,
)
rec2 = models.StateLogParameterRecord.objects.create(
state_log=log1,
parameters={'temperature': '26.0', 'operator': '张三'},
remark='复测',
)
models.StateLogParameterRecord.objects.filter(id=rec2.id).update(
created_at=self.t1b,
updated_at=self.t1b,
)
log2 = models.StateFlowRecord.objects.create(
business_object=self.business_object,
state=self.state2,
completed_by=self.user2,
is_cancelled=True,
cancelled_at=self.t2_cancel,
)
models.StateFlowRecord.objects.filter(id=log2.id).update(
completed_at=self.t2,
created_at=self.t2,
updated_at=self.t2,
cancelled_at=self.t2_cancel,
)
rec3 = models.StateLogParameterRecord.objects.create(
state_log=log2,
parameters={'humidity': '60%'},
remark='记录湿度',
)
models.StateLogParameterRecord.objects.filter(id=rec3.id).update(
created_at=self.t2a,
updated_at=self.t2a,
)
# 重新加载,确保测试数据时间戳生效
self.business_object.refresh_from_db()
def test_clone_business_object_copies_all_records_and_parameters(self):
new_object_id = (self.business_object.object_id or 0) + 1000
cloned = services.clone_business_object(
self.business_object,
new_object_id=new_object_id,
expected_content_type_id=self.business_object.content_type_id,
)
# 基本字段一致
self.assertNotEqual(cloned.id, self.business_object.id)
self.assertEqual(cloned.name, self.business_object.name)
self.assertEqual(cloned.description, self.business_object.description)
self.assertEqual(cloned.process_id, self.business_object.process_id)
self.assertEqual(cloned.content_type_id, self.business_object.content_type_id)
self.assertEqual(cloned.object_id, new_object_id)
self.assertNotEqual(cloned.object_id, self.business_object.object_id)
# ModelBase 时间戳字段同步(严格一致)
self.assertEqual(cloned.created_at, self.business_object.created_at)
self.assertEqual(cloned.updated_at, self.business_object.updated_at)
src_logs = list(self.business_object.state_logs.all().order_by('completed_at', 'id'))
new_logs = list(cloned.state_logs.all().order_by('completed_at', 'id'))
self.assertEqual(len(new_logs), len(src_logs))
for src, new in zip(src_logs, new_logs):
self.assertNotEqual(src.id, new.id)
self.assertEqual(new.state_id, src.state_id)
self.assertEqual(new.completed_by_id, src.completed_by_id)
self.assertEqual(new.is_cancelled, src.is_cancelled)
self.assertEqual(new.cancelled_at, src.cancelled_at)
self.assertEqual(new.completed_at, src.completed_at)
self.assertEqual(new.created_at, src.created_at)
self.assertEqual(new.updated_at, src.updated_at)
src_params = list(src.parameter_records.all().order_by('created_at', 'id'))
new_params = list(new.parameter_records.all().order_by('created_at', 'id'))
self.assertEqual(len(new_params), len(src_params))
for src_rec, new_rec in zip(src_params, new_params):
self.assertNotEqual(src_rec.id, new_rec.id)
self.assertEqual(new_rec.parameters, src_rec.parameters)
self.assertEqual(new_rec.remark, src_rec.remark)
self.assertEqual(new_rec.created_at, src_rec.created_at)
self.assertEqual(new_rec.updated_at, src_rec.updated_at)
# 已撤销记录的参数默认不可见(克隆也应一致)
src_cancelled = src_logs[1]
new_cancelled = new_logs[1]
self.assertTrue(src_cancelled.is_cancelled)
self.assertTrue(new_cancelled.is_cancelled)
self.assertEqual(src_cancelled.get_all_parameters_summary(), {})
self.assertEqual(new_cancelled.get_all_parameters_summary(), {})
self.assertEqual(
new_cancelled.get_all_parameters_summary(include_cancelled=True),
src_cancelled.get_all_parameters_summary(include_cancelled=True),
)
def test_clone_is_independent_from_source(self):
cloned = services.clone_business_object(
self.business_object,
new_object_id=(self.business_object.object_id or 0) + 1000,
expected_content_type_id=self.business_object.content_type_id,
)
# 修改克隆对象的参数记录,不应影响源对象
cloned_first_log = cloned.state_logs.order_by('completed_at', 'id').first()
cloned_first_param = cloned_first_log.parameter_records.order_by('created_at', 'id').first()
cloned_first_param.parameters['temperature'] = '99.9'
cloned_first_param.save(update_fields=['parameters'])
src_first_log = self.business_object.state_logs.order_by('completed_at', 'id').first()
src_first_param = src_first_log.parameter_records.order_by('created_at', 'id').first()
self.assertEqual(src_first_param.parameters['temperature'], '25.5')
def test_clone_empty_business_object(self):
bo = models.BusinessObject.objects.create(
name='BO-empty',
process=self.process,
description='empty',
)
cloned = services.clone_business_object(
bo,
new_object_id=None,
expected_content_type_id=None,
)
self.assertNotEqual(cloned.id, bo.id)
self.assertIsNone(cloned.content_type_id)
self.assertIsNone(cloned.object_id)
self.assertEqual(cloned.state_logs.count(), 0)
def test_clone_rejects_mismatched_content_type(self):
"""content_type 不一致应拒绝克隆"""
# 用另一个 ContentType比如 State
ct_state = ContentType.objects.get_for_model(models.State)
self.assertNotEqual(ct_state.id, self.business_object.content_type_id)
with self.assertRaises(ValueError):
services.clone_business_object(
self.business_object,
new_object_id=(self.business_object.object_id or 0) + 1000,
expected_content_type_id=ct_state.id,
)
def test_clone_requires_new_object_id_when_content_type_present(self):
"""源对象有 content_type 时,必须提供新的 object_id"""
with self.assertRaises(ValueError):
services.clone_business_object(
self.business_object,
new_object_id=None,
expected_content_type_id=self.business_object.content_type_id,
)
def test_clone_rejects_same_object_id(self):
"""object_id 与源对象相同应拒绝克隆"""
with self.assertRaises(ValueError):
services.clone_business_object(
self.business_object,
new_object_id=self.business_object.object_id,
expected_content_type_id=self.business_object.content_type_id,
)