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

15 KiB
Raw Blame History

库位管理货架批量出库 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 行)

  • Step 1: 写入 storedOccupancyId

在循环内、设置 storedItemType 之前(或紧挨其后)增加:

slot["storedOccupancyId"] = occ.id

完整片段应类似:

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:

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
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 行)改为(保留「批量生成格子 / 清空格子」,并在前插入已选与出库):

<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"

推荐实现(与规范「同一行仅一个主行动」一致):

<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 附近增加:

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
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,改为:

} 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(明细弹窗单条出库)成功后:

const next = new Map(selectedOutboundMap.value)
next.delete(currentSlot.value?.id)
selectedOutboundMap.value = next
  • Step 5: 筛选时 prune 仅保留当前可见占用格

页面已有 slotMatchesFilter 与 filteredSlotList。在其旁增加 watch:

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

将格子节点改为:

<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 样式块附近增加:

.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: 更新知识图谱
./scripts/update-graph.sh backend
# 若仅前端大改也可:./scripts/update-graph.sh frontend
# 本功能前后端都改时:
./scripts/update-graph.sh all
  • Step 3: Commit 前端
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 项」。