2026-05-19-process-route-management.md 84 KB

工艺路线管理 实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在生产管理菜单下新增「工艺路线管理」模块,支持路线基础信息维护、版本管理、以及拖拽式三栏路线图配置。

Architecture: 后端 6 张新表(路线主表 + 步骤 + 4 张步骤子表),CRUD 层遵循现有 process.py 模式(主子表级联、软删除、row_to_dict 序列化)。前端列表页遵循 unit/index.vue 模板,路线图配置弹窗使用三栏 flex 布局 + vue-draggable-plus 排序 + HTML5 Drag API 跨栏拖入。

Tech Stack: FastAPI + SQLAlchemy + Pydantic v2 (后端) | Vue 3 + Element Plus + vue-draggable-plus (前端) | MySQL 5.7

设计规格文档: docs/superpowers/specs/2026-05-19-process-route-management-design.md


文件清单

操作 文件
新建 docs/sql/production_process_route.sql
追加 backend/app/models/models.py
追加 backend/app/models/__init__.py
新建 backend/app/crud/process_route.py
新建 backend/app/schemas/process_route.py
追加 backend/app/api/production.py
追加 web-admin/src/api/production.ts
新建 web-admin/src/views/production/process-route/index.vue
追加 web-admin/src/router/modules/sqxp.ts

Task 1: SQL DDL — 6 张表 + 菜单数据

Files:

  • Create: docs/sql/production_process_route.sql

  • [ ] Step 1: 创建 SQL 文件

-- ============================================================================
-- 工艺路线管理 DDL(6张业务表 + 菜单 INSERT + 菜单 order_num 调整)
-- ============================================================================

