Files
zkxw/docs/superpowers/plans/2026-05-20-qc-material-library.md
T
2026-05-20 09:28:36 +08:00

43 KiB
Raw Blame History

质检物料库 + 物料使用扣减 实施计划

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: 2 张新数据库表(biz_qc_material + biz_qc_material_usage),后端遵循现有 qc_third_party_company CRUD 模式,前端遵循 production/consumable 列表页模式。物料扣减嵌入现有 complete-with-result API,向后兼容。

Tech Stack: FastAPI + SQLAlchemy + Pydantic v2(后端);Vue 3 + TypeScript + Element Plus + Tailwind CSS 4(前端);MySQL 5.7

Spec: docs/superpowers/specs/2026-05-20-qc-material-library-design.md


File Structure

New Files (7)

File Responsibility
backend/app/models/qc_material.py BizQcMaterial + BizQcMaterialUsage SQLAlchemy 模型
backend/app/crud/qc_material.py 物料 CRUD:list/get/create/update/soft_delete/stock_in
backend/app/crud/qc_material_usage.py 变动记录:list/deduct_for_task
web-admin/src/api/qc-material.ts 物料 API 类型 + fetch 函数
web-admin/src/api/qc-material-usage.ts 变动记录 API 类型 + fetch 函数
web-admin/src/views/quality-inspection/qc-material/index.vue 质检物料库列表页
web-admin/src/views/quality-inspection/qc-material-usage/index.vue 物料变动记录列表页

Modified Files (5)

File Change
backend/app/models/models.py Import 新模型
backend/app/api/qc.py 新增物料/变动记录路由端点
backend/app/crud/qc_task.py 扩展 complete_task 支持物料扣减
web-admin/src/views/quality-inspection/qc-task/index.vue 完成弹窗嵌入物料使用表格
web-admin/src/router/modules/sqxp.ts 新增 2 个子路由

SQL Files (1)

File Change
docs/sql/qc_material.sql DDL + 菜单插入语句

Task 1: 数据库 DDL + SQL 迁移文件

Files:

  • Create: docs/sql/qc_material.sql

  • Step 1: 创建 SQL 文件

-- ----------------------------
-- 质检物料库 + 物料变动记录
-- ----------------------------

SET NAMES utf8mb4;

