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_id(distinct,便于计数) 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, } ) 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', '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(), })