diff --git a/.cursorrules b/.cursorrules index 95541b27..9a57a7b0 100644 --- a/.cursorrules +++ b/.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` 为准。 diff --git a/AGENTS.md b/AGENTS.md index 9cc02531..ee1694d9 100644 --- a/AGENTS.md +++ b/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) diff --git a/backend/app.pid b/backend/app.pid index 640edf70..b262bc4c 100644 --- a/backend/app.pid +++ b/backend/app.pid @@ -1 +1 @@ -81964 +55545 diff --git a/backend/app/api/production.py b/backend/app/api/production.py index 437a9e7a..8c0936ae 100644 --- a/backend/app/api/production.py +++ b/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文档 # --------------------------------------------------------------------------- diff --git a/backend/app/crud/consumable.py b/backend/app/crud/consumable.py index a2f1e00c..5102a754 100644 --- a/backend/app/crud/consumable.py +++ b/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 diff --git a/backend/app/crud/consumable_stock_log.py b/backend/app/crud/consumable_stock_log.py new file mode 100644 index 00000000..068313dd --- /dev/null +++ b/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) diff --git a/backend/app/crud/production_task.py b/backend/app/crud/production_task.py index 6b2787e7..1545d2ef 100644 --- a/backend/app/crud/production_task.py +++ b/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( diff --git a/backend/app/crud/reagent.py b/backend/app/crud/reagent.py index 2f21b387..31424b40 100644 --- a/backend/app/crud/reagent.py +++ b/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 diff --git a/backend/app/crud/reagent_stock_log.py b/backend/app/crud/reagent_stock_log.py new file mode 100644 index 00000000..ede095da --- /dev/null +++ b/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) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 997501bc..292aabdf 100644 --- a/backend/app/models/models.py +++ b/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 diff --git a/docs/sql/production_material_inventory.sql b/docs/sql/production_material_inventory.sql new file mode 100644 index 00000000..e3d817c6 --- /dev/null +++ b/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='试剂变动记录'; diff --git a/docs/web-admin-ui-pattern-index.md b/docs/web-admin-ui-pattern-index.md new file mode 100644 index 00000000..db938d3e --- /dev/null +++ b/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 底栏 +- 状态列:`` + `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、标准列表 diff --git a/openspec/changes/production-material-inventory/.openspec.yaml b/openspec/changes/production-material-inventory/.openspec.yaml new file mode 100644 index 00000000..0f061698 --- /dev/null +++ b/openspec/changes/production-material-inventory/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-23 diff --git a/openspec/changes/production-material-inventory/design.md b/openspec/changes/production-material-inventory/design.md new file mode 100644 index 00000000..31db9d43 --- /dev/null +++ b/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 变动记录表,回滚代码 diff --git a/openspec/changes/production-material-inventory/proposal.md b/openspec/changes/production-material-inventory/proposal.md new file mode 100644 index 00000000..d665da96 --- /dev/null +++ b/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/` 新增迁移脚本 diff --git a/openspec/changes/production-material-inventory/specs/consumable-inventory/spec.md b/openspec/changes/production-material-inventory/specs/consumable-inventory/spec.md new file mode 100644 index 00000000..6a46755b --- /dev/null +++ b/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=新库存 diff --git a/openspec/changes/production-material-inventory/specs/material-stock-log-ui/spec.md b/openspec/changes/production-material-inventory/specs/material-stock-log-ui/spec.md new file mode 100644 index 00000000..bf92a41a --- /dev/null +++ b/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):入库/出库/步骤扣减,以 `` 展示 +- 变动数量(change_qty):正数绿色,负数红色 +- 变动前库存(stock_before) +- 变动后库存(stock_after) +- 来源(source_type):手动入库/手动出库/步骤保存,以 `` 展示 +- 关联任务(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 使用 `` 展示:启用显示 success 类型,停用显示 info 类型。 + +#### Scenario: Enabled status display +- **WHEN** 耗材状态为启用(status='1') +- **THEN** 列表显示绿色 `` 文本"启用" + +#### Scenario: Disabled status display +- **WHEN** 耗材状态为停用(status='0') +- **THEN** 列表显示灰色 `` 文本"停用" diff --git a/openspec/changes/production-material-inventory/specs/production-step-stock-deduction/spec.md b/openspec/changes/production-material-inventory/specs/production-step-stock-deduction/spec.md new file mode 100644 index 00000000..85024a02 --- /dev/null +++ b/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** 系统跳过该条目的库存扣减,其他正常条目正常扣减 diff --git a/openspec/changes/production-material-inventory/specs/reagent-inventory/spec.md b/openspec/changes/production-material-inventory/specs/reagent-inventory/spec.md new file mode 100644 index 00000000..6a851c2e --- /dev/null +++ b/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=新库存 diff --git a/openspec/changes/production-material-inventory/tasks.md b/openspec/changes/production-material-inventory/tasks.md new file mode 100644 index 00000000..9fe3a0b8 --- /dev/null +++ b/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 结算正确性 diff --git a/openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md b/openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md index e6481022..d1e1b3dc 100644 --- a/openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md +++ b/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 ``` -**多状态映射标准写法(使用辅助函数):** +**多状态映射标准写法(使用状态标签注册表):** ```html ``` ```typescript -function statusTagType(s: string): string { - const m: Record = { 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(() => [ { 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(() => [ ```html
- + {{ acceptConclusionLabel(row.acceptConclusion) }}
``` +```typescript +import { acceptConclusionLabel, acceptConclusionTagType } from '@/utils/status-tag-registry' +``` + #### 4. Tag 类型语义色映射规范 | Tag Type | 颜色 | 适用语义 | 示例 | @@ -655,9 +658,72 @@ const viewSections = computed(() => [ **对于启用/停用的状态字段**,SHALL 使用 `success`(启用)和 `info`(停用),SHALL NOT 使用 `danger` 表示停用状态。 -#### 5. 公共标签映射函数 +#### 5. 状态标签注册表(集中定义 label + tagType) -多个页面复用的状态标签映射 SHALL 抽取为公共工具函数(如 `utils/protocol-payment-labels.ts`、`utils/qc-labels.ts`),同时提供 `xxxLabel`(文本)和 `xxxTagType`(tag type)两个配套函数。仅单个页面使用的状态标签 SHALL 在页面 `