1
0
forked from erp-dev/erp
Files
erpnew/api_v1/views/printing/views.py
2026-06-26 17:03:54 +08:00

1418 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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.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"
)
# 支持同时搜索 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 格式:年月日 + ID20260323001505
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_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", "by_external_order_id"]:
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=False,
methods=["get"],
url_path=r"by-external-order-id/(?P<external_order_id>[^/.]+)",
)
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=["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")
is_production_completed = django_filters.BooleanFilter()
class Meta:
model = models.PrintingJob
fields = ["printing_order", "product", "external_order_id", "is_production_completed"]
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: 最大件数
- 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", "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})