Refresh knowledge graphs after storage facility batch outbound changes. Co-authored-by: Cursor <cursoragent@cursor.com>
15 KiB
库位管理货架批量出库 Implementation Plan
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 (
- [x]) syntax for tracking.
Goal: 在库位管理货架上对占用格常驻勾选,工具栏「出库已选」串行调用现有出库接口完成批量出库。
Architecture: 后端货架 enrichment 补齐 storedOccupancyId;前端用 Map<slotId, selection> 管理勾选;勾选框与格子点击解耦;确认后串行 fetchRemoveOccupancy;不新增批量出库 API。
Tech Stack: FastAPI/SQLAlchemy、Vue 3、Element Plus、现有 fetchRemoveOccupancy
Spec: docs/superpowers/specs/2026-07-28-storage-facility-batch-outbound-design.md
File Structure
| File | Responsibility |
|---|---|
backend/app/crud/storage_occupancy.py |
enrich_slots_with_stored_cell_products 写入 storedOccupancyId |
web-admin/src/views/operations/storage-facility/index.vue |
勾选 UI、工具栏、确认与串行出库、生命周期清理、样式 |
Task 1: 货架 enrichment 增加 storedOccupancyId
Files:
-
Modify:
backend/app/crud/storage_occupancy.py(enrich_slots_with_stored_cell_products,约 306–325 行) -
Step 1: 写入 storedOccupancyId
在循环内、设置 storedItemType 之前(或紧挨其后)增加:
slot["storedOccupancyId"] = occ.id
完整片段应类似:
for slot in slots:
occ = occ_by_slot.get(slot.get("id"))
if not occ:
continue
slot["storedOccupancyId"] = occ.id
slot["storedItemType"] = occ.item_type or ""
slot["storedItemCode"] = occ.item_code or ""
slot["storedItemName"] = occ.item_name or ""
# ... 其余 storedCellProduct 逻辑不变
更新函数 docstring 为:「为占用格附带在库占用 id 与物品摘要(名称/类型),供库位货架展示与批量出库。」
- Step 2: 语法校验
Run:
cd backend && source venv/bin/activate && python -c "from app.crud.storage_occupancy import enrich_slots_with_stored_cell_products; print('ok')"
Expected: ok
- Step 3: Commit
git add backend/app/crud/storage_occupancy.py
git commit -m "$(cat <<'EOF'
feat(storage): expose storedOccupancyId on shelf slot enrichment
Enable web-admin batch outbound to call existing remove occupancy API per selected cell.
EOF
)"
Task 2: 选择状态、工具栏与串行出库逻辑
Files:
-
Modify:
web-admin/src/views/operations/storage-facility/index.vue -
Step 1: 模板 — 改造
.storage-toolbar
将现有工具栏(约 123–139 行)改为(保留「批量生成格子 / 清空格子」,并在前插入已选与出库):
<div class="storage-toolbar">
<template v-if="slotList.length > 0">
<span class="storage-toolbar__selected">已选 {{ selectedOutboundCount }} 项</span>
<el-button
type="primary"
:disabled="selectedOutboundCount === 0"
:loading="batchOutboundLoading"
@click="handleBatchOutbound"
>
<el-icon><Top /></el-icon>
出库已选
</el-button>
</template>
<el-button
type="primary"
:disabled="slotList.length > 0"
@click="batchDialogVisible = true"
>
<el-icon><Plus /></el-icon>
批量生成格子
</el-button>
<el-button
v-if="slotList.length > 0"
class="btn-ghost"
:loading="clearSlotsLoading"
@click="handleClearSlots"
>
清空格子
</el-button>
</div>
说明:当 slotList.length > 0 时,「批量生成格子」仍禁用(与现网一致);「出库已选」为该行唯一可点的主色按钮时,可将「批量生成格子」在有格子时改为 class="btn-ghost" 且不带 type="primary",避免同排两个 primary。推荐最终:
- 有格子时:「出库已选」
type="primary";「批量生成格子」隐藏或保持 disabled 中性;「清空格子」ghost - 无格子时:仅「批量生成格子」
type="primary"
推荐实现(与规范「同一行仅一个主行动」一致):
<div class="storage-toolbar">
<template v-if="slotList.length > 0">
<span class="storage-toolbar__selected">已选 {{ selectedOutboundCount }} 项</span>
<el-button
type="primary"
:disabled="selectedOutboundCount === 0"
:loading="batchOutboundLoading"
@click="handleBatchOutbound"
>
<el-icon><Top /></el-icon>
出库已选
</el-button>
<el-button class="btn-ghost" :loading="clearSlotsLoading" @click="handleClearSlots">
清空格子
</el-button>
</template>
<el-button
v-else
type="primary"
@click="batchDialogVisible = true"
>
<el-icon><Plus /></el-icon>
批量生成格子
</el-button>
</div>
(无格子时才显示「批量生成格子」;有格子时不显示该按钮——与当前「有格子则禁用」等价且更干净。)
- Step 2: script — 选择 Map 与辅助函数
在 script 中、slotFilterKeyword 附近增加:
type OutboundSelection = {
slotId: string
occupancyId: string
slotCode: string
itemName: string
}
const selectedOutboundMap = ref<Map<string, OutboundSelection>>(new Map())
const batchOutboundLoading = ref(false)
const selectedOutboundCount = computed(() => selectedOutboundMap.value.size)
function slotDisplayItemName(cell: any): string {
return (
cell?.storedCellProduct?.productName ||
cell?.storedItemName ||
cell?.storedItemCode ||
'—'
)
}
function isSlotSelectableForOutbound(cell: any): boolean {
return cell?.slotStatus === '占用' && !!(cell?.storedOccupancyId || '').toString().trim()
}
function isSlotOutboundSelected(slotId: string): boolean {
return selectedOutboundMap.value.has(slotId)
}
function toggleSlotOutboundSelect(cell: any, ev?: Event) {
ev?.stopPropagation?.()
if (!isSlotSelectableForOutbound(cell)) return
const id = cell.id
const next = new Map(selectedOutboundMap.value)
if (next.has(id)) {
next.delete(id)
} else {
next.set(id, {
slotId: id,
occupancyId: String(cell.storedOccupancyId),
slotCode: cell.fullSlotCode || cell.slotCode || id,
itemName: slotDisplayItemName(cell)
})
}
selectedOutboundMap.value = next
}
function clearOutboundSelection() {
selectedOutboundMap.value = new Map()
}
function pruneOutboundSelection(slots: any[]) {
const byId = new Map(slots.map((s) => [s.id, s]))
const next = new Map<string, OutboundSelection>()
for (const [slotId, sel] of selectedOutboundMap.value) {
const cell = byId.get(slotId)
if (cell && isSlotSelectableForOutbound(cell)) {
next.set(slotId, {
...sel,
occupancyId: String(cell.storedOccupancyId),
slotCode: cell.fullSlotCode || cell.slotCode || sel.slotCode,
itemName: slotDisplayItemName(cell)
})
}
}
selectedOutboundMap.value = next
}
- Step 3: script — handleBatchOutbound
const handleBatchOutbound = async () => {
const items = [...selectedOutboundMap.value.values()]
if (!items.length) return
const preview = items
.slice(0, 20)
.map((i) => `${i.slotCode} ${i.itemName}`)
.join('\n')
const more =
items.length > 20 ? `\n…等共 ${items.length} 项` : items.length > 1 ? `\n共 ${items.length} 项` : ''
try {
await ElMessageBox.confirm(
`确认将以下储物格物品出库?\n\n${preview}${more}`,
'批量出库确认',
{ type: 'warning', confirmButtonText: '确认出库', cancelButtonText: '取消' }
)
} catch {
return
}
batchOutboundLoading.value = true
let done = 0
try {
for (const item of items) {
try {
await fetchRemoveOccupancy(item.occupancyId)
done += 1
} catch (e: any) {
const msg = e?.message || e?.msg || '未知错误'
ElMessage.error(
done > 0
? `已出库 ${done} 项,第 ${done + 1} 项失败:${msg}`
: `出库失败:${msg}`
)
if (selectedNode.value?.id) await loadSlots(selectedNode.value.id)
pruneOutboundSelection(slotList.value)
return
}
}
ElMessage.success(`出库成功,共 ${done} 项`)
clearOutboundSelection()
if (selectedNode.value?.id) await loadSlots(selectedNode.value.id)
} finally {
batchOutboundLoading.value = false
}
}
注意:项目全局请求失败可能已弹 Message;若重复提示,保留 ElMessage.error 中「已出库 M 项」语义即可,或 catch 后仅用自定义文案(按现有 handleRemoveOccupancy 的「错误已全局处理」风格:失败分支可不二次 ElMessage.error,改为:
} catch {
if (done > 0) {
ElMessage.warning(`已出库 ${done} 项,后续项失败已中止`)
}
if (selectedNode.value?.id) await loadSlots(selectedNode.value.id)
pruneOutboundSelection(slotList.value)
return
}
与单条出库一致优先「错误已全局处理」+ 补充已成功数量提示。
- Step 4: 生命周期 — 清空选择
在 handleNodeClick 开头调用 clearOutboundSelection(),并保留现有 slotFilterKeyword 清空逻辑。
在 handleClearSlots 成功清空后调用 clearOutboundSelection()。
在 loadSlots 成功赋值 slotList 之后调用 pruneOutboundSelection(slotList.value)(筛选变化时格子仍来自同一 list,可见性由 shelfRows 过滤;规格要求筛选后剔除不可见项——见 Step 5)。
在 handleRemoveOccupancy(明细弹窗单条出库)成功后:
const next = new Map(selectedOutboundMap.value)
next.delete(currentSlot.value?.id)
selectedOutboundMap.value = next
- Step 5: 筛选时 prune 仅保留当前可见占用格
页面已有 slotMatchesFilter 与 filteredSlotList。在其旁增加 watch:
watch(
() => [slotFilterKeyword.value, slotList.value] as const,
() => {
const visibleIds = new Set(filteredSlotList.value.map((s) => s.id))
const next = new Map<string, OutboundSelection>()
for (const [id, sel] of selectedOutboundMap.value) {
if (!visibleIds.has(id)) continue
const cell = slotList.value.find((s) => s.id === id)
if (cell && isSlotSelectableForOutbound(cell)) next.set(id, sel)
}
selectedOutboundMap.value = next
}
)
Task 3: 格子勾选 UI 与样式
Files:
-
Modify:
web-admin/src/views/operations/storage-facility/index.vue -
Step 1: 模板 — shelf-cell 增加勾选框与 selected class
将格子节点改为:
<div
v-for="cell in row.cells"
:key="cell.id"
class="shelf-cell"
:class="{
occupied: cell.slotStatus === '占用',
'is-outbound-selected': isSlotOutboundSelected(cell.id)
}"
@click="handleOpenOccupancyDialog(cell)"
>
<button
v-if="isSlotSelectableForOutbound(cell)"
class="cell-check-btn"
type="button"
title="选择出库"
:aria-pressed="isSlotOutboundSelected(cell.id)"
@click="toggleSlotOutboundSelect(cell, $event)"
>
<span class="cell-check-btn__mark">✓</span>
</button>
<button
class="cell-delete-btn"
@click.stop="handleDeleteSlot(cell)"
title="删除"
>×</button>
<!-- 原有产品名 / 空格展示保持不变 -->
</div>
- Step 2: SCSS — 勾选框与选中态
在 .shelf-cell 样式块附近增加:
.cell-check-btn {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
padding: 0;
border: 1.5px solid #cbd5e1;
border-radius: 4px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2;
.cell-check-btn__mark {
font-size: 11px;
line-height: 1;
color: transparent;
}
&:hover {
border-color: var(--primary, #10b981);
}
}
.shelf-cell.is-outbound-selected {
outline: 2px solid var(--primary, #10b981);
outline-offset: 1px;
background: #ecfdf5;
border-color: #a7f3d0;
&.occupied {
background: #ecfdf5;
border-color: #a7f3d0;
.cell-product-name,
.cell-product-type {
color: var(--primary-dark, #059669);
}
}
.cell-check-btn {
background: var(--primary, #10b981);
border-color: var(--primary, #10b981);
.cell-check-btn__mark {
color: #fff;
}
}
}
.storage-toolbar__selected {
font-size: 13px;
color: var(--mint-text-muted, #64748b);
margin-right: 4px;
}
注意:删除按钮在右上,勾选在左上,互不遮挡。
- Step 3: 清空格子按钮补图标(若缺失)
若「清空格子」尚无 el-icon,按 ui-conventions 补一个合适图标(如 Delete),并在 script 中从 @element-plus/icons-vue 导入。本任务若只改批量出库相关,至少保证「出库已选」带 Top 图标(已导入)。
Task 4: 手工验收与收尾
Files: 无新增
- Step 1: 本地验收清单
在 /#/production-group/production/storage-facility:
- 叶子节点有占用格:左上有勾选框;空格无勾选框
- 点勾选框:选中/取消,工具栏「已选 N」变化;点格身:仍开明细
- 选 2+ 格 →「出库已选」→ 确认框列出编码与名称 → 确认后货架变空、统计更新、选择清空
- 明细弹窗内单条出库仍可用;若该格曾勾选,选择集合中移除
- 切换左侧树节点:选择清空
- (可选)断网或造失败:已成功条数提示 + 后续中止
- Step 2: 更新知识图谱
./scripts/update-graph.sh backend
# 若仅前端大改也可:./scripts/update-graph.sh frontend
# 本功能前后端都改时:
./scripts/update-graph.sh all
- Step 3: Commit 前端
git add web-admin/src/views/operations/storage-facility/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): batch outbound selected storage slots from shelf
Add always-on checkboxes on occupied cells and serial remove via existing occupancy API.
EOF
)"
若 Task 1 与前端分两次提交已完成,本步仅提交前端;图谱更新文件一并纳入合适的 chore commit。
Spec Coverage Checklist
| Spec 要求 | Task |
|---|---|
| 占用格常驻勾选 / 空格无 | Task 3 |
| 点框选中、点格身开明细 | Task 3 |
| 已选 N + 出库已选、无全选 | Task 2 |
| ElMessageBox 确认列表 | Task 2 |
| 串行 fetchRemoveOccupancy | Task 2 |
| storedOccupancyId enrichment | Task 1 |
| 切换节点 / 清空格子清选择 | Task 2 |
| 筛选后 prune 选中 | Task 2 Step 5 |
| 明细单条出库行为不变 + 同步选中 | Task 2 Step 4 |
| 中途失败保留已成功 | Task 2 Step 3 |
| 验收 | Task 4 |
Self-Review Notes
- 无 TBD;工具栏「有格子时隐藏批量生成」与现网「有格子则禁用」等价,避免双 primary。
- 筛选 prune 使用现有
filteredSlotList/slotMatchesFilter。 - 错误提示与全局 interceptor 可能重复:优先 warning 补充「已出库 M 项」。