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

@@ -10,6 +10,8 @@ from basic_info import models as basic_models
from printing import models as printing_models
from business import models as business_models
from api_v2.views.printing import PrintingJobByCustomerView
from django.contrib.contenttypes.models import ContentType
from stateflow import models as stateflow_models
class QuickCreateEmployeeUserAPITest(TestCase):
@@ -205,5 +207,275 @@ class PrintingJobByCustomerAPITest(TestCase):
})
self.assertEqual(len(resp.data), 1)
data = resp.data[0]
self.assertEqual(data['product'], self.product.id)
self.assertEqual(data['product']['id'], self.product.id)
self.assertEqual(data['billed_quantity'], '20.00')
class PrintingJobBatchAdvanceV2APITest(TestCase):
def setUp(self):
self.client = APIClient()
# 工厂用户(满足 IsPrintingFactory
self.merchant = basic_models.Merchant.objects.create(
name='印染工厂',
type=basic_models.MerchantTypeEnum.FACTORY,
)
self.user = get_user_model().objects.create_user(username='factory_user', password='pass12345')
self.employee = basic_models.Employee.objects.create(
merchant=self.merchant,
sys_user=self.user,
name='工厂员工',
)
self.client.force_authenticate(user=self.user)
# 基础客户/产品
self.customer = basic_models.Customer.objects.create(
merchant=self.merchant,
name='客户A',
created_by=None,
)
category = basic_models.ProductCategory.objects.create(
merchant=self.merchant,
name='品类',
product_prefix='FAB',
)
self.product = basic_models.Product.objects.create(
merchant=self.merchant,
category=category,
name='产品A',
human_id='FAB-100',
width_size=Decimal('150.00'),
color='红色',
unit=basic_models.ProductUnitEnum.METER,
)
# stateflow流程 + 3个节点 + 节点参数(第一步必填)
self.state1 = stateflow_models.State.objects.create(name='待处理1', description='第一个节点')
self.state2 = stateflow_models.State.objects.create(name='待处理2', description='第二个节点')
self.state3 = stateflow_models.State.objects.create(name='待处理3', description='第三个节点')
self.param_required = stateflow_models.StateParameter.objects.create(
key='temperature',
value='',
is_required=True,
description='温度(必填)',
)
self.state1.parameters.add(self.param_required)
self.process = stateflow_models.Process.objects.create(name='印染流程')
stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state1, order=0)
stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1)
stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2)
# printing订单 + 两条明细(每条明细都有 business_object
self.printing_order = printing_models.PrintingOrder.objects.create(
customer=self.customer,
fabric='',
width='150cm',
process=self.process,
)
bo1 = stateflow_models.BusinessObject.objects.create(
name='BO-1',
process=self.process,
)
bo2 = stateflow_models.BusinessObject.objects.create(
name='BO-2',
process=self.process,
)
self.job1 = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
quantity=10,
unit='',
business_object=bo1,
)
self.job2 = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
quantity=20,
unit='',
business_object=bo2,
)
def test_preview_success_returns_next_state_and_parameters(self):
resp = self.client.post(
'/api/v2/printing-jobs/batch-advance/preview/',
{'printing_job_ids': [self.job1.id, self.job2.id]},
format='json'
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data['printing_order_id'], self.printing_order.id)
self.assertEqual(set(resp.data['printing_job_ids']), {self.job1.id, self.job2.id})
next_state = resp.data['next_state']
self.assertEqual(next_state['id'], self.state1.id)
self.assertEqual(next_state['order'], 0)
self.assertTrue(any(p['key'] == 'temperature' and p['is_required'] for p in next_state['parameters']))
def test_preview_fails_when_jobs_not_same_printing_order(self):
other_order = printing_models.PrintingOrder.objects.create(
customer=self.customer,
fabric='',
width='160cm',
process=self.process,
)
bo3 = stateflow_models.BusinessObject.objects.create(name='BO-3', process=self.process)
job3 = printing_models.PrintingJob.objects.create(
printing_order=other_order,
product=self.product,
quantity=5,
unit='',
business_object=bo3,
)
resp = self.client.post(
'/api/v2/printing-jobs/batch-advance/preview/',
{'printing_job_ids': [self.job1.id, job3.id]},
format='json'
)
self.assertEqual(resp.status_code, 400)
self.assertIn('printing_order', resp.data.get('detail', ''))
def test_submit_success_advances_all_and_creates_batch_record(self):
resp = self.client.post(
'/api/v2/printing-jobs/batch-advance/',
{
'printing_job_ids': [self.job1.id, self.job2.id],
'parameters': {'temperature': '25.5'}
},
format='json'
)
self.assertEqual(resp.status_code, 200)
self.assertIn('batch_id', resp.data)
# stateflow两条 business_object 都应生成 1 条完成日志,且完成的是 state1
self.job1.refresh_from_db()
self.job2.refresh_from_db()
self.assertEqual(self.job1.business_object.state_logs.filter(is_cancelled=False).count(), 1)
self.assertEqual(self.job2.business_object.state_logs.filter(is_cancelled=False).count(), 1)
self.assertEqual(self.job1.business_object.state_logs.first().state_id, self.state1.id)
self.assertEqual(self.job2.business_object.state_logs.first().state_id, self.state1.id)
# printing批量记录应存在且关联 jobs
record = printing_models.PrintingJobBatchAdvanceRecord.objects.get(id=resp.data['batch_id'])
self.assertEqual(record.created_by_id, self.user.id)
self.assertEqual(record.printing_order_id, self.printing_order.id)
self.assertEqual(record.state_id, self.state1.id)
self.assertEqual(record.parameters.get('temperature'), '25.5')
self.assertEqual(set(record.printing_jobs.values_list('id', flat=True)), {self.job1.id, self.job2.id})
def test_submit_fails_when_missing_required_parameter(self):
resp = self.client.post(
'/api/v2/printing-jobs/batch-advance/',
{
'printing_job_ids': [self.job1.id, self.job2.id],
'parameters': {} # 缺少 temperature
},
format='json'
)
self.assertEqual(resp.status_code, 400)
self.assertIn('缺失必填参数', resp.data.get('detail', ''))
# 未产生任何日志/批量记录
self.assertEqual(self.job1.business_object.state_logs.count(), 0)
self.assertEqual(self.job2.business_object.state_logs.count(), 0)
self.assertEqual(printing_models.PrintingJobBatchAdvanceRecord.objects.count(), 0)
class BusinessObjectCloneV2APITest(TestCase):
def setUp(self):
self.client = APIClient()
self.user = get_user_model().objects.create_user(username='u1', password='pass12345')
self.client.force_authenticate(user=self.user)
self.state1 = stateflow_models.State.objects.create(name='S1')
self.state2 = stateflow_models.State.objects.create(name='S2')
self.process = stateflow_models.Process.objects.create(name='P1')
stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state1, order=0)
stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1)
ct = ContentType.objects.get_for_model(stateflow_models.Process)
self.bo = stateflow_models.BusinessObject.objects.create(
name='BO',
process=self.process,
content_type=ct,
object_id=111,
)
# 创建一条日志 + 参数记录,确保“克隆包含工艺参数”
log = stateflow_models.StateFlowRecord.objects.create(
business_object=self.bo,
state=self.state1,
completed_by=self.user,
)
stateflow_models.StateLogParameterRecord.objects.create(
state_log=log,
parameters={'temperature': '25.5'},
remark='r1',
)
def test_clone_business_object_api_returns_new_id(self):
new_object_id = 222
resp = self.client.post(
'/api/v2/stateflow/business-objects/clone/',
{
'business_object_id': self.bo.id,
'content_type': self.bo.content_type_id,
'object_id': new_object_id,
},
format='json'
)
self.assertEqual(resp.status_code, 200)
self.assertIn('business_object_id', resp.data)
new_id = resp.data['business_object_id']
self.assertNotEqual(new_id, self.bo.id)
cloned = stateflow_models.BusinessObject.objects.get(id=new_id)
self.assertEqual(cloned.process_id, self.bo.process_id)
self.assertEqual(cloned.state_logs.count(), self.bo.state_logs.count())
self.assertEqual(cloned.content_type_id, self.bo.content_type_id)
self.assertEqual(cloned.object_id, new_object_id)
def test_clone_business_object_api_404(self):
resp = self.client.post(
'/api/v2/stateflow/business-objects/clone/',
{
'business_object_id': 999999,
'content_type': self.bo.content_type_id,
'object_id': 222,
},
format='json'
)
self.assertEqual(resp.status_code, 404)
def test_clone_business_object_api_rejects_mismatched_content_type(self):
# 用另一个 ContentType比如 State
ct_state = ContentType.objects.get_for_model(stateflow_models.State)
self.assertNotEqual(ct_state.id, self.bo.content_type_id)
resp = self.client.post(
'/api/v2/stateflow/business-objects/clone/',
{
'business_object_id': self.bo.id,
'content_type': ct_state.id,
'object_id': 222,
},
format='json'
)
self.assertEqual(resp.status_code, 400)
self.assertIn('content_type', resp.data.get('detail', ''))
def test_clone_business_object_api_unauthorized(self):
client = APIClient()
resp = client.post(
'/api/v2/stateflow/business-objects/clone/',
{
'business_object_id': self.bo.id,
'content_type': self.bo.content_type_id,
'object_id': 222,
},
format='json'
)
self.assertEqual(resp.status_code, 401)

