This commit is contained in:
zlwu committed 2026-05-23 12:51:22 +08:00
1 parent 3779de12ce
commit fb85230d25
25 files changed
+6003 -234

No files matched your search

+3 -1
View File
@@ -4,8 +4,10 @@
## 权威文档(必读路径)
- **Web 管理端(Fresh Mint)**:`docs/web-admin-ui-spec-fresh-mint.md`
- **Web 管理端(Fresh Mint)**:`docs/web-admin-ui-spec-fresh-mint.md`
适用范围:`web-admin`(Vue 3 + Element Plus + SCSS/Tailwind)。色值、布局、列表、弹窗、树形路径等均以该文档及其中引用的 `fresh-mint-green.html` 为准。
- **界面模式索引(选型 + 参考页)**:`docs/web-admin-ui-pattern-index.md`
新功能开发前按「功能特征 → 模式编号 P01–P27 → Hash/源码」选型;与 Demo 模板页、业务参考页对照。
- **小程序端(三墙 Demo 体系)**:`docs/miniprogram-ui-spec-sanqiang.md`
适用范围:`miniprogram`(客户端 / 供应商端 / 配送员端)。视觉以该文档及 `mobile-ui/sanqiang-*.html` 为准。
+1
View File
@@ -37,6 +37,7 @@ Full specs: `openspec/changes/zkxw-engineering-standards/specs/*/spec.md`. Below
- Product list + view dialog: `src/views/system/product/index.vue`
- Tree master data: `src/views/system/category/index.vue`
- User CRUD: `src/views/system/user/index.vue`
- **UI pattern index** — before implementing a new page, developers SHALL select a pattern code (P01–P27) per `docs/web-admin-ui-pattern-index.md` and OpenSpec `ui-conventions` §「界面模式索引与参考页选型」; PRs SHALL cite pattern code and reference page path.
### API Conventions (backend)
+1 -1
View File
@@ -1 +1 @@
81964
55545
+291
View File
@@ -9,6 +9,8 @@ from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
from app.core.response import success_response, error_response
from app.crud import consumable as consumable_svc
from app.crud import reagent as reagent_svc
from app.crud import consumable_stock_log as consumable_stock_log_svc
from app.crud import reagent_stock_log as reagent_stock_log_svc
from app.crud import sop_document as sop_document_svc
from app.crud import process_category as process_category_svc
from app.crud import output_type as output_type_svc
@@ -178,6 +180,171 @@ async def delete_consumable(
return error_response(400, str(e))
class StockInBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
qty: float = Field(..., gt=0)
remark: Optional[str] = None
class StockOutBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
qty: float = Field(..., gt=0)
receiver: str = Field(..., min_length=1)
purpose: Optional[str] = None
remark: Optional[str] = None
class BatchStockInItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
materialId: str
qty: float = Field(..., gt=0)
remark: Optional[str] = None
class BatchStockInBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
items: List[BatchStockInItem]
remark: Optional[str] = None
class BatchStockOutItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
materialId: str
qty: float = Field(..., gt=0)
class BatchStockOutBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
items: List[BatchStockOutItem]
receiver: str = Field(..., min_length=1)
purpose: Optional[str] = None
remark: Optional[str] = None
@router.post("/consumables/{consumable_id}/stock-in")
async def consumable_stock_in(
consumable_id: str,
body: StockInBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
from decimal import Decimal
obj = consumable_svc.stock_in(
db, consumable_id,
qty=Decimal(str(body.qty)),
remark=body.remark,
user_id=user.get("user_id"),
tenant_no=tenant_no,
)
return success_response(consumable_svc._row_to_dict(obj), message="入库成功")
except ValueError as e:
return error_response(400, str(e))
@router.post("/consumables/{consumable_id}/stock-out")
async def consumable_stock_out(
consumable_id: str,
body: StockOutBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
from decimal import Decimal
obj = consumable_svc.stock_out(
db, consumable_id,
qty=Decimal(str(body.qty)),
receiver=body.receiver,
purpose=body.purpose,
remark=body.remark,
user_id=user.get("user_id"),
tenant_no=tenant_no,
)
return success_response(consumable_svc._row_to_dict(obj), message="出库成功")
except ValueError as e:
return error_response(400, str(e))
@router.post("/consumables/batch-stock-in")
async def consumable_batch_stock_in(
body: BatchStockInBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
items_data = [it.model_dump() for it in body.items]
count = consumable_svc.batch_stock_in(
db, items=items_data, remark=body.remark,
tenant_no=tenant_no, user_id=user.get("user_id"),
)
return success_response({"count": count}, message=f"成功入库 {count} 条")
except ValueError as e:
return error_response(400, str(e))
@router.post("/consumables/batch-stock-out")
async def consumable_batch_stock_out(
body: BatchStockOutBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
items_data = [it.model_dump() for it in body.items]
count = consumable_svc.batch_stock_out(
db, items=items_data,
receiver=body.receiver, purpose=body.purpose, remark=body.remark,
tenant_no=tenant_no, user_id=user.get("user_id"),
)
return success_response({"count": count}, message=f"成功出库 {count} 条")
except ValueError as e:
return error_response(400, str(e))
@router.get("/consumables/{consumable_id}/stock-logs")
async def list_consumable_stock_logs(
consumable_id: str,
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
changeType: Optional[str] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items, total = consumable_stock_log_svc.list_stock_logs(
db, tenant_no, page=page, page_size=pageSize,
material_id=consumable_id, change_type=changeType,
user_info=user,
)
tp = (total + pageSize - 1) // pageSize if total > 0 else 0
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
@router.get("/consumables/stock-logs")
async def list_all_consumable_stock_logs(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
changeType: Optional[str] = None,
startTime: Optional[str] = None,
endTime: Optional[str] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items, total = consumable_stock_log_svc.list_stock_logs(
db, tenant_no, page=page, page_size=pageSize,
keyword=keyword, change_type=changeType,
start_time=startTime, end_time=endTime,
user_info=user,
)
tp = (total + pageSize - 1) // pageSize if total > 0 else 0
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
# ---------------------------------------------------------------------------
# 2. 试剂
# ---------------------------------------------------------------------------
@@ -297,6 +464,130 @@ async def delete_reagent(
return error_response(400, str(e))
@router.post("/reagents/{reagent_id}/stock-in")
async def reagent_stock_in(
reagent_id: str,
body: StockInBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
from decimal import Decimal
obj = reagent_svc.stock_in(
db, reagent_id,
qty=Decimal(str(body.qty)),
remark=body.remark,
user_id=user.get("user_id"),
tenant_no=tenant_no,
)
return success_response(reagent_svc._row_to_dict(obj), message="入库成功")
except ValueError as e:
return error_response(400, str(e))
@router.post("/reagents/{reagent_id}/stock-out")
async def reagent_stock_out(
reagent_id: str,
body: StockOutBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
from decimal import Decimal
obj = reagent_svc.stock_out(
db, reagent_id,
qty=Decimal(str(body.qty)),
receiver=body.receiver,
purpose=body.purpose,
remark=body.remark,
user_id=user.get("user_id"),
tenant_no=tenant_no,
)
return success_response(reagent_svc._row_to_dict(obj), message="出库成功")
except ValueError as e:
return error_response(400, str(e))
@router.post("/reagents/batch-stock-in")
async def reagent_batch_stock_in(
body: BatchStockInBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
items_data = [it.model_dump() for it in body.items]
count = reagent_svc.batch_stock_in(
db, items=items_data, remark=body.remark,
tenant_no=tenant_no, user_id=user.get("user_id"),
)
return success_response({"count": count}, message=f"成功入库 {count} 条")
except ValueError as e:
return error_response(400, str(e))
@router.post("/reagents/batch-stock-out")
async def reagent_batch_stock_out(
body: BatchStockOutBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
items_data = [it.model_dump() for it in body.items]
count = reagent_svc.batch_stock_out(
db, items=items_data,
receiver=body.receiver, purpose=body.purpose, remark=body.remark,
tenant_no=tenant_no, user_id=user.get("user_id"),
)
return success_response({"count": count}, message=f"成功出库 {count} 条")
except ValueError as e:
return error_response(400, str(e))
@router.get("/reagents/{reagent_id}/stock-logs")
async def list_reagent_stock_logs(
reagent_id: str,
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
changeType: Optional[str] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items, total = reagent_stock_log_svc.list_stock_logs(
db, tenant_no, page=page, page_size=pageSize,
material_id=reagent_id, change_type=changeType,
user_info=user,
)
tp = (total + pageSize - 1) // pageSize if total > 0 else 0
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
@router.get("/reagents/stock-logs")
async def list_all_reagent_stock_logs(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
changeType: Optional[str] = None,
startTime: Optional[str] = None,
endTime: Optional[str] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items, total = reagent_stock_log_svc.list_stock_logs(
db, tenant_no, page=page, page_size=pageSize,
keyword=keyword, change_type=changeType,
start_time=startTime, end_time=endTime,
user_info=user,
)
tp = (total + pageSize - 1) // pageSize if total > 0 else 0
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
# ---------------------------------------------------------------------------
# 3. SOP文档
# ---------------------------------------------------------------------------
+343 -1
View File
@@ -3,13 +3,40 @@ from __future__ import annotations
import math
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import BizConsumable
from app.models.models import BizConsumable, BizConsumableStockLog
def _dec(v: Any) -> float:
if v is None:
return 0.0
return float(v)
def _norm_status(v: Any) -> str:
if v is None:
return "1"
if isinstance(v, bool):
return "1" if v else "0"
if isinstance(v, (int, float)):
if int(v) == 0:
return "0"
if int(v) == 1:
return "1"
s = str(v).strip()
if s in ("0", "1"):
return s
return "1"
def _is_enabled_status(v: Any) -> bool:
return _norm_status(v) == "1"
def _row_to_dict(c: BizConsumable) -> Dict[str, Any]:
@@ -21,6 +48,9 @@ def _row_to_dict(c: BizConsumable) -> Dict[str, Any]:
"standardUsage": c.standard_usage or "",
"unit": c.unit or "",
"description": c.description or "",
"stockQty": _dec(c.stock_qty),
"warningQty": _dec(c.warning_qty),
"status": _norm_status(c.status),
"tenantNo": c.tenant_no,
"orderNum": c.order_num if c.order_num is not None else 0,
"remark": c.remark or "",
@@ -179,12 +209,324 @@ def soft_delete_consumable(db: Session, consumable_id: str) -> None:
obj = get_consumable(db, consumable_id)
if not obj:
raise ValueError("耗材不存在")
if obj.stock_qty is not None and _dec(obj.stock_qty) > 0:
raise ValueError("该耗材仍有库存,无法删除")
obj.del_flag = 1
obj.update_time = datetime.now()
db.add(obj)
db.commit()
def _build_manual_out_remark(
*,
receiver: Optional[str] = None,
purpose: Optional[str] = None,
remark: Optional[str] = None,
line_remark: Optional[str] = None,
) -> Optional[str]:
parts: List[str] = []
r = (receiver or "").strip()
if r:
parts.append(f"领用人:{r}")
p = (purpose or "").strip()
if p:
parts.append(f"用途:{p}")
extra = (line_remark or remark or "").strip()
if extra:
parts.append(extra)
return ";".join(parts) if parts else None
def stock_in(
db: Session,
row_id: str,
*,
qty: Decimal,
remark: Optional[str] = None,
user_id: Optional[str] = None,
tenant_no: Optional[str] = None,
operate_time: Optional[datetime] = None,
) -> BizConsumable:
if qty is None or float(qty) <= 0:
raise ValueError("入库数量必须大于0")
obj = (
db.query(BizConsumable)
.filter(BizConsumable.id == row_id, BizConsumable.del_flag == 0)
.with_for_update()
.first()
)
if not obj:
raise ValueError("耗材不存在")
if tenant_no and obj.tenant_no != tenant_no:
raise ValueError("耗材不存在")
if not _is_enabled_status(obj.status):
raise ValueError(f"耗材「{obj.consumable_name}」已停用,无法入库")
op_time = operate_time or datetime.now()
old_stock = obj.stock_qty or 0
new_stock = old_stock + qty
obj.stock_qty = new_stock
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
log = BizConsumableStockLog(
id=generate_uuid(),
material_id=obj.id,
material_code=obj.consumable_code,
material_name=obj.consumable_name,
change_type="in",
change_qty=qty,
stock_before=old_stock,
stock_after=new_stock,
source_type="manual_in",
tenant_no=obj.tenant_no,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id=obj.create_dept_id or "",
create_time=op_time,
update_by=user_id,
update_time=op_time,
del_flag=0,
)
db.add(log)
db.commit()
db.refresh(obj)
return obj
def batch_stock_in(
db: Session,
*,
items: List[Dict[str, Any]],
remark: Optional[str] = None,
tenant_no: str,
user_id: Optional[str] = None,
operate_time: Optional[datetime] = None,
) -> int:
if not items:
raise ValueError("请至少选择一条耗材")
op_time = operate_time or datetime.now()
shared_remark = (remark or "").strip() or None
try:
for it in items:
material_id = (it.get("material_id") or it.get("materialId") or "").strip()
qty = it.get("qty")
if not material_id:
raise ValueError("耗材ID不能为空")
if qty is None or float(qty) <= 0:
raise ValueError("入库数量必须大于0")
qty_dec = Decimal(str(qty))
line_remark = (it.get("remark") or "").strip()
log_remark = line_remark or shared_remark or None
obj = (
db.query(BizConsumable)
.filter(
BizConsumable.id == material_id,
BizConsumable.tenant_no == tenant_no,
BizConsumable.del_flag == 0,
)
.with_for_update()
.first()
)
if not obj:
raise ValueError("耗材不存在")
if not _is_enabled_status(obj.status):
raise ValueError(f"耗材「{obj.consumable_name}」已停用,无法入库")
old_stock = obj.stock_qty or 0
new_stock = old_stock + qty_dec
obj.stock_qty = new_stock
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
log = BizConsumableStockLog(
id=generate_uuid(),
material_id=obj.id,
material_code=obj.consumable_code,
material_name=obj.consumable_name,
change_type="in",
change_qty=qty_dec,
stock_before=old_stock,
stock_after=new_stock,
source_type="manual_in",
tenant_no=obj.tenant_no,
remark=log_remark,
create_by=user_id,
create_dept_id=obj.create_dept_id or "",
create_time=op_time,
update_by=user_id,
update_time=op_time,
del_flag=0,
)
db.add(log)
db.commit()
return len(items)
except Exception:
db.rollback()
raise
def stock_out(
db: Session,
row_id: str,
*,
qty: Decimal,
receiver: Optional[str] = None,
purpose: Optional[str] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
tenant_no: Optional[str] = None,
operate_time: Optional[datetime] = None,
) -> BizConsumable:
if qty is None or float(qty) <= 0:
raise ValueError("出库数量必须大于0")
qty_dec = Decimal(str(qty))
if not (receiver or "").strip():
raise ValueError("请选择领用人")
obj = (
db.query(BizConsumable)
.filter(BizConsumable.id == row_id, BizConsumable.del_flag == 0)
.with_for_update()
.first()
)
if not obj:
raise ValueError("耗材不存在")
if tenant_no and obj.tenant_no != tenant_no:
raise ValueError("耗材不存在")
if not _is_enabled_status(obj.status):
raise ValueError(f"耗材「{obj.consumable_name}」已停用,无法出库")
stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
if stock_before < qty_dec:
raise ValueError(
f"耗材「{obj.consumable_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
)
op_time = operate_time or datetime.now()
new_stock = stock_before - qty_dec
obj.stock_qty = new_stock
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
log = BizConsumableStockLog(
id=generate_uuid(),
material_id=obj.id,
material_code=obj.consumable_code,
material_name=obj.consumable_name,
change_type="use",
change_qty=-qty_dec,
stock_before=stock_before,
stock_after=new_stock,
source_type="manual_out",
tenant_no=obj.tenant_no,
receiver=(receiver or "").strip() or None,
purpose=(purpose or "").strip() or None,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id=obj.create_dept_id or "",
create_time=op_time,
update_by=user_id,
update_time=op_time,
del_flag=0,
)
db.add(log)
db.commit()
db.refresh(obj)
return obj
def batch_stock_out(
db: Session,
*,
items: List[Dict[str, Any]],
receiver: Optional[str] = None,
purpose: Optional[str] = None,
remark: Optional[str] = None,
tenant_no: str,
user_id: Optional[str] = None,
operate_time: Optional[datetime] = None,
) -> int:
if not items:
raise ValueError("请至少选择一条耗材")
if not (receiver or "").strip():
raise ValueError("请选择领用人")
op_time = operate_time or datetime.now()
recv = (receiver or "").strip() or None
purp = (purpose or "").strip() or None
shared_remark = (remark or "").strip() or None
try:
for it in items:
material_id = (it.get("material_id") or it.get("materialId") or "").strip()
qty = it.get("qty")
if not material_id:
raise ValueError("耗材ID不能为空")
if qty is None or float(qty) <= 0:
raise ValueError("出库数量必须大于0")
qty_dec = Decimal(str(qty))
line_remark = (it.get("remark") or "").strip() or shared_remark
obj = (
db.query(BizConsumable)
.filter(
BizConsumable.id == material_id,
BizConsumable.tenant_no == tenant_no,
BizConsumable.del_flag == 0,
)
.with_for_update()
.first()
)
if not obj:
raise ValueError("耗材不存在")
if not _is_enabled_status(obj.status):
raise ValueError(f"耗材「{obj.consumable_name}」已停用,无法出库")
stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
if stock_before < qty_dec:
raise ValueError(
f"耗材「{obj.consumable_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
)
new_stock = stock_before - qty_dec
obj.stock_qty = new_stock
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
log = BizConsumableStockLog(
id=generate_uuid(),
material_id=obj.id,
material_code=obj.consumable_code,
material_name=obj.consumable_name,
change_type="use",
change_qty=-qty_dec,
stock_before=stock_before,
stock_after=new_stock,
source_type="manual_out",
tenant_no=obj.tenant_no,
receiver=recv,
purpose=purp,
remark=line_remark,
create_by=user_id,
create_dept_id=obj.create_dept_id or "",
create_time=op_time,
update_by=user_id,
update_time=op_time,
del_flag=0,
)
db.add(log)
db.commit()
return len(items)
except Exception:
db.rollback()
raise
def total_pages(total: int, page_size: int) -> int:
if total <= 0:
return 0
+279
View File
@@ -0,0 +1,279 @@
"""耗材变动记录 CRUD(biz_consumable_stock_log)"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import BizConsumable, BizConsumableStockLog, SysUser
def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
ids = list({x for x in user_ids if x})
if not ids:
return {}
rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
return {u.id: (u.nick_name or "").strip() for u in rows}
def _parse_receiver_purpose_from_remark(remark: Optional[str]) -> Tuple[str, str]:
receiver, purpose = "", ""
for part in (remark or "").split(";"):
seg = part.strip()
if seg.startswith("领用人:"):
receiver = seg[len("领用人:") :].strip()
elif seg.startswith("用途:"):
purpose = seg[len("用途:") :].strip()
return receiver, purpose
def _resolve_receiver_purpose(r: BizConsumableStockLog) -> Tuple[str, str]:
receiver = (getattr(r, "receiver", None) or "").strip()
purpose = (getattr(r, "purpose", None) or "").strip()
if receiver or purpose:
return receiver, purpose
if (r.source_type or "") == "manual_out":
return _parse_receiver_purpose_from_remark(r.remark)
return "", ""
def _display_remark(r: BizConsumableStockLog) -> str:
remark = (r.remark or "").strip()
if (r.source_type or "") != "manual_out" or not remark:
return remark
parts: List[str] = []
for part in remark.split(";"):
seg = part.strip()
if not seg or seg.startswith("领用人:") or seg.startswith("用途:"):
continue
if seg.startswith("备注"):
seg = seg[2:].strip() or seg
parts.append(seg)
return ";".join(parts) if parts else ""
def _row_to_dict(r: BizConsumableStockLog, *, create_by_name: str = "") -> Dict[str, Any]:
create_by = r.create_by or ""
receiver, purpose = _resolve_receiver_purpose(r)
create_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
is_manual_out = (r.source_type or "") == "manual_out"
return {
"id": r.id,
"materialId": r.material_id,
"materialCode": r.material_code or "",
"materialName": r.material_name or "",
"changeType": r.change_type,
"changeQty": float(r.change_qty) if r.change_qty is not None else 0,
"stockBefore": float(r.stock_before) if r.stock_before is not None else 0,
"stockAfter": float(r.stock_after) if r.stock_after is not None else 0,
"stepId": r.step_id or "",
"taskId": r.task_id or "",
"taskNo": r.task_no or "",
"receiver": receiver,
"purpose": purpose,
"sourceType": r.source_type or "",
"useTime": create_time if is_manual_out else "",
"remark": _display_remark(r),
"createBy": create_by,
"createByName": create_by_name or create_by,
"createTime": create_time,
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
}
def list_stock_logs(
db: Session,
tenant_no: str,
*,
page: int = 1,
page_size: int = 10,
material_id: Optional[str] = None,
change_type: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
keyword: Optional[str] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
def _filtered():
q0 = db.query(BizConsumableStockLog).filter(
BizConsumableStockLog.tenant_no == tenant_no,
BizConsumableStockLog.del_flag == 0,
)
if material_id:
q0 = q0.filter(BizConsumableStockLog.material_id == material_id)
ct = (change_type or "").strip()
if ct == "out":
q0 = q0.filter(
BizConsumableStockLog.change_type == "use",
BizConsumableStockLog.source_type == "manual_out",
)
elif ct == "use":
q0 = q0.filter(
BizConsumableStockLog.change_type == "use",
BizConsumableStockLog.source_type == "step_save",
)
elif ct == "in":
q0 = q0.filter(BizConsumableStockLog.change_type == "in")
if start_time:
q0 = q0.filter(BizConsumableStockLog.create_time >= start_time)
if end_time:
q0 = q0.filter(BizConsumableStockLog.create_time <= end_time + " 23:59:59")
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q0 = q0.filter(
or_(
BizConsumableStockLog.material_code.like(like),
BizConsumableStockLog.material_name.like(like),
)
)
if user_info:
from app.core.data_scope import apply_data_scope
q0 = apply_data_scope(q0, BizConsumableStockLog, user_info, db, tenant_no)
return q0
total = _filtered().order_by(None).with_entities(func.count(BizConsumableStockLog.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
_filtered()
.order_by(BizConsumableStockLog.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
return [
_row_to_dict(x, create_by_name=name_map.get(x.create_by or "", ""))
for x in rows
], int(total)
def revert_step_stock_changes(
db: Session,
step_id: str,
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
"""撤销步骤此前 step_save 扣减,便于重新填报。"""
rows = (
db.query(BizConsumableStockLog)
.filter(
BizConsumableStockLog.step_id == step_id,
BizConsumableStockLog.tenant_no == tenant_no,
BizConsumableStockLog.del_flag == 0,
BizConsumableStockLog.source_type == "step_save",
BizConsumableStockLog.change_type == "use",
)
.all()
)
if not rows:
return
now = datetime.now()
for r in rows:
restore_qty = abs(r.change_qty) if r.change_qty is not None else Decimal("0")
if restore_qty <= 0:
r.del_flag = 1
r.update_by = user_id
r.update_time = now
continue
material = (
db.query(BizConsumable)
.with_for_update()
.filter(
BizConsumable.id == r.material_id,
BizConsumable.tenant_no == tenant_no,
BizConsumable.del_flag == 0,
)
.first()
)
if material:
material.stock_qty = (material.stock_qty or Decimal("0")) + restore_qty
material.update_by = user_id
material.update_time = now
r.del_flag = 1
r.update_by = user_id
r.update_time = now
def deduct_for_step(
db: Session,
step_id: str,
task_id: str,
task_no: str,
consumable_items: List[Dict[str, Any]],
tenant_no: str,
user_id: Optional[str] = None,
dept_id: str = "",
) -> None:
"""扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。"""
for item in consumable_items:
code = (item.get("consumable_code") or "").strip()
actual_usage = item.get("actual_usage")
if not code:
continue
# 跳过空/零/非数字的 actual_usage
if actual_usage is None or str(actual_usage).strip() == "":
continue
try:
usage_val = Decimal(str(actual_usage))
except Exception:
continue
if usage_val <= 0:
continue
# 按 consumable_code 查找耗材(跳过目录中不存在的自定义材料)
material = (
db.query(BizConsumable)
.with_for_update()
.filter(
BizConsumable.consumable_code == code,
BizConsumable.tenant_no == tenant_no,
BizConsumable.del_flag == 0,
BizConsumable.status == "1",
)
.first()
)
if not material:
continue
stock_before = material.stock_qty if material.stock_qty is not None else Decimal("0")
if stock_before < usage_val:
raise HTTPException(
status_code=400,
detail=f"耗材「{material.consumable_name}」库存不足,当前库存 {stock_before},需使用 {usage_val}",
)
new_stock = stock_before - usage_val
material.stock_qty = new_stock
material.update_by = user_id
material.update_time = datetime.now()
log = BizConsumableStockLog(
id=generate_uuid(),
material_id=material.id,
material_code=material.consumable_code,
material_name=material.consumable_name,
change_type="use",
change_qty=-usage_val,
stock_before=stock_before,
stock_after=new_stock,
source_type="step_save",
step_id=step_id,
task_id=task_id,
task_no=task_no,
tenant_no=tenant_no,
remark=str(item.get("remark") or "").strip()[:512] or None,
create_by=user_id,
create_dept_id=dept_id,
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
)
db.add(log)
+90 -32
View File
@@ -833,22 +833,51 @@ def replace_step_reagents(
user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
now = datetime.now()
_soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step_id, now)
for idx, it in enumerate(items):
db.add(BizProductionTaskStepReagent(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
reagent_code=(it.get("reagent_code") or "").strip() or None,
reagent_name=(it.get("reagent_name") or "").strip() or None,
spec=(it.get("spec") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
is_key_material=it.get("is_key_material", "0"),
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
db.commit()
# Look up task info from step_id for stock log
step_row = db.query(BizProductionTaskStep).filter(
and_(BizProductionTaskStep.id == step_id, BizProductionTaskStep.del_flag == 0)
).first()
task_id = ""
task_no = ""
if step_row:
task_row = db.query(BizProductionTask).filter(
and_(BizProductionTask.id == step_row.task_id, BizProductionTask.del_flag == 0)
).first()
if task_row:
task_id = task_row.id
task_no = task_row.task_no or ""
try:
_soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step_id, now)
# Revert previous step_save stock deductions
from app.crud import reagent_stock_log as reagent_stock_log_crud
reagent_stock_log_crud.revert_step_stock_changes(db, step_id, tenant_no, user_id)
for idx, it in enumerate(items):
db.add(BizProductionTaskStepReagent(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
reagent_code=(it.get("reagent_code") or "").strip() or None,
reagent_name=(it.get("reagent_name") or "").strip() or None,
spec=(it.get("spec") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
is_key_material=it.get("is_key_material", "0"),
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
# Deduct stock based on actual_usage
reagent_stock_log_crud.deduct_for_step(
db, step_id, task_id, task_no, items, tenant_no, user_id, "",
)
db.commit()
except Exception:
db.rollback()
raise
def replace_step_consumables(
@@ -856,22 +885,51 @@ def replace_step_consumables(
user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
now = datetime.now()
_soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step_id, now)
for idx, it in enumerate(items):
db.add(BizProductionTaskStepConsumable(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
consumable_code=(it.get("consumable_code") or "").strip() or None,
consumable_name=(it.get("consumable_name") or "").strip() or None,
spec_model=(it.get("spec_model") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
is_required=it.get("is_required", "1"),
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
db.commit()
# Look up task info from step_id for stock log
step_row = db.query(BizProductionTaskStep).filter(
and_(BizProductionTaskStep.id == step_id, BizProductionTaskStep.del_flag == 0)
).first()
task_id = ""
task_no = ""
if step_row:
task_row = db.query(BizProductionTask).filter(
and_(BizProductionTask.id == step_row.task_id, BizProductionTask.del_flag == 0)
).first()
if task_row:
task_id = task_row.id
task_no = task_row.task_no or ""
try:
_soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step_id, now)
# Revert previous step_save stock deductions
from app.crud import consumable_stock_log as consumable_stock_log_crud
consumable_stock_log_crud.revert_step_stock_changes(db, step_id, tenant_no, user_id)
for idx, it in enumerate(items):
db.add(BizProductionTaskStepConsumable(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
consumable_code=(it.get("consumable_code") or "").strip() or None,
consumable_name=(it.get("consumable_name") or "").strip() or None,
spec_model=(it.get("spec_model") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
is_required=it.get("is_required", "1"),
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
# Deduct stock based on actual_usage
consumable_stock_log_crud.deduct_for_step(
db, step_id, task_id, task_no, items, tenant_no, user_id, "",
)
db.commit()
except Exception:
db.rollback()
raise
def replace_step_outputs(
+323 -1
View File
@@ -3,13 +3,40 @@ from __future__ import annotations
import math
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import BizReagent
from app.models.models import BizReagent, BizReagentStockLog
def _dec(v: Any) -> float:
if v is None:
return 0.0
return float(v)
def _norm_status(v: Any) -> str:
if v is None:
return "1"
if isinstance(v, bool):
return "1" if v else "0"
if isinstance(v, (int, float)):
if int(v) == 0:
return "0"
if int(v) == 1:
return "1"
s = str(v).strip()
if s in ("0", "1"):
return s
return "1"
def _is_enabled_status(v: Any) -> bool:
return _norm_status(v) == "1"
def _row_to_dict(r: BizReagent) -> Dict[str, Any]:
@@ -21,6 +48,9 @@ def _row_to_dict(r: BizReagent) -> Dict[str, Any]:
"standardUsage": r.standard_usage or "",
"unit": r.unit or "",
"description": r.description or "",
"stockQty": _dec(r.stock_qty),
"warningQty": _dec(r.warning_qty),
"status": _norm_status(r.status),
"tenantNo": r.tenant_no,
"orderNum": r.order_num if r.order_num is not None else 0,
"remark": r.remark or "",
@@ -179,12 +209,304 @@ def soft_delete_reagent(db: Session, reagent_id: str) -> None:
obj = get_reagent(db, reagent_id)
if not obj:
raise ValueError("试剂不存在")
if obj.stock_qty is not None and _dec(obj.stock_qty) > 0:
raise ValueError("该试剂仍有库存,无法删除")
obj.del_flag = 1
obj.update_time = datetime.now()
db.add(obj)
db.commit()
def stock_in(
db: Session,
row_id: str,
*,
qty: Decimal,
remark: Optional[str] = None,
user_id: Optional[str] = None,
tenant_no: Optional[str] = None,
operate_time: Optional[datetime] = None,
) -> BizReagent:
if qty is None or float(qty) <= 0:
raise ValueError("入库数量必须大于0")
obj = (
db.query(BizReagent)
.filter(BizReagent.id == row_id, BizReagent.del_flag == 0)
.with_for_update()
.first()
)
if not obj:
raise ValueError("试剂不存在")
if tenant_no and obj.tenant_no != tenant_no:
raise ValueError("试剂不存在")
if not _is_enabled_status(obj.status):
raise ValueError(f"试剂「{obj.reagent_name}」已停用,无法入库")
op_time = operate_time or datetime.now()
old_stock = obj.stock_qty or 0
new_stock = old_stock + qty
obj.stock_qty = new_stock
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
log = BizReagentStockLog(
id=generate_uuid(),
material_id=obj.id,
material_code=obj.reagent_code,
material_name=obj.reagent_name,
change_type="in",
change_qty=qty,
stock_before=old_stock,
stock_after=new_stock,
source_type="manual_in",
tenant_no=obj.tenant_no,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id=obj.create_dept_id or "",
create_time=op_time,
update_by=user_id,
update_time=op_time,
del_flag=0,
)
db.add(log)
db.commit()
db.refresh(obj)
return obj
def batch_stock_in(
db: Session,
*,
items: List[Dict[str, Any]],
remark: Optional[str] = None,
tenant_no: str,
user_id: Optional[str] = None,
operate_time: Optional[datetime] = None,
) -> int:
if not items:
raise ValueError("请至少选择一条试剂")
op_time = operate_time or datetime.now()
shared_remark = (remark or "").strip() or None
try:
for it in items:
material_id = (it.get("material_id") or it.get("materialId") or "").strip()
qty = it.get("qty")
if not material_id:
raise ValueError("试剂ID不能为空")
if qty is None or float(qty) <= 0:
raise ValueError("入库数量必须大于0")
qty_dec = Decimal(str(qty))
line_remark = (it.get("remark") or "").strip()
log_remark = line_remark or shared_remark or None
obj = (
db.query(BizReagent)
.filter(
BizReagent.id == material_id,
BizReagent.tenant_no == tenant_no,
BizReagent.del_flag == 0,
)
.with_for_update()
.first()
)
if not obj:
raise ValueError("试剂不存在")
if not _is_enabled_status(obj.status):
raise ValueError(f"试剂「{obj.reagent_name}」已停用,无法入库")
old_stock = obj.stock_qty or 0
new_stock = old_stock + qty_dec
obj.stock_qty = new_stock
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
log = BizReagentStockLog(
id=generate_uuid(),
material_id=obj.id,
material_code=obj.reagent_code,
material_name=obj.reagent_name,
change_type="in",
change_qty=qty_dec,
stock_before=old_stock,
stock_after=new_stock,
source_type="manual_in",
tenant_no=obj.tenant_no,
remark=log_remark,
create_by=user_id,
create_dept_id=obj.create_dept_id or "",
create_time=op_time,
update_by=user_id,
update_time=op_time,
del_flag=0,
)
db.add(log)
db.commit()
return len(items)
except Exception:
db.rollback()
raise
def stock_out(
db: Session,
row_id: str,
*,
qty: Decimal,
receiver: Optional[str] = None,
purpose: Optional[str] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
tenant_no: Optional[str] = None,
operate_time: Optional[datetime] = None,
) -> BizReagent:
if qty is None or float(qty) <= 0:
raise ValueError("出库数量必须大于0")
qty_dec = Decimal(str(qty))
if not (receiver or "").strip():
raise ValueError("请选择领用人")
obj = (
db.query(BizReagent)
.filter(BizReagent.id == row_id, BizReagent.del_flag == 0)
.with_for_update()
.first()
)
if not obj:
raise ValueError("试剂不存在")
if tenant_no and obj.tenant_no != tenant_no:
raise ValueError("试剂不存在")
if not _is_enabled_status(obj.status):
raise ValueError(f"试剂「{obj.reagent_name}」已停用,无法出库")
stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
if stock_before < qty_dec:
raise ValueError(
f"试剂「{obj.reagent_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
)
op_time = operate_time or datetime.now()
new_stock = stock_before - qty_dec
obj.stock_qty = new_stock
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
log = BizReagentStockLog(
id=generate_uuid(),
material_id=obj.id,
material_code=obj.reagent_code,
material_name=obj.reagent_name,
change_type="use",
change_qty=-qty_dec,
stock_before=stock_before,
stock_after=new_stock,
source_type="manual_out",
tenant_no=obj.tenant_no,
receiver=(receiver or "").strip() or None,
purpose=(purpose or "").strip() or None,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id=obj.create_dept_id or "",
create_time=op_time,
update_by=user_id,
update_time=op_time,
del_flag=0,
)
db.add(log)
db.commit()
db.refresh(obj)
return obj
def batch_stock_out(
db: Session,
*,
items: List[Dict[str, Any]],
receiver: Optional[str] = None,
purpose: Optional[str] = None,
remark: Optional[str] = None,
tenant_no: str,
user_id: Optional[str] = None,
operate_time: Optional[datetime] = None,
) -> int:
if not items:
raise ValueError("请至少选择一条试剂")
if not (receiver or "").strip():
raise ValueError("请选择领用人")
op_time = operate_time or datetime.now()
recv = (receiver or "").strip() or None
purp = (purpose or "").strip() or None
shared_remark = (remark or "").strip() or None
try:
for it in items:
material_id = (it.get("material_id") or it.get("materialId") or "").strip()
qty = it.get("qty")
if not material_id:
raise ValueError("试剂ID不能为空")
if qty is None or float(qty) <= 0:
raise ValueError("出库数量必须大于0")
qty_dec = Decimal(str(qty))
line_remark = (it.get("remark") or "").strip() or shared_remark
obj = (
db.query(BizReagent)
.filter(
BizReagent.id == material_id,
BizReagent.tenant_no == tenant_no,
BizReagent.del_flag == 0,
)
.with_for_update()
.first()
)
if not obj:
raise ValueError("试剂不存在")
if not _is_enabled_status(obj.status):
raise ValueError(f"试剂「{obj.reagent_name}」已停用,无法出库")
stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
if stock_before < qty_dec:
raise ValueError(
f"试剂「{obj.reagent_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
)
new_stock = stock_before - qty_dec
obj.stock_qty = new_stock
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
log = BizReagentStockLog(
id=generate_uuid(),
material_id=obj.id,
material_code=obj.reagent_code,
material_name=obj.reagent_name,
change_type="use",
change_qty=-qty_dec,
stock_before=stock_before,
stock_after=new_stock,
source_type="manual_out",
tenant_no=obj.tenant_no,
receiver=recv,
purpose=purp,
remark=line_remark,
create_by=user_id,
create_dept_id=obj.create_dept_id or "",
create_time=op_time,
update_by=user_id,
update_time=op_time,
del_flag=0,
)
db.add(log)
db.commit()
return len(items)
except Exception:
db.rollback()
raise
def total_pages(total: int, page_size: int) -> int:
if total <= 0:
return 0
+278
View File
@@ -0,0 +1,278 @@
"""试剂变动记录 CRUD(biz_reagent_stock_log)"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import BizReagent, BizReagentStockLog, SysUser
def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
ids = list({x for x in user_ids if x})
if not ids:
return {}
rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
return {u.id: (u.nick_name or "").strip() for u in rows}
def _parse_receiver_purpose_from_remark(remark: Optional[str]) -> Tuple[str, str]:
receiver, purpose = "", ""
for part in (remark or "").split(";"):
seg = part.strip()
if seg.startswith("领用人:"):
receiver = seg[len("领用人:") :].strip()
elif seg.startswith("用途:"):
purpose = seg[len("用途:") :].strip()
return receiver, purpose
def _resolve_receiver_purpose(r: BizReagentStockLog) -> Tuple[str, str]:
receiver = (getattr(r, "receiver", None) or "").strip()
purpose = (getattr(r, "purpose", None) or "").strip()
if receiver or purpose:
return receiver, purpose
if (r.source_type or "") == "manual_out":
return _parse_receiver_purpose_from_remark(r.remark)
return "", ""
def _display_remark(r: BizReagentStockLog) -> str:
remark = (r.remark or "").strip()
if (r.source_type or "") != "manual_out" or not remark:
return remark
parts: List[str] = []
for part in remark.split(";"):
seg = part.strip()
if not seg or seg.startswith("领用人:") or seg.startswith("用途:"):
continue
if seg.startswith("备注"):
seg = seg[2:].strip() or seg
parts.append(seg)
return ";".join(parts) if parts else ""
def _row_to_dict(r: BizReagentStockLog, *, create_by_name: str = "") -> Dict[str, Any]:
create_by = r.create_by or ""
receiver, purpose = _resolve_receiver_purpose(r)
create_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
is_manual_out = (r.source_type or "") == "manual_out"
return {
"id": r.id,
"materialId": r.material_id,
"materialCode": r.material_code or "",
"materialName": r.material_name or "",
"changeType": r.change_type,
"changeQty": float(r.change_qty) if r.change_qty is not None else 0,
"stockBefore": float(r.stock_before) if r.stock_before is not None else 0,
"stockAfter": float(r.stock_after) if r.stock_after is not None else 0,
"stepId": r.step_id or "",
"taskId": r.task_id or "",
"taskNo": r.task_no or "",
"receiver": receiver,
"purpose": purpose,
"sourceType": r.source_type or "",
"useTime": create_time if is_manual_out else "",
"remark": _display_remark(r),
"createBy": create_by,
"createByName": create_by_name or create_by,
"createTime": create_time,
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
}
def list_stock_logs(
db: Session,
tenant_no: str,
*,
page: int = 1,
page_size: int = 10,
material_id: Optional[str] = None,
change_type: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
keyword: Optional[str] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
def _filtered():
q0 = db.query(BizReagentStockLog).filter(
BizReagentStockLog.tenant_no == tenant_no,
BizReagentStockLog.del_flag == 0,
)
if material_id:
q0 = q0.filter(BizReagentStockLog.material_id == material_id)
ct = (change_type or "").strip()
if ct == "out":
q0 = q0.filter(
BizReagentStockLog.change_type == "use",
BizReagentStockLog.source_type == "manual_out",
)
elif ct == "use":
q0 = q0.filter(
BizReagentStockLog.change_type == "use",
BizReagentStockLog.source_type == "step_save",
)
elif ct == "in":
q0 = q0.filter(BizReagentStockLog.change_type == "in")
if start_time:
q0 = q0.filter(BizReagentStockLog.create_time >= start_time)
if end_time:
q0 = q0.filter(BizReagentStockLog.create_time <= end_time + " 23:59:59")
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q0 = q0.filter(
or_(
BizReagentStockLog.material_code.like(like),
BizReagentStockLog.material_name.like(like),
)
)
if user_info:
from app.core.data_scope import apply_data_scope
q0 = apply_data_scope(q0, BizReagentStockLog, user_info, db, tenant_no)
return q0
total = _filtered().order_by(None).with_entities(func.count(BizReagentStockLog.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
_filtered()
.order_by(BizReagentStockLog.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
return [
_row_to_dict(x, create_by_name=name_map.get(x.create_by or "", ""))
for x in rows
], int(total)
def revert_step_stock_changes(
db: Session,
step_id: str,
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
"""撤销步骤此前 step_save 扣减,便于重新填报。"""
rows = (
db.query(BizReagentStockLog)
.filter(
BizReagentStockLog.step_id == step_id,
BizReagentStockLog.tenant_no == tenant_no,
BizReagentStockLog.del_flag == 0,
BizReagentStockLog.source_type == "step_save",
BizReagentStockLog.change_type == "use",
)
.all()
)
if not rows:
return
now = datetime.now()
for r in rows:
restore_qty = abs(r.change_qty) if r.change_qty is not None else Decimal("0")
if restore_qty <= 0:
r.del_flag = 1
r.update_by = user_id
r.update_time = now
continue
material = (
db.query(BizReagent)
.with_for_update()
.filter(
BizReagent.id == r.material_id,
BizReagent.tenant_no == tenant_no,
BizReagent.del_flag == 0,
)
.first()
)
if material:
material.stock_qty = (material.stock_qty or Decimal("0")) + restore_qty
material.update_by = user_id
material.update_time = now
r.del_flag = 1
r.update_by = user_id
r.update_time = now
def deduct_for_step(
db: Session,
step_id: str,
task_id: str,
task_no: str,
reagent_items: List[Dict[str, Any]],
tenant_no: str,
user_id: Optional[str] = None,
dept_id: str = "",
) -> None:
"""扣减试剂库存并生成变动记录。调用方控制事务,本函数不 commit。"""
for item in reagent_items:
code = (item.get("reagent_code") or "").strip()
actual_usage = item.get("actual_usage")
if not code:
continue
if actual_usage is None or str(actual_usage).strip() == "":
continue
try:
usage_val = Decimal(str(actual_usage))
except Exception:
continue
if usage_val <= 0:
continue
# 按 reagent_code 查找试剂(跳过目录中不存在的自定义材料)
material = (
db.query(BizReagent)
.with_for_update()
.filter(
BizReagent.reagent_code == code,
BizReagent.tenant_no == tenant_no,
BizReagent.del_flag == 0,
BizReagent.status == "1",
)
.first()
)
if not material:
continue
stock_before = material.stock_qty if material.stock_qty is not None else Decimal("0")
if stock_before < usage_val:
raise HTTPException(
status_code=400,
detail=f"试剂「{material.reagent_name}」库存不足,当前库存 {stock_before},需使用 {usage_val}",
)
new_stock = stock_before - usage_val
material.stock_qty = new_stock
material.update_by = user_id
material.update_time = datetime.now()
log = BizReagentStockLog(
id=generate_uuid(),
material_id=material.id,
material_code=material.reagent_code,
material_name=material.reagent_name,
change_type="use",
change_qty=-usage_val,
stock_before=stock_before,
stock_after=new_stock,
source_type="step_save",
step_id=step_id,
task_id=task_id,
task_no=task_no,
tenant_no=tenant_no,
remark=str(item.get("remark") or "").strip()[:512] or None,
create_by=user_id,
create_dept_id=dept_id,
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
)
db.add(log)
+64
View File
@@ -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
@@ -0,0 +1,80 @@
-- ============================================================
-- 生产物料库存管理 - 数据库迁移脚本
-- 变更: production-material-inventory
-- ============================================================
-- 1. biz_consumable 新增库存字段
ALTER TABLE `biz_consumable`
ADD COLUMN `stock_qty` DECIMAL(18,4) NOT NULL DEFAULT 0.0000 COMMENT '当前库存量' AFTER `description`,
ADD COLUMN `warning_qty` DECIMAL(18,4) NOT NULL DEFAULT 0.0000 COMMENT '预警库存量' AFTER `stock_qty`,
ADD COLUMN `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '1-启用 0-停用' AFTER `warning_qty`;
-- 2. biz_reagent 新增库存字段
ALTER TABLE `biz_reagent`
ADD COLUMN `stock_qty` DECIMAL(18,4) NOT NULL DEFAULT 0.0000 COMMENT '当前库存量' AFTER `description`,
ADD COLUMN `warning_qty` DECIMAL(18,4) NOT NULL DEFAULT 0.0000 COMMENT '预警库存量' AFTER `stock_qty`,
ADD COLUMN `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '1-启用 0-停用' AFTER `warning_qty`;
-- 3. 耗材变动记录表
CREATE TABLE `biz_consumable_stock_log` (
`id` VARCHAR(64) NOT NULL COMMENT '主键ID',
`material_id` VARCHAR(64) NOT NULL COMMENT '耗材ID',
`material_code` VARCHAR(64) DEFAULT NULL COMMENT '耗材编码快照',
`material_name` VARCHAR(200) DEFAULT NULL COMMENT '耗材名称快照',
`change_type` VARCHAR(16) NOT NULL COMMENT '变动类型:in入库 / use使用出库',
`change_qty` DECIMAL(18,4) NOT NULL COMMENT '变动数量(入库正数,出库负数)',
`stock_before` DECIMAL(18,4) NOT NULL COMMENT '变动前库存',
`stock_after` DECIMAL(18,4) NOT NULL COMMENT '变动后库存',
`source_type` VARCHAR(16) NOT NULL COMMENT '来源:manual_in / manual_out / step_save',
`step_id` VARCHAR(64) DEFAULT NULL COMMENT '生产任务步骤ID(step_save时有值)',
`task_id` VARCHAR(64) DEFAULT NULL COMMENT '生产任务ID(step_save时有值)',
`task_no` VARCHAR(32) DEFAULT NULL COMMENT '生产任务编号快照',
`receiver` VARCHAR(64) DEFAULT NULL COMMENT '领用人(手动出库时)',
`purpose` VARCHAR(512) DEFAULT NULL COMMENT '用途/事由(手动出库时)',
`tenant_no` VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`remark` VARCHAR(512) DEFAULT NULL COMMENT '备注',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
`create_dept_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (`id`),
INDEX `idx_csl_material` (`material_id`, `del_flag`),
INDEX `idx_csl_step` (`step_id`, `del_flag`),
INDEX `idx_csl_task` (`task_id`, `del_flag`),
INDEX `idx_csl_tenant_time` (`tenant_no`, `change_type`, `create_time`),
INDEX `idx_csl_source` (`source_type`, `del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='耗材变动记录';
-- 4. 试剂变动记录表
CREATE TABLE `biz_reagent_stock_log` (
`id` VARCHAR(64) NOT NULL COMMENT '主键ID',
`material_id` VARCHAR(64) NOT NULL COMMENT '试剂ID',
`material_code` VARCHAR(64) DEFAULT NULL COMMENT '试剂编码快照',
`material_name` VARCHAR(200) DEFAULT NULL COMMENT '试剂名称快照',
`change_type` VARCHAR(16) NOT NULL COMMENT '变动类型:in入库 / use使用出库',
`change_qty` DECIMAL(18,4) NOT NULL COMMENT '变动数量(入库正数,出库负数)',
`stock_before` DECIMAL(18,4) NOT NULL COMMENT '变动前库存',
`stock_after` DECIMAL(18,4) NOT NULL COMMENT '变动后库存',
`source_type` VARCHAR(16) NOT NULL COMMENT '来源:manual_in / manual_out / step_save',
`step_id` VARCHAR(64) DEFAULT NULL COMMENT '生产任务步骤ID(step_save时有值)',
`task_id` VARCHAR(64) DEFAULT NULL COMMENT '生产任务ID(step_save时有值)',
`task_no` VARCHAR(32) DEFAULT NULL COMMENT '生产任务编号快照',
`receiver` VARCHAR(64) DEFAULT NULL COMMENT '领用人(手动出库时)',
`purpose` VARCHAR(512) DEFAULT NULL COMMENT '用途/事由(手动出库时)',
`tenant_no` VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`remark` VARCHAR(512) DEFAULT NULL COMMENT '备注',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
`create_dept_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (`id`),
INDEX `idx_rsl_material` (`material_id`, `del_flag`),
INDEX `idx_rsl_step` (`step_id`, `del_flag`),
INDEX `idx_rsl_task` (`task_id`, `del_flag`),
INDEX `idx_rsl_tenant_time` (`tenant_no`, `change_type`, `create_time`),
INDEX `idx_rsl_source` (`source_type`, `del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='试剂变动记录';
+509
View File
@@ -0,0 +1,509 @@
# web-admin 界面模式索引
> **用途**:新功能开发前,按「功能特征 → 模式编号 → 参考页」快速选型,保证 UI 与交互一次做对。
> **视觉权威**:`docs/web-admin-ui-spec-fresh-mint.md`
> **弹窗/列表细则**:`.cursor/rules/web-admin-crud-dialog.mdc`
> **Tag 映射**:`web-admin/src/utils/status-tag-registry.ts` + OpenSpec `ui-conventions` §状态标签注册表
> **OpenSpec**:`openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md` §「界面模式索引与参考页选型」
---
## 一、怎么用(30 秒选型)
```
1. 看下方「功能特征 → 模式」决策表,确定模式编号(如 P01、P12)
2. 打开该模式的「标准写法」+「参考页面」
3. 复制页面骨架,替换 API / 字段 / menuCode
4. 枚举字段进 status-tag-registry.ts
```
**官方 Demo 模板(侧栏 Demo 菜单,永远优先对照):**
| 模式 | Hash | 源码 |
|------|------|------|
| 标准列表 CRUD | `/#/demo/unit` | `web-admin/src/views/system/unit/index.vue` |
| 复杂业务列表 | `/#/demo/order` | `web-admin/src/views/system/order/index.vue` |
| 商品类 + 查看弹窗 | `/#/demo/product` | `web-admin/src/views/system/product/index.vue` |
| 树形主数据 | `/#/demo/category` | `web-admin/src/views/system/category/index.vue` |
| 用户类 CRUD | `/#/demo/user` | `web-admin/src/views/system/user/index.vue` |
| 工作台说明 | `/#/demo/console` | `web-admin/src/views/dashboard/console/index.vue` |
---
## 二、功能特征 → 模式(决策表)
| 你的功能长什么样 | 选模式 | 首选参考 |
|----------------|--------|----------|
| 单表主数据,增删改查 + 分页 + 关键词筛选 | **P01** | `/#/demo/unit` → `basic-info/customer` |
| 列表有多种状态,操作按钮随状态变化 | **P02** | `/#/demo/order` → `cell-storage/protocol-audit` |
| 树形结构,父子级联,无分页 | **P03** | `/#/demo/category` → `system/dept` |
| 左侧组织/分类树 + 右侧分页列表 | **P04** | `/#/demo/user` → `system/user` |
| 同一页上下两块表(类型 + 明细) | **P05** | `system/dict` |
| 主表列表 + 弹窗内维护子表 | **P06** | `basic-info/collection-region` |
| 批量生成编号/编码 + 下发/收回 | **P07** | `basic-info/sample-code` |
| 列表顶部 Tab 按状态切换(非路由 Tab) | **P08** | `cell-storage/payment-info` |
| 只读查看,字段分 Section 展示 | **P09** | `SystemViewDialog` → `system/express` |
| 只读查看,业务字段多、需 Tab/KPI | **P10** | `ProtocolViewDialog` / `DonationViewDialog` |
| 简单审核/状态变更(单表单 560px) | **P11** | `ProtocolAuditDialog` |
| 多分区大表单(Tab 锚点 + 子表行) | **P12** | `ProtocolFormDialog` / `DonationFormDialog` |
| 财务/业务确认(只读明细 + 上传附件) | **P13** | `cell-storage/payment-confirm` |
| 主表 + 嵌套配置大弹窗(子资源 CRUD) | **P14** | `basic-info/insurance-company` |
| 主表 + 多 Tab 维护渠道/协议等子模块 | **P15** | `pilot-service/channel` |
| 三栏:资源库 \| 步骤列表 \| 步骤详情 | **P16** | `production/process-route` |
| 工单列表 + 派发/执行弹窗链 | **P17** | `production/production-work-order` |
| 任务执行(虚拟步骤 + 步骤状态机) | **P18** | `TaskExecutionDialog` + `useTaskStepEditor` |
| 主数据 + append-to-body 选择器弹窗 | **P19** | `production/process` |
| 库存主数据 + 入库/出库 + 流水只读页 | **P20** | `production/consumable` + `cell-storage/material-stock-log` |
| 工单/任务(质检域,配置检测项) | **P21** | `quality-inspection/qc-work-order` |
| 审核列表 + 专用审核弹窗 | **P22** | `quality-inspection/storage-release-review` |
| 接收/登记类(关联协议样本) | **P23** | `quality-inspection/sample-reception` |
| 工作流配置 / 待办 / 审批 | **P24** | `system/workflow/*` |
| 独立详情页(非弹窗) | **P25** | `system/user/detail` |
| 角色/权限扩展(多弹窗:菜单树/数据范围) | **P26** | `system/role` |
| 衍生列表(跳转主业务查看弹窗) | **P27** | `cell-storage/storage-cert` |
---
## 三、模式详解(标准写法 + 参考)
### P01 标准列表 CRUD
**适用**:字典类主数据、配置项、简单业务实体(客户、耗材、站点…)
**页面结构**:
```
page-head → panel → filter-block → list-toolbar → list-table → pagination
+ el-dialog.dialog-form-layout(新增/编辑)
+ SystemViewDialog(查看)
```
**标准写法要点**:
- 工具栏:「已选 N 项」→ 唯一 `type="primary"` 新增 → 批量删除
- 表格:`list-table` + `stripe` + `row-key="id"` + 双击查看
- 弹窗:`label-position="top"`、`form-grid-2`、Close/Check 底栏
- 状态列:`<el-tag size="small">` + `status-tag-registry`
**参考页面**:
| 业务 | Hash | 源码 | 后端 API |
|------|------|------|----------|
| Demo 模板 | `/#/demo/unit` | `views/system/unit/index.vue` | `admin.py` item-units |
| 客户管理 | `/#/basic-info/customer` | `views/basic-info/customer/index.vue` | `basic_info.py` customers |
| 耗材管理 | `/#/production/consumable` | `views/production/consumable/index.vue` | `production.py` consumables |
| 快递公司 | `/#/system/express` | `views/system/express/index.vue` | `admin.py` |
| 洁净室 | `/#/operations/clean-room` | `views/operations/clean-room/index.vue` | `operations.py` |
| 市场物料 | `/#/cell-storage/material` | `views/cell-storage/material/index.vue` | `material.py` |
**前端 API 模式**:`fetchXxxList` / `fetchCreateXxx` / `fetchUpdateXxx` / `fetchDeleteXxx`(`src/api/admin.ts` 或域文件)
---
### P02 复杂业务列表
**适用**:多状态枚举、操作列按钮随状态/角色变化、业务 Tag 丰富
**在 P01 基础上增加**:
- 筛选区含状态/类型下拉
- 操作列多种按钮(查看/编辑/提交/作废…)
- 可能挂多个业务弹窗(审核、状态变更)
**参考页面**:
| 业务 | Hash | 源码 |
|------|------|------|
| Demo 模板 | `/#/demo/order` | `views/system/order/index.vue` |
| 协议单管理 | `/#/cell-storage/protocol-audit` | `views/cell-storage/protocol-audit/index.vue` |
| 捐赠协议管理 | `/#/research-donation/audit` | `views/research-donation/audit/index.vue` |
| 入库生产工单 | `/#/production/production-work-order` | `views/production/production-work-order/index.vue` |
| 入库质检工单 | `/#/quality-inspection/qc-work-order` | `views/quality-inspection/qc-work-order/index.vue` |
| 生产任务 | `/#/production/production-task` | `views/production/production-task/index.vue` |
| 请假申请 | `/#/workflow/leave` | `views/system/leave/index.vue` |
---
### P03 树形主数据
**适用**:组织、菜单、分类等父子层级;整表树展示;软删级联子节点
**标准写法**:
- `el-table` + `:tree-props="{ children: 'children' }"`
- 工具栏:全部展开/折叠
- **无分页**
- 下拉/选中显示本级名;表格/导出显示全路径(`tree-path.ts`,分隔符 ` / `)
**参考页面**:
| 业务 | Hash | 源码 |
|------|------|------|
| Demo 模板 | `/#/demo/category` | `views/system/category/index.vue` |
| 组织管理 | `/#/system/dept` | `views/system/dept/index.vue` |
| 菜单管理 | `/#/system/menu` | `views/system/menu/index.vue` |
---
### P04 左树右表
**适用**:按组织/部门筛选用户或业务数据
**参考**:`/#/demo/user` → `views/system/user/index.vue`
**要点**:左侧 `el-tree` 选组织;右侧标准 P01 列表
**路径展示**:`buildPathLabelMapFromTreeRoots`
---
### P05 主从双表(同页)
**适用**:主实体 + 从实体,选中主表行后在同页下方展示从表
**参考**:`/#/system/dict` → `views/system/dict/index.vue`
**特征**:上下两套 toolbar + table;从表依赖主表选中行
---
### P06 主从弹窗(子表在 Dialog)
**适用**:主表字段少,子表在「配置」弹窗内 CRUD(非树形)
**参考**:`/#/basic-info/collection-region` → `views/basic-info/collection-region/index.vue`
**后端**:主资源 + 嵌套路由 `/{id}/hospitals`
**注意**:不要用树 UI;子表用嵌套 `el-table`
---
### P07 号池 / 批量运维
**适用**:预生成编号/编码、批量下发给用户、批量收回
**参考**:
| 业务 | Hash | 源码 |
|------|------|------|
| 样本编码 | `/#/basic-info/sample-code` | `views/basic-info/sample-code/index.vue` |
| 采集协议号 | `/#/basic-info/collection-protocol-no` | `views/basic-info/collection-protocol-no/index.vue` |
**标准写法**:
- 批量生成弹窗:前缀/中间段/序号范围
- 批量下发:选用户 + `issuedUserId` 校验
- API:`/batch-generate`、`/batch-issue`、`/batch-revoke`;超时 `120_000`
---
### P08 状态 Tab 列表
**适用**:同一业务实体按状态分视图(缴费状态、待办/已办…)
**参考**:`/#/cell-storage/payment-info` → `views/cell-storage/payment-info/index.vue`
**写法**:`region-tabs` 切换 `payStatus` 筛选;每 Tab 可不同批量操作
**类似**:`payment-confirm`(pending/confirmed)、`refund-confirm`
---
### P09 标准查看弹窗(SystemViewDialog)
**适用**:字段 ≤ 20 个、分 1–3 个 Section、无复杂 Tab
**组件**:`web-admin/src/components/business/system-view-dialog/index.vue`
**标准写法**:
```typescript
const viewSections = computed(() => [{
title: '基本信息',
fields: [
{ label: '名称', value: d.name },
{ label: '状态', value: enableStatusLabel(d.status), tagType: enableStatusTagType(d.status) }
]
}])
```
**参考**:`system/express`、`basic-info/customer`、`production/consumable-pack`(查看)
---
### P10 业务专用查看弹窗
**适用**:字段极多、多 Tab、KPI 摘要、审核历史、附件区、跨模块信息卡片
**参考**:
| 业务 | 组件 |
|------|------|
| 采集协议 | `cell-storage/protocol/ProtocolViewDialog.vue` |
| 科研捐赠 | `research-donation/list/DonationViewDialog.vue` |
| 渠道信息 | `pilot-service/channel/ChannelViewDialog.vue` |
| 入库放行 | `quality-inspection/storage-release-review/StorageReleaseViewDialog.vue` |
| 质检任务 | `components/business/qc-task-view-dialog/index.vue` |
**写法**:与编辑弹窗 **同 Tab/section 命名**;只读块复用 `ProtocolAttachmentReadonly` 等
---
### P11 轻量业务弹窗(560px)
**适用**:审核结论、状态变更、单字段确认
**参考**:
- `ProtocolAuditDialog.vue` — 审核通过/退回
- `ProtocolStatusChangeDialog.vue` — 生效/过期/作废/转科研
- `DonationAuditDialog.vue` / `DonationStatusChangeDialog.vue`
---
### P12 重表单弹窗(Tab 分区)
**适用**:主从多表一次保存、样本/当事人/缴费等分区、行内子表
**参考**:
- `cell-storage/protocol/ProtocolFormDialog.vue`(1380px,Sticky Tab)
- `research-donation/list/DonationFormDialog.vue`
**标准写法**:
- `DialogFormBody` + `detail-tabs` + `scrollToSection`
- `form-grid--col4` / `col2`;`scroll-to-error`
- 附件:`upload-card`(140×96)+ `fetchUploadAttachment`
- **独立组件文件**,不要堆在 `index.vue`
---
### P13 财务 / 业务确认弹窗
**适用**:只读业务明细 + 财务填写确认字段 + 附件上传
**参考**:
- `/#/cell-storage/payment-confirm` + `protocol-finance-confirm-fields`
- `/#/cell-storage/refund-confirm`
- 组件:`components/business/protocol-payment-detail-readonly`
- 组件:`components/business/protocol-finance-confirm-fields`
---
### P14 主表 + 嵌套配置大弹窗
**适用**:主实体简单,子资源(险种/开票/价格)在独立大弹窗维护
**参考**:`/#/basic-info/insurance-company` → `views/basic-info/insurance-company/index.vue`
**后端**:`/{id}/project-prices`、`/{id}/invoices`
---
### P15 主表 + 多 Tab 子模块页内/弹窗
**适用**:一个主实体关联多种子业务(开票、地址、协议…)
**参考**:`/#/pilot-service/channel` → `views/pilot-service/channel/index.vue` + `ChannelViewDialog.vue`
**类似**:`pilot-service/checkup-institution`(体检机构 + 套餐面板)
---
### P16 三栏工艺编辑器
**适用**:从资源库拖拽到步骤列表,右侧编辑步骤详情子表
**样式**:`web-admin/src/styles/_route-config.scss`
**布局类**:`.route-config-body` = 左 `.route-config-left` | 中 `.route-config-center` | 右 `.route-config-right`
**参考**:
- 原型(逻辑内联):`production/process-route/index.vue`
- 复用版:`production-work-order/DispatchTaskDialog.vue`(phase=edit)
- 右栏面板:`StepDetailPanel.vue`
---
### P17 工单列表 + 任务弹窗链
**适用**:接收/触发自动生成工单 → 派发任务 → 列表查看任务 → 完成工单
**参考**:`/#/production/production-work-order`
**子组件**:`DispatchTaskDialog`、`EditTaskInfoDialog`、任务列表内嵌 dialog
**后端**:`production-work-order.ts` + `production_task.py`
---
### P18 任务执行(Composable 驱动)
**适用**:步骤状态机、虚拟步骤(原材料/产出)、执行态只读/编辑切换
**参考**:
- `TaskExecutionDialog.vue`
- `useTaskStepEditor.ts`(核心 composable)
- `TaskInputEnhancedTable.vue`、`TaskOutputList.vue`
**质检类似**:`/#/quality-inspection/qc-task`(配置+执行,无三栏拖拽)
---
### P19 主数据 + 选择器子弹窗
**适用**:编辑主实体时从另一张表多选关联(试剂、耗材、产出物)
**参考**:`/#/production/process` → `process/index.vue`
**写法**:主弹窗 1200px + `append-to-body` 选择弹窗 1000px
**类似**:`consumable-pack/index.vue`、`cell-product/index.vue`
---
### P20 库存 + 流水
**适用**:主数据 CRUD + 入库/出库 + 独立流水只读列表页
**参考**:
- 主数据+出入库:`/#/production/consumable`
- 流水只读:`/#/cell-storage/material-stock-log`、`/#/quality-inspection/qc-material-usage`
---
### P21 质检工单 / 任务
**适用**:样本接收后自动生成工单,配置检测项,执行任务
**参考**:
- 工单:`/#/quality-inspection/qc-work-order`
- 任务:`/#/quality-inspection/qc-task`
- API:`qc-work-order.ts`、`qc-task.ts`
---
### P22 审核列表 + 专用审核弹窗
**适用**:待审核列表,打开专用 Dialog 填写结论
**参考**:`/#/quality-inspection/storage-release-review`
**组件**:`StorageReleaseReviewDialog.vue`、`StorageReleaseViewDialog.vue`
---
### P23 接收 / 登记
**适用**:关联协议/样本编码,填写接收结论,触发下游工单
**参考**:`/#/quality-inspection/sample-reception`
**跨表键**:`tenant_no` + `sample_code`(不用 protocol_sample_id)
---
### P24 工作流
**适用**:流程配置、待办、已办、我的申请、业务表单驱动审批
**参考**:
- 流程配置:`/#/workflow/process` + `FlowDesigner.vue`
- 待办/已办:`task-pending`、`task-completed` + `ApprovalDialog`
- API:`src/api/workflow.ts`、`src/api/leave.ts`
---
### P25 独立详情页
**适用**:信息量大、需独立 URL、非弹窗场景
**参考**:`/#/system/user/detail` → `views/system/user/detail.vue`(`isHide: true` 路由)
---
### P26 权限扩展 CRUD
**适用**:除 CRUD 外还有菜单树勾选、数据范围配置
**参考**:`/#/system/role` → `views/system/role/index.vue`
**额外弹窗**:菜单配置 `el-tree`、数据权限选部门/用户
---
### P27 衍生列表(跳转主业务查看)
**适用**:从主业务派生的列表页,查看时打开主业务 ViewDialog
**参考**:
- `/#/cell-storage/storage-cert` → `ProtocolViewDialog`(`show-storage-cert`)
- `/#/cell-storage/fullmoon-todo` → `ProtocolViewDialog`(`show-fullmoon-insurance`)
---
## 四、全站页面 → 模式速查
| 页面 | 模式 |
|------|------|
| demo/unit, tenant, site, post, express, customer, cell-sample-type, consumable, reagent, process-category, output-type, qc-material, lab-test-item, third-party-company, material, clean-room, storage-facility, pilot-service/*-type/spec | P01 |
| demo/order, protocol-audit, donation/audit, production-work-order, qc-work-order, production-task, leave, exhibition-appointment | P02 |
| demo/category, dept, menu | P03 |
| demo/user, user | P04 |
| dict | P05 |
| collection-region | P06 |
| sample-code, collection-protocol-no | P07 |
| payment-info, payment-confirm, refund-confirm | P08 |
| 多数页的查看 | P09 |
| protocol/ProtocolViewDialog, DonationViewDialog, ChannelViewDialog, StorageReleaseViewDialog | P10 |
| ProtocolAuditDialog, ProtocolStatusChangeDialog, DonationAuditDialog | P11 |
| ProtocolFormDialog, DonationFormDialog | P12 |
| payment-confirm, refund-confirm | P13 |
| insurance-company | P14 |
| channel, checkup-institution | P15 |
| process-route, DispatchTaskDialog(edit) | P16 |
| production-work-order | P17 |
| TaskExecutionDialog, qc-task(执行) | P18 |
| process, consumable-pack, cell-product, sop | P19 |
| consumable + material-stock-log / qc-material-usage | P20 |
| qc-work-order, qc-task | P21 |
| storage-release-review | P22 |
| sample-reception | P23 |
| workflow/* | P24 |
| user/detail | P25 |
| role | P26 |
| storage-cert, fullmoon-todo | P27 |
| protocol(录入) | P01 + P12 |
| demo/product | P01 + 专用 product-view-dialog |
---
## 五、标准写法 Checklist(所有模式通用)
### 页面骨架
- [ ] `page-container` → `page-head` → `section.panel`
- [ ] 单 `panel-top` 标题;筛选区无重复标题
- [ ] 工具栏「已选 N 项」在最前;仅一个主色实心按钮
- [ ] `list-table` + `stripe`;操作列 `fixed="right"` min-width ≥ 260
### 弹窗
- [ ] `dialog-form-layout` + `align-center` + `label-position="top"`
- [ ] 长表单 `scroll-to-error`;底栏 Close/Check 图标间距 6px
- [ ] 无 backdrop blur
### Tag / 枚举
- [ ] 先注册 `status-tag-registry.ts`
- [ ] 表格 + SystemViewDialog 的 `tagType` 均用 registry getter
### 前后端衔接
- [ ] `sqxp.ts` 添加路由 + `menuCode`
- [ ] 后端 `require_menu_code`(GET)/ `require_admin_or_super_admin`(写)
- [ ] API 函数 + `api.d.ts` 类型
- [ ] crud `_row_to_dict` camelCase;软删 `del_flag`
---
## 六、选型流程图
```mermaid
flowchart TD
A[新功能需求] --> B{是否树形主数据?}
B -->|是| P03[P03 树表 / P04 左树右表]
B -->|否| C{是否多 Tab 大表单?}
C -->|是| P12[P12 重表单 / P10 查看]
C -->|否| D{是否工单/任务流?}
D -->|是| P17[P17/P18 工单任务]
D -->|否| E{是否有子表/号池/审核?}
E -->|号池| P07
E -->|子表弹窗| P06/P14/P15
E -->|状态 Tab| P08
E -->|审核| P11/P22
E -->|三栏编辑| P16
E -->|否| F{操作是否随状态变化?}
F -->|是| P02
F -->|否| P01
```
---
## 七、维护说明
- **新增页面后**:在本文件 §四 补充「页面 → 模式」映射
- **新增模式后**:在 §二决策表 + §三详解 补充,并同步更新 OpenSpec `ui-conventions/spec.md`「界面模式索引与参考页选型」Requirement
- **Cursor / AI 检索关键词**:界面模式、UI pattern、参考页、模板页、P01、标准列表
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-23
@@ -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 变动记录表,回滚代码
@@ -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/` 新增迁移脚本
@@ -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=新库存
@@ -0,0 +1,80 @@
## ADDED Requirements
### Requirement: Per-row stock log dialog
耗材/试剂管理列表每行操作列 SHALL 有一个「变动记录」按钮,点击弹出 Dialog,标题显示物料名称,列表仅展示该物料的变动记录,支持分页。
#### Scenario: Open stock log for single material
- **WHEN** 用户点击耗材A行的「变动记录」按钮
- **THEN** 弹出 Dialog,标题为"耗材A — 变动记录",列表按时间倒序展示该耗材的所有变动记录
#### Scenario: Stock log dialog pagination
- **WHEN** 变动记录超过 20 条
- **THEN** Dialog 内列表支持分页
### Requirement: Tab view for all stock logs
耗材管理页面和试剂管理页面 SHALL 各自有一个顶部 Tab「全部变动记录」,切换后展示所有耗材(或试剂)的汇总变动记录列表,支持按物料名称/编码、变动类型、时间范围筛选,支持分页。
#### Scenario: Switch to all stock logs tab
- **WHEN** 用户点击耗材管理页面顶部「全部变动记录」Tab
- **THEN** 切换到全部变动记录视图,展示所有耗材的变动记录汇总列表
#### Scenario: Filter by material name
- **WHEN** 用户在全部变动记录 Tab 中搜索物料名称"培养皿"
- **THEN** 列表仅展示物料名称包含"培养皿"的变动记录
#### Scenario: Filter by change type
- **WHEN** 用户筛选变动类型为"出库"
- **THEN** 列表仅展示 change_type=use 的变动记录(含 manual_out 和 step_save)
#### Scenario: Filter by time range
- **WHEN** 用户选择时间范围 2026-01-01 至 2026-03-31
- **THEN** 列表仅展示该时间范围内的变动记录
### Requirement: Stock log display fields
变动记录列表(弹窗和 Tab 视图)SHALL 展示以下字段:
- 物料编码(material_code)
- 物料名称(material_name)
- 变动类型(change_type):入库/出库/步骤扣减,以 `<el-tag>` 展示
- 变动数量(change_qty):正数绿色,负数红色
- 变动前库存(stock_before)
- 变动后库存(stock_after)
- 来源(source_type):手动入库/手动出库/步骤保存,以 `<el-tag>` 展示
- 关联任务(task_no):步骤扣减时展示任务编号
- 领用人(receiver):手动出库时展示
- 用途(purpose):手动出库时展示
- 操作人(create_by_name)
- 操作时间(create_time)
#### Scenario: Manual out log display
- **WHEN** 变动记录为手动出库类型
- **THEN** 展示领用人、用途、操作人、时间,来源显示"手动出库"
#### Scenario: Step save log display
- **WHEN** 变动记录为步骤保存类型
- **THEN** 展示关联任务编号、操作人、时间,来源显示"步骤扣减",不展示领用人/用途
### Requirement: Stock log API endpoints
后端 SHALL 提供以下变动记录 API:
- `GET /web/admin/consumables/{id}/stock-logs` — 单个耗材变动记录(分页)
- `GET /web/admin/consumables/stock-logs` — 全部耗材变动记录(分页,支持筛选)
- `GET /web/admin/reagents/{id}/stock-logs` — 单个试剂变动记录(分页)
- `GET /web/admin/reagents/stock-logs` — 全部试剂变动记录(分页,支持筛选)
#### Scenario: Single material stock log API
- **WHEN** 前端请求 `GET /web/admin/consumables/{id}/stock-logs?page=1&pageSize=20`
- **THEN** 返回该耗材的变动记录列表和总数
#### Scenario: All stock logs API with filters
- **WHEN** 前端请求 `GET /web/admin/consumables/stock-logs?keyword=培养皿&changeType=out&startTime=2026-01-01&endTime=2026-03-31`
- **THEN** 返回筛选后的变动记录列表和总数
### Requirement: Status field display in list
耗材/试剂管理列表的状态列 SHALL 使用 `<el-tag>` 展示:启用显示 success 类型,停用显示 info 类型。
#### Scenario: Enabled status display
- **WHEN** 耗材状态为启用(status='1')
- **THEN** 列表显示绿色 `<el-tag type="success" size="small">` 文本"启用"
#### Scenario: Disabled status display
- **WHEN** 耗材状态为停用(status='0')
- **THEN** 列表显示灰色 `<el-tag type="info" size="small">` 文本"停用"
@@ -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** 系统跳过该条目的库存扣减,其他正常条目正常扣减
@@ -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=新库存
@@ -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 结算正确性
@@ -575,23 +575,20 @@ import { Plus, Close, Check, Search, RefreshLeft, Edit, Delete, View } from '@el
</el-table-column>
```
**多状态映射标准写法(使用辅助函数):**
**多状态映射标准写法(使用状态标签注册表):**
```html
<el-table-column prop="workOrderStatus" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.workOrderStatus)" size="small">
{{ statusLabel(row.workOrderStatus) }}
<el-tag :type="workOrderStatusTagType(row.workOrderStatus)" size="small">
{{ workOrderStatusLabel(row.workOrderStatus) }}
</el-tag>
</template>
</el-table-column>
```
```typescript
function statusTagType(s: string): string {
const m: Record<string, string> = { ywc: 'success', ypz: 'warning', dpz: 'info' }
return m[s] || 'info'
}
import { workOrderStatusLabel, workOrderStatusTagType } from '@/utils/status-tag-registry'
```
#### 2. 查看弹窗(SystemViewDialog)中的 Tag 展示
@@ -615,13 +612,15 @@ export interface ViewField {
**标准写法:**
```typescript
import { taskStatusLabel, taskStatusTagType, qcTaskConclusionLabel, qcTaskConclusionTagType } from '@/utils/status-tag-registry'
const viewSections = computed<ViewSection[]>(() => [
{
title: '任务信息',
fields: [
{ label: '任务号', value: d.taskNo },
{ label: '状态', value: statusLabel(d.status), tagType: d.status === 'ywc' ? 'success' : d.status === 'jxz' ? 'warning' : 'info' },
{ label: '检测结论', value: conclusionLabel(d.conclusion), tagType: d.conclusion === 'tg' ? 'success' : d.conclusion === 'btg' ? 'danger' : 'info' },
{ label: '状态', value: taskStatusLabel(d.status), tagType: taskStatusTagType(d.status) },
{ label: '检测结论', value: qcTaskConclusionLabel(d.conclusion), tagType: qcTaskConclusionTagType(d.conclusion) },
// 普通字段不加 tagType,仍以纯文本展示
{ label: '备注', value: d.remark || '—', span: true },
]
@@ -637,12 +636,16 @@ const viewSections = computed<ViewSection[]>(() => [
```html
<dd>
<el-tag :type="row.acceptConclusion === 'js' ? 'success' : 'danger'" size="small">
<el-tag :type="acceptConclusionTagType(row.acceptConclusion)" size="small">
{{ acceptConclusionLabel(row.acceptConclusion) }}
</el-tag>
</dd>
```
```typescript
import { acceptConclusionLabel, acceptConclusionTagType } from '@/utils/status-tag-registry'
```
#### 4. Tag 类型语义色映射规范
| Tag Type | 颜色 | 适用语义 | 示例 |
@@ -655,9 +658,72 @@ const viewSections = computed<ViewSection[]>(() => [
**对于启用/停用的状态字段**,SHALL 使用 `success`(启用)和 `info`(停用),SHALL NOT 使用 `danger` 表示停用状态。
#### 5. 公共标签映射函数
#### 5. 状态标签注册表(集中定义 label + tagType)
多个页面复用的状态标签映射 SHALL 抽取为公共工具函数(如 `utils/protocol-payment-labels.ts`、`utils/qc-labels.ts`),同时提供 `xxxLabel`(文本)和 `xxxTagType`(tag type)两个配套函数。仅单个页面使用的状态标签 SHALL 在页面 `<script setup>` 中定义。
`web-admin` 中所有状态、结论、类型等枚举字段的 **文案(label)与 Tag 语义色(tagType)SHALL 在单一注册表中集中定义**,权威文件为 `web-admin/src/utils/status-tag-registry.ts`。
**核心原则:**
1. **同一枚举值全局一致**:同一枚举 code 在列表、查看弹窗、导出、嵌套子列表等任何展示位置,SHALL 使用相同的 label 与 tagType;SHALL NOT 在页面内用三元表达式或局部 `Record` 重复定义映射。
2. **成对导出**:每个枚举域 SHALL 导出 registry 对象及配套的 `xxxLabel(code)`、`xxxTagType(code)` 两个 getter;未知 code 时 label 回退为原值、`tagType` 回退为 `'info'`。
3. **新增枚举先注册后使用**:新增业务枚举时,SHALL 先在 `status-tag-registry.ts` 追加 registry 与 getter,再在页面/组件中 import 使用;SHALL NOT 直接在 `.vue` 中写 `if/else` 或内联映射。
4. **域内分组**:registry 内按业务域分区维护(协议/存储、质检、渠道、订单、系统通用等),便于检索与 code review。
**注册表结构(已实现):**
```typescript
/** el-tag 的 type 属性值 */
export type TagType = 'success' | 'warning' | 'danger' | 'info' | '' | 'primary'
export interface StatusItem {
label: string
tagType: TagType
}
export type StatusRegistry = Record<string, StatusItem>
export const protocolStatus: StatusRegistry = {
dsx: { label: '待生效', tagType: 'info' },
sxz: { label: '生效中', tagType: 'success' },
ygq: { label: '已过期', tagType: 'warning' },
yzf: { label: '已作废', tagType: 'danger' },
zky: { label: '转科研', tagType: 'primary' }
}
export const protocolStatusLabel = createLabelGetter(protocolStatus)
export const protocolStatusTagType = createTagTypeGetter(protocolStatus)
```
**页面使用方式:**
```typescript
import { protocolStatusLabel, protocolStatusTagType } from '@/utils/status-tag-registry'
```
```html
<el-tag :type="protocolStatusTagType(row.status)" size="small">
{{ protocolStatusLabel(row.status) }}
</el-tag>
```
**兼容 re-export(非新增入口):**
`utils/protocol-payment-labels.ts`、`utils/qc-labels.ts` 等历史文件 MAY 继续 re-export 注册表中的 getter 以保持既有 import 路径;**新增枚举 SHALL 直接写入 `status-tag-registry.ts`**,SHALL NOT 再新建独立的 label/tag 映射文件。
**禁止写法:**
- 页面 `<script setup>` 内定义 `function xxxTagType()` / `const xxxMap = { ... }` 映射已在或应在注册表中的枚举
- 查看弹窗 `ViewField.tagType` 使用内联三元表达式(如 `d.status === 'ywc' ? 'success' : ...`)替代 registry getter
- 同一枚举在不同页面使用不同 tagType
#### Scenario: 新增业务枚举的状态展示
- **WHEN** 新增页面需展示新的状态/结论/类型枚举字段
- **THEN** 开发者 SHALL 先在 `status-tag-registry.ts` 注册该枚举的 label 与 tagType,并在页面中 import 对应的 `xxxLabel` / `xxxTagType` getter 使用
#### Scenario: 复用已有枚举的状态展示
- **WHEN** 页面需展示已在注册表中定义的枚举(如 `protocolStatus`、`taskStatus`、`enableStatus`)
- **THEN** 页面 SHALL 直接从 `@/utils/status-tag-registry` import 并使用,SHALL NOT 在页面内重复定义映射
#### Scenario: 新增列表页包含状态列
@@ -672,7 +738,7 @@ const viewSections = computed<ViewSection[]>(() => [
#### Scenario: Code review Tag 展示
- **WHEN** PR 修改列表页或查看弹窗中的状态/结论/类型字段展示
- **THEN** 审查者 SHALL 确认使用 `<el-tag>` 组件(表格列)或 `tagType` 属性(SystemViewDialog),SHALL NOT 出现纯文本状态展示或自定义 `<span class="tag">`
- **THEN** 审查者 SHALL 确认使用 `<el-tag>` 组件(表格列)或 `tagType` 属性(SystemViewDialog),SHALL NOT 出现纯文本状态展示或自定义 `<span class="tag">`;若枚举已在或应进入注册表,SHALL 确认映射来自 `status-tag-registry.ts` 而非页面内联定义
### Requirement: 嵌套弹窗与子弹窗内列表风格一致
@@ -701,3 +767,108 @@ const viewSections = computed<ViewSection[]>(() => [
- **WHEN** PR 在弹窗、侧栏详情或嵌套面板中新增或修改 `el-table`
- **THEN** 审查者 SHALL 确认未使用 `size="small"` / 紧凑字号覆盖,且列表风格与主列表页一致
### Requirement: web-admin 界面模式索引与参考页选型
`web-admin` 新增或改造业务页面时,开发者 SHALL 在编码前按 **功能特征 → 模式编号(P01–P27)→ 参考页** 完成界面选型;模式定义、决策表、各模式标准写法与全站页面映射的**详细说明**以 `docs/web-admin-ui-pattern-index.md` 为权威索引,本 Requirement 给出 SHALL 级门禁与摘要。
**选型流程(SHALL 按序执行):**
1. 对照下方「功能特征 → 模式」决策表或索引文档 §二,确定模式编号(如 P01、P12)。
2. 打开该模式对应的 **Demo 模板页** 或 **业务参考页**(Hash + 源码路径见索引文档 §三)。
3. 复制页面骨架与交互模式,替换 API、字段、`menuCode`;**SHALL NOT** 从零设计布局或另起一套类名习惯。
4. 枚举字段 SHALL 注册至 `status-tag-registry.ts`(见本 spec「状态标签注册表」Requirement)。
**官方 Demo 模板(永远优先对照,SHALL 优先于业务页):**
| 用途 | Hash | 源码 |
|------|------|------|
| 标准列表 CRUD | `/#/demo/unit` | `web-admin/src/views/system/unit/index.vue` |
| 复杂业务列表 | `/#/demo/order` | `web-admin/src/views/system/order/index.vue` |
| 商品类 + 查看弹窗 | `/#/demo/product` | `web-admin/src/views/system/product/index.vue` |
| 树形主数据 | `/#/demo/category` | `web-admin/src/views/system/category/index.vue` |
| 用户类 CRUD | `/#/demo/user` | `web-admin/src/views/system/user/index.vue` |
| 工作台说明 | `/#/demo/console` | `web-admin/src/views/dashboard/console/index.vue` |
**功能特征 → 模式编号(决策表):**
| 功能特征 | 模式 | 首选参考 |
|----------|------|----------|
| 单表主数据,增删改查 + 分页 + 关键词筛选 | **P01** | `/#/demo/unit` → `basic-info/customer` |
| 列表多状态,操作按钮随状态变化 | **P02** | `/#/demo/order` → `cell-storage/protocol-audit` |
| 树形结构,父子级联,无分页 | **P03** | `/#/demo/category` → `system/dept` |
| 左侧组织/分类树 + 右侧分页列表 | **P04** | `/#/demo/user` → `system/user` |
| 同页上下两块表(类型 + 明细) | **P05** | `system/dict` |
| 主表列表 + 弹窗内维护子表 | **P06** | `basic-info/collection-region` |
| 批量生成编号/编码 + 下发/收回 | **P07** | `basic-info/sample-code` |
| 列表顶部 Tab 按状态切换(非路由 Tab) | **P08** | `cell-storage/payment-info` |
| 只读查看,字段分 Section(≤20 字段) | **P09** | `SystemViewDialog` → `system/express` |
| 只读查看,多 Tab/KPI/审核历史/附件 | **P10** | `ProtocolViewDialog` / `DonationViewDialog` |
| 简单审核/状态变更(单表单 ~560px) | **P11** | `ProtocolAuditDialog` |
| 多分区大表单(Tab 锚点 + 行内子表) | **P12** | `ProtocolFormDialog` / `DonationFormDialog` |
| 财务/业务确认(只读明细 + 上传附件) | **P13** | `cell-storage/payment-confirm` |
| 主表 + 嵌套配置大弹窗(子资源 CRUD) | **P14** | `basic-info/insurance-company` |
| 主表 + 多 Tab 子模块(渠道/协议等) | **P15** | `pilot-service/channel` |
| 三栏:资源库 \| 步骤列表 \| 步骤详情 | **P16** | `production/process-route` |
| 工单列表 + 派发/执行弹窗链 | **P17** | `production/production-work-order` |
| 任务执行(虚拟步骤 + 步骤状态机) | **P18** | `TaskExecutionDialog` + `useTaskStepEditor` |
| 主数据 + append-to-body 选择器弹窗 | **P19** | `production/process` |
| 库存主数据 + 入库/出库 + 流水只读页 | **P20** | `production/consumable` + `material-stock-log` |
| 质检工单/任务(配置检测项) | **P21** | `quality-inspection/qc-work-order` |
| 审核列表 + 专用审核弹窗 | **P22** | `quality-inspection/storage-release-review` |
| 接收/登记(关联协议样本) | **P23** | `quality-inspection/sample-reception` |
| 工作流配置 / 待办 / 审批 | **P24** | `system/workflow/*` |
| 独立详情页(非弹窗) | **P25** | `system/user/detail` |
| 角色/权限扩展(菜单树/数据范围) | **P26** | `system/role` |
| 衍生列表(跳转主业务查看弹窗) | **P27** | `cell-storage/storage-cert` |
**各模式 SHALL 级要点(摘要):**
- **P01**:`page-head` → `filter-block` → `list-toolbar`(已选 N 项在前)→ `list-table stripe` → 分页;`dialog-form-layout` 编辑 + `SystemViewDialog` 查看。
- **P02**:在 P01 基础上增加状态筛选、操作列多按钮、业务弹窗(审核/状态变更)。
- **P03**:`el-table` 树形、无分页;路径展示遵循「树形主数据展示规则」Requirement。
- **P04**:左 `el-tree` + 右 P01 列表;组织全路径用 `tree-path.ts`。
- **P06**:主从平铺 + 子 API;**SHALL NOT** 误用树形主数据 UI。
- **P07**:`/batch-generate`、`/batch-issue`、`/batch-revoke`;批量请求超时 SHALL ≥ 120s。
- **P08**:`region-tabs` 切换列表筛选;各 Tab 可有不同批量操作。
- **P09**:查看 SHALL 使用 `SystemViewDialog`;状态字段传 `tagType`。
- **P10**:复杂查看 SHALL 使用独立 `*ViewDialog` 组件;与编辑弹窗 **同 Tab/section 命名**;只读附件复用 `ProtocolAttachmentReadonly` 等。
- **P11/P22**:轻量审核弹窗 width ~560px;结论字段用 registry Tag。
- **P12**:重表单 SHALL 拆为独立 `.vue` 组件;`DialogFormBody` + `detail-tabs` + `scroll-to-error`;附件卡片化布局遵循本 spec 附件 Requirement。
- **P16**:三栏布局 SHALL 使用 `_route-config.scss` 的 `.route-config-*` 类;新三栏场景 SHALL 复用 `useTaskStepEditor` + `StepDetailPanel`,**SHALL NOT** 复制 `process-route` 内联逻辑。
- **P17/P18**:工单/任务流 SHALL 对齐 `production-work-order` 弹窗链与 composable 模式。
- **P27**:衍生列表查看 SHALL 打开主业务 ViewDialog(如 `ProtocolViewDialog`),**SHALL NOT** 重复实现完整查看 UI。
**维护约定:**
- 新增页面落地后,SHALL 在 `docs/web-admin-ui-pattern-index.md` §四补充「页面 → 模式」映射。
- 出现现有 P01–P27 无法覆盖的新交互形态时,SHALL 先在索引文档新增模式编号与参考页,再更新本 Requirement 决策表。
#### Scenario: 新增标准主数据页
- **WHEN** 新增单表 CRUD 主数据页(如供应商、配置项)
- **THEN** 开发者 SHALL 选定模式 **P01**,以 `/#/demo/unit` 或 `basic-info/customer` 为骨架参考
- **THEN** PR 描述或设计说明中 SHALL 注明所选模式编号与参考页路径
#### Scenario: 新增复杂业务或工单页
- **WHEN** 新增含多状态操作、工单派发、任务执行或三栏编辑的页面
- **THEN** 开发者 SHALL 从 P02、P16、P17、P18、P21 等决策表中选定模式,并对照索引文档 §三对应参考页实现
- **THEN** SHALL NOT 仅参照 P01 标准列表强行裁剪复杂交互
#### Scenario: 新增重表单或专用查看弹窗
- **WHEN** 新增字段多、需 Tab 分区或 KPI 的业务表单/查看
- **THEN** 编辑 SHALL 采用 **P12**(独立 FormDialog 组件);查看字段极多 SHALL 采用 **P10**(专用 ViewDialog)
- **THEN** SHALL NOT 在 `index.vue` 内堆叠超过 500 行表单/查看模板
#### Scenario: Code review 界面模式选型
- **WHEN** PR 新增或大幅改造 `web-admin` 业务页面
- **THEN** 审查者 SHALL 确认 PR 注明模式编号(Pxx)及参考页
- **THEN** 页面骨架、工具栏顺序、弹窗类名与参考页一致;若偏离 SHALL 有书面理由
#### Scenario: 索引文档与 OpenSpec 同步
- **WHEN** 新增 P28 或更高级别模式,或全站页面映射变更
- **THEN** 维护者 SHALL 同步更新 `docs/web-admin-ui-pattern-index.md` 与本 Requirement 决策表
+118
View File
@@ -303,3 +303,121 @@ export function fetchUpdateStepOutputs(data: { stepId: string; items: any[] }) {
export function fetchUpdateStepDataCollections(data: { stepId: string; items: any[] }) {
return request.put({ url: '/web/admin/process-routes/step/data-collection/update', params: data })
}
// ---------------------------------------------------------------------------
// 试剂库存操作
// ---------------------------------------------------------------------------
export function fetchReagentStockIn(id: string, data: { qty: number; remark?: string }) {
return request.post({ url: `/web/admin/reagents/${id}/stock-in`, data })
}
export function fetchReagentStockOut(
id: string,
data: { qty: number; receiver: string; purpose?: string; remark?: string }
) {
return request.post({ url: `/web/admin/reagents/${id}/stock-out`, data })
}
export function fetchReagentBatchStockIn(data: {
items: Array<{ materialId: string; qty: number; remark?: string }>
remark?: string
}) {
return request.post<{ count: number }>({ url: '/web/admin/reagents/batch-stock-in', data })
}
export function fetchReagentBatchStockOut(data: {
items: Array<{ materialId: string; qty: number }>
receiver: string
purpose?: string
remark?: string
}) {
return request.post<{ count: number }>({ url: '/web/admin/reagents/batch-stock-out', data })
}
export function fetchReagentStockLogs(
id: string,
params: { page: number; pageSize: number; changeType?: string }
) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
}>({ url: `/web/admin/reagents/${id}/stock-logs`, params })
}
export function fetchAllReagentStockLogs(params: {
page: number
pageSize: number
keyword?: string
changeType?: string
startTime?: string
endTime?: string
}) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
}>({ url: '/web/admin/reagents/stock-logs', params })
}
// ---------------------------------------------------------------------------
// 耗材库存操作
// ---------------------------------------------------------------------------
export function fetchConsumableStockIn(id: string, data: { qty: number; remark?: string }) {
return request.post({ url: `/web/admin/consumables/${id}/stock-in`, data })
}
export function fetchConsumableStockOut(
id: string,
data: { qty: number; receiver?: string; purpose?: string; remark?: string }
) {
return request.post({ url: `/web/admin/consumables/${id}/stock-out`, data })
}
export function fetchConsumableBatchStockIn(data: {
items: { materialId: string; qty: number; remark?: string }[]
remark?: string
}) {
return request.post<{ count: number }>({ url: '/web/admin/consumables/batch-stock-in', data })
}
export function fetchConsumableBatchStockOut(data: {
items: { materialId: string; qty: number }[]
receiver?: string
purpose?: string
remark?: string
}) {
return request.post<{ count: number }>({ url: '/web/admin/consumables/batch-stock-out', data })
}
export function fetchConsumableStockLogs(
id: string,
params: { page: number; pageSize: number; changeType?: string }
) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
}>({ url: `/web/admin/consumables/${id}/stock-logs`, params })
}
export function fetchAllConsumableStockLogs(params: {
page: number
pageSize: number
keyword?: string
changeType?: string
startTime?: string
endTime?: string
}) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
}>({ url: '/web/admin/consumables/stock-logs', params })
}
@@ -411,6 +411,13 @@ body {
}
}
.page-container .panel-tabs {
> .el-tabs__header {
margin: 0;
padding: 0 16px;
}
}
.page-container :is(.panel-top h2, .panel-top h3, .storage-content-header h3, .storage-aside__head h2, .user-dept-aside__head h2).auto-title-tenant,
.console-container .panel-top h2.auto-title-tenant {
--panel-title-icon-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z'/%3E%3Cpath d='M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2'/%3E%3Cpath d='M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2'/%3E%3Cpath d='M10 6h4'/%3E%3Cpath d='M10 10h4'/%3E%3Cpath d='M10 14h4'/%3E%3Cpath d='M10 18h4'/%3E%3C/svg%3E");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff