From dbd0d9f20538ca31058b5b7fc58aff6de60fa591 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Sun, 19 Jul 2026 16:37:55 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=87=BA=E5=85=A5=E5=BA=93=E5=8D=95?= =?UTF-8?q?=E7=8E=B0=E5=9C=BA=E9=93=BE=E6=8E=A5=E4=B8=8E=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按已确认 design 拆分为 enrichment、公共页、列表弹窗与计划回传共 7 个可勾选任务。 Co-authored-by: Cursor --- ...-07-19-pl-io-order-site-link-enrichment.md | 628 ++++++++++++++++++ 1 file changed, 628 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-pl-io-order-site-link-enrichment.md diff --git a/docs/superpowers/plans/2026-07-19-pl-io-order-site-link-enrichment.md b/docs/superpowers/plans/2026-07-19-pl-io-order-site-link-enrichment.md new file mode 100644 index 000000000..15b8225b9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-pl-io-order-site-link-enrichment.md @@ -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 + + + + +``` + +- [ ] **Step 2: 弹窗加宽并补主信息** + +`width="1080px"`。在往来单位后增加只读项: + +- 取货地址、取货时间、取货注意事项(注意事项 / 地址 `form-col-span-2`) +- 送货地址、送达时间、送货注意事项 + +时间用简单展示:`editForm.pickupTime` 字符串即可。 + +- [ ] **Step 3: 产品表改为 8 列** + +```vue + + + + + + + + + + + + +``` + +- [ ] **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`。