From d4c927a8b9db1c33a4587ed352527601d646df0b Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Tue, 28 Jul 2026 19:10:29 +0800 Subject: [PATCH] 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 --- ...6-07-28-storage-facility-batch-outbound.md | 503 ++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-28-storage-facility-batch-outbound.md diff --git a/docs/superpowers/plans/2026-07-28-storage-facility-batch-outbound.md b/docs/superpowers/plans/2026-07-28-storage-facility-batch-outbound.md new file mode 100644 index 00000000..69b8ef7c --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-storage-facility-batch-outbound.md @@ -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` 管理勾选;勾选框与格子点击解耦;确认后串行 `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 +
+ + + + 批量生成格子 + + + 清空格子 + +
+``` + +说明:当 `slotList.length > 0` 时,「批量生成格子」仍禁用(与现网一致);「出库已选」为该行唯一可点的主色按钮时,可将「批量生成格子」在有格子时改为 `class="btn-ghost"` 且不带 `type="primary"`,避免同排两个 primary。推荐最终: + +- 有格子时:「出库已选」`type="primary"`;「批量生成格子」隐藏或保持 disabled 中性;「清空格子」ghost +- 无格子时:仅「批量生成格子」`type="primary"` + +推荐实现(与规范「同一行仅一个主行动」一致): + +```vue +
+ + + + 批量生成格子 + +
+``` + +(无格子时才显示「批量生成格子」;有格子时不显示该按钮——与当前「有格子则禁用」等价且更干净。) + +- [ ] **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 +} +``` + +- [ ] **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() + 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 +
+ + + +
+``` + +- [ ] **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 项」。