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:
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 项」。
|
||||
Reference in new issue
Block a user