# 库位管理货架批量出库 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` 管理勾选;勾选框与格子点击解耦;确认后串行 `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
批量生成格子 清空格子
``` 说明:当 `slotList.length > 0` 时,「批量生成格子」仍禁用(与现网一致);「出库已选」为该行唯一可点的主色按钮时,可将「批量生成格子」在有格子时改为 `class="btn-ghost"` 且不带 `type="primary"`,避免同排两个 primary。推荐最终: - 有格子时:「出库已选」`type="primary"`;「批量生成格子」隐藏或保持 disabled 中性;「清空格子」ghost - 无格子时:仅「批量生成格子」`type="primary"` 推荐实现(与规范「同一行仅一个主行动」一致): ```vue
批量生成格子
``` (无格子时才显示「批量生成格子」;有格子时不显示该按钮——与当前「有格子则禁用」等价且更干净。) - [x] **Step 2: script — 选择 Map 与辅助函数** 在 script 中、`slotFilterKeyword` 附近增加: ```ts type OutboundSelection = { slotId: string occupancyId: string slotCode: string itemName: string } const selectedOutboundMap = ref>(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() 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() 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
``` - [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 项」。