|
|
@@ -0,0 +1,504 @@
|
|
|
+# 出入库现场链接 · 单据状态可选 — 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:** `PublicIoOrderFeedback` 增加必填 `status`;`apply_site_feedback` 显式写入状态并删除「首次提交自动已完成」。前端 `PublicIoOrderPage` 增加 `van-radio-group`,作废确认后提交;`voided` 时改只读展示而非空白 empty。
|
|
|
+
|
|
|
+**Tech Stack:** FastAPI + Pydantic v2 + SQLAlchemy;Vue 3 + Vant(公共页)
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-07-19-pl-site-io-order-status-select-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+| 文件 | 职责 |
|
|
|
+|------|------|
|
|
|
+| `backend/app/schemas/pl/io_order.py` | `PublicIoOrderFeedback.status` |
|
|
|
+| `backend/app/services/pl_io_order_service.py` | `apply_site_feedback` 接收并写入 `status` |
|
|
|
+| `backend/app/api/pl/public_io_order.py` | `save_feedback` 传入 `payload.status` |
|
|
|
+| `backend/tests/test_pl_io_order_site_feedback_status.py` | 状态写入 / 非法值 / 已作废拒绝 / 不再隐式完成 |
|
|
|
+| `web-admin/src/api/pl/public-io-order.ts` | `saveFeedback` 参数含 `status` |
|
|
|
+| `web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue` | 状态选择、作废确认、只读态 |
|
|
|
+| `docs/superpowers/specs/2026-07-19-pl-inbound-outbound-order-design.md` | D5 旁注被本设计覆盖(可选收尾) |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: 后端 `apply_site_feedback` 显式 status(TDD)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `backend/tests/test_pl_io_order_site_feedback_status.py`
|
|
|
+- Modify: `backend/app/services/pl_io_order_service.py`(`apply_site_feedback`)
|
|
|
+- Modify: `backend/app/schemas/pl/io_order.py`(`PublicIoOrderFeedback`)
|
|
|
+- Modify: `backend/app/api/pl/public_io_order.py`(传入 `status`)
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败测试**
|
|
|
+
|
|
|
+在 `backend/tests/test_pl_io_order_site_feedback_status.py` 写入:
|
|
|
+
|
|
|
+```python
|
|
|
+from datetime import datetime
|
|
|
+from decimal import Decimal
|
|
|
+from types import SimpleNamespace
|
|
|
+from unittest.mock import MagicMock
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from app.services.pl_io_order_service import apply_site_feedback
|
|
|
+
|
|
|
+
|
|
|
+def _header(**kwargs):
|
|
|
+ defaults = dict(
|
|
|
+ status="in_progress",
|
|
|
+ site_feedback_remark=None,
|
|
|
+ site_feedback_images=None,
|
|
|
+ site_feedback_time=None,
|
|
|
+ site_first_submitted_at=None,
|
|
|
+ update_by=None,
|
|
|
+ )
|
|
|
+ defaults.update(kwargs)
|
|
|
+ return SimpleNamespace(**defaults)
|
|
|
+
|
|
|
+
|
|
|
+def _item(item_id="item-1"):
|
|
|
+ return SimpleNamespace(
|
|
|
+ id=item_id,
|
|
|
+ actual_qty=None,
|
|
|
+ qty_source=None,
|
|
|
+ update_by=None,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def test_apply_site_feedback_sets_status_completed():
|
|
|
+ header = _header()
|
|
|
+ item = _item()
|
|
|
+ db = MagicMock()
|
|
|
+ apply_site_feedback(
|
|
|
+ db,
|
|
|
+ header=header,
|
|
|
+ items=[item],
|
|
|
+ item_qty={"item-1": Decimal("12.5")},
|
|
|
+ remark="ok",
|
|
|
+ images=["a.jpg"],
|
|
|
+ status="completed",
|
|
|
+ )
|
|
|
+ assert header.status == "completed"
|
|
|
+ assert item.actual_qty == Decimal("12.5")
|
|
|
+ assert header.site_first_submitted_at is not None
|
|
|
+ assert header.site_feedback_remark == "ok"
|
|
|
+ db.flush.assert_called_once()
|
|
|
+
|
|
|
+
|
|
|
+def test_apply_site_feedback_sets_status_voided():
|
|
|
+ header = _header()
|
|
|
+ apply_site_feedback(
|
|
|
+ MagicMock(),
|
|
|
+ header=header,
|
|
|
+ items=[_item()],
|
|
|
+ item_qty={"item-1": Decimal("1")},
|
|
|
+ remark=None,
|
|
|
+ images=[],
|
|
|
+ status="voided",
|
|
|
+ )
|
|
|
+ assert header.status == "voided"
|
|
|
+
|
|
|
+
|
|
|
+def test_apply_site_feedback_keeps_in_progress_when_requested():
|
|
|
+ """首次提交选进行中时,不得隐式改成已完成。"""
|
|
|
+ header = _header(status="in_progress")
|
|
|
+ apply_site_feedback(
|
|
|
+ MagicMock(),
|
|
|
+ header=header,
|
|
|
+ items=[_item()],
|
|
|
+ item_qty={"item-1": Decimal("1")},
|
|
|
+ remark=None,
|
|
|
+ images=[],
|
|
|
+ status="in_progress",
|
|
|
+ )
|
|
|
+ assert header.status == "in_progress"
|
|
|
+ assert header.site_first_submitted_at is not None
|
|
|
+
|
|
|
+
|
|
|
+def test_apply_site_feedback_rejects_invalid_status():
|
|
|
+ header = _header()
|
|
|
+ with pytest.raises(ValueError, match="无效状态"):
|
|
|
+ apply_site_feedback(
|
|
|
+ MagicMock(),
|
|
|
+ header=header,
|
|
|
+ items=[_item()],
|
|
|
+ item_qty={},
|
|
|
+ remark=None,
|
|
|
+ images=[],
|
|
|
+ status="done",
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def test_apply_site_feedback_rejects_already_voided():
|
|
|
+ header = _header(status="voided")
|
|
|
+ with pytest.raises(ValueError, match="单据已作废"):
|
|
|
+ apply_site_feedback(
|
|
|
+ MagicMock(),
|
|
|
+ header=header,
|
|
|
+ items=[_item()],
|
|
|
+ item_qty={},
|
|
|
+ remark=None,
|
|
|
+ images=[],
|
|
|
+ status="completed",
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def test_apply_site_feedback_does_not_reset_first_submitted_at():
|
|
|
+ first = datetime(2026, 1, 1, 12, 0, 0)
|
|
|
+ header = _header(
|
|
|
+ status="completed",
|
|
|
+ site_first_submitted_at=first,
|
|
|
+ )
|
|
|
+ apply_site_feedback(
|
|
|
+ MagicMock(),
|
|
|
+ header=header,
|
|
|
+ items=[_item()],
|
|
|
+ item_qty={"item-1": Decimal("2")},
|
|
|
+ remark="二改",
|
|
|
+ images=[],
|
|
|
+ status="in_progress",
|
|
|
+ )
|
|
|
+ assert header.status == "in_progress"
|
|
|
+ assert header.site_first_submitted_at == first
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测试确认失败**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_pl_io_order_site_feedback_status.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL(`apply_site_feedback()` 缺少 `status` 参数,或行为仍为隐式 `completed`)
|
|
|
+
|
|
|
+- [ ] **Step 3: Schema 增加 `status`**
|
|
|
+
|
|
|
+修改 `backend/app/schemas/pl/io_order.py` 中 `PublicIoOrderFeedback`:
|
|
|
+
|
|
|
+```python
|
|
|
+class PublicIoOrderFeedback(BaseModel):
|
|
|
+ status: str
|
|
|
+ remark: Optional[str] = None
|
|
|
+ images: list[str] = Field(default_factory=list)
|
|
|
+ items: list[PublicIoOrderItemFeedback] = Field(default_factory=list)
|
|
|
+ model_config = {"populate_by_name": True}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 改 `apply_site_feedback`**
|
|
|
+
|
|
|
+将 `backend/app/services/pl_io_order_service.py` 中函数改为:
|
|
|
+
|
|
|
+```python
|
|
|
+_IO_ORDER_STATUS_OK = frozenset({"in_progress", "completed", "voided"})
|
|
|
+
|
|
|
+
|
|
|
+def apply_site_feedback(
|
|
|
+ db: Session,
|
|
|
+ *,
|
|
|
+ header: OrderHeader,
|
|
|
+ items: list[OrderItem],
|
|
|
+ item_qty: dict[str, Decimal],
|
|
|
+ remark: Optional[str],
|
|
|
+ images: list[str],
|
|
|
+ status: str,
|
|
|
+ user_id: Optional[str] = None,
|
|
|
+) -> None:
|
|
|
+ if header.status == "voided":
|
|
|
+ raise ValueError("单据已作废,无法提交")
|
|
|
+ if status not in _IO_ORDER_STATUS_OK:
|
|
|
+ raise ValueError("无效状态")
|
|
|
+ now = datetime.now()
|
|
|
+ for item in items:
|
|
|
+ if item.id not in item_qty:
|
|
|
+ continue
|
|
|
+ item.actual_qty = item_qty[item.id]
|
|
|
+ item.qty_source = "manual"
|
|
|
+ item.update_by = user_id
|
|
|
+ header.site_feedback_remark = (remark or "").strip() or None
|
|
|
+ header.site_feedback_images = dump_feedback_images(images) if images else header.site_feedback_images
|
|
|
+ header.site_feedback_time = now
|
|
|
+ header.status = status
|
|
|
+ if not header.site_first_submitted_at:
|
|
|
+ header.site_first_submitted_at = now
|
|
|
+ header.update_by = user_id
|
|
|
+ db.flush()
|
|
|
+```
|
|
|
+
|
|
|
+要点:删除 `if header.status == "in_progress": header.status = "completed"`。
|
|
|
+
|
|
|
+- [ ] **Step 5: API 传入 `status`**
|
|
|
+
|
|
|
+修改 `backend/app/api/pl/public_io_order.py` 中 `apply_site_feedback(...)` 调用,增加:
|
|
|
+
|
|
|
+```python
|
|
|
+status=payload.status,
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 跑测试确认通过**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_pl_io_order_site_feedback_status.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 全部 PASS
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/tests/test_pl_io_order_site_feedback_status.py \
|
|
|
+ backend/app/services/pl_io_order_service.py \
|
|
|
+ backend/app/schemas/pl/io_order.py \
|
|
|
+ backend/app/api/pl/public_io_order.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(pl): 现场回执提交显式写入出入库单状态
|
|
|
+
|
|
|
+删除首次提交自动改为已完成的隐式逻辑,改为由客户端传入 status。
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: 前端 API 类型增加 `status`
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/api/pl/public-io-order.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: 更新 `saveFeedback` 参数类型**
|
|
|
+
|
|
|
+```typescript
|
|
|
+saveFeedback: (
|
|
|
+ token: string,
|
|
|
+ data: {
|
|
|
+ status: 'in_progress' | 'completed' | 'voided'
|
|
|
+ remark?: string
|
|
|
+ images?: string[]
|
|
|
+ items: { id: string; actualQty: number }[]
|
|
|
+ }
|
|
|
+) => publicPlPost<{ success: boolean }>(`${base}/${token}/feedback`, data),
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/api/pl/public-io-order.ts
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(web-admin): 出入库现场回执 API 增加 status 字段
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: 公共页状态选择 + 作废确认 + 只读态
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: script — 增加状态与只读计算**
|
|
|
+
|
|
|
+在现有 `ref` 旁增加:
|
|
|
+
|
|
|
+```typescript
|
|
|
+import { showConfirmDialog, showFailToast, showSuccessToast } from 'vant'
|
|
|
+import 'vant/es/dialog/style'
|
|
|
+
|
|
|
+const selectedStatus = ref<'in_progress' | 'completed' | 'voided'>('in_progress')
|
|
|
+const isReadOnly = computed(() => Boolean(page.value?.voided))
|
|
|
+```
|
|
|
+
|
|
|
+在 `loadPage` 成功赋值后:
|
|
|
+
|
|
|
+```typescript
|
|
|
+selectedStatus.value = (['in_progress', 'completed', 'voided'].includes(data.status || '')
|
|
|
+ ? data.status
|
|
|
+ : 'in_progress') as 'in_progress' | 'completed' | 'voided'
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: template — 去掉 voided 整页 empty,改为可展示 + 提示条**
|
|
|
+
|
|
|
+将:
|
|
|
+
|
|
|
+```vue
|
|
|
+<van-empty v-else-if="page?.voided" description="单据已作废" />
|
|
|
+<template v-else-if="page">
|
|
|
+```
|
|
|
+
|
|
|
+改为:
|
|
|
+
|
|
|
+```vue
|
|
|
+<template v-else-if="page">
|
|
|
+ <van-notice-bar
|
|
|
+ v-if="isReadOnly"
|
|
|
+ left-icon="info-o"
|
|
|
+ color="#92400e"
|
|
|
+ background="#fffbeb"
|
|
|
+ text="单据已作废,仅可查看"
|
|
|
+ />
|
|
|
+```
|
|
|
+
|
|
|
+(保留链接无效时的 `errorMessage` empty。)
|
|
|
+
|
|
|
+- [ ] **Step 3: template — 状态选择器(可编辑时)**
|
|
|
+
|
|
|
+在「现场备注」`van-cell-group` **之前**插入:
|
|
|
+
|
|
|
+```vue
|
|
|
+<van-cell-group v-if="!isReadOnly" inset class="section-card" title="单据状态">
|
|
|
+ <div class="status-radio-wrap">
|
|
|
+ <van-radio-group v-model="selectedStatus" direction="horizontal">
|
|
|
+ <van-radio name="in_progress">进行中</van-radio>
|
|
|
+ <van-radio name="completed">已完成</van-radio>
|
|
|
+ <van-radio name="voided">已作废</van-radio>
|
|
|
+ </van-radio-group>
|
|
|
+ </div>
|
|
|
+</van-cell-group>
|
|
|
+```
|
|
|
+
|
|
|
+样式(scoped):
|
|
|
+
|
|
|
+```scss
|
|
|
+.status-radio-wrap {
|
|
|
+ padding: 12px 16px 16px;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: template — 只读禁用数量 / 上传 / 备注;隐藏提交**
|
|
|
+
|
|
|
+- 实际数量 `van-field`:加 `:readonly="isReadOnly"`
|
|
|
+- `van-uploader`:加 `v-if="!isReadOnly"`;只读时用图片列表展示 `feedbackImages`(可用简单 `<img>` 行或保留 uploader 且 `:deletable="false"` `:show-upload="false"`)
|
|
|
+- 备注 `van-field`:`:readonly="isReadOnly"`
|
|
|
+- footer:`v-if="!isReadOnly"` 包住提交按钮
|
|
|
+
|
|
|
+推荐 uploader 只读写法:
|
|
|
+
|
|
|
+```vue
|
|
|
+<van-uploader
|
|
|
+ v-model="uploaderFiles"
|
|
|
+ multiple
|
|
|
+ :max-count="9"
|
|
|
+ :deletable="!isReadOnly"
|
|
|
+ :show-upload="!isReadOnly"
|
|
|
+ :after-read="handleAfterRead"
|
|
|
+ :before-delete="handleBeforeDelete"
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: `handleSave` 带 status + 作废确认**
|
|
|
+
|
|
|
+```typescript
|
|
|
+async function handleSave() {
|
|
|
+ if (isReadOnly.value) return
|
|
|
+ if (selectedStatus.value === 'voided') {
|
|
|
+ try {
|
|
|
+ await showConfirmDialog({
|
|
|
+ title: '确认作废',
|
|
|
+ message: '确认作废本出入库单?作废后不可再提交。'
|
|
|
+ })
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ saving.value = true
|
|
|
+ try {
|
|
|
+ await api.saveFeedback(token.value, {
|
|
|
+ status: selectedStatus.value,
|
|
|
+ remark: feedbackRemark.value,
|
|
|
+ images: feedbackImages.value,
|
|
|
+ items: editableItems.value.map(it => ({
|
|
|
+ id: it.id,
|
|
|
+ actualQty: Number(it.actualQtyText || 0)
|
|
|
+ }))
|
|
|
+ })
|
|
|
+ showSuccessToast('提交成功')
|
|
|
+ await loadPage()
|
|
|
+ } catch (e: any) {
|
|
|
+ showFailToast(e?.message || '提交失败')
|
|
|
+ } finally {
|
|
|
+ saving.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 手工验收(开发服务器)**
|
|
|
+
|
|
|
+1. 打开已安排的出入库现场链接 → 状态默认「进行中」
|
|
|
+2. 选「已完成」提交 → 刷新后顶部状态为已完成,选择器仍为已完成
|
|
|
+3. 再选「进行中」提交 → 回到进行中
|
|
|
+4. 选「已作废」→ 确认 → 提交成功 → 出现提示条,无提交按钮,内容可查看
|
|
|
+5. 管理端确认:仅本单作废,计划跟进状态未变
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(web-admin): 出入库现场页支持选择单据状态并作废只读
|
|
|
+
|
|
|
+提交携带 status;作废需确认;作废后整页只读可查看。
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 关联设计旁注 + 图谱更新
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `docs/superpowers/specs/2026-07-19-pl-inbound-outbound-order-design.md`(D5 行)
|
|
|
+- Run: `./scripts/update-graph.sh all`(或分别 backend / frontend)
|
|
|
+
|
|
|
+- [ ] **Step 1: D5 旁注**
|
|
|
+
|
|
|
+在原 D5 表格单元格末尾追加:
|
|
|
+
|
|
|
+```text
|
|
|
+(现场自动完成规则已被 `2026-07-19-pl-site-io-order-status-select-design.md` 覆盖:现场提交须显式传 status)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 更新知识图谱**
|
|
|
+
|
|
|
+```bash
|
|
|
+./scripts/update-graph.sh all
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add docs/superpowers/specs/2026-07-19-pl-inbound-outbound-order-design.md \
|
|
|
+ backend/graphify-out web-admin/graphify-out 2>/dev/null || true
|
|
|
+git add docs/superpowers/specs/2026-07-19-pl-inbound-outbound-order-design.md
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+docs(pl): 标注出入库单 D5 现场状态规则已被显式选择设计覆盖
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+(若 graphify 输出有变更再一并 add;无变更则只提交 docs。)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 自检(对照 Spec)
|
|
|
+
|
|
|
+| Spec 项 | Task |
|
|
|
+|---------|------|
|
|
|
+| D1 提交体带 status | Task 1 + 2 + 3 |
|
|
|
+| D2 三态可选 | Task 3 radio |
|
|
|
+| D3 默认当前真实状态 | Task 3 `loadPage` |
|
|
|
+| D4 作废后只读可查看 | Task 3 notice + disable |
|
|
|
+| D5 仅作废本单 | Task 1 只写 `header.status` |
|
|
|
+| D6 删除隐式完成 | Task 1 测试 + 实现 |
|
|
|
+| D7 入库出库对称 | 共用 `PublicIoOrderPage` / `apply_site_feedback` |
|
|
|
+| 作废二次确认 | Task 3 `showConfirmDialog` |
|
|
|
+| 无 DDL | 全计划未改 SQL |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 执行说明
|
|
|
+
|
|
|
+完成计划后可选执行方式见下方对话确认。
|