import datetime from django.utils import timezone from django.db import transaction from django.db.models import Q, Count, CharField, Prefetch 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 stateflow import models as stateflow_models 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', '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']: 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() process_parameters = serializers.SerializerMethodField() class Meta: model = printing_models.PlateOrder fields = [ 'id', 'design_code', 'customer', 'customer_name', 'style_name', 'urgency_level', 'is_invalid', 'business_object_id', '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, })