From aa5fbe778093b8fcae80e338693ae1b3b3db69f5 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Sun, 19 Jul 2026 17:16:39 +0800 Subject: [PATCH] =?UTF-8?q?docs(pl):=20=E5=87=BA=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E7=8E=B0=E5=9C=BA=E7=8A=B6=E6=80=81=E5=8F=AF=E9=80=89=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E3=80=81=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92=E4=B8=8E?= =?UTF-8?q?=20D5=20=E6=97=81=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录显式 status 提交流程,并标注旧出入库设计中现场自动完成规则已被覆盖。 Co-authored-by: Cursor --- ...26-07-19-pl-site-io-order-status-select.md | 504 ++++++++++++++++++ ...-07-19-pl-inbound-outbound-order-design.md | 240 +++++++++ ...9-pl-site-io-order-status-select-design.md | 172 ++++++ 3 files changed, 916 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-pl-site-io-order-status-select.md create mode 100644 docs/superpowers/specs/2026-07-19-pl-inbound-outbound-order-design.md create mode 100644 docs/superpowers/specs/2026-07-19-pl-site-io-order-status-select-design.md diff --git a/docs/superpowers/plans/2026-07-19-pl-site-io-order-status-select.md b/docs/superpowers/plans/2026-07-19-pl-site-io-order-status-select.md new file mode 100644 index 000000000..712d920c8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-pl-site-io-order-status-select.md @@ -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 + +