zlwu 5 ay önce
ebeveyn
işleme
fb85230d25

+ 3 - 1
.cursorrules

@@ -4,8 +4,10 @@
 
 ## 权威文档(必读路径)
 
-- **Web 管理端(Fresh Mint)**:`docs/web-admin-ui-spec-fresh-mint.md`  
+- **Web 管理端(Fresh Mint)**:`docs/web-admin-ui-spec-fresh-mint.md` 
   适用范围:`web-admin`(Vue 3 + Element Plus + SCSS/Tailwind)。色值、布局、列表、弹窗、树形路径等均以该文档及其中引用的 `fresh-mint-green.html` 为准。
+- **界面模式索引(选型 + 参考页)**:`docs/web-admin-ui-pattern-index.md`  
+  新功能开发前按「功能特征 → 模式编号 P01–P27 → Hash/源码」选型;与 Demo 模板页、业务参考页对照。
 - **小程序端(三墙 Demo 体系)**:`docs/miniprogram-ui-spec-sanqiang.md`  
   适用范围:`miniprogram`(客户端 / 供应商端 / 配送员端)。视觉以该文档及 `mobile-ui/sanqiang-*.html` 为准。
 

+ 1 - 0
AGENTS.md

@@ -37,6 +37,7 @@ Full specs: `openspec/changes/zkxw-engineering-standards/specs/*/spec.md`. Below
   - Product list + view dialog: `src/views/system/product/index.vue`
   - Tree master data: `src/views/system/category/index.vue`
   - User CRUD: `src/views/system/user/index.vue`
+- **UI pattern index** — before implementing a new page, developers SHALL select a pattern code (P01–P27) per `docs/web-admin-ui-pattern-index.md` and OpenSpec `ui-conventions` §「界面模式索引与参考页选型」; PRs SHALL cite pattern code and reference page path.
 
 ### API Conventions (backend)
 

+ 1 - 1
backend/app.pid

@@ -1 +1 @@
-81964
+55545

+ 291 - 0
backend/app/api/production.py

@@ -9,6 +9,8 @@ from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
 from app.core.response import success_response, error_response
 from app.crud import consumable as consumable_svc
 from app.crud import reagent as reagent_svc
+from app.crud import consumable_stock_log as consumable_stock_log_svc
+from app.crud import reagent_stock_log as reagent_stock_log_svc
 from app.crud import sop_document as sop_document_svc
 from app.crud import process_category as process_category_svc
 from app.crud import output_type as output_type_svc
