""" 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, Prefetch 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.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') 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.PrintingOrder fields = ['customer', 'is_urgent', 'is_fabric_received', 'is_invalid', 'external_order_id'] @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_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: 出货日期结束 - 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', '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 in ['list', 'retrieve']: queryset = queryset.select_related('customer') # 预取 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=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') 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') class Meta: model = models.PrintingJob fields = ['printing_order', 'product', 'external_order_id'] 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: 外部订单编号(按所属订单模糊查询) - product: 产品ID - product_name: 产品名称(模糊查询) - unit: 单位(模糊查询) - quantity_min: 最小数量 - quantity_max: 最大数量 - pieces_min: 最小件数 - pieces_max: 最大件数 - 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']: queryset = queryset.select_related('printing_order', 'product') # 预取批量推进记录(避免 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.models import SalesItem job_ids = [job.id for job in jobs] saleitems = list( SalesItem.objects.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) 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 ( PlateOrderListSerializer, PlateOrderDetailSerializer, PlateOrderCreateUpdateSerializer, ) if self.action == 'list': return PlateOrderListSerializer 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') # 为搜索提供 design_code 的兜底(为空时使用主键字符串) queryset = queryset.annotate( design_code_normalized=Coalesce('design_code', Cast('id', 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 })