View File

@@ -1,9 +1,18 @@
from django.urls import path
from api_v2.views import QuickCreateEmployeeUserView, PrintingJobByCustomerView
from api_v2.views import (
QuickCreateEmployeeUserView,
PrintingJobByCustomerView,
PrintingJobBatchAdvancePreviewView,
PrintingJobBatchAdvanceSubmitView,
BusinessObjectCloneView,
)
urlpatterns = [
path('users/quick-create/', QuickCreateEmployeeUserView.as_view(), name='api_v2_user_quick_create'),
path('printing-jobs/by-customer/', PrintingJobByCustomerView.as_view(), name='api_v2_printing_job_by_customer'),
path('printing-jobs/batch-advance/preview/', PrintingJobBatchAdvancePreviewView.as_view(), name='api_v2_printing_job_batch_advance_preview'),
path('printing-jobs/batch-advance/', PrintingJobBatchAdvanceSubmitView.as_view(), name='api_v2_printing_job_batch_advance_submit'),
path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'),
]

View File

@@ -3,11 +3,20 @@ api_v2 视图包。
"""
from .users import QuickCreateEmployeeUserView
from .printing import PrintingJobByCustomerView, PrintingJobV2Serializer
from .printing import (
PrintingJobByCustomerView,
PrintingJobV2Serializer,
PrintingJobBatchAdvancePreviewView,
PrintingJobBatchAdvanceSubmitView,
)
from .stateflow import BusinessObjectCloneView
__all__ = [
'QuickCreateEmployeeUserView',
'PrintingJobByCustomerView',
'PrintingJobV2Serializer',
'PrintingJobBatchAdvancePreviewView',
'PrintingJobBatchAdvanceSubmitView',
'BusinessObjectCloneView',
]

View File

@@ -1,14 +1,29 @@
import datetime
from django.utils import timezone
from django.db import transaction
from rest_framework import serializers, status, permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from basic_info import models as basic_models
from printing import models as printing_models
from api_man.serializers import ProductSerializer
class IsPrintingFactory(permissions.BasePermission):
"""仅允许印染工厂用户访问(与 api_v1 逻辑保持一致)"""
message = '您没有访问印染订单的权限'
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
if hasattr(request.user, 'employee'):
return request.user.employee.merchant.type == basic_models.MerchantTypeEnum.FACTORY
return False
class PrintingJobV2Serializer(serializers.ModelSerializer):
"""v2 独立的印染任务序列化器,包含开单数量"""
@@ -138,3 +153,210 @@ class PrintingJobByCustomerView(APIView):
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
class PrintingJobBatchAdvancePreviewRequestSerializer(serializers.Serializer):
"""批量推进:预览/校验请求"""
printing_job_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
allow_empty=False,
help_text='需要批量推进的 printing_job id 列表',
)
def validate_printing_job_ids(self, value):
# 去重保持稳定性(前端可能重复传)
deduped = list(dict.fromkeys(value))
if not deduped:
raise serializers.ValidationError('printing_job_ids 不能为空')
return deduped
class PrintingJobBatchAdvanceSubmitRequestSerializer(PrintingJobBatchAdvancePreviewRequestSerializer):
"""批量推进:提交请求"""
parameters = serializers.DictField(
child=serializers.JSONField(),
required=False,
default=dict,
help_text='与单条推进接口一致的工艺参数(将作为 **kwargs 传给 stateflow',
)
def _validate_jobs_for_batch_advance(printing_job_ids: list[int]):
"""
批量推进的核心一致性校验preview 与 submit 共用)
规则:
1) 所有 id 都存在
2) 全部属于同一个 printing_order
3) 全部存在 business_object流程实例
4) 全部具有相同的 next_pending_state下一待执行节点否则不允许批量
注意这里不做“竞态”处理preview 后 submit 前状态变化submit 时会再次调用该函数重新校验。
后续如需增强,可在 preview 返回 snapshot token在 submit 校验 token 以提升用户体验。
"""
# 查询并校验存在性
qs = (
printing_models.PrintingJob.objects
.select_related('printing_order', 'business_object', 'business_object__process')
.filter(id__in=printing_job_ids)
)
jobs = list(qs)
found_ids = {j.id for j in jobs}
missing_ids = [str(i) for i in printing_job_ids if i not in found_ids]
if missing_ids:
raise serializers.ValidationError({'detail': f'以下 printing_job 不存在: {", ".join(missing_ids)}'})
# 同一订单
order_ids = {j.printing_order_id for j in jobs}
if len(order_ids) != 1:
raise serializers.ValidationError({'detail': '所选明细不属于同一个 printing_order无法批量推进'})
printing_order_id = next(iter(order_ids))
printing_order = jobs[0].printing_order
# 必须有关联流程实例
no_bo = [str(j.id) for j in jobs if not j.business_object_id]
if no_bo:
raise serializers.ValidationError({'detail': f'以下 printing_job 未关联流程实例business_object无法推进: {", ".join(no_bo)}'})
# 计算并校验 next_pending_state 一致
from stateflow import services as stateflow_services
next_infos = []
for j in jobs:
info = stateflow_services.get_next_pending_state(j.business_object, include_parameters=True)
if info is None:
next_infos.append((j.id, None))
else:
next_infos.append((j.id, info))
# 不能包含“无待执行节点”(流程已完成或无节点)
cannot_advance = [str(job_id) for job_id, info in next_infos if info is None]
if cannot_advance:
raise serializers.ValidationError({'detail': f'以下 printing_job 没有待执行节点(流程已完成或无节点),无法批量推进: {", ".join(cannot_advance)}'})
# 比对 state_id
first_info = next_infos[0][1]
target_state = first_info['state']
target_order = first_info['order']
target_state_id = target_state.id
diff_jobs = []
for job_id, info in next_infos:
if info['state'].id != target_state_id:
diff_jobs.append(str(job_id))
if diff_jobs:
raise serializers.ValidationError({'detail': f'所选明细当前待执行节点不一致,无法批量推进(不同节点的 jobs: {", ".join(diff_jobs)}'})
# 参数定义取目标节点(所有一致)
target_parameters = first_info.get('parameters', []) or []
return {
'printing_order': printing_order,
'printing_order_id': printing_order_id,
'jobs': jobs,
'target_state': target_state,
'target_order': target_order,
'target_parameters': target_parameters,
}
class PrintingJobBatchAdvancePreviewView(APIView):
"""
批量推进:预览
作用:
- 校验 printing_job_ids 是否可批量推进(同订单/同待执行节点)
- 返回“下一步待执行节点”的信息及其工艺参数定义,供前端生成批量表单
"""
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
def post(self, request):
srz = PrintingJobBatchAdvancePreviewRequestSerializer(data=request.data)
srz.is_valid(raise_exception=True)
data = _validate_jobs_for_batch_advance(srz.validated_data['printing_job_ids'])
from stateflow.serializers import StateParameterSerializer
params_srz = StateParameterSerializer(
data['target_parameters'],
many=True,
context={'request': request},
)
return Response({
'printing_order_id': data['printing_order_id'],
'printing_job_ids': [j.id for j in data['jobs']],
'next_state': {
'id': data['target_state'].id,
'name': data['target_state'].name,
'description': data['target_state'].description,
'order': data['target_order'],
'parameters': params_srz.data,
}
})
class PrintingJobBatchAdvanceSubmitView(APIView):
"""
批量推进:提交
规则:全成功/全失败
- 任意一个 job 推进失败:整体回滚(不产生任何 stateflow 日志,也不产生批量推进记录)
"""
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
def post(self, request):
srz = PrintingJobBatchAdvanceSubmitRequestSerializer(data=request.data)
srz.is_valid(raise_exception=True)
payload = srz.validated_data
parameters = payload.get('parameters') or {}
data = _validate_jobs_for_batch_advance(payload['printing_job_ids'])
from stateflow import services as stateflow_services
# 全成功/全失败:用事务包住整个批量推进
with transaction.atomic():
record = printing_models.PrintingJobBatchAdvanceRecord.objects.create(
printing_order=data['printing_order'],
state=data['target_state'],
created_by=request.user,
parameters=parameters,
)
record.printing_jobs.set(data['jobs'])
# 逐个复用单条推进逻辑
last_message = None
for job in data['jobs']:
ok, msg, _state_log = stateflow_services.advance_to_next_state(
job.business_object,
request.user,
**parameters
)
if not ok:
# 抛异常触发事务回滚,保证“全部失败”
raise serializers.ValidationError({'detail': msg})
last_message = msg
# 返回最新的 job 列表(可用于前端刷新)
refreshed_jobs = (
printing_models.PrintingJob.objects
.select_related('printing_order', 'product')
.filter(id__in=[j.id for j in data['jobs']])
.order_by('id')
)
job_srz = PrintingJobV2Serializer(refreshed_jobs, many=True)
return Response({
'detail': last_message or '批量推进成功',
'batch_id': record.id,
'printing_order_id': data['printing_order_id'],
'printing_job_ids': [j.id for j in data['jobs']],
'jobs': job_srz.data,
})

62
api_v2/views/stateflow.py Normal file
View File

@@ -0,0 +1,62 @@
from rest_framework import serializers, status, permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from stateflow import models as stateflow_models
from stateflow import services as stateflow_services
class BusinessObjectCloneRequestSerializer(serializers.Serializer):
business_object_id = serializers.IntegerField(min_value=1)
# 调用方必须显式传入 content_type 与 object_id
# - content_type仅用于校验与源对象一致不做额外校验
# - object_id克隆后的新业务对象 ID必须与源对象不同
content_type = serializers.IntegerField(min_value=1, allow_null=True)
object_id = serializers.IntegerField(min_value=1, allow_null=True)
class BusinessObjectCloneView(APIView):
"""
克隆 BusinessObject含所有状态流转记录与工艺参数记录
POST /api/v2/stateflow/business-objects/clone/
请求体:
{
"business_object_id": 123,
"content_type": 9,
"object_id": 456
}
返回:
{
"business_object_id": 456
}
"""
permission_classes = [permissions.IsAuthenticated]
def post(self, request):
srz = BusinessObjectCloneRequestSerializer(data=request.data)
srz.is_valid(raise_exception=True)
bo_id = srz.validated_data['business_object_id']
expected_content_type_id = srz.validated_data.get('content_type')
new_object_id = srz.validated_data.get('object_id')
try:
bo = stateflow_models.BusinessObject.objects.get(id=bo_id)
except stateflow_models.BusinessObject.DoesNotExist:
return Response({'detail': 'business_object 不存在'}, status=status.HTTP_404_NOT_FOUND)
try:
cloned = stateflow_services.clone_business_object(
bo,
new_object_id=new_object_id,
expected_content_type_id=expected_content_type_id,
)
except ValueError as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response({'business_object_id': cloned.id})