docs: 出入库单现场链接与信息完善实现计划

按已确认 design 拆分为 enrichment、公共页、列表弹窗与计划回传共 7 个可勾选任务。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-19 16:37:55 +08:00
1 parent 9a1e43e371
commit dbd0d9f205
1 file changed
+628
@@ -0,0 +1,628 @@
# 入库/出库单信息完善与现场链接对齐 — 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:** 按 Demo「提案」完善入库/出库现场公共页,并同步管理端列表、编辑弹窗、计划查看回传:展示取送货与完整产品字段(含包装规格、单位中文标签)。
**Architecture:** 后端在 `pl_io_order_service.order_header_to_dict` 读时 enrichment:按 `plan_id` 取 `pl_plan`、按明细 `plan_product_id` 取 `pl_plan_product`,附加物流字段与 `productNo`/`packageSpec`/`unitLabel`/`remark`;公共页 `unit` 直接返回中文标签。前端改共享页 `PublicIoOrderPage`、`IoOrderListPage`、`PlPlanViewTabs`。
**Tech Stack:** FastAPI + SQLAlchemy;Vue 3 + Vant(公共页)+ Element Plus(管理端)
**Spec:** `docs/superpowers/specs/2026-07-19-pl-io-order-site-link-enrichment-design.md`
**Demo:** `docs/superpowers/brainstorm/2026-07-19-pl-outbound-site-link-demo.html`
---
## 文件结构
| 文件 | 职责 |
|------|------|
| `backend/app/services/pl_io_order_service.py` | `enrich_io_order_dict`:计划物流 + 产品扩展 + 单位标签 |
| `backend/app/schemas/pl/io_order.py` | `IoOrderItemRead` / `IoOrderRead` 增加可选字段 |
| `backend/app/api/pl/public_io_order.py` | `_build_page` 调用 enrichment;公共页 `unit` 用标签 |
| `backend/tests/test_pl_io_order_enrichment.py` | enrichment 纯函数/字典断言(无 DB 时可测 helper) |
| `web-admin/src/api/pl/index.ts` | `PlIoOrder` / `PlIoOrderItem` 类型扩展 |
| `web-admin/src/api/pl/public-io-order.ts` | 公共页类型扩展 |
| `web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue` | 按 Demo 重排 UI |
| `web-admin/src/views/pl/_shared/IoOrderListPage.vue` | 列表列 + 编辑弹窗 |
| `web-admin/src/components/business/pl-plan-view-tabs/index.vue` | 回传 KV + 产品表 + 快捷编辑表 |
---
### Task 1: 后端 enrichment helper + `order_header_to_dict`
**Files:**
- Modify: `backend/app/services/pl_io_order_service.py`
- Create: `backend/tests/test_pl_io_order_enrichment.py`
- [ ] **Step 1: 写失败测试(单位标签与计划字段合并)**
```python
# backend/tests/test_pl_io_order_enrichment.py
from app.services.pl_io_order_service import (
resolve_unit_label,
PLAN_TYPE_BUSINESS_LABEL,
merge_plan_logistics_into_order,
merge_plan_product_into_item,
)
def test_resolve_unit_label_maps_dun():
assert resolve_unit_label({"dun": "吨"}, "dun") == "吨"
assert resolve_unit_label({}, "dun") == "dun"
assert resolve_unit_label({}, None) == "吨"
assert resolve_unit_label({}, "") == "吨"
def test_merge_plan_logistics_into_order():
data = {"remark": "order-remark"}
plan = type(
"P",
(),
{
"plan_type": "outbound",
"remark": "plan-remark",
"pickup_address": "取货路1号",
"pickup_lng": 117.0,
"pickup_lat": 30.5,
"pickup_time": None,
"pickup_notes": "戴帽",
"destination_name": "仓库A",
"destination_address": "送货路2号",
"destination_lng": None,
"destination_lat": None,
"delivery_time": None,
"destination_notes": "北门",
},
)()
merge_plan_logistics_into_order(data, plan)
assert data["businessType"] == "出库"
assert data["planRemark"] == "plan-remark"
assert data["remark"] == "order-remark" # 单据备注不被覆盖
assert data["pickupAddress"] == "取货路1号"
assert data["destinationAddress"] == "送货路2号"
assert data["destinationName"] == "仓库A"
def test_merge_plan_product_into_item():
item = {"unit": "dun", "productName": "尿素"}
product = type(
"PP",
(),
{
"product_no": "P001",
"package_spec": "50kg/袋",
"remark": "易潮",
"unit": "dun",
"model": "46.4%",
"product_name": "工业级尿素",
},
)()
merge_plan_product_into_item(item, product, unit_map={"dun": "吨"})
assert item["productNo"] == "P001"
assert item["packageSpec"] == "50kg/袋"
assert item["remark"] == "易潮"
assert item["unitLabel"] == "吨"
assert item["unit"] == "dun"
```
- [ ] **Step 2: 运行确认失败**
```bash
cd backend && source venv/bin/activate && pytest tests/test_pl_io_order_enrichment.py -v
```
Expected: FAIL(函数未定义)
- [ ] **Step 3: 在 `pl_io_order_service.py` 实现 helper(放在 `order_header_to_dict` 之前)**
```python
CRM_PRODUCT_UNIT = "crm_product_unit"
PLAN_TYPE_BUSINESS_LABEL = {
"inbound": "入库",
"outbound": "出库",
"direct": "直销",
}
def resolve_unit_label(unit_map: dict[str, str], unit_code: Optional[str]) -> str:
code = (unit_code or "").strip()
if not code:
return "吨"
return unit_map.get(code, code)
def merge_plan_logistics_into_order(data: dict, plan: Optional[PlPlan]) -> None:
if not plan:
data.setdefault("businessType", None)
data.setdefault("planRemark", None)
data.setdefault("pickupAddress", None)
data.setdefault("pickupLng", None)
data.setdefault("pickupLat", None)
data.setdefault("pickupTime", None)
data.setdefault("pickupNotes", None)
data.setdefault("destinationName", None)
data.setdefault("destinationAddress", None)
data.setdefault("destinationLng", None)
data.setdefault("destinationLat", None)
data.setdefault("deliveryTime", None)
data.setdefault("destinationNotes", None)
return
data["businessType"] = PLAN_TYPE_BUSINESS_LABEL.get(plan.plan_type, plan.plan_type)
data["planRemark"] = (plan.remark or "").strip() or None
data["pickupAddress"] = plan.pickup_address
data["pickupLng"] = plan.pickup_lng
data["pickupLat"] = plan.pickup_lat
data["pickupTime"] = plan.pickup_time
data["pickupNotes"] = plan.pickup_notes
data["destinationName"] = plan.destination_name
data["destinationAddress"] = plan.destination_address or plan.destination_name
data["destinationLng"] = plan.destination_lng
data["destinationLat"] = plan.destination_lat
data["deliveryTime"] = plan.delivery_time
data["destinationNotes"] = plan.destination_notes
def merge_plan_product_into_item(
item: dict,
product: Optional[PlPlanProduct],
*,
unit_map: dict[str, str],
) -> None:
if not product:
item.setdefault("productNo", None)
item.setdefault("packageSpec", None)
item.setdefault("remark", None)
item["unitLabel"] = resolve_unit_label(unit_map, item.get("unit"))
return
item["productNo"] = product.product_no
item["packageSpec"] = product.package_spec
item["remark"] = (product.remark or "").strip() or None
if not item.get("specification") and product.model:
item["specification"] = product.model
item["unitLabel"] = resolve_unit_label(unit_map, item.get("unit") or product.unit)
def enrich_io_order_dict(
db: Session,
data: dict,
*,
tenant_no: str,
plan_id: Optional[str],
items: list[Any] | None,
public_unit_as_label: bool = False,
) -> dict:
"""读时附加计划取送货与产品扩展字段。"""
from app.utils.excel_export import load_dict_label_maps
plan = None
if plan_id:
plan = (
db.query(PlPlan)
.filter(PlPlan.id == plan_id, PlPlan.del_flag == 0)
.first()
)
merge_plan_logistics_into_order(data, plan)
unit_map = load_dict_label_maps(
db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no
).get(CRM_PRODUCT_UNIT, {})
raw_items = data.get("items")
if raw_items is None:
return data
product_ids = [
it.get("planProductId")
for it in raw_items
if it.get("planProductId")
]
products_by_id: dict[str, PlPlanProduct] = {}
if product_ids:
rows = (
db.query(PlPlanProduct)
.filter(
PlPlanProduct.id.in_(product_ids),
PlPlanProduct.del_flag == 0,
)
.all()
)
products_by_id = {r.id: r for r in rows}
for it in raw_items:
pp = products_by_id.get(it.get("planProductId") or "")
merge_plan_product_into_item(it, pp, unit_map=unit_map)
if public_unit_as_label:
it["unit"] = it.get("unitLabel") or resolve_unit_label(unit_map, it.get("unit"))
return data
```
确保文件顶部已 import:`PlPlan`, `PlPlanProduct`(若尚未导入则补上)。
- [ ] **Step 4: 改造 `order_header_to_dict` 签名与调用 enrichment**
将函数改为接受 `db`(可选,有则 enrichment):
```python
def order_header_to_dict(
kind: str,
row: OrderHeader,
items: list[Any] | None = None,
*,
db: Session | None = None,
public_unit_as_label: bool = False,
) -> dict:
# ... existing data / items build ...
if db is not None:
enrich_io_order_dict(
db,
data,
tenant_no=row.tenant_no,
plan_id=row.plan_id,
items=items,
public_unit_as_label=public_unit_as_label,
)
return data
```
明细 dict 中保持 `unit` 为库内码;`unitLabel` 由 enrichment 写入。
- [ ] **Step 5: 跑通单元测试**
```bash
cd backend && source venv/bin/activate && pytest tests/test_pl_io_order_enrichment.py -v
```
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add backend/app/services/pl_io_order_service.py backend/tests/test_pl_io_order_enrichment.py
git commit -m "feat(pl): 出入库单读时 enrichment 取送货与产品扩展字段"
```
---
### Task 2: Schema + 管理端 / 公共 API 接入 enrichment
**Files:**
- Modify: `backend/app/schemas/pl/io_order.py`
- Modify: `backend/app/api/pl/io_order.py`
- Modify: `backend/app/api/pl/public_io_order.py`
- [ ] **Step 1: 扩展 Pydantic 模型**
`IoOrderItemRead` 增加:
```python
product_no: Optional[str] = Field(default=None, alias="productNo")
package_spec: Optional[str] = Field(default=None, alias="packageSpec")
remark: Optional[str] = None
unit_label: Optional[str] = Field(default=None, alias="unitLabel")
```
`IoOrderRead` 增加(均为 Optional):
```python
business_type: Optional[str] = Field(default=None, alias="businessType")
plan_remark: Optional[str] = Field(default=None, alias="planRemark")
pickup_address: Optional[str] = Field(default=None, alias="pickupAddress")
pickup_lng: Optional[float] = Field(default=None, alias="pickupLng")
pickup_lat: Optional[float] = Field(default=None, alias="pickupLat")
pickup_time: Optional[datetime] = Field(default=None, alias="pickupTime")
pickup_notes: Optional[str] = Field(default=None, alias="pickupNotes")
destination_name: Optional[str] = Field(default=None, alias="destinationName")
destination_address: Optional[str] = Field(default=None, alias="destinationAddress")
destination_lng: Optional[float] = Field(default=None, alias="destinationLng")
destination_lat: Optional[float] = Field(default=None, alias="destinationLat")
delivery_time: Optional[datetime] = Field(default=None, alias="deliveryTime")
destination_notes: Optional[str] = Field(default=None, alias="destinationNotes")
```
- [ ] **Step 2: 管理端 `_to_read` 传入 db**
```python
def _to_read(kind: str, header, items, db: Session) -> IoOrderRead:
return IoOrderRead.model_validate(
order_header_to_dict(kind, header, items, db=db)
)
```
所有 `_to_read(...)` 调用补上 `db`(list / get / patch / by-plan)。
- [ ] **Step 3: 公共 `_build_page`**
```python
def _build_page(db: Session, kind: str, header, items) -> dict:
plan = db.query(PlPlan).filter(PlPlan.id == header.plan_id, PlPlan.del_flag == 0).first()
voided = header.status == "voided" or bool(plan and plan.follow_status == "cancelled")
data = order_header_to_dict(
kind, header, items, db=db, public_unit_as_label=True
)
data["voided"] = voided
data["title"] = "入库单" if kind == "inbound" else "出库单"
# enrichment 已写入 pickup/destination;勿再重复简陋覆盖丢失经纬度
return data
```
删除旧的仅写 `pickupAddress`/`destinationAddress` 两行简陋赋值(由 enrichment 统一提供)。
- [ ] **Step 4: 冒烟 import**
```bash
cd backend && source venv/bin/activate && python -c "
from app.api.pl.io_order import inbound_router
from app.api.pl.public_io_order import outbound_public_router
print('ok')
"
```
Expected: `ok`
- [ ] **Step 5: Commit**
```bash
git add backend/app/schemas/pl/io_order.py backend/app/api/pl/io_order.py backend/app/api/pl/public_io_order.py
git commit -m "feat(pl): 出入库 API 接入 enrichment 并扩展 schema"
```
---
### Task 3: 前端 API 类型
**Files:**
- Modify: `web-admin/src/api/pl/index.ts`
- Modify: `web-admin/src/api/pl/public-io-order.ts`
- [ ] **Step 1: `PlIoOrderItem` 扩展**
```typescript
export interface PlIoOrderItem {
id: string
orderId: string
planProductId: string
productId?: string
productNo?: string
productName?: string
specification?: string
unit?: string
unitLabel?: string
planQty?: number
actualQty?: number
qtySource?: string
packageSpec?: string
remark?: string
}
```
- [ ] **Step 2: `PlIoOrder` 扩展**
```typescript
businessType?: string
planRemark?: string
pickupAddress?: string
pickupLng?: number | null
pickupLat?: number | null
pickupTime?: string
pickupNotes?: string
destinationName?: string
destinationAddress?: string
destinationLng?: number | null
destinationLat?: number | null
deliveryTime?: string
destinationNotes?: string
```
- [ ] **Step 3: `PublicIoOrderPage` / `PublicIoOrderItem` 对齐公共响应**
`PublicIoOrderItem`:`packageSpec?`、`remark?`、`productNo?`(可选)
`PublicIoOrderPage`:与上列物流字段一致 + `planRemark?` + `businessType?` + `date?`(可用 `planDate`)
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/api/pl/index.ts web-admin/src/api/pl/public-io-order.ts
git commit -m "feat(web-admin): 扩展出入库单与公共页 TypeScript 类型"
```
---
### Task 4: 现场公共页 UI(对齐 Demo)
**Files:**
- Modify: `web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue`
- Reference: `web-admin/src/views/public/pl/site/index.vue`(导航、地址 cell)
- Reference: `docs/superpowers/brainstorm/2026-07-19-pl-outbound-site-link-demo.html`
- [ ] **Step 1: 模板按 Demo 区块重排**
概况 `van-cell-group`:业务类型、日期(`planDate`)、往来单位、车牌、计划号、单号、状态、`planRemark`(有则显示)。
取货 / 送货两组:地址 + `buildAmapNavigationUrl` 导航按钮、时间 `formatDateTime`、注意事项;样式复用 site 页 `address-cell` / `multiline-cell`。
产品区:每项 `.product-card` — 名称、规格、包装规格、计划数量+`unit`(已是标签)、实际数量 `van-field`;样式按 Demo(绿边卡片、大字输入)。
回执、现场备注、上次保存、底部提交保留。
- [ ] **Step 2: script 补导航与格式化**
```typescript
import { buildAmapNavigationUrl } from '@/utils/amap/navigation'
function formatDateTime(value?: string) {
if (!value) return '—'
const normalized = String(value).replace('T', ' ')
return normalized.length > 16 ? normalized.slice(0, 16) : normalized
}
const pickupNavUrl = computed(() => {
if (!page.value) return null
return buildAmapNavigationUrl(
page.value.pickupLng,
page.value.pickupLat,
page.value.pickupAddress || '取货点'
)
})
// destinationNavUrl 同理
```
`navTitle` 可用:`出库单 · 现场回执` / `入库单 · 现场回执`。
- [ ] **Step 3: 本地打开任一 token 页目视核对**
对照 Demo:单位为「吨」、有包装规格、有取送货。
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue
git commit -m "feat(web-admin): 出入库现场链接页对齐 Demo 信息密度与样式"
```
---
### Task 5: 管理端列表 + 编辑弹窗
**Files:**
- Modify: `web-admin/src/views/pl/_shared/IoOrderListPage.vue`
- [ ] **Step 1: 列表增加两列(放在「产品」前或「车牌」后)**
```vue
<el-table-column
prop="pickupAddress"
label="取货地址"
min-width="160"
show-overflow-tooltip
/>
<el-table-column label="送货地址" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ row.destinationAddress || row.destinationName || '—' }}
</template>
</el-table-column>
```
- [ ] **Step 2: 弹窗加宽并补主信息**
`width="1080px"`。在往来单位后增加只读项:
- 取货地址、取货时间、取货注意事项(注意事项 / 地址 `form-col-span-2`)
- 送货地址、送达时间、送货注意事项
时间用简单展示:`editForm.pickupTime` 字符串即可。
- [ ] **Step 3: 产品表改为 8 列**
```vue
<el-table-column prop="productNo" label="产品编号" min-width="110" show-overflow-tooltip />
<el-table-column prop="productName" label="产品名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="specification" label="型号/规格" min-width="100" show-overflow-tooltip />
<el-table-column label="单位" min-width="72" align="center">
<template #default="{ row }">{{ row.unitLabel || row.unit || '—' }}</template>
</el-table-column>
<el-table-column prop="planQty" label="计划数量" min-width="100" align="right" />
<el-table-column label="实际数量" min-width="120">
<!-- 既有 el-input-number -->
</el-table-column>
<el-table-column prop="packageSpec" label="包装规格" min-width="110" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
```
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/views/pl/_shared/IoOrderListPage.vue
git commit -m "feat(web-admin): 出入库列表与编辑弹窗展示取送货及完整产品列"
```
---
### Task 6: 计划查看回传对齐
**Files:**
- Modify: `web-admin/src/components/business/pl-plan-view-tabs/index.vue`
- [ ] **Step 1: 扩展 `ioOrderViewFields`**
在既有字段后追加:
```typescript
{ label: '取货地址', value: ioOrder.value.pickupAddress, span: 'full' },
{ label: '取货时间', value: formatDateTime(ioOrder.value.pickupTime) || '—' },
{ label: '取货注意事项', value: ioOrder.value.pickupNotes, span: 'full' },
{
label: '送货地址',
value: ioOrder.value.destinationAddress || ioOrder.value.destinationName,
span: 'full'
},
{ label: '送达时间', value: formatDateTime(ioOrder.value.deliveryTime) || '—' },
{ label: '送货注意事项', value: ioOrder.value.destinationNotes, span: 'full' },
```
(若 `ViewField` 无 `span` 则按该组件既有 full-width 写法。)
- [ ] **Step 2: 只读子表列对齐 Task 5 的 8 列**
替换现有仅 `productName` / `specification` / `planQty` / `actualQty` 的列定义。
- [ ] **Step 3: 快捷编辑弹窗 `ioOrderEditForm` 产品表同样 8 列**(实际数量可编辑)
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/components/business/pl-plan-view-tabs/index.vue
git commit -m "feat(web-admin): 计划查看出入库回传补齐取送货与产品列"
```
---
### Task 7: 图谱更新与手工验收
**Files:** 无新增业务文件时仍更新图谱
- [ ] **Step 1: 更新知识图谱**
```bash
./scripts/update-graph.sh all
```
或分别 `backend` / `frontend`。
- [ ] **Step 2: 手工验收对照 Spec §8**
| ID | 操作 |
|----|------|
| V1 | 打开出库现场链接:取送货、导航、包装规格、单位「吨」 |
| V2 | 入库现场链接对称 |
| V3 | `/#/pl/.../inbound` 与 outbound 列表有取货/送货列 |
| V4 | 编辑弹窗 8 列产品 + 取送货;改实际数量保存成功 |
| V5 | 计划查看回传区一致 |
| V6 | 作废单现场不可提交 |
- [ ] **Step 3: 若有图谱变更则 commit**
```bash
git add backend/graphify-out web-admin/graphify-out graphify-out 2>/dev/null || true
git commit -m "chore: 更新 graphify 图谱(出入库 enrichment)" || true
```
---
## Spec 覆盖自检
| Spec 项 | Task |
|---------|------|
| D1 读时 enrichment | Task 1–2 |
| D2 入库出库对称 | Task 4–6 共享组件 |
| D3 表面 C | Task 5–6 |
| D4 单位标签 | Task 1 `unitLabel` / 公共 `public_unit_as_label` |
| D5 列表不铺满产品列 | Task 5 仅地址列 |
| D6 列表物流列 | Task 5 |
| D7 扩展只读 | Task 5–6 |
| 公共页 Demo | Task 4 |
| V1–V6 | Task 7 |
无 DDL;不改旧 `/public/pl/site`。