@@ -178,6 +180,171 @@ async def delete_consumable(
         return error_response(400, str(e))
 
 
+class StockInBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    qty: float = Field(..., gt=0)
+    remark: Optional[str] = None
+
+
+class StockOutBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    qty: float = Field(..., gt=0)
+    receiver: str = Field(..., min_length=1)
+    purpose: Optional[str] = None
+    remark: Optional[str] = None
+
+
+class BatchStockInItem(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    materialId: str
+    qty: float = Field(..., gt=0)
+    remark: Optional[str] = None
+
+
+class BatchStockInBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    items: List[BatchStockInItem]
+    remark: Optional[str] = None
+
+
+class BatchStockOutItem(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    materialId: str
+    qty: float = Field(..., gt=0)
+
+
+class BatchStockOutBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    items: List[BatchStockOutItem]
+    receiver: str = Field(..., min_length=1)
+    purpose: Optional[str] = None
+    remark: Optional[str] = None
+
+
+@router.post("/consumables/{consumable_id}/stock-in")
+async def consumable_stock_in(
+    consumable_id: str,
+    body: StockInBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        from decimal import Decimal
+        obj = consumable_svc.stock_in(
+            db, consumable_id,
+            qty=Decimal(str(body.qty)),
+            remark=body.remark,
+            user_id=user.get("user_id"),
+            tenant_no=tenant_no,
+        )
+        return success_response(consumable_svc._row_to_dict(obj), message="入库成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.post("/consumables/{consumable_id}/stock-out")
+async def consumable_stock_out(
+    consumable_id: str,
+    body: StockOutBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        from decimal import Decimal
+        obj = consumable_svc.stock_out(
+            db, consumable_id,
+            qty=Decimal(str(body.qty)),
+            receiver=body.receiver,
+            purpose=body.purpose,
+            remark=body.remark,
+            user_id=user.get("user_id"),
+            tenant_no=tenant_no,
+        )
+        return success_response(consumable_svc._row_to_dict(obj), message="出库成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.post("/consumables/batch-stock-in")
+async def consumable_batch_stock_in(
+    body: BatchStockInBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items_data = [it.model_dump() for it in body.items]
+        count = consumable_svc.batch_stock_in(
+            db, items=items_data, remark=body.remark,
+            tenant_no=tenant_no, user_id=user.get("user_id"),
+        )
+        return success_response({"count": count}, message=f"成功入库 {count} 条")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.post("/consumables/batch-stock-out")
+async def consumable_batch_stock_out(
+    body: BatchStockOutBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items_data = [it.model_dump() for it in body.items]
+        count = consumable_svc.batch_stock_out(
+            db, items=items_data,
+            receiver=body.receiver, purpose=body.purpose, remark=body.remark,
+            tenant_no=tenant_no, user_id=user.get("user_id"),
+        )
+        return success_response({"count": count}, message=f"成功出库 {count} 条")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.get("/consumables/{consumable_id}/stock-logs")
+async def list_consumable_stock_logs(
+    consumable_id: str,
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
+    changeType: Optional[str] = None,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    tenant_no = _get_tenant_no(db, user)
+    items, total = consumable_stock_log_svc.list_stock_logs(
+        db, tenant_no, page=page, page_size=pageSize,
+        material_id=consumable_id, change_type=changeType,
+        user_info=user,
+    )
+    tp = (total + pageSize - 1) // pageSize if total > 0 else 0
+    return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
+
+
+@router.get("/consumables/stock-logs")
+async def list_all_consumable_stock_logs(
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
+    keyword: Optional[str] = None,
+    changeType: Optional[str] = None,
+    startTime: Optional[str] = None,
+    endTime: Optional[str] = None,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    tenant_no = _get_tenant_no(db, user)
+    items, total = consumable_stock_log_svc.list_stock_logs(
+        db, tenant_no, page=page, page_size=pageSize,
+        keyword=keyword, change_type=changeType,
+        start_time=startTime, end_time=endTime,
+        user_info=user,
+    )
+    tp = (total + pageSize - 1) // pageSize if total > 0 else 0
+    return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
+
+
 # ---------------------------------------------------------------------------
 # 2. 试剂
 # ---------------------------------------------------------------------------
@@ -297,6 +464,130 @@ async def delete_reagent(
         return error_response(400, str(e))
 
 
+@router.post("/reagents/{reagent_id}/stock-in")
+async def reagent_stock_in(
+    reagent_id: str,
+    body: StockInBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        from decimal import Decimal
+        obj = reagent_svc.stock_in(
+            db, reagent_id,
+            qty=Decimal(str(body.qty)),
+            remark=body.remark,
+            user_id=user.get("user_id"),
+            tenant_no=tenant_no,
+        )
+        return success_response(reagent_svc._row_to_dict(obj), message="入库成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.post("/reagents/{reagent_id}/stock-out")
+async def reagent_stock_out(
+    reagent_id: str,
+    body: StockOutBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        from decimal import Decimal
+        obj = reagent_svc.stock_out(
+            db, reagent_id,
+            qty=Decimal(str(body.qty)),
+            receiver=body.receiver,
+            purpose=body.purpose,
+            remark=body.remark,
+            user_id=user.get("user_id"),
+            tenant_no=tenant_no,
+        )
+        return success_response(reagent_svc._row_to_dict(obj), message="出库成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.post("/reagents/batch-stock-in")
+async def reagent_batch_stock_in(
+    body: BatchStockInBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items_data = [it.model_dump() for it in body.items]
+        count = reagent_svc.batch_stock_in(
+            db, items=items_data, remark=body.remark,
+            tenant_no=tenant_no, user_id=user.get("user_id"),
+        )
+        return success_response({"count": count}, message=f"成功入库 {count} 条")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.post("/reagents/batch-stock-out")
+async def reagent_batch_stock_out(
+    body: BatchStockOutBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items_data = [it.model_dump() for it in body.items]
+        count = reagent_svc.batch_stock_out(
+            db, items=items_data,
+            receiver=body.receiver, purpose=body.purpose, remark=body.remark,
+            tenant_no=tenant_no, user_id=user.get("user_id"),
+        )
+        return success_response({"count": count}, message=f"成功出库 {count} 条")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.get("/reagents/{reagent_id}/stock-logs")
+async def list_reagent_stock_logs(
+    reagent_id: str,
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
+    changeType: Optional[str] = None,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    tenant_no = _get_tenant_no(db, user)
+    items, total = reagent_stock_log_svc.list_stock_logs(
+        db, tenant_no, page=page, page_size=pageSize,
+        material_id=reagent_id, change_type=changeType,
+        user_info=user,
+    )
+    tp = (total + pageSize - 1) // pageSize if total > 0 else 0
+    return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
+
+
+@router.get("/reagents/stock-logs")
+async def list_all_reagent_stock_logs(
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
+    keyword: Optional[str] = None,
+    changeType: Optional[str] = None,
+    startTime: Optional[str] = None,
+    endTime: Optional[str] = None,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    tenant_no = _get_tenant_no(db, user)
+    items, total = reagent_stock_log_svc.list_stock_logs(
+        db, tenant_no, page=page, page_size=pageSize,
+        keyword=keyword, change_type=changeType,
+        start_time=startTime, end_time=endTime,
+        user_info=user,
+    )
+    tp = (total + pageSize - 1) // pageSize if total > 0 else 0
+    return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
+
+
 # ---------------------------------------------------------------------------
 # 3. SOP文档
 # ---------------------------------------------------------------------------

+ 343 - 1
backend/app/crud/consumable.py

@@ -3,13 +3,40 @@ from __future__ import annotations
 
 import math
 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 BizConsumable
+from app.models.models import BizConsumable, BizConsumableStockLog
+
+
+def _dec(v: Any) -> float:
+    if v is None:
+        return 0.0
+    return float(v)
+
+
+def _norm_status(v: Any) -> str:
+    if v is None:
+        return "1"
+    if isinstance(v, bool):
+        return "1" if v else "0"
+    if isinstance(v, (int, float)):
+        if int(v) == 0:
+            return "0"
+        if int(v) == 1:
+            return "1"
+    s = str(v).strip()
+    if s in ("0", "1"):
+        return s
+    return "1"
+
+
+def _is_enabled_status(v: Any) -> bool:
+    return _norm_status(v) == "1"
 
 
 def _row_to_dict(c: BizConsumable) -> Dict[str, Any]:
@@ -21,6 +48,9 @@ def _row_to_dict(c: BizConsumable) -> Dict[str, Any]:
         "standardUsage": c.standard_usage or "",
         "unit": c.unit or "",
         "description": c.description or "",
+        "stockQty": _dec(c.stock_qty),
+        "warningQty": _dec(c.warning_qty),
+        "status": _norm_status(c.status),
         "tenantNo": c.tenant_no,
         "orderNum": c.order_num if c.order_num is not None else 0,
         "remark": c.remark or "",
@@ -179,12 +209,324 @@ def soft_delete_consumable(db: Session, consumable_id: str) -> None:
     obj = get_consumable(db, consumable_id)
     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 _build_manual_out_remark(
+    *,
+    receiver: Optional[str] = None,
+    purpose: Optional[str] = None,
+    remark: Optional[str] = None,
+    line_remark: Optional[str] = None,
+) -> Optional[str]:
+    parts: List[str] = []
+    r = (receiver or "").strip()
+    if r:
+        parts.append(f"领用人:{r}")
+    p = (purpose or "").strip()
+    if p:
+        parts.append(f"用途:{p}")
+    extra = (line_remark or remark or "").strip()
+    if extra:
+        parts.append(extra)
+    return ";".join(parts) if parts else None
+
+
+def stock_in(
+    db: Session,
+    row_id: str,
+    *,
+    qty: Decimal,
+    remark: Optional[str] = None,
+    user_id: Optional[str] = None,
+    tenant_no: Optional[str] = None,
+    operate_time: Optional[datetime] = None,
+) -> BizConsumable:
+    if qty is None or float(qty) <= 0:
+        raise ValueError("入库数量必须大于0")
+    obj = (
+        db.query(BizConsumable)
+        .filter(BizConsumable.id == row_id, BizConsumable.del_flag == 0)
+        .with_for_update()
+        .first()
+    )
+    if not obj:
+        raise ValueError("耗材不存在")
+    if tenant_no and obj.tenant_no != tenant_no:
+        raise ValueError("耗材不存在")
+    if not _is_enabled_status(obj.status):
+        raise ValueError(f"耗材「{obj.consumable_name}」已停用,无法入库")
+    op_time = operate_time or datetime.now()
+    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)
+    log = BizConsumableStockLog(
+        id=generate_uuid(),
+        material_id=obj.id,
+        material_code=obj.consumable_code,
+        material_name=obj.consumable_name,
+        change_type="in",
+        change_qty=qty,
+        stock_before=old_stock,
+        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=op_time,
+        update_by=user_id,
+        update_time=op_time,
+        del_flag=0,
+    )
+    db.add(log)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def batch_stock_in(
+    db: Session,
+    *,
+    items: List[Dict[str, Any]],
+    remark: Optional[str] = None,
+    tenant_no: str,
+    user_id: Optional[str] = None,
+    operate_time: Optional[datetime] = None,
+) -> int:
+    if not items:
+        raise ValueError("请至少选择一条耗材")
+    op_time = operate_time or datetime.now()
+    shared_remark = (remark or "").strip() or None
+
+    try:
+        for it in items:
+            material_id = (it.get("material_id") or it.get("materialId") or "").strip()
+            qty = it.get("qty")
+            if not material_id:
+                raise ValueError("耗材ID不能为空")
+            if qty is None or float(qty) <= 0:
+                raise ValueError("入库数量必须大于0")
+            qty_dec = Decimal(str(qty))
+            line_remark = (it.get("remark") or "").strip()
+            log_remark = line_remark or shared_remark or None
+
+            obj = (
+                db.query(BizConsumable)
+                .filter(
+                    BizConsumable.id == material_id,
+                    BizConsumable.tenant_no == tenant_no,
+                    BizConsumable.del_flag == 0,
+                )
+                .with_for_update()
+                .first()
+            )
+            if not obj:
+                raise ValueError("耗材不存在")
+            if not _is_enabled_status(obj.status):
+                raise ValueError(f"耗材「{obj.consumable_name}」已停用,无法入库")
+
+            old_stock = obj.stock_qty or 0
+            new_stock = old_stock + qty_dec
+            obj.stock_qty = new_stock
+            obj.update_by = user_id
+            obj.update_time = datetime.now()
+            db.add(obj)
+
+            log = BizConsumableStockLog(
+                id=generate_uuid(),
+                material_id=obj.id,
+                material_code=obj.consumable_code,
+                material_name=obj.consumable_name,
+                change_type="in",
+                change_qty=qty_dec,
+                stock_before=old_stock,
+                stock_after=new_stock,
+                source_type="manual_in",
+                tenant_no=obj.tenant_no,
+                remark=log_remark,
+                create_by=user_id,
+                create_dept_id=obj.create_dept_id or "",
+                create_time=op_time,
+                update_by=user_id,
+                update_time=op_time,
+                del_flag=0,
+            )
+            db.add(log)
+        db.commit()
+        return len(items)
+    except Exception:
+        db.rollback()
+        raise
+
+
+def stock_out(
+    db: Session,
+    row_id: str,
+    *,
+    qty: Decimal,
+    receiver: Optional[str] = None,
+    purpose: Optional[str] = None,
+    remark: Optional[str] = None,
+    user_id: Optional[str] = None,
+    tenant_no: Optional[str] = None,
+    operate_time: Optional[datetime] = None,
+) -> BizConsumable:
+    if qty is None or float(qty) <= 0:
+        raise ValueError("出库数量必须大于0")
+    qty_dec = Decimal(str(qty))
+    if not (receiver or "").strip():
+        raise ValueError("请选择领用人")
+
+    obj = (
+        db.query(BizConsumable)
+        .filter(BizConsumable.id == row_id, BizConsumable.del_flag == 0)
+        .with_for_update()
+        .first()
+    )
+    if not obj:
+        raise ValueError("耗材不存在")
+    if tenant_no and obj.tenant_no != tenant_no:
+        raise ValueError("耗材不存在")
+    if not _is_enabled_status(obj.status):
+        raise ValueError(f"耗材「{obj.consumable_name}」已停用,无法出库")
+
+    stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
+    if stock_before < qty_dec:
+        raise ValueError(
+            f"耗材「{obj.consumable_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
+        )
+
+    op_time = operate_time or datetime.now()
+    new_stock = stock_before - qty_dec
+    obj.stock_qty = new_stock
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.add(obj)
+
+    log = BizConsumableStockLog(
+        id=generate_uuid(),
+        material_id=obj.id,
+        material_code=obj.consumable_code,
+        material_name=obj.consumable_name,
+        change_type="use",
+        change_qty=-qty_dec,
+        stock_before=stock_before,
+        stock_after=new_stock,
+        source_type="manual_out",
+        tenant_no=obj.tenant_no,
+        receiver=(receiver or "").strip() or None,
+        purpose=(purpose or "").strip() or None,
+        remark=(remark or "").strip() or None,
+        create_by=user_id,
+        create_dept_id=obj.create_dept_id or "",
+        create_time=op_time,
+        update_by=user_id,
+        update_time=op_time,
+        del_flag=0,
+    )
+    db.add(log)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def batch_stock_out(
+    db: Session,
+    *,
+    items: List[Dict[str, Any]],
+    receiver: Optional[str] = None,
+    purpose: Optional[str] = None,
+    remark: Optional[str] = None,
+    tenant_no: str,
+    user_id: Optional[str] = None,
+    operate_time: Optional[datetime] = None,
+) -> int:
+    if not items:
+        raise ValueError("请至少选择一条耗材")
+    if not (receiver or "").strip():
+        raise ValueError("请选择领用人")
+
+    op_time = operate_time or datetime.now()
+    recv = (receiver or "").strip() or None
+    purp = (purpose or "").strip() or None
+    shared_remark = (remark or "").strip() or None
+
+    try:
+        for it in items:
+            material_id = (it.get("material_id") or it.get("materialId") or "").strip()
+            qty = it.get("qty")
+            if not material_id:
+                raise ValueError("耗材ID不能为空")
+            if qty is None or float(qty) <= 0:
+                raise ValueError("出库数量必须大于0")
+            qty_dec = Decimal(str(qty))
+            line_remark = (it.get("remark") or "").strip() or shared_remark
+
+            obj = (
+                db.query(BizConsumable)
+                .filter(
+                    BizConsumable.id == material_id,
+                    BizConsumable.tenant_no == tenant_no,
+                    BizConsumable.del_flag == 0,
+                )
+                .with_for_update()
+                .first()
+            )
+            if not obj:
+                raise ValueError("耗材不存在")
+            if not _is_enabled_status(obj.status):
+                raise ValueError(f"耗材「{obj.consumable_name}」已停用,无法出库")
+
+            stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
+            if stock_before < qty_dec:
+                raise ValueError(
+                    f"耗材「{obj.consumable_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
+                )
+
+            new_stock = stock_before - qty_dec
+            obj.stock_qty = new_stock
+            obj.update_by = user_id
+            obj.update_time = datetime.now()
+            db.add(obj)
+
+            log = BizConsumableStockLog(
+                id=generate_uuid(),
+                material_id=obj.id,
+                material_code=obj.consumable_code,
+                material_name=obj.consumable_name,
+                change_type="use",
+                change_qty=-qty_dec,
+                stock_before=stock_before,
+                stock_after=new_stock,
+                source_type="manual_out",
+                tenant_no=obj.tenant_no,
+                receiver=recv,
+                purpose=purp,
+                remark=line_remark,
+                create_by=user_id,
+                create_dept_id=obj.create_dept_id or "",
+                create_time=op_time,
+                update_by=user_id,
+                update_time=op_time,
+                del_flag=0,
+            )
+            db.add(log)
+        db.commit()
+        return len(items)
+    except Exception:
+        db.rollback()
+        raise
+
+
 def total_pages(total: int, page_size: int) -> int:
     if total <= 0:
         return 0

+ 279 - 0
backend/app/crud/consumable_stock_log.py

@@ -0,0 +1,279 @@
+"""耗材变动记录 CRUD(biz_consumable_stock_log)"""
+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, or_
+from sqlalchemy.orm import Session
+
+from app.core.utils import generate_uuid
+from app.models.models import BizConsumable, BizConsumableStockLog, SysUser
+
+
+def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
+    ids = list({x for x in user_ids if x})
+    if not ids:
+        return {}
+    rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
+    return {u.id: (u.nick_name or "").strip() for u in rows}
+
+
+def _parse_receiver_purpose_from_remark(remark: Optional[str]) -> Tuple[str, str]:
+    receiver, purpose = "", ""
+    for part in (remark or "").split(";"):
+        seg = part.strip()
+        if seg.startswith("领用人:"):
+            receiver = seg[len("领用人:") :].strip()
+        elif seg.startswith("用途:"):
+            purpose = seg[len("用途:") :].strip()
+    return receiver, purpose
+
+
+def _resolve_receiver_purpose(r: BizConsumableStockLog) -> Tuple[str, str]:
+    receiver = (getattr(r, "receiver", None) or "").strip()
+    purpose = (getattr(r, "purpose", None) or "").strip()
+    if receiver or purpose:
+        return receiver, purpose
+    if (r.source_type or "") == "manual_out":
+        return _parse_receiver_purpose_from_remark(r.remark)
+    return "", ""
+
+
+def _display_remark(r: BizConsumableStockLog) -> str:
+    remark = (r.remark or "").strip()
+    if (r.source_type or "") != "manual_out" or not remark:
+        return remark
+    parts: List[str] = []
+    for part in remark.split(";"):
+        seg = part.strip()
+        if not seg or seg.startswith("领用人:") or seg.startswith("用途:"):
+            continue
+        if seg.startswith("备注"):
+            seg = seg[2:].strip() or seg
+        parts.append(seg)
+    return ";".join(parts) if parts else ""
+
+
+def _row_to_dict(r: BizConsumableStockLog, *, create_by_name: str = "") -> Dict[str, Any]:
+    create_by = r.create_by or ""
+    receiver, purpose = _resolve_receiver_purpose(r)
+    create_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
+    is_manual_out = (r.source_type or "") == "manual_out"
+    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,
+        "stockBefore": float(r.stock_before) if r.stock_before is not None else 0,
+        "stockAfter": float(r.stock_after) if r.stock_after is not None else 0,
+        "stepId": r.step_id or "",
+        "taskId": r.task_id or "",
+        "taskNo": r.task_no or "",
+        "receiver": receiver,
+        "purpose": purpose,
+        "sourceType": r.source_type or "",
+        "useTime": create_time if is_manual_out else "",
+        "remark": _display_remark(r),
+        "createBy": create_by,
+        "createByName": create_by_name or create_by,
+        "createTime": create_time,
+        "updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
+    }
+
+
+def list_stock_logs(
+    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,
+    keyword: Optional[str] = None,
+    user_info=None,
+) -> Tuple[List[Dict[str, Any]], int]:
+    def _filtered():
+        q0 = db.query(BizConsumableStockLog).filter(
+            BizConsumableStockLog.tenant_no == tenant_no,
+            BizConsumableStockLog.del_flag == 0,
+        )
+        if material_id:
+            q0 = q0.filter(BizConsumableStockLog.material_id == material_id)
+        ct = (change_type or "").strip()
+        if ct == "out":
+            q0 = q0.filter(
+                BizConsumableStockLog.change_type == "use",
+                BizConsumableStockLog.source_type == "manual_out",
+            )
+        elif ct == "use":
+            q0 = q0.filter(
+                BizConsumableStockLog.change_type == "use",
+                BizConsumableStockLog.source_type == "step_save",
+            )
+        elif ct == "in":
+            q0 = q0.filter(BizConsumableStockLog.change_type == "in")
+        if start_time:
+            q0 = q0.filter(BizConsumableStockLog.create_time >= start_time)
+        if end_time:
+            q0 = q0.filter(BizConsumableStockLog.create_time <= end_time + " 23:59:59")
+        kw = (keyword or "").strip()
+        if kw:
+            like = f"%{kw}%"
+            q0 = q0.filter(
+                or_(
+                    BizConsumableStockLog.material_code.like(like),
+                    BizConsumableStockLog.material_name.like(like),
+                )
+            )
+        if user_info:
+            from app.core.data_scope import apply_data_scope
+            q0 = apply_data_scope(q0, BizConsumableStockLog, user_info, db, tenant_no)
+        return q0
+
+    total = _filtered().order_by(None).with_entities(func.count(BizConsumableStockLog.id)).scalar() or 0
+    page = max(1, page)
+    page_size = min(max(1, page_size), 100)
+    rows = (
+        _filtered()
+        .order_by(BizConsumableStockLog.create_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
+    return [
+        _row_to_dict(x, create_by_name=name_map.get(x.create_by or "", ""))
+        for x in rows
+    ], int(total)
+
+
+def revert_step_stock_changes(
+    db: Session,
+    step_id: str,
+    tenant_no: str,
+    user_id: Optional[str] = None,
+) -> None:
+    """撤销步骤此前 step_save 扣减,便于重新填报。"""
+    rows = (
+        db.query(BizConsumableStockLog)
+        .filter(
+            BizConsumableStockLog.step_id == step_id,
+            BizConsumableStockLog.tenant_no == tenant_no,
+            BizConsumableStockLog.del_flag == 0,
+            BizConsumableStockLog.source_type == "step_save",
+            BizConsumableStockLog.change_type == "use",
+        )
+        .all()
+    )
+    if not rows:
+        return
+    now = datetime.now()
+    for r in rows:
+        restore_qty = abs(r.change_qty) if r.change_qty is not None else Decimal("0")
+        if restore_qty <= 0:
+            r.del_flag = 1
+            r.update_by = user_id
+            r.update_time = now
+            continue
+        material = (
+            db.query(BizConsumable)
+            .with_for_update()
+            .filter(
+                BizConsumable.id == r.material_id,
+                BizConsumable.tenant_no == tenant_no,
+                BizConsumable.del_flag == 0,
+            )
+            .first()
+        )
+        if material:
+            material.stock_qty = (material.stock_qty or Decimal("0")) + restore_qty
+            material.update_by = user_id
+            material.update_time = now
+        r.del_flag = 1
+        r.update_by = user_id
+        r.update_time = now
+
+
+def deduct_for_step(
+    db: Session,
+    step_id: str,
+    task_id: str,
+    task_no: str,
+    consumable_items: List[Dict[str, Any]],
+    tenant_no: str,
+    user_id: Optional[str] = None,
+    dept_id: str = "",
+) -> None:
+    """扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。"""
+    for item in consumable_items:
+        code = (item.get("consumable_code") or "").strip()
+        actual_usage = item.get("actual_usage")
+        if not code:
+            continue
+        # 跳过空/零/非数字的 actual_usage
+        if actual_usage is None or str(actual_usage).strip() == "":
+            continue
+        try:
+            usage_val = Decimal(str(actual_usage))
+        except Exception:
+            continue
+        if usage_val <= 0:
+            continue
+
+        # 按 consumable_code 查找耗材(跳过目录中不存在的自定义材料)
+        material = (
+            db.query(BizConsumable)
+            .with_for_update()
+            .filter(
+                BizConsumable.consumable_code == code,
+                BizConsumable.tenant_no == tenant_no,
+                BizConsumable.del_flag == 0,
+                BizConsumable.status == "1",
+            )
+            .first()
+        )
+        if not material:
+            continue
+
+        stock_before = material.stock_qty if material.stock_qty is not None else Decimal("0")
+        if stock_before < usage_val:
+            raise HTTPException(
+                status_code=400,
+                detail=f"耗材「{material.consumable_name}」库存不足,当前库存 {stock_before},需使用 {usage_val}",
+            )
+
+        new_stock = stock_before - usage_val
+        material.stock_qty = new_stock
+        material.update_by = user_id
+        material.update_time = datetime.now()
+
+        log = BizConsumableStockLog(
+            id=generate_uuid(),
+            material_id=material.id,
+            material_code=material.consumable_code,
+            material_name=material.consumable_name,
+            change_type="use",
+            change_qty=-usage_val,
+            stock_before=stock_before,
+            stock_after=new_stock,
+            source_type="step_save",
+            step_id=step_id,
+            task_id=task_id,
+            task_no=task_no,
+            tenant_no=tenant_no,
+            remark=str(item.get("remark") or "").strip()[:512] or None,
+            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(log)

+ 90 - 32
backend/app/crud/production_task.py

@@ -833,22 +833,51 @@ def replace_step_reagents(
     user_id: Optional[str], items: List[Dict[str, Any]],
 ) -> None:
     now = datetime.now()
-    _soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step_id, now)
-    for idx, it in enumerate(items):
-        db.add(BizProductionTaskStepReagent(
-            id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
-            reagent_code=(it.get("reagent_code") or "").strip() or None,
-            reagent_name=(it.get("reagent_name") or "").strip() or None,
-            spec=(it.get("spec") or "").strip() or None,
-            standard_usage=(it.get("standard_usage") or "").strip() or None,
-            actual_usage=(it.get("actual_usage") or "").strip() or None,
-            unit=(it.get("unit") or "").strip() or None,
-            is_key_material=it.get("is_key_material", "0"),
-            remark=(it.get("remark") or "").strip() or None,
-            order_num=idx, create_by=user_id, create_dept_id="",
-            create_time=now, update_by=user_id, update_time=now, del_flag=0,
-        ))
-    db.commit()
+    # Look up task info from step_id for stock log
+    step_row = db.query(BizProductionTaskStep).filter(
+        and_(BizProductionTaskStep.id == step_id, BizProductionTaskStep.del_flag == 0)
+    ).first()
+    task_id = ""
+    task_no = ""
+    if step_row:
+        task_row = db.query(BizProductionTask).filter(
+            and_(BizProductionTask.id == step_row.task_id, BizProductionTask.del_flag == 0)
+        ).first()
+        if task_row:
+            task_id = task_row.id
+            task_no = task_row.task_no or ""
+
+    try:
+        _soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step_id, now)
+
+        # Revert previous step_save stock deductions
+        from app.crud import reagent_stock_log as reagent_stock_log_crud
+        reagent_stock_log_crud.revert_step_stock_changes(db, step_id, tenant_no, user_id)
+
+        for idx, it in enumerate(items):
+            db.add(BizProductionTaskStepReagent(
+                id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
+                reagent_code=(it.get("reagent_code") or "").strip() or None,
+                reagent_name=(it.get("reagent_name") or "").strip() or None,
+                spec=(it.get("spec") or "").strip() or None,
+                standard_usage=(it.get("standard_usage") or "").strip() or None,
+                actual_usage=(it.get("actual_usage") or "").strip() or None,
+                unit=(it.get("unit") or "").strip() or None,
+                is_key_material=it.get("is_key_material", "0"),
+                remark=(it.get("remark") or "").strip() or None,
+                order_num=idx, create_by=user_id, create_dept_id="",
+                create_time=now, update_by=user_id, update_time=now, del_flag=0,
+            ))
+
+        # Deduct stock based on actual_usage
+        reagent_stock_log_crud.deduct_for_step(
+            db, step_id, task_id, task_no, items, tenant_no, user_id, "",
+        )
+
+        db.commit()
+    except Exception:
+        db.rollback()
+        raise
 
 
 def replace_step_consumables(
@@ -856,22 +885,51 @@ def replace_step_consumables(
     user_id: Optional[str], items: List[Dict[str, Any]],
 ) -> None:
     now = datetime.now()
-    _soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step_id, now)
-    for idx, it in enumerate(items):
-        db.add(BizProductionTaskStepConsumable(
-            id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
-            consumable_code=(it.get("consumable_code") or "").strip() or None,
-            consumable_name=(it.get("consumable_name") or "").strip() or None,
-            spec_model=(it.get("spec_model") or "").strip() or None,
-            standard_usage=(it.get("standard_usage") or "").strip() or None,
-            actual_usage=(it.get("actual_usage") or "").strip() or None,
-            unit=(it.get("unit") or "").strip() or None,
-            is_required=it.get("is_required", "1"),
-            remark=(it.get("remark") or "").strip() or None,
-            order_num=idx, create_by=user_id, create_dept_id="",
-            create_time=now, update_by=user_id, update_time=now, del_flag=0,
-        ))
-    db.commit()
+    # Look up task info from step_id for stock log
+    step_row = db.query(BizProductionTaskStep).filter(
+        and_(BizProductionTaskStep.id == step_id, BizProductionTaskStep.del_flag == 0)
+    ).first()
+    task_id = ""
+    task_no = ""
+    if step_row:
+        task_row = db.query(BizProductionTask).filter(
+            and_(BizProductionTask.id == step_row.task_id, BizProductionTask.del_flag == 0)
+        ).first()
+        if task_row:
+            task_id = task_row.id
+            task_no = task_row.task_no or ""
+
+    try:
+        _soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step_id, now)
+
+        # Revert previous step_save stock deductions
+        from app.crud import consumable_stock_log as consumable_stock_log_crud
+        consumable_stock_log_crud.revert_step_stock_changes(db, step_id, tenant_no, user_id)
+
+        for idx, it in enumerate(items):
+            db.add(BizProductionTaskStepConsumable(
+                id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
+                consumable_code=(it.get("consumable_code") or "").strip() or None,
+                consumable_name=(it.get("consumable_name") or "").strip() or None,
+                spec_model=(it.get("spec_model") or "").strip() or None,
+                standard_usage=(it.get("standard_usage") or "").strip() or None,
+                actual_usage=(it.get("actual_usage") or "").strip() or None,
+                unit=(it.get("unit") or "").strip() or None,
+                is_required=it.get("is_required", "1"),
+                remark=(it.get("remark") or "").strip() or None,
+                order_num=idx, create_by=user_id, create_dept_id="",
+                create_time=now, update_by=user_id, update_time=now, del_flag=0,
+            ))
+
+        # Deduct stock based on actual_usage
+        consumable_stock_log_crud.deduct_for_step(
+            db, step_id, task_id, task_no, items, tenant_no, user_id, "",
+        )
+
+        db.commit()
+    except Exception:
+        db.rollback()
+        raise
 
 
 def replace_step_outputs(

+ 323 - 1
backend/app/crud/reagent.py

@@ -3,13 +3,40 @@ from __future__ import annotations
 
 import math
 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 BizReagent
+from app.models.models import BizReagent, BizReagentStockLog
+
+
+def _dec(v: Any) -> float:
+    if v is None:
+        return 0.0
+    return float(v)
+
+
+def _norm_status(v: Any) -> str:
+    if v is None:
+        return "1"
+    if isinstance(v, bool):
+        return "1" if v else "0"
+    if isinstance(v, (int, float)):
+        if int(v) == 0:
+            return "0"
+        if int(v) == 1:
+            return "1"
+    s = str(v).strip()
+    if s in ("0", "1"):
+        return s
+    return "1"
+
+
+def _is_enabled_status(v: Any) -> bool:
+    return _norm_status(v) == "1"
 
 
 def _row_to_dict(r: BizReagent) -> Dict[str, Any]:
@@ -21,6 +48,9 @@ def _row_to_dict(r: BizReagent) -> Dict[str, Any]:
         "standardUsage": r.standard_usage or "",
         "unit": r.unit or "",
         "description": r.description or "",
+        "stockQty": _dec(r.stock_qty),
+        "warningQty": _dec(r.warning_qty),
+        "status": _norm_status(r.status),
         "tenantNo": r.tenant_no,
         "orderNum": r.order_num if r.order_num is not None else 0,
         "remark": r.remark or "",
@@ -179,12 +209,304 @@ def soft_delete_reagent(db: Session, reagent_id: str) -> None:
     obj = get_reagent(db, reagent_id)
     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,
+    operate_time: Optional[datetime] = None,
+) -> BizReagent:
+    if qty is None or float(qty) <= 0:
+        raise ValueError("入库数量必须大于0")
+    obj = (
+        db.query(BizReagent)
+        .filter(BizReagent.id == row_id, BizReagent.del_flag == 0)
+        .with_for_update()
+        .first()
+    )
+    if not obj:
+        raise ValueError("试剂不存在")
+    if tenant_no and obj.tenant_no != tenant_no:
+        raise ValueError("试剂不存在")
+    if not _is_enabled_status(obj.status):
+        raise ValueError(f"试剂「{obj.reagent_name}」已停用,无法入库")
+    op_time = operate_time or datetime.now()
+    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)
+    log = BizReagentStockLog(
+        id=generate_uuid(),
+        material_id=obj.id,
+        material_code=obj.reagent_code,
+        material_name=obj.reagent_name,
+        change_type="in",
+        change_qty=qty,
+        stock_before=old_stock,
+        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=op_time,
+        update_by=user_id,
+        update_time=op_time,
+        del_flag=0,
+    )
+    db.add(log)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def batch_stock_in(
+    db: Session,
+    *,
+    items: List[Dict[str, Any]],
+    remark: Optional[str] = None,
+    tenant_no: str,
+    user_id: Optional[str] = None,
+    operate_time: Optional[datetime] = None,
+) -> int:
+    if not items:
+        raise ValueError("请至少选择一条试剂")
+    op_time = operate_time or datetime.now()
+    shared_remark = (remark or "").strip() or None
+
+    try:
+        for it in items:
+            material_id = (it.get("material_id") or it.get("materialId") or "").strip()
+            qty = it.get("qty")
+            if not material_id:
+                raise ValueError("试剂ID不能为空")
+            if qty is None or float(qty) <= 0:
+                raise ValueError("入库数量必须大于0")
+            qty_dec = Decimal(str(qty))
+            line_remark = (it.get("remark") or "").strip()
+            log_remark = line_remark or shared_remark or None
+
+            obj = (
+                db.query(BizReagent)
+                .filter(
+                    BizReagent.id == material_id,
+                    BizReagent.tenant_no == tenant_no,
+                    BizReagent.del_flag == 0,
+                )
+                .with_for_update()
+                .first()
+            )
+            if not obj:
+                raise ValueError("试剂不存在")
+            if not _is_enabled_status(obj.status):
+                raise ValueError(f"试剂「{obj.reagent_name}」已停用,无法入库")
+
+            old_stock = obj.stock_qty or 0
+            new_stock = old_stock + qty_dec
+            obj.stock_qty = new_stock
+            obj.update_by = user_id
+            obj.update_time = datetime.now()
+            db.add(obj)
+
+            log = BizReagentStockLog(
+                id=generate_uuid(),
+                material_id=obj.id,
+                material_code=obj.reagent_code,
+                material_name=obj.reagent_name,
+                change_type="in",
+                change_qty=qty_dec,
+                stock_before=old_stock,
+                stock_after=new_stock,
+                source_type="manual_in",
+                tenant_no=obj.tenant_no,
+                remark=log_remark,
+                create_by=user_id,
+                create_dept_id=obj.create_dept_id or "",
+                create_time=op_time,
+                update_by=user_id,
+                update_time=op_time,
+                del_flag=0,
+            )
+            db.add(log)
+        db.commit()
+        return len(items)
+    except Exception:
+        db.rollback()
+        raise
+
+
+def stock_out(
+    db: Session,
+    row_id: str,
+    *,
+    qty: Decimal,
+    receiver: Optional[str] = None,
+    purpose: Optional[str] = None,
+    remark: Optional[str] = None,
+    user_id: Optional[str] = None,
+    tenant_no: Optional[str] = None,
+    operate_time: Optional[datetime] = None,
+) -> BizReagent:
+    if qty is None or float(qty) <= 0:
+        raise ValueError("出库数量必须大于0")
+    qty_dec = Decimal(str(qty))
+    if not (receiver or "").strip():
+        raise ValueError("请选择领用人")
+
+    obj = (
+        db.query(BizReagent)
+        .filter(BizReagent.id == row_id, BizReagent.del_flag == 0)
+        .with_for_update()
+        .first()
+    )
+    if not obj:
+        raise ValueError("试剂不存在")
+    if tenant_no and obj.tenant_no != tenant_no:
+        raise ValueError("试剂不存在")
+    if not _is_enabled_status(obj.status):
+        raise ValueError(f"试剂「{obj.reagent_name}」已停用,无法出库")
+
+    stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
+    if stock_before < qty_dec:
+        raise ValueError(
+            f"试剂「{obj.reagent_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
+        )
+
+    op_time = operate_time or datetime.now()
+    new_stock = stock_before - qty_dec
+    obj.stock_qty = new_stock
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.add(obj)
+
+    log = BizReagentStockLog(
+        id=generate_uuid(),
+        material_id=obj.id,
+        material_code=obj.reagent_code,
+        material_name=obj.reagent_name,
+        change_type="use",
+        change_qty=-qty_dec,
+        stock_before=stock_before,
+        stock_after=new_stock,
+        source_type="manual_out",
+        tenant_no=obj.tenant_no,
+        receiver=(receiver or "").strip() or None,
+        purpose=(purpose or "").strip() or None,
+        remark=(remark or "").strip() or None,
+        create_by=user_id,
+        create_dept_id=obj.create_dept_id or "",
+        create_time=op_time,
+        update_by=user_id,
+        update_time=op_time,
+        del_flag=0,
+    )
+    db.add(log)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def batch_stock_out(
+    db: Session,
+    *,
+    items: List[Dict[str, Any]],
+    receiver: Optional[str] = None,
+    purpose: Optional[str] = None,
+    remark: Optional[str] = None,
+    tenant_no: str,
+    user_id: Optional[str] = None,
+    operate_time: Optional[datetime] = None,
+) -> int:
+    if not items:
+        raise ValueError("请至少选择一条试剂")
+    if not (receiver or "").strip():
+        raise ValueError("请选择领用人")
+
+    op_time = operate_time or datetime.now()
+    recv = (receiver or "").strip() or None
+    purp = (purpose or "").strip() or None
+    shared_remark = (remark or "").strip() or None
+
+    try:
+        for it in items:
+            material_id = (it.get("material_id") or it.get("materialId") or "").strip()
+            qty = it.get("qty")
+            if not material_id:
+                raise ValueError("试剂ID不能为空")
+            if qty is None or float(qty) <= 0:
+                raise ValueError("出库数量必须大于0")
+            qty_dec = Decimal(str(qty))
+            line_remark = (it.get("remark") or "").strip() or shared_remark
+
+            obj = (
+                db.query(BizReagent)
+                .filter(
+                    BizReagent.id == material_id,
+                    BizReagent.tenant_no == tenant_no,
+                    BizReagent.del_flag == 0,
+                )
+                .with_for_update()
+                .first()
+            )
+            if not obj:
+                raise ValueError("试剂不存在")
+            if not _is_enabled_status(obj.status):
+                raise ValueError(f"试剂「{obj.reagent_name}」已停用,无法出库")
+
+            stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
+            if stock_before < qty_dec:
+                raise ValueError(
+                    f"试剂「{obj.reagent_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
+                )
+
+            new_stock = stock_before - qty_dec
+            obj.stock_qty = new_stock
+            obj.update_by = user_id
+            obj.update_time = datetime.now()
+            db.add(obj)
+
+            log = BizReagentStockLog(
+                id=generate_uuid(),
+                material_id=obj.id,
+                material_code=obj.reagent_code,
+                material_name=obj.reagent_name,
+                change_type="use",
+                change_qty=-qty_dec,
+                stock_before=stock_before,
+                stock_after=new_stock,
+                source_type="manual_out",
+                tenant_no=obj.tenant_no,
+                receiver=recv,
+                purpose=purp,
+                remark=line_remark,
+                create_by=user_id,
+                create_dept_id=obj.create_dept_id or "",
+                create_time=op_time,
+                update_by=user_id,
+                update_time=op_time,
+                del_flag=0,
+            )
+            db.add(log)
+        db.commit()
+        return len(items)
+    except Exception:
+        db.rollback()
+        raise
+
+
 def total_pages(total: int, page_size: int) -> int:
     if total <= 0:
         return 0

+ 278 - 0
backend/app/crud/reagent_stock_log.py

@@ -0,0 +1,278 @@
+"""试剂变动记录 CRUD(biz_reagent_stock_log)"""
+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, or_
+from sqlalchemy.orm import Session
+
+from app.core.utils import generate_uuid
+from app.models.models import BizReagent, BizReagentStockLog, SysUser
+
+
+def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
+    ids = list({x for x in user_ids if x})
+    if not ids:
+        return {}
+    rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
+    return {u.id: (u.nick_name or "").strip() for u in rows}
+
+
+def _parse_receiver_purpose_from_remark(remark: Optional[str]) -> Tuple[str, str]:
+    receiver, purpose = "", ""
+    for part in (remark or "").split(";"):
+        seg = part.strip()
+        if seg.startswith("领用人:"):
+            receiver = seg[len("领用人:") :].strip()
+        elif seg.startswith("用途:"):
+            purpose = seg[len("用途:") :].strip()
+    return receiver, purpose
+
+
+def _resolve_receiver_purpose(r: BizReagentStockLog) -> Tuple[str, str]:
+    receiver = (getattr(r, "receiver", None) or "").strip()
+    purpose = (getattr(r, "purpose", None) or "").strip()
+    if receiver or purpose:
+        return receiver, purpose
+    if (r.source_type or "") == "manual_out":
+        return _parse_receiver_purpose_from_remark(r.remark)
+    return "", ""
+
+
+def _display_remark(r: BizReagentStockLog) -> str:
+    remark = (r.remark or "").strip()
+    if (r.source_type or "") != "manual_out" or not remark:
+        return remark
+    parts: List[str] = []
+    for part in remark.split(";"):
+        seg = part.strip()
+        if not seg or seg.startswith("领用人:") or seg.startswith("用途:"):
+            continue
+        if seg.startswith("备注"):
+            seg = seg[2:].strip() or seg
+        parts.append(seg)
+    return ";".join(parts) if parts else ""
+
+
+def _row_to_dict(r: BizReagentStockLog, *, create_by_name: str = "") -> Dict[str, Any]:
+    create_by = r.create_by or ""
+    receiver, purpose = _resolve_receiver_purpose(r)
+    create_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
+    is_manual_out = (r.source_type or "") == "manual_out"
+    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,
+        "stockBefore": float(r.stock_before) if r.stock_before is not None else 0,
+        "stockAfter": float(r.stock_after) if r.stock_after is not None else 0,
+        "stepId": r.step_id or "",
+        "taskId": r.task_id or "",
+        "taskNo": r.task_no or "",
+        "receiver": receiver,
+        "purpose": purpose,
+        "sourceType": r.source_type or "",
+        "useTime": create_time if is_manual_out else "",
+        "remark": _display_remark(r),
+        "createBy": create_by,
+        "createByName": create_by_name or create_by,
+        "createTime": create_time,
+        "updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
+    }
+
+
+def list_stock_logs(
+    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,
+    keyword: Optional[str] = None,
+    user_info=None,
+) -> Tuple[List[Dict[str, Any]], int]:
+    def _filtered():
+        q0 = db.query(BizReagentStockLog).filter(
+            BizReagentStockLog.tenant_no == tenant_no,
+            BizReagentStockLog.del_flag == 0,
+        )
+        if material_id:
+            q0 = q0.filter(BizReagentStockLog.material_id == material_id)
+        ct = (change_type or "").strip()
+        if ct == "out":
+            q0 = q0.filter(
+                BizReagentStockLog.change_type == "use",
+                BizReagentStockLog.source_type == "manual_out",
+            )
+        elif ct == "use":
+            q0 = q0.filter(
+                BizReagentStockLog.change_type == "use",
+                BizReagentStockLog.source_type == "step_save",
+            )
+        elif ct == "in":
+            q0 = q0.filter(BizReagentStockLog.change_type == "in")
+        if start_time:
+            q0 = q0.filter(BizReagentStockLog.create_time >= start_time)
+        if end_time:
+            q0 = q0.filter(BizReagentStockLog.create_time <= end_time + " 23:59:59")
+        kw = (keyword or "").strip()
+        if kw:
+            like = f"%{kw}%"
+            q0 = q0.filter(
+                or_(
+                    BizReagentStockLog.material_code.like(like),
+                    BizReagentStockLog.material_name.like(like),
+                )
+            )
+        if user_info:
+            from app.core.data_scope import apply_data_scope
+            q0 = apply_data_scope(q0, BizReagentStockLog, user_info, db, tenant_no)
+        return q0
+
+    total = _filtered().order_by(None).with_entities(func.count(BizReagentStockLog.id)).scalar() or 0
+    page = max(1, page)
+    page_size = min(max(1, page_size), 100)
+    rows = (
+        _filtered()
+        .order_by(BizReagentStockLog.create_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
+    return [
+        _row_to_dict(x, create_by_name=name_map.get(x.create_by or "", ""))
+        for x in rows
+    ], int(total)
+
+
+def revert_step_stock_changes(
+    db: Session,
+    step_id: str,
+    tenant_no: str,
+    user_id: Optional[str] = None,
+) -> None:
+    """撤销步骤此前 step_save 扣减,便于重新填报。"""
+    rows = (
+        db.query(BizReagentStockLog)
+        .filter(
+            BizReagentStockLog.step_id == step_id,
+            BizReagentStockLog.tenant_no == tenant_no,
+            BizReagentStockLog.del_flag == 0,
+            BizReagentStockLog.source_type == "step_save",
+            BizReagentStockLog.change_type == "use",
+        )
+        .all()
+    )
+    if not rows:
+        return
+    now = datetime.now()
+    for r in rows:
+        restore_qty = abs(r.change_qty) if r.change_qty is not None else Decimal("0")
+        if restore_qty <= 0:
+            r.del_flag = 1
+            r.update_by = user_id
+            r.update_time = now
+            continue
+        material = (
+            db.query(BizReagent)
+            .with_for_update()
+            .filter(
+                BizReagent.id == r.material_id,
+                BizReagent.tenant_no == tenant_no,
+                BizReagent.del_flag == 0,
+            )
+            .first()
+        )
+        if material:
+            material.stock_qty = (material.stock_qty or Decimal("0")) + restore_qty
+            material.update_by = user_id
+            material.update_time = now
+        r.del_flag = 1
+        r.update_by = user_id
+        r.update_time = now
+
+
+def deduct_for_step(
+    db: Session,
+    step_id: str,
+    task_id: str,
+    task_no: str,
+    reagent_items: List[Dict[str, Any]],
+    tenant_no: str,
+    user_id: Optional[str] = None,
+    dept_id: str = "",
+) -> None:
+    """扣减试剂库存并生成变动记录。调用方控制事务,本函数不 commit。"""
+    for item in reagent_items:
+        code = (item.get("reagent_code") or "").strip()
+        actual_usage = item.get("actual_usage")
+        if not code:
+            continue
+        if actual_usage is None or str(actual_usage).strip() == "":
+            continue
+        try:
+            usage_val = Decimal(str(actual_usage))
+        except Exception:
+            continue
+        if usage_val <= 0:
+            continue
+
+        # 按 reagent_code 查找试剂(跳过目录中不存在的自定义材料)
+        material = (
+            db.query(BizReagent)
+            .with_for_update()
+            .filter(
+                BizReagent.reagent_code == code,
+                BizReagent.tenant_no == tenant_no,
+                BizReagent.del_flag == 0,
+                BizReagent.status == "1",
+            )
+            .first()
+        )
+        if not material:
+            continue
+
+        stock_before = material.stock_qty if material.stock_qty is not None else Decimal("0")
+        if stock_before < usage_val:
+            raise HTTPException(
+                status_code=400,
+                detail=f"试剂「{material.reagent_name}」库存不足,当前库存 {stock_before},需使用 {usage_val}",
+            )
+
+        new_stock = stock_before - usage_val
+        material.stock_qty = new_stock
+        material.update_by = user_id
+        material.update_time = datetime.now()
+
+        log = BizReagentStockLog(
+            id=generate_uuid(),
+            material_id=material.id,
+            material_code=material.reagent_code,
+            material_name=material.reagent_name,
+            change_type="use",
+            change_qty=-usage_val,
+            stock_before=stock_before,
+            stock_after=new_stock,
+            source_type="step_save",
+            step_id=step_id,
+            task_id=task_id,
+            task_no=task_no,
+            tenant_no=tenant_no,
+            remark=str(item.get("remark") or "").strip()[:512] or None,
+            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(log)

+ 64 - 0
backend/app/models/models.py

@@ -1789,6 +1789,9 @@ class BizConsumable(Base):
     standard_usage = Column(String(100), nullable=True, comment="标准用量")
     unit = Column(String(64), nullable=True, comment="单位")
     description = Column(String(500), nullable=True, 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(String(1), nullable=False, default="1", comment="1-启用 0-停用")
     tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
     order_num = Column(Integer, nullable=True, comment="排序号")
     remark = Column(String(512), nullable=True, comment="备注")
@@ -1812,6 +1815,9 @@ class BizReagent(Base):
     standard_usage = Column(String(100), nullable=True, comment="标准用量")
     unit = Column(String(64), nullable=True, comment="单位")
     description = Column(String(500), nullable=True, 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(String(1), nullable=False, default="1", comment="1-启用 0-停用")
     tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
     order_num = Column(Integer, nullable=True, comment="排序号")
     remark = Column(String(512), nullable=True, comment="备注")
@@ -2535,6 +2541,64 @@ class BizInsuranceProjectPrice(Base):
     del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
 
 
+class BizConsumableStockLog(Base):
+    """耗材变动记录 biz_consumable_stock_log"""
+
+    __tablename__ = "biz_consumable_stock_log"
+
+    id = Column(String(64), primary_key=True, comment="主键ID")
+    material_id = Column(String(64), nullable=False, comment="耗材ID")
+    material_code = Column(String(64), nullable=True, comment="耗材编码快照")
+    material_name = Column(String(200), nullable=True, comment="耗材名称快照")
+    change_type = Column(String(16), nullable=False, comment="变动类型:in/use")
+    change_qty = Column(Numeric(18, 4), nullable=False, comment="变动数量")
+    stock_before = Column(Numeric(18, 4), nullable=False, comment="变动前库存")
+    stock_after = Column(Numeric(18, 4), nullable=False, comment="变动后库存")
+    source_type = Column(String(16), nullable=False, comment="来源:manual_in/manual_out/step_save")
+    step_id = Column(String(64), nullable=True, comment="生产任务步骤ID")
+    task_id = Column(String(64), nullable=True, comment="生产任务ID")
+    task_no = Column(String(32), nullable=True, comment="生产任务编号快照")
+    receiver = Column(String(64), nullable=True, comment="领用人")
+    purpose = Column(String(512), nullable=True, comment="用途/事由")
+    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
+    remark = Column(String(512), nullable=True, comment="备注")
+    create_by = Column(String(64), nullable=True, comment="创建人id")
+    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
+    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
+    update_by = Column(String(64), nullable=True, comment="更新人id")
+    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
+    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
+
+
+class BizReagentStockLog(Base):
+    """试剂变动记录 biz_reagent_stock_log"""
+
+    __tablename__ = "biz_reagent_stock_log"
+
+    id = Column(String(64), primary_key=True, comment="主键ID")
+    material_id = Column(String(64), nullable=False, comment="试剂ID")
+    material_code = Column(String(64), nullable=True, comment="试剂编码快照")
+    material_name = Column(String(200), nullable=True, comment="试剂名称快照")
+    change_type = Column(String(16), nullable=False, comment="变动类型:in/use")
+    change_qty = Column(Numeric(18, 4), nullable=False, comment="变动数量")
+    stock_before = Column(Numeric(18, 4), nullable=False, comment="变动前库存")
+    stock_after = Column(Numeric(18, 4), nullable=False, comment="变动后库存")
+    source_type = Column(String(16), nullable=False, comment="来源:manual_in/manual_out/step_save")
+    step_id = Column(String(64), nullable=True, comment="生产任务步骤ID")
+    task_id = Column(String(64), nullable=True, comment="生产任务ID")
+    task_no = Column(String(32), nullable=True, comment="生产任务编号快照")
+    receiver = Column(String(64), nullable=True, comment="领用人")
+    purpose = Column(String(512), nullable=True, comment="用途/事由")
+    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
+    remark = Column(String(512), nullable=True, comment="备注")
+    create_by = Column(String(64), nullable=True, comment="创建人id")
+    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
+    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
+    update_by = Column(String(64), nullable=True, comment="更新人id")
+    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
+    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
+
+
 from app.models.qc_param import BizQcItemParam, BizQcTaskResult  # noqa: E402,F401
 from app.models.qc_material import BizQcMaterial, BizQcMaterialUsage  # noqa: E402,F401
 from app.models.material import BizMaterial, BizMaterialStockLog  # noqa: E402,F401

+ 80 - 0
docs/sql/production_material_inventory.sql

@@ -0,0 +1,80 @@
+-- ============================================================
+-- 生产物料库存管理 - 数据库迁移脚本
+-- 变更: production-material-inventory
+-- ============================================================
+
+-- 1. biz_consumable 新增库存字段
+ALTER TABLE `biz_consumable`
+    ADD COLUMN `stock_qty` DECIMAL(18,4) NOT NULL DEFAULT 0.0000 COMMENT '当前库存量' AFTER `description`,
+    ADD COLUMN `warning_qty` DECIMAL(18,4) NOT NULL DEFAULT 0.0000 COMMENT '预警库存量' AFTER `stock_qty`,
+    ADD COLUMN `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '1-启用 0-停用' AFTER `warning_qty`;
+
+-- 2. biz_reagent 新增库存字段
+ALTER TABLE `biz_reagent`
+    ADD COLUMN `stock_qty` DECIMAL(18,4) NOT NULL DEFAULT 0.0000 COMMENT '当前库存量' AFTER `description`,
+    ADD COLUMN `warning_qty` DECIMAL(18,4) NOT NULL DEFAULT 0.0000 COMMENT '预警库存量' AFTER `stock_qty`,
+    ADD COLUMN `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '1-启用 0-停用' AFTER `warning_qty`;
+
+-- 3. 耗材变动记录表
+CREATE TABLE `biz_consumable_stock_log` (
+    `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 '变动类型:in入库 / use使用出库',
+    `change_qty` DECIMAL(18,4) NOT NULL COMMENT '变动数量(入库正数,出库负数)',
+    `stock_before` DECIMAL(18,4) NOT NULL COMMENT '变动前库存',
+    `stock_after` DECIMAL(18,4) NOT NULL COMMENT '变动后库存',
+    `source_type` VARCHAR(16) NOT NULL COMMENT '来源:manual_in / manual_out / step_save',
+    `step_id` VARCHAR(64) DEFAULT NULL COMMENT '生产任务步骤ID(step_save时有值)',
+    `task_id` VARCHAR(64) DEFAULT NULL COMMENT '生产任务ID(step_save时有值)',
+    `task_no` VARCHAR(32) DEFAULT NULL COMMENT '生产任务编号快照',
+    `receiver` VARCHAR(64) DEFAULT NULL COMMENT '领用人(手动出库时)',
+    `purpose` VARCHAR(512) DEFAULT NULL COMMENT '用途/事由(手动出库时)',
+    `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` SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
+    PRIMARY KEY (`id`),
+    INDEX `idx_csl_material` (`material_id`, `del_flag`),
+    INDEX `idx_csl_step` (`step_id`, `del_flag`),
+    INDEX `idx_csl_task` (`task_id`, `del_flag`),
+    INDEX `idx_csl_tenant_time` (`tenant_no`, `change_type`, `create_time`),
+    INDEX `idx_csl_source` (`source_type`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='耗材变动记录';
+
+-- 4. 试剂变动记录表
+CREATE TABLE `biz_reagent_stock_log` (
+    `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 '变动类型:in入库 / use使用出库',
+    `change_qty` DECIMAL(18,4) NOT NULL COMMENT '变动数量(入库正数,出库负数)',
+    `stock_before` DECIMAL(18,4) NOT NULL COMMENT '变动前库存',
+    `stock_after` DECIMAL(18,4) NOT NULL COMMENT '变动后库存',
+    `source_type` VARCHAR(16) NOT NULL COMMENT '来源:manual_in / manual_out / step_save',
+    `step_id` VARCHAR(64) DEFAULT NULL COMMENT '生产任务步骤ID(step_save时有值)',
+    `task_id` VARCHAR(64) DEFAULT NULL COMMENT '生产任务ID(step_save时有值)',
+    `task_no` VARCHAR(32) DEFAULT NULL COMMENT '生产任务编号快照',
+    `receiver` VARCHAR(64) DEFAULT NULL COMMENT '领用人(手动出库时)',
+    `purpose` VARCHAR(512) DEFAULT NULL COMMENT '用途/事由(手动出库时)',
+    `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` SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
+    PRIMARY KEY (`id`),
+    INDEX `idx_rsl_material` (`material_id`, `del_flag`),
+    INDEX `idx_rsl_step` (`step_id`, `del_flag`),
+    INDEX `idx_rsl_task` (`task_id`, `del_flag`),
+    INDEX `idx_rsl_tenant_time` (`tenant_no`, `change_type`, `create_time`),
+    INDEX `idx_rsl_source` (`source_type`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='试剂变动记录';

+ 509 - 0
docs/web-admin-ui-pattern-index.md

@@ -0,0 +1,509 @@
+# web-admin 界面模式索引
+
+> **用途**:新功能开发前,按「功能特征 → 模式编号 → 参考页」快速选型,保证 UI 与交互一次做对。  
+> **视觉权威**:`docs/web-admin-ui-spec-fresh-mint.md`  
+> **弹窗/列表细则**:`.cursor/rules/web-admin-crud-dialog.mdc`  
+> **Tag 映射**:`web-admin/src/utils/status-tag-registry.ts` + OpenSpec `ui-conventions` §状态标签注册表  
+> **OpenSpec**:`openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md` §「界面模式索引与参考页选型」
+
+---
+
+## 一、怎么用(30 秒选型)
+
+```
+1. 看下方「功能特征 → 模式」决策表,确定模式编号(如 P01、P12)
+2. 打开该模式的「标准写法」+「参考页面」
+3. 复制页面骨架,替换 API / 字段 / menuCode
+4. 枚举字段进 status-tag-registry.ts
+```
+
+**官方 Demo 模板(侧栏 Demo 菜单,永远优先对照):**
+
+| 模式 | Hash | 源码 |
+|------|------|------|
+| 标准列表 CRUD | `/#/demo/unit` | `web-admin/src/views/system/unit/index.vue` |
+| 复杂业务列表 | `/#/demo/order` | `web-admin/src/views/system/order/index.vue` |
+| 商品类 + 查看弹窗 | `/#/demo/product` | `web-admin/src/views/system/product/index.vue` |
+| 树形主数据 | `/#/demo/category` | `web-admin/src/views/system/category/index.vue` |
+| 用户类 CRUD | `/#/demo/user` | `web-admin/src/views/system/user/index.vue` |
+| 工作台说明 | `/#/demo/console` | `web-admin/src/views/dashboard/console/index.vue` |
+
+---
+
+## 二、功能特征 → 模式(决策表)
+
+| 你的功能长什么样 | 选模式 | 首选参考 |
+|----------------|--------|----------|
+| 单表主数据,增删改查 + 分页 + 关键词筛选 | **P01** | `/#/demo/unit` → `basic-info/customer` |
+| 列表有多种状态,操作按钮随状态变化 | **P02** | `/#/demo/order` → `cell-storage/protocol-audit` |
+| 树形结构,父子级联,无分页 | **P03** | `/#/demo/category` → `system/dept` |
+| 左侧组织/分类树 + 右侧分页列表 | **P04** | `/#/demo/user` → `system/user` |
+| 同一页上下两块表(类型 + 明细) | **P05** | `system/dict` |
+| 主表列表 + 弹窗内维护子表 | **P06** | `basic-info/collection-region` |
+| 批量生成编号/编码 + 下发/收回 | **P07** | `basic-info/sample-code` |
+| 列表顶部 Tab 按状态切换(非路由 Tab) | **P08** | `cell-storage/payment-info` |
+| 只读查看,字段分 Section 展示 | **P09** | `SystemViewDialog` → `system/express` |
+| 只读查看,业务字段多、需 Tab/KPI | **P10** | `ProtocolViewDialog` / `DonationViewDialog` |
+| 简单审核/状态变更(单表单 560px) | **P11** | `ProtocolAuditDialog` |
+| 多分区大表单(Tab 锚点 + 子表行) | **P12** | `ProtocolFormDialog` / `DonationFormDialog` |
+| 财务/业务确认(只读明细 + 上传附件) | **P13** | `cell-storage/payment-confirm` |
+| 主表 + 嵌套配置大弹窗(子资源 CRUD) | **P14** | `basic-info/insurance-company` |
+| 主表 + 多 Tab 维护渠道/协议等子模块 | **P15** | `pilot-service/channel` |
+| 三栏:资源库 \| 步骤列表 \| 步骤详情 | **P16** | `production/process-route` |
+| 工单列表 + 派发/执行弹窗链 | **P17** | `production/production-work-order` |
+| 任务执行(虚拟步骤 + 步骤状态机) | **P18** | `TaskExecutionDialog` + `useTaskStepEditor` |
+| 主数据 + append-to-body 选择器弹窗 | **P19** | `production/process` |
+| 库存主数据 + 入库/出库 + 流水只读页 | **P20** | `production/consumable` + `cell-storage/material-stock-log` |
+| 工单/任务(质检域,配置检测项) | **P21** | `quality-inspection/qc-work-order` |
+| 审核列表 + 专用审核弹窗 | **P22** | `quality-inspection/storage-release-review` |
+| 接收/登记类(关联协议样本) | **P23** | `quality-inspection/sample-reception` |
+| 工作流配置 / 待办 / 审批 | **P24** | `system/workflow/*` |
+| 独立详情页(非弹窗) | **P25** | `system/user/detail` |
+| 角色/权限扩展(多弹窗:菜单树/数据范围) | **P26** | `system/role` |
+| 衍生列表(跳转主业务查看弹窗) | **P27** | `cell-storage/storage-cert` |
+
+---
+
+## 三、模式详解(标准写法 + 参考)
+
+### P01 标准列表 CRUD
+
+**适用**:字典类主数据、配置项、简单业务实体(客户、耗材、站点…)
+
+**页面结构**:
+```
+page-head → panel → filter-block → list-toolbar → list-table → pagination
++ el-dialog.dialog-form-layout(新增/编辑)
++ SystemViewDialog(查看)
+```
+
+**标准写法要点**:
+- 工具栏:「已选 N 项」→ 唯一 `type="primary"` 新增 → 批量删除
+- 表格:`list-table` + `stripe` + `row-key="id"` + 双击查看
+- 弹窗:`label-position="top"`、`form-grid-2`、Close/Check 底栏
+- 状态列:`<el-tag size="small">` + `status-tag-registry`
+
+**参考页面**:
+
+| 业务 | Hash | 源码 | 后端 API |
+|------|------|------|----------|
+| Demo 模板 | `/#/demo/unit` | `views/system/unit/index.vue` | `admin.py` item-units |
+| 客户管理 | `/#/basic-info/customer` | `views/basic-info/customer/index.vue` | `basic_info.py` customers |
+| 耗材管理 | `/#/production/consumable` | `views/production/consumable/index.vue` | `production.py` consumables |
+| 快递公司 | `/#/system/express` | `views/system/express/index.vue` | `admin.py` |
+| 洁净室 | `/#/operations/clean-room` | `views/operations/clean-room/index.vue` | `operations.py` |
+| 市场物料 | `/#/cell-storage/material` | `views/cell-storage/material/index.vue` | `material.py` |
+
+**前端 API 模式**:`fetchXxxList` / `fetchCreateXxx` / `fetchUpdateXxx` / `fetchDeleteXxx`(`src/api/admin.ts` 或域文件)
+
+---
+
+### P02 复杂业务列表
+
+**适用**:多状态枚举、操作列按钮随状态/角色变化、业务 Tag 丰富
+
+**在 P01 基础上增加**:
+- 筛选区含状态/类型下拉
+- 操作列多种按钮(查看/编辑/提交/作废…)
+- 可能挂多个业务弹窗(审核、状态变更)
+
+**参考页面**:
+
+| 业务 | Hash | 源码 |
+|------|------|------|
+| Demo 模板 | `/#/demo/order` | `views/system/order/index.vue` |
+| 协议单管理 | `/#/cell-storage/protocol-audit` | `views/cell-storage/protocol-audit/index.vue` |
+| 捐赠协议管理 | `/#/research-donation/audit` | `views/research-donation/audit/index.vue` |
+| 入库生产工单 | `/#/production/production-work-order` | `views/production/production-work-order/index.vue` |
+| 入库质检工单 | `/#/quality-inspection/qc-work-order` | `views/quality-inspection/qc-work-order/index.vue` |
+| 生产任务 | `/#/production/production-task` | `views/production/production-task/index.vue` |
+| 请假申请 | `/#/workflow/leave` | `views/system/leave/index.vue` |
+
+---
+
+### P03 树形主数据
+
+**适用**:组织、菜单、分类等父子层级;整表树展示;软删级联子节点
+
+**标准写法**:
+- `el-table` + `:tree-props="{ children: 'children' }"`
+- 工具栏:全部展开/折叠
+- **无分页**
+- 下拉/选中显示本级名;表格/导出显示全路径(`tree-path.ts`,分隔符 ` / `)
+
+**参考页面**:
+
+| 业务 | Hash | 源码 |
+|------|------|------|
+| Demo 模板 | `/#/demo/category` | `views/system/category/index.vue` |
+| 组织管理 | `/#/system/dept` | `views/system/dept/index.vue` |
+| 菜单管理 | `/#/system/menu` | `views/system/menu/index.vue` |
+
+---
+
+### P04 左树右表
+
+**适用**:按组织/部门筛选用户或业务数据
+
+**参考**:`/#/demo/user` → `views/system/user/index.vue`  
+**要点**:左侧 `el-tree` 选组织;右侧标准 P01 列表  
+**路径展示**:`buildPathLabelMapFromTreeRoots`
+
+---
+
+### P05 主从双表(同页)
+
+**适用**:主实体 + 从实体,选中主表行后在同页下方展示从表
+
+**参考**:`/#/system/dict` → `views/system/dict/index.vue`  
+**特征**:上下两套 toolbar + table;从表依赖主表选中行
+
+---
+
+### P06 主从弹窗(子表在 Dialog)
+
+**适用**:主表字段少,子表在「配置」弹窗内 CRUD(非树形)
+
+**参考**:`/#/basic-info/collection-region` → `views/basic-info/collection-region/index.vue`  
+**后端**:主资源 + 嵌套路由 `/{id}/hospitals`  
+**注意**:不要用树 UI;子表用嵌套 `el-table`
+
+---
+
+### P07 号池 / 批量运维
+
+**适用**:预生成编号/编码、批量下发给用户、批量收回
+
+**参考**:
+
+| 业务 | Hash | 源码 |
+|------|------|------|
+| 样本编码 | `/#/basic-info/sample-code` | `views/basic-info/sample-code/index.vue` |
+| 采集协议号 | `/#/basic-info/collection-protocol-no` | `views/basic-info/collection-protocol-no/index.vue` |
+
+**标准写法**:
+- 批量生成弹窗:前缀/中间段/序号范围
+- 批量下发:选用户 + `issuedUserId` 校验
+- API:`/batch-generate`、`/batch-issue`、`/batch-revoke`;超时 `120_000`
+
+---
+
+### P08 状态 Tab 列表
+
+**适用**:同一业务实体按状态分视图(缴费状态、待办/已办…)
+
+**参考**:`/#/cell-storage/payment-info` → `views/cell-storage/payment-info/index.vue`  
+**写法**:`region-tabs` 切换 `payStatus` 筛选;每 Tab 可不同批量操作  
+**类似**:`payment-confirm`(pending/confirmed)、`refund-confirm`
+
+---
+
+### P09 标准查看弹窗(SystemViewDialog)
+
+**适用**:字段 ≤ 20 个、分 1–3 个 Section、无复杂 Tab
+
+**组件**:`web-admin/src/components/business/system-view-dialog/index.vue`
+
+**标准写法**:
+```typescript
+const viewSections = computed(() => [{
+  title: '基本信息',
+  fields: [
+    { label: '名称', value: d.name },
+    { label: '状态', value: enableStatusLabel(d.status), tagType: enableStatusTagType(d.status) }
+  ]
+}])
+```
+
+**参考**:`system/express`、`basic-info/customer`、`production/consumable-pack`(查看)
+
+---
+
+### P10 业务专用查看弹窗
+
+**适用**:字段极多、多 Tab、KPI 摘要、审核历史、附件区、跨模块信息卡片
+
+**参考**:
+
+| 业务 | 组件 |
+|------|------|
+| 采集协议 | `cell-storage/protocol/ProtocolViewDialog.vue` |
+| 科研捐赠 | `research-donation/list/DonationViewDialog.vue` |
+| 渠道信息 | `pilot-service/channel/ChannelViewDialog.vue` |
+| 入库放行 | `quality-inspection/storage-release-review/StorageReleaseViewDialog.vue` |
+| 质检任务 | `components/business/qc-task-view-dialog/index.vue` |
+
+**写法**:与编辑弹窗 **同 Tab/section 命名**;只读块复用 `ProtocolAttachmentReadonly` 等
+
+---
+
+### P11 轻量业务弹窗(560px)
+
+**适用**:审核结论、状态变更、单字段确认
+
+**参考**:
+- `ProtocolAuditDialog.vue` — 审核通过/退回
+- `ProtocolStatusChangeDialog.vue` — 生效/过期/作废/转科研
+- `DonationAuditDialog.vue` / `DonationStatusChangeDialog.vue`
+
+---
+
+### P12 重表单弹窗(Tab 分区)
+
+**适用**:主从多表一次保存、样本/当事人/缴费等分区、行内子表
+
+**参考**:
+- `cell-storage/protocol/ProtocolFormDialog.vue`(1380px,Sticky Tab)
+- `research-donation/list/DonationFormDialog.vue`
+
+**标准写法**:
+- `DialogFormBody` + `detail-tabs` + `scrollToSection`
+- `form-grid--col4` / `col2`;`scroll-to-error`
+- 附件:`upload-card`(140×96)+ `fetchUploadAttachment`
+- **独立组件文件**,不要堆在 `index.vue`
+
+---
+
+### P13 财务 / 业务确认弹窗
+
+**适用**:只读业务明细 + 财务填写确认字段 + 附件上传
+
+**参考**:
+- `/#/cell-storage/payment-confirm` + `protocol-finance-confirm-fields`
+- `/#/cell-storage/refund-confirm`
+- 组件:`components/business/protocol-payment-detail-readonly`
+- 组件:`components/business/protocol-finance-confirm-fields`
+
+---
+
+### P14 主表 + 嵌套配置大弹窗
+
+**适用**:主实体简单,子资源(险种/开票/价格)在独立大弹窗维护
+
+**参考**:`/#/basic-info/insurance-company` → `views/basic-info/insurance-company/index.vue`  
+**后端**:`/{id}/project-prices`、`/{id}/invoices`
+
+---
+
+### P15 主表 + 多 Tab 子模块页内/弹窗
+
+**适用**:一个主实体关联多种子业务(开票、地址、协议…)
+
+**参考**:`/#/pilot-service/channel` → `views/pilot-service/channel/index.vue` + `ChannelViewDialog.vue`  
+**类似**:`pilot-service/checkup-institution`(体检机构 + 套餐面板)
+
+---
+
+### P16 三栏工艺编辑器
+
+**适用**:从资源库拖拽到步骤列表,右侧编辑步骤详情子表
+
+**样式**:`web-admin/src/styles/_route-config.scss`  
+**布局类**:`.route-config-body` = 左 `.route-config-left` | 中 `.route-config-center` | 右 `.route-config-right`
+
+**参考**:
+- 原型(逻辑内联):`production/process-route/index.vue`
+- 复用版:`production-work-order/DispatchTaskDialog.vue`(phase=edit)
+- 右栏面板:`StepDetailPanel.vue`
+
+---
+
+### P17 工单列表 + 任务弹窗链
+
+**适用**:接收/触发自动生成工单 → 派发任务 → 列表查看任务 → 完成工单
+
+**参考**:`/#/production/production-work-order`  
+**子组件**:`DispatchTaskDialog`、`EditTaskInfoDialog`、任务列表内嵌 dialog  
+**后端**:`production-work-order.ts` + `production_task.py`
+
+---
+
+### P18 任务执行(Composable 驱动)
+
+**适用**:步骤状态机、虚拟步骤(原材料/产出)、执行态只读/编辑切换
+
+**参考**:
+- `TaskExecutionDialog.vue`
+- `useTaskStepEditor.ts`(核心 composable)
+- `TaskInputEnhancedTable.vue`、`TaskOutputList.vue`
+
+**质检类似**:`/#/quality-inspection/qc-task`(配置+执行,无三栏拖拽)
+
+---
+
+### P19 主数据 + 选择器子弹窗
+
+**适用**:编辑主实体时从另一张表多选关联(试剂、耗材、产出物)
+
+**参考**:`/#/production/process` → `process/index.vue`  
+**写法**:主弹窗 1200px + `append-to-body` 选择弹窗 1000px  
+**类似**:`consumable-pack/index.vue`、`cell-product/index.vue`
+
+---
+
+### P20 库存 + 流水
+
+**适用**:主数据 CRUD + 入库/出库 + 独立流水只读列表页
+
+**参考**:
+- 主数据+出入库:`/#/production/consumable`
+- 流水只读:`/#/cell-storage/material-stock-log`、`/#/quality-inspection/qc-material-usage`
+
+---
+
+### P21 质检工单 / 任务
+
+**适用**:样本接收后自动生成工单,配置检测项,执行任务
+
+**参考**:
+- 工单:`/#/quality-inspection/qc-work-order`
+- 任务:`/#/quality-inspection/qc-task`
+- API:`qc-work-order.ts`、`qc-task.ts`
+
+---
+
+### P22 审核列表 + 专用审核弹窗
+
+**适用**:待审核列表,打开专用 Dialog 填写结论
+
+**参考**:`/#/quality-inspection/storage-release-review`  
+**组件**:`StorageReleaseReviewDialog.vue`、`StorageReleaseViewDialog.vue`
+
+---
+
+### P23 接收 / 登记
+
+**适用**:关联协议/样本编码,填写接收结论,触发下游工单
+
+**参考**:`/#/quality-inspection/sample-reception`  
+**跨表键**:`tenant_no` + `sample_code`(不用 protocol_sample_id)
+
+---
+
+### P24 工作流
+
+**适用**:流程配置、待办、已办、我的申请、业务表单驱动审批
+
+**参考**:
+- 流程配置:`/#/workflow/process` + `FlowDesigner.vue`
+- 待办/已办:`task-pending`、`task-completed` + `ApprovalDialog`
+- API:`src/api/workflow.ts`、`src/api/leave.ts`
+
+---
+
+### P25 独立详情页
+
+**适用**:信息量大、需独立 URL、非弹窗场景
+
+**参考**:`/#/system/user/detail` → `views/system/user/detail.vue`(`isHide: true` 路由)
+
+---
+
+### P26 权限扩展 CRUD
+
+**适用**:除 CRUD 外还有菜单树勾选、数据范围配置
+
+**参考**:`/#/system/role` → `views/system/role/index.vue`  
+**额外弹窗**:菜单配置 `el-tree`、数据权限选部门/用户
+
+---
+
+### P27 衍生列表(跳转主业务查看)
+
+**适用**:从主业务派生的列表页,查看时打开主业务 ViewDialog
+
+**参考**:
+- `/#/cell-storage/storage-cert` → `ProtocolViewDialog`(`show-storage-cert`)
+- `/#/cell-storage/fullmoon-todo` → `ProtocolViewDialog`(`show-fullmoon-insurance`)
+
+---
+
+## 四、全站页面 → 模式速查
+
+| 页面 | 模式 |
+|------|------|
+| demo/unit, tenant, site, post, express, customer, cell-sample-type, consumable, reagent, process-category, output-type, qc-material, lab-test-item, third-party-company, material, clean-room, storage-facility, pilot-service/*-type/spec | P01 |
+| demo/order, protocol-audit, donation/audit, production-work-order, qc-work-order, production-task, leave, exhibition-appointment | P02 |
+| demo/category, dept, menu | P03 |
+| demo/user, user | P04 |
+| dict | P05 |
+| collection-region | P06 |
+| sample-code, collection-protocol-no | P07 |
+| payment-info, payment-confirm, refund-confirm | P08 |
+| 多数页的查看 | P09 |
+| protocol/ProtocolViewDialog, DonationViewDialog, ChannelViewDialog, StorageReleaseViewDialog | P10 |
+| ProtocolAuditDialog, ProtocolStatusChangeDialog, DonationAuditDialog | P11 |
+| ProtocolFormDialog, DonationFormDialog | P12 |
+| payment-confirm, refund-confirm | P13 |
+| insurance-company | P14 |
+| channel, checkup-institution | P15 |
+| process-route, DispatchTaskDialog(edit) | P16 |
+| production-work-order | P17 |
+| TaskExecutionDialog, qc-task(执行) | P18 |
+| process, consumable-pack, cell-product, sop | P19 |
+| consumable + material-stock-log / qc-material-usage | P20 |
+| qc-work-order, qc-task | P21 |
+| storage-release-review | P22 |
+| sample-reception | P23 |
+| workflow/* | P24 |
+| user/detail | P25 |
+| role | P26 |
+| storage-cert, fullmoon-todo | P27 |
+| protocol(录入) | P01 + P12 |
+| demo/product | P01 + 专用 product-view-dialog |
+
+---
+
+## 五、标准写法 Checklist(所有模式通用)
+
+### 页面骨架
+- [ ] `page-container` → `page-head` → `section.panel`
+- [ ] 单 `panel-top` 标题;筛选区无重复标题
+- [ ] 工具栏「已选 N 项」在最前;仅一个主色实心按钮
+- [ ] `list-table` + `stripe`;操作列 `fixed="right"` min-width ≥ 260
+
+### 弹窗
+- [ ] `dialog-form-layout` + `align-center` + `label-position="top"`
+- [ ] 长表单 `scroll-to-error`;底栏 Close/Check 图标间距 6px
+- [ ] 无 backdrop blur
+
+### Tag / 枚举
+- [ ] 先注册 `status-tag-registry.ts`
+- [ ] 表格 + SystemViewDialog 的 `tagType` 均用 registry getter
+
+### 前后端衔接
+- [ ] `sqxp.ts` 添加路由 + `menuCode`
+- [ ] 后端 `require_menu_code`(GET)/ `require_admin_or_super_admin`(写)
+- [ ] API 函数 + `api.d.ts` 类型
+- [ ] crud `_row_to_dict` camelCase;软删 `del_flag`
+
+---
+
+## 六、选型流程图
+
+```mermaid
+flowchart TD
+  A[新功能需求] --> B{是否树形主数据?}
+  B -->|是| P03[P03 树表 / P04 左树右表]
+  B -->|否| C{是否多 Tab 大表单?}
+  C -->|是| P12[P12 重表单 / P10 查看]
+  C -->|否| D{是否工单/任务流?}
+  D -->|是| P17[P17/P18 工单任务]
+  D -->|否| E{是否有子表/号池/审核?}
+  E -->|号池| P07
+  E -->|子表弹窗| P06/P14/P15
+  E -->|状态 Tab| P08
+  E -->|审核| P11/P22
+  E -->|三栏编辑| P16
+  E -->|否| F{操作是否随状态变化?}
+  F -->|是| P02
+  F -->|否| P01
+```
+
+---
+
+## 七、维护说明
+
+- **新增页面后**:在本文件 §四 补充「页面 → 模式」映射
+- **新增模式后**:在 §二决策表 + §三详解 补充,并同步更新 OpenSpec `ui-conventions/spec.md`「界面模式索引与参考页选型」Requirement
+- **Cursor / AI 检索关键词**:界面模式、UI pattern、参考页、模板页、P01、标准列表

+ 2 - 0
openspec/changes/production-material-inventory/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-05-23

+ 131 - 0
openspec/changes/production-material-inventory/design.md

@@ -0,0 +1,131 @@
+## Context
+
+生产部门当前仅有耗材目录表(`biz_consumable`)和试剂目录表(`biz_reagent`),管理编码、名称、规格、标准用量等基础信息,无库存管理。市场部门和质检部门已有完整的库存管理体系,采用「主表含库存字段 + 变动记录表」的双表模式。
+
+现有耗材/试剂被 10 张下游关联表(耗材包、工艺、工艺路线、生产任务步骤)引用,但全部使用快照复制(denormalized code/name),无 FK 依赖,对主表加字段零影响。
+
+### 现有参照实现
+
+- **市场物料**:`biz_material`(含 stock_qty)+ `biz_material_stock_log`(变动记录,rk/ck,正数)
+- **质检物料**:`biz_qc_material`(含 stock_qty)+ `biz_qc_material_usage`(变动记录,in/use,正负数,含 task_complete 自动扣减 + revert 回退)
+
+### 步骤保存挂钩点
+
+生产任务步骤的耗材/试剂保存入口:
+- `PUT /production-tasks/{task_id}/steps/{step_id}/reagents` → `replace_step_reagents()` 
+- `PUT /production-tasks/{task_id}/steps/{step_id}/consumables` → `replace_step_consumables()`
+
+这两个函数使用"软删旧 + 插入新"模式,需在此处加入 diff 结算逻辑。
+
+## Goals / Non-Goals
+
+**Goals:**
+- 为耗材和试剂增加库存管理能力(库存量、预警量、出入库操作)
+- 支持手动单个/批量入库出库,参照质检物料模式
+- 支持生产任务步骤保存时按 actual_usage 自动 diff 结算库存
+- 变动记录在耗材/试剂页面内以子弹窗和 Tab 形式展示
+
+**Non-Goals:**
+- 不新建独立菜单页面(变动记录嵌入现有页面)
+- 不修改现有耗材/试剂目录字段(只加库存字段)
+- 不修改下游 10 张关联表
+- 不实现仓库/库位管理(单库存维度)
+- 不实现库存预警通知(仅记录 warning_qty 字段)
+- 不统一耗材/试剂为统一物料表(保持两个独立目录)
+
+## Decisions
+
+### Decision 1: 在现有目录表上加库存字段,不新建库存主表
+
+**选择**:ALTER TABLE `biz_consumable` / `biz_reagent` 新增 `stock_qty`、`warning_qty`、`status` 字段。
+
+**理由**:
+- 下游 10 张关联表全是快照复制,加字段零影响
+- 与市场/质检物料的「主表含库存字段」模式一致
+- 避免引入目录表→库存表的间接层和数据同步问题
+- 改动最小:复用现有 CRUD/API/前端,只需扩展
+
+**替代方案**:新建 `biz_consumable_stock` 表,通过 consumable_id 关联。放弃原因:多一层间接、需维护目录-库存同步、与已有模式不一致。
+
+### Decision 2: 新建独立的变动记录表
+
+**选择**:新建 `biz_consumable_stock_log` 和 `biz_reagent_stock_log`,参照 `biz_material_stock_log` 结构。
+
+**理由**:
+- 变动记录是独立的关注点,不应混在目录表中
+- 需要快照字段(code/name)和 before/after 库存
+- 需要溯源字段(source_type、step_id、task_id、task_no)
+- 参照 `biz_qc_material_usage` 的 source_type 模式:manual_in / manual_out / step_save
+
+**关键设计**:参照质检物料采用正负数表示方向(入库正、出库负),同时保留 `change_type` 字段(in/use)。
+
+### Decision 3: 步骤保存 diff 结算算法
+
+**选择**:全量回退 + 重算模式(参照质检物料的 revert + deduct 模式)。
+
+**算法**:
+1. 查出该步骤当前所有 source_type=step_save 的变动记录
+2. 按变动记录逐条回退库存(加回扣减量),软删变动记录
+3. 遍历新的步骤耗材/试剂列表,按 actual_usage 扣减库存,生成新变动记录
+4. 整体在同一事务中
+
+**理由**:
+- 步骤保存频率不高,全量回退+重算简单可靠
+- 避免复杂的逐项 diff 计算(需处理编码变更等边界情况)
+- 与质检物料 deduct_for_task() 模式一致,代码可复用
+
+**替代方案**:逐项 diff(旧用量 - 新用量 = 差额)。放弃原因:需处理编码变更(物料A改成物料B)、处理删除、新增等复杂场景,容易出 bug。全量回退更简单且已验证。
+
+### Decision 4: 变动记录子弹窗 + Tab 双入口
+
+**选择**:
+- 每行操作列「变动记录」按钮 → Dialog 弹窗,按 material_id 筛选
+- 页面顶部 Tab「全部变动记录」→ Tab 切换视图,展示所有物料的汇总记录
+
+**理由**:
+- 不新增菜单位,保持生产管理菜单简洁
+- 行按钮满足"看某个物料历史"的快速需求
+- Tab 视图满足"看所有变动流水"的审计需求
+
+### Decision 5: 挂钩点在 replace_step_reagents/consumables
+
+**选择**:在 `replace_step_reagents()` 和 `replace_step_consumables()` 函数中,在 soft delete 旧记录之后、insert 新记录之前,执行库存 diff 结算。
+
+**调用链**:
+```
+API: PUT /production-tasks/{task_id}/steps/{step_id}/consumables
+  → replace_step_consumables(db, step_id, tenant_no, user_id, items)
+    → 1. 查出步骤信息(step_id → task_id, task_no)
+    → 2. 调用 diff_settle_step_consumables(db, step_id, task_id, task_no, tenant_no, user_id)
+         → revert step 的所有 step_save 变动记录(回退库存)
+    → 3. soft delete 旧步骤耗材子记录
+    → 4. insert 新步骤耗材子记录
+    → 5. 调用 deduct_step_consumables(db, step_id, task_id, task_no, tenant_no, user_id, new_items)
+         → 按 actual_usage 扣减库存,生成 step_save 变动记录
+    → 6. commit
+```
+
+## Risks / Trade-offs
+
+### Risk: 步骤保存时库存不足
+**影响**:任务执行保存步骤时,若耗材/试剂库存不足,保存会失败。
+**缓解**:提供清晰的错误提示("物料「XX」库存不足,当前库存 N,需使用 M"),让用户先去入库。同时前端可在步骤编辑时显示当前库存量供参考。
+
+### Risk: actual_usage 为空或非数字
+**影响**:步骤保存时 actual_usage 字段可能为空字符串或非数字。
+**缓解**:diff 结算时跳过 actual_usage 为空或无法转为正数的条目,仅处理有效数据。
+
+### Risk: 并发保存同一物料库存
+**影响**:多个步骤同时保存涉及同一耗材/试剂。
+**缓解**:使用 `with_for_update()` 行锁,与质检物料一致。
+
+### Trade-off: 全量回退 vs 逐项 diff
+**选择**全量回退,牺牲少量性能换取代码简洁性和正确性保证。步骤保存频率低,性能影响可忽略。
+
+## Migration Plan
+
+1. **数据库迁移**:ALTER TABLE 加字段(有默认值) + CREATE TABLE 建变动记录表
+2. **后端部署**:模型 → CRUD → API 顺序部署
+3. **前端部署**:扩展现有页面
+4. **无需数据迁移**:现有耗材/试剂的 stock_qty 默认为 0,warning_qty 默认为 0,status 默认为 '1'
+5. **回滚方案**:ALTER TABLE 删除新增字段,DROP TABLE 变动记录表,回滚代码

+ 34 - 0
openspec/changes/production-material-inventory/proposal.md

@@ -0,0 +1,34 @@
+## Why
+
+生产部门目前【耗材管理】和【试剂管理】仅维护目录数据(编码、名称、规格、单位等),无法追踪库存数量、出入库记录。市场部门和质检部门已有完整的物料库存管理体系(主表含库存字段 + 变动记录表),生产部门需要补齐这一能力,同时生产任务执行过程中的步骤保存需自动扣减耗材/试剂库存。
+
+## What Changes
+
+- 在 `biz_consumable` 和 `biz_reagent` 现有目录表上新增库存字段(stock_qty、warning_qty、status)
+- 新建 `biz_consumable_stock_log` 和 `biz_reagent_stock_log` 两张变动记录表(参照 `biz_material_stock_log` 结构)
+- 后端新增出入库 API(单个/批量入库出库),含行锁防超卖、出库校验、批量事务回滚
+- 后端新增生产任务步骤保存时的自动库存 diff 结算(增量扣减/回退)
+- 前端在现有耗材/试剂管理页面增加:库存列、入库/出库/批量入库/批量出库操作按钮及弹窗
+- 前端新增变动记录展示:行按钮弹窗(单物料历史)+ 顶部 Tab 全部变动记录视图
+- 变动记录区分 source_type:manual_in(手动入库)、manual_out(手动出库)、step_save(步骤保存自动扣减/回退)
+
+## Capabilities
+
+### New Capabilities
+
+- `consumable-inventory`: 耗材库存管理 — 库存字段扩展、手动出入库(单个+批量)、批量事务、库存校验、删除保护
+- `reagent-inventory`: 试剂库存管理 — 同上,独立于耗材的平行实现
+- `production-step-stock-deduction`: 生产任务步骤保存自动扣减 — diff 结算算法、增量扣减/回退、并发安全、溯源关联(step_id/task_id/task_no)
+- `material-stock-log-ui`: 变动记录子弹窗 UI — 行按钮单物料弹窗 + 顶部 Tab 全部记录视图、筛选、分页
+
+### Modified Capabilities
+
+(无需修改现有 spec 的行为要求)
+
+## Impact
+
+- **数据库**:`biz_consumable`、`biz_reagent` 各加 3 个字段(ALTER ADD COLUMN,有默认值,零风险);新建 2 张变动记录表
+- **后端**:修改 `models/models.py`(加字段+新 Model)、新建 `crud/consumable_stock_log.py` 和 `crud/reagent_stock_log.py`、扩展 `crud/consumable.py` 和 `crud/reagent.py` 出入库逻辑、扩展 `api/production.py` 出入库和变动记录 API、修改 `crud/production_task.py` 步骤保存加入 diff 扣减
+- **前端**:修改 `views/production/consumable/index.vue` 和 `views/production/reagent/index.vue`(增加库存列、出入库弹窗、变动记录 Tab/弹窗)、扩展 `api/production.ts` API 调用
+- **下游不受影响**:耗材包、工艺库、工艺路线、生产任务步骤等 10 张下游关联表均为快照复制,无 FK 依赖,加字段零影响
+- **SQL 迁移**:需在 `docs/sql/` 新增迁移脚本

+ 107 - 0
openspec/changes/production-material-inventory/specs/consumable-inventory/spec.md

@@ -0,0 +1,107 @@
+## ADDED Requirements
+
+### Requirement: Consumable inventory fields
+系统 SHALL 在 `biz_consumable` 表上新增以下库存字段:
+- `stock_qty` DECIMAL(18,4) NOT NULL DEFAULT 0 — 当前库存量
+- `warning_qty` DECIMAL(18,4) NOT NULL DEFAULT 0 — 预警库存量
+- `status` CHAR(1) NOT NULL DEFAULT '1' — 启用/停用状态(1=启用,0=停用)
+
+后端 SQLAlchemy Model `BizConsumable` SHALL 同步新增对应字段。前端列表和表单 SHALL 展示这些字段。
+
+#### Scenario: Existing consumables have zero stock after migration
+- **WHEN** 数据库迁移完成,已有耗材记录存在
+- **THEN** 所有已有耗材的 stock_qty 为 0,warning_qty 为 0,status 为 '1'
+
+#### Scenario: Consumable list shows stock info
+- **WHEN** 用户访问耗材管理列表
+- **THEN** 列表显示当前库存量、预警库存量、状态列
+
+### Requirement: Manual stock-in for consumable
+系统 SHALL 支持对单个耗材进行手动入库操作。入库数量必须大于 0。入库后 stock_qty 增加,同时生成一条变动记录(source_type=manual_in,change_type=in)。
+
+#### Scenario: Successful stock-in
+- **WHEN** 用户对库存为 10 的耗材入库 5
+- **THEN** 耗材 stock_qty 变为 15,生成变动记录 change_qty=5, stock_after=15, source_type=manual_in
+
+#### Scenario: Stock-in with disabled consumable
+- **WHEN** 用户对已停用的耗材执行入库
+- **THEN** 系统拒绝并提示"物料「XX」已停用,无法入库"
+
+### Requirement: Manual stock-out for consumable
+系统 SHALL 支持对单个耗材进行手动出库操作。出库数量必须大于 0,且不能超过当前库存。出库必须填写领用人。出库后 stock_qty 减少,同时生成一条变动记录(source_type=manual_out,change_type=use,change_qty 为负数)。
+
+#### Scenario: Successful stock-out
+- **WHEN** 用户对库存为 10 的耗材出库 3,领用人为"张三"
+- **THEN** 耗材 stock_qty 变为 7,生成变动记录 change_qty=-3, stock_after=7, source_type=manual_out, receiver=张三
+
+#### Scenario: Stock-out exceeds inventory
+- **WHEN** 用户对库存为 2 的耗材出库 5
+- **THEN** 系统拒绝并提示"物料「XX」库存不足,当前库存 2,需出库 5"
+
+#### Scenario: Stock-out without receiver
+- **WHEN** 用户出库时不填写领用人
+- **THEN** 系统拒绝并提示"请选择领用人"
+
+### Requirement: Batch stock-in for consumables
+系统 SHALL 支持对多个耗材批量入库。所有条目在同一事务内处理,任一条目失败则全部回滚。每条入库记录独立生成变动记录。
+
+#### Scenario: Successful batch stock-in
+- **WHEN** 用户批量入库 3 种耗材,数量分别为 5、10、2
+- **THEN** 3 种耗材库存分别增加,生成 3 条变动记录,全部在同一事务内
+
+#### Scenario: Batch stock-in with one invalid item
+- **WHEN** 用户批量入库 3 种耗材,其中一种已停用
+- **THEN** 全部回滚,没有任何耗材库存变化
+
+### Requirement: Batch stock-out for consumables
+系统 SHALL 支持对多个耗材批量出库。批量出库需统一填写领用人和用途。所有条目在同一事务内处理,任一条目失败则全部回滚。
+
+#### Scenario: Successful batch stock-out
+- **WHEN** 用户批量出库 3 种耗材,领用人"张三",数量分别为 2、1、3
+- **THEN** 3 种耗材库存分别减少,生成 3 条变动记录(source_type=manual_out)
+
+#### Scenario: Batch stock-out with insufficient stock
+- **WHEN** 用户批量出库 3 种耗材,其中一种库存不足
+- **THEN** 全部回滚,提示具体哪个物料库存不足
+
+### Requirement: Delete protection for stocked consumable
+系统 SHALL 禁止删除库存量大于 0 的耗材。
+
+#### Scenario: Delete consumable with stock
+- **WHEN** 用户尝试删除库存为 5 的耗材
+- **THEN** 系统拒绝并提示"该物料仍有库存,无法删除"
+
+#### Scenario: Delete consumable with zero stock
+- **WHEN** 用户删除库存为 0 的耗材
+- **THEN** 软删除成功(del_flag=1)
+
+### Requirement: Consumable stock-out disabled status check
+出库和入库操作 SHALL 检查物料状态,已停用物料不允许出入库。
+
+#### Scenario: Stock-out with disabled status
+- **WHEN** 用户对 status='0' 的耗材执行出库
+- **THEN** 系统拒绝并提示"物料「XX」已停用,无法出库"
+
+### Requirement: Row-level locking for concurrent stock operations
+出入库操作 SHALL 使用 `with_for_update()` 行锁,防止并发操作导致库存数据不一致。
+
+#### Scenario: Concurrent stock-in
+- **WHEN** 两个用户同时对同一耗材入库
+- **THEN** 行锁保证两次操作串行执行,库存结果正确
+
+### Requirement: Consumable stock log table
+系统 SHALL 新建 `biz_consumable_stock_log` 变动记录表,字段参照 `biz_material_stock_log` 并增加生产溯源字段:
+- `material_id` — 耗材 ID
+- `material_code` / `material_name` — 快照字段
+- `change_type` — in(入库) / use(使用/出库)
+- `change_qty` — 变动数量(入库正数,出库负数)
+- `stock_before` — 变动前库存
+- `stock_after` — 变动后库存
+- `source_type` — manual_in / manual_out / step_save
+- `step_id` / `task_id` / `task_no` — 步骤溯源字段(step_save 时有值)
+- `receiver` / `purpose` — 领用人/用途(manual_out 时有值)
+- 公共字段
+
+#### Scenario: Stock log created on stock-in
+- **WHEN** 耗材入库 5
+- **THEN** 变动记录 change_type=in, change_qty=5, source_type=manual_in, stock_before=旧库存, stock_after=新库存

+ 80 - 0
openspec/changes/production-material-inventory/specs/material-stock-log-ui/spec.md

@@ -0,0 +1,80 @@
+## ADDED Requirements
+
+### Requirement: Per-row stock log dialog
+耗材/试剂管理列表每行操作列 SHALL 有一个「变动记录」按钮,点击弹出 Dialog,标题显示物料名称,列表仅展示该物料的变动记录,支持分页。
+
+#### Scenario: Open stock log for single material
+- **WHEN** 用户点击耗材A行的「变动记录」按钮
+- **THEN** 弹出 Dialog,标题为"耗材A — 变动记录",列表按时间倒序展示该耗材的所有变动记录
+
+#### Scenario: Stock log dialog pagination
+- **WHEN** 变动记录超过 20 条
+- **THEN** Dialog 内列表支持分页
+
+### Requirement: Tab view for all stock logs
+耗材管理页面和试剂管理页面 SHALL 各自有一个顶部 Tab「全部变动记录」,切换后展示所有耗材(或试剂)的汇总变动记录列表,支持按物料名称/编码、变动类型、时间范围筛选,支持分页。
+
+#### Scenario: Switch to all stock logs tab
+- **WHEN** 用户点击耗材管理页面顶部「全部变动记录」Tab
+- **THEN** 切换到全部变动记录视图,展示所有耗材的变动记录汇总列表
+
+#### Scenario: Filter by material name
+- **WHEN** 用户在全部变动记录 Tab 中搜索物料名称"培养皿"
+- **THEN** 列表仅展示物料名称包含"培养皿"的变动记录
+
+#### Scenario: Filter by change type
+- **WHEN** 用户筛选变动类型为"出库"
+- **THEN** 列表仅展示 change_type=use 的变动记录(含 manual_out 和 step_save)
+
+#### Scenario: Filter by time range
+- **WHEN** 用户选择时间范围 2026-01-01 至 2026-03-31
+- **THEN** 列表仅展示该时间范围内的变动记录
+
+### Requirement: Stock log display fields
+变动记录列表(弹窗和 Tab 视图)SHALL 展示以下字段:
+- 物料编码(material_code)
+- 物料名称(material_name)
+- 变动类型(change_type):入库/出库/步骤扣减,以 `<el-tag>` 展示
+- 变动数量(change_qty):正数绿色,负数红色
+- 变动前库存(stock_before)
+- 变动后库存(stock_after)
+- 来源(source_type):手动入库/手动出库/步骤保存,以 `<el-tag>` 展示
+- 关联任务(task_no):步骤扣减时展示任务编号
+- 领用人(receiver):手动出库时展示
+- 用途(purpose):手动出库时展示
+- 操作人(create_by_name)
+- 操作时间(create_time)
+
+#### Scenario: Manual out log display
+- **WHEN** 变动记录为手动出库类型
+- **THEN** 展示领用人、用途、操作人、时间,来源显示"手动出库"
+
+#### Scenario: Step save log display
+- **WHEN** 变动记录为步骤保存类型
+- **THEN** 展示关联任务编号、操作人、时间,来源显示"步骤扣减",不展示领用人/用途
+
+### Requirement: Stock log API endpoints
+后端 SHALL 提供以下变动记录 API:
+- `GET /web/admin/consumables/{id}/stock-logs` — 单个耗材变动记录(分页)
+- `GET /web/admin/consumables/stock-logs` — 全部耗材变动记录(分页,支持筛选)
+- `GET /web/admin/reagents/{id}/stock-logs` — 单个试剂变动记录(分页)
+- `GET /web/admin/reagents/stock-logs` — 全部试剂变动记录(分页,支持筛选)
+
+#### Scenario: Single material stock log API
+- **WHEN** 前端请求 `GET /web/admin/consumables/{id}/stock-logs?page=1&pageSize=20`
+- **THEN** 返回该耗材的变动记录列表和总数
+
+#### Scenario: All stock logs API with filters
+- **WHEN** 前端请求 `GET /web/admin/consumables/stock-logs?keyword=培养皿&changeType=out&startTime=2026-01-01&endTime=2026-03-31`
+- **THEN** 返回筛选后的变动记录列表和总数
+
+### Requirement: Status field display in list
+耗材/试剂管理列表的状态列 SHALL 使用 `<el-tag>` 展示:启用显示 success 类型,停用显示 info 类型。
+
+#### Scenario: Enabled status display
+- **WHEN** 耗材状态为启用(status='1')
+- **THEN** 列表显示绿色 `<el-tag type="success" size="small">` 文本"启用"
+
+#### Scenario: Disabled status display
+- **WHEN** 耗材状态为停用(status='0')
+- **THEN** 列表显示灰色 `<el-tag type="info" size="small">` 文本"停用"

+ 76 - 0
openspec/changes/production-material-inventory/specs/production-step-stock-deduction/spec.md

@@ -0,0 +1,76 @@
+## ADDED Requirements
+
+### Requirement: Step save auto stock deduction
+系统 SHALL 在生产任务步骤保存耗材/试剂时,根据 `actual_usage` 字段自动扣减对应耗材/试剂的库存。变动记录的 `source_type` SHALL 为 `step_save`。
+
+#### Scenario: First save with actual usage
+- **WHEN** 用户首次保存步骤,耗材A 的 actual_usage=2,耗材B 的 actual_usage=3
+- **THEN** 耗材A 库存扣减 2,耗材B 库存扣减 3,生成 2 条变动记录 source_type=step_save
+
+#### Scenario: Save with empty actual_usage
+- **WHEN** 用户保存步骤,某耗材的 actual_usage 为空或为 0
+- **THEN** 该耗材不扣减库存,不生成变动记录
+
+#### Scenario: Save with non-numeric actual_usage
+- **WHEN** 用户保存步骤,某耗材的 actual_usage 为非数字字符串
+- **THEN** 系统跳过该条目,仅处理 actual_usage 为有效正数的条目
+
+### Requirement: Step save diff settlement via full revert and re-deduct
+系统 SHALL 采用全量回退+重算模式处理步骤保存的库存变动:
+1. 查出该步骤此前所有 source_type=step_save 的变动记录
+2. 按变动记录逐条回退库存(加回扣减量),软删变动记录
+3. 遍历新的步骤耗材/试剂列表,按 actual_usage 扣减库存,生成新变动记录
+
+#### Scenario: Modify usage upward
+- **WHEN** 步骤首次保存耗材A actual_usage=2,第二次保存改为 actual_usage=3
+- **THEN** 回退 2(库存+2),重新扣减 3(库存-3),净效果库存-1
+
+#### Scenario: Modify usage downward
+- **WHEN** 步骤首次保存耗材A actual_usage=5,第二次保存改为 actual_usage=2
+- **THEN** 回退 5(库存+5),重新扣减 2(库存-2),净效果库存+3
+
+#### Scenario: Remove material from step
+- **WHEN** 步骤首次保存耗材A actual_usage=3 和耗材B actual_usage=2,第二次保存仅保留耗材B actual_usage=2
+- **THEN** 回退耗材A 的 3 和耗材B 的 2,重新扣减耗材B 的 2。净效果:耗材A 库存+3,耗材B 不变
+
+#### Scenario: Add new material to step
+- **WHEN** 步骤首次保存耗材A actual_usage=2,第二次保存新增耗材C actual_usage=4
+- **THEN** 回退耗材A 的 2,重新扣减耗材A 的 2 和耗材C 的 4。净效果:耗材A 不变,耗材C 库存-4
+
+### Requirement: Step save stock deduction uses row-level locking
+diff 结算过程中 SHALL 使用 `with_for_update()` 行锁锁定涉及的耗材/试剂行。
+
+#### Scenario: Concurrent step saves on same material
+- **WHEN** 两个步骤同时保存,都涉及耗材A
+- **THEN** 行锁保证串行执行,库存变动正确
+
+### Requirement: Step save stock deduction in same transaction
+diff 结算 SHALL 在步骤耗材/试剂保存的同一数据库事务中执行。库存扣减失败时,步骤保存也回滚。
+
+#### Scenario: Stock insufficient during step save
+- **WHEN** 用户保存步骤,耗材A actual_usage=10,但库存仅 3
+- **THEN** 整个步骤保存回滚,提示"物料「耗材A」库存不足,当前库存 3,需使用 10"
+
+### Requirement: Stock deduction traceability
+每条 step_save 类型的变动记录 SHALL 包含以下溯源字段:
+- `step_id` — 生产任务步骤 ID
+- `task_id` — 生产任务 ID
+- `task_no` — 生产任务编号快照
+
+#### Scenario: Stock log shows task info
+- **WHEN** 步骤保存触发库存扣减
+- **THEN** 变动记录包含 step_id、task_id、task_no,可追溯至具体任务和步骤
+
+### Requirement: Deduction hooks in replace_step_reagents and replace_step_consumables
+库存 diff 结算 SHALL 在 `replace_step_reagents()` 和 `replace_step_consumables()` 函数中执行,调用时机为软删旧子记录之后、插入新子记录之前。
+
+#### Scenario: replace_step_consumables triggers deduction
+- **WHEN** API `PUT /production-tasks/{task_id}/steps/{step_id}/consumables` 被调用
+- **THEN** 系统在替换步骤耗材的同时执行库存 diff 结算
+
+### Requirement: Skip deduction for materials not in catalog
+如果步骤中的耗材编码或试剂编码在 `biz_consumable` / `biz_reagent` 中不存在(可能是手工录入的自定义物料),SHALL 跳过该条目的库存扣减,不报错。
+
+#### Scenario: Custom material code in step
+- **WHEN** 步骤保存包含一个 consumable_code 不在 biz_consumable 表中的条目
+- **THEN** 系统跳过该条目的库存扣减,其他正常条目正常扣减

+ 75 - 0
openspec/changes/production-material-inventory/specs/reagent-inventory/spec.md

@@ -0,0 +1,75 @@
+## ADDED Requirements
+
+### Requirement: Reagent inventory fields
+系统 SHALL 在 `biz_reagent` 表上新增以下库存字段:
+- `stock_qty` DECIMAL(18,4) NOT NULL DEFAULT 0 — 当前库存量
+- `warning_qty` DECIMAL(18,4) NOT NULL DEFAULT 0 — 预警库存量
+- `status` CHAR(1) NOT NULL DEFAULT '1' — 启用/停用状态(1=启用,0=停用)
+
+后端 SQLAlchemy Model `BizReagent` SHALL 同步新增对应字段。前端列表和表单 SHALL 展示这些字段。
+
+#### Scenario: Existing reagents have zero stock after migration
+- **WHEN** 数据库迁移完成,已有试剂记录存在
+- **THEN** 所有已有试剂的 stock_qty 为 0,warning_qty 为 0,status 为 '1'
+
+#### Scenario: Reagent list shows stock info
+- **WHEN** 用户访问试剂管理列表
+- **THEN** 列表显示当前库存量、预警库存量、状态列
+
+### Requirement: Manual stock-in for reagent
+系统 SHALL 支持对单个试剂进行手动入库操作。入库数量必须大于 0。入库后 stock_qty 增加,同时生成一条变动记录(source_type=manual_in,change_type=in)。
+
+#### Scenario: Successful stock-in
+- **WHEN** 用户对库存为 10 的试剂入库 5
+- **THEN** 试剂 stock_qty 变为 15,生成变动记录 change_qty=5, stock_after=15, source_type=manual_in
+
+### Requirement: Manual stock-out for reagent
+系统 SHALL 支持对单个试剂进行手动出库操作。出库数量必须大于 0,且不能超过当前库存。出库必须填写领用人。出库后 stock_qty 减少,同时生成一条变动记录(source_type=manual_out,change_type=use,change_qty 为负数)。
+
+#### Scenario: Successful stock-out
+- **WHEN** 用户对库存为 10 的试剂出库 3,领用人"李四"
+- **THEN** 试剂 stock_qty 变为 7,生成变动记录 change_qty=-3, stock_after=7, source_type=manual_out, receiver=李四
+
+#### Scenario: Stock-out exceeds inventory
+- **WHEN** 用户对库存为 2 的试剂出库 5
+- **THEN** 系统拒绝并提示库存不足
+
+### Requirement: Batch stock-in for reagents
+系统 SHALL 支持对多个试剂批量入库。所有条目在同一事务内处理,任一条目失败则全部回滚。
+
+#### Scenario: Successful batch stock-in
+- **WHEN** 用户批量入库 3 种试剂,数量分别为 5、10、2
+- **THEN** 3 种试剂库存分别增加,生成 3 条变动记录,全部在同一事务内
+
+### Requirement: Batch stock-out for reagents
+系统 SHALL 支持对多个试剂批量出库。批量出库需统一填写领用人和用途。所有条目在同一事务内处理,任一条目失败则全部回滚。
+
+#### Scenario: Successful batch stock-out
+- **WHEN** 用户批量出库 3 种试剂,领用人"李四"
+- **THEN** 3 种试剂库存分别减少,生成 3 条变动记录
+
+### Requirement: Delete protection for stocked reagent
+系统 SHALL 禁止删除库存量大于 0 的试剂。
+
+#### Scenario: Delete reagent with stock
+- **WHEN** 用户尝试删除库存为 5 的试剂
+- **THEN** 系统拒绝并提示"该物料仍有库存,无法删除"
+
+### Requirement: Row-level locking for reagent stock operations
+出入库操作 SHALL 使用 `with_for_update()` 行锁,防止并发操作导致库存数据不一致。
+
+### Requirement: Reagent stock log table
+系统 SHALL 新建 `biz_reagent_stock_log` 变动记录表,字段结构与 `biz_consumable_stock_log` 一致:
+- `material_id` — 试剂 ID
+- `material_code` / `material_name` — 快照字段
+- `change_type` — in(入库) / use(使用/出库)
+- `change_qty` — 变动数量(入库正数,出库负数)
+- `stock_before` / `stock_after` — 变动前后库存
+- `source_type` — manual_in / manual_out / step_save
+- `step_id` / `task_id` / `task_no` — 步骤溯源字段
+- `receiver` / `purpose` — 领用人/用途
+- 公共字段
+
+#### Scenario: Stock log created on reagent stock-in
+- **WHEN** 试剂入库 5
+- **THEN** 变动记录 change_type=in, change_qty=5, source_type=manual_in, stock_before=旧库存, stock_after=新库存

+ 56 - 0
openspec/changes/production-material-inventory/tasks.md

@@ -0,0 +1,56 @@
+## 1. Database Migration
+
+- [x] 1.1 编写 SQL 迁移脚本 `docs/sql/production_material_inventory.sql`:ALTER TABLE `biz_consumable` 新增 stock_qty/warning_qty/status 字段,ALTER TABLE `biz_reagent` 新增同上字段,CREATE TABLE `biz_consumable_stock_log`,CREATE TABLE `biz_reagent_stock_log`
+- [ ] 1.2 在开发数据库执行迁移脚本,验证字段和表创建成功
+
+## 2. Backend Model & CRUD — Consumable
+
+- [x] 2.1 在 `backend/app/models/models.py` 中 `BizConsumable` 新增 stock_qty、warning_qty、status 字段;新建 `BizConsumableStockLog` Model(参照 `BizQcMaterialUsage`,增加 step_id/task_id/task_no 字段)
+- [x] 2.2 扩展 `backend/app/crud/consumable.py`:修改 `_row_to_dict` 返回库存字段,新增 `stock_in()`、`stock_out()`、`batch_stock_in()`、`batch_stock_out()` 函数(参照 `crud/qc_material.py`),修改 `soft_delete` 增加库存检查
+- [x] 2.3 新建 `backend/app/crud/consumable_stock_log.py`:`list_stock_logs()` 函数(参照 `crud/qc_material_usage.py` 的 `list_usages()`),支持按 material_id、change_type、时间范围筛选
+
+## 3. Backend Model & CRUD — Reagent
+
+- [x] 3.1 在 `backend/app/models/models.py` 中 `BizReagent` 新增 stock_qty、warning_qty、status 字段;新建 `BizReagentStockLog` Model(同 BizConsumableStockLog)
+- [x] 3.2 扩展 `backend/app/crud/reagent.py`:修改 `_row_to_dict` 返回库存字段,新增 `stock_in()`、`stock_out()`、`batch_stock_in()`、`batch_stock_out()` 函数,修改 `soft_delete` 增加库存检查
+- [x] 3.3 新建 `backend/app/crud/reagent_stock_log.py`:`list_stock_logs()` 函数,支持筛选
+
+## 4. Backend API — Stock Operations & Log Endpoints
+
+- [x] 4.1 在 `backend/app/api/production.py` 新增耗材出入库 API:`POST /consumables/{id}/stock-in`、`POST /consumables/{id}/stock-out`、`POST /consumables/batch-stock-in`、`POST /consumables/batch-stock-out`
+- [x] 4.2 在 `backend/app/api/production.py` 新增耗材变动记录 API:`GET /consumables/{id}/stock-logs`、`GET /consumables/stock-logs`
+- [x] 4.3 在 `backend/app/api/production.py` 新增试剂出入库 API:`POST /reagents/{id}/stock-in`、`POST /reagents/{id}/stock-out`、`POST /reagents/batch-stock-in`、`POST /reagents/batch-stock-out`
+- [x] 4.4 在 `backend/app/api/production.py` 新增试剂变动记录 API:`GET /reagents/{id}/stock-logs`、`GET /reagents/stock-logs`
+
+## 5. Backend — Step Save Auto Deduction
+
+- [x] 5.1 在 `backend/app/crud/consumable_stock_log.py` 新增 `revert_step_stock_changes()` 函数(参照 `qc_material_usage.py` 的 `revert_task_material_usages`,按 step_id + source_type=step_save 回退库存)
+- [x] 5.2 在 `backend/app/crud/consumable_stock_log.py` 新增 `deduct_for_step()` 函数(参照 `qc_material_usage.py` 的 `deduct_for_task`,按 actual_usage 扣减库存)
+- [x] 5.3 在 `backend/app/crud/reagent_stock_log.py` 新增 `revert_step_stock_changes()` 和 `deduct_for_step()` 函数(同上,试剂版)
+- [x] 5.4 修改 `backend/app/crud/production_task.py` 的 `replace_step_reagents()` 函数:在 soft delete 旧记录后、insert 新记录后,调用 `revert_step_stock_changes()` + `deduct_for_step()`
+- [x] 5.5 修改 `backend/app/crud/production_task.py` 的 `replace_step_consumables()` 函数:同上逻辑
+- [x] 5.6 修改 `backend/app/api/production.py` 中 `replace_task_step_reagents` 和 `replace_task_step_consumables` API,传入 task_id/task_no 以支持溯源
+
+## 6. Frontend — Consumable Page Enhancement
+
+- [x] 6.1 扩展 `web-admin/src/api/production.ts`:新增耗材出入库 API 调用函数(stockIn、stockOut、batchStockIn、batchStockOut、getStockLogs、getAllStockLogs)
+- [x] 6.2 修改 `web-admin/src/views/production/consumable/index.vue`:列表新增库存量、预警量、状态列,表单新增库存字段
+- [x] 6.3 在耗材页面新增操作按钮:单个入库、单个出库弹窗(参照质检物料 `qc-material/index.vue`)
+- [x] 6.4 在耗材页面新增批量入库、批量出库弹窗(参照质检物料批量操作)
+- [x] 6.5 在耗材页面行操作列新增「变动记录」按钮 → Dialog 弹窗,展示单物料变动记录列表
+- [x] 6.6 在耗材页面新增顶部 Tab「全部变动记录」视图,展示所有耗材汇总变动记录,支持筛选和分页
+
+## 7. Frontend — Reagent Page Enhancement
+
+- [x] 7.1 扩展 `web-admin/src/api/production.ts`:新增试剂量出入库 API 调用函数
+- [x] 7.2 修改 `web-admin/src/views/production/reagent/index.vue`:列表新增库存量、预警量、状态列,表单新增库存字段
+- [x] 7.3 在试剂页面新增操作按钮:单个入库、单个出库弹窗
+- [x] 7.4 在试剂页面新增批量入库、批量出库弹窗
+- [x] 7.5 在试剂页面行操作列新增「变动记录」按钮 → Dialog 弹窗
+- [x] 7.6 在试剂页面新增顶部 Tab「全部变动记录」视图
+
+## 8. Verification
+
+- [ ] 8.1 后端验证:启动后端服务,通过 Swagger 测试所有新增 API(CRUD + 出入库 + 变动记录)
+- [ ] 8.2 前端验证:启动前端开发服务,手动测试耗材/试剂页面的所有新增功能(入库、出库、批量操作、变动记录弹窗、变动记录 Tab)
+- [ ] 8.3 步骤扣减验证:创建生产任务,执行步骤保存,验证耗材/试剂库存自动扣减,修改用量后验证 diff 结算正确性

+ 184 - 13
openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md

@@ -575,23 +575,20 @@ import { Plus, Close, Check, Search, RefreshLeft, Edit, Delete, View } from '@el
 </el-table-column>
 ```
 
-**多状态映射标准写法(使用辅助函数):**
+**多状态映射标准写法(使用状态标签注册表):**
 
 ```html
 <el-table-column prop="workOrderStatus" label="状态" width="100" align="center">
   <template #default="{ row }">
-    <el-tag :type="statusTagType(row.workOrderStatus)" size="small">
-      {{ statusLabel(row.workOrderStatus) }}
+    <el-tag :type="workOrderStatusTagType(row.workOrderStatus)" size="small">
+      {{ workOrderStatusLabel(row.workOrderStatus) }}
     </el-tag>
   </template>
 </el-table-column>
 ```
 
 ```typescript
-function statusTagType(s: string): string {
-  const m: Record<string, string> = { ywc: 'success', ypz: 'warning', dpz: 'info' }
-  return m[s] || 'info'
-}
+import { workOrderStatusLabel, workOrderStatusTagType } from '@/utils/status-tag-registry'
 ```
 
 #### 2. 查看弹窗(SystemViewDialog)中的 Tag 展示
@@ -615,13 +612,15 @@ export interface ViewField {
 **标准写法:**
 
 ```typescript
+import { taskStatusLabel, taskStatusTagType, qcTaskConclusionLabel, qcTaskConclusionTagType } from '@/utils/status-tag-registry'
+
 const viewSections = computed<ViewSection[]>(() => [
   {
     title: '任务信息',
     fields: [
       { label: '任务号', value: d.taskNo },
-      { label: '状态', value: statusLabel(d.status), tagType: d.status === 'ywc' ? 'success' : d.status === 'jxz' ? 'warning' : 'info' },
-      { label: '检测结论', value: conclusionLabel(d.conclusion), tagType: d.conclusion === 'tg' ? 'success' : d.conclusion === 'btg' ? 'danger' : 'info' },
+      { label: '状态', value: taskStatusLabel(d.status), tagType: taskStatusTagType(d.status) },
+      { label: '检测结论', value: qcTaskConclusionLabel(d.conclusion), tagType: qcTaskConclusionTagType(d.conclusion) },
       // 普通字段不加 tagType,仍以纯文本展示
       { label: '备注', value: d.remark || '—', span: true },
     ]
@@ -637,12 +636,16 @@ const viewSections = computed<ViewSection[]>(() => [
 
 ```html
 <dd>
-  <el-tag :type="row.acceptConclusion === 'js' ? 'success' : 'danger'" size="small">
+  <el-tag :type="acceptConclusionTagType(row.acceptConclusion)" size="small">
     {{ acceptConclusionLabel(row.acceptConclusion) }}
   </el-tag>
 </dd>
 ```
 
+```typescript
+import { acceptConclusionLabel, acceptConclusionTagType } from '@/utils/status-tag-registry'
+```
+
 #### 4. Tag 类型语义色映射规范
 
 | Tag Type | 颜色 | 适用语义 | 示例 |
@@ -655,9 +658,72 @@ const viewSections = computed<ViewSection[]>(() => [
 
 **对于启用/停用的状态字段**,SHALL 使用 `success`(启用)和 `info`(停用),SHALL NOT 使用 `danger` 表示停用状态。
 
-#### 5. 公共标签映射函数
+#### 5. 状态标签注册表(集中定义 label + tagType)
+
+`web-admin` 中所有状态、结论、类型等枚举字段的 **文案(label)与 Tag 语义色(tagType)SHALL 在单一注册表中集中定义**,权威文件为 `web-admin/src/utils/status-tag-registry.ts`。
+
+**核心原则:**
+
+1. **同一枚举值全局一致**:同一枚举 code 在列表、查看弹窗、导出、嵌套子列表等任何展示位置,SHALL 使用相同的 label 与 tagType;SHALL NOT 在页面内用三元表达式或局部 `Record` 重复定义映射。
+2. **成对导出**:每个枚举域 SHALL 导出 registry 对象及配套的 `xxxLabel(code)`、`xxxTagType(code)` 两个 getter;未知 code 时 label 回退为原值、`tagType` 回退为 `'info'`。
+3. **新增枚举先注册后使用**:新增业务枚举时,SHALL 先在 `status-tag-registry.ts` 追加 registry 与 getter,再在页面/组件中 import 使用;SHALL NOT 直接在 `.vue` 中写 `if/else` 或内联映射。
+4. **域内分组**:registry 内按业务域分区维护(协议/存储、质检、渠道、订单、系统通用等),便于检索与 code review。
+
+**注册表结构(已实现):**
+
+```typescript
+/** el-tag 的 type 属性值 */
+export type TagType = 'success' | 'warning' | 'danger' | 'info' | '' | 'primary'
+
+export interface StatusItem {
+  label: string
+  tagType: TagType
+}
+
+export type StatusRegistry = Record<string, StatusItem>
+
+export const protocolStatus: StatusRegistry = {
+  dsx: { label: '待生效', tagType: 'info' },
+  sxz: { label: '生效中', tagType: 'success' },
+  ygq: { label: '已过期', tagType: 'warning' },
+  yzf: { label: '已作废', tagType: 'danger' },
+  zky: { label: '转科研', tagType: 'primary' }
+}
+export const protocolStatusLabel = createLabelGetter(protocolStatus)
+export const protocolStatusTagType = createTagTypeGetter(protocolStatus)
+```
+
+**页面使用方式:**
+
+```typescript
+import { protocolStatusLabel, protocolStatusTagType } from '@/utils/status-tag-registry'
+```
+
+```html
+<el-tag :type="protocolStatusTagType(row.status)" size="small">
+  {{ protocolStatusLabel(row.status) }}
+</el-tag>
+```
 
-多个页面复用的状态标签映射 SHALL 抽取为公共工具函数(如 `utils/protocol-payment-labels.ts`、`utils/qc-labels.ts`),同时提供 `xxxLabel`(文本)和 `xxxTagType`(tag type)两个配套函数。仅单个页面使用的状态标签 SHALL 在页面 `<script setup>` 中定义。
+**兼容 re-export(非新增入口):**
+
+`utils/protocol-payment-labels.ts`、`utils/qc-labels.ts` 等历史文件 MAY 继续 re-export 注册表中的 getter 以保持既有 import 路径;**新增枚举 SHALL 直接写入 `status-tag-registry.ts`**,SHALL NOT 再新建独立的 label/tag 映射文件。
+
+**禁止写法:**
+
+- 页面 `<script setup>` 内定义 `function xxxTagType()` / `const xxxMap = { ... }` 映射已在或应在注册表中的枚举
+- 查看弹窗 `ViewField.tagType` 使用内联三元表达式(如 `d.status === 'ywc' ? 'success' : ...`)替代 registry getter
+- 同一枚举在不同页面使用不同 tagType
+
+#### Scenario: 新增业务枚举的状态展示
+
+- **WHEN** 新增页面需展示新的状态/结论/类型枚举字段
+- **THEN** 开发者 SHALL 先在 `status-tag-registry.ts` 注册该枚举的 label 与 tagType,并在页面中 import 对应的 `xxxLabel` / `xxxTagType` getter 使用
+
+#### Scenario: 复用已有枚举的状态展示
+
+- **WHEN** 页面需展示已在注册表中定义的枚举(如 `protocolStatus`、`taskStatus`、`enableStatus`)
+- **THEN** 页面 SHALL 直接从 `@/utils/status-tag-registry` import 并使用,SHALL NOT 在页面内重复定义映射
 
 #### Scenario: 新增列表页包含状态列
 
@@ -672,7 +738,7 @@ const viewSections = computed<ViewSection[]>(() => [
 #### Scenario: Code review Tag 展示
 
 - **WHEN** PR 修改列表页或查看弹窗中的状态/结论/类型字段展示
-- **THEN** 审查者 SHALL 确认使用 `<el-tag>` 组件(表格列)或 `tagType` 属性(SystemViewDialog),SHALL NOT 出现纯文本状态展示或自定义 `<span class="tag">`
+- **THEN** 审查者 SHALL 确认使用 `<el-tag>` 组件(表格列)或 `tagType` 属性(SystemViewDialog),SHALL NOT 出现纯文本状态展示或自定义 `<span class="tag">`;若枚举已在或应进入注册表,SHALL 确认映射来自 `status-tag-registry.ts` 而非页面内联定义
 
 ### Requirement: 嵌套弹窗与子弹窗内列表风格一致
 
@@ -701,3 +767,108 @@ const viewSections = computed<ViewSection[]>(() => [
 - **WHEN** PR 在弹窗、侧栏详情或嵌套面板中新增或修改 `el-table`
 - **THEN** 审查者 SHALL 确认未使用 `size="small"` / 紧凑字号覆盖,且列表风格与主列表页一致
 
+### Requirement: web-admin 界面模式索引与参考页选型
+
+`web-admin` 新增或改造业务页面时,开发者 SHALL 在编码前按 **功能特征 → 模式编号(P01–P27)→ 参考页** 完成界面选型;模式定义、决策表、各模式标准写法与全站页面映射的**详细说明**以 `docs/web-admin-ui-pattern-index.md` 为权威索引,本 Requirement 给出 SHALL 级门禁与摘要。
+
+**选型流程(SHALL 按序执行):**
+
+1. 对照下方「功能特征 → 模式」决策表或索引文档 §二,确定模式编号(如 P01、P12)。
+2. 打开该模式对应的 **Demo 模板页** 或 **业务参考页**(Hash + 源码路径见索引文档 §三)。
+3. 复制页面骨架与交互模式,替换 API、字段、`menuCode`;**SHALL NOT** 从零设计布局或另起一套类名习惯。
+4. 枚举字段 SHALL 注册至 `status-tag-registry.ts`(见本 spec「状态标签注册表」Requirement)。
+
+**官方 Demo 模板(永远优先对照,SHALL 优先于业务页):**
+
+| 用途 | Hash | 源码 |
+|------|------|------|
+| 标准列表 CRUD | `/#/demo/unit` | `web-admin/src/views/system/unit/index.vue` |
+| 复杂业务列表 | `/#/demo/order` | `web-admin/src/views/system/order/index.vue` |
+| 商品类 + 查看弹窗 | `/#/demo/product` | `web-admin/src/views/system/product/index.vue` |
+| 树形主数据 | `/#/demo/category` | `web-admin/src/views/system/category/index.vue` |
+| 用户类 CRUD | `/#/demo/user` | `web-admin/src/views/system/user/index.vue` |
+| 工作台说明 | `/#/demo/console` | `web-admin/src/views/dashboard/console/index.vue` |
+
+**功能特征 → 模式编号(决策表):**
+
+| 功能特征 | 模式 | 首选参考 |
+|----------|------|----------|
+| 单表主数据,增删改查 + 分页 + 关键词筛选 | **P01** | `/#/demo/unit` → `basic-info/customer` |
+| 列表多状态,操作按钮随状态变化 | **P02** | `/#/demo/order` → `cell-storage/protocol-audit` |
+| 树形结构,父子级联,无分页 | **P03** | `/#/demo/category` → `system/dept` |
+| 左侧组织/分类树 + 右侧分页列表 | **P04** | `/#/demo/user` → `system/user` |
+| 同页上下两块表(类型 + 明细) | **P05** | `system/dict` |
+| 主表列表 + 弹窗内维护子表 | **P06** | `basic-info/collection-region` |
+| 批量生成编号/编码 + 下发/收回 | **P07** | `basic-info/sample-code` |
+| 列表顶部 Tab 按状态切换(非路由 Tab) | **P08** | `cell-storage/payment-info` |
+| 只读查看,字段分 Section(≤20 字段) | **P09** | `SystemViewDialog` → `system/express` |
+| 只读查看,多 Tab/KPI/审核历史/附件 | **P10** | `ProtocolViewDialog` / `DonationViewDialog` |
+| 简单审核/状态变更(单表单 ~560px) | **P11** | `ProtocolAuditDialog` |
+| 多分区大表单(Tab 锚点 + 行内子表) | **P12** | `ProtocolFormDialog` / `DonationFormDialog` |
+| 财务/业务确认(只读明细 + 上传附件) | **P13** | `cell-storage/payment-confirm` |
+| 主表 + 嵌套配置大弹窗(子资源 CRUD) | **P14** | `basic-info/insurance-company` |
+| 主表 + 多 Tab 子模块(渠道/协议等) | **P15** | `pilot-service/channel` |
+| 三栏:资源库 \| 步骤列表 \| 步骤详情 | **P16** | `production/process-route` |
+| 工单列表 + 派发/执行弹窗链 | **P17** | `production/production-work-order` |
+| 任务执行(虚拟步骤 + 步骤状态机) | **P18** | `TaskExecutionDialog` + `useTaskStepEditor` |
+| 主数据 + append-to-body 选择器弹窗 | **P19** | `production/process` |
+| 库存主数据 + 入库/出库 + 流水只读页 | **P20** | `production/consumable` + `material-stock-log` |
+| 质检工单/任务(配置检测项) | **P21** | `quality-inspection/qc-work-order` |
+| 审核列表 + 专用审核弹窗 | **P22** | `quality-inspection/storage-release-review` |
+| 接收/登记(关联协议样本) | **P23** | `quality-inspection/sample-reception` |
+| 工作流配置 / 待办 / 审批 | **P24** | `system/workflow/*` |
+| 独立详情页(非弹窗) | **P25** | `system/user/detail` |
+| 角色/权限扩展(菜单树/数据范围) | **P26** | `system/role` |
+| 衍生列表(跳转主业务查看弹窗) | **P27** | `cell-storage/storage-cert` |
+
+**各模式 SHALL 级要点(摘要):**
+
+- **P01**:`page-head` → `filter-block` → `list-toolbar`(已选 N 项在前)→ `list-table stripe` → 分页;`dialog-form-layout` 编辑 + `SystemViewDialog` 查看。
+- **P02**:在 P01 基础上增加状态筛选、操作列多按钮、业务弹窗(审核/状态变更)。
+- **P03**:`el-table` 树形、无分页;路径展示遵循「树形主数据展示规则」Requirement。
+- **P04**:左 `el-tree` + 右 P01 列表;组织全路径用 `tree-path.ts`。
+- **P06**:主从平铺 + 子 API;**SHALL NOT** 误用树形主数据 UI。
+- **P07**:`/batch-generate`、`/batch-issue`、`/batch-revoke`;批量请求超时 SHALL ≥ 120s。
+- **P08**:`region-tabs` 切换列表筛选;各 Tab 可有不同批量操作。
+- **P09**:查看 SHALL 使用 `SystemViewDialog`;状态字段传 `tagType`。
+- **P10**:复杂查看 SHALL 使用独立 `*ViewDialog` 组件;与编辑弹窗 **同 Tab/section 命名**;只读附件复用 `ProtocolAttachmentReadonly` 等。
+- **P11/P22**:轻量审核弹窗 width ~560px;结论字段用 registry Tag。
+- **P12**:重表单 SHALL 拆为独立 `.vue` 组件;`DialogFormBody` + `detail-tabs` + `scroll-to-error`;附件卡片化布局遵循本 spec 附件 Requirement。
+- **P16**:三栏布局 SHALL 使用 `_route-config.scss` 的 `.route-config-*` 类;新三栏场景 SHALL 复用 `useTaskStepEditor` + `StepDetailPanel`,**SHALL NOT** 复制 `process-route` 内联逻辑。
+- **P17/P18**:工单/任务流 SHALL 对齐 `production-work-order` 弹窗链与 composable 模式。
+- **P27**:衍生列表查看 SHALL 打开主业务 ViewDialog(如 `ProtocolViewDialog`),**SHALL NOT** 重复实现完整查看 UI。
+
+**维护约定:**
+
+- 新增页面落地后,SHALL 在 `docs/web-admin-ui-pattern-index.md` §四补充「页面 → 模式」映射。
+- 出现现有 P01–P27 无法覆盖的新交互形态时,SHALL 先在索引文档新增模式编号与参考页,再更新本 Requirement 决策表。
+
+#### Scenario: 新增标准主数据页
+
+- **WHEN** 新增单表 CRUD 主数据页(如供应商、配置项)
+- **THEN** 开发者 SHALL 选定模式 **P01**,以 `/#/demo/unit` 或 `basic-info/customer` 为骨架参考
+- **THEN** PR 描述或设计说明中 SHALL 注明所选模式编号与参考页路径
+
+#### Scenario: 新增复杂业务或工单页
+
+- **WHEN** 新增含多状态操作、工单派发、任务执行或三栏编辑的页面
+- **THEN** 开发者 SHALL 从 P02、P16、P17、P18、P21 等决策表中选定模式,并对照索引文档 §三对应参考页实现
+- **THEN** SHALL NOT 仅参照 P01 标准列表强行裁剪复杂交互
+
+#### Scenario: 新增重表单或专用查看弹窗
+
+- **WHEN** 新增字段多、需 Tab 分区或 KPI 的业务表单/查看
+- **THEN** 编辑 SHALL 采用 **P12**(独立 FormDialog 组件);查看字段极多 SHALL 采用 **P10**(专用 ViewDialog)
+- **THEN** SHALL NOT 在 `index.vue` 内堆叠超过 500 行表单/查看模板
+
+#### Scenario: Code review 界面模式选型
+
+- **WHEN** PR 新增或大幅改造 `web-admin` 业务页面
+- **THEN** 审查者 SHALL 确认 PR 注明模式编号(Pxx)及参考页
+- **THEN** 页面骨架、工具栏顺序、弹窗类名与参考页一致;若偏离 SHALL 有书面理由
+
+#### Scenario: 索引文档与 OpenSpec 同步
+
+- **WHEN** 新增 P28 或更高级别模式,或全站页面映射变更
+- **THEN** 维护者 SHALL 同步更新 `docs/web-admin-ui-pattern-index.md` 与本 Requirement 决策表
+

+ 118 - 0
web-admin/src/api/production.ts

@@ -303,3 +303,121 @@ export function fetchUpdateStepOutputs(data: { stepId: string; items: any[] }) {
 export function fetchUpdateStepDataCollections(data: { stepId: string; items: any[] }) {
   return request.put({ url: '/web/admin/process-routes/step/data-collection/update', params: data })
 }
+
+// ---------------------------------------------------------------------------
+// 试剂库存操作
+// ---------------------------------------------------------------------------
+
+export function fetchReagentStockIn(id: string, data: { qty: number; remark?: string }) {
+  return request.post({ url: `/web/admin/reagents/${id}/stock-in`, data })
+}
+
+export function fetchReagentStockOut(
+  id: string,
+  data: { qty: number; receiver: string; purpose?: string; remark?: string }
+) {
+  return request.post({ url: `/web/admin/reagents/${id}/stock-out`, data })
+}
+
+export function fetchReagentBatchStockIn(data: {
+  items: Array<{ materialId: string; qty: number; remark?: string }>
+  remark?: string
+}) {
+  return request.post<{ count: number }>({ url: '/web/admin/reagents/batch-stock-in', data })
+}
+
+export function fetchReagentBatchStockOut(data: {
+  items: Array<{ materialId: string; qty: number }>
+  receiver: string
+  purpose?: string
+  remark?: string
+}) {
+  return request.post<{ count: number }>({ url: '/web/admin/reagents/batch-stock-out', data })
+}
+
+export function fetchReagentStockLogs(
+  id: string,
+  params: { page: number; pageSize: number; changeType?: string }
+) {
+  return request.get<{
+    list: any[]
+    total: number
+    page: number
+    pageSize: number
+  }>({ url: `/web/admin/reagents/${id}/stock-logs`, params })
+}
+
+export function fetchAllReagentStockLogs(params: {
+  page: number
+  pageSize: number
+  keyword?: string
+  changeType?: string
+  startTime?: string
+  endTime?: string
+}) {
+  return request.get<{
+    list: any[]
+    total: number
+    page: number
+    pageSize: number
+  }>({ url: '/web/admin/reagents/stock-logs', params })
+}
+
+// ---------------------------------------------------------------------------
+// 耗材库存操作
+// ---------------------------------------------------------------------------
+
+export function fetchConsumableStockIn(id: string, data: { qty: number; remark?: string }) {
+  return request.post({ url: `/web/admin/consumables/${id}/stock-in`, data })
+}
+
+export function fetchConsumableStockOut(
+  id: string,
+  data: { qty: number; receiver?: string; purpose?: string; remark?: string }
+) {
+  return request.post({ url: `/web/admin/consumables/${id}/stock-out`, data })
+}
+
+export function fetchConsumableBatchStockIn(data: {
+  items: { materialId: string; qty: number; remark?: string }[]
+  remark?: string
+}) {
+  return request.post<{ count: number }>({ url: '/web/admin/consumables/batch-stock-in', data })
+}
+
+export function fetchConsumableBatchStockOut(data: {
+  items: { materialId: string; qty: number }[]
+  receiver?: string
+  purpose?: string
+  remark?: string
+}) {
+  return request.post<{ count: number }>({ url: '/web/admin/consumables/batch-stock-out', data })
+}
+
+export function fetchConsumableStockLogs(
+  id: string,
+  params: { page: number; pageSize: number; changeType?: string }
+) {
+  return request.get<{
+    list: any[]
+    total: number
+    page: number
+    pageSize: number
+  }>({ url: `/web/admin/consumables/${id}/stock-logs`, params })
+}
+
+export function fetchAllConsumableStockLogs(params: {
+  page: number
+  pageSize: number
+  keyword?: string
+  changeType?: string
+  startTime?: string
+  endTime?: string
+}) {
+  return request.get<{
+    list: any[]
+    total: number
+    page: number
+    pageSize: number
+  }>({ url: '/web/admin/consumables/stock-logs', params })
+}

+ 7 - 0
web-admin/src/assets/styles/core/app.scss

@@ -411,6 +411,13 @@ body {
   }
 }
 
+.page-container .panel-tabs {
+  > .el-tabs__header {
+    margin: 0;
+    padding: 0 16px;
+  }
+}
+
 .page-container :is(.panel-top h2, .panel-top h3, .storage-content-header h3, .storage-aside__head h2, .user-dept-aside__head h2).auto-title-tenant,
 .console-container .panel-top h2.auto-title-tenant {
   --panel-title-icon-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z'/%3E%3Cpath d='M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2'/%3E%3Cpath d='M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2'/%3E%3Cpath d='M10 6h4'/%3E%3Cpath d='M10 10h4'/%3E%3Cpath d='M10 14h4'/%3E%3Cpath d='M10 18h4'/%3E%3C/svg%3E");

Dosya farkı çok büyük olduğundan ihmal edildi
+ 905 - 94
web-admin/src/views/production/consumable/index.vue


Dosya farkı çok büyük olduğundan ihmal edildi
+ 961 - 85
web-admin/src/views/production/reagent/index.vue


Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor