docs: add storage facility batch outbound implementation plan

Break down shelf checkbox selection and serial occupancy remove into backend enrichment and Vue tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-28 19:10:29 +08:00
1 parent 45c2e3df01
commit d4c927a8b9
1 file changed
+503
@@ -0,0 +1,503 @@
# 库位管理货架批量出库 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 (`- [ ]`) 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` 之前(或紧挨其后)增加:
```python
slot["storedOccupancyId"] = occ.id
```
完整片段应类似:
```python
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:
```bash
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**
```bash
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 行)改为(保留「批量生成格子 / 清空格子」,并在前插入已选与出库):
```vue
<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"`
推荐实现(与规范「同一行仅一个主行动」一致):
```vue
<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` 附近增加:
```ts
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**
```ts
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`,改为:
```ts
} 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`(明细弹窗单条出库)成功后:
```ts
const next = new Map(selectedOutboundMap.value)
next.delete(currentSlot.value?.id)
selectedOutboundMap.value = next
```
- [ ] **Step 5: 筛选时 prune 仅保留当前可见占用格**
页面已有 `slotMatchesFilter` 与 `filteredSlotList`。在其旁增加 watch:
```ts
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**
将格子节点改为:
```vue
<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` 样式块附近增加:
```scss
.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`:
1. 叶子节点有占用格:左上有勾选框;空格无勾选框
2. 点勾选框:选中/取消,工具栏「已选 N」变化;点格身:仍开明细
3. 选 2+ 格 →「出库已选」→ 确认框列出编码与名称 → 确认后货架变空、统计更新、选择清空
4. 明细弹窗内单条出库仍可用;若该格曾勾选,选择集合中移除
5. 切换左侧树节点:选择清空
6. (可选)断网或造失败:已成功条数提示 + 后续中止
- [ ] **Step 2: 更新知识图谱**
```bash
./scripts/update-graph.sh backend
# 若仅前端大改也可:./scripts/update-graph.sh frontend
# 本功能前后端都改时:
./scripts/update-graph.sh all
```
- [ ] **Step 3: Commit 前端**
```bash
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 项」。