1
0
forked from erp-dev/erp
Files
erpnew/api_v2/views/printing.py

1858 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import datetime
from django.utils import timezone
from django.db import transaction
from django.db import models as django_models
from django.db.models import Q, Count, CharField, Prefetch, Exists, OuterRef, Subquery
from django.db.models.fields.json import KeyTextTransform
from django.db.models.functions import Cast, Coalesce
from rest_framework import serializers, status, permissions
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.response import Response
from rest_framework.views import APIView
from basic_info import models as basic_models
from business import models as business_models
from printing import models as printing_models
from api_v1.tasks import (
ExternalPrintingOrderSnapshotSyncError,
sync_external_printing_order_snapshot_impl,
)
from api_man.serializers import ProductSerializer
from api_v1.views.printing.serializers import (
PlateOrderListSerializer as PlateOrderListV1Serializer,
_serialize_plate_images,
)
from stateflow import models as stateflow_models
from stateflow import services as stateflow_services
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
def _serialize_plate_image_first(raw_value, request):
images = _serialize_plate_images(raw_value, request)
if not images:
return []
return images[:1]
class PrintingJobV2Serializer(serializers.ModelSerializer):
"""v2 独立的印染任务序列化器,包含开单数量"""
external_order_id = serializers.CharField(source='printing_order.external_order_id', read_only=True)
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
business_object_id = serializers.SerializerMethodField()
width = serializers.SerializerMethodField()
fabric = serializers.SerializerMethodField()
product = ProductSerializer(read_only=True)
class Meta:
model = printing_models.PrintingJob
fields = [
'id',
'original_id',
'printing_order',
'external_order_id',
'product',
'work_state',
'quantity',
'width',
'fabric',
'unit',
'size',
'pieces',
'description',
'business_object_id',
'created_at',
'updated_at',
'billed_quantity',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'external_order_id', 'billed_quantity']
def get_business_object_id(self, obj):
return obj.business_object_id
def get_width(self, obj: printing_models.PrintingJob) -> float:
return obj.printing_order.width
def get_fabric(self, obj: printing_models.PrintingJob) -> str:
return obj.printing_order.fabric
class PrintingJobByCustomerView(APIView):
"""
按客户与日期范围查询印染任务。
必填 query 参数:
- customer_id: 客户 ID
- date_from: 开始日期 (YYYY-MM-DD)
- date_to: 结束日期 (YYYY-MM-DD),闭区间,包含 23:59:59
可选过滤:
- printing_order: 按印染主订单 ID
- product_id / product_name / product_human_id / product_width_size / product_color
"""
serializer_class = PrintingJobV2Serializer
permission_classes = [permissions.AllowAny]
def get(self, request):
qp = request.query_params
customer_id = qp.get('customer_id')
date_from = qp.get('date_from')
date_to = qp.get('date_to')
printing_order_id = qp.get('printing_order')
product_id = qp.get('product_id')
product_name = qp.get('product_name')
product_human_id = qp.get('product_human_id')
product_width_size = qp.get('product_width_size')
product_color = qp.get('product_color')
if not customer_id:
return Response({'detail': 'customer_id 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
if not date_from or not date_to:
return Response({'detail': 'date_from 与 date_to 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
try:
customer_id_int = int(customer_id)
except (TypeError, ValueError):
return Response({'detail': 'customer_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
try:
start_date = datetime.datetime.strptime(date_from, '%Y-%m-%d').date()
end_date = datetime.datetime.strptime(date_to, '%Y-%m-%d').date()
except ValueError:
return Response({'detail': '日期格式需为 YYYY-MM-DD'}, status=status.HTTP_400_BAD_REQUEST)
# 闭区间:包含当日 00:00:00 和 23:59:59.999999
start_dt = datetime.datetime.combine(start_date, datetime.time.min)
end_dt = datetime.datetime.combine(end_date, datetime.time.max)
if timezone.is_naive(start_dt):
start_dt = timezone.make_aware(start_dt, timezone.get_default_timezone())
if timezone.is_naive(end_dt):
end_dt = timezone.make_aware(end_dt, timezone.get_default_timezone())
queryset = printing_models.PrintingJob.objects.select_related('printing_order', 'product').filter(
printing_order__customer_id=customer_id_int,
created_at__gte=start_dt,
created_at__lte=end_dt,
)
# 可选过滤printing_order
if printing_order_id:
try:
queryset = queryset.filter(printing_order_id=int(printing_order_id))
except (TypeError, ValueError):
return Response({'detail': 'printing_order 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
# 可选过滤product
if product_id:
try:
queryset = queryset.filter(product_id=int(product_id))
except (TypeError, ValueError):
return Response({'detail': 'product_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
if product_name:
queryset = queryset.filter(product__name__icontains=product_name)
if product_human_id:
queryset = queryset.filter(product__human_id__icontains=product_human_id)
if product_width_size:
try:
width_decimal = float(product_width_size)
except (TypeError, ValueError):
return Response({'detail': 'product_width_size 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
queryset = queryset.filter(product__width_size=width_decimal)
if product_color:
queryset = queryset.filter(product__color__icontains=product_color)
queryset = queryset.order_by('-created_at')
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
class PrintingOrderWithoutSalesOrderSerializer(serializers.ModelSerializer):
"""未关联非作废销售单的印染订单列表序列化器。"""
customer_name = serializers.CharField(source='customer.name', read_only=True)
class Meta:
model = printing_models.PrintingOrder
fields = [
'id',
'human_id',
'external_order_id',
'customer',
'customer_name',
'fabric',
'width',
'is_urgent',
'is_invalid',
'outgoing_date',
'created_at',
'updated_at',
]
read_only_fields = fields
class PrintingOrderWithoutSalesOrderView(APIView):
"""
按客户查询尚未关联非作废销售单的印染订单。
关联路径:
PrintingOrder -> PrintingJob -> SalesOrderItem -> SalesOrder
已作废 SalesOrder 不计为有效关联。
"""
serializer_class = PrintingOrderWithoutSalesOrderSerializer
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
def get(self, request):
customer_id = request.query_params.get('customer_id')
if not customer_id:
return Response({'detail': 'customer_id 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
try:
customer_id_int = int(customer_id)
except (TypeError, ValueError):
return Response({'detail': 'customer_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
merchant = request.user.employee.merchant
active_sales_order_items = business_models.SalesOrderItem.objects.filter(
printing_job__printing_order_id=OuterRef('pk'),
).exclude(
sales_order__status=business_models.SalesOrderStatusEnum.CANCELLED,
)
queryset = (
printing_models.PrintingOrder.objects
.select_related('customer')
.filter(customer_id=customer_id_int)
.filter(Q(merchant=merchant) | Q(merchant__isnull=True, customer__merchant=merchant))
.annotate(_has_active_sales_order=Exists(active_sales_order_items))
.filter(_has_active_sales_order=False)
.order_by('-created_at', '-id')
)
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']:
# 修复历史/异常数据:确保该 job 的流程实例正确绑定到 job避免 BusinessObject.object_id/content_type 为空)
stateflow_services.ensure_business_object_bound_to_instance(
job.business_object,
job,
default_name=f"PrintingJob-{job.id}",
)
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,
})
class PlateOrderByProcessNodeSerializer(serializers.ModelSerializer):
"""按流程节点筛选 PlateOrder 的列表序列化(尽量保持轻量)"""
design_code = serializers.SerializerMethodField()
customer_name = serializers.CharField(source='customer.name', read_only=True)
business_object_id = serializers.SerializerMethodField()
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
process_parameters = serializers.SerializerMethodField()
plate_image = serializers.SerializerMethodField()
class Meta:
model = printing_models.PlateOrder
fields = [
'id',
'original_id',
'design_code',
'plate_image',
'customer',
'customer_name',
'style_name',
'urgency_level',
'is_invalid',
'business_object_id',
'created_by',
'process_parameters',
'created_at',
'updated_at',
]
read_only_fields = fields
def get_design_code(self, obj: printing_models.PlateOrder) -> str | None:
return obj.design_code or (str(obj.id) if obj.id else None)
def get_business_object_id(self, obj: printing_models.PlateOrder) -> int | None:
return obj.business_object_id
def get_process_parameters(self, obj: printing_models.PlateOrder) -> list[dict]:
"""
返回“订单维度”的工艺参数 key/value当前 process_node 对应 state 的参数)。
取值来源:
- 优先取该订单 business_object 在目标 state 的**最新一次 StateFlowRecord**(可能是已撤销记录)
对应的 StateLogParameterRecord 汇总(后提交覆盖先提交)。
- 若从未提交过该 state 的参数value 为 null由前端自行用顶层 parameters 的默认值做兜底/占位)
"""
keys: list[str] = self.context.get('target_parameter_keys') or []
target_state_id: int | None = self.context.get('target_state_id')
if not keys or not target_state_id:
return [{'key': k, 'value': None} for k in keys]
bo = getattr(obj, 'business_object', None)
if not bo:
return [{'key': k, 'value': None} for k in keys]
logs = getattr(bo, '_prefetched_target_state_logs', None)
if logs is None:
# fallback极少数情况下未预取
logs = list(
bo.state_logs.filter(state_id=target_state_id).order_by('-completed_at', '-id')[:1]
)
latest_log = logs[0] if logs else None
summary: dict = {}
if latest_log:
param_records = getattr(latest_log, '_prefetched_parameter_records', None)
if param_records is None:
param_records = list(latest_log.parameter_records.all().order_by('created_at', 'id'))
for rec in param_records:
summary.update(rec.parameters or {})
return [{'key': k, 'value': summary.get(k)} for k in keys]
def get_plate_image(self, obj: printing_models.PlateOrder) -> list[dict]:
return _serialize_plate_image_first(getattr(obj, 'plate_image', None), self.context.get('request'))
class PlateOrderByProcessNodeView(APIView):
"""
按 process_node_id 查询“当前处于该节点NEXT 模式:下一个待执行节点)”的 PlateOrder 列表。
GET /api/v2/plate-orders/by-process-node/
Query 参数:
- process_node_id: 必填ProcessNode.id
- search: 可选。支持:
- 纯数字:同时匹配 id 精确 + design_code icontains
- 非纯数字design_code icontains
- ordering: 可选,默认 -created_at支持: id / created_at / updated_at / design_code
- limit/offset: 分页limit 默认 20
"""
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
_ORDERING_FIELDS = {'id', 'created_at', 'updated_at', 'design_code'}
def get(self, request):
qp = request.query_params
process_node_id = qp.get('process_node_id')
param_key = (qp.get('param_key') or '').strip()
param_value = (qp.get('param_value') or '').strip()
date_from = qp.get('date_from')
date_to = qp.get('date_to')
if not process_node_id:
return Response({'detail': 'process_node_id 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
if (param_key and not param_value) or (param_value and not param_key):
return Response({'detail': 'param_key 与 param_value 必须同时提供'}, status=status.HTTP_400_BAD_REQUEST)
if (date_from and not date_to) or (date_to and not date_from):
return Response({'detail': 'date_from 与 date_to 必须同时提供'}, status=status.HTTP_400_BAD_REQUEST)
try:
process_node_id_int = int(process_node_id)
except (TypeError, ValueError):
return Response({'detail': 'process_node_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
try:
process_node = (
stateflow_models.ProcessNode.objects
.select_related('process', 'state')
.get(id=process_node_id_int)
)
except stateflow_models.ProcessNode.DoesNotExist:
return Response({'detail': 'process_node 不存在'}, status=status.HTTP_404_NOT_FOUND)
if date_from and date_to:
try:
start_date = datetime.datetime.strptime(date_from, '%Y-%m-%d').date()
end_date = datetime.datetime.strptime(date_to, '%Y-%m-%d').date()
except ValueError:
return Response({'detail': '日期格式需为 YYYY-MM-DD'}, status=status.HTTP_400_BAD_REQUEST)
# 闭区间:包含当日 00:00:00 和 23:59:59.999999
start_dt = datetime.datetime.combine(start_date, datetime.time.min)
end_dt = datetime.datetime.combine(end_date, datetime.time.max)
if timezone.is_naive(start_dt):
start_dt = timezone.make_aware(start_dt, timezone.get_default_timezone())
if timezone.is_naive(end_dt):
end_dt = timezone.make_aware(end_dt, timezone.get_default_timezone())
# 目标节点信息
target_state_id = process_node.state_id
target_order = process_node.order
process_id = process_node.process_id
# 目标节点之前的所有 state_iddistinct便于计数
prev_state_ids = list(
stateflow_models.ProcessNode.objects
.filter(process_id=process_id, order__lt=target_order)
.order_by('order', 'id')
.values_list('state_id', flat=True)
.distinct()
)
queryset = (
printing_models.PlateOrder.objects
.select_related('customer', 'business_object')
.filter(business_object__isnull=False, business_object__process_id=process_id)
)
if date_from and date_to:
queryset = queryset.filter(created_at__gte=start_dt, created_at__lte=end_dt)
# COMPLETED 模式判定:目标节点已完成(未撤销)
if prev_state_ids:
queryset = queryset.annotate(
completed_prev_states=Count(
'business_object__state_logs__state_id',
filter=Q(
business_object__state_logs__is_cancelled=False,
business_object__state_logs__state_id__in=prev_state_ids,
),
distinct=True,
)
).filter(completed_prev_states=len(prev_state_ids))
queryset = queryset.annotate(
completed_target=Count(
'business_object__state_logs__id',
filter=Q(
business_object__state_logs__is_cancelled=False,
business_object__state_logs__state_id=target_state_id,
),
distinct=True,
)
).filter(completed_target__gt=0)
if param_key and param_value:
matching_logs = stateflow_models.StateFlowRecord.objects.filter(
business_object_id=OuterRef('business_object_id'),
state_id=target_state_id,
is_cancelled=False,
parameter_records__parameters__has_key=param_key,
).annotate(
_param_value=KeyTextTransform(param_key, 'parameter_records__parameters')
).filter(
_param_value__icontains=param_value,
)
queryset = queryset.annotate(_has_state_param=Exists(matching_logs)).filter(_has_state_param=True)
# search同时支持主键与 design_code icontains不新增额外参数
search = (qp.get('search') or '').strip()
if search:
if search.isdigit():
try:
search_id = int(search)
except (TypeError, ValueError):
search_id = None
cond = Q(design_code__icontains=search)
if search_id is not None:
cond = cond | Q(id=search_id)
queryset = queryset.filter(cond)
else:
queryset = queryset.filter(design_code__icontains=search)
# ordering默认 -created_at
ordering = (qp.get('ordering') or '-created_at').strip() or '-created_at'
direction = '-' if ordering.startswith('-') else ''
field = ordering[1:] if ordering.startswith('-') else ordering
if field not in self._ORDERING_FIELDS:
return Response(
{'detail': f'ordering 不支持: {ordering}(可选: {", ".join(sorted(self._ORDERING_FIELDS))}'},
status=status.HTTP_400_BAD_REQUEST,
)
# 为 design_code 排序提供兜底:为空时按主键字符串
if field == 'design_code':
queryset = queryset.annotate(
design_code_normalized=Coalesce('design_code', Cast('id', output_field=CharField()))
).order_by(f'{direction}design_code_normalized', 'id')
else:
queryset = queryset.order_by(f'{direction}{field}', 'id')
# 该节点参数模板(仅 key/value同时用于 results[*].process_parameters 的 key 集合
params = list(process_node.state.parameters.order_by('id').values('key', 'value'))
target_keys = [p['key'] for p in params]
# 预取:目标 state 的最新日志及其参数记录(用于拼装“订单维度”的参数值,避免 N+1
param_records_prefetch = Prefetch(
'parameter_records',
queryset=stateflow_models.StateLogParameterRecord.objects.order_by('created_at', 'id'),
to_attr='_prefetched_parameter_records',
)
target_state_logs_qs = (
stateflow_models.StateFlowRecord.objects
.filter(state_id=target_state_id)
.order_by('-completed_at', '-id')
.prefetch_related(param_records_prefetch)
)
queryset = queryset.prefetch_related(
Prefetch(
'business_object__state_logs',
queryset=target_state_logs_qs,
to_attr='_prefetched_target_state_logs',
)
)
# 分页limit 默认 20
paginator = LimitOffsetPagination()
paginator.default_limit = 20
page = paginator.paginate_queryset(queryset, request, view=self)
results = page if page is not None else list(queryset)
srz = PlateOrderByProcessNodeSerializer(
results,
many=True,
context={
'request': request,
'target_state_id': target_state_id,
'target_parameter_keys': target_keys,
},
)
return Response({
'process_node': {
'id': process_node.id,
'process_id': process_id,
'state_id': target_state_id,
'state_name': process_node.state.name,
'order': target_order,
},
'parameters': params,
'count': getattr(paginator, 'count', len(results)),
'next': paginator.get_next_link() if page is not None else None,
'previous': paginator.get_previous_link() if page is not None else None,
'results': srz.data,
})
class PlateOrderByProcessSerializer(serializers.ModelSerializer):
"""按流程process_id查询 PlateOrder附带所有节点的参数汇总订单维度"""
design_code = serializers.SerializerMethodField()
customer_name = serializers.CharField(source='customer.name', read_only=True)
business_object_id = serializers.SerializerMethodField()
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
process_params = serializers.SerializerMethodField()
plate_image = serializers.SerializerMethodField()
class Meta:
model = printing_models.PlateOrder
fields = [
'id',
'original_id',
'design_code',
'plate_image',
'customer',
'customer_name',
'style_name',
'urgency_level',
'is_invalid',
'business_object_id',
'created_by',
'process_params',
'created_at',
'updated_at',
]
read_only_fields = fields
def get_design_code(self, obj: printing_models.PlateOrder) -> str | None:
return obj.design_code or (str(obj.id) if obj.id else None)
def get_business_object_id(self, obj: printing_models.PlateOrder) -> int | None:
return obj.business_object_id
def get_process_params(self, obj: printing_models.PlateOrder) -> list[dict]:
"""
返回所有流程节点的参数视图(订单维度)。
约束(前端可依赖):
- 一定包含 process 的全部节点(按 order 升序)
- 每个节点包含 is_executed默认不含撤销记录
- params 的 key/顺序与该节点 State.parameters 的 key/顺序一致
- 未执行或未提交的参数 value 为 null
"""
nodes_info: list[dict] = self.context.get('process_nodes_info') or []
if not nodes_info:
return []
bo = getattr(obj, 'business_object', None)
if not bo:
# 没有关联流程实例:全部视为未执行
result = []
for node in nodes_info:
result.append({
'process_node_id': node['process_node_id'],
'state_id': node['state_id'],
'node_name': node['node_name'],
'order': node['order'],
'is_executed': False,
'params': [{'key': k, 'value': None} for k in (node.get('keys') or [])],
})
return result
logs = getattr(bo, '_prefetched_state_logs_for_process_params', None)
if logs is None:
state_ids = [n['state_id'] for n in nodes_info]
logs = list(
bo.state_logs.filter(is_cancelled=False, state_id__in=state_ids)
.order_by('-completed_at', '-id')
.prefetch_related('parameter_records')
)
# logs 已按时间倒序:第一次出现的 state_id 即“最新一次非撤销执行记录”
latest_log_by_state: dict[int, stateflow_models.StateFlowRecord] = {}
for log in logs:
if log.state_id not in latest_log_by_state:
latest_log_by_state[log.state_id] = log
result = []
for node in nodes_info:
state_id = node['state_id']
keys = node.get('keys') or []
log = latest_log_by_state.get(state_id)
is_executed = log is not None
summary: dict = {}
if log is not None:
param_records = getattr(log, '_prefetched_parameter_records', None)
if param_records is None:
param_records = list(log.parameter_records.all().order_by('created_at', 'id'))
for rec in param_records:
summary.update(rec.parameters or {})
result.append({
'process_node_id': node['process_node_id'],
'state_id': state_id,
'node_name': node['node_name'],
'order': node['order'],
'is_executed': is_executed,
'params': [{'key': k, 'value': summary.get(k) if is_executed else None} for k in keys],
})
return result
def get_plate_image(self, obj: printing_models.PlateOrder) -> list[dict]:
return _serialize_plate_image_first(getattr(obj, 'plate_image', None), self.context.get('request'))
class PlateOrderByStateStatusSerializer(PlateOrderListV1Serializer):
"""按单个节点状态过滤 PlateOrder并扩展状态流信息"""
state_parameters = serializers.SerializerMethodField()
state_log = serializers.SerializerMethodField()
state_status = serializers.SerializerMethodField()
class Meta(PlateOrderListV1Serializer.Meta):
fields = list(PlateOrderListV1Serializer.Meta.fields) + [
'state_status',
'state_parameters',
'state_log',
]
read_only_fields = list(PlateOrderListV1Serializer.Meta.read_only_fields) + [
'state_status',
'state_parameters',
'state_log',
]
def get_state_status(self, obj: printing_models.PlateOrder) -> str:
return self.context.get('requested_status', '')
def _get_latest_state_log(self, obj: printing_models.PlateOrder):
bo = getattr(obj, 'business_object', None)
if not bo:
return None
logs = getattr(bo, '_prefetched_target_state_logs', None)
if logs:
return logs[0]
target_state_id = self.context.get('target_state_id')
if not target_state_id:
return None
return (
bo.state_logs
.filter(state_id=target_state_id)
.order_by('-completed_at', '-id')
.select_related('completed_by')
.first()
)
def get_state_parameters(self, obj: printing_models.PlateOrder) -> list[dict]:
keys: list[str] = self.context.get('target_parameter_keys') or []
if not keys:
return []
bo = getattr(obj, 'business_object', None)
if not bo:
return [{'key': k, 'value': None} for k in keys]
latest_log = self._get_latest_state_log(obj)
if latest_log is None:
return [{'key': k, 'value': None} for k in keys]
param_records = getattr(latest_log, '_prefetched_parameter_records', None)
if param_records is None:
param_records = list(latest_log.parameter_records.all().order_by('created_at', 'id'))
summary: dict = {}
for rec in param_records:
summary.update(rec.parameters or {})
return [{'key': k, 'value': summary.get(k)} for k in keys]
def get_state_log(self, obj: printing_models.PlateOrder) -> dict | None:
log = self._get_latest_state_log(obj)
if log is None:
return None
completed_by = log.completed_by
return {
'id': log.id,
'state_id': log.state_id,
'completed_at': log.completed_at,
'completed_by': completed_by.id if completed_by else None,
'completed_by_username': completed_by.username if completed_by else None,
'is_cancelled': log.is_cancelled,
}
def to_representation(self, instance):
data = super().to_representation(instance)
plate_image = data.get('plate_image')
if isinstance(plate_image, list):
data['plate_image'] = plate_image[:1]
return data
class PlateOrderByProcessView(APIView):
"""
按 process_id 查询 PlateOrder 列表,并在每条 PlateOrder 中返回 process 的所有节点参数结构。
GET /api/v2/plate-orders/by-process/
Query 参数:
- process_id: 必填Process.id
- date_from/date_to: 必填YYYY-MM-DD按 PlateOrder.created_at 闭区间过滤)
- plate_order: 可选。支持:
- 纯数字:同时匹配 id 精确 + design_code icontains
- 非纯数字design_code icontains
- ordering: 可选,默认 -created_at支持: id / created_at / updated_at / design_code
- limit/offset: 分页limit 默认 20
"""
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
_ORDERING_FIELDS = {'id', 'created_at', 'updated_at', 'design_code'}
def get(self, request):
qp = request.query_params
process_id = qp.get('process_id')
if not process_id:
return Response({'detail': 'process_id 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
try:
process_id_int = int(process_id)
except (TypeError, ValueError):
return Response({'detail': 'process_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
date_from = qp.get('date_from')
date_to = qp.get('date_to')
if not date_from or not date_to:
return Response({'detail': 'date_from 与 date_to 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
try:
start_date = datetime.datetime.strptime(date_from, '%Y-%m-%d').date()
end_date = datetime.datetime.strptime(date_to, '%Y-%m-%d').date()
except ValueError:
return Response({'detail': '日期格式需为 YYYY-MM-DD'}, status=status.HTTP_400_BAD_REQUEST)
# 闭区间:包含当日 00:00:00 和 23:59:59.999999
start_dt = datetime.datetime.combine(start_date, datetime.time.min)
end_dt = datetime.datetime.combine(end_date, datetime.time.max)
if timezone.is_naive(start_dt):
start_dt = timezone.make_aware(start_dt, timezone.get_default_timezone())
if timezone.is_naive(end_dt):
end_dt = timezone.make_aware(end_dt, timezone.get_default_timezone())
try:
process = stateflow_models.Process.objects.get(id=process_id_int)
except stateflow_models.Process.DoesNotExist:
return Response({'detail': 'process 不存在'}, status=status.HTTP_404_NOT_FOUND)
# 该 process 的所有节点(含参数定义 key 顺序)
params_prefetch = Prefetch(
'state__parameters',
queryset=stateflow_models.StateParameter.objects.order_by('id'),
)
process_nodes = list(
stateflow_models.ProcessNode.objects
.filter(process_id=process_id_int)
.select_related('state')
.prefetch_related(params_prefetch)
.order_by('order', 'id')
)
process_nodes_info = []
state_ids = []
for pn in process_nodes:
keys = [p.key for p in pn.state.parameters.all()]
process_nodes_info.append({
'process_node_id': pn.id,
'state_id': pn.state_id,
'node_name': pn.state.name,
'order': pn.order,
'keys': keys,
})
state_ids.append(pn.state_id)
queryset = (
printing_models.PlateOrder.objects
.select_related('customer', 'business_object')
.filter(
process=process_id_int,
created_at__gte=start_dt,
created_at__lte=end_dt,
)
)
# plate_order同时支持主键与 design_code icontains不新增额外参数
plate_order = (qp.get('plate_order') or '').strip()
if plate_order:
if plate_order.isdigit():
try:
pid = int(plate_order)
except (TypeError, ValueError):
pid = None
cond = Q(design_code__icontains=plate_order)
if pid is not None:
cond = cond | Q(id=pid)
queryset = queryset.filter(cond)
else:
queryset = queryset.filter(design_code__icontains=plate_order)
# ordering默认 -created_at
ordering = (qp.get('ordering') or '-created_at').strip() or '-created_at'
direction = '-' if ordering.startswith('-') else ''
field = ordering[1:] if ordering.startswith('-') else ordering
if field not in self._ORDERING_FIELDS:
return Response(
{'detail': f'ordering 不支持: {ordering}(可选: {", ".join(sorted(self._ORDERING_FIELDS))}'},
status=status.HTTP_400_BAD_REQUEST,
)
if field == 'design_code':
queryset = queryset.annotate(
design_code_normalized=Coalesce('design_code', Cast('id', output_field=CharField()))
).order_by(f'{direction}design_code_normalized', 'id')
else:
queryset = queryset.order_by(f'{direction}{field}', 'id')
# 预取:非撤销的状态日志及其参数记录(用于计算 is_executed + value
state_ids_unique = sorted(set(state_ids))
param_records_prefetch = Prefetch(
'parameter_records',
queryset=stateflow_models.StateLogParameterRecord.objects.order_by('created_at', 'id'),
to_attr='_prefetched_parameter_records',
)
logs_qs = (
stateflow_models.StateFlowRecord.objects
.filter(is_cancelled=False)
.order_by('-completed_at', '-id')
.prefetch_related(param_records_prefetch)
)
if state_ids_unique:
logs_qs = logs_qs.filter(state_id__in=state_ids_unique)
queryset = queryset.prefetch_related(
Prefetch(
'business_object__state_logs',
queryset=logs_qs,
to_attr='_prefetched_state_logs_for_process_params',
)
)
paginator = LimitOffsetPagination()
paginator.default_limit = 20
page = paginator.paginate_queryset(queryset, request, view=self)
results = page if page is not None else list(queryset)
srz = PlateOrderByProcessSerializer(
results,
many=True,
context={
'request': request,
'process_nodes_info': process_nodes_info,
},
)
return Response({
'process': {
'id': process.id,
'name': process.name,
'node_count': len(process_nodes_info),
},
'count': getattr(paginator, 'count', len(results)),
'next': paginator.get_next_link() if page is not None else None,
'previous': paginator.get_previous_link() if page is not None else None,
'results': srz.data,
})
class PlateOrderByStateStatusView(APIView):
"""按 process_id + state_id + status 过滤 PlateOrder。"""
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
_ORDERING_FIELDS = {'id', 'created_at', 'updated_at', 'design_code'}
_SUPPORTED_STATUS = {'completed', 'not_started', 'cancelled', 'in_progress'}
def get(self, request):
qp = request.query_params
process_id = qp.get('process_id')
if not process_id:
return Response({'detail': 'process_id 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
try:
process_id_int = int(process_id)
except (TypeError, ValueError):
return Response({'detail': 'process_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
state_id_raw = qp.get('state_id')
state_id_int: int | None = None
if state_id_raw not in (None, ''):
try:
state_id_int = int(state_id_raw)
except (TypeError, ValueError):
return Response({'detail': 'state_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
status_value = (qp.get('status') or 'completed').strip().lower()
if state_id_int is None:
if qp.get('status') and status_value != 'not_started':
return Response({'detail': 'state_id 为空时仅支持 status=not_started'}, status=status.HTTP_400_BAD_REQUEST)
status_value = 'not_started'
elif status_value == 'not_started':
return Response({'detail': 'state_id 不为空时不支持 status=not_started'}, status=status.HTTP_400_BAD_REQUEST)
elif status_value not in self._SUPPORTED_STATUS:
options = ', '.join(sorted(self._SUPPORTED_STATUS))
return Response({'detail': f'status 不支持: {status_value}(可选: {options}'}, status=status.HTTP_400_BAD_REQUEST)
try:
process = stateflow_models.Process.objects.get(id=process_id_int)
except stateflow_models.Process.DoesNotExist:
return Response({'detail': 'process 不存在'}, status=status.HTTP_404_NOT_FOUND)
state = None
state_parameters_template: list[dict] = []
target_keys: list[str] = []
representative_order: int | None = None
process_node_ids: list[int] = []
base_bo = stateflow_models.BusinessObject.objects.filter(
process_id=process_id_int,
plate_order__isnull=False,
)
if state_id_int is None:
filtered_bo = base_bo.annotate(
has_any_log=Exists(
stateflow_models.StateFlowRecord.objects.filter(business_object_id=OuterRef('pk'))
)
).filter(has_any_log=False)
queryset = (
printing_models.PlateOrder.objects
.select_related('customer', 'business_object', 'created_by')
.filter(process=process_id_int, business_object_id__in=filtered_bo.values('id'))
)
else:
try:
state = stateflow_models.State.objects.get(id=state_id_int)
except stateflow_models.State.DoesNotExist:
return Response({'detail': 'state 不存在'}, status=status.HTTP_404_NOT_FOUND)
process_nodes = list(
stateflow_models.ProcessNode.objects
.filter(process_id=process_id_int, state_id=state_id_int)
.order_by('order', 'id')
)
if not process_nodes:
return Response({'detail': 'state 不属于该 process'}, status=status.HTTP_400_BAD_REQUEST)
state_parameters_template = [
{'key': param.key, 'value': param.value}
for param in state.parameters.order_by('id')
]
target_keys = [item['key'] for item in state_parameters_template]
representative_order = process_nodes[0].order
process_node_ids = [node.id for node in process_nodes]
filtered_bo = stateflow_services.query_business_objects_by_state_status(
state_ids=[state_id_int],
status=status_value,
process_id=process_id_int,
base_queryset=base_bo,
)
queryset = (
printing_models.PlateOrder.objects
.select_related('customer', 'business_object', 'created_by')
.filter(process=process_id_int, business_object_id__in=filtered_bo.values('id'))
)
param_records_prefetch = Prefetch(
'parameter_records',
queryset=stateflow_models.StateLogParameterRecord.objects.order_by('created_at', 'id'),
to_attr='_prefetched_parameter_records',
)
target_logs_qs = (
stateflow_models.StateFlowRecord.objects
.filter(state_id=state_id_int)
.order_by('-completed_at', '-id')
.select_related('completed_by')
.prefetch_related(param_records_prefetch)
)
queryset = queryset.prefetch_related(
Prefetch(
'business_object__state_logs',
queryset=target_logs_qs,
to_attr='_prefetched_target_state_logs',
)
)
if status_value == 'completed':
latest_completed_state_subquery = (
stateflow_models.StateFlowRecord.objects
.filter(
business_object_id=OuterRef('business_object_id'),
is_cancelled=False,
)
.order_by('-completed_at', '-id')
)
queryset = queryset.annotate(
latest_completed_state_id=Subquery(
latest_completed_state_subquery.values('state_id')[:1]
)
).filter(latest_completed_state_id=state_id_int)
ordering = (qp.get('ordering') or '-created_at').strip() or '-created_at'
direction = '-' if ordering.startswith('-') else ''
field = ordering[1:] if ordering.startswith('-') else ordering
if field not in self._ORDERING_FIELDS:
options = ', '.join(sorted(self._ORDERING_FIELDS))
return Response({'detail': f'ordering 不支持: {ordering}(可选: {options}'}, status=status.HTTP_400_BAD_REQUEST)
if field == 'design_code':
queryset = queryset.annotate(
design_code_normalized=Coalesce('design_code', Cast('id', output_field=CharField()))
).order_by(f'{direction}design_code_normalized', 'id')
else:
queryset = queryset.order_by(f'{direction}{field}', 'id')
paginator = LimitOffsetPagination()
paginator.default_limit = 20
page = paginator.paginate_queryset(queryset, request, view=self)
results = page if page is not None else list(queryset)
srz = PlateOrderByStateStatusSerializer(
results,
many=True,
context={
'request': request,
'target_state_id': state_id_int,
'target_parameter_keys': target_keys,
'requested_status': status_value,
},
)
return Response({
'process': {
'id': process.id,
'name': process.name,
},
'state': {
'id': state.id if state else None,
'name': state.name if state else None,
'order': representative_order,
'process_node_ids': process_node_ids,
} if state else None,
'status': status_value,
'state_parameters': state_parameters_template,
'count': getattr(paginator, 'count', len(results)),
'next': paginator.get_next_link() if page is not None else None,
'previous': paginator.get_previous_link() if page is not None else None,
'results': srz.data,
})
# 允许批量更新的字段白名单:只改这里即可增减
PLATE_ORDER_BATCH_UPDATE_ALLOWED_FIELDS = [
"original_id",
"design_code",
"plate_type",
"plate_date",
"plate_method",
"image_name",
"plate_notes",
"reprint_reason",
"urgency_level",
"is_invalid",
"customer",
"area",
"default_address",
"salesperson",
"merchandiser",
"designer",
"style_name",
"fabric",
"fabric_source",
"width",
"production_method",
"is_mark_frame",
"drawing_rating",
"color_matching_rating",
"sample_rating",
"difficulty_rating",
"sample_meter",
"required_sample_meters",
"required_completion_date",
"completion_date",
"approval_result",
"is_ordered",
"customer_feedback",
]
class PlateOrderBatchUpdateDataSerializer(serializers.ModelSerializer):
"""
PlateOrder 批量更新允许字段(白名单)。
说明:
- 不允许更新 process/business_object/created_by 等会触发流程副作用或越权的字段
- plate_image 属于结构化字段(且 v1 有额外转换逻辑),暂不纳入批量更新,避免前端误用
"""
class Meta:
model = printing_models.PlateOrder
fields = PLATE_ORDER_BATCH_UPDATE_ALLOWED_FIELDS
class PlateOrderBatchUpdateRequestSerializer(serializers.Serializer):
plate_order_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
allow_empty=False,
help_text="需要批量更新的 PlateOrder id 列表(同一组 data 会应用到所有 id",
)
data = serializers.DictField(
child=serializers.JSONField(),
allow_empty=False,
help_text='需要更新的字段集合,例如 {"urgency_level": "加急", "designer": 123}',
)
dry_run = serializers.BooleanField(
required=False,
default=False,
help_text="仅校验与预览,不实际写库",
)
def validate_plate_order_ids(self, value):
# 去重保持稳定性(前端可能重复传)
deduped = list(dict.fromkeys(value))
if not deduped:
raise serializers.ValidationError("plate_order_ids 不能为空")
return deduped
def validate_data(self, value):
data_srz = PlateOrderBatchUpdateDataSerializer(data=value, partial=True)
data_srz.is_valid(raise_exception=True)
if not data_srz.validated_data:
raise serializers.ValidationError({"detail": "data 不能为空"})
return data_srz.validated_data
class PlateOrderBatchUpdateView(APIView):
"""
PlateOrder 批量更新(同一份 data 应用到多个 plate_order
POST /api/v2/plate-orders/batch-update/
Body:
{
"plate_order_ids": [1, 2, 3],
"data": {"urgency_level": "加急", "designer": 10},
"dry_run": false
}
规则:
- 全成功/全失败(任意 id 不存在直接 400不做部分更新
- 需要 printing.change_plateorder 权限
- 如包含 is_invalid
- is_invalid=true 需要 printing.can_invalidate_plateorder
- is_invalid=false 需要 printing.can_activate_plateorder
"""
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
def post(self, request):
# 注意Django 会缓存 has_perm 结果user._perm_cache
# 在测试中 APIClient.force_authenticate 会复用同一个 user 实例,
# 可能导致“中途赋权后第二次请求仍判定无权限”的假阴性;这里主动清理缓存更稳妥。
for cache_attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
if hasattr(request.user, cache_attr):
delattr(request.user, cache_attr)
if not request.user.has_perm("printing.change_plateorder"):
return Response({"detail": "您没有权限批量更新开版订单"}, status=status.HTTP_403_FORBIDDEN)
# 先对 data 做“字段白名单”校验(保证错误输出为顶层 detail便于前端/测试消费)
raw_data = request.data.get("data") if isinstance(request.data, dict) else None
if isinstance(raw_data, dict):
allowed = set(PLATE_ORDER_BATCH_UPDATE_ALLOWED_FIELDS)
unknown = sorted(set(raw_data.keys()) - allowed)
if unknown:
return Response(
{"detail": f"不支持批量更新字段: {', '.join(unknown)}", "allowed_fields": sorted(allowed)},
status=status.HTTP_400_BAD_REQUEST,
)
srz = PlateOrderBatchUpdateRequestSerializer(data=request.data)
srz.is_valid(raise_exception=True)
payload = srz.validated_data
plate_order_ids: list[int] = payload["plate_order_ids"]
data: dict = payload["data"]
dry_run: bool = payload.get("dry_run", False)
# is_invalid 权限语义:沿用 v1 的作废/恢复权限
if "is_invalid" in data:
if data["is_invalid"] is True and not request.user.has_perm("printing.can_invalidate_plateorder"):
return Response({"detail": "您没有权限作废开版订单"}, status=status.HTTP_403_FORBIDDEN)
if data["is_invalid"] is False and not request.user.has_perm("printing.can_activate_plateorder"):
return Response({"detail": "您没有权限恢复开版订单"}, status=status.HTTP_403_FORBIDDEN)
qs = printing_models.PlateOrder.objects.filter(id__in=plate_order_ids)
found_ids = list(qs.values_list("id", flat=True))
found_set = set(found_ids)
missing_ids = [str(i) for i in plate_order_ids if i not in found_set]
if missing_ids:
return Response({"detail": f"以下 PlateOrder 不存在: {', '.join(missing_ids)}"}, status=status.HTTP_400_BAD_REQUEST)
# 返回/展示时保持与入参一致的顺序
found_ids = [i for i in plate_order_ids if i in found_set]
# 将 validated_data 转换为 queryset.update 可用的 kwargs处理 FK -> *_id
update_kwargs: dict = {}
for k, v in data.items():
try:
field = printing_models.PlateOrder._meta.get_field(k)
except Exception:
update_kwargs[k] = v
continue
if isinstance(field, django_models.ForeignKey):
update_kwargs[f"{k}_id"] = v.pk if v is not None else None
else:
update_kwargs[k] = v
update_kwargs["updated_at"] = timezone.now()
if dry_run:
return Response(
{
"dry_run": True,
"plate_order_ids": found_ids,
"matched_count": len(found_ids),
"data": request.data.get("data") or {},
}
)
with transaction.atomic():
updated_count = qs.update(**update_kwargs)
return Response(
{
"detail": "批量更新成功",
"updated_count": updated_count,
"plate_order_ids": found_ids,
}
)
class BatchAdvanceRecordSerializer(serializers.ModelSerializer):
"""批量操作记录序列化器"""
state_id = serializers.IntegerField(source='state.id', read_only=True)
state_name = serializers.CharField(source='state.name', read_only=True)
created_by_username = serializers.CharField(source='created_by.username', read_only=True, allow_null=True)
created_by_name = serializers.SerializerMethodField()
printing_job_ids = serializers.SerializerMethodField()
printing_job_count = serializers.SerializerMethodField()
class Meta:
model = printing_models.PrintingJobBatchAdvanceRecord
fields = [
'id',
'printing_order',
'state',
'state_id',
'state_name',
'created_by',
'created_by_username',
'created_by_name',
'parameters',
'only_parameters',
'printing_job_ids',
'printing_job_count',
'created_at',
]
read_only_fields = fields
def get_created_by_name(self, obj):
"""获取操作人员工姓名"""
user = getattr(obj, 'created_by', None)
if not user:
return None
emp = getattr(user, 'employee', None)
return getattr(emp, 'name', None)
def get_printing_job_ids(self, obj):
"""获取涉及的印染明细 ID 列表"""
# 使用预取的数据避免 N+1
if hasattr(obj, '_prefetched_objects_cache') and 'printing_jobs' in obj._prefetched_objects_cache:
return [job.id for job in obj.printing_jobs.all()]
return list(obj.printing_jobs.values_list('id', flat=True))
def get_printing_job_count(self, obj):
"""获取涉及的印染明细数量"""
if hasattr(obj, '_prefetched_objects_cache') and 'printing_jobs' in obj._prefetched_objects_cache:
return len(obj.printing_jobs.all())
return obj.printing_jobs.count()
class PrintingOrderBatchAdvanceRecordsView(APIView):
"""
获取印染订单的批量推进记录
根据 printing_order_id 查询该订单下所有的批量推进审计记录。
GET /api/v2/printing-orders/{printing_order_id}/batch-advance-records/
响应示例:
{
"printing_order_id": 55,
"records": [
{
"id": 9,
"printing_order": 55,
"state": 7,
"state_id": 7,
"state_name": "染色",
"created_by": 1001,
"created_by_username": "factory_user",
"created_by_name": "张三",
"parameters": {"temperature": "25.5"},
"printing_job_ids": [101, 102, 103],
"printing_job_count": 3,
"created_at": "2025-12-14T10:00:00+08:00"
}
],
"total_count": 1
}
"""
permission_classes = [permissions.IsAuthenticated]
def get(self, request, printing_order_id):
# 验证订单是否存在
try:
printing_order = printing_models.PrintingOrder.objects.get(id=printing_order_id)
except printing_models.PrintingOrder.DoesNotExist:
return Response(
{'error': f'未找到 ID 为 {printing_order_id} 的印染订单'},
status=status.HTTP_404_NOT_FOUND
)
# 查询批量推进记录,按创建时间倒序
records = printing_models.PrintingJobBatchAdvanceRecord.objects.filter(
printing_order_id=printing_order_id
).select_related(
'state',
'created_by',
'created_by__employee',
).prefetch_related(
'printing_jobs',
).order_by('-created_at')
serializer = BatchAdvanceRecordSerializer(records, many=True)
return Response({
'printing_order_id': printing_order_id,
'records': serializer.data,
'total_count': records.count(),
})
class PrintingOrderExternalSnapshotSyncRequestSerializer(serializers.Serializer):
external_order_id = serializers.CharField(max_length=100, help_text='外部订单编号')
allow_reset_stateflow = serializers.BooleanField(
required=False,
default=False,
help_text='是否允许先撤销目标订单下已有的所有工序,再执行覆盖同步',
)
def validate_external_order_id(self, value):
normalized = str(value or '').strip()
if not normalized:
raise serializers.ValidationError('external_order_id 不能为空')
return normalized
class PrintingOrderExternalSnapshotSyncView(APIView):
"""
按 external_order_id 触发外部订单快照同步。
行为:
- 若目标订单任意 printing_job 已关联销售品,则立即失败并记录审计
- 若目标订单存在已执行工序,默认失败;可通过 allow_reset_stateflow=true 先撤销全部工序后再覆盖
- 成功时复用现有外部订单映射逻辑覆盖 PrintingOrder / PrintingJob
"""
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
def post(self, request):
srz = PrintingOrderExternalSnapshotSyncRequestSerializer(data=request.data)
srz.is_valid(raise_exception=True)
payload = srz.validated_data
try:
result = sync_external_printing_order_snapshot_impl(
external_order_id=payload['external_order_id'],
operator_user=request.user,
allow_reset_stateflow=payload.get('allow_reset_stateflow', False),
)
except ExternalPrintingOrderSnapshotSyncError as exc:
return Response(
{
'detail': str(exc),
'audit_id': exc.audit_id,
'external_order_id': payload['external_order_id'],
},
status=exc.status_code,
)
return Response(
{
'detail': '外部订单快照同步成功',
**result,
},
status=status.HTTP_200_OK,
)
class PrintingJobBatchAddParametersRequestSerializer(serializers.Serializer):
"""批量补充参数请求序列化器"""
printing_job_ids = serializers.ListField(
child=serializers.IntegerField(),
min_length=1,
help_text='印染明细ID列表'
)
state_id = serializers.IntegerField(help_text='目标流程节点ID')
parameters = serializers.DictField(
child=serializers.CharField(allow_blank=True),
help_text='要补充的工艺参数'
)
remark = serializers.CharField(required=False, default='', allow_blank=True, help_text='备注说明')
def _validate_jobs_for_batch_add_parameters(printing_job_ids: list[int], state_id: int) -> dict:
"""
验证批量补充参数的 jobs 并返回相关数据
验证规则:
1. 所有 job 必须存在
2. 所有 job 必须属于同一个 printing_order
3. 所有 job 必须有 business_object
4. 所有 job 的 business_object 必须有对应 state_id 的 state_log未撤销
返回:
{
'jobs': QuerySet[PrintingJob],
'printing_order': PrintingOrder,
'printing_order_id': int,
'state': State,
'state_logs': dict[int, StateFlowRecord], # job_id -> state_log
}
"""
from stateflow import models as stateflow_models
jobs = (
printing_models.PrintingJob.objects
.select_related('printing_order', 'business_object')
.filter(id__in=printing_job_ids)
)
found_ids = set(jobs.values_list('id', flat=True))
missing_ids = [i for i in printing_job_ids if i not in found_ids]
if missing_ids:
raise serializers.ValidationError({
'detail': f'以下 PrintingJob 不存在: {missing_ids}',
'missing_jobs': missing_ids,
})
# 验证同一订单
order_ids = set(jobs.values_list('printing_order_id', flat=True))
if len(order_ids) != 1:
raise serializers.ValidationError({
'detail': '所有 PrintingJob 必须属于同一个 PrintingOrder',
})
printing_order_id = order_ids.pop()
printing_order = printing_models.PrintingOrder.objects.get(id=printing_order_id)
# 验证 state 存在
try:
state = stateflow_models.State.objects.get(id=state_id)
except stateflow_models.State.DoesNotExist:
raise serializers.ValidationError({
'detail': f'State ID {state_id} 不存在',
})
# 验证所有 job 有 business_object 和对应的 state_log
jobs_without_bo = []
jobs_without_state_log = []
state_logs = {} # job_id -> state_log
for job in jobs:
if not job.business_object:
jobs_without_bo.append(job.id)
continue
# 查找对应 state_id 的 state_log未撤销
state_log = job.business_object.state_logs.filter(
state_id=state_id,
is_cancelled=False
).first()
if not state_log:
jobs_without_state_log.append(job.id)
else:
state_logs[job.id] = state_log
if jobs_without_bo:
raise serializers.ValidationError({
'detail': f'以下 PrintingJob 没有流程实例 (business_object): {jobs_without_bo}',
'missing_business_object': jobs_without_bo,
})
if jobs_without_state_log:
raise serializers.ValidationError({
'detail': f'以下 PrintingJob 没有对应 state_id={state_id} 的状态流转记录(或已撤销): {jobs_without_state_log}',
'missing_state_log': jobs_without_state_log,
})
return {
'jobs': jobs,
'printing_order': printing_order,
'printing_order_id': printing_order_id,
'state': state,
'state_logs': state_logs,
}
class PrintingJobBatchAddParametersView(APIView):
"""
批量补充工艺参数
为多个 PrintingJob 的已完成状态流转记录批量补充工艺参数。
POST /api/v2/printing-jobs/batch-add-parameters/
请求体:
{
"printing_job_ids": [101, 102, 103],
"state_id": 7,
"parameters": {
"temperature": "26.0",
"operator": "李四"
},
"remark": "批量补充参数备注"
}
响应:
{
"detail": "批量补充参数成功",
"batch_record_id": 15,
"printing_order_id": 55,
"printing_job_ids": [101, 102, 103],
"state_id": 7,
"state_name": "染色",
"parameters": {...},
"affected_count": 3
}
"""
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
def post(self, request):
srz = PrintingJobBatchAddParametersRequestSerializer(data=request.data)
srz.is_valid(raise_exception=True)
payload = srz.validated_data
printing_job_ids = payload['printing_job_ids']
state_id = payload['state_id']
parameters = payload['parameters']
remark = payload.get('remark', '')
if not parameters:
raise serializers.ValidationError({
'detail': '参数不能为空',
})
# 验证数据
data = _validate_jobs_for_batch_add_parameters(printing_job_ids, state_id)
from stateflow import services as stateflow_services
# 全成功/全失败
with transaction.atomic():
# 创建批量操作记录
record = printing_models.PrintingJobBatchAdvanceRecord.objects.create(
printing_order=data['printing_order'],
state=data['state'],
created_by=request.user,
parameters=parameters,
only_parameters=True, # 标记为仅补充参数
)
record.printing_jobs.set(data['jobs'])
# 逐个补充参数
for job in data['jobs']:
state_log = data['state_logs'][job.id]
stateflow_services.add_parameters_to_state_log(
state_log,
remark=remark,
**parameters
)
return Response({
'detail': '批量补充参数成功',
'batch_record_id': record.id,
'printing_order_id': data['printing_order_id'],
'printing_job_ids': [j.id for j in data['jobs']],
'state_id': state_id,
'state_name': data['state'].name,
'parameters': parameters,
'affected_count': len(data['jobs']),
})