""" Printing API ViewSet """ from rest_framework import viewsets, filters, status from rest_framework.decorators import action from rest_framework.exceptions import ValidationError as DRFValidationError from rest_framework.response import Response from rest_framework.permissions import BasePermission from rest_framework.permissions import DjangoModelPermissions from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from django.core.exceptions import ValidationError from django.db.models import CharField, Exists, OuterRef, Prefetch, Subquery from django.db.models.functions import Cast, Coalesce from django.views.decorators.cache import cache_page from django.views.decorators.http import condition from django.utils.decorators import method_decorator from django.utils import timezone from django.utils.dateparse import parse_date, parse_datetime from django_filters.rest_framework import DjangoFilterBackend from django_filters import rest_framework as django_filters from django_filters import IsoDateTimeFilter from datetime import datetime, time, timedelta from flower.viewsets import LimitedModelViewSet from printing import models from basic_info import models as basic_models from basic_info.models import MerchantTypeEnum from .serializers import ( PrintingOrderListSerializer, PrintingOrderDetailSerializer, PrintingOrderCreateUpdateSerializer, PrintingJobListSerializer, PrintingJobDetailSerializer, PrintingJobCreateUpdateSerializer, ) from .mixins import CustomerVisibilityFilterMixin class IsPrintingFactory(BasePermission): """自定义权限类,允许印染工厂用户访问""" message = "您没有访问印染订单的权限" def has_permission(self, request, view): if not request.user.is_authenticated: return False if hasattr(request.user, "employee"): return request.user.employee.merchant.type == MerchantTypeEnum.FACTORY return False class HasInvalidatePrintingOrderPermission(BasePermission): """自定义权限类,检查用户是否有作废印染订单的权限""" message = "您没有权限作废订单" def has_permission(self, request, view): if not request.user.is_authenticated: return False return request.user.has_perm("printing.can_invalidate_printingorder") class HasActivatePrintingOrderPermission(BasePermission): """自定义权限类,检查用户是否有恢复印染订单的权限""" message = "您没有权限恢复订单" def has_permission(self, request, view): if not request.user.is_authenticated: return False return request.user.has_perm("printing.can_activate_printingorder") class PrintingOrderFilterSet(django_filters.FilterSet): """印染订单过滤器""" external_order_id = django_filters.CharFilter(lookup_expr="icontains") customer_name = django_filters.CharFilter( field_name="customer__name", lookup_expr="icontains" ) customer_phone = django_filters.CharFilter( field_name="customer__phone", lookup_expr="icontains" ) fabric = django_filters.CharFilter(lookup_expr="icontains") is_urgent = django_filters.BooleanFilter() is_fabric_received = django_filters.BooleanFilter() is_invalid = django_filters.BooleanFilter() area = django_filters.CharFilter(lookup_expr="icontains") outgoing_date_from = django_filters.CharFilter(method="filter_outgoing_date_from") outgoing_date_to = django_filters.CharFilter(method="filter_outgoing_date_to") kd_riqi_from = django_filters.CharFilter(method="filter_kd_riqi_from") kd_riqi_to = django_filters.CharFilter(method="filter_kd_riqi_to") created_date_from = django_filters.DateFilter( field_name="created_at", lookup_expr="gte" ) created_date_to = django_filters.DateFilter( field_name="created_at", method="filter_created_date_to" ) # 支持同时搜索 human_id 和 external_order_id order_number = django_filters.CharFilter(method="filter_order_number") class Meta: model = models.PrintingOrder fields = [ "customer", "is_urgent", "is_fabric_received", "is_invalid", "external_order_id", "order_number", ] def filter_order_number(self, queryset, name, value): """ 按订单号过滤(支持 human_id 和 external_order_id) 同时搜索: 1. external_order_id(模糊匹配) 2. id(从 human_id 格式年月日+ID 中提取) 例如:order_number=20260323001505 - 匹配 external_order_id 包含 "20260323001505" - 匹配 id=1505(提取最后6位) """ if not value: return queryset from django.db.models import Q # 构建查询条件 q_objects = Q(external_order_id__icontains=value) # 尝试从 human_id 格式提取 ID # human_id 格式:年月日 + ID(如:20260323001505) if len(value) >= 6: # 尝试提取最后6位作为 ID id_part = value[-6:].lstrip("0") # 去除前导零 if id_part.isdigit(): q_objects |= Q(id=int(id_part)) # 如果 value 是纯数字,也尝试直接匹配 ID if value.isdigit(): q_objects |= Q(id=int(value)) return queryset.filter(q_objects) @staticmethod def _parse_date_or_datetime(value: str | None) -> tuple[datetime | None, bool]: """ 解析日期/日期时间字符串为“当前时区”的 aware datetime。 返回: - (dt, has_time) - has_time=True:输入包含时间(datetime) - has_time=False:输入为纯日期(date-only) """ s = (value or "").strip() if not s: return None, False tz = timezone.get_current_timezone() # 注意:Django 的 parse_datetime 在某些版本会把纯日期(YYYY-MM-DD)也解析成 00:00:00, # 这会让“to=日期”语义从“包含整天”退化为“<= 当天 00:00:00”。因此这里先尝试 parse_date。 d = parse_date(s) if d is not None: dt0 = timezone.make_aware(datetime.combine(d, time.min), tz) return dt0, False dt = parse_datetime(s) if dt is not None: if timezone.is_naive(dt): dt = timezone.make_aware(dt, tz) else: dt = timezone.localtime(dt, tz) return dt, True return None, False def filter_outgoing_date_from(self, queryset, name, value): dt, _has_time = self._parse_date_or_datetime(value) if dt is None: # 与 DRF/DjangoFilterBackend 行为保持一致:参数非法返回 400 raise ValidationError("outgoing_date_from 必须是日期或日期时间") return queryset.filter(outgoing_date__gte=dt) def filter_outgoing_date_to(self, queryset, name, value): dt, has_time = self._parse_date_or_datetime(value) if dt is None: raise ValidationError("outgoing_date_to 必须是日期或日期时间") # 纯日期:包含整天 => outgoing_date < 次日 00:00:00 if not has_time: return queryset.filter(outgoing_date__lt=dt + timedelta(days=1)) # 日期时间:按“<=”包含到秒 return queryset.filter(outgoing_date__lte=dt) def filter_kd_riqi_from(self, queryset, name, value): dt, _has_time = self._parse_date_or_datetime(value) if dt is None: raise ValidationError("kd_riqi_from 必须是日期或日期时间") return queryset.filter(kd_riqi__gte=dt) def filter_kd_riqi_to(self, queryset, name, value): dt, has_time = self._parse_date_or_datetime(value) if dt is None: raise ValidationError("kd_riqi_to 必须是日期或日期时间") if not has_time: return queryset.filter(kd_riqi__lt=dt + timedelta(days=1)) return queryset.filter(kd_riqi__lte=dt) def filter_created_date_to(self, queryset, name, value): """ 过滤 created_at <= 指定日期的 23:59:59.999999 使用 lt 次日 00:00:00 实现,确保包含整天 """ if value: from datetime import timedelta next_day = value + timedelta(days=1) return queryset.filter(created_at__lt=next_day) return queryset @method_decorator(cache_page(20), name="list") class PrintingOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): """ 印染订单 ViewSet 提供印染订单的增改查功能(不支持删除) list: 获取印染订单列表 retrieve: 获取印染订单详情 create: 创建印染订单 update: 更新印染订单 partial_update: 部分更新印染订单 查询参数: - customer: 客户ID - external_order_id: 外部订单编号(模糊查询) - customer_name: 客户名称(模糊查询) - customer_phone: 客户电话(模糊查询) - fabric: 面料(模糊查询) - is_urgent: 是否紧急(true/false) - is_fabric_received: 布料是否已收(true/false) - is_invalid: 是否作废(true/false) - area: 地区(模糊查询) - outgoing_date_from: 出货日期起始 - outgoing_date_to: 出货日期结束 - kd_riqi_from: 外部开单日期时间起始 - kd_riqi_to: 外部开单日期时间结束 - created_date_from: 创建日期起始 - created_date_to: 创建日期结束 - search: 全文搜索(客户名称、面料、地区、工艺) - ordering: 排序字段 返回字段说明: - jobs_status_summary: 订单下所有印染任务按下一待执行状态分组的数量汇总 格式: [{"state_name": "进度一", "state_id": 1, "count": 3}, ...] - jobs_last_status_summary: 订单下所有印染任务按最后完成状态分组的数量汇总 格式: [{"state_name": "进度一", "count": 3}, ...] 权限控制: - 默认按客户可见性过滤(员工只能看到自己负责的客户的订单) - 拥有 printing.view_all_printingorders 权限可突破此限制 """ queryset = models.PrintingOrder.objects.all() customer_field = "customer" view_all_permission = "printing.view_all_printingorders" permission_classes = [DjangoModelPermissions] filter_backends = [ DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter, ] filterset_class = PrintingOrderFilterSet search_fields = ["customer__name", "fabric", "area", "craft", "description"] ordering_fields = [ "id", "created_at", "updated_at", "outgoing_date", "kd_riqi", "is_urgent", "is_fabric_received", "is_invalid", ] ordering = ["-created_at"] def get_permissions(self): permissions = super().get_permissions() + [IsPrintingFactory()] if self.action == "invalidate": permissions.append(HasInvalidatePrintingOrderPermission()) elif self.action == "activate": permissions.append(HasActivatePrintingOrderPermission()) return permissions def get_serializer_class(self): """根据动作选择序列化器""" if self.action == "list": return PrintingOrderListSerializer elif self.action in ["create", "update", "partial_update"]: return PrintingOrderCreateUpdateSerializer else: # retrieve return PrintingOrderDetailSerializer def get_queryset(self): """优化查询""" queryset = super().get_queryset() # 默认不返回作废订单(仅 list);如显式传入 is_invalid 参数,则交由 FilterSet 处理 # 注意:不能影响 activate/invalidate 等 action,否则会因 get_object() 被过滤而 404。 if ( self.action == "list" and self.request and "is_invalid" not in self.request.query_params ): queryset = queryset.filter(is_invalid=False) if self.action == "list": customer_qs = basic_models.Customer.objects.filter(id=OuterRef("customer_id")) queryset = queryset.annotate( customer_name=Subquery(customer_qs.values("name")[:1]), customer_phone=Subquery(customer_qs.values("mobile")[:1]), ) elif self.action in ["retrieve", "by_external_order_id"]: queryset = queryset.select_related("customer") if self.action in ["list", "retrieve", "by_external_order_id"]: # 预取 printing_jobs 及其 business_object,用于状态汇总统计 # 同时预取 state_logs 和 process 相关数据以减少 job.status 属性调用时的 N+1 查询 queryset = queryset.prefetch_related( Prefetch( "printing_jobs", queryset=models.PrintingJob.objects.select_related( "business_object", "business_object__process", ).prefetch_related( "business_object__state_logs", "business_object__process__process_nodes", "business_object__process__process_nodes__state", ), ) ) return queryset def destroy(self, request, *args, **kwargs): """禁用删除操作""" return Response( {"detail": "印染订单不支持删除操作,请使用作废功能"}, status=status.HTTP_405_METHOD_NOT_ALLOWED, ) @action( detail=False, methods=["get"], url_path=r"by-external-order-id/(?P[^/.]+)", ) def by_external_order_id(self, request, external_order_id=None): """通过 external_order_id 获取印染订单详情""" normalized_external_order_id = str(external_order_id or "").strip() printing_order = ( self.get_queryset() .filter(external_order_id=normalized_external_order_id) .order_by("id") .first() ) if not printing_order: return Response( {"detail": "未找到该 external_order_id 对应的印染订单"}, status=status.HTTP_404_NOT_FOUND, ) serializer = self.get_serializer(printing_order) return Response(serializer.data) @action(detail=True, methods=["get"], url_path="ids") def ids(self, request, pk=None): """返回该印染订单下所有 PrintingJob ID。""" printing_order = self.get_object() ids = list( printing_order.printing_jobs.order_by("id").values_list("id", flat=True) ) return Response({"ids": ids}) @action(detail=True, methods=["post"]) def invalidate(self, request, pk=None): """ 作废订单 需要权限: printing.can_invalidate_printingorder """ # 检查权限 if not request.user.has_perm("printing.can_invalidate_printingorder"): return Response( {"detail": "您没有权限作废订单"}, status=status.HTTP_403_FORBIDDEN ) printing_order = self.get_object() if printing_order.is_invalid: return Response( {"detail": "该订单已经作废"}, status=status.HTTP_400_BAD_REQUEST ) printing_order.is_invalid = True printing_order.save() serializer = PrintingOrderDetailSerializer(printing_order) return Response({"detail": "订单已作废", "data": serializer.data}) @action(detail=True, methods=["post"]) def activate(self, request, pk=None): """ 恢复订单 需要权限: printing.can_activate_printingorder """ # 检查权限 if not request.user.has_perm("printing.can_activate_printingorder"): return Response( {"detail": "您没有权限恢复订单"}, status=status.HTTP_403_FORBIDDEN ) printing_order = self.get_object() if not printing_order.is_invalid: return Response( {"detail": "该订单未作废,无需恢复"}, status=status.HTTP_400_BAD_REQUEST ) printing_order.is_invalid = False printing_order.save() serializer = PrintingOrderDetailSerializer(printing_order) return Response({"detail": "订单已恢复", "data": serializer.data}) @action(detail=True, methods=["post"]) def mark_fabric_received(self, request, pk=None): """标记布料已收""" printing_order = self.get_object() if printing_order.is_fabric_received: return Response( {"detail": "布料已经标记为已收"}, status=status.HTTP_400_BAD_REQUEST ) printing_order.is_fabric_received = True printing_order.save() serializer = PrintingOrderDetailSerializer(printing_order) return Response({"detail": "已标记布料已收", "data": serializer.data}) class PrintingJobFilterSet(django_filters.FilterSet): """印染款式明细过滤器""" printing_order = django_filters.NumberFilter() external_order_id = django_filters.CharFilter( field_name="printing_order__external_order_id", lookup_expr="icontains" ) customer_name = django_filters.CharFilter( field_name="printing_order__customer__name", lookup_expr="icontains" ) product = django_filters.NumberFilter() product_name = django_filters.CharFilter( field_name="product__name", lookup_expr="icontains" ) unit = django_filters.CharFilter(lookup_expr="icontains") quantity_min = django_filters.NumberFilter(field_name="quantity", lookup_expr="gte") quantity_max = django_filters.NumberFilter(field_name="quantity", lookup_expr="lte") pieces_min = django_filters.NumberFilter(field_name="pieces", lookup_expr="gte") pieces_max = django_filters.NumberFilter(field_name="pieces", lookup_expr="lte") created_at_from = django_filters.CharFilter(method="filter_created_at_from") created_at_to = django_filters.CharFilter(method="filter_created_at_to") is_production_completed = django_filters.BooleanFilter() class Meta: model = models.PrintingJob fields = ["printing_order", "product", "external_order_id", "is_production_completed"] def filter_created_at_from(self, queryset, name, value): dt, _has_time = PrintingOrderFilterSet._parse_date_or_datetime(value) if dt is None: raise ValidationError("created_at_from 必须是日期或日期时间") return queryset.filter(created_at__gte=dt) def filter_created_at_to(self, queryset, name, value): dt, has_time = PrintingOrderFilterSet._parse_date_or_datetime(value) if dt is None: raise ValidationError("created_at_to 必须是日期或日期时间") if not has_time: return queryset.filter(created_at__lt=dt + timedelta(days=1)) return queryset.filter(created_at__lte=dt) class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): """ 印染款式明细 ViewSet 提供印染款式明细的增改查功能(不支持删除) list: 获取款式明细列表 retrieve: 获取款式明细详情 create: 创建款式明细 update: 更新款式明细 partial_update: 部分更新款式明细 advance_to_next_state: 推进到下一个状态 step_back_one_state: 回退一步 completed_states: 查询已完成的流程列表 timeline: 获取流程时间线 注意:状态流转接口是对 stateflow 模块的便捷包装 - 前端只需提供 printing_job 的 ID - 系统会自动查找关联的 business_object 并进行状态流转 - 也可以直接使用 stateflow 的原始接口(需要 business_object_id) 查询参数: - printing_order: 印染订单ID - external_order_id: 外部订单编号(按所属订单模糊查询) - customer_name: 客户名称(按所属订单客户名称模糊查询) - product: 产品ID - product_name: 产品名称(模糊查询) - unit: 单位(模糊查询) - quantity_min: 最小数量 - quantity_max: 最大数量 - pieces_min: 最小件数 - pieces_max: 最大件数 - created_at_from: 创建时间开始(支持 YYYY-MM-DD 或日期时间) - created_at_to: 创建时间结束(YYYY-MM-DD 会包含整天) - is_production_completed: 是否已标记完成生产(true/false) - search: 全文搜索(产品名称、单位、尺寸、备注) - ordering: 排序字段 权限控制: - 默认按客户可见性过滤(通过 printing_order.customer) - 拥有 printing.view_all_printingorders 权限可突破此限制 """ queryset = models.PrintingJob.objects.all() customer_field = "printing_order__customer" view_all_permission = "printing.view_all_printingorders" permission_classes = [DjangoModelPermissions, IsPrintingFactory] filter_backends = [ DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter, ] filterset_class = PrintingJobFilterSet search_fields = ["product__name", "unit", "size", "description"] ordering_fields = ["id", "created_at", "updated_at", "quantity", "pieces"] ordering = ["-created_at"] def get_serializer_class(self): """根据动作选择序列化器""" if self.action == "list": return PrintingJobListSerializer elif self.action in ["create", "update", "partial_update"]: return PrintingJobCreateUpdateSerializer else: # retrieve return PrintingJobDetailSerializer def get_queryset(self): """优化查询""" queryset = super().get_queryset() if self.action in ["list", "retrieve"]: from business.models import SalesOrderItem, SalesOrderStatusEnum queryset = queryset.select_related("printing_order", "printing_order__customer", "product") queryset = queryset.annotate( annotated_is_sales_order_bound=Exists( SalesOrderItem.objects.filter( printing_job_id=OuterRef("pk"), ).exclude(sales_order__status=SalesOrderStatusEnum.CANCELLED) ) ) # 预取批量推进记录(避免 serializer 产生 N+1) queryset = queryset.prefetch_related( Prefetch( "batch_advance_records", queryset=models.PrintingJobBatchAdvanceRecord.objects.select_related( "state", "created_by" ).order_by("-created_at", "-id"), ) ) return queryset @staticmethod def _attach_saleitems_cache(jobs): if not jobs: return from shipment.services import get_active_sales_items_queryset job_ids = [job.id for job in jobs] saleitems = list( get_active_sales_items_queryset().select_related("shipment", "created_by") .filter(printing_job_id__in=job_ids) .order_by("id") ) items_by_job_id = {job_id: [] for job_id in job_ids} for item in saleitems: items_by_job_id.setdefault(item.printing_job_id, []).append(item) for job in jobs: job._saleitems_cache = items_by_job_id.get(job.id, []) def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) if page is not None: self._attach_saleitems_cache(page) serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) jobs = list(queryset) self._attach_saleitems_cache(jobs) serializer = self.get_serializer(jobs, many=True) return Response(serializer.data) def retrieve(self, request, *args, **kwargs): instance = self.get_object() self._attach_saleitems_cache([instance]) serializer = self.get_serializer(instance) return Response(serializer.data) @action(detail=False, methods=["post"], url_path="mark-production-completed") def mark_production_completed(self, request): """ 显式标记印染任务已完成生产。 请求体: { "printing_job_id": 123 } """ printing_job_id = request.data.get("printing_job_id") if not printing_job_id: return Response( {"detail": "printing_job_id 不能为空"}, status=status.HTTP_400_BAD_REQUEST, ) user = request.user merchant = None if hasattr(user, "employee") and user.employee and user.employee.merchant: merchant = user.employee.merchant if merchant is None: return Response( {"detail": "当前用户未关联商户"}, status=status.HTTP_403_FORBIDDEN, ) try: job = models.PrintingJob.objects.get(pk=printing_job_id) except models.PrintingJob.DoesNotExist: return Response( {"detail": "PrintingJob 不存在"}, status=status.HTTP_404_NOT_FOUND, ) job_merchant = getattr(job, "merchant", None) if job_merchant is None and getattr(job, "printing_order", None): job_merchant = getattr(job.printing_order, "merchant", None) if job_merchant and job_merchant.id != merchant.id: return Response( {"detail": "您不能操作其他商户的 PrintingJob"}, status=status.HTTP_403_FORBIDDEN, ) from .services import PrintingJobService PrintingJobService.make_printing_job_production_completed( job, triggered_by=user, ) return Response({"message": "OK"}, status=status.HTTP_200_OK) def destroy(self, request, *args, **kwargs): """禁用删除操作""" return Response( {"detail": "印染款式明细不支持删除操作"}, status=status.HTTP_405_METHOD_NOT_ALLOWED, ) @action(detail=True, methods=["post"], url_path="advance-to-next-state") def advance_to_next_state(self, request, pk=None): """ 推进到下一个状态 将印染任务推进到下一个流程状态 请求体: { "parameters": { "temperature": "25.5", "operator": "张三" } } 参数可选,但如果下一个状态有必填参数,则必须提供 注意:这是对 stateflow.BusinessObject.advance 接口的便捷包装, 自动通过 printing_job 查找关联的 business_object 并调用状态流转服务。 前端只需提供 printing_job 的 ID,无需关心 business_object_id。 """ job = self.get_object() if not job.business_object: return Response( {"detail": "该任务没有关联的流程实例"}, status=status.HTTP_400_BAD_REQUEST, ) # 获取请求参数 parameters = request.data.get("parameters", {}) # 调用 stateflow 统一服务进行状态流转 from stateflow import services as stateflow_services # 修复历史/异常数据:确保该 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}", ) success, message, state_log = stateflow_services.advance_to_next_state( job.business_object, request.user, **parameters ) if not success: return Response({"detail": message}, status=status.HTTP_400_BAD_REQUEST) # 更新 updated_at 以使缓存失效 job.save(update_fields=["updated_at"]) # 重新获取 job 以刷新状态 job.refresh_from_db() serializer = PrintingJobDetailSerializer(job) return Response({"detail": message, "data": serializer.data}) @action(detail=True, methods=["post"], url_path="step-back-one-state") def step_back_one_state(self, request, pk=None): """ 回退一步 将印染任务回退到上一个流程状态 注意:这是对 stateflow.BusinessObject.step_back 接口的便捷包装, 自动通过 printing_job 查找关联的 business_object 并调用状态流转服务。 前端只需提供 printing_job 的 ID,无需关心 business_object_id。 """ job = self.get_object() if not job.business_object: return Response( {"detail": "该任务没有关联的流程实例"}, status=status.HTTP_400_BAD_REQUEST, ) # 调用 stateflow 统一服务进行状态回退 from stateflow import services as stateflow_services success, message = stateflow_services.step_back_one_state( job.business_object, request.user ) if not success: return Response({"detail": message}, status=status.HTTP_400_BAD_REQUEST) # 更新 updated_at 以使缓存失效 job.save(update_fields=["updated_at"]) # 重新获取 job 以刷新状态 job.refresh_from_db() serializer = PrintingJobDetailSerializer(job) return Response({"detail": message, "data": serializer.data}) @action(detail=True, methods=["get"], url_path="completed-states") def completed_states(self, request, pk=None): """ 查询已完成的流程列表 查询参数: - include_cancelled: 是否包含已撤销的流程(true/false),默认 false """ job = self.get_object() if not job.business_object: return Response({"count": 0, "results": []}) # 获取查询参数 include_cancelled = ( request.query_params.get("include_cancelled", "false").lower() == "true" ) # 查询已完成的状态 state_logs = job.business_object.state_logs.select_related( "state", "completed_by" ).order_by("completed_at") if not include_cancelled: state_logs = state_logs.filter(is_cancelled=False) results = [] for log in state_logs: results.append( { "id": log.id, "state_id": log.state.id, "state_name": log.state.name, "completed_at": log.completed_at, "completed_by": log.completed_by.username if log.completed_by else None, "completed_by_name": getattr(log.completed_by, "employee", None) and log.completed_by.employee.name or None, "is_cancelled": log.is_cancelled, "cancelled_at": log.cancelled_at, } ) return Response({"count": len(results), "results": results}) @action(detail=True, methods=["get"], url_path="timeline") def timeline(self, request, pk=None): """ 获取流程时间线 返回该任务的完整流程时间线,包括未开始、进行中和已完成的状态 不包含已撤销的流程记录 """ job = self.get_object() if not job.business_object or not job.printing_order.process: return Response({"count": 0, "results": []}) from stateflow import services as stateflow_services timeline_data = stateflow_services.get_business_object_state_timeline( job.business_object ) # 过滤掉已撤销的记录 timeline_data = [item for item in timeline_data if not item.get("is_cancelled")] results = [] for item in timeline_data: results.append( { "state_id": item["state"].id, "state_name": item["state"].name, "state_description": item["state"].description, "order": item["order"], "status": item["status"], # not_started, in_progress, completed "completed_at": item["completed_at"], "completed_by": item["completed_by"].username if item["completed_by"] else None, "completed_by_name": getattr(item["completed_by"], "employee", None) and item["completed_by"].employee.name or None if item["completed_by"] else None, } ) return Response({"count": len(results), "results": results}) class HasInvalidatePlateOrderPermission(BasePermission): """自定义权限类,检查用户是否有作废开版订单的权限""" message = "您没有权限作废开版订单" def has_permission(self, request, view): if not request.user.is_authenticated: return False return request.user.has_perm("printing.can_invalidate_plateorder") class HasActivatePlateOrderPermission(BasePermission): """自定义权限类,检查用户是否有恢复开版订单的权限""" message = "您没有权限恢复开版订单" def has_permission(self, request, view): if not request.user.is_authenticated: return False return request.user.has_perm("printing.can_activate_plateorder") class PlateOrderFilterSet(django_filters.FilterSet): """开版订单过滤器""" MAX_RANGE_DAYS = 10 customer_name = django_filters.CharFilter( field_name="customer__name", lookup_expr="icontains" ) customer_phone = django_filters.CharFilter( field_name="customer__mobile", lookup_expr="icontains" ) image_name = django_filters.CharFilter(lookup_expr="icontains") salesperson = django_filters.NumberFilter() merchandiser = django_filters.NumberFilter() plate_type = django_filters.CharFilter(lookup_expr="icontains") urgency_level = django_filters.CharFilter(lookup_expr="icontains") is_invalid = django_filters.BooleanFilter() is_ordered = django_filters.BooleanFilter() is_mark_frame = django_filters.BooleanFilter() fabric = django_filters.CharFilter(lookup_expr="icontains") style_name = django_filters.CharFilter(lookup_expr="icontains") plate_date_from = django_filters.DateFilter( field_name="plate_date", lookup_expr="gte" ) plate_date_to = django_filters.DateFilter( field_name="plate_date", method="filter_plate_date_to" ) # plate_at_from / plate_at_to: 支持带时分秒的精确时间筛选(ISO 8601 格式) plate_at_from = IsoDateTimeFilter(field_name="plate_date", lookup_expr="gte") plate_at_to = IsoDateTimeFilter(field_name="plate_date", lookup_expr="lte") required_completion_date_from = IsoDateTimeFilter( field_name="required_completion_date", lookup_expr="gte" ) required_completion_date_to = IsoDateTimeFilter( field_name="required_completion_date", lookup_expr="lte" ) created_date_from = django_filters.DateFilter( field_name="created_at", lookup_expr="gte" ) created_date_to = django_filters.DateFilter( field_name="created_at", method="filter_created_date_to" ) class Meta: model = models.PlateOrder fields = [ "customer", "salesperson", "merchandiser", "urgency_level", "is_invalid", "is_ordered", ] def filter_plate_date_to(self, queryset, name, value): """ 过滤 plate_date <= 指定日期的 23:59:59.999999 使用 lt 次日 00:00:00 实现,确保包含整天 """ if value: from datetime import timedelta next_day = value + timedelta(days=1) return queryset.filter(plate_date__lt=next_day) return queryset def filter_created_date_to(self, queryset, name, value): """ 过滤 created_at <= 指定日期的 23:59:59.999999 使用 lt 次日 00:00:00 实现,确保包含整天 """ if value: from datetime import timedelta next_day = value + timedelta(days=1) return queryset.filter(created_at__lt=next_day) return queryset @staticmethod def _to_local_date_from_datetime(raw_value): dt = parse_datetime(str(raw_value)) if dt is None: raise DRFValidationError("日期时间格式无效") if timezone.is_aware(dt): dt = timezone.localtime(dt) return dt.date() @staticmethod def _to_date(raw_value): d = parse_date(str(raw_value)) if d is None: raise DRFValidationError("日期格式无效") return d def _validate_range_days(self, from_key, to_key, mode, label): raw_from = self.data.get(from_key) raw_to = self.data.get(to_key) if not raw_from or not raw_to: return if mode == "datetime": from_date = self._to_local_date_from_datetime(raw_from) to_date = self._to_local_date_from_datetime(raw_to) else: from_date = self._to_date(raw_from) to_date = self._to_date(raw_to) days = (to_date - from_date).days + 1 if days > self.MAX_RANGE_DAYS: raise DRFValidationError(f"{label}查询跨度不能超过{self.MAX_RANGE_DAYS}天") @property def qs(self): self._validate_range_days( "plate_date_from", "plate_date_to", mode="date", label="开版日期" ) self._validate_range_days( "plate_at_from", "plate_at_to", mode="datetime", label="开版时间" ) self._validate_range_days( "required_completion_date_from", "required_completion_date_to", mode="datetime", label="要求完成时间", ) self._validate_range_days( "created_date_from", "created_date_to", mode="date", label="创建日期" ) return super().qs def _plate_order_last_modified(request, *args, **kwargs): """ 返回 PlateOrder 表中最新的 updated_at 时间。 用于条件请求(304 Not Modified)判断。 """ latest = ( models.PlateOrder.objects.order_by("-updated_at") .values_list("updated_at", flat=True) .first() ) return latest # @method_decorator(condition(last_modified_func=_plate_order_last_modified), name='list') class PlateOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): """ 开版订单 ViewSet 提供开版订单的增改查功能(不支持删除) list: 获取开版订单列表 retrieve: 获取开版订单详情 create: 创建开版订单 image_name: 图片名称(模糊查询) update: 更新开版订单 partial_update: 部分更新开版订单 invalidate: 作废开版订单 activate: 恢复开版订单 advance_to_next_state: 推进到下一个状态(便捷包装) step_back_one_state: 回退一步(便捷包装) completed_states: 查询已完成的流程列表 timeline: 获取流程时间线 注意:状态流转接口是对 stateflow 模块的便捷包装 - 前端只需提供 plate_order 的 ID - 系统会自动查找关联的 business_object 并进行状态流转 - 也可以直接使用 stateflow 的原始接口(需要 business_object_id) 查询参数: - customer: 客户ID - customer_name: 客户名称(模糊查询) - customer_phone: 客户电话(模糊查询) - salesperson: 业务员ID - merchandiser: 跟单员ID - plate_type: 版型(模糊查询) - urgency_level: 紧急程度(1-5) - is_invalid: 是否作废(true/false) - is_ordered: 是否已下单(true/false) - is_mark_frame: 是否打样架(true/false) - fabric: 面料(模糊查询) - style_name: 款式名称(模糊查询) - plate_date_from: 开版日期起始(仅日期) - plate_date_to: 开版日期结束(仅日期,包含整天) - plate_at_from: 开版时间起始(ISO 8601 格式,含时分秒,如 2024-01-01T08:00:00) - plate_at_to: 开版时间结束(ISO 8601 格式,含时分秒) - required_completion_date_from: 要求完成时间起始(含时分秒) - required_completion_date_to: 要求完成时间结束 - created_date_from: 创建日期起始 - created_date_to: 创建日期结束 - search: 全文搜索(设计编号、款式名称、客户名称、面料) - ordering: 排序字段 权限控制: - 默认按客户可见性过滤(员工只能看到自己负责的客户的订单) - 拥有 printing.view_all_plateorders 权限可突破此限制 """ queryset = models.PlateOrder.objects.all() customer_field = "customer" view_all_permission = "printing.view_all_plateorders" permission_classes = [DjangoModelPermissions] parser_classes = [MultiPartParser, FormParser, JSONParser] # 支持文件上传 filter_backends = [ DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter, ] filterset_class = PlateOrderFilterSet # search_fields 说明(模糊搜索支持的字段): # - design_code: 设计编号 # - design_code_normalized: 设计编号兜底(为空时使用主键 ID,通过 get_queryset 注解) # - style_name: 款号名称 # - customer__name: 客户名称(跨表) # - fabric: 布料 # - plate_notes: 打版注意事项 # - reprint_reason: 复版原因 # - image_name: 图片名称 # - default_address: 默认地址 # 待添加:area, customer_feedback, fabric_source, width 等 search_fields = [ "design_code", "design_code_normalized", "style_name", "customer__name", "fabric", "plate_notes", "reprint_reason", "image_name", "default_address", ] ordering_fields = [ "id", "created_at", "updated_at", "plate_date", "required_completion_date", "completion_date", "urgency_level", "is_ordered", "is_invalid", ] ordering = ["-created_at"] def get_serializer_class(self): """根据动作选择序列化器""" from .serializers import ( PlateOrderKanbanListSerializer, PlateOrderListSerializer, PlateOrderDetailSerializer, PlateOrderCreateUpdateSerializer, ) if self.action == "list": return PlateOrderKanbanListSerializer elif self.action in ["create", "update", "partial_update"]: return PlateOrderCreateUpdateSerializer else: # retrieve return PlateOrderDetailSerializer def get_queryset(self): """优化查询""" queryset = super().get_queryset() # 默认不返回作废订单(仅 list);如显式传入 is_invalid 参数,则交由 FilterSet 处理 # 注意:不能影响 activate/invalidate 等 action,否则会因 get_object() 被过滤而 404。 if ( self.action == "list" and self.request and "is_invalid" not in self.request.query_params ): queryset = queryset.filter(is_invalid=False) if self.action in ["list", "retrieve"]: queryset = queryset.select_related( "customer", "salesperson", "merchandiser", "business_object", "created_by__employee", ) # 预取流程名称(process 是 IntegerField 非外键,用 Subquery 避免 N+1) from stateflow.models import Process queryset = queryset.annotate( design_code_normalized=Coalesce( "design_code", Cast("id", output_field=CharField()) ), annotated_process_name=Subquery( Process.objects.filter(id=OuterRef("process")).values("name")[:1], output_field=CharField(), ), ) return queryset def destroy(self, request, *args, **kwargs): """禁用删除操作""" return Response( {"detail": "开版订单不支持删除操作,请使用作废功能"}, status=status.HTTP_405_METHOD_NOT_ALLOWED, ) def perform_create(self, serializer): """ 创建时自动绑定创建人(created_by)和商户(merchant),不允许前端传参控制。 """ user = self.request.user merchant = None if hasattr(user, "employee") and user.employee and user.employee.merchant: merchant = user.employee.merchant serializer.save(created_by=user, merchant=merchant) @action(detail=True, methods=["post"]) def invalidate(self, request, pk=None): """ 作废开版订单 需要权限: printing.can_invalidate_plateorder """ # 检查权限 if not request.user.has_perm("printing.can_invalidate_plateorder"): return Response( {"detail": "您没有权限作废开版订单"}, status=status.HTTP_403_FORBIDDEN ) plate_order = self.get_object() if plate_order.is_invalid: return Response( {"detail": "该开版订单已经作废"}, status=status.HTTP_400_BAD_REQUEST ) plate_order.is_invalid = True plate_order.save() from .serializers import PlateOrderDetailSerializer serializer = PlateOrderDetailSerializer( plate_order, context={"request": request} ) return Response({"detail": "开版订单已作废", "data": serializer.data}) @action(detail=True, methods=["post"]) def activate(self, request, pk=None): """ 恢复开版订单 需要权限: printing.can_activate_plateorder """ # 检查权限 if not request.user.has_perm("printing.can_activate_plateorder"): return Response( {"detail": "您没有权限恢复开版订单"}, status=status.HTTP_403_FORBIDDEN ) plate_order = self.get_object() if not plate_order.is_invalid: return Response( {"detail": "该开版订单未作废,无需恢复"}, status=status.HTTP_400_BAD_REQUEST, ) plate_order.is_invalid = False plate_order.save() from .serializers import PlateOrderDetailSerializer serializer = PlateOrderDetailSerializer( plate_order, context={"request": request} ) return Response({"detail": "开版订单已恢复", "data": serializer.data}) @action(detail=True, methods=["post"], url_path="advance-to-next-state") def advance_to_next_state(self, request, pk=None): """ 推进到下一个状态 将开版订单推进到下一个流程状态 请求体: { "parameters": { "inspector": "李四", "quality_score": "95" } } 参数可选,但如果下一个状态有必填参数,则必须提供 注意:这是对 stateflow.BusinessObject.advance 接口的便捷包装, 自动通过 plate_order 查找关联的 business_object 并调用状态流转服务。 """ plate_order = self.get_object() if not plate_order.business_object: return Response( {"detail": "该开版订单没有关联的流程实例"}, status=status.HTTP_400_BAD_REQUEST, ) # 获取请求参数 parameters = request.data.get("parameters", {}) # 调用 stateflow 统一服务进行状态流转 from stateflow import services as stateflow_services # 修复历史/异常数据:确保该 plate_order 的流程实例正确绑定到 plate_order(避免 BusinessObject.object_id/content_type 为空) stateflow_services.ensure_business_object_bound_to_instance( plate_order.business_object, plate_order, default_name=f"PlateOrder-{plate_order.id}", ) success, message, state_log = stateflow_services.advance_to_next_state( plate_order.business_object, request.user, **parameters ) if not success: return Response({"detail": message}, status=status.HTTP_400_BAD_REQUEST) # 更新 updated_at 以使缓存失效 plate_order.save(update_fields=["updated_at"]) # 重新获取 plate_order 以刷新状态 plate_order.refresh_from_db() from .serializers import PlateOrderDetailSerializer serializer = PlateOrderDetailSerializer( plate_order, context={"request": request} ) return Response({"detail": message, "data": serializer.data}) @action(detail=True, methods=["post"], url_path="step-back-one-state") def step_back_one_state(self, request, pk=None): """ 回退一步 将开版订单回退到上一个流程状态 注意:这是对 stateflow.BusinessObject.step_back 接口的便捷包装, 自动通过 plate_order 查找关联的 business_object 并调用状态流转服务。 """ plate_order = self.get_object() if not plate_order.business_object: return Response( {"detail": "该开版订单没有关联的流程实例"}, status=status.HTTP_400_BAD_REQUEST, ) # 调用 stateflow 统一服务进行状态回退 from stateflow import services as stateflow_services success, message = stateflow_services.step_back_one_state( plate_order.business_object, request.user ) if not success: return Response({"detail": message}, status=status.HTTP_400_BAD_REQUEST) # 更新 updated_at 以使缓存失效 plate_order.save(update_fields=["updated_at"]) # 重新获取 plate_order 以刷新状态 plate_order.refresh_from_db() from .serializers import PlateOrderDetailSerializer serializer = PlateOrderDetailSerializer( plate_order, context={"request": request} ) return Response({"detail": message, "data": serializer.data}) @action(detail=True, methods=["get"], url_path="completed-states") def completed_states(self, request, pk=None): """ 查询已完成的流程列表 查询参数: - include_cancelled: 是否包含已撤销的流程(true/false),默认 false """ plate_order = self.get_object() if not plate_order.business_object: return Response({"count": 0, "results": []}) # 获取查询参数 include_cancelled = ( request.query_params.get("include_cancelled", "false").lower() == "true" ) # 查询已完成的状态 state_logs = plate_order.business_object.state_logs.select_related( "state", "completed_by" ).order_by("completed_at") if not include_cancelled: state_logs = state_logs.filter(is_cancelled=False) results = [] for log in state_logs: results.append( { "id": log.id, "state_id": log.state.id, "state_name": log.state.name, "completed_at": log.completed_at, "completed_by": log.completed_by.username if log.completed_by else None, "completed_by_name": getattr(log.completed_by, "employee", None) and log.completed_by.employee.name or None, "is_cancelled": log.is_cancelled, "cancelled_at": log.cancelled_at, } ) return Response({"count": len(results), "results": results}) @action(detail=True, methods=["get"], url_path="timeline") def timeline(self, request, pk=None): """ 获取流程时间线 返回该开版订单的完整流程时间线,包括未开始、进行中和已完成的状态 不包含已撤销的流程记录 """ plate_order = self.get_object() if not plate_order.business_object: return Response({"count": 0, "results": []}) from stateflow import services as stateflow_services # 获取流程 from stateflow.models import Process try: process = Process.objects.get(id=plate_order.process) except Process.DoesNotExist: return Response({"count": 0, "results": []}) timeline_data = stateflow_services.get_business_object_state_timeline( plate_order.business_object ) # 过滤掉已撤销的记录 timeline_data = [item for item in timeline_data if not item.get("is_cancelled")] results = [] for item in timeline_data: results.append( { "state_id": item["state"].id, "state_name": item["state"].name, "state_description": item["state"].description, "order": item["order"], "status": item["status"], # not_started, in_progress, completed "completed_at": item["completed_at"], "completed_by": item["completed_by"].username if item["completed_by"] else None, "completed_by_name": getattr(item["completed_by"], "employee", None) and item["completed_by"].employee.name or None if item["completed_by"] else None, } ) return Response({"count": len(results), "results": results})