Bläddra i källkod

feat(qc): implement material library with CRUD, stock-in, usage tracking and task deduction

zlwu 5 månader sedan
förälder
incheckning
e6072457fd

+ 177 - 0
backend/app/api/qc.py

@@ -14,6 +14,8 @@ from app.crud import qc_task_result as result_svc
 from app.crud import qc_task as task_svc
 from app.crud import qc_third_party_company as tpc_svc
 from app.crud import qc_third_party_item as tpi_svc
+from app.crud import qc_material as mat_svc
+from app.crud import qc_material_usage as mu_svc
 from app.crud import qc_work_order as wo_svc
 from app.crud.collection_protocol import total_pages
 from app.api.sample_reception import _get_current_user_id, _get_tenant_no, _page_payload
@@ -373,6 +375,7 @@ class TaskCompleteWithResultBody(BaseModel):
     results: List[Dict[str, Any]] = Field(default_factory=list, alias="results")
     remark: Optional[str] = Field(None, alias="remark")
     third_party_attachments: Optional[str] = Field(None, alias="thirdPartyAttachments")
+    material_usages: Optional[List[Dict[str, Any]]] = Field(None, alias="materialUsages")
 
 
 @router.get("/qc-lab-test-items")
@@ -745,6 +748,18 @@ async def complete_task_with_result(
                 db, task_id, attachments=body.third_party_attachments,
                 tenant_no=tenant_no, user_id=user_id,
             )
