2026-07-19-pl-site-io-order-status-select.md 14 KB

出入库现场链接 · 单据状态可选 — 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 写入:

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: 跑测试确认失败
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:

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 中函数改为:

_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(...) 调用,增加:

status=payload.status,
  • Step 6: 跑测试确认通过
cd backend && source venv/bin/activate && pytest tests/test_pl_io_order_site_feedback_status.py -v

Expected: 全部 PASS

  • Step 7: Commit
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 参数类型

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
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 旁增加:

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 成功赋值后:

selectedStatus.value = (['in_progress', 'completed', 'voided'].includes(data.status || '')
  ? data.status
  : 'in_progress') as 'in_progress' | 'completed' | 'voided'
  • Step 2: template — 去掉 voided 整页 empty,改为可展示 + 提示条

将:

<van-empty v-else-if="page?.voided" description="单据已作废" />
<template v-else-if="page">

改为:

<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 之前插入:

<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):

.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 只读写法:

<van-uploader
  v-model="uploaderFiles"
  multiple
  :max-count="9"
  :deletable="!isReadOnly"
  :show-upload="!isReadOnly"
  :after-read="handleAfterRead"
  :before-delete="handleBeforeDelete"
/>
  • Step 5: handleSave 带 status + 作废确认
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
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 表格单元格末尾追加:

(现场自动完成规则已被 `2026-07-19-pl-site-io-order-status-select-design.md` 覆盖:现场提交须显式传 status)
  • Step 2: 更新知识图谱
./scripts/update-graph.sh all
  • Step 3: Commit
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

执行说明

完成计划后可选执行方式见下方对话确认。