43 KiB
质检物料库 + 物料使用扣减 实施计划
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()处理 datetimecode_exists()— 租户内编码唯一校验list_xxx()—_filtered()内部函数 +func.count+ 分页create_xxx()—generate_uuid()+datetime.now()+del_flag=0update_xxx()— 仅更新非 None 字段soft_delete_xxx()—del_flag=1stock_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