-- ----------------------------
-- Table structure for biz_qc_material
-- ----------------------------
DROP TABLE IF EXISTS `biz_qc_material`;
CREATE TABLE `biz_qc_material` (
  `id` varchar(64) NOT NULL COMMENT '主键ID',
  `material_code` varchar(64) NOT NULL COMMENT '物料编码',
  `material_name` varchar(200) NOT NULL COMMENT '物料名称',
  `spec_model` varchar(200) DEFAULT NULL COMMENT '规格型号',
  `unit` varchar(32) DEFAULT NULL COMMENT '单位',
  `stock_qty` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '当前库存量',
  `warning_qty` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '预警库存量',
  `status` char(1) NOT NULL DEFAULT '1' COMMENT '1-启用 0-停用',
  `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`),
  UNIQUE KEY `uk_qc_mat_tenant_code` (`tenant_no`, `material_code`, `del_flag`),
  KEY `idx_qc_mat_tenant_status` (`tenant_no`, `status`, `del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='质检物料';

-- ----------------------------
-- Table structure for biz_qc_material_usage
-- ----------------------------
DROP TABLE IF EXISTS `biz_qc_material_usage`;
CREATE TABLE `biz_qc_material_usage` (
  `id` varchar(64) NOT NULL COMMENT '主键ID',
  `material_id` varchar(64) NOT NULL COMMENT '物料ID',
  `material_code` varchar(64) DEFAULT NULL COMMENT '物料编码快照',
  `material_name` varchar(200) DEFAULT NULL COMMENT '物料名称快照',
  `change_type` varchar(16) NOT NULL COMMENT '变动类型:use使用 / in入库',
  `change_qty` decimal(18,4) NOT NULL COMMENT '变动数量(使用为负,入库为正)',
  `stock_after` decimal(18,4) NOT NULL COMMENT '变动后库存量',
  `qc_task_id` varchar(64) DEFAULT NULL COMMENT '关联质检任务ID(使用时有值)',
  `qc_task_no` varchar(32) DEFAULT NULL COMMENT '质检任务号快照',
  `source_type` varchar(16) NOT NULL COMMENT '来源类型:task_complete任务完成 / manual_in手动入库',
  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' 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_qc_mu_material` (`material_id`, `del_flag`),
  KEY `idx_qc_mu_task` (`qc_task_id`, `del_flag`),
  KEY `idx_qc_mu_tenant_time` (`tenant_no`, `change_type`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 ('70000000000000000000000000000009', '质检物料库', '70000000000000000000000000000001', 'C', 'qc-material', '/quality-inspection/qc-material/index', 'biz:qcMaterial:list', 1, '1', '1', 'zkxw', 8, NULL, NULL, '', NOW(), NULL, NOW(), 0);

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 ('70000000000000000000000000000010', '物料变动记录', '70000000000000000000000000000001', 'C', 'qc-material-usage', '/quality-inspection/qc-material-usage/index', 'biz:qcMaterialUsage:list', 1, '1', '1', 'zkxw', 9, NULL, NULL, '', NOW(), NULL, NOW(), 0);
  • Step 2: 在数据库执行 SQL

连接 MySQL 执行上述 DDL,确认表和菜单创建成功。

  • Step 3: Commit
git add docs/sql/qc_material.sql
git commit -m "feat(qc): add DDL for QC material library and usage tables"

Task 2: 后端模型定义

Files:

  • Create: backend/app/models/qc_material.py

  • Modify: backend/app/models/models.py(末尾添加 import)

  • Step 1: 创建模型文件 backend/app/models/qc_material.py

参照 backend/app/models/models.py 中 BizQcThirdPartyCompany 的模式(L1071-1104),保持完全一致的公共字段风格:

"""质检物料库 + 物料变动记录 SQLAlchemy 模型"""
from sqlalchemy import Column, String, Numeric, CHAR, Integer, DateTime, text
from sqlalchemy.ext.declarative import declarative_base

from app.models.base import Base, generate_uuid


class BizQcMaterial(Base):
    """质检物料"""
    __tablename__ = 'biz_qc_material'

    id = Column(String(64), primary_key=True, default=generate_uuid, comment='主键ID')
    material_code = Column(String(64), nullable=False, comment='物料编码')
    material_name = Column(String(200), nullable=False, comment='物料名称')
    spec_model = Column(String(200), comment='规格型号')
    unit = Column(String(32), comment='单位')
    stock_qty = Column(Numeric(18, 4), nullable=False, default=0, comment='当前库存量')
    warning_qty = Column(Numeric(18, 4), nullable=False, default=0, comment='预警库存量')
    status = Column(CHAR(1), nullable=False, default='1', comment='1-启用 0-停用')

    # 公共字段
    tenant_no = Column(String(64), nullable=False, default='zkxw', comment='租户编号')
    order_num = Column(Integer, comment='排序号')
    remark = Column(String(512), comment='备注')
    create_by = Column(String(64), comment='创建人id')
    create_dept_id = Column(String(64), nullable=False, default='', comment='创建人部门ID')
    create_time = Column(DateTime, nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间')
    update_by = Column(String(64), comment='更新人id')
    update_time = Column(DateTime, nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间')
    del_flag = Column(Integer, nullable=False, default=0, comment='删除标记:0-正常 1-已删除')


class BizQcMaterialUsage(Base):
    """质检物料变动记录"""
    __tablename__ = 'biz_qc_material_usage'

    id = Column(String(64), primary_key=True, default=generate_uuid, comment='主键ID')
    material_id = Column(String(64), nullable=False, comment='物料ID')
    material_code = Column(String(64), comment='物料编码快照')
    material_name = Column(String(200), comment='物料名称快照')
    change_type = Column(String(16), nullable=False, comment='变动类型:use使用 / in入库')
    change_qty = Column(Numeric(18, 4), nullable=False, comment='变动数量(使用为负,入库为正)')
    stock_after = Column(Numeric(18, 4), nullable=False, comment='变动后库存量')
    qc_task_id = Column(String(64), comment='关联质检任务ID')
    qc_task_no = Column(String(32), comment='质检任务号快照')
    source_type = Column(String(16), nullable=False, comment='来源类型:task_complete / manual_in')

    # 公共字段(无 order_num)
    tenant_no = Column(String(64), nullable=False, default='zkxw', comment='租户编号')
    remark = Column(String(512), comment='备注')
    create_by = Column(String(64), comment='创建人id')
    create_dept_id = Column(String(64), nullable=False, default='', comment='创建人部门ID')
    create_time = Column(DateTime, nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='创建时间')
    update_by = Column(String(64), comment='更新人id')
    update_time = Column(DateTime, nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='更新时间')
    del_flag = Column(Integer, nullable=False, default=0, comment='删除标记:0-正常 1-已删除')
  • Step 2: 在 backend/app/models/models.py 末尾添加 import

在 models.py 文件的 import 区域(其他 from app.models.xxx import 附近)添加:

from app.models.qc_material import BizQcMaterial, BizQcMaterialUsage

注意:确认 models.py 中已有的 import 模式,在同样的位置添加。

  • Step 3: 验证模型可正常导入
cd backend && source venv/bin/activate && python -c "from app.models.qc_material import BizQcMaterial, BizQcMaterialUsage; print('OK')"

Expected: OK

  • Step 4: Commit
git add backend/app/models/qc_material.py backend/app/models/models.py
git commit -m "feat(qc): add SQLAlchemy models for QC material and usage"

Task 3: 后端物料 CRUD

Files:

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

  • Step 1: 创建物料 CRUD 文件

严格参照 backend/app/crud/qc_third_party_company.py 的模式(259 行)。关键模式:

  • _row_to_dict() — 手动序列化,camelCase 键名,_dec() 处理 decimal,_fmt_dt() 处理 datetime
  • code_exists() — 租户内编码唯一校验
  • list_xxx() — _filtered() 内部函数 + func.count + 分页
  • create_xxx() — generate_uuid() + datetime.now() + del_flag=0
  • update_xxx() — 仅更新非 None 字段
  • soft_delete_xxx() — del_flag=1
  • stock_in() — 入库:增加 stock_qty + 写变动记录
"""质检物料 CRUD"""
from datetime import datetime
from typing import Optional

from fastapi import HTTPException
from sqlalchemy import func, and_
from sqlalchemy.orm import Session

from app.core.auth import get_current_user_id, get_current_user_dept_id, get_current_tenant_no
from app.models.qc_material import BizQcMaterial, BizQcMaterialUsage
from app.utils.uuid import generate_uuid


def _dec(v):
    """Decimal -> float 或 None"""
    return float(v) if v is not None else None


def _fmt_dt(v):
    """datetime -> str 或 None"""
    return v.strftime('%Y-%m-%d %H:%M:%S') if v else None


def _row_to_dict(row: BizQcMaterial) -> dict:
    return {
        'id': row.id,
        'materialCode': row.material_code,
        'materialName': row.material_name,
        'specModel': row.spec_model,
        'unit': row.unit,
        'stockQty': _dec(row.stock_qty),
        'warningQty': _dec(row.warning_qty),
        'status': row.status,
        'tenantNo': row.tenant_no,
        'orderNum': row.order_num,
        'remark': row.remark,
        'createBy': row.create_by,
        'createDeptId': row.create_dept_id,
        'createTime': _fmt_dt(row.create_time),
        'updateBy': row.update_by,
        'updateTime': _fmt_dt(row.update_time),
    }


def code_exists(db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
    q = db.query(func.count()).select_from(BizQcMaterial).filter(
        BizQcMaterial.material_code == code,
        BizQcMaterial.tenant_no == tenant_no,
        BizQcMaterial.del_flag == 0,
    )
    if exclude_id:
        q = q.filter(BizQcMaterial.id != exclude_id)
    return q.scalar() > 0


def total_pages(db: Session, tenant_no: str, keyword: Optional[str] = None) -> int:
    q = db.query(func.count()).select_from(BizQcMaterial).filter(
        BizQcMaterial.tenant_no == tenant_no,
        BizQcMaterial.del_flag == 0,
    )
    if keyword:
        q = q.filter(BizQcMaterial.material_name.like(f'%{keyword}%'))
    return q.scalar() or 0


def list_materials(db: Session, tenant_no: str, page: int = 1, page_size: int = 20,
                   keyword: Optional[str] = None, status: Optional[str] = None):
    def _filtered(q):
        q = q.filter(BizQcMaterial.tenant_no == tenant_no, BizQcMaterial.del_flag == 0)
        if keyword:
            q = q.filter(BizQcMaterial.material_name.like(f'%{keyword}%'))
        if status is not None:
            q = q.filter(BizQcMaterial.status == status)
        return q

    total = _filtered(db.query(func.count()).select_from(BizQcMaterial)).scalar() or 0
    rows = (_filtered(db.query(BizQcMaterial))
            .order_by(BizQcMaterial.create_time.desc())
            .offset((page - 1) * page_size)
            .limit(page_size)
            .all())
    return [_row_to_dict(r) for r in rows], total


def get_material(db: Session, material_id: str) -> Optional[dict]:
    row = db.query(BizQcMaterial).filter(
        BizQcMaterial.id == material_id, BizQcMaterial.del_flag == 0
    ).first()
    return _row_to_dict(row) if row else None


def create_material(db: Session, data: dict, tenant_no: str, user_id: str, dept_id: str) -> dict:
    code = (data.get('materialCode') or '').strip()
    name = (data.get('materialName') or '').strip()
    if not code or not name:
        raise HTTPException(400, '编码和名称不能为空')
    if code_exists(db, code, tenant_no):
        raise HTTPException(400, f'物料编码 {code} 已存在')

    row = BizQcMaterial(
        id=generate_uuid(),
        material_code=code,
        material_name=name,
        spec_model=(data.get('specModel') or '').strip() or None,
        unit=(data.get('unit') or '').strip() or None,
        stock_qty=0,
        warning_qty=float(data.get('warningQty') or 0),
        status=data.get('status', '1'),
        tenant_no=tenant_no,
        order_num=data.get('orderNum'),
        remark=(data.get('remark') or '').strip() or None,
        create_by=user_id,
        create_dept_id=dept_id,
        del_flag=0,
    )
    db.add(row)
    db.commit()
    db.refresh(row)
    return _row_to_dict(row)


def update_material(db: Session, material_id: str, data: dict, tenant_no: str) -> Optional[dict]:
    row = db.query(BizQcMaterial).filter(
        BizQcMaterial.id == material_id,
        BizQcMaterial.tenant_no == tenant_no,
        BizQcMaterial.del_flag == 0,
    ).first()
    if not row:
        return None

    new_code = data.get('materialCode')
    if new_code is not None:
        new_code = new_code.strip()
        if new_code != row.material_code and code_exists(db, new_code, tenant_no, exclude_id=material_id):
            raise HTTPException(400, f'物料编码 {new_code} 已存在')
        row.material_code = new_code

    simple_fields = {
        'materialName': 'material_name',
        'specModel': 'spec_model',
        'unit': 'unit',
        'warningQty': 'warning_qty',
        'status': 'status',
        'orderNum': 'order_num',
        'remark': 'remark',
    }
    for api_key, col_name in simple_fields.items():
        v = data.get(api_key)
        if v is not None:
            if isinstance(v, str):
                v = v.strip() or None
            setattr(row, col_name, v)

    db.commit()
    db.refresh(row)
    return _row_to_dict(row)


def soft_delete_material(db: Session, material_id: str, tenant_no: str) -> bool:
    row = db.query(BizQcMaterial).filter(
        BizQcMaterial.id == material_id,
        BizQcMaterial.tenant_no == tenant_no,
        BizQcMaterial.del_flag == 0,
    ).first()
    if not row:
        return False
    if row.stock_qty and float(row.stock_qty) > 0:
        raise HTTPException(400, '库存不为零的物料不能删除')
    row.del_flag = 1
    db.commit()
    return True


def stock_in(db: Session, material_id: str, qty: float, remark: Optional[str],
             tenant_no: str, user_id: str, dept_id: str) -> dict:
    """手动入库"""
    if qty <= 0:
        raise HTTPException(400, '入库数量必须大于零')

    # 行锁防并发
    row = db.query(BizQcMaterial).filter(
        BizQcMaterial.id == material_id,
        BizQcMaterial.tenant_no == tenant_no,
        BizQcMaterial.del_flag == 0,
    ).with_for_update().first()
    if not row:
        raise HTTPException(404, '物料不存在')

    old_stock = float(row.stock_qty or 0)
    new_stock = old_stock + qty
    row.stock_qty = new_stock

    usage = BizQcMaterialUsage(
        id=generate_uuid(),
        material_id=row.id,
        material_code=row.material_code,
        material_name=row.material_name,
        change_type='in',
        change_qty=qty,
        stock_after=new_stock,
        source_type='manual_in',
        tenant_no=tenant_no,
        remark=(remark or '').strip() or None,
        create_by=user_id,
        create_dept_id=dept_id,
        del_flag=0,
    )
    db.add(usage)
    db.commit()
    db.refresh(row)
    return _row_to_dict(row)


def options_list(db: Session, tenant_no: str) -> list:
    """下拉选项(仅启用且有库存的物料)"""
    rows = db.query(BizQcMaterial).filter(
        BizQcMaterial.tenant_no == tenant_no,
        BizQcMaterial.del_flag == 0,
        BizQcMaterial.status == '1',
    ).order_by(BizQcMaterial.material_code).all()
    return [{'id': r.id, 'label': f'{r.material_name}({r.material_code})',
             'materialCode': r.material_code, 'materialName': r.material_name,
             'specModel': r.spec_model, 'unit': r.unit,
             'stockQty': _dec(r.stock_qty)} for r in rows]
  • Step 2: 验证导入
cd backend && source venv/bin/activate && python -c "from app.crud.qc_material import list_materials, create_material, stock_in; print('OK')"

Expected: OK

  • Step 3: Commit
git add backend/app/crud/qc_material.py
git commit -m "feat(qc): add QC material CRUD with stock-in support"

Task 4: 后端物料变动记录 CRUD + 扣减逻辑

Files:

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

  • Step 1: 创建变动记录 CRUD 文件

"""质检物料变动记录 CRUD"""
from datetime import datetime
from typing import Optional

from fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session

from app.models.qc_material import BizQcMaterialUsage, BizQcMaterial
from app.utils.uuid import generate_uuid


def _dec(v):
    return float(v) if v is not None else None


def _fmt_dt(v):
    return v.strftime('%Y-%m-%d %H:%M:%S') if v else None


def _row_to_dict(row: BizQcMaterialUsage) -> dict:
    return {
        'id': row.id,
        'materialId': row.material_id,
        'materialCode': row.material_code,
        'materialName': row.material_name,
        'changeType': row.change_type,
        'changeQty': _dec(row.change_qty),
        'stockAfter': _dec(row.stock_after),
        'qcTaskId': row.qc_task_id,
        'qcTaskNo': row.qc_task_no,
        'sourceType': row.source_type,
        'tenantNo': row.tenant_no,
        'remark': row.remark,
        'createBy': row.create_by,
        'createDeptId': row.create_dept_id,
        'createTime': _fmt_dt(row.create_time),
        'updateBy': row.update_by,
        'updateTime': _fmt_dt(row.update_time),
    }


def list_usages(db: Session, tenant_no: str, page: int = 1, page_size: int = 20,
                material_id: Optional[str] = None, change_type: Optional[str] = None,
                start_time: Optional[str] = None, end_time: Optional[str] = None):
    q = db.query(BizQcMaterialUsage).filter(
        BizQcMaterialUsage.tenant_no == tenant_no,
        BizQcMaterialUsage.del_flag == 0,
    )
    if material_id:
        q = q.filter(BizQcMaterialUsage.material_id == material_id)
    if change_type:
        q = q.filter(BizQcMaterialUsage.change_type == change_type)
    if start_time:
        q = q.filter(BizQcMaterialUsage.create_time >= start_time)
    if end_time:
        q = q.filter(BizQcMaterialUsage.create_time <= end_time + ' 23:59:59')

    total = q.with_entities(func.count()).scalar() or 0
    rows = (q.order_by(BizQcMaterialUsage.create_time.desc())
            .offset((page - 1) * page_size)
            .limit(page_size)
            .all())
    return [_row_to_dict(r) for r in rows], total


def deduct_for_task(db: Session, task_id: str, task_no: str,
                    material_usages: list, tenant_no: str, user_id: str, dept_id: str):
    """
    质检任务完成时扣减物料库存。
    material_usages: [{'materialId': '...', 'qty': 1.5}, ...]
    整体在一个事务中执行(由调用方控制事务)。
    """
    if not material_usages:
        return

    for item in material_usages:
        mid = item.get('materialId')
        qty = float(item.get('qty', 0))
        if not mid or qty <= 0:
            raise HTTPException(400, '物料ID和使用数量不能为空')

        # 行锁
        mat = db.query(BizQcMaterial).filter(
            BizQcMaterial.id == mid,
            BizQcMaterial.tenant_no == tenant_no,
            BizQcMaterial.del_flag == 0,
            BizQcMaterial.status == '1',
        ).with_for_update().first()

        if not mat:
            raise HTTPException(400, f'物料 {mid} 不存在或已停用')

        old_stock = float(mat.stock_qty or 0)
        if qty > old_stock:
            raise HTTPException(400, f'物料「{mat.material_name}」库存不足,当前库存 {old_stock},需使用 {qty}')

        new_stock = old_stock - qty
        mat.stock_qty = new_stock

        usage = BizQcMaterialUsage(
            id=generate_uuid(),
            material_id=mat.id,
            material_code=mat.material_code,
            material_name=mat.material_name,
            change_type='use',
            change_qty=-qty,
            stock_after=new_stock,
            qc_task_id=task_id,
            qc_task_no=task_no,
            source_type='task_complete',
            tenant_no=tenant_no,
            remark=item.get('remark', '').strip() or None,
            create_by=user_id,
            create_dept_id=dept_id,
            del_flag=0,
        )
        db.add(usage)

    # 不 commit,由调用方统一 commit
  • Step 2: 验证导入
cd backend && source venv/bin/activate && python -c "from app.crud.qc_material_usage import list_usages, deduct_for_task; print('OK')"

Expected: OK

  • Step 3: Commit
git add backend/app/crud/qc_material_usage.py
git commit -m "feat(qc): add QC material usage CRUD with deduction logic"

Task 5: 后端 API 路由 + 扩展任务完成逻辑

Files:

  • Modify: backend/app/api/qc.py

  • Modify: backend/app/crud/qc_task.py

  • Step 1: 在 backend/app/api/qc.py 顶部添加 import

在已有的 import 区域添加:

from app.crud import qc_material as mat_svc
from app.crud import qc_material_usage as mu_svc
  • Step 2: 在 backend/app/api/qc.py 末尾添加物料库路由

参照文件中已有的路由模式(require_menu_code 权限守卫 + success_response 响应格式):

# ==================== 质检物料库 ====================

@router.get('/qc-materials')
async def api_list_qc_materials(
    page: int = Query(1, ge=1),
    pageSize: int = Query(20, ge=1, le=200),
    keyword: Optional[str] = Query(None),
    status: Optional[str] = Query(None),
    db: Session = Depends(get_db),
    _=Depends(require_menu_code('biz:qcMaterial:list')),
):
    tenant_no = get_current_tenant_no()
    items, total = mat_svc.list_materials(db, tenant_no, page, pageSize, keyword, status)
    return success_response(_page_payload(items, total, page, pageSize))


@router.get('/qc-materials/options')
async def api_qc_material_options(
    db: Session = Depends(get_db),
    _=Depends(require_menu_code('biz:qcMaterial:list')),
):
    tenant_no = get_current_tenant_no()
    return success_response(mat_svc.options_list(db, tenant_no))


@router.post('/qc-materials')
async def api_create_qc_material(
    body: dict = Body(...),
    db: Session = Depends(get_db),
    _=Depends(require_menu_code('biz:qcMaterial:list')),
):
    tenant_no = get_current_tenant_no()
    user_id = get_current_user_id()
    dept_id = get_current_user_dept_id()
    return success_response(mat_svc.create_material(db, body, tenant_no, user_id, dept_id))


@router.put('/qc-materials/{material_id}')
async def api_update_qc_material(
    material_id: str = Path(...),
    body: dict = Body(...),
    db: Session = Depends(get_db),
    _=Depends(require_menu_code('biz:qcMaterial:list')),
):
    tenant_no = get_current_tenant_no()
    result = mat_svc.update_material(db, material_id, body, tenant_no)
    if not result:
        raise HTTPException(404, '物料不存在')
    return success_response(result)


@router.delete('/qc-materials/{material_id}')
async def api_delete_qc_material(
    material_id: str = Path(...),
    db: Session = Depends(get_db),
    _=Depends(require_menu_code('biz:qcMaterial:list')),
):
    tenant_no = get_current_tenant_no()
    if not mat_svc.soft_delete_material(db, material_id, tenant_no):
        raise HTTPException(404, '物料不存在')
    return success_response(None)


@router.post('/qc-materials/{material_id}/stock-in')
async def api_stock_in_qc_material(
    material_id: str = Path(...),
    body: dict = Body(...),
    db: Session = Depends(get_db),
    _=Depends(require_menu_code('biz:qcMaterial:list')),
):
    tenant_no = get_current_tenant_no()
    user_id = get_current_user_id()
    dept_id = get_current_user_dept_id()
    qty = float(body.get('qty', 0))
    remark = body.get('remark', '')
    return success_response(mat_svc.stock_in(db, material_id, qty, remark, tenant_no, user_id, dept_id))


# ==================== 物料变动记录 ====================

@router.get('/qc-material-usages')
async def api_list_qc_material_usages(
    page: int = Query(1, ge=1),
    pageSize: int = Query(20, ge=1, le=200),
    materialId: Optional[str] = Query(None),
    changeType: Optional[str] = Query(None),
    startTime: Optional[str] = Query(None),
    endTime: Optional[str] = Query(None),
    db: Session = Depends(get_db),
    _=Depends(require_menu_code('biz:qcMaterialUsage:list')),
):
    tenant_no = get_current_tenant_no()
    items, total = mu_svc.list_usages(db, tenant_no, page, pageSize, materialId, changeType, startTime, endTime)
    return success_response(_page_payload(items, total, page, pageSize))
  • Step 3: 修改 backend/app/crud/qc_task.py 的 apply_task_result 函数

在文件顶部添加 import:

from app.crud import qc_material_usage as mu_svc

找到 apply_task_result 函数,在函数签名中新增 material_usages 参数,并在保存结果后、commit 前调用扣减:

修改后的函数签名和逻辑(在 complete_task 调用或直接更新之前,添加物料扣减):

def apply_task_result(db: Session, task_id: str, conclusion: str, remark: Optional[str],
                      attachments: Optional[str], results: Optional[list] = None,
                      material_usages: Optional[list] = None):

在函数体内,complete_task 或状态更新逻辑之后、最终 commit 之前,添加:

    # 物料扣减(在同一个事务中)
    if material_usages:
        mu_svc.deduct_for_task(
            db, task_id=task_id, task_no=task.task_no,
            material_usages=material_usages,
            tenant_no=task.tenant_no,
            user_id=get_current_user_id(),
            dept_id=get_current_user_dept_id(),
        )

注意:需要根据 qc_task.py 中 apply_task_result 的实际代码位置插入。确保在 db.commit() 调用之前。

  • Step 4: 修改 backend/app/api/qc.py 中 complete-with-result 端点

在端点的请求体解析中添加 materialUsages 字段。找到类似以下的 body 解析:

results = body.get('results', [])
conclusion = body.get('conclusion')
remark = body.get('remark', '')
attachments = body.get('attachments', '')

在其后添加:

material_usages = body.get('materialUsages', None)

并在调用 task_svc.apply_task_result() 时传入:

task_svc.apply_task_result(db, task_id=task_id, conclusion=conclusion, remark=remark,
                           attachments=attachments, results=results,
                           material_usages=material_usages)
  • Step 5: 验证 API 无语法错误
cd backend && source venv/bin/activate && python -c "from app.api.qc import router; print('OK')"

Expected: OK

  • Step 6: Commit
git add backend/app/api/qc.py backend/app/crud/qc_task.py
git commit -m "feat(qc): add material library API routes and extend task completion with deduction"

Task 6: 前端 API 类型定义

Files:

  • Create: web-admin/src/api/qc-material.ts

  • Create: web-admin/src/api/qc-material-usage.ts

  • Step 1: 创建 web-admin/src/api/qc-material.ts

参照 web-admin/src/api/qc-task.ts 的模式:

import request from '@utils/request'

export interface QcMaterial {
  id: string
  materialCode: string
  materialName: string
  specModel: string
  unit: string
  stockQty: number
  warningQty: number
  status: string
  tenantNo: string
  orderNum: number
  remark: string
  createBy: string
  createTime: string
  updateTime: string
}

export interface QcMaterialOption {
  id: string
  label: string
  materialCode: string
  materialName: string
  specModel: string
  unit: string
  stockQty: number
}

export function fetchQcMaterialList(params: {
  page: number
  pageSize: number
  keyword?: string
  status?: string
}) {
  return request({ url: '/admin/web/admin/qc-materials', method: 'get', params })
}

export function fetchQcMaterialOptions() {
  return request({ url: '/admin/web/admin/qc-materials/options', method: 'get' })
}

export function fetchCreateQcMaterial(data: Record<string, any>) {
  return request({ url: '/admin/web/admin/qc-materials', method: 'post', data })
}

export function fetchUpdateQcMaterial(id: string, data: Record<string, any>) {
  return request({ url: `/admin/web/admin/qc-materials/${id}`, method: 'put', data })
}

export function fetchDeleteQcMaterial(id: string) {
  return request({ url: `/admin/web/admin/qc-materials/${id}`, method: 'delete' })
}

export function fetchStockInQcMaterial(id: string, data: { qty: number; remark?: string }) {
  return request({ url: `/admin/web/admin/qc-materials/${id}/stock-in`, method: 'post', data })
}
  • Step 2: 创建 web-admin/src/api/qc-material-usage.ts
import request from '@utils/request'

export interface QcMaterialUsage {
  id: string
  materialId: string
  materialCode: string
  materialName: string
  changeType: string
  changeQty: number
  stockAfter: number
  qcTaskId: string
  qcTaskNo: string
  sourceType: string
  remark: string
  createBy: string
  createTime: string
}

export function fetchQcMaterialUsageList(params: {
  page: number
  pageSize: number
  materialId?: string
  changeType?: string
  startTime?: string
  endTime?: string
}) {
  return request({ url: '/admin/web/admin/qc-material-usages', method: 'get', params })
}
  • Step 3: Commit
git add web-admin/src/api/qc-material.ts web-admin/src/api/qc-material-usage.ts
git commit -m "feat(qc): add frontend API types for QC material and usage"

Task 7: 前端路由配置

Files:

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

  • Step 1: 在 sqxp.ts 质量检测 children 数组末尾添加 2 个路由

找到质量检测的 children 数组(最后一个子路由是 qc-task),在其后添加:

{
  id: '70000000000000000000000000000009',
  name: 'QualityInspectionQcMaterial',
  path: 'qc-material',
  component: '/quality-inspection/qc-material/index',
  meta: { title: '质检物料库', keepAlive: true, menuCode: 'biz:qcMaterial:list' },
},
{
  id: '70000000000000000000000000000010',
  name: 'QualityInspectionQcMaterialUsage',
  path: 'qc-material-usage',
  component: '/quality-inspection/qc-material-usage/index',
  meta: { title: '物料变动记录', keepAlive: true, menuCode: 'biz:qcMaterialUsage:list' },
},
  • Step 2: Commit
git add web-admin/src/router/modules/sqxp.ts
git commit -m "feat(qc): add routes for QC material and usage pages"

Task 8: 前端质检物料库页面

Files:

  • Create: web-admin/src/views/quality-inspection/qc-material/index.vue

  • Step 1: 创建物料库列表页

严格参照 web-admin/src/views/production/consumable/index.vue(385 行)的完整模式。页面结构:

  • page-container > page-head > panel
  • 筛选区:关键词搜索 + 状态下拉
  • 工具栏:「新增物料」primary 按钮
  • 表格列:选择框、序号、物料编码、物料名称、规格型号、单位、当前库存(预警标红)、预警库存、状态(Tag)、操作列
  • 操作列:编辑、入库、停用/启用、删除
  • 新增/编辑弹窗:编码、名称、规格、单位、预警量、状态、备注
  • 入库弹窗:入库数量(el-input-number)+ 备注
  • 查看弹窗:SystemViewDialog

页面整体结构、样式类名、变量命名、提交逻辑完全参照 consumable/index.vue。 差异点:

  • 表格多一列「当前库存」和「预警库存」
  • 多一个「入库」操作按钮和对应的入库弹窗
  • 库存预警:stock_qty <= warning_qty 时用红色显示库存数字
  • 删除前校验:前端也提示库存不为零不能删除
<!-- 
  参照 web-admin/src/views/production/consumable/index.vue 的完整结构编写。
  关键差异:
  1. API 调用改为 fetchQcMaterialList / fetchCreateQcMaterial 等
  2. 表格增加 stockQty / warningQty 列
  3. 增加入库弹窗(stockInVisible, stockInForm, stockInSubmit)
  4. 库存预警样式
  5. 状态 Tag 使用 status-tag-registry 中的 enabled/disabled 映射
  6. 单位下拉从 fetchItemUnitList 获取(与 consumable 一致)
-->

实际代码按 consumable/index.vue 为模板修改,此处标注关键差异,实施时复制模板后逐一修改。

  • Step 2: Commit
git add web-admin/src/views/quality-inspection/qc-material/index.vue
git commit -m "feat(qc): add QC material library page"

Task 9: 前端物料变动记录页面

Files:

  • Create: web-admin/src/views/quality-inspection/qc-material-usage/index.vue

  • Step 1: 创建变动记录列表页

标准只读列表页,参照其他 QC 列表页的模式:

  • 筛选区:物料下拉(调用 fetchQcMaterialOptions)、变动类型下拉(use/in)、时间范围(el-date-picker type="daterange")
  • 表格列:物料名称、物料编码、变动类型(Tag:use=橙色/in=绿色)、变动数量、变动后库存、关联任务号、来源类型、备注、操作人、操作时间
  • 无新增/编辑/删除操作
  • 变动数量:入库为正数绿色,使用为负数红色

参照 web-admin/src/views/quality-inspection/third-party-company/index.vue 的列表结构。

  • Step 2: Commit
git add web-admin/src/views/quality-inspection/qc-material-usage/index.vue
git commit -m "feat(qc): add QC material usage record page"

Task 10: 前端质检任务完成弹窗嵌入物料使用

Files:

  • Modify: web-admin/src/views/quality-inspection/qc-task/index.vue

  • Modify: web-admin/src/api/qc-task.ts

  • Step 1: 在 qc-task/index.vue 的 script 中添加物料相关状态

在现有状态变量(如 resultAttachments)附近添加:

import { fetchQcMaterialOptions, type QcMaterialOption } from '@api/qc-material'

// 物料使用相关
const materialOptions = ref<QcMaterialOption[]>([])
const materialUsages = ref<Array<{
  materialId: string
  materialName: string
  specModel: string
  unit: string
  stockQty: number
  qty: number | undefined
}>>([])
  • Step 2: 在 openResultDialog 函数中加载物料选项

在打开结果弹窗的函数中添加:

fetchQcMaterialOptions().then((res) => {
  materialOptions.value = res.data || []
})
materialUsages.value = []
  • Step 3: 在 qc-task/index.vue 的结果弹窗 template 中添加物料使用区域

在现有「检测参数」section 和「检测结论」section 之间插入新的 section:

<!-- 物料使用 -->
<div class="form-section">
  <div class="section-title">物料使用(可选)</div>
  <el-table :data="materialUsages" border size="small" style="margin-top: 12px">
    <el-table-column label="物料" min-width="200">
      <template #default="{ row, $index }">
        <el-select v-model="row.materialId" placeholder="选择物料" filterable style="width: 100%"
          @change="(val: string) => onMaterialSelect(val, $index)">
          <el-option v-for="opt in materialOptions" :key="opt.id" :label="opt.label" :value="opt.id" />
        </el-select>
      </template>
    </el-table-column>
    <el-table-column label="规格" prop="specModel" width="120" />
    <el-table-column label="当前库存" prop="stockQty" width="100" />
    <el-table-column label="使用数量" width="140">
      <template #default="{ row }">
        <el-input-number v-model="row.qty" :min="0.0001" :precision="4" :max="row.stockQty"
          size="small" style="width: 100%" />
      </template>
    </el-table-column>
    <el-table-column label="操作" width="60" fixed="right">
      <template #default="{ $index }">
        <el-button type="danger" link size="small" @click="materialUsages.splice($index, 1)">删除</el-button>
      </template>
    </el-table-column>
  </el-table>
  <el-button type="primary" link size="small" style="margin-top: 8px" @click="addMaterialRow">
    + 添加物料
  </el-button>
</div>
  • Step 4: 在 script 中添加物料选择和添加行的逻辑
function onMaterialSelect(val: string, index: number) {
  const opt = materialOptions.value.find((o) => o.id === val)
  if (opt) {
    materialUsages.value[index].materialName = opt.materialName
    materialUsages.value[index].specModel = opt.specModel || ''
    materialUsages.value[index].unit = opt.unit || ''
    materialUsages.value[index].stockQty = opt.stockQty || 0
  }
}

function addMaterialRow() {
  materialUsages.value.push({
    materialId: '',
    materialName: '',
    specModel: '',
    unit: '',
    stockQty: 0,
    qty: undefined,
  })
}
  • Step 5: 修改提交逻辑,在 payload 中包含 materialUsages

找到 resultSubmit 或类似的提交函数,在构建 payload 时添加:

// 在现有 payload 构建之后添加
const validMaterialUsages = materialUsages.value
  .filter((m) => m.materialId && m.qty && m.qty > 0)
  .map((m) => ({
    materialId: m.materialId,
    qty: m.qty,
  }))

// 添加到请求 payload 中
payload.materialUsages = validMaterialUsages.length > 0 ? validMaterialUsages : undefined
  • Step 6: Commit
git add web-admin/src/views/quality-inspection/qc-task/index.vue
git commit -m "feat(qc): embed material usage table in QC task completion dialog"

Task 11: 状态标签注册 + 最终验证

Files:

  • Modify: web-admin/src/utils/status-tag-registry.ts

  • Step 1: 在 status-tag-registry.ts 中注册变动类型标签

找到文件中 QC 相关的注册区域,添加:

// 物料变动类型
export const changeTypeLabels: Record<string, string> = {
  use: '使用',
  'in': '入库',
}

export const changeTypeTagType: Record<string, string> = {
  use: 'warning',
  'in': 'success',
}
  • Step 2: 全局验证 — 后端启动
cd backend && source venv/bin/activate && python -c "from app.main import app; print('Backend OK')"

Expected: Backend OK

  • Step 3: 全局验证 — 前端构建
cd web-admin && pnpm build

Expected: 构建成功,无 TypeScript 错误

  • Step 4: 最终 Commit
git add web-admin/src/utils/status-tag-registry.ts
git commit -m "feat(qc): register change type status tags and final verification"

Task Dependency Graph

Task 1 (SQL) ←──── prerequisite for all
    ↓
Task 2 (Models) ←── depends on Task 1
    ↓
Task 3 (CRUD material) + Task 4 (CRUD usage) ←── can run in parallel, both depend on Task 2
    ↓
Task 5 (API routes + task extension) ←── depends on Task 3 + 4
    ↓
Task 6 (Frontend API) + Task 7 (Router) ←── can run in parallel, independent of backend
    ↓
Task 8 (Material page) + Task 9 (Usage page) ←── depend on Task 6 + 7
    ↓
Task 10 (Task dialog) ←── depends on Task 6
    ↓
Task 11 (Registry + verification) ←── depends on all

Parallelizable groups:

  • Group A (backend): Task 1 → Task 2 → [Task 3 | Task 4] → Task 5
  • Group B (frontend): Task 6 + Task 7 → [Task 8 | Task 9] + Task 10
  • Final: Task 11