For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 为库位管理增加占用记录功能,支持多物品类型(细胞产品 + 其他)的统一出入库追踪和历史记录。
Architecture: 新增 biz_storage_slot_occupancy 表作为多态关联占用记录,通过 item_type + item_id 关联不同来源的物品。在 biz_storage_facility_slot 上增加缓存字段(occupied_count 等)提升查询性能。细胞产品 CRUD 入库/出库时自动同步 occupancy 记录。
Tech Stack: FastAPI, SQLAlchemy, Pydantic v2, MySQL, Vue 3, Element Plus, TypeScript
Design Spec: docs/superpowers/specs/2026-05-22-storage-slot-occupancy-design.md
| 操作 | 文件 | 职责 |
|---|---|---|
| Create | docs/sql/2026-05-22-storage-slot-occupancy.sql |
DDL 迁移脚本 |
| Modify | backend/app/models/models.py |
新增 BizStorageSlotOccupancy 模型 + BizStorageFacilitySlot 增加缓存字段 |
| Modify | backend/app/models/__init__.py |
新增导出 |
| Create | backend/app/schemas/storage_occupancy.py |
Pydantic schemas |
| Create | backend/app/crud/storage_occupancy.py |
CRUD 层(含缓存刷新) |
| Modify | backend/app/api/operations.py |
新增 4 个 API 路由 |
| Modify | backend/app/crud/cell_product.py |
细胞产品入库/出库同步 occupancy |
| Modify | web-admin/src/api/admin.ts |
新增 4 个前端 API 函数 |
| Modify | web-admin/src/views/operations/storage-facility/index.vue |
占用明细弹窗 + 手动出入库 |
Files:
Create: docs/sql/2026-05-22-storage-slot-occupancy.sql
[ ] Step 1: 创建 SQL 迁移文件
-- ============================================================
-- 库位占用记录功能迁移
-- 日期: 2026-05-22
-- ============================================================
-- 1. 新增:库位占用记录表
CREATE TABLE biz_storage_slot_occupancy (
id VARCHAR(64) NOT NULL COMMENT '主键ID',
slot_id VARCHAR(64) NOT NULL COMMENT '储物格ID (FK → biz_storage_facility_slot)',
item_type VARCHAR(32) NOT NULL COMMENT '物品类型: cell_product=细胞产品, other=其他',
item_id VARCHAR(64) NOT NULL COMMENT '物品ID (指向对应业务表主键)',
item_code VARCHAR(64) NULL COMMENT '物品编码 (冗余展示)',
item_name VARCHAR(200) NULL COMMENT '物品名称 (冗余展示)',
item_source VARCHAR(32) NULL COMMENT '来源: production=生产入库, manual=手动入库, transfer=转移',
stored_at DATETIME NULL COMMENT '入库时间',
removed_at DATETIME NULL COMMENT '出库时间 (NULL=当前在库)',
occupancy_status VARCHAR(16) NOT NULL DEFAULT 'stored' COMMENT 'stored=在库, removed=已出库',
tenant_no VARCHAR(64) NULL COMMENT '租户编号',
order_num INT DEFAULT 0 COMMENT '排序',
remark VARCHAR(500) NULL COMMENT '备注',
create_by VARCHAR(64) NULL COMMENT '创建者',
create_dept_id VARCHAR(64) NULL COMMENT '创建部门',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by VARCHAR(64) NULL COMMENT '更新者',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
del_flag CHAR(1) DEFAULT '0' COMMENT '删除标志(0存在 1删除)',
PRIMARY KEY (id),
INDEX idx_sso_slot (slot_id, occupancy_status, del_flag),
INDEX idx_sso_item (item_type, item_id, del_flag),
INDEX idx_sso_tenant (tenant_no, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库位占用记录表';
-- 2. 增强:biz_storage_facility_slot 增加缓存字段
ALTER TABLE biz_storage_facility_slot
ADD COLUMN occupied_count INT DEFAULT 0 COMMENT '当前占用数量',
ADD COLUMN last_item_type VARCHAR(32) NULL COMMENT '最近存储物品类型',
ADD COLUMN last_stored_at DATETIME NULL COMMENT '最近入库时间';
Files:
backend/app/models/models.py (在 BizStorageFacilitySlot 类后插入新模型,在 BizStorageFacilitySlot 类中增加字段)Modify: backend/app/models/__init__.py
[ ] Step 1: 在 BizStorageFacilitySlot 模型中增加缓存字段
在 backend/app/models/models.py 中,BizStorageFacilitySlot 类的 del_flag 字段后面,增加三个字段:
occupied_count = Column(Integer, nullable=True, default=0, comment="当前占用数量")
last_item_type = Column(String(32), nullable=True, comment="最近存储物品类型")
last_stored_at = Column(DateTime, nullable=True, comment="最近入库时间")
即修改后的 BizStorageFacilitySlot 类完整如下:
class BizStorageFacilitySlot(Base):
"""存储设施小格 biz_storage_facility_slot"""
__tablename__ = "biz_storage_facility_slot"
id = Column(String(64), primary_key=True, comment="主键ID")
facility_node_id = Column(String(64), nullable=False, comment="设施树叶子节点ID")
slot_code = Column(String(64), nullable=True, comment="小格编码/坐标")
slot_status = Column(String(16), nullable=True, comment="占用状态等扩展")
occupied_count = Column(Integer, nullable=True, default=0, comment="当前占用数量")
last_item_type = Column(String(32), nullable=True, comment="最近存储物品类型")
last_stored_at = Column(DateTime, nullable=True, comment="最近入库时间")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, 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="删除标记")
BizStorageFacilitySlot 类之后插入 BizStorageSlotOccupancy 模型在 BizStorageFacilitySlot 类结束(del_flag 行之后)和 BizExhibitionAppointment 类之间,插入新模型:
class BizStorageSlotOccupancy(Base):
"""库位占用记录 biz_storage_slot_occupancy"""
__tablename__ = "biz_storage_slot_occupancy"
id = Column(String(64), primary_key=True, comment="主键ID")
slot_id = Column(String(64), nullable=False, comment="储物格ID")
item_type = Column(String(32), nullable=False, comment="物品类型: cell_product/other")
item_id = Column(String(64), nullable=False, comment="物品ID")
item_code = Column(String(64), nullable=True, comment="物品编码(冗余)")
item_name = Column(String(200), nullable=True, comment="物品名称(冗余)")
item_source = Column(String(32), nullable=True, comment="来源: production/manual/transfer")
stored_at = Column(DateTime, nullable=True, comment="入库时间")
removed_at = Column(DateTime, nullable=True, comment="出库时间")
occupancy_status = Column(String(16), nullable=False, default="stored", comment="stored=在库/removed=已出库")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(500), 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="删除标记")
backend/app/models/__init__.py 中导出新模型在 import 块中(BizStorageFacilitySlot 附近,实际文件中没有直接导出它,但需要在 from app.models.models import (...) 中添加):
在 from app.models.models import ( 的导入列表末尾(BizProductionTaskOutput, 之后)添加:
BizStorageSlotOccupancy,
在 __all__ 列表末尾添加:
"BizStorageSlotOccupancy",
Files:
Create: backend/app/schemas/storage_occupancy.py
[ ] Step 1: 创建 Pydantic schemas 文件
"""库位占用记录 schemas"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class OccupancyCreateBody(BaseModel):
"""手动入库请求"""
model_config = ConfigDict(populate_by_name=True)
slot_id: str = Field(..., alias="slotId")
item_type: str = Field("other", alias="itemType")
item_code: str = Field(..., alias="itemCode")
item_name: str = Field(..., alias="itemName")
item_source: str = Field("manual", alias="itemSource")
remark: Optional[str] = None
class OccupancyListQuery(BaseModel):
"""占用记录查询"""
model_config = ConfigDict(populate_by_name=True)
slot_id: str = Field(..., alias="slotId")
occupancy_status: Optional[str] = Field("stored", alias="occupancyStatus")
item_type: Optional[str] = Field(None, alias="itemType")
page: int = 1
page_size: int = Field(20, alias="pageSize")
class OccupancyHistoryQuery(BaseModel):
"""出入库历史查询"""
model_config = ConfigDict(populate_by_name=True)
slot_id: Optional[str] = Field(None, alias="slotId")
facility_node_id: Optional[str] = Field(None, alias="facilityNodeId")
item_type: Optional[str] = Field(None, alias="itemType")
start_date: Optional[str] = Field(None, alias="startDate")
end_date: Optional[str] = Field(None, alias="endDate")
page: int = 1
page_size: int = Field(20, alias="pageSize")
Files:
Create: backend/app/crud/storage_occupancy.py
[ ] Step 1: 创建 CRUD 文件
"""库位占用记录 CRUD(biz_storage_slot_occupancy)"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import BizStorageFacilitySlot, BizStorageSlotOccupancy
def _row_to_dict(c: BizStorageSlotOccupancy) -> Dict[str, Any]:
return {
"id": c.id,
"slotId": c.slot_id,
"itemType": c.item_type or "",
"itemId": c.item_id or "",
"itemCode": c.item_code or "",
"itemName": c.item_name or "",
"itemSource": c.item_source or "",
"storedAt": c.stored_at.strftime("%Y-%m-%d %H:%M:%S") if c.stored_at else "",
"removedAt": c.removed_at.strftime("%Y-%m-%d %H:%M:%S") if c.removed_at else "",
"occupancyStatus": c.occupancy_status or "",
"tenantNo": c.tenant_no,
"remark": c.remark or "",
"createBy": c.create_by or "",
"createTime": c.create_time.strftime("%Y-%m-%d %H:%M:%S") if c.create_time else "",
}
def _refresh_slot_cache(db: Session, slot_id: str) -> None:
"""刷新 slot 的 occupied_count / last_item_type / last_stored_at / slot_status"""
slot = db.query(BizStorageFacilitySlot).filter(
BizStorageFacilitySlot.id == slot_id,
BizStorageFacilitySlot.del_flag == 0,
).first()
if not slot:
return
count = db.query(func.count(BizStorageSlotOccupancy.id)).filter(
BizStorageSlotOccupancy.slot_id == slot_id,
BizStorageSlotOccupancy.occupancy_status == "stored",
BizStorageSlotOccupancy.del_flag == 0,
).scalar() or 0
slot.occupied_count = count
if count > 0:
slot.slot_status = "占用"
latest = db.query(BizStorageSlotOccupancy).filter(
BizStorageSlotOccupancy.slot_id == slot_id,
BizStorageSlotOccupancy.occupancy_status == "stored",
BizStorageSlotOccupancy.del_flag == 0,
).order_by(BizStorageSlotOccupancy.stored_at.desc()).first()
if latest:
slot.last_item_type = latest.item_type
slot.last_stored_at = latest.stored_at
else:
slot.slot_status = "空"
slot.last_item_type = None
slot.last_stored_at = None
slot.update_time = datetime.now()
db.add(slot)
db.flush()
def create_occupancy(
db: Session,
*,
slot_id: str,
item_type: str,
item_id: str,
item_code: str,
item_name: str,
item_source: str = "manual",
tenant_no: str = "",
user_id: Optional[str] = None,
remark: Optional[str] = None,
) -> BizStorageSlotOccupancy:
"""创建入库记录 + 刷新 slot 缓存"""
now = datetime.now()
obj = BizStorageSlotOccupancy(
id=generate_uuid(),
slot_id=slot_id,
item_type=item_type,
item_id=item_id,
item_code=(item_code or "").strip() or None,
item_name=(item_name or "").strip() or None,
item_source=(item_source or "manual").strip(),
stored_at=now,
removed_at=None,
occupancy_status="stored",
tenant_no=tenant_no,
order_num=0,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
db.flush()
_refresh_slot_cache(db, slot_id)
db.commit()
db.refresh(obj)
return obj
def batch_create_occupancies(
db: Session,
*,
items: List[Dict[str, Any]],
tenant_no: str = "",
user_id: Optional[str] = None,
) -> List[BizStorageSlotOccupancy]:
"""批量入库"""
now = datetime.now()
created: List[BizStorageSlotOccupancy] = []
slot_ids_seen: set = set()
for it in items:
obj = BizStorageSlotOccupancy(
id=generate_uuid(),
slot_id=it["slot_id"],
item_type=it.get("item_type", "cell_product"),
item_id=it["item_id"],
item_code=(it.get("item_code") or "").strip() or None,
item_name=(it.get("item_name") or "").strip() or None,
item_source=(it.get("item_source") or "production").strip(),
stored_at=now,
removed_at=None,
occupancy_status="stored",
tenant_no=tenant_no,
order_num=0,
remark=(it.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
created.append(obj)
slot_ids_seen.add(it["slot_id"])
db.flush()
for sid in slot_ids_seen:
_refresh_slot_cache(db, sid)
db.commit()
for obj in created:
db.refresh(obj)
return created
def remove_occupancy(
db: Session,
occupancy_id: str,
*,
user_id: Optional[str] = None,
) -> BizStorageSlotOccupancy:
"""手动出库"""
obj = db.query(BizStorageSlotOccupancy).filter(
BizStorageSlotOccupancy.id == occupancy_id,
BizStorageSlotOccupancy.del_flag == 0,
).first()
if not obj:
raise ValueError("占用记录不存在")
if obj.occupancy_status == "removed":
raise ValueError("该物品已出库")
now = datetime.now()
obj.occupancy_status = "removed"
obj.removed_at = now
obj.update_by = user_id
obj.update_time = now
db.add(obj)
db.flush()
_refresh_slot_cache(db, obj.slot_id)
db.commit()
db.refresh(obj)
return obj
def remove_by_item(
db: Session,
item_type: str,
item_id: str,
*,
user_id: Optional[str] = None,
) -> None:
"""按物品类型+ID出库(细胞产品状态变更时调用)"""
obj = db.query(BizStorageSlotOccupancy).filter(
BizStorageSlotOccupancy.item_type == item_type,
BizStorageSlotOccupancy.item_id == item_id,
BizStorageSlotOccupancy.occupancy_status == "stored",
BizStorageSlotOccupancy.del_flag == 0,
).first()
if not obj:
return
now = datetime.now()
obj.occupancy_status = "removed"
obj.removed_at = now
obj.update_by = user_id
obj.update_time = now
db.add(obj)
db.flush()
_refresh_slot_cache(db, obj.slot_id)
db.commit()
def list_occupancies(
db: Session,
*,
slot_id: str,
occupancy_status: Optional[str] = "stored",
item_type: Optional[str] = None,
tenant_no: str = "",
page: int = 1,
page_size: int = 20,
) -> Dict[str, Any]:
"""分页查询占用记录"""
q = db.query(BizStorageSlotOccupancy).filter(
BizStorageSlotOccupancy.slot_id == slot_id,
BizStorageSlotOccupancy.del_flag == 0,
)
if tenant_no:
q = q.filter(BizStorageSlotOccupancy.tenant_no == tenant_no)
if occupancy_status:
q = q.filter(BizStorageSlotOccupancy.occupancy_status == occupancy_status)
if item_type:
q = q.filter(BizStorageSlotOccupancy.item_type == item_type)
total = q.order_by(None).with_entities(func.count(BizStorageSlotOccupancy.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
q.order_by(BizStorageSlotOccupancy.stored_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return {"items": [_row_to_dict(r) for r in rows], "total": int(total)}
def list_occupancy_history(
db: Session,
*,
slot_id: Optional[str] = None,
facility_node_id: Optional[str] = None,
item_type: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
tenant_no: str = "",
page: int = 1,
page_size: int = 20,
) -> Dict[str, Any]:
"""出入库历史"""
q = db.query(BizStorageSlotOccupancy).filter(
BizStorageSlotOccupancy.del_flag == 0,
)
if tenant_no:
q = q.filter(BizStorageSlotOccupancy.tenant_no == tenant_no)
if slot_id:
q = q.filter(BizStorageSlotOccupancy.slot_id == slot_id)
if item_type:
q = q.filter(BizStorageSlotOccupancy.item_type == item_type)
if start_date:
try:
sd = datetime.strptime(start_date, "%Y-%m-%d")
q = q.filter(BizStorageSlotOccupancy.stored_at >= sd)
except ValueError:
pass
if end_date:
try:
ed = datetime.strptime(end_date, "%Y-%m-%d")
ed = ed.replace(hour=23, minute=59, second=59)
q = q.filter(BizStorageSlotOccupancy.stored_at <= ed)
except ValueError:
pass
if facility_node_id:
slot_ids = [
r[0] for r in db.query(BizStorageFacilitySlot.id).filter(
BizStorageFacilitySlot.facility_node_id == facility_node_id,
BizStorageFacilitySlot.del_flag == 0,
).all()
]
if slot_ids:
q = q.filter(BizStorageSlotOccupancy.slot_id.in_(slot_ids))
else:
return {"items": [], "total": 0}
total = q.order_by(None).with_entities(func.count(BizStorageSlotOccupancy.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
q.order_by(BizStorageSlotOccupancy.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return {"items": [_row_to_dict(r) for r in rows], "total": int(total)}
Files:
Modify: backend/app/api/operations.py
[ ] Step 1: 在文件顶部导入中添加新依赖
在 backend/app/api/operations.py 的 import 区域,添加:
from app.crud import storage_occupancy as occupancy_svc
from app.models.models import BizStorageSlotOccupancy
(BizStorageSlotOccupancy 加到现有的 BizStorageFacility, BizStorageFacilitySlot 后面)
在 operations.py 文件中,找到 storage-facility-slots 相关路由的末尾(大约在 delete_storage_slot 路由之后),添加以下路由:
# ---------------------------------------------------------------------------
# 库位占用记录
# ---------------------------------------------------------------------------
class OccupancyCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
slot_id: str = Field(..., alias="slotId")
item_type: str = Field("other", alias="itemType")
item_code: str = Field(..., alias="itemCode")
item_name: str = Field(..., alias="itemName")
item_source: str = Field("manual", alias="itemSource")
remark: Optional[str] = None
@router.get("/storage-slot-occupancies")
async def list_occupancies(
slotId: str = Query(..., alias="slotId"),
occupancyStatus: Optional[str] = Query("stored", alias="occupancyStatus"),
itemType: Optional[str] = Query(None, alias="itemType"),
page: int = Query(1),
pageSize: int = Query(20, alias="pageSize"),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
result = occupancy_svc.list_occupancies(
db,
slot_id=slotId,
occupancy_status=occupancyStatus,
item_type=itemType,
tenant_no=tenant_no,
page=page,
page_size=pageSize,
)
return success_response(result)
@router.get("/storage-slot-occupancies/history")
async def list_occupancy_history(
slotId: Optional[str] = Query(None, alias="slotId"),
facilityNodeId: Optional[str] = Query(None, alias="facilityNodeId"),
itemType: Optional[str] = Query(None, alias="itemType"),
startDate: Optional[str] = Query(None, alias="startDate"),
endDate: Optional[str] = Query(None, alias="endDate"),
page: int = Query(1),
pageSize: int = Query(20, alias="pageSize"),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
result = occupancy_svc.list_occupancy_history(
db,
slot_id=slotId,
facility_node_id=facilityNodeId,
item_type=itemType,
start_date=startDate,
end_date=endDate,
tenant_no=tenant_no,
page=page,
page_size=pageSize,
)
return success_response(result)
@router.post("/storage-slot-occupancies")
async def create_occupancy(
body: OccupancyCreateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
item_id = generate_uuid()
obj = occupancy_svc.create_occupancy(
db,
slot_id=body.slot_id,
item_type=body.item_type,
item_id=item_id,
item_code=body.item_code,
item_name=body.item_name,
item_source=body.item_source,
tenant_no=tenant_no,
user_id=user.get("user_id"),
remark=body.remark,
)
return success_response(occupancy_svc._row_to_dict(obj), message="入库成功")
except ValueError as e:
return error_response(400, str(e))
@router.put("/storage-slot-occupancies/{occupancy_id}/remove")
async def remove_occupancy(
occupancy_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
obj = occupancy_svc.remove_occupancy(
occupancy_id,
user_id=user.get("user_id"),
db=db,
)
return success_response(occupancy_svc._row_to_dict(obj), message="出库成功")
except ValueError as e:
return error_response(400, str(e))
注意:需要在文件顶部增加 generate_uuid 导入:
from app.core.utils import generate_uuid
Files:
Modify: backend/app/crud/cell_product.py
[ ] Step 1: 在文件顶部添加导入
在 from app.core.utils import generate_uuid 之后添加:
from app.crud import storage_occupancy as occupancy_svc
create_cell_product 函数中,db.commit() 之前添加 occupancy 同步在 create_cell_product 函数中,找到 db.add(obj) 之后、db.commit() 之前的位置。在 db.add(obj) 后面添加:
db.flush()
# 同步写入占用记录
if obj.storage_slot_id:
try:
occupancy_svc.create_occupancy(
db,
slot_id=obj.storage_slot_id,
item_type="cell_product",
item_id=obj.id,
item_code=obj.product_code,
item_name=obj.product_name,
item_source=obj.source_type or "manual",
tenant_no=tenant_no,
user_id=user_id,
)
except Exception:
pass # occupancy 写入失败不影响主流程
注意:原来的 db.commit() 和 db.refresh(obj) 保持不变(create_occupancy 内部已经有 db.commit())。
但实际上 create_occupancy 有自己的 commit,所以需要调整 —— 在 create_cell_product 中改为先 db.flush(),然后调 occupancy(occupancy 内部 commit 会一起提交),然后 refresh。
实际修改策略:把 create_occupancy 改为不 commit 的内部版本,让调用方控制事务。
更好的做法:在 cell_product.py 中直接内联写 occupancy 记录,不通过 occupancy_svc.create_occupancy(避免嵌套 commit 问题)。
最终方案:在 create_cell_product 的 db.add(obj) 之后、db.commit() 之前,直接内联写入 occupancy:
db.add(obj)
db.flush() # 先 flush 拿到 obj.id
# 同步写入占用记录
if obj.storage_slot_id:
occ = BizStorageSlotOccupancy(
id=generate_uuid(),
slot_id=obj.storage_slot_id,
item_type="cell_product",
item_id=obj.id,
item_code=obj.product_code,
item_name=obj.product_name,
item_source=obj.source_type or "manual",
stored_at=datetime.now(),
occupancy_status="stored",
tenant_no=tenant_no,
order_num=0,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
)
db.add(occ)
occupancy_svc._refresh_slot_cache(db, obj.storage_slot_id)
db.commit()
db.refresh(obj)
return obj
同时在文件顶部 import 中添加:
from app.models.models import BizStorageSlotOccupancy
create_cell_products_batch 函数中添加批量 occupancy 同步在 create_cell_products_batch 函数中,db.commit() 之前(即 for 循环结束之后),添加:
# 批量同步占用记录
for obj in created:
if obj.storage_slot_id:
occ = BizStorageSlotOccupancy(
id=generate_uuid(),
slot_id=obj.storage_slot_id,
item_type="cell_product",
item_id=obj.id,
item_code=obj.product_code,
item_name=obj.product_name,
item_source=obj.source_type or "manual",
stored_at=now,
occupancy_status="stored",
tenant_no=tenant_no,
order_num=0,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(occ)
# 刷新涉及的 slot 缓存
slot_ids_seen = {obj.storage_slot_id for obj in created if obj.storage_slot_id}
for sid in slot_ids_seen:
occupancy_svc._refresh_slot_cache(db, sid)
这段代码放在 db.add(obj) 循环结束之后、db.commit() 之前。
update_cell_product 函数中,状态变更时触发出库在 update_cell_product 函数中,找到状态变更的代码块:
if data.product_status is not None and data.product_status != obj.product_status:
allowed = {"frozen": {"sold", "used", "voided"}}
from_status = obj.product_status
to_status = data.product_status
if from_status not in allowed or to_status not in allowed[from_status]:
raise ValueError(f"不允许从状态 {from_status} 变更为 {to_status}")
obj.product_status = data.product_status
在 obj.product_status = data.product_status 之后添加:
# 状态变更时自动出库占用记录
if obj.storage_slot_id:
try:
occupancy_svc.remove_by_item(
db, "cell_product", obj.id, user_id=user_id,
)
except Exception:
pass
Files:
Modify: web-admin/src/api/admin.ts
[ ] Step 1: 在库位管理 API 区域末尾添加 4 个新函数
在 fetchDeleteStorageSlot 函数之后、// 展馆预约 注释之前,添加:
// 库位占用记录
export function fetchSlotOccupancies(params: {
slotId: string
occupancyStatus?: string
itemType?: string
page?: number
pageSize?: number
}) {
return request.get({ url: '/web/admin/storage-slot-occupancies', params })
}
export function fetchSlotOccupancyHistory(params?: {
slotId?: string
facilityNodeId?: string
itemType?: string
startDate?: string
endDate?: string
page?: number
pageSize?: number
}) {
return request.get({ url: '/web/admin/storage-slot-occupancies/history', params })
}
export function fetchCreateOccupancy(data: {
slotId: string
itemType?: string
itemCode: string
itemName: string
itemSource?: string
remark?: string
}) {
return request.post({ url: '/web/admin/storage-slot-occupancies', params: data })
}
export function fetchRemoveOccupancy(id: string) {
return request.put({ url: `/web/admin/storage-slot-occupancies/${id}/remove` })
}
Files:
Modify: web-admin/src/views/operations/storage-facility/index.vue
[ ] Step 1: 添加新的 API 导入
在文件 <script setup> 区域的 import 块中,添加 3 个新 API 函数:
import {
fetchStorageFacilityList,
fetchCreateStorageFacility,
fetchUpdateStorageFacility,
fetchDeleteStorageFacility,
fetchStorageSlotList,
fetchBatchCreateSlots,
fetchClearStorageSlots,
fetchInsertStorageSlotRow,
fetchCreateStorageSlot,
fetchUpdateStorageSlot,
fetchDeleteStorageSlot,
fetchSlotOccupancies,
fetchSlotOccupancyHistory,
fetchCreateOccupancy,
fetchRemoveOccupancy
} from '@/api/admin'
将现有的格子点击事件从 @click="handleEditSlot(cell)" 改为弹出席占明细弹窗。
在 shelf-cell 的 click 处理中,将 handleEditSlot(cell) 替换为 handleOpenOccupancyDialog(cell):
<div
v-for="(cell, cellIndex) in row.cells"
:key="cell.id"
class="shelf-cell"
:class="{ occupied: cell.slotStatus === '占用' }"
@click="handleOpenOccupancyDialog(cell)"
>
<button class="cell-delete-btn" @click.stop="handleDeleteSlot(cell)" title="删除">×</button>
<span class="cell-code">{{ cellIndex + 1 }}</span>
<span v-if="cell.occupiedCount > 0" class="cell-badge">{{ cell.occupiedCount }}</span>
</div>
<template> 中添加占用明细弹窗在现有 </section> 之前、最后一个弹窗(新增行弹窗)之后,添加:
<!-- 占用明细弹窗 -->
<el-dialog
v-model="occupancyDialogVisible"
:title="occupancyDialogTitle"
width="720px"
class="dialog-form-layout"
align-center
destroy-on-close
>
<el-tabs v-model="occupancyActiveTab">
<el-tab-pane label="在库物品" name="stored">
<div class="occupancy-toolbar">
<el-button type="primary" size="small" @click="handleOpenStoreDialog">
入库
</el-button>
</div>
<el-table :data="occupancyList" v-loading="occupancyLoading" size="small" max-height="360">
<el-table-column label="物品类型" width="120">
<template #default="{ row }">
<el-tag :type="row.itemType === 'cell_product' ? 'primary' : 'info'" size="small">
{{ row.itemType === 'cell_product' ? '细胞产品' : '其他' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="itemCode" label="物品编码" min-width="140" />
<el-table-column prop="itemName" label="物品名称" min-width="140" />
<el-table-column label="来源" width="120">
<template #default="{ row }">
{{ { production: '生产入库', manual: '手动入库', transfer: '转移' }[row.itemSource] || row.itemSource }}
</template>
</el-table-column>
<el-table-column prop="storedAt" label="入库时间" width="170" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button type="danger" link size="small" @click="handleRemoveOccupancy(row)">
出库
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="occupancyTotal > occupancyPageSize" class="occupancy-pagination">
<el-pagination
small
layout="total, prev, pager, next"
:total="occupancyTotal"
:page-size="occupancyPageSize"
v-model:current-page="occupancyPage"
@current-change="loadOccupancies"
/>
</div>
</el-tab-pane>
<el-tab-pane label="出入库历史" name="history">
<el-table :data="historyList" v-loading="historyLoading" size="small" max-height="400">
<el-table-column label="物品类型" width="120">
<template #default="{ row }">
<el-tag :type="row.itemType === 'cell_product' ? 'primary' : 'info'" size="small">
{{ row.itemType === 'cell_product' ? '细胞产品' : '其他' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="itemCode" label="物品编码" min-width="120" />
<el-table-column prop="itemName" label="物品名称" min-width="120" />
<el-table-column label="来源" width="100">
<template #default="{ row }">
{{ { production: '生产入库', manual: '手动入库', transfer: '转移' }[row.itemSource] || row.itemSource }}
</template>
</el-table-column>
<el-table-column prop="storedAt" label="入库时间" width="170" />
<el-table-column prop="removedAt" label="出库时间" width="170" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.occupancyStatus === 'stored' ? 'success' : 'warning'" size="small">
{{ row.occupancyStatus === 'stored' ? '在库' : '已出库' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div v-if="historyTotal > historyPageSize" class="occupancy-pagination">
<el-pagination
small
layout="total, prev, pager, next"
:total="historyTotal"
:page-size="historyPageSize"
v-model:current-page="historyPage"
@current-change="loadHistory"
/>
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
<!-- 手动入库弹窗 -->
<el-dialog
v-model="storeDialogVisible"
title="手动入库"
width="480px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="storeFormRef"
:model="storeFormData"
:rules="storeFormRules"
label-position="top"
>
<el-form-item label="物品编码" prop="itemCode">
<el-input v-model="storeFormData.itemCode" placeholder="请输入物品编码" />
</el-form-item>
<el-form-item label="物品名称" prop="itemName">
<el-input v-model="storeFormData.itemName" placeholder="请输入物品名称" />
</el-form-item>
<el-form-item label="来源" prop="itemSource">
<el-select v-model="storeFormData.itemSource" placeholder="请选择来源">
<el-option label="手动入库" value="manual" />
<el-option label="转移" value="transfer" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="storeFormData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="storeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="storeSubmitLoading" @click="handleStoreSubmit">确认入库</el-button>
</template>
</el-dialog>
<script setup> 中添加占用弹窗相关数据和方法在脚本末尾(onMounted(loadData) 之前)添加:
// ==================== 占用明细弹窗 ====================
const occupancyDialogVisible = ref(false)
const occupancyDialogTitle = ref('')
const occupancyActiveTab = ref('stored')
const currentSlot = ref<any>(null)
const occupancyLoading = ref(false)
const occupancyList = ref<any[]>([])
const occupancyTotal = ref(0)
const occupancyPage = ref(1)
const occupancyPageSize = 20
const historyLoading = ref(false)
const historyList = ref<any[]>([])
const historyTotal = ref(0)
const historyPage = ref(1)
const historyPageSize = 20
// 手动入库弹窗
const storeDialogVisible = ref(false)
const storeSubmitLoading = ref(false)
const storeFormRef = ref()
const storeFormData = reactive({
itemCode: '',
itemName: '',
itemSource: 'manual',
remark: ''
})
const storeFormRules = {
itemCode: [{ required: true, message: '请输入物品编码', trigger: 'blur' }],
itemName: [{ required: true, message: '请输入物品名称', trigger: 'blur' }],
itemSource: [{ required: true, message: '请选择来源', trigger: 'change' }]
}
const handleOpenOccupancyDialog = (cell: any) => {
currentSlot.value = cell
occupancyDialogTitle.value = `储物格明细 — ${cell.slotCode || cell.id}`
occupancyActiveTab.value = 'stored'
occupancyPage.value = 1
historyPage.value = 1
occupancyDialogVisible.value = true
loadOccupancies()
loadHistory()
}
const loadOccupancies = async () => {
if (!currentSlot.value) return
occupancyLoading.value = true
try {
const res: any = await fetchSlotOccupancies({
slotId: currentSlot.value.id,
occupancyStatus: 'stored',
page: occupancyPage.value,
pageSize: occupancyPageSize
})
const data = res?.data || res || {}
occupancyList.value = data.items || data.list || []
occupancyTotal.value = data.total || 0
} catch {
occupancyList.value = []
} finally {
occupancyLoading.value = false
}
}
const loadHistory = async () => {
if (!currentSlot.value) return
historyLoading.value = true
try {
const res: any = await fetchSlotOccupancyHistory({
slotId: currentSlot.value.id,
page: historyPage.value,
pageSize: historyPageSize
})
const data = res?.data || res || {}
historyList.value = data.items || data.list || []
historyTotal.value = data.total || 0
} catch {
historyList.value = []
} finally {
historyLoading.value = false
}
}
const handleOpenStoreDialog = () => {
Object.assign(storeFormData, { itemCode: '', itemName: '', itemSource: 'manual', remark: '' })
storeDialogVisible.value = true
nextTick(() => storeFormRef.value?.clearValidate?.())
}
const handleStoreSubmit = async () => {
await storeFormRef.value?.validate?.()
if (!currentSlot.value) return
storeSubmitLoading.value = true
try {
await fetchCreateOccupancy({
slotId: currentSlot.value.id,
itemType: 'other',
itemCode: storeFormData.itemCode.trim(),
itemName: storeFormData.itemName.trim(),
itemSource: storeFormData.itemSource,
remark: storeFormData.remark?.trim() || undefined
})
ElMessage.success('入库成功')
storeDialogVisible.value = false
loadOccupancies()
loadHistory()
if (selectedNode.value?.id) loadSlots(selectedNode.value.id)
} catch {
/* 错误已全局处理 */
} finally {
storeSubmitLoading.value = false
}
}
const handleRemoveOccupancy = async (row: any) => {
try {
await ElMessageBox.confirm(`确认将「${row.itemName || row.itemCode}」出库?`, '出库确认', {
type: 'warning'
})
await fetchRemoveOccupancy(row.id)
ElMessage.success('出库成功')
loadOccupancies()
loadHistory()
if (selectedNode.value?.id) loadSlots(selectedNode.value.id)
} catch {
/* 取消或错误 */
}
}
<style> 中添加角标和分页样式在 <style> 区域末尾添加:
.cell-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
line-height: 16px;
font-size: 10px;
text-align: center;
color: #fff;
background: #f56c6c;
border-radius: 8px;
padding: 0 4px;
}
.occupancy-toolbar {
margin-bottom: 12px;
}
.occupancy-pagination {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
# 在 MySQL 中执行迁移脚本
mysql -u root -p sanqiang < docs/sql/2026-05-22-storage-slot-occupancy.sql
检查 backend/app/models/models.py、backend/app/crud/cell_product.py、backend/app/api/operations.py 无语法错误。
cd web-admin && pnpm build
Expected: 构建成功,无错误。
./scripts/update-graph.sh all