docs(pl): 出入库现场状态可选设计、实现计划与 D5 旁注
记录显式 status 提交流程,并标注旧出入库设计中现场自动完成规则已被覆盖。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
dee85f182e
commit
aa5fbe7780
3 files changed
+916
No files matched your search
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
## 执行说明
|
||||
|
||||
完成计划后可选执行方式见下方对话确认。
|
||||
@@ -0,0 +1,240 @@
|
||||
# 运输计划 · 入库单 / 出库单设计
|
||||
|
||||
**日期:** 2026-07-19
|
||||
**状态:** 已确认并实现中
|
||||
**范围:** web-admin 运输管理 + backend PL API + 免登录现场公共页
|
||||
**界面模式:** 列表对齐标准列表(P01 / `system/unit`);编辑弹窗对齐 Fresh Mint `dialog-form-layout`
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
现状为双轨:
|
||||
|
||||
- 安排运输后按产品生成 `pl_transport_site_link`(现场仅备注+附件,数量只读)
|
||||
- 可选勾选生成地磅 `db_inbound_outbound`(合一列表 `/#/db/inventory`)
|
||||
|
||||
客户需要运输计划域下**独立入库单 / 出库单**界面:安排后落单、现场按产品填实际数量、整单附件备注、归档可见;并覆盖取消与「只能作废重建」的约束。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已确认决策
|
||||
|
||||
| # | 决策 | 选择 |
|
||||
|---|------|------|
|
||||
| D1 | 单据域 | 新建 PL 主从表:入库一套 + 出库一套(对称),不复用地磅表作主单据 |
|
||||
| D2 | 地磅关系 | 有关联地磅单时,用其数量**预填** `actual_qty`,仍可改;不做双向自动覆盖 |
|
||||
| D3 | 现场链接 | 免登录公共页;**一单一链**(多产品同页) |
|
||||
| D4 | 多产品 | 明细各自填实际数量;附件+备注在主表整单共用 |
|
||||
| D5 | 状态 | `in_progress` / `completed` / `voided`;新建默认进行中;**现场首次提交 → 自动已完成**;管理端可手动选择进行中/已完成;**已作废**主要由计划作废写入(管理端也可手动置作废,作废后禁止现场提交)(现场自动完成规则已被 `2026-07-19-pl-site-io-order-status-select-design.md` 覆盖:现场提交须显式传 status) |
|
||||
| D6 | 已完成可改 | 已完成仍可继续完善数量/附件/备注 |
|
||||
| D7 | 归档 | 以入库/出库单为权威,归档**只读展示**;可选同步到 `pl_plan_product` |
|
||||
| D8 | 快照 | **最小快照**(兼容作废单历史展示)+ 保留 `plan_id` / `transport_id` |
|
||||
| D9 | 计划冻结 | 安排后**完全冻结**:不可编辑计划、**不提供调整运输**;只可作废 → 新建计划再安排 |
|
||||
| D10 | 菜单 | 一级「运输管理」→ 二级「库存管理」→「入库单」「出库单」 |
|
||||
| D11 | 直销单 | 不生成入库/出库单 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 菜单与路由
|
||||
|
||||
```
|
||||
运输管理(PL_MENU_ROOT,既有)
|
||||
└── 库存管理(新建 M,建议 id: PL_GROUP_STOCK)
|
||||
├── 入库单(C,perms: pl:inbound:list)
|
||||
└── 出库单(C,perms: pl:outbound:list)
|
||||
```
|
||||
|
||||
| 菜单 | 建议 path | component | 路由 Hash(示意) |
|
||||
|------|-----------|-----------|-------------------|
|
||||
| 库存管理 | `stock-module` | — | `/#/pl/stock-module/...` |
|
||||
| 入库单 | `inbound` | `pl/inbound/index` | `/#/pl/stock-module/inbound` |
|
||||
| 出库单 | `outbound` | `pl/outbound/index` | `/#/pl/stock-module/outbound` |
|
||||
|
||||
说明:
|
||||
|
||||
- 与既有「地磅出入库」`/#/db/inventory`、证件提醒路径 `pl/inventory/...` **分离**,故二级目录用 `stock-module`,避免与 `pl_inventory` 库存台账语义混淆。
|
||||
- 权限按钮:列表查看、编辑、改状态、复制现场链接(细项实现阶段落库 `sys_menu`)。
|
||||
- 角色授权:实现时挂到既有运输相关角色;`super_admin` 默认可见。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 入库主表 `pl_inbound_order`
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | VARCHAR(64) UUID |
|
||||
| `order_no` | 业务单号(如 RK + 日期序号) |
|
||||
| `plan_id` | → `pl_plan` |
|
||||
| `transport_id` | → `pl_transport` |
|
||||
| `status` | `in_progress` \| `completed` \| `voided` |
|
||||
| `partner_company` | 最小快照:往来单位 |
|
||||
| `vehicle_no` | 最小快照:车牌 |
|
||||
| `plan_no` | 最小快照:计划号 |
|
||||
| `plan_date` | 最小快照:计划日期 |
|
||||
| `site_access_token` | 一单一链,唯一 |
|
||||
| `site_feedback_remark` | 整单现场备注 |
|
||||
| `site_feedback_images` | 回执图(逗号分隔 URL 或 JSON,与现现场链路一致) |
|
||||
| `site_feedback_time` | 最近提交时间 |
|
||||
| `site_first_submitted_at` | 首次提交时间;用于自动置 `completed` |
|
||||
| 公共字段 | `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` |
|
||||
|
||||
### 4.2 入库明细 `pl_inbound_order_item`
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | UUID |
|
||||
| `order_id` | → `pl_inbound_order` |
|
||||
| `plan_product_id` | → `pl_plan_product` |
|
||||
| `product_id` / `product_name` / `specification` / `unit` | 产品快照 |
|
||||
| `plan_qty` | 安排时计划数量 |
|
||||
| `actual_qty` | 实际数量(可改) |
|
||||
| `qty_source` | `plan` \| `dibang` \| `manual` |
|
||||
| 公共字段 | 同规范 |
|
||||
|
||||
### 4.3 出库
|
||||
|
||||
`pl_outbound_order` / `pl_outbound_order_item` 与上表**字段对称**(单号前缀 CK)。
|
||||
|
||||
### 4.4 索引建议
|
||||
|
||||
- `(tenant_no, plan_id)`、`(tenant_no, transport_id)`、`(tenant_no, status)`、`site_access_token` UNIQUE
|
||||
- 明细 `(order_id)`、`(plan_product_id)`
|
||||
|
||||
### 4.5 与旧对象
|
||||
|
||||
| 对象 | 关系 |
|
||||
|------|------|
|
||||
| `pl_transport_site_link` | **新安排不再生成**;历史数据只读兼容或后续迁移脚本(实现计划中单列任务) |
|
||||
| `db_inbound_outbound` | 仍可选「生成地磅」;有关联时预填 `actual_qty`,不互相覆盖写死 |
|
||||
| `pl_plan` / `pl_transport` | 关联键 + 最小快照;列表可跳转计划 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 生命周期
|
||||
|
||||
```
|
||||
安排运输(入库/出库计划)
|
||||
→ 创建 1 主表 + N 明细,status=in_progress,生成 token
|
||||
→ actual_qty 默认 = plan_qty
|
||||
→ 若有地磅关联产品行 → 预填 actual_qty,qty_source=dibang
|
||||
|
||||
现场首次成功提交
|
||||
→ 写明细 actual_qty + 主表备注/附件
|
||||
→ site_first_submitted_at 有值则 status → completed(若仍为 in_progress)
|
||||
|
||||
管理端
|
||||
→ 可手动改 status(含 completed ↔ in_progress)
|
||||
→ 已完成仍可改数量/附件/备注
|
||||
|
||||
计划作废
|
||||
→ 关联未作废的入库/出库单 → voided
|
||||
→ token 失效(公共页只读提示已作废,禁止提交)
|
||||
|
||||
已安排后
|
||||
→ 禁止编辑计划正文
|
||||
→ 禁止「调整运输」
|
||||
→ 纠错路径:作废计划 → 新建计划 → 再安排(新单 + 新链接)
|
||||
```
|
||||
|
||||
直销单:安排运输不创建入库/出库单(可继续仅司机链接,若现网有)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 接口(示意)
|
||||
|
||||
### 6.1 管理端
|
||||
|
||||
- `GET /api/.../pl/inbound` 列表
|
||||
- `GET /api/.../pl/inbound/{id}` 详情(含 items)
|
||||
- `PATCH /api/.../pl/inbound/{id}` 编辑(数量、备注、附件、status)
|
||||
- `GET /api/.../pl/inbound/{id}/site-url` 复制现场链接
|
||||
- 出库对称:`/pl/outbound/...`
|
||||
|
||||
安排运输成功响应中返回:`inboundOrder` / `outboundOrder`(含 `siteUrl`),供安排成功弹窗复制。
|
||||
|
||||
作废计划:在现有 cancel 流程中追加作废关联入库/出库单。
|
||||
|
||||
### 6.2 公共页
|
||||
|
||||
- `GET /api/public/pl/inbound/{token}` 摘要 + 明细
|
||||
- `POST /api/public/pl/inbound/{token}/feedback` 提交实际数量(按 item)+ 备注 + 附件
|
||||
- 出库对称
|
||||
|
||||
URL 形态:`{PUBLIC_WEB_ORIGIN}/#/public/pl/inbound/{token}`(出库 `/public/pl/outbound/{token}`)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端界面
|
||||
|
||||
### 7.1 列表
|
||||
|
||||
- 筛选:单号、计划号、状态、日期、往来单位、车牌等
|
||||
- 列:单号(主标识链接打开查看/编辑)、状态 Tag、计划号、往来单位、车牌、产品摘要、实际量合计、首次/最近提交时间
|
||||
- 工具栏:复制现场链接(选中一行)等
|
||||
- 状态 Tag:`进行中=warning`,`已完成=success`,`已作废=info`(或 danger,实现时对齐 UI 规范枚举)
|
||||
|
||||
### 7.2 编辑弹窗
|
||||
|
||||
- 主表:状态(可选手动改)、快照信息只读、整单备注与附件
|
||||
- 明细表:计划数量只读、实际数量可编辑
|
||||
- `label-position="top"`,无背景模糊
|
||||
|
||||
### 7.3 现场公共页
|
||||
|
||||
- 免登录;布局对齐「一张单编辑」:产品行实际数量 + 底部备注/附件 + 提交
|
||||
- `voided`:不可提交;`completed`:仍可再次提交完善
|
||||
|
||||
### 7.4 归档
|
||||
|
||||
- `PlPlanViewTabs` / 归档弹窗:以关联入库/出库单只读展示实际数量、附件、备注
|
||||
- 可选:归档保存时同步 `pl_plan_product.actual_qty`(及 inbound/outbound 数量字段)
|
||||
|
||||
### 7.5 运输计划
|
||||
|
||||
- `arranged` 后:隐藏/禁用编辑与「调整运输」;作废入口保留
|
||||
- 安排成功:复制现场链接改为入库/出库单单链(替代按产品多条旧现场 link)
|
||||
|
||||
---
|
||||
|
||||
## 8. 非目标(本期不做)
|
||||
|
||||
- 入库/出库与地磅单双向自动覆盖
|
||||
- 拼车多计划共用一张出入库单
|
||||
- 强制迁移全部历史 `pl_transport_site_link`(可后续脚本)
|
||||
- 小程序端入库/出库列表(若未单列需求)
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收要点
|
||||
|
||||
| ID | 场景 |
|
||||
|----|------|
|
||||
| V1 | 入库计划安排 → 库存管理-入库单可见,状态进行中,有现场链接 |
|
||||
| V2 | 多产品:一链打开,各产品实际数量独立,备注附件整单 |
|
||||
| V3 | 有地磅时 actual 预填地磅量,可改 |
|
||||
| V4 | 现场首次提交 → 已完成;管理端可改回进行中/再改完成 |
|
||||
| V5 | 已完成后仍可改数量与附件 |
|
||||
| V6 | 作废计划 → 单作废,链接不可再提交 |
|
||||
| V7 | 已安排计划不可编辑、无调整运输;须作废重建 |
|
||||
| V8 | 归档只读看到单上实际数据 |
|
||||
| V9 | 菜单:运输管理 → 库存管理 → 入库单/出库单 |
|
||||
| V10 | 直销单不产生入库/出库单 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 实现备注
|
||||
|
||||
- DDL 同步 `docs/sql/`;遵守 database-conventions 公共字段与软删
|
||||
- 租户隔离、数据权限与现有 PL API 一致
|
||||
- 字段一致性:`_row_to_dict` / CreateUpdateBody / 前端 form 对齐
|
||||
- 替代旧现场 link 时,安排运输 API 与前端复制链接 UX 一并切换
|
||||
|
||||
---
|
||||
|
||||
## 11. 决策记录(brainstorm)
|
||||
|
||||
- 可视化会话:`.superpowers/brainstorm/` 下 HTML 选项页
|
||||
- 方案选型:主从表分离 A;现场公共页 A;一单一链 A;归档权威 A;最小快照 A;计划完全冻结 B
|
||||
@@ -0,0 +1,172 @@
|
||||
# 出入库现场链接 · 单据状态可选设计
|
||||
|
||||
**日期:** 2026-07-19
|
||||
**状态:** 已确认
|
||||
**范围:** web-admin 公共现场页 `PublicIoOrderPage` + backend `public_io_order` / `apply_site_feedback`
|
||||
**关联:** 修正 `2026-07-19-pl-inbound-outbound-order-design.md` 中 D5「现场首次提交 → 自动已完成」规则
|
||||
**界面模式:** 公共页对齐既有 `public/pl/inbound|outbound`(Vant)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
安排运输后生成出入库单现场链接。当前「提交」只保存实际数量、备注、附件;状态变更依赖服务端隐式规则:
|
||||
|
||||
- 首次提交且 `status == in_progress` → 自动改为 `completed`
|
||||
- 再次提交不改状态
|
||||
- 页面状态只读展示,无法选手动状态(含作废)
|
||||
|
||||
现场需要明确选择单据状态(含作废),且默认展示当前真实状态。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已确认决策
|
||||
|
||||
| # | 决策 | 选择 |
|
||||
|---|------|------|
|
||||
| D1 | 实现方案 | 提交体携带 `status`,服务端按所选写入(方案 1) |
|
||||
| D2 | 可选状态 | `in_progress` / `completed` / `voided`(进行中 / 已完成 / 已作废) |
|
||||
| D3 | 选择器默认值 | 进入页时展示 **当前单据真实状态**(多为进行中) |
|
||||
| D4 | 作废后链接 | 仍可打开 **只读查看**,不可再提交 / 上传 |
|
||||
| D5 | 作废影响范围 | **仅本出入库单** `status = voided`;不联动作废运输计划、运输单、地磅、司机链接 |
|
||||
| D6 | 隐式完成 | **删除**「首次提交自动已完成」 |
|
||||
| D7 | 入库 / 出库 | 对称;共用 `PublicIoOrderPage` 与 `apply_site_feedback` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端(公共页)
|
||||
|
||||
文件:`web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue`
|
||||
|
||||
### 3.1 状态选择
|
||||
|
||||
- 在「现场备注」区块上方增加「单据状态」选择,使用 **`van-radio-group` 横向三项**(与现有 Vant 公共页一致)
|
||||
- 选项文案:`进行中` / `已完成` / `已作废`,值分别为 `in_progress` / `completed` / `voided`
|
||||
- `loadPage` 后:`selectedStatus = page.status`
|
||||
- 可编辑态:顶部信息卡「状态」改为只读展示当前已保存状态;下方选择器为本次拟提交状态
|
||||
- 只读态:仅顶部信息卡展示状态标签,不展示选择器
|
||||
|
||||
### 3.2 提交
|
||||
|
||||
- `saveFeedback` body 增加 `status: selectedStatus`(必填)
|
||||
- 当用户选择 `voided` 并点提交:先 `showConfirmDialog`(或等价)确认文案大致为「确认作废本出入库单?作废后不可再提交。」;取消则不请求
|
||||
- 提交成功后 `loadPage()` 刷新
|
||||
|
||||
### 3.3 只读态(`page.voided === true`)
|
||||
|
||||
判定保持后端现有:`header.status == "voided"` **或** 计划 `follow_status == "cancelled"`。
|
||||
|
||||
- **不再**仅用 `van-empty`「单据已作废」遮住整页
|
||||
- 展示与可编辑态相同的信息结构(业务信息、取送货、产品数量、回执、备注、状态)
|
||||
- 数量输入、上传、备注、状态选择、提交按钮全部禁用或隐藏
|
||||
- 顶部增加只读提示条:「单据已作废,仅可查看」
|
||||
|
||||
### 3.4 类型
|
||||
|
||||
- `web-admin/src/api/pl/public-io-order.ts`:`saveFeedback` 参数增加 `status`
|
||||
|
||||
---
|
||||
|
||||
## 4. 后端
|
||||
|
||||
### 4.1 Schema
|
||||
|
||||
`PublicIoOrderFeedback`(`backend/app/schemas/pl/io_order.py`)增加:
|
||||
|
||||
```text
|
||||
status: str # 必填;alias 可选;合法值 in_progress | completed | voided
|
||||
```
|
||||
|
||||
非法值 → 业务 400「无效状态」。
|
||||
|
||||
### 4.2 `apply_site_feedback`
|
||||
|
||||
文件:`backend/app/services/pl_io_order_service.py`
|
||||
|
||||
1. 若 `header.status == "voided"` → 仍抛 `ValueError("单据已作废,无法提交")`(调用方在 commit 前已用 `page["voided"]` 拦计划取消场景)
|
||||
2. 校验 `status ∈ {in_progress, completed, voided}`
|
||||
3. 写入 `header.status = status`(显式,不再隐式升级)
|
||||
4. 照旧写明细 `actual_qty`、`site_feedback_*`、`site_feedback_time`
|
||||
5. `site_first_submitted_at`:若为空则写入当前时间(**与本次所选状态无关**)
|
||||
6. **删除**以下逻辑:
|
||||
|
||||
```text
|
||||
if not header.site_first_submitted_at:
|
||||
...
|
||||
if header.status == "in_progress":
|
||||
header.status = "completed"
|
||||
```
|
||||
|
||||
(首次提交时间与状态写入顺序:先写 feedback 字段与 status,再按需补 `site_first_submitted_at`。)
|
||||
|
||||
### 4.3 公共 API
|
||||
|
||||
`backend/app/api/pl/public_io_order.py` 的 `save_feedback`:把 `payload.status` 传入 `apply_site_feedback`。
|
||||
`page["voided"]` 时拒绝提交 / 上传的既有逻辑不变。
|
||||
|
||||
### 4.4 非目标
|
||||
|
||||
- 不修改管理端 `PATCH` 出入库单改状态逻辑
|
||||
- 不调用计划作废(`void_plan`)及其级联(运输取消、地磅、现场旧链 revoke 等)
|
||||
- 不改司机公共页、旧 `pl_transport_site_link` 现场页
|
||||
- 无 DDL 变更
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据流
|
||||
|
||||
```text
|
||||
现场页 load → GET /public/pl/{inbound|outbound}/{token}
|
||||
→ selectedStatus = page.status
|
||||
|
||||
用户编辑数量/备注/附件/状态 → 提交
|
||||
→(若 status=voided)确认弹窗
|
||||
→ POST .../feedback { status, items, remark, images }
|
||||
→ apply_site_feedback 写 status + 回执字段
|
||||
→ 若写为 voided:下次 GET voided=true → 只读
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 错误处理
|
||||
|
||||
| 情况 | 行为 |
|
||||
|------|------|
|
||||
| 链接无效 | 404 / 前端空态(既有) |
|
||||
| 已 voided 或计划已取消仍提交 | 400「单据已作废,无法提交」 |
|
||||
| `status` 缺失或非法 | 400「无效状态」 |
|
||||
| 作废确认取消 | 不发请求 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收
|
||||
|
||||
| # | 场景 | 期望 |
|
||||
|---|------|------|
|
||||
| 1 | 安排后首次打开现场链接 | 状态选择器为「进行中」 |
|
||||
| 2 | 选「已完成」提交 | 单据 `completed`;管理端列表一致;可再次进入继续改数量/状态 |
|
||||
| 3 | 选「进行中」提交(曾已完成) | 单据回到 `in_progress` |
|
||||
| 4 | 选「已作废」并确认提交 | 仅本单 `voided`;运输计划跟进状态不变 |
|
||||
| 5 | 作废后再打开 | 可查看历史回执与数量;不可提交/上传 |
|
||||
| 6 | 管理端作废计划后打开现场链接 | 只读(既有 `voided`) |
|
||||
| 7 | 回归 | 无 `status` 的旧客户端若仍存在会失败;本期仅本仓库公共页,同步改前端即可 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 改动文件清单
|
||||
|
||||
| 层 | 文件 |
|
||||
|----|------|
|
||||
| Schema | `backend/app/schemas/pl/io_order.py` |
|
||||
| Service | `backend/app/services/pl_io_order_service.py` |
|
||||
| API | `backend/app/api/pl/public_io_order.py` |
|
||||
| 前端 API | `web-admin/src/api/pl/public-io-order.ts` |
|
||||
| 公共页 | `web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue` |
|
||||
| 文档 | 本文件;实现后可在关联设计 D5 旁注「已被本设计覆盖」 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试建议
|
||||
|
||||
- 后端:对 `apply_site_feedback` 单测或手工:首次提交 `completed` / `voided` / `in_progress`;已 voided 再调应失败
|
||||
- 前端:入库与出库各走一遍上述验收表 1–5
|
||||
Reference in new issue
Block a user