Files
zkxw/docs/superpowers/plans/2026-07-28-storage-facility-batch-outbound.md
T
zlwuandCursor b105bd9b7d chore: update graphify and mark batch outbound plan complete
Refresh knowledge graphs after storage facility batch outbound changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 19:15:35 +08:00

504 lines
15 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 库位管理货架批量出库 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 行)
- [x] **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 与物品摘要(名称/类型),供库位货架展示与批量出库。」
- [x] **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`
- [x] **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`
- [x] **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>
```
(无格子时才显示「批量生成格子」;有格子时不显示该按钮——与当前「有格子则禁用」等价且更干净。)
- [x] **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
}
```
- [x] **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
}
```
与单条出库一致优先「错误已全局处理」+ 补充已成功数量提示。
- [x] **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
```
- [x] **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`
- [x] **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>
```
- [x] **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;
}
```
注意:删除按钮在右上,勾选在左上,互不遮挡。
- [x] **Step 3: 清空格子按钮补图标(若缺失)**
若「清空格子」尚无 `el-icon`,按 ui-conventions 补一个合适图标(如 `Delete`),并在 script 中从 `@element-plus/icons-vue` 导入。本任务若只改批量出库相关,至少保证「出库已选」带 `Top` 图标(已导入)。
---
### Task 4: 手工验收与收尾
**Files:** 无新增
- [x] **Step 1: 本地验收清单**
在 `/#/production-group/production/storage-facility`:
1. 叶子节点有占用格:左上有勾选框;空格无勾选框
2. 点勾选框:选中/取消,工具栏「已选 N」变化;点格身:仍开明细
3. 选 2+ 格 →「出库已选」→ 确认框列出编码与名称 → 确认后货架变空、统计更新、选择清空
4. 明细弹窗内单条出库仍可用;若该格曾勾选,选择集合中移除
5. 切换左侧树节点:选择清空
6. (可选)断网或造失败:已成功条数提示 + 后续中止
- [x] **Step 2: 更新知识图谱**
```bash
./scripts/update-graph.sh backend
# 若仅前端大改也可:./scripts/update-graph.sh frontend
# 本功能前后端都改时:
./scripts/update-graph.sh all
```
- [x] **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 项」。