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 旁注被本设计覆盖(可选收尾) |
apply_site_feedback 显式 status(TDD)Files:
backend/tests/test_pl_io_order_site_feedback_status.pybackend/app/services/pl_io_order_service.py(apply_site_feedback)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
cd backend && source venv/bin/activate && pytest tests/test_pl_io_order_site_feedback_status.py -v
Expected: FAIL(apply_site_feedback() 缺少 status 参数,或行为仍为隐式 completed)
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}
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"。
status修改 backend/app/api/pl/public_io_order.py 中 apply_site_feedback(...) 调用,增加:
status=payload.status,
cd backend && source venv/bin/activate && pytest tests/test_pl_io_order_site_feedback_status.py -v
Expected: 全部 PASS
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
)"
statusFiles:
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),
git add web-admin/src/api/pl/public-io-order.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): 出入库现场回执 API 增加 status 字段
EOF
)"
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'
将:
<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。)
在「现场备注」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"
/>
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
}
}
git add web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): 出入库现场页支持选择单据状态并作废只读
提交携带 status;作废需确认;作废后整页只读可查看。
EOF
)"
Files:
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)
./scripts/update-graph.sh all
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 项 | 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 |
完成计划后可选执行方式见下方对话确认。