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

1348 lines
53 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.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 printing import models as printing_models
from api_man.serializers import ProductSerializer
from api_v1.views.printing.serializers import PlateOrderListSerializer as PlateOrderListV1Serializer
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
class PrintingJobV2Serializer(serializers.ModelSerializer):
"""v2 独立的印染任务序列化器,包含开单数量"""
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',
'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', '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 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()
class Meta:
model = printing_models.PlateOrder
fields = [
'id',
'original_id',
'design_code',
'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]
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')
if not process_node_id:
return Response({'detail': 'process_node_id 为必填参数'}, 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)
# 目标节点信息
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)
)
# NEXT 模式判定:前置节点都已完成(未撤销) + 目标节点尚未完成(未撤销)
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=0)
# 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()
class Meta:
model = printing_models.PlateOrder
fields = [
'id',
'original_id',
'design_code',
'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
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,
}
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,
}
)