-- ---------------------------------------------------------------------------
-- 1. 工艺路线主表 biz_process_route
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `biz_process_route` (
  `id` varchar(64) NOT NULL COMMENT '主键ID',
  `route_code` varchar(64) NOT NULL COMMENT '路线编码',
  `route_name` varchar(100) NOT NULL COMMENT '路线名称',
  `product_type_id` varchar(64) DEFAULT NULL COMMENT '细胞产品类型ID',
  `version` varchar(50) DEFAULT NULL COMMENT '版本号(如V1.0)',
  `is_current` char(1) DEFAULT '0' COMMENT '是否当前版本(0-否 1-是)',
  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
  `create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
  `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
  PRIMARY KEY (`id`),
  KEY `idx_route_tenant` (`tenant_no`, `del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工艺路线';

-- ---------------------------------------------------------------------------
-- 2. 路线工序步骤 biz_process_route_step
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `biz_process_route_step` (
  `id` varchar(64) NOT NULL COMMENT '主键ID',
  `route_id` varchar(64) NOT NULL COMMENT '所属路线ID',
  `process_id` varchar(64) DEFAULT NULL COMMENT '来源工艺库工艺ID',
  `step_order` int(11) DEFAULT NULL COMMENT '步骤顺序',
  `process_code` varchar(64) DEFAULT NULL COMMENT '工序代码(克隆)',
  `category_id` varchar(64) DEFAULT NULL COMMENT '工序分类ID(可编辑)',
  `process_name` varchar(100) DEFAULT NULL COMMENT '工序名称(可编辑)',
  `description` varchar(500) DEFAULT NULL COMMENT '描述(可编辑)',
  `equipment_name` varchar(200) DEFAULT NULL COMMENT '预计使用设备(可编辑)',
  `estimated_duration` varchar(100) DEFAULT NULL COMMENT '预估耗时(可编辑)',
  `sop_ids` varchar(1000) DEFAULT NULL COMMENT '关联SOP文档ID(逗号分隔)',
  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
  `create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
  `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
  PRIMARY KEY (`id`),
  KEY `idx_prs_route` (`route_id`, `del_flag`),
  KEY `idx_prs_tenant` (`tenant_no`, `del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='路线工序步骤';

-- ---------------------------------------------------------------------------
-- 3. 步骤试剂 biz_process_route_step_reagent
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `biz_process_route_step_reagent` (
  `id` varchar(64) NOT NULL COMMENT '主键ID',
  `step_id` varchar(64) NOT NULL COMMENT '所属步骤ID',
  `reagent_code` varchar(64) DEFAULT NULL COMMENT '试剂编码',
  `reagent_name` varchar(100) DEFAULT NULL COMMENT '试剂名称',
  `spec` varchar(200) DEFAULT NULL COMMENT '规格',
  `standard_usage` varchar(100) DEFAULT NULL COMMENT '标准用量',
  `unit` varchar(64) DEFAULT NULL COMMENT '单位',
  `is_key_material` char(1) DEFAULT '0' COMMENT '是否关键物料(0-否 1-是)',
  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
  `create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
  `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
  PRIMARY KEY (`id`),
  KEY `idx_prsr_step` (`step_id`, `del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='步骤试剂';

-- ---------------------------------------------------------------------------
-- 4. 步骤耗材 biz_process_route_step_consumable
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `biz_process_route_step_consumable` (
  `id` varchar(64) NOT NULL COMMENT '主键ID',
  `step_id` varchar(64) NOT NULL COMMENT '所属步骤ID',
  `consumable_code` varchar(64) DEFAULT NULL COMMENT '耗材编码',
  `consumable_name` varchar(100) DEFAULT NULL COMMENT '耗材名称',
  `spec_model` varchar(200) DEFAULT NULL COMMENT '规格型号',
  `standard_usage` varchar(100) DEFAULT NULL COMMENT '标准用量',
  `unit` varchar(64) DEFAULT NULL COMMENT '单位',
  `is_required` char(1) DEFAULT '1' COMMENT '是否必需(1-必须 0-可选)',
  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
  `create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
  `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
  PRIMARY KEY (`id`),
  KEY `idx_prsc_step` (`step_id`, `del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='步骤耗材';

-- ---------------------------------------------------------------------------
-- 5. 步骤产出物 biz_process_route_step_output
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `biz_process_route_step_output` (
  `id` varchar(64) NOT NULL COMMENT '主键ID',
  `step_id` varchar(64) NOT NULL COMMENT '所属步骤ID',
  `output_type_id` varchar(64) DEFAULT NULL COMMENT '产出类型ID',
  `output_name` varchar(200) DEFAULT NULL COMMENT '产出物名称',
  `output_barcode` varchar(200) DEFAULT NULL COMMENT '产出物条码',
  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
  `create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
  `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
  PRIMARY KEY (`id`),
  KEY `idx_prso_step` (`step_id`, `del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='步骤产出物';

-- ---------------------------------------------------------------------------
-- 6. 步骤数据采集 biz_process_route_step_data_collection
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `biz_process_route_step_data_collection` (
  `id` varchar(64) NOT NULL COMMENT '主键ID',
  `step_id` varchar(64) NOT NULL COMMENT '所属步骤ID',
  `collection_name` varchar(100) DEFAULT NULL COMMENT '采集信息名称',
  `unit` varchar(64) DEFAULT NULL COMMENT '单位',
  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
  `create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
  `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
  PRIMARY KEY (`id`),
  KEY `idx_prsdc_step` (`step_id`, `del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='步骤数据采集';

-- ============================================================================
-- 菜单数据
-- ============================================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES ('90000000000000000000000000000010', '工艺路线管理', '90000000000000000000000000000001', 'C', 'process-route', '/production/process-route/index', 'production:process-route:list', 1, '1', '1', 'zkxw', 7, NULL, NULL, '', '2026-05-19 00:00:00', NULL, '2026-05-19 00:00:00', 0);

-- SOP文档 order_num 从 7 改为 8
UPDATE `sys_menu` SET `order_num` = 8 WHERE `id` = '90000000000000000000000000000004';
  • Step 2: 在 MySQL 中执行 SQL 验证建表成功

Run: 在 MySQL 客户端执行 docs/sql/production_process_route.sql Expected: 6 张表创建成功,菜单插入成功,SOP order_num 更新成功

  • Step 3: 提交
git add docs/sql/production_process_route.sql
git commit -m "feat: add process route management SQL DDL (6 tables + menu)"

Task 2: SQLAlchemy Model — 6 个新模型

Files:

  • Modify: backend/app/models/models.py (追加到文件末尾 from app.models.qc_param 之前)
  • Modify: backend/app/models/__init__.py (追加 6 个 import + 6 个 all 条目)

参考: 现有 BizProcess (line 1854)、BizProcessReagent (line 1878)、BizProcessConsumable (line 1902)、BizProcessOutput (line 1926) 的写法。

  • Step 1: 在 models.py 末尾追加 6 个 Model

在 from app.models.qc_param import ... 这行之前,追加以下内容:

class BizProcessRoute(Base):
    """工艺路线 biz_process_route"""
    __tablename__ = "biz_process_route"

    id = Column(String(64), primary_key=True, comment="主键ID")
    route_code = Column(String(64), nullable=False, comment="路线编码")
    route_name = Column(String(100), nullable=False, comment="路线名称")
    product_type_id = Column(String(64), nullable=True, comment="细胞产品类型ID")
    version = Column(String(50), nullable=True, comment="版本号")
    is_current = Column(CHAR(1), nullable=True, default="0", comment="是否当前版本(0-否 1-是)")
    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
    order_num = Column(Integer, nullable=True, comment="排序号")
    remark = Column(String(512), nullable=True, comment="备注")
    create_by = Column(String(64), nullable=True, comment="创建人id")
    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
    update_by = Column(String(64), nullable=True, comment="更新人id")
    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")


class BizProcessRouteStep(Base):
    """路线工序步骤 biz_process_route_step"""
    __tablename__ = "biz_process_route_step"

    id = Column(String(64), primary_key=True, comment="主键ID")
    route_id = Column(String(64), nullable=False, comment="所属路线ID")
    process_id = Column(String(64), nullable=True, comment="来源工艺库工艺ID")
    step_order = Column(Integer, nullable=True, comment="步骤顺序")
    process_code = Column(String(64), nullable=True, comment="工序代码(克隆)")
    category_id = Column(String(64), nullable=True, comment="工序分类ID")
    process_name = Column(String(100), nullable=True, comment="工序名称")
    description = Column(String(500), nullable=True, comment="描述")
    equipment_name = Column(String(200), nullable=True, comment="预计使用设备")
    estimated_duration = Column(String(100), nullable=True, comment="预估耗时")
    sop_ids = Column(String(1000), nullable=True, comment="关联SOP文档ID(逗号分隔)")
    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
    order_num = Column(Integer, nullable=True, comment="排序号")
    remark = Column(String(512), nullable=True, comment="备注")
    create_by = Column(String(64), nullable=True, comment="创建人id")
    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
    update_by = Column(String(64), nullable=True, comment="更新人id")
    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")


class BizProcessRouteStepReagent(Base):
    """步骤试剂 biz_process_route_step_reagent"""
    __tablename__ = "biz_process_route_step_reagent"

    id = Column(String(64), primary_key=True, comment="主键ID")
    step_id = Column(String(64), nullable=False, comment="所属步骤ID")
    reagent_code = Column(String(64), nullable=True, comment="试剂编码")
    reagent_name = Column(String(100), nullable=True, comment="试剂名称")
    spec = Column(String(200), nullable=True, comment="规格")
    standard_usage = Column(String(100), nullable=True, comment="标准用量")
    unit = Column(String(64), nullable=True, comment="单位")
    is_key_material = Column(CHAR(1), nullable=True, default="0", comment="是否关键物料(0-否 1-是)")
    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
    order_num = Column(Integer, nullable=True, comment="排序号")
    remark = Column(String(512), nullable=True, comment="备注")
    create_by = Column(String(64), nullable=True, comment="创建人id")
    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
    update_by = Column(String(64), nullable=True, comment="更新人id")
    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")


class BizProcessRouteStepConsumable(Base):
    """步骤耗材 biz_process_route_step_consumable"""
    __tablename__ = "biz_process_route_step_consumable"

    id = Column(String(64), primary_key=True, comment="主键ID")
    step_id = Column(String(64), nullable=False, comment="所属步骤ID")
    consumable_code = Column(String(64), nullable=True, comment="耗材编码")
    consumable_name = Column(String(100), nullable=True, comment="耗材名称")
    spec_model = Column(String(200), nullable=True, comment="规格型号")
    standard_usage = Column(String(100), nullable=True, comment="标准用量")
    unit = Column(String(64), nullable=True, comment="单位")
    is_required = Column(CHAR(1), nullable=True, default="1", comment="是否必需(1-必须 0-可选)")
    remark = Column(String(512), nullable=True, comment="备注")
    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
    order_num = Column(Integer, nullable=True, comment="排序号")
    create_by = Column(String(64), nullable=True, comment="创建人id")
    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
    update_by = Column(String(64), nullable=True, comment="更新人id")
    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")


class BizProcessRouteStepOutput(Base):
    """步骤产出物 biz_process_route_step_output"""
    __tablename__ = "biz_process_route_step_output"

    id = Column(String(64), primary_key=True, comment="主键ID")
    step_id = Column(String(64), nullable=False, comment="所属步骤ID")
    output_type_id = Column(String(64), nullable=True, comment="产出类型ID")
    output_name = Column(String(200), nullable=True, comment="产出物名称")
    output_barcode = Column(String(200), nullable=True, comment="产出物条码")
    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
    order_num = Column(Integer, nullable=True, comment="排序号")
    remark = Column(String(512), nullable=True, comment="备注")
    create_by = Column(String(64), nullable=True, comment="创建人id")
    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
    update_by = Column(String(64), nullable=True, comment="更新人id")
    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")


class BizProcessRouteStepDataCollection(Base):
    """步骤数据采集 biz_process_route_step_data_collection"""
    __tablename__ = "biz_process_route_step_data_collection"

    id = Column(String(64), primary_key=True, comment="主键ID")
    step_id = Column(String(64), nullable=False, comment="所属步骤ID")
    collection_name = Column(String(100), nullable=True, comment="采集信息名称")
    unit = Column(String(64), nullable=True, comment="单位")
    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
    order_num = Column(Integer, nullable=True, comment="排序号")
    remark = Column(String(512), nullable=True, comment="备注")
    create_by = Column(String(64), nullable=True, comment="创建人id")
    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
    update_by = Column(String(64), nullable=True, comment="更新人id")
    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
  • Step 2: 在 __init__.py 追加 import 和 all

在 import 块追加(在 BizProcessDataCollection, 之后):

    BizProcessRoute,
    BizProcessRouteStep,
    BizProcessRouteStepReagent,
    BizProcessRouteStepConsumable,
    BizProcessRouteStepOutput,
    BizProcessRouteStepDataCollection,

在 __all__ 列表追加(在 "BizProcessDataCollection", 之后):

    "BizProcessRoute",
    "BizProcessRouteStep",
    "BizProcessRouteStepReagent",
    "BizProcessRouteStepConsumable",
    "BizProcessRouteStepOutput",
    "BizProcessRouteStepDataCollection",
  • Step 3: 验证后端能正常启动

Run: cd backend && source venv/bin/activate && python -c "from app.models import BizProcessRoute, BizProcessRouteStep; print('Models OK')" Expected: Models OK

  • Step 4: 提交
git add backend/app/models/models.py backend/app/models/__init__.py
git commit -m "feat: add process route SQLAlchemy models (6 classes)"

Task 3: Pydantic Schemas

Files:

  • Create: backend/app/schemas/process_route.py

参考: 后端现有模式是在 API 文件中 inline 定义 Pydantic schema。但本模块 schema 数量多(~15个),独立文件更清晰。

  • Step 1: 创建 schema 文件
"""工艺路线管理 Pydantic Schemas"""
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List


# ---------------------------------------------------------------------------
# 路线主表
# ---------------------------------------------------------------------------

class ProcessRouteCreateBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    route_code: str = Field(..., alias="routeCode")
    route_name: str = Field(..., alias="routeName")
    product_type_id: Optional[str] = Field(None, alias="productTypeId")
    version: Optional[str] = Field(None, alias="version")
    remark: Optional[str] = None


class ProcessRouteUpdateBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    route_code: Optional[str] = Field(None, alias="routeCode")
    route_name: Optional[str] = Field(None, alias="routeName")
    product_type_id: Optional[str] = Field(None, alias="productTypeId")
    version: Optional[str] = Field(None, alias="version")
    remark: Optional[str] = None


class SetCurrentBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    route_id: str = Field(..., alias="routeId")


# ---------------------------------------------------------------------------
# 步骤基础信息
# ---------------------------------------------------------------------------

class StepUpdateBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    step_id: str = Field(..., alias="stepId")
    process_code: Optional[str] = Field(None, alias="processCode")
    category_id: Optional[str] = Field(None, alias="categoryId")
    process_name: Optional[str] = Field(None, alias="processName")
    description: Optional[str] = None
    equipment_name: Optional[str] = Field(None, alias="equipmentName")
    estimated_duration: Optional[str] = Field(None, alias="estimatedDuration")
    sop_ids: Optional[str] = Field(None, alias="sopIds")


class StepAddBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    route_id: str = Field(..., alias="routeId")
    process_id: str = Field(..., alias="processId")
    step_order: Optional[int] = Field(None, alias="stepOrder")


class StepReorderBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    route_id: str = Field(..., alias="routeId")
    step_ids: List[str] = Field(..., alias="stepIds")


# ---------------------------------------------------------------------------
# 步骤子表 items
# ---------------------------------------------------------------------------

class StepReagentItem(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    reagent_code: Optional[str] = Field(None, alias="reagentCode")
    reagent_name: Optional[str] = Field(None, alias="reagentName")
    spec: Optional[str] = None
    standard_usage: Optional[str] = Field(None, alias="standardUsage")
    unit: Optional[str] = None
    is_key_material: Optional[str] = Field("0", alias="isKeyMaterial")
    remark: Optional[str] = None


class StepConsumableItem(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    consumable_code: Optional[str] = Field(None, alias="consumableCode")
    consumable_name: Optional[str] = Field(None, alias="consumableName")
    spec_model: Optional[str] = Field(None, alias="specModel")
    standard_usage: Optional[str] = Field(None, alias="standardUsage")
    unit: Optional[str] = None
    is_required: Optional[str] = Field("1", alias="isRequired")
    remark: Optional[str] = None


class StepOutputItem(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    output_type_id: Optional[str] = Field(None, alias="outputTypeId")
    output_name: Optional[str] = Field(None, alias="outputName")
    output_barcode: Optional[str] = Field(None, alias="outputBarcode")
    remark: Optional[str] = None


class StepDataCollectionItem(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    collection_name: Optional[str] = Field(None, alias="collectionName")
    unit: Optional[str] = None
    order_num: Optional[int] = Field(0, alias="orderNum")
    remark: Optional[str] = None


# ---------------------------------------------------------------------------
# 子表更新 body(step_id + items[])
# ---------------------------------------------------------------------------

class StepSubTableUpdateBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    step_id: str = Field(..., alias="stepId")
    items: List = Field(...)


class StepReagentUpdateBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    step_id: str = Field(..., alias="stepId")
    items: List[StepReagentItem] = Field(...)


class StepConsumableUpdateBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    step_id: str = Field(..., alias="stepId")
    items: List[StepConsumableItem] = Field(...)


class StepOutputUpdateBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    step_id: str = Field(..., alias="stepId")
    items: List[StepOutputItem] = Field(...)


class StepDataCollectionUpdateBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    step_id: str = Field(..., alias="stepId")
    items: List[StepDataCollectionItem] = Field(...)
  • Step 2: 验证 import 正常

Run: cd backend && source venv/bin/activate && python -c "from app.schemas.process_route import ProcessRouteCreateBody; print('Schemas OK')" Expected: Schemas OK

  • Step 3: 提交
git add backend/app/schemas/process_route.py
git commit -m "feat: add process route Pydantic schemas"

Task 4: CRUD 层 — process_route.py

Files:

  • Create: backend/app/crud/process_route.py

参考: backend/app/crud/process.py 的完整模式:_row_to_dict 序列化、_create_sub_* 辅助函数、_soft_delete_sub_records 级联软删除、完整替换子表策略。

本文件包含:

  1. 6 个 _row_to_dict 序列化函数
  2. 路线主表 CRUD:list_routes、get_route、get_route_detail、create_route、update_route、soft_delete_route、set_current_route
  3. 步骤 CRUD:add_step(克隆工艺+子表)、update_step、reorder_steps、soft_delete_step
  4. 子表更新:update_step_reagents、update_step_consumables、update_step_outputs、update_step_data_collections
  • Step 1: 创建 CRUD 文件

文件内容遵循 process.py 模式,关键函数签名:

"""工艺路线 CRUD(biz_process_route + biz_process_route_step + 4张步骤子表)"""
from __future__ import annotations

import math
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple

from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session

from app.core.utils import generate_uuid
from app.models.models import (
    BizProcess,
    BizProcessReagent,
    BizProcessConsumable,
    BizProcessOutput,
    BizProcessDataCollection,
    BizProcessRoute,
    BizProcessRouteStep,
    BizProcessRouteStepReagent,
    BizProcessRouteStepConsumable,
    BizProcessRouteStepOutput,
    BizProcessRouteStepDataCollection,
)


# ---------------------------------------------------------------------------
# 序列化函数
# ---------------------------------------------------------------------------

def _route_row_to_dict(r: BizProcessRoute) -> Dict[str, Any]:
    return {
        "id": r.id,
        "routeCode": r.route_code,
        "routeName": r.route_name,
        "productTypeId": r.product_type_id or "",
        "version": r.version or "",
        "isCurrent": r.is_current if r.is_current is not None else "0",
        "tenantNo": r.tenant_no,
        "orderNum": r.order_num if r.order_num is not None else 0,
        "remark": r.remark or "",
        "createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
        "updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
    }


def _step_row_to_dict(s: BizProcessRouteStep) -> Dict[str, Any]:
    return {
        "id": s.id,
        "routeId": s.route_id,
        "processId": s.process_id or "",
        "stepOrder": s.step_order if s.step_order is not None else 0,
        "processCode": s.process_code or "",
        "categoryId": s.category_id or "",
        "processName": s.process_name or "",
        "description": s.description or "",
        "equipmentName": s.equipment_name or "",
        "estimatedDuration": s.estimated_duration or "",
        "sopIds": s.sop_ids or "",
        "remark": s.remark or "",
    }


def _step_reagent_row_to_dict(r: BizProcessRouteStepReagent) -> Dict[str, Any]:
    return {
        "id": r.id,
        "stepId": r.step_id,
        "reagentCode": r.reagent_code or "",
        "reagentName": r.reagent_name or "",
        "spec": r.spec or "",
        "standardUsage": r.standard_usage or "",
        "unit": r.unit or "",
        "isKeyMaterial": r.is_key_material if r.is_key_material is not None else "0",
        "remark": r.remark or "",
    }


def _step_consumable_row_to_dict(c: BizProcessRouteStepConsumable) -> Dict[str, Any]:
    return {
        "id": c.id,
        "stepId": c.step_id,
        "consumableCode": c.consumable_code or "",
        "consumableName": c.consumable_name or "",
        "specModel": c.spec_model or "",
        "standardUsage": c.standard_usage or "",
        "unit": c.unit or "",
        "isRequired": c.is_required if c.is_required is not None else "1",
        "remark": c.remark or "",
    }


def _step_output_row_to_dict(o: BizProcessRouteStepOutput) -> Dict[str, Any]:
    return {
        "id": o.id,
        "stepId": o.step_id,
        "outputTypeId": o.output_type_id or "",
        "outputName": o.output_name or "",
        "outputBarcode": o.output_barcode or "",
        "remark": o.remark or "",
    }


def _step_data_collection_row_to_dict(d: BizProcessRouteStepDataCollection) -> Dict[str, Any]:
    return {
        "id": d.id,
        "stepId": d.step_id,
        "collectionName": d.collection_name or "",
        "unit": d.unit or "",
        "orderNum": d.order_num if d.order_num is not None else 0,
        "remark": d.remark or "",
    }


# ---------------------------------------------------------------------------
# 路线主表 CRUD
# ---------------------------------------------------------------------------

def list_routes(db: Session, *, page: int = 1, page_size: int = 10,
                route_name: Optional[str] = None, route_code: Optional[str] = None,
                product_type_id: Optional[str] = None,
                tenant_no: Optional[str] = None) -> Tuple[List[Dict], int]:
    """路线分页列表,支持 route_name 模糊、route_code 精确、product_type_id 筛选"""
    def _filtered():
        q = db.query(BizProcessRoute).filter(BizProcessRoute.del_flag == 0)
        if tenant_no:
            q = q.filter(BizProcessRoute.tenant_no == tenant_no)
        if route_name and route_name.strip():
            q = q.filter(BizProcessRoute.route_name.like(f"%{route_name.strip()}%"))
        if route_code and route_code.strip():
            q = q.filter(BizProcessRoute.route_code == route_code.strip())
        if product_type_id and product_type_id.strip():
            q = q.filter(BizProcessRoute.product_type_id == product_type_id.strip())
        return q

    total = _filtered().order_by(None).with_entities(func.count(BizProcessRoute.id)).scalar() or 0
    page = max(1, page)
    page_size = min(max(1, page_size), 100)
    rows = (
        _filtered()
        .order_by(BizProcessRoute.order_num.asc().nullslast(), BizProcessRoute.create_time.desc())
        .offset((page - 1) * page_size)
        .limit(page_size)
        .all()
    )
    return [_route_row_to_dict(x) for x in rows], int(total)


def get_route(db: Session, route_id: str) -> Optional[BizProcessRoute]:
    return db.query(BizProcessRoute).filter(
        and_(BizProcessRoute.id == route_id, BizProcessRoute.del_flag == 0)
    ).first()


def get_route_detail(db: Session, route_id: str) -> Optional[Dict[str, Any]]:
    """路线详情 = 主表 + 步骤列表(含每个步骤的4张子表)"""
    route = get_route(db, route_id)
    if not route:
        return None
    result = _route_row_to_dict(route)

    steps = (
        db.query(BizProcessRouteStep)
        .filter(and_(BizProcessRouteStep.route_id == route_id, BizProcessRouteStep.del_flag == 0))
        .order_by(BizProcessRouteStep.step_order.asc(), BizProcessRouteStep.create_time.asc())
        .all()
    )
    step_list = []
    for s in steps:
        sd = _step_row_to_dict(s)
        # 加载4张子表
        reagents = db.query(BizProcessRouteStepReagent).filter(
            and_(BizProcessRouteStepReagent.step_id == s.id, BizProcessRouteStepReagent.del_flag == 0)
        ).order_by(BizProcessRouteStepReagent.create_time.asc()).all()
        consumables = db.query(BizProcessRouteStepConsumable).filter(
            and_(BizProcessRouteStepConsumable.step_id == s.id, BizProcessRouteStepConsumable.del_flag == 0)
        ).order_by(BizProcessRouteStepConsumable.create_time.asc()).all()
        outputs = db.query(BizProcessRouteStepOutput).filter(
            and_(BizProcessRouteStepOutput.step_id == s.id, BizProcessRouteStepOutput.del_flag == 0)
        ).order_by(BizProcessRouteStepOutput.create_time.asc()).all()
        data_collections = db.query(BizProcessRouteStepDataCollection).filter(
            and_(BizProcessRouteStepDataCollection.step_id == s.id, BizProcessRouteStepDataCollection.del_flag == 0)
        ).order_by(BizProcessRouteStepDataCollection.order_num.asc()).all()

        sd["reagents"] = [_step_reagent_row_to_dict(r) for r in reagents]
        sd["consumables"] = [_step_consumable_row_to_dict(c) for c in consumables]
        sd["outputs"] = [_step_output_row_to_dict(o) for o in outputs]
        sd["dataCollections"] = [_step_data_collection_row_to_dict(d) for d in data_collections]
        step_list.append(sd)

    result["steps"] = step_list
    return result


def create_route(db: Session, *, route_code: str, route_name: str,
                 product_type_id: Optional[str] = None, version: Optional[str] = None,
                 remark: Optional[str] = None, tenant_no: str = "zkxw",
                 user_id: Optional[str] = None) -> BizProcessRoute:
    rc = (route_code or "").strip()
    if not rc:
        raise ValueError("路线编码不能为空")
    now = datetime.now()
    obj = BizProcessRoute(
        id=generate_uuid(),
        route_code=rc,
        route_name=(route_name or "").strip() or rc,
        product_type_id=(product_type_id or "").strip() or None,
        version=(version or "").strip() or None,
        is_current="0",
        tenant_no=tenant_no,
        order_num=0,
        remark=(remark or "").strip() or None,
        create_by=user_id,
        create_dept_id="",
        create_time=now,
        update_by=user_id,
        update_time=now,
        del_flag=0,
    )
    db.add(obj)
    db.commit()
    db.refresh(obj)
    return obj


def update_route(db: Session, route_id: str, *, user_id: Optional[str] = None, **kwargs) -> BizProcessRoute:
    obj = get_route(db, route_id)
    if not obj:
        raise ValueError("路线不存在")
    for field in ["route_code", "route_name", "product_type_id", "version", "remark"]:
        val = kwargs.get(field)
        if val is not None:
            setattr(obj, field, (val or "").strip() or None if field != "route_code" else (val or "").strip())
    obj.update_by = user_id
    obj.update_time = datetime.now()
    db.add(obj)
    db.commit()
    db.refresh(obj)
    return obj


def set_current_route(db: Session, route_id: str, tenant_no: str) -> BizProcessRoute:
    """设置指定路线为当前版本:同 route_code 下先全部置0,再置目标为1"""
    obj = get_route(db, route_id)
    if not obj:
        raise ValueError("路线不存在")
    now = datetime.now()
    # 同编码全部置0
    siblings = db.query(BizProcessRoute).filter(
        and_(BizProcessRoute.route_code == obj.route_code,
             BizProcessRoute.tenant_no == tenant_no,
             BizProcessRoute.del_flag == 0)
    ).all()
    for s in siblings:
        s.is_current = "0"
        s.update_time = now
        db.add(s)
    # 目标置1
    obj.is_current = "1"
    obj.update_time = now
    db.add(obj)
    db.commit()
    db.refresh(obj)
    return obj


# ---------------------------------------------------------------------------
# 步骤子表辅助
# ---------------------------------------------------------------------------

def _soft_delete_sub_records(db: Session, model_class, step_id: str, now: datetime) -> None:
    rows = db.query(model_class).filter(
        and_(model_class.step_id == step_id, model_class.del_flag == 0)
    ).all()
    for r in rows:
        r.del_flag = 1
        r.update_time = now
        db.add(r)


def _create_step_reagent(db, step_id, tenant_no, user_id, now, data):
    db.add(BizProcessRouteStepReagent(
        id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
        reagent_code=(data.get("reagent_code") or "").strip() or None,
        reagent_name=(data.get("reagent_name") or "").strip() or None,
        spec=(data.get("spec") or "").strip() or None,
        standard_usage=(data.get("standard_usage") or "").strip() or None,
        unit=(data.get("unit") or "").strip() or None,
        is_key_material=data.get("is_key_material", "0"),
        remark=(data.get("remark") or "").strip() or None,
        order_num=0, create_by=user_id, create_dept_id="",
        create_time=now, update_by=user_id, update_time=now, del_flag=0,
    ))


def _create_step_consumable(db, step_id, tenant_no, user_id, now, data):
    db.add(BizProcessRouteStepConsumable(
        id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
        consumable_code=(data.get("consumable_code") or "").strip() or None,
        consumable_name=(data.get("consumable_name") or "").strip() or None,
        spec_model=(data.get("spec_model") or "").strip() or None,
        standard_usage=(data.get("standard_usage") or "").strip() or None,
        unit=(data.get("unit") or "").strip() or None,
        is_required=data.get("is_required", "1"),
        remark=(data.get("remark") or "").strip() or None,
        order_num=0, create_by=user_id, create_dept_id="",
        create_time=now, update_by=user_id, update_time=now, del_flag=0,
    ))


def _create_step_output(db, step_id, tenant_no, user_id, now, data):
    db.add(BizProcessRouteStepOutput(
        id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
        output_type_id=(data.get("output_type_id") or "").strip() or None,
        output_name=(data.get("output_name") or "").strip() or None,
        output_barcode=(data.get("output_barcode") or "").strip() or None,
        remark=(data.get("remark") or "").strip() or None,
        order_num=0, create_by=user_id, create_dept_id="",
        create_time=now, update_by=user_id, update_time=now, del_flag=0,
    ))


def _create_step_data_collection(db, step_id, tenant_no, user_id, now, data):
    db.add(BizProcessRouteStepDataCollection(
        id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
        collection_name=(data.get("collection_name") or "").strip() or None,
        unit=(data.get("unit") or "").strip() or None,
        order_num=data.get("order_num", 0) or 0,
        remark=(data.get("remark") or "").strip() or None,
        create_by=user_id, create_dept_id="",
        create_time=now, update_by=user_id, update_time=now, del_flag=0,
    ))


# ---------------------------------------------------------------------------
# 步骤 CRUD
# ---------------------------------------------------------------------------

def add_step(db: Session, *, route_id: str, process_id: str, step_order: Optional[int] = None,
             tenant_no: str = "zkxw", user_id: Optional[str] = None) -> Dict[str, Any]:
    """从工艺库克隆创建步骤"""
    route = get_route(db, route_id)
    if not route:
        raise ValueError("路线不存在")

    # 读取源工艺
    proc = db.query(BizProcess).filter(
        and_(BizProcess.id == process_id, BizProcess.del_flag == 0)
    ).first()
    if not proc:
        raise ValueError("工艺不存在")

    now = datetime.now()
    # 计算 step_order
    if step_order is None:
        max_order = db.query(func.max(BizProcessRouteStep.step_order)).filter(
            and_(BizProcessRouteStep.route_id == route_id, BizProcessRouteStep.del_flag == 0)
        ).scalar()
        step_order = (max_order or 0) + 1

    step = BizProcessRouteStep(
        id=generate_uuid(), route_id=route_id, process_id=process_id,
        step_order=step_order,
        process_code=proc.process_code, category_id=proc.category_id,
        process_name=proc.process_name, description=proc.description,
        equipment_name=proc.equipment_name, estimated_duration=proc.estimated_duration,
        sop_ids=proc.sop_ids,
        tenant_no=tenant_no, order_num=0,
        remark=None, create_by=user_id, create_dept_id="",
        create_time=now, update_by=user_id, update_time=now, del_flag=0,
    )
    db.add(step)
    db.flush()

    # 克隆4张子表
    src_reagents = db.query(BizProcessReagent).filter(
        and_(BizProcessReagent.process_id == process_id, BizProcessReagent.del_flag == 0)
    ).all()
    for r in src_reagents:
        _create_step_reagent(db, step.id, tenant_no, user_id, now, {
            "reagent_code": r.reagent_code, "reagent_name": r.reagent_name,
            "spec": r.spec, "standard_usage": r.standard_usage,
            "unit": r.unit, "is_key_material": r.is_key_material,
        })

    src_consumables = db.query(BizProcessConsumable).filter(
        and_(BizProcessConsumable.process_id == process_id, BizProcessConsumable.del_flag == 0)
    ).all()
    for c in src_consumables:
        _create_step_consumable(db, step.id, tenant_no, user_id, now, {
            "consumable_code": c.consumable_code, "consumable_name": c.consumable_name,
            "spec_model": c.spec_model, "standard_usage": c.standard_usage,
            "unit": c.unit, "is_required": c.is_required, "remark": c.remark,
        })

    src_outputs = db.query(BizProcessOutput).filter(
        and_(BizProcessOutput.process_id == process_id, BizProcessOutput.del_flag == 0)
    ).all()
    for o in src_outputs:
        _create_step_output(db, step.id, tenant_no, user_id, now, {
            "output_type_id": o.output_type_id, "output_name": o.output_name,
            "output_barcode": o.output_barcode,
        })

    src_collections = db.query(BizProcessDataCollection).filter(
        and_(BizProcessDataCollection.process_id == process_id, BizProcessDataCollection.del_flag == 0)
    ).all()
    for d in src_collections:
        _create_step_data_collection(db, step.id, tenant_no, user_id, now, {
            "collection_name": d.collection_name, "unit": d.unit, "order_num": d.order_num,
        })

    db.commit()

    # 返回完整 step(含子表)
    return get_route_detail(db, route_id)["steps"][-1] if get_route_detail(db, route_id)["steps"] else _step_row_to_dict(step)


def update_step(db: Session, step_id: str, *, user_id: Optional[str] = None, **kwargs) -> BizProcessRouteStep:
    step = db.query(BizProcessRouteStep).filter(
        and_(BizProcessRouteStep.id == step_id, BizProcessRouteStep.del_flag == 0)
    ).first()
    if not step:
        raise ValueError("步骤不存在")
    for field in ["process_code", "category_id", "process_name", "description",
                   "equipment_name", "estimated_duration", "sop_ids"]:
        val = kwargs.get(field)
        if val is not None:
            setattr(step, field, (val or "").strip() or None)
    step.update_by = user_id
    step.update_time = datetime.now()
    db.add(step)
    db.commit()
    db.refresh(step)
    return step


def reorder_steps(db: Session, route_id: str, step_ids: List[str]) -> None:
    """按 step_ids 数组顺序更新 step_order"""
    now = datetime.now()
    for idx, sid in enumerate(step_ids):
        step = db.query(BizProcessRouteStep).filter(
            and_(BizProcessRouteStep.id == sid, BizProcessRouteStep.route_id == route_id,
                 BizProcessRouteStep.del_flag == 0)
        ).first()
        if step:
            step.step_order = idx + 1
            step.update_time = now
            db.add(step)
    db.commit()


def soft_delete_step(db: Session, step_id: str) -> None:
    step = db.query(BizProcessRouteStep).filter(
        and_(BizProcessRouteStep.id == step_id, BizProcessRouteStep.del_flag == 0)
    ).first()
    if not step:
        raise ValueError("步骤不存在")
    now = datetime.now()
    step.del_flag = 1
    step.update_time = now
    db.add(step)
    _soft_delete_sub_records(db, BizProcessRouteStepReagent, step_id, now)
    _soft_delete_sub_records(db, BizProcessRouteStepConsumable, step_id, now)
    _soft_delete_sub_records(db, BizProcessRouteStepOutput, step_id, now)
    _soft_delete_sub_records(db, BizProcessRouteStepDataCollection, step_id, now)
    db.commit()


def soft_delete_route(db: Session, route_id: str) -> None:
    """级联软删除路线 + 所有步骤 + 所有步骤子表"""
    route = get_route(db, route_id)
    if not route:
        raise ValueError("路线不存在")
    now = datetime.now()
    route.del_flag = 1
    route.update_time = now
    db.add(route)

    steps = db.query(BizProcessRouteStep).filter(
        and_(BizProcessRouteStep.route_id == route_id, BizProcessRouteStep.del_flag == 0)
    ).all()
    for s in steps:
        s.del_flag = 1
        s.update_time = now
        db.add(s)
        _soft_delete_sub_records(db, BizProcessRouteStepReagent, s.id, now)
        _soft_delete_sub_records(db, BizProcessRouteStepConsumable, s.id, now)
        _soft_delete_sub_records(db, BizProcessRouteStepOutput, s.id, now)
        _soft_delete_sub_records(db, BizProcessRouteStepDataCollection, s.id, now)
    db.commit()


# ---------------------------------------------------------------------------
# 子表完整替换更新
# ---------------------------------------------------------------------------

def update_step_reagents(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
    now = datetime.now()
    _soft_delete_sub_records(db, BizProcessRouteStepReagent, step_id, now)
    for it in items:
        _create_step_reagent(db, step_id, tenant_no, user_id, now, it)
    db.commit()


def update_step_consumables(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
    now = datetime.now()
    _soft_delete_sub_records(db, BizProcessRouteStepConsumable, step_id, now)
    for it in items:
        _create_step_consumable(db, step_id, tenant_no, user_id, now, it)
    db.commit()


def update_step_outputs(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
    now = datetime.now()
    _soft_delete_sub_records(db, BizProcessRouteStepOutput, step_id, now)
    for it in items:
        _create_step_output(db, step_id, tenant_no, user_id, now, it)
    db.commit()


def update_step_data_collections(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
    now = datetime.now()
    _soft_delete_sub_records(db, BizProcessRouteStepDataCollection, step_id, now)
    for it in items:
        _create_step_data_collection(db, step_id, tenant_no, user_id, now, it)
    db.commit()


def total_pages(total: int, page_size: int) -> int:
    if total <= 0:
        return 0
    return int(math.ceil(total / float(page_size)))
  • Step 2: 验证 import 正常

Run: cd backend && source venv/bin/activate && python -c "from app.crud.process_route import list_routes, add_step; print('CRUD OK')" Expected: CRUD OK

  • Step 3: 提交
git add backend/app/crud/process_route.py
git commit -m "feat: add process route CRUD layer with clone and cascade delete"

Task 5: API 路由 — 追加到 production.py

Files:

  • Modify: backend/app/api/production.py (文件末尾追加 ~200 行)

参考: 现有 production.py 中的工艺库 API(line 735-943)模式:inline Pydantic 已改为从 schemas/process_route.py 导入,路由前缀为 /web/admin。

注意: 本模块的路由路径不按设计规格文档中的 /api/admin/production/route/,而是沿用现有模式 /web/admin/process-routes/*,保持与项目其他路由风格一致。

  • Step 1: 在 production.py 文件头部追加 import

在 from app.crud import process as process_svc 之后追加:

from app.crud import process_route as route_svc
from app.schemas.process_route import (
    ProcessRouteCreateBody,
    ProcessRouteUpdateBody,
    SetCurrentBody,
    StepAddBody,
    StepUpdateBody,
    StepReorderBody,
    StepReagentUpdateBody,
    StepConsumableUpdateBody,
    StepOutputUpdateBody,
    StepDataCollectionUpdateBody,
)
  • Step 2: 在文件末尾追加 API 路由

在 delete_process 函数之后追加以下全部路由:


# ---------------------------------------------------------------------------
# 8. 工艺路线管理
# ---------------------------------------------------------------------------


@router.get("/process-routes")
async def list_process_routes(
    page: int = Query(1, ge=1),
    pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
    routeName: Optional[str] = None,
    routeCode: Optional[str] = None,
    productTypeId: Optional[str] = None,
    user: dict = Depends(require_menu_code("production:process-route:list")),
    db: Session = Depends(get_db),
):
    tenant_no = _get_tenant_no(db, user)
    items, total = route_svc.list_routes(
        db, page=page, page_size=pageSize,
        route_name=routeName, route_code=routeCode,
        product_type_id=productTypeId, tenant_no=tenant_no,
    )
    tp = route_svc.total_pages(total, pageSize)
    return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})


@router.get("/process-routes/{route_id}")
async def get_process_route_detail(
    route_id: str,
    _user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    detail = route_svc.get_route_detail(db, route_id)
    if not detail:
        return error_response(404, "路线不存在")
    return success_response(detail)


@router.post("/process-routes")
async def create_process_route(
    body: ProcessRouteCreateBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        obj = route_svc.create_route(
            db,
            route_code=body.route_code,
            route_name=body.route_name,
            product_type_id=body.product_type_id,
            version=body.version,
            remark=body.remark,
            tenant_no=tenant_no,
            user_id=user.get("user_id"),
        )
        return success_response(route_svc._route_row_to_dict(obj), message="创建成功")
    except ValueError as e:
        return error_response(400, str(e))


@router.put("/process-routes/{route_id}")
async def update_process_route(
    route_id: str,
    body: ProcessRouteUpdateBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        payload = body.model_dump(exclude_unset=True, by_alias=False)
        obj = route_svc.update_route(
            db, route_id,
            user_id=user.get("user_id"),
            route_code=payload.get("route_code"),
            route_name=payload.get("route_name"),
            product_type_id=payload.get("product_type_id"),
            version=payload.get("version"),
            remark=payload.get("remark"),
        )
        return success_response(route_svc._route_row_to_dict(obj), message="更新成功")
    except ValueError as e:
        return error_response(400, str(e))


@router.put("/process-routes/set-current")
async def set_current_process_route(
    body: SetCurrentBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        obj = route_svc.set_current_route(db, body.route_id, tenant_no)
        return success_response(route_svc._route_row_to_dict(obj), message="设置成功")
    except ValueError as e:
        return error_response(400, str(e))


@router.delete("/process-routes/{route_id}")
async def delete_process_route(
    route_id: str,
    _user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        route_svc.soft_delete_route(db, route_id)
        return success_response(None, message="删除成功")
    except ValueError as e:
        return error_response(400, str(e))


# ----- 步骤操作 -----

@router.post("/process-routes/step/add")
async def add_route_step(
    body: StepAddBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        step_data = route_svc.add_step(
            db,
            route_id=body.route_id,
            process_id=body.process_id,
            step_order=body.step_order,
            tenant_no=tenant_no,
            user_id=user.get("user_id"),
        )
        return success_response(step_data, message="添加成功")
    except ValueError as e:
        return error_response(400, str(e))


@router.put("/process-routes/step/update")
async def update_route_step(
    body: StepUpdateBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        payload = body.model_dump(exclude_unset=True, by_alias=False)
        payload.pop("step_id", None)
        obj = route_svc.update_step(db, body.step_id, user_id=user.get("user_id"), **payload)
        return success_response(route_svc._step_row_to_dict(obj), message="更新成功")
    except ValueError as e:
        return error_response(400, str(e))


@router.put("/process-routes/step/reorder")
async def reorder_route_steps(
    body: StepReorderBody,
    _user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        route_svc.reorder_steps(db, body.route_id, body.step_ids)
        return success_response(None, message="排序成功")
    except ValueError as e:
        return error_response(400, str(e))


@router.delete("/process-routes/step/{step_id}")
async def delete_route_step(
    step_id: str,
    _user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        route_svc.soft_delete_step(db, step_id)
        return success_response(None, message="删除成功")
    except ValueError as e:
        return error_response(400, str(e))


# ----- 步骤子表 -----

@router.put("/process-routes/step/reagent/update")
async def update_step_reagents(
    body: StepReagentUpdateBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        items_data = [it.model_dump(by_alias=False) for it in body.items]
        route_svc.update_step_reagents(db, body.step_id, items_data, tenant_no, user.get("user_id"))
        return success_response(None, message="更新成功")
    except ValueError as e:
        return error_response(400, str(e))


@router.put("/process-routes/step/consumable/update")
async def update_step_consumables(
    body: StepConsumableUpdateBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        items_data = [it.model_dump(by_alias=False) for it in body.items]
        route_svc.update_step_consumables(db, body.step_id, items_data, tenant_no, user.get("user_id"))
        return success_response(None, message="更新成功")
    except ValueError as e:
        return error_response(400, str(e))


@router.put("/process-routes/step/output/update")
async def update_step_outputs(
    body: StepOutputUpdateBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        items_data = [it.model_dump(by_alias=False) for it in body.items]
        route_svc.update_step_outputs(db, body.step_id, items_data, tenant_no, user.get("user_id"))
        return success_response(None, message="更新成功")
    except ValueError as e:
        return error_response(400, str(e))


@router.put("/process-routes/step/data-collection/update")
async def update_step_data_collections(
    body: StepDataCollectionUpdateBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        items_data = [it.model_dump(by_alias=False) for it in body.items]
        route_svc.update_step_data_collections(db, body.step_id, items_data, tenant_no, user.get("user_id"))
        return success_response(None, message="更新成功")
    except ValueError as e:
        return error_response(400, str(e))
  • Step 2: 验证后端启动正常

Run: cd backend && source venv/bin/activate && python -c "from app.api.production import router; print(f'Routes: {len(router.routes)}')" Expected: 输出路由数量(应比原来增加约 15 个)

  • Step 3: 启动后端验证 Swagger

Run: cd backend && source venv/bin/activate && uvicorn app.main:app --reload --port 8000 打开 http://127.0.0.1:8000/docs,搜索 process-routes,确认所有端点可见。

  • Step 4: 提交
git add backend/app/api/production.py
git commit -m "feat: add process route API endpoints (14 routes)"

Task 6: 前端 API 函数 — 追加到 production.ts

Files:

  • Modify: web-admin/src/api/production.ts (文件末尾追加)

  • [ ] Step 1: 在 production.ts 文件末尾追加工艺路线 API 函数


// ---------------------------------------------------------------------------
// 工艺路线管理
// ---------------------------------------------------------------------------

export function fetchProcessRouteList(params?: {
  page?: number
  pageSize?: number
  routeName?: string
  routeCode?: string
  productTypeId?: string
}) {
  return request.get<{
    list: any[]
    total: number
    page: number
    pageSize: number
    totalPages: number
  }>({ url: '/web/admin/process-routes', params })
}

export function fetchProcessRouteDetail(id: string) {
  return request.get<any>({ url: `/web/admin/process-routes/${id}` })
}

export function fetchCreateProcessRoute(data: Record<string, any>) {
  return request.post({ url: '/web/admin/process-routes', params: data })
}

export function fetchUpdateProcessRoute(id: string, data: Record<string, any>) {
  return request.put({ url: `/web/admin/process-routes/${id}`, params: data })
}

export function fetchSetCurrentProcessRoute(routeId: string) {
  return request.put({ url: '/web/admin/process-routes/set-current', params: { routeId } })
}

export function fetchDeleteProcessRoute(id: string) {
  return request.del({ url: `/web/admin/process-routes/${id}` })
}

// 步骤操作
export function fetchAddRouteStep(data: { routeId: string; processId: string; stepOrder?: number }) {
  return request.post({ url: '/web/admin/process-routes/step/add', params: data })
}

export function fetchUpdateRouteStep(data: Record<string, any>) {
  return request.put({ url: '/web/admin/process-routes/step/update', params: data })
}

export function fetchReorderRouteSteps(data: { routeId: string; stepIds: string[] }) {
  return request.put({ url: '/web/admin/process-routes/step/reorder', params: data })
}

export function fetchDeleteRouteStep(stepId: string) {
  return request.del({ url: `/web/admin/process-routes/step/${stepId}` })
}

// 步骤子表
export function fetchUpdateStepReagents(data: { stepId: string; items: any[] }) {
  return request.put({ url: '/web/admin/process-routes/step/reagent/update', params: data })
}

export function fetchUpdateStepConsumables(data: { stepId: string; items: any[] }) {
  return request.put({ url: '/web/admin/process-routes/step/consumable/update', params: data })
}

export function fetchUpdateStepOutputs(data: { stepId: string; items: any[] }) {
  return request.put({ url: '/web/admin/process-routes/step/output/update', params: data })
}

export function fetchUpdateStepDataCollections(data: { stepId: string; items: any[] }) {
  return request.put({ url: '/web/admin/process-routes/step/data-collection/update', params: data })
}
  • Step 2: 验证无 TypeScript 报错

Run: cd web-admin && pnpm build:typecheck Expected: 无新增错误

  • Step 3: 提交
git add web-admin/src/api/production.ts
git commit -m "feat: add process route frontend API functions"

Task 7: 前端路由 — 追加到 sqxp.ts

Files:

  • Modify: web-admin/src/router/modules/sqxp.ts

  • [ ] Step 1: 在 ProductionManagement 的 children 数组中追加路由条目

在 ProductionSop 路由对象之后(path: 'sop' 那一项),追加:

      {
        id: '90000000000000000000000000000010',
        name: 'ProductionProcessRoute',
        path: 'process-route',
        component: '/production/process-route/index',
        meta: { title: '工艺路线管理', keepAlive: true, menuCode: 'production:process-route:list' }
      }
  • Step 2: 提交
git add web-admin/src/router/modules/sqxp.ts
git commit -m "feat: add process route frontend route"

Task 8: 前端页面 — 列表页 + 路线图配置弹窗

Files:

  • Create: web-admin/src/views/production/process-route/index.vue

参考:

  • 列表页结构: src/views/system/unit/index.vue(标准列表模板)
  • 三栏拖拽弹窗: 设计规格文档「前端设计 > 路线图配置弹窗」章节
  • vue-draggable-plus 用法: src/views/system/product/index.vue(import { VueDraggable as draggable } from 'vue-draggable-plus')
  • 工艺库详情编辑: src/views/production/process/index.vue(子表 inline 表格模式)

本页面约 1800-2200 行,包含:

  1. 列表页(搜索栏 + 工具栏 + 表格 + 分页)
  2. 新增/编辑路线弹窗
  3. 路线图配置弹窗(三栏布局)
    • 左栏:el-collapse 工艺库卡片(HTML5 draggable)
    • 中栏:vue-draggable-plus 步骤卡片排序
    • 右栏:步骤详情编辑(基础信息 + 4 个 tab 子表)

因页面代码量大,此处给出完整骨架和关键交互逻辑,实施时按骨架填充。

  • Step 1: 创建页面文件骨架

创建 web-admin/src/views/production/process-route/index.vue,核心结构:

<!-- 工艺路线管理 -->
<template>
  <div class="page-container">
    <header class="page-head">
      <h1>工艺路线管理</h1>
      <p class="page-desc">管理工艺路线基础信息及路线图配置。</p>
    </header>

    <section class="panel">
      <div class="panel-top">
        <h2 class="title-bar-heading">路线列表</h2>
      </div>

      <!-- 搜索栏 -->
      <div class="filter-block">
        <el-form :model="searchForm" class="filter-form" @submit.prevent>
          <div class="filter-grid">
            <el-form-item label="路线名称">
              <el-input v-model="searchForm.routeName" placeholder="路线名称" clearable @keyup.enter="handleSearch" />
            </el-form-item>
            <el-form-item label="路线编码">
              <el-input v-model="searchForm.routeCode" placeholder="路线编码" clearable @keyup.enter="handleSearch" />
            </el-form-item>
            <el-form-item label="产品类型">
              <el-select v-model="searchForm.productTypeId" placeholder="全部" clearable filterable style="width:100%">
                <el-option v-for="t in productTypeOptions" :key="t.id" :label="t.productTypeName" :value="t.id" />
              </el-select>
            </el-form-item>
          </div>
          <div class="filter-actions-row">
            <span class="filter-hint">按路线名称、编码、产品类型筛选。</span>
            <div class="filter-actions-end">
              <el-button class="btn-ghost" @click="handleReset">重置</el-button>
              <el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
            </div>
          </div>
        </el-form>
      </div>

      <!-- 工具栏 -->
      <div class="list-toolbar">
        <div class="list-toolbar-row">
          <el-button class="btn-main" type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>新增路线</el-button>
        </div>
      </div>

      <!-- 表格 -->
      <div class="list-body list-wrap">
        <el-table class="list-table" :data="tableData" v-loading="loading" stripe row-key="id">
          <el-table-column type="index" label="序号" width="68" align="center" header-align="center"
            :index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1" />
          <el-table-column prop="routeName" label="路线名称" min-width="140" align="center" header-align="center" />
          <el-table-column prop="routeCode" label="路线编码" min-width="130" align="center" header-align="center" />
          <el-table-column label="产品类型" min-width="130" align="center" header-align="center">
            <template #default="{ row }">{{ getProductTypeName(row.productTypeId) }}</template>
          </el-table-column>
          <el-table-column prop="version" label="版本号" min-width="100" align="center" header-align="center" />
          <el-table-column label="当前版本" width="100" align="center" header-align="center">
            <template #default="{ row }">
              <el-tag v-if="row.isCurrent === '1'" type="success" size="small">当前</el-tag>
            </template>
          </el-table-column>
          <el-table-column prop="remark" label="备注" min-width="120" align="center" header-align="center" show-overflow-tooltip />
          <el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
          <el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
            <template #default="{ row }">
              <el-button class="btn-op-item primary" @click="handleRouteConfig(row)">路线图配置</el-button>
              <el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
              <el-button v-if="row.isCurrent !== '1'" class="btn-op-item" @click="handleSetCurrent(row)">设为当前</el-button>
              <el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>
        <div class="pagination list-pager-bar">
          <el-pagination :current-page="pagination.page" :page-size="pagination.pageSize" :total="pagination.total"
            :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next"
            @size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
            @current-change="(val) => ((pagination.page = val), loadData())" />
        </div>
      </div>

      <!-- 新增/编辑弹窗 -->
      <el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" class="dialog-form-layout"
        align-center destroy-on-close :close-on-click-modal="false">
        <el-form ref="formRef" :model="formData" :rules="formRules" label-position="top" scroll-to-error>
          <div class="form-grid">
            <el-form-item label="路线编码" prop="routeCode">
              <el-input v-model="formData.routeCode" placeholder="请输入路线编码" :disabled="!!editingId" />
            </el-form-item>
            <el-form-item label="路线名称" prop="routeName">
              <el-input v-model="formData.routeName" placeholder="请输入路线名称" />
            </el-form-item>
            <el-form-item label="产品类型">
              <el-select v-model="formData.productTypeId" placeholder="请选择产品类型" clearable filterable style="width:100%">
                <el-option v-for="t in productTypeOptions" :key="t.id" :label="t.productTypeName" :value="t.id" />
              </el-select>
            </el-form-item>
            <el-form-item label="版本号">
              <el-input v-model="formData.version" placeholder="如 V1.0" />
            </el-form-item>
            <el-form-item label="备注" class="form-col-span-2">
              <el-input v-model="formData.remark" placeholder="可选" />
            </el-form-item>
          </div>
        </el-form>
        <template #footer>
          <el-button @click="dialogVisible = false">取消</el-button>
          <el-button type="primary" @click="handleSubmit">确定</el-button>
        </template>
      </el-dialog>

      <!-- ★ 路线图配置弹窗(三栏布局) -->
      <el-dialog v-model="configDialogVisible" :title="`路线图配置 - ${configRouteName} ${configRouteVersion}`"
        width="90vw" class="dialog-form-layout route-config-dialog" align-center destroy-on-close
        :close-on-click-modal="false">
        <div class="route-config-layout">
          <!-- 左栏:工艺库面板 -->
          <div class="route-config-left">
            <h3>工艺库</h3>
            <el-collapse v-model="expandedCategories" class="process-category-collapse">
              <el-collapse-item v-for="group in processLibrary" :key="group.categoryId || '__none__'"
                :title="group.categoryName" :name="group.categoryId || '__none__'">
                <div v-for="proc in group.processes" :key="proc.id" class="process-source-card"
                  draggable="true" @dragstart="onDragStart($event, proc.id)">
                  <div class="process-source-card-name">{{ proc.processName }}</div>
                  <div class="process-source-card-code">{{ proc.processCode }}</div>
                </div>
                <el-empty v-if="group.processes.length === 0" description="暂无工艺" :image-size="32" />
              </el-collapse-item>
            </el-collapse>
          </div>

          <!-- 中栏:流程布局区 -->
          <div class="route-config-center"
            @dragover.prevent="onDragOverCenter($event)"
            @drop="onDropToCenter($event)">
            <h3>工艺流程布局</h3>
            <draggable v-model="stepList" class="step-card-list" handle=".step-card-handle"
              :animation="180" :force-fallback="true" @end="onStepDragEnd">
              <template #item="{ element, index }">
                <div class="step-card" :class="{ active: selectedStepId === element.id }"
                  @click="selectStep(element)">
                  <div class="step-card-header">
                    <el-icon class="step-card-handle"><Rank /></el-icon>
                    <span class="step-card-order">{{ index + 1 }}</span>
                    <span class="step-card-name">{{ element.processName || '未命名' }}</span>
                    <el-button link type="danger" size="small" @click.stop="handleDeleteStep(element)">
                      <el-icon><Delete /></el-icon>
                    </el-button>
                  </div>
                </div>
              </template>
            </draggable>
            <div v-if="stepList.length === 0" class="step-empty-hint">
              <el-empty description="从左侧拖入工艺卡片,或点击已添加的步骤查看详情" :image-size="64" />
            </div>
          </div>

          <!-- 右栏:工艺详情编辑 -->
          <div class="route-config-right">
            <template v-if="selectedStep">
              <h3>工艺详情</h3>
              <el-form :model="selectedStep" label-position="top" class="step-detail-form">
                <el-form-item label="工序名称">
                  <el-input v-model="selectedStep.processName" @change="onStepFieldChange" />
                </el-form-item>
                <el-form-item label="工序分类">
                  <el-select v-model="selectedStep.categoryId" clearable filterable style="width:100%" @change="onStepFieldChange">
                    <el-option v-for="cat in categoryOptions" :key="cat.id" :label="cat.categoryName || cat.name" :value="cat.id" />
                  </el-select>
                </el-form-item>
                <el-form-item label="描述">
                  <el-input v-model="selectedStep.description" type="textarea" :rows="2" @change="onStepFieldChange" />
                </el-form-item>
                <el-form-item label="设备名称">
                  <el-input v-model="selectedStep.equipmentName" @change="onStepFieldChange" />
                </el-form-item>
                <el-form-item>
                  <template #label>预估耗时<span class="form-label-tip">(min)</span></template>
                  <el-input v-model="selectedStep.estimatedDuration" @change="onStepFieldChange" />
                </el-form-item>
                <el-form-item label="关联SOP">
                  <el-select v-model="selectedStep.sopIdsArr" multiple clearable filterable style="width:100%" @change="onSopChange">
                    <el-option v-for="sop in sopOptions" :key="sop.id" :label="sop.sopName || sop.name" :value="sop.id" />
                  </el-select>
                </el-form-item>
              </el-form>

              <!-- 子表 tabs -->
              <el-tabs v-model="activeSubTab" class="step-sub-tabs">
                <el-tab-pane label="试剂" name="reagent">
                  <!-- 试剂表格:列(编码/名称/规格/标准用量/单位/关键物料),底部添加按钮,行删除 -->
                  <!-- 修改后调用 fetchUpdateStepReagents -->
                </el-tab-pane>
                <el-tab-pane label="耗材" name="consumable">
                  <!-- 耗材表格:列(编码/名称/规格型号/标准用量/单位/必需/备注),同上 -->
                </el-tab-pane>
                <el-tab-pane label="产出" name="output">
                  <!-- 产出表格:列(产出类型[下拉]/名称/条码),同上 -->
                </el-tab-pane>
                <el-tab-pane label="数据采集" name="dataCollection">
                  <!-- 采集表格:列(采集名称/单位),同上 -->
                </el-tab-pane>
              </el-tabs>
            </template>
            <div v-else class="step-empty-hint">
              <el-empty description="请从左侧工艺库拖入工艺,或点击已添加的步骤查看详情" :image-size="64" />
            </div>
          </div>
        </div>
      </el-dialog>
    </section>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VueDraggable as draggable } from 'vue-draggable-plus'
import {
  fetchProcessRouteList, fetchProcessRouteDetail,
  fetchCreateProcessRoute, fetchUpdateProcessRoute,
  fetchSetCurrentProcessRoute, fetchDeleteProcessRoute,
  fetchAddRouteStep, fetchUpdateRouteStep, fetchReorderRouteSteps,
  fetchDeleteRouteStep, fetchUpdateStepReagents, fetchUpdateStepConsumables,
  fetchUpdateStepOutputs, fetchUpdateStepDataCollections,
} from '@/api/production'
import { fetchAllProcesses } from '@/api/production'
import { fetchAllProcessCategories } from '@/api/production'
import { fetchAllSopDocuments } from '@/api/production'
import { fetchCellProductTypeList } from '@/api/admin'

// ===== 列表页状态 =====
const loading = ref(false)
const tableData = ref<any[]>([])
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const searchForm = reactive({ routeName: '', routeCode: '', productTypeId: '' })
const productTypeOptions = ref<any[]>([])

// ===== 新增/编辑弹窗 =====
const dialogVisible = ref(false)
const dialogTitle = ref('新增路线')
const editingId = ref('')
const formData = reactive({ routeCode: '', routeName: '', productTypeId: '', version: '', remark: '' })
const formRules = { routeCode: [{ required: true, message: '请输入路线编码', trigger: 'blur' }], routeName: [{ required: true, message: '请输入路线名称', trigger: 'blur' }] }

// ===== 路线图配置弹窗 =====
const configDialogVisible = ref(false)
const configRouteId = ref('')
const configRouteName = ref('')
const configRouteVersion = ref('')
const stepList = ref<any[]>([])
const selectedStepId = ref('')
const selectedStep = computed(() => stepList.value.find(s => s.id === selectedStepId.value) || null)
const activeSubTab = ref('reagent')

// 工艺库数据(按分类分组)
const processLibrary = ref<any[]>([])
const expandedCategories = ref<string[]>([])
const categoryOptions = ref<any[]>([])
const sopOptions = ref<any[]>([])

// ===== 列表操作 =====
async function loadData() { /* 调用 fetchProcessRouteList */ }
function handleSearch() { pagination.page = 1; loadData() }
function handleReset() { searchForm.routeName = ''; searchForm.routeCode = ''; searchForm.productTypeId = ''; handleSearch() }
function handleAdd() { /* 打开新增弹窗 */ }
function handleEdit(row: any) { /* 打开编辑弹窗 */ }
async function handleSubmit() { /* 新增或编辑提交 */ }
async function handleSetCurrent(row: any) { /* 调用 fetchSetCurrentProcessRoute */ }
async function handleDelete(row: any) { /* 确认后调用 fetchDeleteProcessRoute */ }
function getProductTypeName(id: string) { return productTypeOptions.value.find(t => t.id === id)?.productTypeName || '-' }

// ===== 路线图配置 =====
async function handleRouteConfig(row: any) {
  /* 1. 设置 configRouteId/Name/Version
     2. 调用 fetchAllProcesses + fetchAllProcessCategories → 组装 processLibrary
     3. 调用 fetchProcessRouteDetail → 填充 stepList
     4. 打开 configDialogVisible */
}

// 左栏拖拽
function onDragStart(e: DragEvent, processId: string) {
  e.dataTransfer?.setData('processId', processId)
}
function onDragOverCenter(e: DragEvent) { /* 视觉提示 */ }
async function onDropToCenter(e: DragEvent) {
  const processId = e.dataTransfer?.getData('processId')
  if (!processId) return
  const res = await fetchAddRouteStep({ routeId: configRouteId.value, processId, stepOrder: stepList.value.length + 1 })
  stepList.value.push(res)
}

// 中栏排序
async function onStepDragEnd() {
  const stepIds = stepList.value.map(s => s.id)
  await fetchReorderRouteSteps({ routeId: configRouteId.value, stepIds })
  stepList.value.forEach((s, i) => { s.stepOrder = i + 1 })
}

// 选中步骤
function selectStep(step: any) { selectedStepId.value = step.id }

// 删除步骤
async function handleDeleteStep(step: any) {
  await ElMessageBox.confirm('确认删除该步骤?', '提示', { type: 'warning' })
  await fetchDeleteRouteStep(step.id)
  stepList.value = stepList.value.filter(s => s.id !== step.id)
  if (selectedStepId.value === step.id) selectedStepId.value = ''
}

// 右栏字段实时保存
async function onStepFieldChange() {
  if (!selectedStep.value) return
  await fetchUpdateRouteStep({ stepId: selectedStep.value.id, ...selectedStep.value })
}
function onSopChange(val: string[]) {
  if (selectedStep.value) {
    selectedStep.value.sopIds = val.join(',')
    onStepFieldChange()
  }
}

onMounted(() => {
  loadData()
  fetchCellProductTypeList({ pageSize: 999 }).then(res => { productTypeOptions.value = res.list || [] })
  fetchAllProcessCategories().then(res => { categoryOptions.value = res || [] })
  fetchAllSopDocuments().then(res => { sopOptions.value = res || [] })
})
</script>

关键 CSS(追加在 <style lang="scss" scoped> 中):

.route-config-dialog {
  :deep(.el-dialog__body) {
    padding: 0;
    max-height: calc(100vh - 100px);
    overflow: hidden;
  }
}
.route-config-layout {
  display: flex;
  height: calc(100vh - 120px);
  gap: 0;
}
.route-config-left {
  width: 240px;
  border-right: 1px solid #e5e7eb;
  padding: 16px;
  overflow-y: auto;
  flex-shrink: 0;
}
.route-config-center {
  flex: 1;
  padding: 16px;
  overflow-y: auto;
  min-width: 200px;
}
.route-config-right {
  width: 360px;
  border-left: 1px solid #e5e7eb;
  padding: 16px;
  overflow-y: auto;
  flex-shrink: 0;
}

.process-source-card {
  padding: 8px 12px;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  margin-bottom: 8px;
  cursor: grab;
  transition: box-shadow 0.2s;
  &:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
}
.process-source-card-name { font-size: 14px; font-weight: 500; }
.process-source-card-code { font-size: 12px; color: #9ca3af; }

.step-card {
  padding: 12px 16px;
  border: 2px solid #e5e7eb;
  border-radius: 8px;
  margin-bottom: 8px;
  cursor: pointer;
  transition: border-color 0.2s;
  &:hover { border-color: #10b981; }
  &.active { border-color: #10b981; background: #ecfdf5; }
}
.step-card-header { display: flex; align-items: center; gap: 8px; }
.step-card-handle { cursor: grab; color: #9ca3af; }
.step-card-order {
  background: #10b981;
  color: white;
  border-radius: 50%;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  font-weight: 600;
  flex-shrink: 0;
}
.step-card-name { flex: 1; font-size: 14px; font-weight: 500; }

.step-empty-hint { padding: 40px 0; }
.step-detail-form { margin-bottom: 16px; }
  • Step 2: 填充子表 tab 内容

参照 production/process/index.vue 中试剂/耗材/产出物/数据采集的 inline 表格模式,为 4 个 el-tab-pane 填充完整的表格 UI:

  • 每个 tab 内使用 el-table 展示数据
  • 底部"添加"按钮追加空行
  • 每行有删除按钮
  • 修改后调用对应的 fetchUpdateStep* API 保存

  • [ ] Step 3: 验证页面正常渲染

Run: cd web-admin && pnpm dev 打开 http://localhost:端口/#/production/process-route,确认页面正常显示。

  • Step 4: 提交
git add web-admin/src/views/production/process-route/
git commit -m "feat: add process route management page with drag-and-drop route config"

Task 9: 联调验证

  • Step 1: 后端启动验证

Run: cd backend && source venv/bin/activate && uvicorn app.main:app --reload --port 8000 打开 http://127.0.0.1:8000/docs,确认所有 process-routes 端点可访问。

  • Step 2: 前端启动验证

Run: cd web-admin && pnpm dev 打开工艺路线管理页面,验证:

  1. 列表页渲染正常
  2. 新增路线弹窗可提交
  3. 编辑路线弹窗可修改
  4. 设为当前版本可点击
  5. 路线图配置弹窗可打开
  6. 左栏工艺库按分类展示
  7. 拖拽工艺到中栏可创建步骤
  8. 中栏步骤可排序
  9. 点击步骤右栏可编辑
  10. 删除步骤可确认删除
  • Step 3: 最终提交

如有修复,提交所有修复。