+        # 物料扣减(可选,仅当传了 materialUsages 时)
+        if body.material_usages:
+            task_obj_pre = task_svc.get_task(db, task_id, tenant_no=tenant_no)
+            task_no = task_obj_pre.task_no if task_obj_pre else ""
+            mu_svc.deduct_for_task(
+                db,
+                task_id,
+                task_no=task_no,
+                material_usages=body.material_usages,
+                tenant_no=tenant_no,
+                user_id=user_id,
+            )
         obj = task_svc.apply_task_result(
             db,
             task_id,
@@ -788,3 +803,165 @@ async def update_qc_task_attachments(
         return error_response(str(e))
     except Exception as e:
         return error_response(str(e))
+
+
+# ---------- 质检物料库 ----------
+
+
+class QcMaterialBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    material_code: str = Field(..., alias="materialCode")
+    material_name: str = Field(..., alias="materialName")
+    spec_model: Optional[str] = Field(None, alias="specModel")
+    unit: Optional[str] = None
+    warning_qty: Optional[float] = Field(0, alias="warningQty")
+    status: str = "1"
+    remark: Optional[str] = None
+
+
+class QcMaterialUpdateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    material_code: Optional[str] = Field(None, alias="materialCode")
+    material_name: Optional[str] = Field(None, alias="materialName")
+    spec_model: Optional[str] = Field(None, alias="specModel")
+    unit: Optional[str] = None
+    warning_qty: Optional[float] = Field(None, alias="warningQty")
+    status: Optional[str] = None
+    remark: Optional[str] = None
+
+
+class StockInBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    qty: float = Field(..., gt=0)
+    remark: Optional[str] = None
+
+
+@router.get("/qc-materials")
+async def list_qc_materials(
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100),
+    keyword: Optional[str] = None,
+    status: Optional[str] = None,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items, total = mat_svc.list_materials(
+            db, page=page, page_size=pageSize, keyword=keyword, status=status, tenant_no=tenant_no,
+        )
+        return success_response(_page_payload(items, total, page, pageSize))
+    except PermissionError as e:
+        return error_response(str(e), code=403)
+    except Exception as e:
+        return error_response(str(e))
+
+
+@router.get("/qc-materials/options")
+async def qc_material_options(
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        return success_response(mat_svc.options_list(db, tenant_no=tenant_no))
+    except Exception as e:
+        return error_response(str(e))
+
+
+@router.post("/qc-materials")
+async def create_qc_material(
+    body: QcMaterialBody,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = _get_current_user_id(user)
+        obj = mat_svc.create_material(db, body.model_dump(by_alias=True, exclude_none=True), tenant_no=tenant_no, user_id=user_id)
+        return success_response(obj, message="创建成功")
+    except ValueError as e:
+        return error_response(str(e))
+    except Exception as e:
+        return error_response(str(e))
+
+
+@router.put("/qc-materials/{material_id}")
+async def update_qc_material(
+    material_id: str,
+    body: QcMaterialUpdateBody,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = _get_current_user_id(user)
+        obj = mat_svc.update_material(db, material_id, body.model_dump(by_alias=True, exclude_none=True), tenant_no=tenant_no, user_id=user_id)
+        return success_response(obj, message="更新成功")
+    except ValueError as e:
+        return error_response(str(e))
+    except Exception as e:
+        return error_response(str(e))
+
+
+@router.delete("/qc-materials/{material_id}")
+async def delete_qc_material(
+    material_id: str,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = _get_current_user_id(user)
+        mat_svc.soft_delete_material(db, material_id, tenant_no=tenant_no, user_id=user_id)
+        return success_response(None, message="删除成功")
+    except ValueError as e:
+        return error_response(str(e))
+    except Exception as e:
+        return error_response(str(e))
+
+
+@router.post("/qc-materials/{material_id}/stock-in")
+async def stock_in_qc_material(
+    material_id: str,
+    body: StockInBody,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = _get_current_user_id(user)
+        obj = mat_svc.stock_in(db, material_id, qty=body.qty, remark=body.remark, tenant_no=tenant_no, user_id=user_id)
+        return success_response(obj, message="入库成功")
+    except ValueError as e:
+        return error_response(str(e))
+    except Exception as e:
+        return error_response(str(e))
+
+
+# ---------- 物料变动记录 ----------
+
+
+@router.get("/qc-material-usages")
+async def list_qc_material_usages(
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100),
+    materialId: Optional[str] = None,
+    changeType: Optional[str] = None,
+    startTime: Optional[str] = None,
+    endTime: Optional[str] = None,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:qcMaterialUsage:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items, total = mu_svc.list_usages(
+            db, tenant_no, page=page, page_size=pageSize,
+            material_id=materialId, change_type=changeType,
+            start_time=startTime, end_time=endTime,
+        )
+        return success_response(_page_payload(items, total, page, pageSize))
+    except PermissionError as e:
+        return error_response(str(e), code=403)
+    except Exception as e:
+        return error_response(str(e))

+ 313 - 0
backend/app/crud/qc_material.py

@@ -0,0 +1,313 @@
+"""物料库 CRUD"""
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+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 BizQcMaterial, BizQcMaterialUsage
+
+
+def _dec(v: Any) -> float:
+    if v is None:
+        return 0.0
+    return float(v)
+
+
+def _fmt_dt(t: Optional[datetime]) -> str:
+    return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
+
+
+def _row_to_dict(r: BizQcMaterial) -> Dict[str, Any]:
+    return {
+        "id": r.id,
+        "materialCode": r.material_code,
+        "materialName": r.material_name,
+        "specModel": r.spec_model or "",
+        "unit": r.unit or "",
+        "stockQty": _dec(r.stock_qty),
+        "warningQty": _dec(r.warning_qty),
+        "status": r.status or "1",
+        "orderNum": r.order_num if r.order_num is not None else 0,
+        "remark": r.remark or "",
+        "createTime": _fmt_dt(r.create_time),
+        "updateTime": _fmt_dt(r.update_time),
+    }
+
+
+def code_exists(
+    db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None
+) -> bool:
+    c = (code or "").strip()
+    if not c:
+        return False
+    q = db.query(BizQcMaterial).filter(
+        BizQcMaterial.material_code == c,
+        BizQcMaterial.tenant_no == tenant_no,
+        BizQcMaterial.del_flag == 0,
+    )
+    if exclude_id:
+        q = q.filter(BizQcMaterial.id != exclude_id)
+    return q.first() is not None
+
+
+def total_pages(db: Session, tenant_no: str, page_size: int = 10) -> int:
+    cnt = (
+        db.query(func.count(BizQcMaterial.id))
+        .filter(
+            BizQcMaterial.tenant_no == tenant_no,
+            BizQcMaterial.del_flag == 0,
+        )
+        .scalar()
+        or 0
+    )
+    ps = max(1, page_size)
+    return (cnt + ps - 1) // ps
+
+
+def list_materials(
+    db: Session,
+    *,
+    page: int = 1,
+    page_size: int = 10,
+    keyword: Optional[str] = None,
+    status: Optional[str] = None,
+    tenant_no: Optional[str] = None,
+) -> Tuple[List[Dict[str, Any]], int]:
+    def _filtered():
+        q0 = db.query(BizQcMaterial).filter(BizQcMaterial.del_flag == 0)
+        if tenant_no:
+            q0 = q0.filter(BizQcMaterial.tenant_no == tenant_no)
+        kw = (keyword or "").strip()
+        if kw:
+            like = f"%{kw}%"
+            q0 = q0.filter(
+                or_(
+                    BizQcMaterial.material_code.like(like),
+                    BizQcMaterial.material_name.like(like),
+                )
+            )
+        st = (status or "").strip()
+        if st in ("0", "1"):
+            q0 = q0.filter(BizQcMaterial.status == st)
+        return q0
+
+    total = _filtered().order_by(None).with_entities(func.count(BizQcMaterial.id)).scalar() or 0
+    page = max(1, page)
+    page_size = min(max(1, page_size), 100)
+    rows = (
+        _filtered()
+        .order_by(BizQcMaterial.create_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    return [_row_to_dict(x) for x in rows], int(total)
+
+
+def get_material(
+    db: Session, row_id: str, tenant_no: Optional[str] = None
+) -> Optional[BizQcMaterial]:
+    q = db.query(BizQcMaterial).filter(
+        BizQcMaterial.id == row_id, BizQcMaterial.del_flag == 0
+    )
+    if tenant_no:
+        q = q.filter(BizQcMaterial.tenant_no == tenant_no)
+    return q.first()
+
+
+def create_material(
+    db: Session,
+    *,
+    material_code: str,
+    material_name: str,
+    spec_model: Optional[str] = None,
+    unit: Optional[str] = None,
+    stock_qty: Optional[Decimal] = None,
+    warning_qty: Optional[Decimal] = None,
+    status: str = "1",
+    tenant_no: str,
+    order_num: Optional[int] = None,
+    remark: Optional[str] = None,
+    user_id: Optional[str] = None,
+    create_dept_id: str = "",
+) -> BizQcMaterial:
+    code = (material_code or "").strip()
+    if not code:
+        raise ValueError("物料编码不能为空")
+    if code_exists(db, code, tenant_no):
+        raise ValueError("物料编码已存在")
+    name = (material_name or "").strip()
+    if not name:
+        raise ValueError("物料名称不能为空")
+    st = (status or "1").strip()
+    if st not in ("0", "1"):
+        raise ValueError("状态无效")
+    obj = BizQcMaterial(
+        id=generate_uuid(),
+        material_code=code,
+        material_name=name,
+        spec_model=(spec_model or "").strip() or None,
+        unit=(unit or "").strip() or None,
+        stock_qty=stock_qty if stock_qty is not None else 0,
+        warning_qty=warning_qty if warning_qty is not None else 0,
+        status=st,
+        tenant_no=tenant_no,
+        order_num=order_num if order_num is not None else 0,
+        remark=(remark or "").strip() or None,
+        create_by=user_id,
+        create_dept_id=create_dept_id or "",
+        create_time=datetime.now(),
+        update_by=user_id,
+        update_time=datetime.now(),
+        del_flag=0,
+    )
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def update_material(
+    db: Session,
+    row_id: str,
+    *,
+    material_code: Optional[str] = None,
+    material_name: Optional[str] = None,
+    spec_model: Optional[str] = None,
+    unit: Optional[str] = None,
+    warning_qty: Optional[Decimal] = None,
+    status: Optional[str] = None,
+    order_num: Optional[int] = None,
+    remark: Optional[str] = None,
+    user_id: Optional[str] = None,
+    tenant_no: Optional[str] = None,
+) -> BizQcMaterial:
+    obj = get_material(db, row_id, tenant_no=tenant_no)
+    if not obj:
+        raise ValueError("物料不存在")
+    if material_code is not None:
+        code = material_code.strip()
+        if not code:
+            raise ValueError("物料编码不能为空")
+        if code != obj.material_code and code_exists(db, code, obj.tenant_no, exclude_id=row_id):
+            raise ValueError("物料编码已存在")
+        obj.material_code = code
+    if material_name is not None:
+        name = (material_name or "").strip()
+        if not name:
+            raise ValueError("物料名称不能为空")
+        obj.material_name = name
+    if spec_model is not None:
+        obj.spec_model = (spec_model or "").strip() or None
+    if unit is not None:
+        obj.unit = (unit or "").strip() or None
+    if warning_qty is not None:
+        obj.warning_qty = warning_qty
+    if status is not None:
+        st = status.strip()
+        if st not in ("0", "1"):
+            raise ValueError("状态无效")
+        obj.status = st
+    if order_num is not None:
+        obj.order_num = order_num
+    if remark is not None:
+        obj.remark = (remark or "").strip() or None
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def soft_delete_material(db: Session, row_id: str, tenant_no: Optional[str] = None) -> None:
+    obj = get_material(db, row_id, tenant_no=tenant_no)
+    if not obj:
+        raise ValueError("物料不存在")
+    if obj.stock_qty is not None and _dec(obj.stock_qty) > 0:
+        raise ValueError("该物料仍有库存,无法删除")
+    obj.del_flag = 1
+    obj.update_time = datetime.now()
+    db.add(obj)
+    db.commit()
+
+
+def stock_in(
+    db: Session,
+    row_id: str,
+    *,
+    qty: Decimal,
+    remark: Optional[str] = None,
+    user_id: Optional[str] = None,
+    tenant_no: Optional[str] = None,
+) -> BizQcMaterial:
+    if qty is None or float(qty) <= 0:
+        raise ValueError("入库数量必须大于0")
+    # 行锁锁定物料行
+    obj = (
+        db.query(BizQcMaterial)
+        .filter(BizQcMaterial.id == row_id, BizQcMaterial.del_flag == 0)
+        .with_for_update()
+        .first()
+    )
+    if not obj:
+        raise ValueError("物料不存在")
+    old_stock = obj.stock_qty or 0
+    new_stock = old_stock + qty
+    obj.stock_qty = new_stock
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.add(obj)
+    # 写入使用记录
+    usage = BizQcMaterialUsage(
+        id=generate_uuid(),
+        material_id=obj.id,
+        material_code=obj.material_code,
+        material_name=obj.material_name,
+        change_type="in",
+        change_qty=qty,
+        stock_after=new_stock,
+        source_type="manual_in",
+        tenant_no=obj.tenant_no,
+        remark=(remark or "").strip() or None,
+        create_by=user_id,
+        create_dept_id=obj.create_dept_id or "",
+        create_time=datetime.now(),
+        update_by=user_id,
+        update_time=datetime.now(),
+        del_flag=0,
+    )
+    db.add(usage)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def options_list(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
+    rows = (
+        db.query(BizQcMaterial)
+        .filter(
+            BizQcMaterial.tenant_no == tenant_no,
+            BizQcMaterial.del_flag == 0,
+            BizQcMaterial.status == "1",
+        )
+        .order_by(BizQcMaterial.create_time.desc())
+        .all()
+    )
+    return [
+        {
+            "id": r.id,
+            "label": r.material_name,
+            "materialCode": r.material_code,
+            "materialName": r.material_name,
+            "specModel": r.spec_model or "",
+            "unit": r.unit or "",
+            "stockQty": _dec(r.stock_qty),
+        }
+        for r in rows
+    ]

+ 140 - 0
backend/app/crud/qc_material_usage.py

@@ -0,0 +1,140 @@
+"""物料变动记录 CRUD"""
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+from typing import Any, Dict, List, Optional, Tuple
+
+from fastapi import HTTPException
+from sqlalchemy import func
+from sqlalchemy.orm import Session
+
+from app.core.utils import generate_uuid
+from app.models.qc_material import BizQcMaterial, BizQcMaterialUsage
+
+
+def _row_to_dict(r: BizQcMaterialUsage) -> Dict[str, Any]:
+    return {
+        "id": r.id,
+        "materialId": r.material_id,
+        "materialCode": r.material_code or "",
+        "materialName": r.material_name or "",
+        "changeType": r.change_type,
+        "changeQty": float(r.change_qty) if r.change_qty is not None else 0,
+        "stockAfter": float(r.stock_after) if r.stock_after is not None else 0,
+        "qcTaskId": r.qc_task_id or "",
+        "qcTaskNo": r.qc_task_no or "",
+        "sourceType": r.source_type,
+        "remark": r.remark or "",
+        "createBy": r.create_by 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 list_usages(
+    db: Session,
+    tenant_no: str,
+    *,
+    page: int = 1,
+    page_size: int = 10,
+    material_id: Optional[str] = None,
+    change_type: Optional[str] = None,
+    start_time: Optional[str] = None,
+    end_time: Optional[str] = None,
+) -> Tuple[List[Dict[str, Any]], int]:
+    def _filtered():
+        q0 = db.query(BizQcMaterialUsage).filter(
+            BizQcMaterialUsage.tenant_no == tenant_no,
+            BizQcMaterialUsage.del_flag == 0,
+        )
+        if material_id:
+            q0 = q0.filter(BizQcMaterialUsage.material_id == material_id)
+        if change_type:
+            q0 = q0.filter(BizQcMaterialUsage.change_type == change_type)
+        if start_time:
+            q0 = q0.filter(BizQcMaterialUsage.create_time >= start_time)
+        if end_time:
+            q0 = q0.filter(BizQcMaterialUsage.create_time <= end_time + " 23:59:59")
+        return q0
+
+    total = _filtered().order_by(None).with_entities(func.count(BizQcMaterialUsage.id)).scalar() or 0
+    page = max(1, page)
+    page_size = min(max(1, page_size), 100)
+    rows = (
+        _filtered()
+        .order_by(BizQcMaterialUsage.create_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    return [_row_to_dict(x) for x in rows], int(total)
+
+
+def deduct_for_task(
+    db: Session,
+    task_id: str,
+    task_no: str,
+    material_usages: List[Dict[str, Any]],
+    tenant_no: str,
+    user_id: Optional[str] = None,
+    dept_id: str = "",
+) -> None:
+    """扣减物料库存并生成变动记录。调用方控制事务,本函数不 commit。"""
+    for item in material_usages:
+        mid = item.get("materialId") or ""
+        qty = item.get("qty")
+        if not mid:
+            raise HTTPException(status_code=400, detail="物料ID不能为空")
+        if qty is None or Decimal(str(qty)) <= 0:
+            raise HTTPException(status_code=400, detail="物料使用数量必须大于0")
+
+        qty_dec = Decimal(str(qty))
+
+        material = (
+            db.query(BizQcMaterial)
+            .with_for_update()
+            .filter(
+                BizQcMaterial.id == mid,
+                BizQcMaterial.tenant_no == tenant_no,
+                BizQcMaterial.del_flag == 0,
+                BizQcMaterial.status == "1",
+            )
+            .first()
+        )
+        if not material:
+            raise HTTPException(status_code=400, detail=f"物料不存在或已停用(ID: {mid})")
+
+        stock_qty = material.stock_qty if material.stock_qty is not None else Decimal("0")
+        if stock_qty < qty_dec:
+            raise HTTPException(
+                status_code=400,
+                detail=f"物料「{material.material_name}」库存不足,当前库存 {stock_qty},需使用 {qty_dec}",
+            )
+
+        new_stock = stock_qty - qty_dec
+        material.stock_qty = new_stock
+        material.update_by = user_id
+        material.update_time = datetime.now()
+
+        usage = BizQcMaterialUsage(
+            id=generate_uuid(),
+            material_id=mid,
+            material_code=material.material_code,
+            material_name=material.material_name,
+            change_type="use",
+            change_qty=-qty_dec,
+            stock_after=new_stock,
+            qc_task_id=task_id,
+            qc_task_no=task_no,
+            source_type="task_complete",
+            tenant_no=tenant_no,
+            remark="",
+            create_by=user_id,
+            create_dept_id=dept_id,
+            create_time=datetime.now(),
+            update_by=user_id,
+            update_time=datetime.now(),
+            del_flag=0,
+        )
+        db.add(usage)

+ 39 - 0
web-admin/src/api/qc-material-usage.ts

@@ -0,0 +1,39 @@
+import request from '@/utils/http'
+
+type PageList<T> = {
+  list: T[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
+}
+
+export type 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.get<PageList<QcMaterialUsage>>({
+    url: '/web/admin/qc-material-usages',
+    params
+  })
+}

+ 81 - 0
web-admin/src/api/qc-material.ts

@@ -0,0 +1,81 @@
+import request from '@/utils/http'
+
+type PageList<T> = {
+  list: T[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
+}
+
+export type 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 type 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.get<PageList<QcMaterial>>({
+    url: '/web/admin/qc-materials',
+    params
+  })
+}
+
+export function fetchQcMaterialOptions() {
+  return request.get<QcMaterialOption[]>({
+    url: '/web/admin/qc-materials/options'
+  })
+}
+
+export function fetchCreateQcMaterial(data: Record<string, any>) {
+  return request.post<QcMaterial>({
+    url: '/web/admin/qc-materials',
+    data
+  })
+}
+
+export function fetchUpdateQcMaterial(id: string, data: Record<string, any>) {
+  return request.put<QcMaterial>({
+    url: `/web/admin/qc-materials/${id}`,
+    data
+  })
+}
+
+export function fetchDeleteQcMaterial(id: string) {
+  return request.delete<void>({
+    url: `/web/admin/qc-materials/${id}`
+  })
+}
+
+export function fetchStockInQcMaterial(id: string, data: { qty: number; remark?: string }) {
+  return request.post<QcMaterial>({
+    url: `/web/admin/qc-materials/${id}/stock-in`,
+    data
+  })
+}

+ 35 - 0
web-admin/src/router/modules/sqxp.ts

@@ -191,6 +191,13 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
         path: 'sample-code',
         component: '/basic-info/sample-code/index',
         meta: { title: '样本编码管理', keepAlive: true }
+      },
+      {
+        id: '40000000000000000000000000000007',
+        name: 'BasicInfoInsuranceCompany',
+        path: 'insurance-company',
+        component: '/basic-info/insurance-company/index',
+        meta: { title: '保险公司及项目价格', keepAlive: true }
       }
     ]
   },
@@ -240,6 +247,20 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
         path: 'refund-confirm',
         component: '/cell-storage/refund-confirm/index',
         meta: { title: '退费确认(财务)', keepAlive: true }
+      },
+      {
+        id: '50000000000000000000000000000006',
+        name: 'CellStorageStorageCert',
+        path: 'storage-cert',
+        component: '/cell-storage/storage-cert/index',
+        meta: { title: '储存证管理', keepAlive: true }
+      },
+      {
+        id: '50000000000000000000000000000008',
+        name: 'CellStorageFullmoonTodo',
+        path: 'fullmoon-todo',
+        component: '/cell-storage/fullmoon-todo/index',
+        meta: { title: '满月待办管理', keepAlive: true }
       }
     ]
   },
@@ -344,6 +365,20 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
           keepAlive: true,
           menuCode: 'biz:qcTask:list'
         }
+      },
+      {
+        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' }
       }
     ]
   },

+ 259 - 0
web-admin/src/views/quality-inspection/qc-material-usage/index.vue

@@ -0,0 +1,259 @@
+<!-- 物料变动记录 -->
+<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>变动记录列表</h2>
+      </div>
+
+      <div class="filter-block">
+        <el-form :model="searchForm" class="filter-form" @submit.prevent>
+          <div class="filter-grid">
+            <el-form-item label="物料筛选">
+              <el-select
+                v-model="searchForm.materialId"
+                placeholder="全部物料"
+                clearable
+                filterable
+              >
+                <el-option
+                  v-for="m in materialOptions"
+                  :key="m.id"
+                  :label="m.label"
+                  :value="m.id"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="变动类型">
+              <el-select v-model="searchForm.changeType" placeholder="全部" clearable>
+                <el-option label="使用" value="use" />
+                <el-option label="入库" value="in" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="时间范围">
+              <el-date-picker
+                v-model="dateRange"
+                type="daterange"
+                range-separator="至"
+                start-placeholder="开始日期"
+                end-placeholder="结束日期"
+                value-format="YYYY-MM-DD"
+                clearable
+              />
+            </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-icon><RefreshLeft /></el-icon>重置
+              </el-button>
+              <el-button class="btn-main" type="primary" @click="handleSearch">
+                <el-icon><Search /></el-icon>搜索
+              </el-button>
+            </div>
+          </div>
+        </el-form>
+      </div>
+
+      <div class="list-toolbar">
+        <div class="list-toolbar-row">
+          <div class="list-toolbar-end">
+            <el-tooltip content="列设置" placement="top">
+              <el-button class="btn-ghost" circle @click="showColumnSettings = true">
+                <el-icon><Setting /></el-icon>
+              </el-button>
+            </el-tooltip>
+            <el-tooltip content="刷新" placement="top">
+              <el-button class="btn-ghost" circle @click="loadData">
+                <el-icon><Refresh /></el-icon>
+              </el-button>
+            </el-tooltip>
+          </div>
+        </div>
+      </div>
+
+      <div class="list-body list-wrap">
+        <el-table
+          class="list-table"
+          :data="tableData"
+          v-loading="loading"
+          stripe
+        >
+          <el-table-column
+            type="index"
+            label="序号"
+            width="68"
+            align="center"
+            header-align="center"
+            :index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
+          />
+          <el-table-column prop="materialCode" label="物料编码" min-width="120" />
+          <el-table-column prop="materialName" label="物料名称" min-width="140" />
+          <el-table-column prop="changeType" label="变动类型" min-width="100" align="center">
+            <template #default="{ row }">
+              <el-tag
+                :type="row.changeType === 'use' ? 'warning' : 'success'"
+                size="small"
+              >
+                {{ row.changeType === 'use' ? '使用' : '入库' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="changeQty" label="变动数量" min-width="120" align="right">
+            <template #default="{ row }">
+              <span :class="row.changeQty < 0 ? 'qty-negative' : 'qty-positive'">
+                {{ row.changeQty < 0 ? row.changeQty : `+${row.changeQty}` }}
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="stockAfter" label="变动后库存" min-width="120" align="right" />
+          <el-table-column prop="qcTaskNo" label="关联任务号" min-width="160">
+            <template #default="{ row }">
+              {{ row.qcTaskNo || '-' }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="sourceType" label="来源类型" min-width="120" align="center">
+            <template #default="{ row }">
+              <el-tag
+                :type="getSourceTagType(row.sourceType)"
+                size="small"
+              >
+                {{ getSourceLabel(row.sourceType) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
+          <el-table-column prop="createBy" label="操作人" min-width="100" />
+          <el-table-column prop="createTime" label="变动时间" min-width="170" />
+        </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, 100]"
+            layout="total, sizes, prev, pager, next, jumper"
+            @size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
+            @current-change="(val) => ((pagination.page = val), loadData())"
+          />
+        </div>
+      </div>
+    </section>
+
+    <!-- 列设置抽屉 -->
+    <el-drawer v-model="showColumnSettings" title="列设置" size="300px">
+      <p>列设置功能预留</p>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { Search, RefreshLeft, Refresh, Setting } from '@element-plus/icons-vue'
+import { fetchQcMaterialUsageList } from '@/api/qc-material-usage'
+import type { QcMaterialUsage } from '@/api/qc-material-usage'
+import { fetchQcMaterialOptions } from '@/api/qc-material'
+import type { QcMaterialOption } from '@/api/qc-material'
+
+defineOptions({ name: 'QcMaterialUsage' })
+
+const route = useRoute()
+
+const searchForm = reactive<{
+  materialId: string
+  changeType: string
+}>({
+  materialId: '',
+  changeType: ''
+})
+
+const dateRange = ref<[string, string] | null>(null)
+const tableData = ref<QcMaterialUsage[]>([])
+const loading = ref(false)
+const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
+const materialOptions = ref<QcMaterialOption[]>([])
+const showColumnSettings = ref(false)
+
+function getSourceTagType(sourceType: string) {
+  if (sourceType === 'task_complete') return 'primary'
+  if (sourceType === 'manual_in') return 'success'
+  return 'info'
+}
+
+function getSourceLabel(sourceType: string) {
+  if (sourceType === 'task_complete') return '任务完成'
+  if (sourceType === 'manual_in') return '手动入库'
+  return sourceType || '-'
+}
+
+async function loadMaterialOptions() {
+  try {
+    const res = await fetchQcMaterialOptions()
+    materialOptions.value = res || []
+  } catch {
+    materialOptions.value = []
+  }
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const params: Record<string, any> = {
+      page: pagination.page,
+      pageSize: pagination.pageSize
+    }
+    if (searchForm.materialId) params.materialId = searchForm.materialId
+    if (searchForm.changeType) params.changeType = searchForm.changeType
+    if (dateRange.value && dateRange.value[0]) {
+      params.startTime = dateRange.value[0]
+      params.endTime = dateRange.value[1]
+    }
+    const res = await fetchQcMaterialUsageList(params)
+    tableData.value = res.list || []
+    pagination.total = res.total ?? 0
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  pagination.page = 1
+  loadData()
+}
+
+function handleReset() {
+  searchForm.materialId = ''
+  searchForm.changeType = ''
+  dateRange.value = null
+  pagination.page = 1
+  loadData()
+}
+
+onMounted(async () => {
+  await loadMaterialOptions()
+  if (route.query.materialId) {
+    searchForm.materialId = route.query.materialId as string
+  }
+  loadData()
+})
+</script>
+
+<style scoped lang="scss">
+.qty-positive {
+  color: var(--el-color-success);
+  font-weight: 500;
+}
+
+.qty-negative {
+  color: var(--el-color-danger);
+  font-weight: 500;
+}
+</style>

+ 472 - 0
web-admin/src/views/quality-inspection/qc-material/index.vue

@@ -0,0 +1,472 @@
+<!-- 质检物料库 -->
+<!-- eslint-disable vue/no-v-model-argument -->
+<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>物料列表</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.keyword" placeholder="物料名称/编码" clearable />
+            </el-form-item>
+            <el-form-item label="状态">
+              <el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 100%">
+                <el-option label="启用" value="1" />
+                <el-option label="停用" value="0" />
+              </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">
+          <div class="list-toolbar-selected">
+            已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
+          </div>
+          <el-button class="btn-main" type="primary" @click="handleAdd">
+            <el-icon><Plus /></el-icon>
+            新增
+          </el-button>
+          <el-button
+            class="btn-ghost"
+            :disabled="selectedRows.length === 0"
+            @click="handleStockInFromToolbar"
+          >
+            入库
+          </el-button>
+        </div>
+      </div>
+
+      <div class="list-body list-wrap">
+        <el-table
+          class="list-table"
+          :data="tableData"
+          v-loading="loading"
+          stripe
+          row-key="id"
+          @selection-change="handleSelectionChange"
+        >
+          <el-table-column type="selection" width="55" align="center" header-align="center" />
+          <el-table-column
+            type="index"
+            label="序号"
+            width="68"
+            align="center"
+            header-align="center"
+            :index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
+          />
+          <el-table-column prop="materialCode" label="物料编码" min-width="120" align="center" header-align="center" />
+          <el-table-column prop="materialName" label="物料名称" min-width="140" align="center" header-align="center" />
+          <el-table-column prop="specModel" label="规格型号" min-width="140" align="center" header-align="center" />
+          <el-table-column prop="unit" label="单位" min-width="80" align="center" header-align="center" />
+          <el-table-column label="当前库存" min-width="100" align="right" header-align="center">
+            <template #default="{ row }">
+              {{ row.stockQty ?? 0 }}
+            </template>
+          </el-table-column>
+          <el-table-column label="预警库存" min-width="100" align="right" header-align="center">
+            <template #default="{ row }">
+              {{ row.warningQty ?? 0 }}
+            </template>
+          </el-table-column>
+          <el-table-column label="库存状态" min-width="100" align="center" header-align="center">
+            <template #default="{ row }">
+              <el-tag
+                :type="row.stockQty <= row.warningQty ? 'danger' : 'success'"
+                size="small"
+              >
+                {{ row.stockQty <= row.warningQty ? '库存预警' : '正常' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="状态" min-width="80" align="center" header-align="center">
+            <template #default="{ row }">
+              <el-tag :type="row.status === '1' ? 'success' : 'info'" size="small">
+                {{ row.status === '1' ? '启用' : '停用' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="remark" label="备注" min-width="140" align="center" header-align="center" show-overflow-tooltip />
+          <el-table-column prop="createTime" label="创建时间" min-width="170" align="center" header-align="center" />
+          <el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
+            <template #default="{ row }">
+              <el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
+              <el-button class="btn-op-item" @click="handleStockIn(row)">入库</el-button>
+              <el-button class="btn-op-item" @click="handleViewUsage(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>
+
+      <!-- 新增/编辑 Dialog -->
+      <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"
+          class="material-dialog-form"
+          scroll-to-error
+          :scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
+        >
+          <div class="form-grid-2">
+            <el-form-item label="物料编码" prop="materialCode">
+              <el-input v-model="formData.materialCode" placeholder="请输入物料编码" />
+            </el-form-item>
+            <el-form-item label="物料名称" prop="materialName">
+              <el-input v-model="formData.materialName" placeholder="请输入物料名称" />
+            </el-form-item>
+            <el-form-item label="规格型号">
+              <el-input v-model="formData.specModel" placeholder="请输入规格型号" />
+            </el-form-item>
+            <el-form-item label="单位">
+              <el-input v-model="formData.unit" placeholder="请输入单位" />
+            </el-form-item>
+            <el-form-item label="预警库存">
+              <el-input-number
+                v-model="formData.warningQty"
+                :min="0"
+                :precision="0"
+                style="width: 100%"
+                placeholder="请输入预警库存"
+              />
+            </el-form-item>
+            <el-form-item label="状态">
+              <el-radio-group v-model="formData.status">
+                <el-radio value="1">启用</el-radio>
+                <el-radio value="0">停用</el-radio>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item label="备注" class="form-col-span-2">
+              <el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
+            </el-form-item>
+          </div>
+        </el-form>
+        <template #footer>
+          <el-button class="btn-ghost" @click="dialogVisible = false">
+            <el-icon><Close /></el-icon>
+            取消
+          </el-button>
+          <el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
+            <el-icon><Check /></el-icon>
+            确定
+          </el-button>
+        </template>
+      </el-dialog>
+
+      <!-- 入库 Dialog -->
+      <el-dialog
+        v-model="stockInVisible"
+        title="物料入库"
+        width="480px"
+        class="dialog-form-layout"
+        align-center
+        destroy-on-close
+        :close-on-click-modal="false"
+      >
+        <el-form
+          ref="stockInFormRef"
+          :model="stockInForm"
+          :rules="stockInRules"
+          label-position="top"
+          class="material-dialog-form"
+          scroll-to-error
+          :scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
+        >
+          <el-form-item label="物料名称">
+            <el-input :model-value="stockInForm.materialName" disabled />
+          </el-form-item>
+          <el-form-item label="当前库存">
+            <el-input :model-value="stockInForm.currentStock" disabled />
+          </el-form-item>
+          <el-form-item label="入库数量" prop="qty">
+            <el-input-number
+              v-model="stockInForm.qty"
+              :min="0.0001"
+              :precision="4"
+              style="width: 100%"
+              placeholder="请输入入库数量"
+            />
+          </el-form-item>
+          <el-form-item label="备注">
+            <el-input v-model="stockInForm.remark" type="textarea" :rows="3" placeholder="选填" />
+          </el-form-item>
+        </el-form>
+        <template #footer>
+          <el-button class="btn-ghost" @click="stockInVisible = false">
+            <el-icon><Close /></el-icon>
+            取消
+          </el-button>
+          <el-button class="btn-main" type="primary" @click="handleStockInSubmit" :loading="stockInLoading">
+            <el-icon><Check /></el-icon>
+            确定
+          </el-button>
+        </template>
+      </el-dialog>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, reactive, onMounted } from 'vue'
+  import { ElMessage, ElMessageBox } from 'element-plus'
+  import { Plus, Close, Check } from '@element-plus/icons-vue'
+  import {
+    fetchQcMaterialList,
+    fetchCreateQcMaterial,
+    fetchUpdateQcMaterial,
+    fetchDeleteQcMaterial,
+    fetchStockInQcMaterial,
+  } from '@/api/qc-material'
+  import type { QcMaterial } from '@/api/qc-material'
+
+  defineOptions({ name: 'QualityInspectionQcMaterial' })
+
+  const searchForm = reactive({ keyword: '', status: '' })
+  const tableData = ref<QcMaterial[]>([])
+  const selectedRows = ref<QcMaterial[]>([])
+  const loading = ref(false)
+  const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
+
+  // 新增/编辑
+  const dialogVisible = ref(false)
+  const dialogTitle = ref('新增物料')
+  const isEdit = ref(false)
+  const submitLoading = ref(false)
+  const formRef = ref()
+  const editingId = ref<string | null>(null)
+
+  const formData = reactive({
+    materialCode: '',
+    materialName: '',
+    specModel: '',
+    unit: '',
+    warningQty: 0,
+    status: '1',
+    remark: ''
+  })
+
+  const formRules = {
+    materialCode: [{ required: true, message: '请输入物料编码', trigger: 'blur' }],
+    materialName: [{ required: true, message: '请输入物料名称', trigger: 'blur' }]
+  }
+
+  // 入库
+  const stockInVisible = ref(false)
+  const stockInLoading = ref(false)
+  const stockInFormRef = ref()
+  const stockInTargetId = ref<string | null>(null)
+
+  const stockInForm = reactive({
+    materialName: '',
+    currentStock: 0,
+    qty: undefined as number | undefined,
+    remark: ''
+  })
+
+  const stockInRules = {
+    qty: [{ required: true, message: '请输入入库数量', trigger: 'blur' }]
+  }
+
+  const handleSelectionChange = (rows: QcMaterial[]) => {
+    selectedRows.value = rows
+  }
+
+  async function loadData() {
+    loading.value = true
+    try {
+      const res = await fetchQcMaterialList({
+        page: pagination.page,
+        pageSize: pagination.pageSize,
+        keyword: searchForm.keyword || undefined,
+        status: searchForm.status || undefined
+      })
+      tableData.value = res.list || []
+      pagination.total = res.total ?? 0
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function handleSearch() {
+    pagination.page = 1
+    loadData()
+  }
+
+  function handleReset() {
+    searchForm.keyword = ''
+    searchForm.status = ''
+    pagination.page = 1
+    loadData()
+  }
+
+  // 新增/编辑
+  function resetForm() {
+    formData.materialCode = ''
+    formData.materialName = ''
+    formData.specModel = ''
+    formData.unit = ''
+    formData.warningQty = 0
+    formData.status = '1'
+    formData.remark = ''
+    editingId.value = null
+  }
+
+  function handleAdd() {
+    isEdit.value = false
+    dialogTitle.value = '新增物料'
+    resetForm()
+    dialogVisible.value = true
+    formRef.value?.clearValidate?.()
+  }
+
+  function handleEdit(row: QcMaterial) {
+    isEdit.value = true
+    dialogTitle.value = '编辑物料'
+    editingId.value = row.id
+    formData.materialCode = row.materialCode || ''
+    formData.materialName = row.materialName || ''
+    formData.specModel = row.specModel || ''
+    formData.unit = row.unit || ''
+    formData.warningQty = row.warningQty ?? 0
+    formData.status = row.status || '1'
+    formData.remark = row.remark || ''
+    dialogVisible.value = true
+    formRef.value?.clearValidate?.()
+  }
+
+  async function handleSubmit() {
+    await formRef.value?.validate?.()
+    submitLoading.value = true
+    try {
+      const payload = {
+        materialCode: formData.materialCode.trim(),
+        materialName: formData.materialName.trim(),
+        specModel: formData.specModel?.trim() || undefined,
+        unit: formData.unit?.trim() || undefined,
+        warningQty: formData.warningQty,
+        status: formData.status,
+        remark: formData.remark?.trim() || undefined
+      }
+      if (isEdit.value && editingId.value) {
+        await fetchUpdateQcMaterial(editingId.value, payload)
+      } else {
+        await fetchCreateQcMaterial(payload)
+      }
+      ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
+      dialogVisible.value = false
+      await loadData()
+    } catch {
+      /* 校验或接口错误已由全局处理 */
+    } finally {
+      submitLoading.value = false
+    }
+  }
+
+  // 入库
+  function openStockInDialog(row: QcMaterial) {
+    stockInTargetId.value = row.id
+    stockInForm.materialName = row.materialName || ''
+    stockInForm.currentStock = row.stockQty ?? 0
+    stockInForm.qty = undefined
+    stockInForm.remark = ''
+    stockInVisible.value = true
+    stockInFormRef.value?.clearValidate?.()
+  }
+
+  function handleStockIn(row: QcMaterial) {
+    openStockInDialog(row)
+  }
+
+  function handleStockInFromToolbar() {
+    if (selectedRows.value.length === 0) return
+    openStockInDialog(selectedRows.value[0])
+  }
+
+  async function handleStockInSubmit() {
+    await stockInFormRef.value?.validate?.()
+    if (!stockInTargetId.value) return
+    stockInLoading.value = true
+    try {
+      await fetchStockInQcMaterial(stockInTargetId.value, {
+        qty: stockInForm.qty!,
+        remark: stockInForm.remark?.trim() || undefined
+      })
+      ElMessage.success('入库成功')
+      stockInVisible.value = false
+      await loadData()
+    } catch {
+      /* 校验或接口错误已由全局处理 */
+    } finally {
+      stockInLoading.value = false
+    }
+  }
+
+  // 查看变动记录
+  function handleViewUsage(row: QcMaterial) {
+    window.location.hash = `/sqxp/quality-inspection/qc-material-usage?materialId=${row.id}`
+  }
+
+  // 删除
+  async function handleDelete(row: QcMaterial) {
+    if ((row.stockQty ?? 0) > 0) {
+      ElMessage.warning('当前库存大于0,无法删除')
+      return
+    }
+    try {
+      await ElMessageBox.confirm('确定要删除该物料吗?', '提示', { type: 'warning' })
+    } catch {
+      return
+    }
+    await fetchDeleteQcMaterial(row.id)
+    ElMessage.success('删除成功')
+    await loadData()
+  }
+
+  onMounted(() => loadData())
+</script>
+
+<style scoped lang="scss">
+  .material-dialog-form {
+    :deep(.el-form-item) {
+      margin-bottom: 0;
+    }
+  }
+</style>

+ 121 - 21
web-admin/src/views/quality-inspection/qc-task/index.vue

@@ -9,7 +9,7 @@
 
     <section class="panel">
       <div class="panel-top">
-        <h2 class="title-bar-heading">任务列表</h2>
+        <h2>任务列表</h2>
       </div>
 
       <div class="filter-block">
@@ -178,6 +178,43 @@
               </el-form-item>
             </div>
           </div>
+          <div class="form-section material-usage-section">
+            <div class="section-title">物料使用(选填)</div>
+            <div v-for="(mu, idx) in materialUsages" :key="idx" class="material-usage-row">
+              <el-form-item label="物料" class="mu-field-material">
+                <el-select
+                  v-model="mu.materialId"
+                  filterable
+                  placeholder="选择物料"
+                  style="width: 100%"
+                  @change="(v: string) => onMaterialSelect(mu, v)"
+                >
+                  <el-option
+                    v-for="opt in materialOptions"
+                    :key="opt.id"
+                    :label="opt.label"
+                    :value="opt.id"
+                  />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="使用数量" class="mu-field-qty">
+                <el-input-number
+                  v-model="mu.qty"
+                  :min="0.0001"
+                  :precision="4"
+                  :step="1"
+                  style="width: 100%"
+                  placeholder="数量"
+                />
+              </el-form-item>
+              <el-button class="btn-ghost mu-remove-btn" @click="materialUsages.splice(idx, 1)">
+                <el-icon><Delete /></el-icon>
+              </el-button>
+            </div>
+            <el-button class="btn-ghost" size="small" @click="materialUsages.push({ materialId: '', materialName: '', qty: undefined })">
+              <el-icon><Plus /></el-icon>添加物料
+            </el-button>
+          </div>
           <div class="form-section result-conclusion-section">
             <div class="section-title">检测结论</div>
             <el-form-item label="结论" required>
@@ -186,21 +223,18 @@
                 <el-radio value="btg">不通过</el-radio>
               </el-radio-group>
             </el-form-item>
-          </div>
-          <div class="form-section result-remark-section">
-            <div class="section-title">备注</div>
             <el-form-item label="备注">
               <el-input v-model="resultRemark" type="textarea" :rows="3" placeholder="选填" />
             </el-form-item>
-          </div>
-          <div class="form-section result-attachment-section">
-            <ProtocolFinanceConfirmFields
-              v-model:finance-remark="resultRemark"
-              v-model:attachments="resultAttachments"
-              :show-remark="false"
-              :attachment-label="resultAttachmentLabel"
-              :upload-folder="resultUploadFolder"
-            />
+            <div class="result-attachment-block">
+              <ProtocolFinanceConfirmFields
+                v-model:finance-remark="resultRemark"
+                v-model:attachments="resultAttachments"
+                :show-remark="false"
+                :attachment-label="resultAttachmentLabel"
+                :upload-folder="resultUploadFolder"
+              />
+            </div>
           </div>
         </el-form>
         <template #footer>
@@ -270,6 +304,7 @@
     type QcTaskDetail
   } from '@/api/qc-task'
   import { fetchQcTaskResults, fetchCompleteTaskWithResult, type QcTaskResultItem } from '@/api/qc-task-result'
+  import { fetchQcMaterialOptions, type QcMaterialOption } from '@/api/qc-material'
   import {
     taskTypeLabel,
     taskStatusLabel,
@@ -298,6 +333,8 @@
   const resultRemark = ref('')
   const resultConclusion = ref('')
   const resultAttachments = ref<FinanceAttachmentItem[]>([])
+  const materialOptions = ref<QcMaterialOption[]>([])
+  const materialUsages = ref<Array<{ materialId: string; materialName: string; qty: number | undefined }>>([])
 
   function canFillResult(row: QcTask) {
     if (row.taskType === 'sysz') {
@@ -321,6 +358,11 @@
     resultTaskType.value === 'dsfjc' ? 'qc-third-party' : 'qc-lab'
   )
 
+  function onMaterialSelect(mu: { materialId: string; materialName: string; qty: number | undefined }, id: string) {
+    const opt = materialOptions.value.find((o) => o.id === id)
+    mu.materialName = opt ? opt.materialName : ''
+  }
+
   const viewVisible = ref(false)
   const viewDetail = ref<QcTaskDetail | null>(null)
   const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
@@ -580,6 +622,7 @@
     resultRemark.value = row.remark || ''
     resultConclusion.value = ''
     resultAttachments.value = apiFilesToFinanceItems(row.attachmentFiles)
+    materialUsages.value = []
     resultLoading.value = true
     try {
       const detail = await fetchQcTaskDetail(row.id)
@@ -594,6 +637,11 @@
     } finally {
       resultLoading.value = false
     }
+    try {
+      materialOptions.value = await fetchQcMaterialOptions()
+    } catch {
+      materialOptions.value = []
+    }
     resultVisible.value = true
   }
 
@@ -612,11 +660,15 @@
     }
     resultLoading.value = true
     try {
+      const validMaterialUsages = materialUsages.value.filter(
+        (mu) => mu.materialId && mu.qty && mu.qty > 0
+      )
       const data: {
         conclusion: string
         results: QcTaskResultItem[]
         remark?: string
         thirdPartyAttachments?: string
+        materialUsages?: Array<{ materialId: string; qty: number }>
       } = {
         conclusion: resultConclusion.value,
         remark: resultRemark.value,
@@ -628,6 +680,12 @@
           }))
       }
       data.thirdPartyAttachments = financeAttachmentsToUrlString(resultAttachments.value)
+      if (validMaterialUsages.length > 0) {
+        data.materialUsages = validMaterialUsages.map((mu) => ({
+          materialId: mu.materialId,
+          qty: mu.qty!,
+        }))
+      }
       await fetchCompleteTaskWithResult(resultTaskId.value, data)
       ElMessage.success(resultTaskStatus.value === 'ywc' ? '已保存' : '已完成')
       resultVisible.value = false
@@ -658,19 +716,61 @@
     border-left: 3px solid #10b981;
   }
 
-  .result-conclusion-section {
+  .material-usage-section {
     margin-top: 12px;
-  }
 
-  .result-remark-section {
-    margin-top: 12px;
+    .material-usage-row {
+      display: flex;
+      align-items: flex-start;
+      gap: 12px;
+      margin-bottom: 12px;
+
+      .mu-field-material {
+        flex: 3;
+      }
+
+      .mu-field-qty {
+        flex: 1.2;
+      }
+
+      .mu-remove-btn {
+        margin-top: 28px;
+        padding: 8px;
+      }
+    }
   }
 
-  .result-attachment-section {
+  .result-conclusion-section {
     margin-top: 12px;
-    background: #fafafa;
-    border-radius: 8px;
-    padding: 20px;
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+
+    .section-title {
+      margin-bottom: 0;
+    }
+
+    :deep(.el-form-item) {
+      margin-bottom: 0;
+    }
+
+    :deep(.el-form-item__label) {
+      display: inline-flex;
+      width: 100%;
+      justify-content: flex-start;
+      text-align: left;
+      padding-left: 0 !important;
+    }
+
+    .result-attachment-block {
+      margin-top: 0;
+      padding: 0;
+      background: transparent;
+
+      :deep(.protocol-finance-confirm-fields .el-form-item) {
+        margin-bottom: 0;
+      }
+    }
   }
 
   .biz-dialog-form {