# ADR-0013: billcost 内嵌临时涨价图 = base 报价之上的 overlay - 自有 flyto 表落库 + 图预览可编辑审阅 + 统一分析视图 (overlay 不 flatten), 吸收 bill-recon 写路径作其继任者 - **Status**: Accepted as direction (5 条 confirmed decisions PM 拍板 2026-06-02); 实现进度: **Range A 已建** (内嵌图 VLM 抽 `parser.AdjustmentBundle` 作独立平行只读输出 + 内存态, ADR-0012 follow-up, 见 commit `bbc3a76`/`0b27d72`); P1 图预览+可编辑 / P2 落库+confirm 端点 / P3 统一分析视图 **属本 ADR roadmap 未建**. 本 ADR 把 billcost 定位为 bill-recon 内嵌图调价能力的**自足继任者**: bill-recon (老 物流对账 product) 在本能力过验收后**废弃**, 故 billcost 端到端自有 (落 `flyto` 库自有表, 吸收而非依赖 bill-recon 写逻辑). 中性共享库 (`parser.AdjustmentBundle` / `billrecon/llm.VisionClient`) 继续复用 (app 死, 抽取库活; 改中性路径名是后续 cleanup 不在本设计). - **Date**: 2026-06-02 - **Deciders**: PM (产品经理) + Flyto Agent core team - **Related code**: `platform/common/internal/server/quotedispatch_handler.go` (dispatch 端点 / `extractEmbeddedImageNotices` / `embedded_image` SSE / `handleQuoteDispatchGet`), `platform/common/internal/server/quotedispatch_vision.go` (`VisionExtractor.ExtractImageNotice`), `platform/common/quotedispatch/embedded_images.go` (`EmbeddedImage` / `ExtractEmbeddedImages`), `platform/common/quotedispatchstore/{store.go,postgres.go,inmemory.go}` (SessionStore 契约 + 实现), `platform/common/internal/db/pool.go` (`platformMigrations`), `platform/common/internal/billrecon/{parser,store,web}` (吸收源 + 中性库) - **Related ADRs**: ADR-0012 (billcost dispatch = sub-prompt 进化机 - 本 ADR 是其 follow-up: Range A 的内嵌图独立平行输出在此变 durable + editable + merged-as-overlay), ADR-0004 (bill-recon 价格模型 WMS 对齐 - cost_type 0/1 overlay 模型来源 + "当前不双写不 push WMS" §99-101 是本 ADR terminal-sink 风险的依据), ADR-0008 (quote-probe 协议 - dispatch loop 底座), ADR-0006 (typed ErrorCode - confirm 端点错误分类) - **Related memory**: `project_billcost_embedded_image_surcharge.md` / `project_billcost_quote_extraction.md` / `feedback_verify_against_truth.md` / `feedback_verify_extraction_against_prompt_rules.md` / `feedback_doc_sync.md` (34 省非 31, godoc 撒谎的实证教训) / `feedback_exported_field_delete_needs_review.md` (`reflect_tool.go` stale-comment trap) - **Commit chain**: Range A 底座 `bbc3a76` (内嵌图结构化临时加价抽取 + prompt UI 可编辑) + `0b27d72` (xlsx 内嵌图 VLM 解析独立平行输出); 本 ADR 设计归档 0 行代码, P1/P2/P3 实现属 roadmap. --- ## 1. 背景 / Context ### 1.1 内嵌图 = 临时涨价, 是报价的重要组成部分, 不是装饰 物流报价 xlsx 的 `xl/media/imageN.png` 内嵌着**临时涨价通知图** (调价公告): 某时间窗内, 某些省/市/网点的揽收或派费在基础报价之上**临时加收**. 这是报价的真实组成部分 (time-bounded surcharge), 不是附件. ADR-0012 follow-up (Range A, commit `bbc3a76`) 已把这些图经 MiniMax VLM 转录成结构化 `parser.AdjustmentBundle`, 作**独立平行输出** — 绝不进 SHEET_DUMP / sub-main-验收 loop (对齐 bill-recon C6 vision 独立 bundle). 但今天它**只活在流式 `final` / `embedded_image` NDJSON 行 + 内存里**: 不落库, GET 路径看不到, UI 渲染 (`fmtBundle`) 只读. 一刷新就丢. The xlsx embeds temporary-surcharge notice images. Range A already transcribes them to structured `parser.AdjustmentBundle` as a parallel read-only output, but it lives only on the transient SSE/NDJSON lines + memory: not persisted, invisible to GET, read-only UI, gone on refresh. ### 1.2 当前态三个缺口 (Range A 留下的) | 能力 | bill-recon (待废) | billcost 今天 | 缺口 | |---|---|---|---| | 结构化抽取 | `VisionClient`→bundle | 同 (共享库) | **PARITY** 无缺口 | | 图预览 | 落盘 `{sid}-images/` + `handleSessionImage` 服务字节 (含 path-traversal 加固) | `embedded_image` 事件只带 `{name,bundle,error}`, **无字节**; `fmtBundle` 只读 | **缺**: 无预览路径 | | 可编辑结构表 | 逐图 editable form + POST `bundleConfirmPayload` | 只读 | **缺**: 无编辑 | | approve/reject + 落库 | `handleAdjustmentsConfirm`: reject 过滤 + `SaveShipCostCfg` 写 `cost_type=1` overlay 行 | 无 confirm 端点, overlay 仅内存 | **缺**: 无落库 | ### 1.3 billcost 必须自足: bill-recon 是要废弃的, billcost 是其继任者 PM 拍板: 本能力过验收后**废弃 bill-recon** (老 物流对账 product). 因此 billcost **端到端自有**这条链路: 落到 billcost 自己在 `flyto` 库的表 (**不**接 bill-recon 的 `flyto_billrecon` 库 — 它要走了), **吸收** bill-recon 的写逻辑进 billcost (不 import/依赖将死的 bill-recon app). 中性共享库 (`parser.AdjustmentBundle` 结构 / `billrecon/llm.VisionClient`) 仍可复用 (app 死, 抽取库活; 改中性路径名是后续 cleanup, 不在本设计). 验收线: 必须足够好到 PM 能安全删掉 bill-recon 的内嵌图调价能力 — 把它设计成 bill-recon 的继任者, 不是玩具附加件. ### 1.4 DB 拓扑 ONE postgres-16 实例, TWO 库: `flyto` (billcost/platform) + `flyto_billrecon` (bill-recon, 待走). billcost 只连 `flyto`. 内嵌图 overlay 落 `flyto` 自有新表, 不碰 `flyto_billrecon`. --- ## 2. 决策 / Decision 把内嵌临时涨价图能力正式定位为 **base 报价之上的 overlay**, 落 billcost 自有 `flyto` 表, 镜像 bill-recon UX (图预览 + 可编辑结构表 + approve 落库), 吸收 bill-recon 写路径. 五条 confirmed decision (不重新论证) + 一份 reconcile 后的**单一** schema. ### 2.0 五条 confirmed decision (设计前提, 不 relitigate) 1. **OVERLAY 模型, 不 flatten**. 临时涨价 = time-bounded overlay = WMS `cost_type=1` (AdjustedCost) 行叠加在 base 报价**之上**. "merge into analysis" = **统一视图** (base + overlay 一起渲染); WMS 在 calc 时按 date+basis overlay-then-compute. **绝不**把 overlay 行物理插进 `base.details[]` (那正是要刻意避开的 bill-recon C7 time-basis-join flatten 问题). 2. **billcost 自足**, 落 `flyto` 自有表, 吸收非依赖 bill-recon. 中性库可继续复用. 3. **3 维度** UI 要 surface + group: (a) **时间期限** = `start_date`/`end_date`; (b) **揽收涨价** = `is_delivery_fee=false` (揽收日基, 中转费/揽收); (c) **目的地网点涨价** = `is_delivery_fee=true` (派费/签收日基) **且/或** `target_sites` (具体目的地网点代码) + `is_target_specific`. 4. **镜像 bill-recon UX**: 图预览 next to 解析结果 + 可编辑结构表 + APPROVE 动作落库. 5. **验收线**: 好到 PM 能安全删 bill-recon 内嵌图调价能力. 当继任者设计, 非玩具. ### 2.1 3 维是 FACET 不是三分区 (核心建模认知) decision 3 的三维是同一条 `AdjustmentDetail` 行**同时携带**的三属性, 不是把行分进互斥桶. 证据: decision 3 措辞 "目的地网点 = `is_delivery_fee=true` **AND/OR** `target_sites`" — 一条 `is_delivery_fee=false` 但 pin 了 `target_sites` 的行同属 (b) 揽收 和 (c) 目的地网点. **禁止三个互斥 tab UI** (这种行无处可归). 正确模型: 每行展示全部三属性, UI 让 reviewer 按任一 facet pivot/group. 默认按**时间期限优先**分组 (镜像 WMS calc 时 date→basis→scope 的施加顺序, 最不误导). `is_delivery_fee` 是唯一真二元 facet (false=揽收·按揽收 / true=派费·按签收, 正是 `fmtBundle` 已用的两 label), 作组内次级分隔. ### 2.2 数据模型: 关系型 master+detail 双表, 不是 sessions 上的 JSONB 列 **单一 schema 权威 (本节, 兼并 P1/P2/数据模型三处草案的矛盾)**: 两张 billcost 自有表 `quote_dispatch_adjustment_master` + `quote_dispatch_adjustment_detail`, 列对列镜像 bill-recon 的 `ship_cost_cfg_master`/`ship_cost_cfg`, 但 keyed 到 `session_id TEXT` 而非 `bill_id BIGINT`. 决定性论据是**吸收成本**, 不是 WMS 美学. 要吸收的写路径 (`SaveShipCostCfg`, `store/postgres.go:261-400`) 有 INSERT 列表在 :308 + :373. DDL 对齐这两个列表, 整条写路径 (delete-then-insert 幂等 / `nullIfEmpty`/`startAny`/`vwAny` NULL 处理 / `target_sites` JSON marshal / bool→int16) 近原样移植. 只两处变: `bill_id BIGINT` → `session_id TEXT`, 表名换. sessions 上一个 JSONB 列会扔掉这套代码换成定制 merge/marshal. 关系型胜出, JSONB-on-sessions 败的三条: 1. **逐行人工 EDIT** (decision 4): 关系行逐行 UPDATE; JSONB blob 每改一格要 read-modify-write 整数组. 2. **按维度可查** (decision 3): 关系列 + partial index 直服务; JSONB 要 expression/GIN index 同效但 ergonomics 差. 3. **`quote_dispatch_sessions` 已带 `xlsx_blob` (会 TOAST)**: overlay JSONB 堆这行上, 每次 `SessionStore.Get` (读整行含 blob) 都拖 overlay = regression. overlay 该在自己表, 只在渲染 overlay 视图时读. JSONB 只用在一处它配得上的地方: master 上 `raw_extraction JSONB` = VLM 原始 bundle (provenance / revert), 完全照 bill-recon. ### 2.3 单一 reconcile 后的 DDL (CREATE TABLE IF NOT EXISTS, 追加进 `internal/db/pool.go` 的 `platformMigrations`) > **本 DDL 解决三个 schema-owning 草案之间的矛盾**, 实现前以此为唯一权威 (见 §3 替代方案记录被否的写法): > - **无 `approved` 列** (drafts ephemeral, 见 §2.6; 否决 autosave-draft 模型). > - **无 `file_site` 列** (billcost 图在内存, 用 `image_name` 作 per-image 唯一键; 移植 INSERT 时不引用 `FileSite`). > - **无 `dest_site_no` 列** (bill-recon 已弃, alpha.23 终态). > - **per-image 列定名 `image_name`** (非 `source_image`; 三草案曾用 `source_image`/`image_name`/`file_site` 三名指同一概念, 统一为 `image_name`). 这列**load-bearing 非装饰**: billcost 路径 `NoticeGroupID` 恒空 (见 §2.7), `image_name` 是唯一稳定 per-image 键. > - **provenance 列定名 `vision_extracted_at`** (非 `vision_at`; 对齐 `VisionAt` struct 的 json tag + bill-recon 列名, 降吸收摩擦). ```sql -- quote_dispatch_adjustment_master: billcost-OWNED successor to bill-recon -- CWMACCT.ShipCostCfgMaster. One embedded price-adjustment notice image -> -- one master row + N detail rows. Keyed to a dispatch session (TEXT id, -- ON DELETE CASCADE) instead of bill-recon's bill_id. cost_type fixed 1 -- (AdjustedCost = the time-bounded overlay applied ON TOP of the base quote; -- NEVER flattened into the base details[]). Absorbs SaveShipCostCfg's write -- path: column list mirrors ship_cost_cfg_master so the proven INSERT ports -- near-verbatim (only bill_id->session_id + table name change). -- -- billcost 自有, 继任 bill-recon ShipCostCfgMaster. 一张内嵌调价图 -> 一 -- master + N detail. 按 session_id (TEXT, CASCADE) keying 而非 bill_id. -- cost_type 恒 1 (overlay 叠在 base 之上, 永不 flatten 进 base details[]). CREATE TABLE IF NOT EXISTS quote_dispatch_adjustment_master ( id BIGSERIAL PRIMARY KEY, session_id TEXT NOT NULL REFERENCES quote_dispatch_sessions(id) ON DELETE CASCADE, notice_group_id TEXT NOT NULL DEFAULT '', -- bundle group; billcost 路径恒空, 见 image_name image_name TEXT NOT NULL DEFAULT '', -- per-image 唯一键 (EmbeddedImage.Name, e.g. "image3.png"). load-bearing, not provenance-only. whs_id CHAR(10) NOT NULL DEFAULT '', cost_type SMALLINT NOT NULL DEFAULT 1, -- always 1 = AdjustedCost (overlay). never 0 here. ship_type_id VARCHAR(10), ship_type_msn VARCHAR(20), site_no VARCHAR(100) NOT NULL DEFAULT '', -- carried for absorption fidelity; not driven by billcost yet. standard_cost_sys_no INTEGER NOT NULL DEFAULT 0, -- carried; ALWAYS 0 on billcost (formless VLM flow, no WMS-base pin). see §4 F-WMS3. start_date DATE, -- DIM 1 (time period), notice-level default. end_date DATE, -- DIM 1 (time period), notice-level default. priority INTEGER NOT NULL DEFAULT 0, -- carried, unused. common_status SMALLINT NOT NULL DEFAULT 1, -- 1 = Actived (reaches store only after approve). vision_extracted_at TIMESTAMPTZ, -- audit: when the VLM read it. NULL = hand-filled (vlm errored). MUST be set at successful extract, see §2.7. raw_extraction JSONB, -- audit: the VLM's ORIGINAL bundle; detail rows below are the EDITED/live version. summary TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_qd_adj_master_session ON quote_dispatch_adjustment_master(session_id); CREATE INDEX IF NOT EXISTS idx_qd_adj_master_dates ON quote_dispatch_adjustment_master(start_date, end_date); -- quote_dispatch_adjustment_detail: billcost-OWNED successor to bill-recon -- CWMACCT.ShipCostCfg (1 master : N detail). master_id NOT NULL CASCADE FK. -- Column list mirrors ship_cost_cfg (alpha.23 final shape) so the detail -- INSERT ports verbatim. dest_site_no + file_site DROPPED. is_delivery_fee + -- is_target_specific/target_sites carry DIM 2 (揽收 vs 派费) + DIM 3 (目的地 -- 网点). province_name stores "全国" VERBATIM -- NO store-time fan-out (see -- §2.4 + decision 1); the unified view (P3) expands at render/calc time. -- -- billcost 自有, 继任 ship_cost_cfg. province_name 存 "全国" 字面, 不在 -- 落库时 fan-out (那是 flatten, 违 decision 1); P3 视图层按需展开. CREATE TABLE IF NOT EXISTS quote_dispatch_adjustment_detail ( id BIGSERIAL PRIMARY KEY, master_id BIGINT NOT NULL REFERENCES quote_dispatch_adjustment_master(id) ON DELETE CASCADE, whs_id CHAR(10) NOT NULL DEFAULT '', ship_type_id VARCHAR(10), ship_type_msn VARCHAR(20), province_name VARCHAR(20) NOT NULL DEFAULT '', -- stores "全国" verbatim (NO fan-out); base quote is 31-province-partitioned. city_name VARCHAR(50), is_delivery_fee SMALLINT NOT NULL DEFAULT 0, -- DIM 2: 0 = 揽收/pickup-basis (中转费); 1 = 派费/签收-basis. is_target_specific BOOLEAN NOT NULL DEFAULT FALSE, -- DIM 3: TRUE = applies only to target_sites branches. server-authoritative = len(target_sites)>0, see §2.7. target_sites JSONB NOT NULL DEFAULT '[]'::jsonb, -- DIM 3: ["BJ-CY-201",...] dest network-branch codes; [] when not specific. is_strip_fee SMALLINT NOT NULL DEFAULT 0, -- 单带费 flag (coexists independently). limit_top INTEGER NOT NULL DEFAULT 0, -- weight band (grams). limit_bottom INTEGER NOT NULL DEFAULT 0, base_weight NUMERIC(10,2) NOT NULL DEFAULT 0, base_amount NUMERIC(10,2) NOT NULL DEFAULT 0, increment_weight INTEGER NOT NULL DEFAULT 0, increment_price NUMERIC(10,2) NOT NULL DEFAULT 0, other_amount NUMERIC(10,2) NOT NULL DEFAULT 0, -- the surcharge delta. start_date DATE, -- DIM 1 per-row override of master dates. end_date DATE, -- DIM 1 per-row override. vw_rate INTEGER, -- carried for absorption fidelity. created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_qd_adj_detail_master ON quote_dispatch_adjustment_detail(master_id); CREATE INDEX IF NOT EXISTS idx_qd_adj_detail_province ON quote_dispatch_adjustment_detail(province_name); CREATE INDEX IF NOT EXISTS idx_qd_adj_detail_dates ON quote_dispatch_adjustment_detail(start_date, end_date); CREATE INDEX IF NOT EXISTS idx_qd_adj_detail_delivery_fee ON quote_dispatch_adjustment_detail(is_delivery_fee) WHERE is_delivery_fee <> 0; CREATE INDEX IF NOT EXISTS idx_qd_adj_detail_target ON quote_dispatch_adjustment_detail(is_target_specific) WHERE is_target_specific = TRUE; CREATE INDEX IF NOT EXISTS idx_qd_adj_detail_strip ON quote_dispatch_adjustment_detail(is_strip_fee) WHERE is_strip_fee <> 0; ``` 迁移顺序: master 条目在 detail 之前 (detail FK 引 master), 追加进 `platformMigrations` (pool.go:44-148) `quote_dispatch_rounds` 块之后. append-only, 各自独立幂等; `quote_dispatch_sessions(id)` FK 已在列表更早处建. **机械陷阱 (已核实)**: `quote_dispatch_sessions(id)` 是 `TEXT`, 故 overlay FK 是 `session_id TEXT NOT NULL REFERENCES quote_dispatch_sessions(id) ON DELETE CASCADE` — 不是反射性从 bill-recon 抄的 `BIGINT bill_id`. ### 2.4 全国 fan-out: 落库**不** fan-out, 视图层展开 (reconcile 两草案矛盾) 数据模型草案曾说 "fan-out 留 store 层"; P2 草案反对 "store 全国 verbatim". **P2 对, 数据模型草案错** — store-time fan-out **就是** flatten (展开了 destination 维), 违 decision 1. 故 billcost 落库存 `全国` **单行字面**, store 方法**不**调 `parser.IsNationwide`/`ChinaProvinces`. `raw_extraction` 保留原始 "全国" 简写供 revert/audit. **关键 reconcile (实现者必读)**: "落库不 fan-out" 与 "P3 视图展开全国" **不矛盾** — view-time 展开 (为显示/coverage 检查) ≠ storage flatten (为物理存). P3 统一视图**必须**在 match 前于视图层展开全国 (复用中性库 `parser.ChinaProvinces`/`IsNationwide`), 否则最常见的全国临时加价会 silent-orphan (见 §2.8 + §4 F-COV1, 这是 live bug). 一句话: store 不展开, view 必须展开. > **`ChinaProvinces` 是 34 条不是 31** (实证 grep: 31 省 + 港/澳/台 + 自治区). godoc :241 写 "31-entry list" 是**撒谎** (`feedback_doc_sync` 教训复现). 任何写 "全国→31 行" 的测试会对真 34 条 slice 失败, 或有人"修" slice 去对齐注释从而静默丢港澳台. **实现时顺手把 `ChinaProvinces` godoc 31→34 改对**, 否则谎言传染进 billcost. ### 2.5 raw-vs-edited 存储 + provenance 照 bill-recon: `raw_extraction JSONB` on master = VLM 原始 bundle; detail 行 **是** edited/live 版. 人编辑表格某格 → 对应 detail 行 UPDATE; `raw_extraction` 永不动 (保 provenance baseline + "全国" 简写). provenance 列: `vision_extracted_at` (VLM 何时跑; SQL NULL = "VLM 出错, 人手填", 同 bill-recon `SaveShipCostCfg` :292-299 约定) + `image_name` (哪张图) + `notice_group_id` (bundle group). ### 2.6 草稿 ephemeral + 落库幂等 + confirm 端点契约 **drafts ephemeral (否决 `approved` autosave 模型)**: 无 `approved` 列. "in-table = 已 approve 落库 (persisted), absent = draft". 核实依据: bill-recon 的 mid-review 草稿**也不**跨刷新存活 — `handleGetBill`→`ListShipCostCfg` 只 re-fetch **已落库** adjustments (web/server.go:376); 未 confirm 的草稿只活在 live engine/SSE 流 + 浏览器 DOM. 故 billcost drafts ephemeral = **parity 非 regression** (验收线 decision 5 满足). 刷新丢未确认编辑记为 known limitation (§4 F-DRAFT), autosave 记为 deferred follow-up (§6). **落库幂等 = whole-overlay-replace (delete-by-session-then-insert-all, 单 tx)**, 照 `SaveShipCostCfg` :274 (CASCADE 清 detail). 同 session 重 confirm = 幂等替换 = 正是 "re-open + edit" 需求. **无 409** (bill-recon 的 409 守的是 buffered-channel double-submit, billcost store-direct 无此, 移植 409 会破 re-open+edit). **confirm 端点契约 (whole-replace 的客户端约束, 必须命名+测试)**: 因 delete-all 先跑, confirm POST **必须 serialize 整套 bundle** (`ListAdjustments` 返的全部 + 任何 live 新增). **partial POST 是有意 wipe, 不是 bug** — 这是 whole-replace 语义, 不是服务端守卫. 故 P3 的 approve 动作 (作 confirm 端点第二 client) **必须**也 serialize 整套; P3 §2.11 的 "one draft object" 由此从约定升为**强制不变量**. 对应测试 = 整套 round-trip 存活 (见 §5); **不**写 "POST 仅 B, A+C 存活" 那种自相矛盾的断言 (whole-replace 下 delete-all-then-insert-B 必然 wipe A+C, 那是语义不是 bug). > 替代: per-image upsert (`DELETE … WHERE session_id=$1 AND image_name=$N`), 允许 confirm 只带改动的图. 编辑 loop 更安全但需稳定 per-image 键 (`image_name` 已具备). 否决理由: whole-replace + 客户端整套契约更简单且与 ephemeral-draft + `ListAdjustments`-based re-open 一致. 按 "原方案保留" 记于此 + §3. ### 2.7 服务端权威字段 + 抽取时刻写 provenance (bug-class fix, 入 spec 非 caveat) - **`IsTargetSpecific` 服务端权威**: confirm handler 设 `bundle.Details[i].IsTargetSpecific = len(dp.TargetSites) > 0`, **忽略 payload bool** (或校验不一致则 400). 不信每个 client (P1 浏览器 / P3 面板) 都正确 re-derive. 否则 user 清空 `target_sites` 但浏览器 derivation 漏改, 会持久化 `is_target_specific=true` + `target_sites=[]` 的不连贯行, compute 层会 mis-scope. - **`VisionAt` 抽取成功时必须 `time.Now()`**: 今天 billcost 路径**从不**设 `VisionAt` (grep 无赋值). 不设则 `vision_extracted_at` 对**成功**抽取也 NULL → "VLM 读过 vs 手填" 的 audit 判别器**反转** (每次成功看似手填). 必须在 `extractEmbeddedImageNotices` (或 `ExtractImageNotice`) 成功后、emit 前设 `VisionAt`. 并确认 `RawExtraction` 确被 `ExtractShipCostCfgWithPrompt` 填充 (若空, NULL 判别器同样反转). - **`NoticeGroupID` billcost 路径恒空**: bill-recon 由其 workflow 在 `SaveShipCostCfg` 前填; billcost VLM 路径从不填. 故 `image_name` (= `EmbeddedImage.Name`) 是 billcost 唯一稳定 per-image 键, 用于 P1↔P3 record 匹配 + (若选 per-image upsert) upsert 键. `notice_group_id` 列容忍空, `image_name` 权威. ### 2.8 统一视图必须标 coverage verdict (bug-class fix, 入 spec) P3 统一视图对每条 overlay 行**必须**渲染三种 coverage verdict 之一, 不能只渲正向 match (只渲 match = silent-failure trap): - **`matched`**: 正向, 带它影响的 base 行. - **`orphan`**: 此 grain 无 base 行匹配 → **大声 warning** (全国未在视图层展开 / 偏远省 base 无此行 / period 与 base 报价有效窗不交). - **`unverifiable`**: branch-scoped (`target_sites`) 比 base grain (省/市) 细, base 无 per-branch 行 → 中性 flag, **不**按省假 match (会误示全省覆盖). 四个 silent-never-apply 模式视图必须标 (见 §4 F-COV): 全国 grain mismatch (最常见, live bug, 必须视图层展开全国再 match) / overlay 行匹配零 base 行 / overlay period 在 base 有效窗外 / `target_sites` pin 了 base grain 表达不了的网点. 验收线含 "reviewer 能看见未生效的 surcharge" — 这正是人 sanity-check coverage 的依凭. ### 2.9 多页通知: 完整度 badge + edit-one-reject-sibling 工作流 (bug-class fix, 入 spec) 抽取严格逐图 (`extractEmbeddedImageNotices`, 一图一 VLM 调用, 零跨图上下文). `_vision.md` rule 7 让多页通知非首页产出**partial-but-valid** bundle (priced master 但空/错 dates, 或 dated master 但空 `details[]`) — VLM **不**报 error. 当前 badge `ok = !obj.error` 会给两个 partial 卡都打绿 `已解析`, 无 banner → approve 两个 = surcharge silently mis-dated. 这是 `verify-against-truth` 文化要防的 success-shaped failure. **修 (派生完整度 badge, 替换二元 badge)**: - priced `details[]` + 空 master dates → **amber** "缺生效日期, 请核对". - 非空 master dates + 空 `details[]` → **amber** "无定价明细 (可能多页通知非首页) — 合并到对应定价卡或剔除". - 两者全 → **green** `已解析`. **多页 consolidation 工作流 (文档化, 非隐式)**: 编辑**一**张卡 (priced 的) 到完整 (从 image6 显示设 dates, `+加一行明细` 补行) → **reject** 兄弟 partial 卡 (P2 reject-drop 让被拒半永不落库). 结果一份正确 `cost_type=1` master. `image_name` 读 "image5" 非 "image5+6" — cosmetic 非结构. **空-details 卡的 master fields 必须可见可编辑** (即使 details 空), 不得 gate master-header 渲染于 `details.length>0` — 否则 image6 的 dates (它唯一携带的) 不可恢复. **不**建 image-independent / cross-card-merge overlay primitive (over-engineering, 一卡一图模型 + `+加一行` + reject-sibling 已覆盖; 记为 optional-future). ### 2.10 三元组 (承运商身份) 上传前捕获 - 移植 bill-recon "vlm 不再抽" 表单 (PM 2026-06-02 升 in-scope) PM 拍板**整个**抛弃 bill-recon app (非仅内嵌图能力, 见 §4 F-RECON 已确认接受丢 ingest + 对账录入). 唯一**必须移植**的是 bill-recon 上传表单那段 **"承运商成本配置 (上传前选定, vlm 不再抽)"** — 它捕获报价/调价的**归属身份**: 仓库 `whs_id` (默认 W02) + 承运商 `ship_type_id` + 月结账号 `ship_type_msn` + 网点 `site_no`. 这几个是 VLM **抽不可靠**的业务上下文 (承运商/月结账号不在图里, 是签约关系), bill-recon 故意用表单选定而非 VLM. billcost 是 VLM 驱动的, 今天这些在 overlay/base master 上**空/free-text** (= F-WMS3 的 identity 部分). 不移植 = 持久化的报价/涨价是**匿名行** (不知哪个仓/承运商/账号/网点), 落库无意义. **移植 (in-scope, 非 deferred)**: 把这段"上传前选身份"搬进 billcost dispatch UI, 经 dispatch request 串到 server, **stamp 到每条 overlay master (P2) + base 报价 master**: - `whs_id` (仓库, 默认 W02 可改) / `ship_type_id` (承运商, 必填) / `ship_type_msn` (月结账号, 选承运商后填) / `site_no` (网点, 自由填可空). - **"三元组" = 核心承运商身份 (`ship_type_id` / `ship_type_msn` / `site_no`)**; `whs_id` 作仓库上下文一并捕获. 四个 master 列均已在 §2.3 DDL (`whs_id`/`ship_type_id`/`ship_type_msn`/`site_no`), 本就是吸收 `SaveShipCostCfg` 列表的一部分, 零 schema 变更 - 只缺**前端捕获 + 串接 + stamp**. - `standard_cost_sys_no` 的 WMS-base-pin 仍 **deferred** (那需接 WMS 下拉选 base 行, 是 WMS-linkability 不是 identity, 见 F-WMS3). **file-level 追加** (并入 §7.1 P1 / §7.2 P2): P1 UI 加承运商身份输入区 (复用 bill-recon 表单形态: 仓库/承运商/月结账号/网点, 但 billcost 无 WMS 字典下拉故承运商/月结暂 free-text 或本地预置 list, 接 WMS 字典是 follow-up); dispatch multipart 接这四字段 (`whs_id`/`ship_type_id`/`ship_type_msn`/`site_no`); P2 `handleQuoteDispatchAdjustmentsConfirm` 的 `toBundle()` 把它们写进 master; base 报价 master 在 `SetPhase1Result` 前同样 stamp (handler 已持有这四字段). 测试: round-trip 含三元组存活 (并入 §5.1/§5.2). > **实现说明 (2026-06-04, base-stamp 已做; 有意偏离上文)**: overlay master 三元组 stamp 如计划 (P2 已做). base 报价三元组**改用 session 旁列**而非"`SetPhase1Result` 前 stamp 进 master" — 即 `quote_dispatch_sessions` 加 4 个 idempotent ADD COLUMN (`whs_id`/`ship_type_id`/`ship_type_msn`/`site_no` TEXT), 在 `Create` 时从 dispatch form stamp, **不注入 `phase_1_result` JSON blob**. 理由 (advisor): mutate 一个已抽取/已存好的 result blob 是风险路径 (注入破坏报价 JSON / 抽取与 stamp 时序耦合); 旁列零风险、零 blob 改写、Get 自然 round-trip. `GET /analysis` 加 `identity` 对象 (从旁列读), 前端 AnalysisView 显承运商身份行. 效果等价 (base + overlay 都带可见三元组, 审阅者看归属一致), 路径更安全. --- ## 3. 替代方案 / Alternatives - **sessions 上一个 overlay JSONB 列 (非关系双表)**: 否决 — 扔掉吸收的 `SaveShipCostCfg` 写路径换定制 merge/marshal; 逐行 edit 要整数组 read-modify-write; 维度查询要 GIN; 拖累 `Get` (读含 `xlsx_blob` 的整行). 见 §2.2. - **落库时 fan-out 全国→34 行**: 否决 — store-time fan-out 是 flatten (展开 destination 维), 违 decision 1. 存字面 "全国", P3 视图层展开. 见 §2.4. (数据模型草案的 "fan-out 留 store 层" 据此 strike.) - **`approved BOOLEAN` 列 + autosave-draft 模型** (抽取时写 `approved=FALSE`, confirm 翻 TRUE): 否决 — bill-recon 草稿也不跨刷新存活, ephemeral = parity 非 regression; 加列 + 双写路径 + 让 P3 的 `source=draft` 依赖落库 draft (不存在). 选 "in-table=persisted / absent=draft" (P2+P3 自洽). autosave 记为 deferred follow-up (§6). 见 §2.6. (本是 edit-conflict 草案推的 BLOCKING 修, 但核实 bill-recon 草稿 ephemeral 后降级为 follow-up.) - **per-image upsert (`DELETE WHERE session_id AND image_name`) 替 whole-replace**: 编辑 loop 更安全 (confirm 可只带改动图), 但需稳定 per-image 键 (`image_name` 已具备) + 偏离吸收的 `SaveShipCostCfg` 整段 delete-by-key. 否决理由: whole-replace + 客户端整套契约更简单且与 ephemeral-draft + `ListAdjustments` re-open 一致. 留作 follow-up 选项. 见 §2.6. - **import `billrecon/store` 而非吸收 `SaveShipCostCfg`**: 否决 — 该 store 硬连 `bills(id)` FK + 将死的 `flyto_billrecon` schema, 构造器混入 recon-only 方法. 必须复制算法、自有表. 见 §7 吸收图. - **P1 图字节走 serve 端点 (非 base64-inline)**: 当前 corpus (6 图, 最大 906KB, base64 ~2.9MB) inline 可接受且零 timing 依赖; serve 端点是 net-new 路由 + path allow-list. 否决为 P1 默认, 留作阈值逃生 (>2MB/图 或 >15 图 → 切 `xlsx_blob` re-extract 端点, 已核实可行因 `xlsx_blob` 在 create 时落). 见 §7 P1. - **image-independent / cross-card-merge overlay primitive**: 否决为 over-engineering — 一卡一图 + `+加一行` + reject-sibling 已覆盖多页 consolidation; 引入第二编辑面. 记 optional-future. 见 §2.9. - **直接 push overlay 进真 WMS (`cwmacct`) 作生产定价**: 不在本设计 — WMS 连接是 read-only ADS replica (契约上不可写), 无 billcost→WMS 写路径. 是 REQUIRED UNBUILT follow-up (§4 F-WMS + §6). --- ## 4. 影响 / Consequences ### 4.1 正面 - loop 的内嵌图 overlay 从 "流式一次性内存输出" 升为 **durable + editable + 统一视图 merged**; 一刷新即丢的缺陷被修. - billcost 端到端自有内嵌图调价能力, 达 bill-recon parity (含其 `cost_type=1` overlay-not-flatten 原生形态), PM 可安全删 bill-recon 内嵌图能力. - overlay 与 base 在 `flyto` 内自洽并置 (统一视图), 不污染 `phase_1_result`. - **billcost 的 180s timeout + 3x EOF-retry (2s backoff, ctx-cancel-aware) 本就优于 bill-recon** (后者硬编码 90s 无 retry; 90s 曾让 ytosample image1 的 33 行通知 clip). 这是 parity-**plus**: 废 bill-recon 在此零损失, 反丢掉它劣等的 90s/无 retry 行为. **不要 re-absorb 或回退到 90s** (它已在 billcost, 非缺口非待吸收). ### 4.2 诚实记录的风险 / 缺口 / 限制 (不埋, 验收前 PM 必须听到) **F-WMS (terminal-sink, 最大澄清)**: 持久化的 overlay 今天**到达不了任何自动 consumer**. 实证: (a) `wms` 包**只读** (`doc.go` 称 AnalyticDB ADS read replica, "physically unable to take writes"; grep `INSERT|UPDATE|Exec` 在该包**无**); (b) ADR-0004:99-101 明写 "当前不双写不 push WMS"; (c) 应用 `cost_type=1` 行的 C7/recon consumer **未建** (`recon_results` 无 writer, `store/postgres.go:3-9` 仍承诺 "later commits add writers"). 故 billcost 的 `flyto.quote_dispatch_adjustment_*` 行是**今天的 terminal sink** — 只被自己的审阅/统一视图读, 无自动 calc 消费. **这是 parity 非 regression**: bill-recon 也存了无人应用的 `cost_type=1` 行 (无 recon engine 无 WMS push). store 仍配位: 人工统一视图审阅**就是**验收线, 且这些表是未来 C7/C8 引擎的正确地基. - **必做 (editorial + ADR-recorded)**: scrub 所有 "WMS overlays-then-computes by date+basis at calc time" 措辞 (数据模型 DIM-1 note / P3 §4) → "是**未来** calc 层 (WMS C7 / billcost compute step) 的职责, **尚未建**; billcost 的活到 structured + reviewed + persisted 为止". 本 ADR 已 scrub. re-eval trigger 指向 ADR-0004 §7. **F-WMS2 (base 也不在 WMS)**: `phase_1_result` 只在 `flyto.quote_dispatch_sessions`, 无 writer push 外. billcost 抽的 base 报价本身也从不发布进 WMS `ShipCostCfgMaster` (cost_type=0). 故 base 与 overlay **都**在 `flyto` Postgres, 与它们概念上 overlay 的 WMS 行脱钩. "merge into analysis" 交付的是 **review artifact, 不是生产价源**. 明示 scope: 本能力产出 `flyto`-local、结构化、人审的 overlay+base **分析**; **不**更新 WMS 生产定价. 把两者 reconcile 进/发布进 `cwmacct.ShipCostCfgMaster` 是 out-of-scope + unbuilt. **F-WMS3 (`standard_cost_sys_no` 是 dead FK)**: WMS 里调价 master 经 `StandardCostSysNo` 关联 base (cost_type=0) 行 (`dictionaries.go:163-165`), 让 C7 反查拿 base 价. bill-recon 操作员从 WMS 下拉**选** `StandardCost` 故 overlay 被 pin 到真 WMS base 行. billcost **无 upload-time 5-下拉 form** (P1 carrier 字段 VLM-extracted/free-text), 故 `standard_cost_sys_no` 在每条 billcost overlay 行**恒 0/空**. overlay 因此**不可寻址**到 WMS base 行. 列定义已注 "carried; ALWAYS 0 on billcost", **不**让未来工程师误信链接存在. 若要 WMS-linkability 是 follow-up (把 `ListStandardCosts` 选择接进 P1 审阅 UI 让操作员 pin base). **注: identity 部分 (承运商三元组 `whs_id`/`ship_type_id`/`ship_type_msn`/`site_no`) 已升 in-scope (§2.10, PM 要求移植); 此处 deferred 的仅是 `standard_cost_sys_no` 的 WMS-base-pin (linkability), 两者别混.** **F-RECON (reconciliation 不覆盖, 业务语言大声 flag)**: **本能力替代 bill-recon 内嵌图调价抽取 ONLY, 不提供 carrier-bill-vs-quote 对账** (bill-recon 自己也从没建完). 实证: `recon_results` 无 writer (只代码注释承诺); C7 (派费签收日 join) / C8 (规则引擎) — 真对账数学 — 未实现; 唯一接通的对账面是 `RecordBillReconciliationScore` (score **intake** 端点, 外部 pipeline POST `{match_count,total_count}`, bill-recon **存**别处算的分, 不**算**对账). **故废 bill-recon app 会丢: (a) 圆通月账单 ingest pipeline; (b) bill-vs-quote 对账的 *aspiration* (空表 + score-intake, 无引擎).** **PM 必须知**: "删 bill-recon 内嵌图能力" ≠ "删 bill-recon **app**". 若删整 app, 也删圆通发票 ingest + 对账 score-intake 端点. 若将来要真对账, 是 net-new 引擎 — billcost 现自有的 `cost_type=1` overlay 表正是其正确地基, 但比对逻辑两 product 今天都不存在. **PM 决定 (2026-06-02): 整个 app 抛弃, 接受丢失圆通 ingest + 对账 score-intake (对账引擎本就没建完). 唯一必须移植的是承运商身份三元组 (§2.10, 已升 in-scope)** — 因它是 VLM 抽不到、又是报价归属所必需的. 圆通 invoice ingest 若将来还要, 是 net-new (不在本 scope). **F-DRAFT (草稿刷新丢失, known limitation)**: 未 approve 的 overlay (含人手填的失败-抽取卡) **不跨浏览器刷新存活** (drafts ephemeral, §2.6). re-open 恢复 approved-only. = bill-recon parity (其草稿也只活 live stream). P3 的 `source=draft` 只存在于 live 未刷新 client (SSE 流灌进 `adjEdits`); GET `/analysis` 总返 `persisted` (可能空). **autosave 草稿 (抽取/首次编辑时写, confirm 时定稿) 是 deferred follow-up** (§6) — 选它当 "successor 到 bill-recon, PM 能安全删" 验收线不需要, 但若 PM 后续要 "刷新不丢手填" 再做. **F-PARTIAL-POST (跨 P1/P3 边界的 silent data-loss, 已由契约关闭)**: whole-replace delete-all 语义下, confirm 的 partial POST 会 wipe 未重发的图. P3 引入第二 client (统一视图 approve) POST 同端点 → P1-confirmed 的其他图可能被 P3 partial POST 静默 wipe. **关闭机制**: §2.6 命名 "confirm 必须 serialize 整套" 为 confirm 端点契约不变量 + P1/P3 共享一份 `adjEdits` 整套序列化 (P3 §2.11 "one draft object" 升为强制) + §5 整套 round-trip 测试. **必须测, 否则是设计自造的 silent loss 路径**. **F-COV (surcharge 可能 silent never-apply, 已由 §2.8 coverage verdict 关闭)**: 见 §2.8. 全国 grain mismatch 是 **live bug 非假设** — base 报价是 31-province-partition (reflector 硬契约 `engine_factory.go:277` / `dispatch.go:856` "31 省名单"), overlay 存 "全国" 字面, 朴素 `province_name` 等值 join 对**每个全国 surcharge 零匹配** → 最常见的 overlay 形态 silently orphan, reviewer 误判已覆盖. 修: 视图层 match 前展开全国 (§2.4) + 三态 coverage verdict (§2.8). **F-LAYER (base/overlay 共享 flag 命名空间, minor)**: base sub-agent prompt **也**在 base 行 emit `is_delivery_fee`/`is_strip_fee`/`is_target_specific`/`target_sites` (sub_agent.md:99-101: 北京单带费 / 广东派费 / 成都 target_sites 在 *base* 报价里). 这些 flag **非** overlay 专属. 统一视图的 layer 分离**必须**来自**行从哪张表读** (base=`phase_1_result`, overlay=`quote_dispatch_adjustment_*`), **绝不**靠 sniff `is_delivery_fee`. P3 已按 source table keying — 明示之, 免实现者 "detect overlay" 误分类 base 派费行. **F-IDEMPOTENCY (仅 per-session-confirm 幂等, 无 cross-upload dedup)**: 幂等键 = `session_id`, 只让同 session 重 confirm 幂等. bill-recon 在更高层 dedup (`SaveBill` ON CONFLICT on 文件 SHA256, 同文件复用同 `bill_id`). billcost 的 `quote_dispatch_sessions` **无 sha256 列无 dedup**, 每次上传新 `id`. 故同物理 xlsx 重传 = 独立 session = 独立 overlay (**不**复制 within session, 但跨 session 不替换). **不得宣称 "re-upload replaces"** — 是 re-*confirm* replace 非 re-*upload*. 若 PM 期望 "同文件=同 overlay" parity, 是 GAP (需 `quote_dispatch_sessions` 加 sha256 + dedup-on-create), 不在本 scope, 记为决策非沉默 (§6). **F-DELTA (delta-vs-full-price 可改不可自检)**: 已知 VLM 错 (揽收/签收 basis 错 / 漏 `target_sites` / delta-vs-全价混淆) 在 P1 可编辑表直接可改 (`is_delivery_fee` 行内 toggle 实时 re-home, `target_sites_str` 文本, `base_amount`/`other_amount` 数字). 但 **delta-vs-全价混淆只在操作员读图时可 *catch*** — `other_amount=1.5,base_amount=0` (对 delta) 与 `base_amount=1.5,other_amount=0` (错, 当全价) 都渲成 plausible 非报错行. 编辑器让人**修**, 不**自动 flag**. **诚实说**: 设计让它 fixable-on-inspection, 非 auto-detected; 别宣称 "handles" delta 混淆. **F-CONCURRENCY (并发安全靠 tx)**: 无 409, 并发安全全靠单 tx + Postgres row lock. 两并发 confirm 同 session 会在 DELETE 上 race (Postgres 串行化, 但 user 得两 200 无信号, 且 -race/负载下第二 tx 的 delete 可能落在第一的 delete-insert 之间, 产 transient 空-overlay 窗口被并发 read 看见). 测试计划含 concurrent-confirm-same-session on **真 Postgres 路径** (InMemory 用不同 mutex, 证不了 Postgres tx). 可选 `SELECT … FOR UPDATE` on session 行或 advisory lock 求确定性. ### 4.3 trap (吸收/废弃时易踩, 显式 flag) **`reflect_tool.go` stale-comment trap**: 其 godoc 自称 "bill-recon-specific wrapper", 但 `engine_factory.go:113/285` 把 `llm.ReflectQuoteTool` import 进 **live billcost dispatch loop** (response gate). 凭文件注释废 bill-recon 会删它从而破 billcost 响应闸. **注释 stale, 符号 load-bearing** (`feedback_exported_field_delete_needs_review` 类). 废弃时按符号实际 consumer 判, 不凭注释. --- ## 5. 验证 / Validation ### 5.1 store -race (`quotedispatchstore/inmemory_test.go`) 无 Postgres harness 在本包 (postgres.go 仅真 DB 跑; Postgres 测试 gate docker 可用, 照 `internal/billrecon/store/postgres_test.go`): - save → list round-trip: 字段存活 (含 `is_delivery_fee` / `target_sites` / "全国" verbatim / dates / `other_amount` delta / audit `raw_extraction`+`vision_extracted_at`). - 幂等替换: save A, save B ⇒ list 只返 B. - 零-bundle save 清前 overlay (DELETE-then-return-0, "re-open 全 reject" 案). - `ErrSessionNotFound` on unknown session 两方法. - 并发 `SaveAdjustments` + `ListAdjustments` under `-race` (InMemory deep-copy 的理由). ### 5.2 confirm 端点 (`httptest` + `InMemoryStore`) - 混合 `confirm`/`reject` 数组 ⇒ 只 confirmed bundle 落库 (经 `ListAdjustments` 验). - **整套 round-trip 不变量** (whole-replace 契约, §2.6): persist 整套 A+B+C → re-POST 修改后的整套 A'+B+C → list 返 A'+B+C; **不**写 "POST 仅 B 而 A+C 存活" (whole-replace 下自相矛盾). - re-POST 不同整套 ⇒ replace (re-open+edit), 各 200 (无 409). - unknown session ⇒ 404; nil cfg/store ⇒ 503; bad JSON ⇒ 400. - `IsTargetSpecific` 服务端权威: POST `target_sites=[]` 但 payload `is_target_specific=true` ⇒ 落库 false (§2.7). - `GET /dispatch/{id}` (或 `/analysis`) confirm 后返 overlay. - **并发 confirm 同 session on 真 Postgres 路径** (docker-gated): 两并发 `SaveAdjustments` 留恰好一份一致 overlay, 无 torn read (§4 F-CONCURRENCY). ### 5.3 P3 统一视图 coverage (§2.8 验收线) - 全国 overlay + 31-province base ⇒ 视图层展开全国后 `matched` (非 `orphan`); 不展开则测捕到 silent-orphan 即视为 bug. - 偏远省 overlay 无 base 行 ⇒ `orphan` warning 渲出. - overlay period 与 base 有效窗不交 ⇒ period-disjoint flag. - `target_sites` branch-scoped ⇒ `unverifiable` flag (非按省假 match). - base 派费行 (源自 `phase_1_result`) 不被误分类为 overlay (§4 F-LAYER, layer 靠 source table). ### 5.4 JSX 防白屏 + handler 编译 - 新组件经 `node /tmp/jsxcheck.js` (从 `deploy/billcost-test/` 跑, 查 inline block + `main-screen.jsx` transpile). 编辑 inline block 与 `main-screen.jsx` 后都跑. - `go build ./...` + `go test -race -count=1 -timeout 300s ./...` (在 `platform/common`): base64 import 编译 + handler emit map 变更 + store 方法. --- ## 6. 触发重新评估的条件 / Trigger conditions - **billcost→WMS 写/同步路径建成** (现 read-only ADS replica 契约上不可写, 无写 client) → overlay 从 terminal-sink 变可 push 生产定价, 重估 F-WMS/F-WMS2/F-WMS3; 触发器指 ADR-0004 §7. - **真 carrier-bill-vs-quote 对账被需要** → net-new C7/C8 引擎, billcost 现自有的 `cost_type=1` overlay 表是地基, 但比对逻辑需另建 (F-RECON). - **图 corpus 越阈值** (任一图 >2MB raw, 或 >15 图) → P1 base64-inline 切 `xlsx_blob` re-extract serve 端点 (已核实可行). - **PM 要 "刷新不丢手填" / "同 xlsx 同 overlay"** → 落地 autosave 草稿 (F-DRAFT, `approved` 列 / per-image upsert) / `quote_dispatch_sessions` 加 sha256 dedup (F-IDEMPOTENCY). - **操作员需把 overlay pin 到 WMS base 行** → 把 `ListStandardCosts` 选择接进 P1 审阅 UI, 填 `standard_cost_sys_no` 恢复 WMS-linkability (F-WMS3). --- ## 7. 工程量 / Engineering footprint ### 7.0 吸收图 (neutral lib 活 / bill-recon-app-specific 死, 包粒度) Go 整包编译, 不能留 `adjustment.go` 删同包 `excel.go`. 拆: | 必须活 (billcost import closure, 改中性路径名是后续 cleanup 非本设计) | 废弃时删 (零 billcost import) | |---|---| | `parser` 包 (MIXED): `adjustment.go` (Master/Detail/Bundle/CostType/CommonStatus) + `ChinaProvinces`/`IsNationwide`/`InferRegions`/`CHAR10`/`Decimal`. 同包 dead weight (rename 时 prune): `excel.go`/`headers.go`/`rules.go`/`quote_to_bundle.go`/`images.go` | `internal/billrecon/web` (1157 行, bill HTTP server) | | `llm` 包 (MIXED): `vision.go` (`VisionClient`) + `reflect_tool.go`+`validator_adapter.go` (`ReflectQuoteTool`, billcost dispatch loop 用). 同包 bill-specific: `columns.go`/`quote.go` | `internal/billrecon/workflow` (1362 行 state machine) | | | `internal/billrecon/wms` (read-only WMS 查询层, 仅 bill web/cmd 用) | | | `internal/billrecon/store` (P2 吸收 `SaveShipCostCfg` 算法后) | | | `dump`/`types.go`/`doc.go` + `cmd/bill-recon`+`cmd/bill-recon-web` | billcost 今天只 import 两 billrecon 子包: `parser` (`quotedispatch_handler.go:86` + `quotedispatch_vision.go:41`) + `llm` (`quotedispatch_vision.go:40` + `engine_factory.go:13/113/285`). P2 要**吸收**的 (今天不 import) 是 store 写路径 — 不能 import `billrecon/store` 保留 (硬连 `bills(id)` FK + 将死 schema). ### 7.1 P1 — 图预览 + 可编辑解析结果 (无落库无 merge) scope: PREVIEW + EDITABLE. 编辑存 React state, 结构上让 P2 一次 POST. wire 变更 additive: 一个 live 事件加两字段. **图传输 = base64-inline (P1 默认)**: 当前 corpus (6 图 / 最大 906KB → base64 ~1.2MB/行 / 总 ~2.9MB) 可接受且零 timing 依赖 (字节随事件原子到达, UI 已消费该事件). 阈值逃生 (>2MB/图 或 >15 图 → `xlsx_blob` re-extract serve 端点, 已核实 `xlsx_blob` 在 create 时落 `quotedispatch_handler.go:679-683` 故可行) 写进 handler godoc. **file-level**: - `platform/common/internal/server/quotedispatch_handler.go`: `import "encoding/base64"`; `extractEmbeddedImageNotices` (~1470) emit map 加 `"media_type": img.MediaType` + `"image_b64": base64.StdEncoding.EncodeToString(img.Data)`, 字节**无条件** (含 `r.Error != ""`, 失败时预览最有用); **不**碰 final `embedded_images` 数组 (~1384, 字节不进 durable summary); **加 `VisionAt = time.Now()` 在成功抽取后 emit 前** (§2.7); godoc 记 inline-vs-endpoint 阈值. - `deploy/billcost-test/index.html` (inline babel block): `makeInitialScenario()` 加 `embeddedImages: []` (防御); `App()` 加 `const [adjEdits, setAdjEdits] = useState({})`; `embedded_image` ingest 分支 (~646) 留一行 log 面包屑, seed `setAdjEdits(prev => prev[obj.name] ? prev : {...prev, [obj.name]: })` (按 name keying, 只 absent 才 seed 防 re-deliver clobber, 失败时 `details:[]`/`master:{}`), 停把 bundle 走 `fmtBundle` 进 `agentMessages.detail` 作主渲染; App return (~1016) 渲 `{Object.keys(adjEdits).length > 0 && }` (gate on `adjEdits` 非 run-active, 穿过 `final` 事件). - `deploy/billcost-test/main-screen.jsx`: 新 `EmbeddedAdjustmentReview({edits,onChange})` (controlled, 映射图→`AdjustmentCard`) + `AdjustmentCard({rec,onChange})`: 预览缩略图 (``) **beside** 可编辑表; master header (时间期限 + summary, `formatYMD` 归一); details 按 `is_delivery_fee` 分 揽收(b)/目的地·派费(c) 两 section (**overlap 规则**: partition on `is_delivery_fee`, `target_sites` 两 section 都可编辑只在 c 强调, 行翻 basis 实时 re-home); 完整度 amber badge (§2.9); 逐行字段镜像 `app.js:560-581` (grams 单位 label "(g)") + 删行 + `+加一行明细`/section (seed 预置该 section 的 `is_delivery_fee`); 逐图 approve/reject radios; 空-details 卡 master 仍可见可编辑 (§2.9); 行 key 用稳定 `_rid` 非 index. **验证**: `node /tmp/jsxcheck.js`; `go build ./...` / `go test -race`. ### 7.2 P2 — 落库 + confirm 端点 (依赖 P1 的编辑 state shape, 在 P1 后) **关键 reframe**: bill-recon confirm 是 engine-mediated (查 live `s.engines[sid]`, 404 if absent, `eng.ConfirmAdjustments` 转发, engine 写 store). billcost **confirm 时无 engine** (dispatch 已 done, 内嵌图在 bg goroutine 抽完发在 final NDJSON 行). 故 billcost 端点 **store-direct**: `store.Get` (存在性) → `store.SaveAdjustments` 直写. 镜像 bill-recon 的 request body + UX, 非其转发机制. **file-level**: - `platform/common/internal/db/pool.go`: 追加 2 表 + index 进 `platformMigrations` (master 在 detail 前, `quote_dispatch_rounds` 块后), DDL = §2.3. - `platform/common/quotedispatchstore/store.go`: `SessionStore` 加 2 方法 (`SaveAdjustments(ctx, sessionID, []parser.AdjustmentBundle) (int, error)` + `ListAdjustments(ctx, sessionID) ([]parser.AdjustmentBundle, error)`) + 修 godoc ("only needs these seven methods" 注释 stale, 这俩有真非测 consumer); import `parser`. - `platform/common/quotedispatchstore/postgres.go`: `SaveAdjustments` (真 tx: `Begin`→`DELETE WHERE session_id=$1`→per-bundle `INSERT … RETURNING id`→per-detail `INSERT`→`Commit`, `defer tx.Rollback`; key=sessionID; `len(bundles)==0` 仍 DELETE 后 return 0; **去掉 fan-out 块**, 每 detail 一行字面写; 保 date-NULL/`vw_rate`-NULL/`target_sites` JSON-marshal/bool→int16; audit `rawAny`/`visionAtAny` NULL 逻辑; **服务端 `IsTargetSpecific = len(TargetSites)>0`** §2.7; pre-check session 存在 → `ErrSessionNotFound`) + `ListAdjustments` (`SELECT` master by session_id ORDER id → per-master `SELECT` detail by master_id ORDER id → 解码回 bundle: int16→bool/JSONB→[]string/NULL date→zero) + 本地 `nullIfEmpty` (5 行 copy, 不 import bill-recon store). - `platform/common/quotedispatchstore/inmemory.go`: `adjustments map[string][]parser.AdjustmentBundle` (existing `sync.RWMutex` 守); `SaveAdjustments` (Lock, session 存在检查, **deep-copy** bundle 防御, 替换 slice, return count); `ListAdjustments` (RLock, 返 copy/空 slice); `NewInMemoryStore` init map. - `platform/common/internal/server/server.go`: `mux.HandleFunc("POST /api/v1/billcost/dispatch/{id}/adjustments/confirm", s.handleQuoteDispatchAdjustmentsConfirm)` (~L489 邻其他 billcost 路由). - `platform/common/internal/server/quotedispatch_handler.go`: `handleQuoteDispatchAdjustmentsConfirm` (cfg/store nil→503; sid 空→400; decode→400; `store.Get`→404 if `ErrSessionNotFound`; reject-drop `strings.EqualFold(p.Status,"reject")`; `p.toBundle()` 设 `CostType=parser.CostTypeAdjusted`/`CommonStatus=parser.CommonStatusActived` + 本地 `parseYMD` (4 行 copy, 不 import `billrecon/web`); `SaveAdjustments`; 200 `{"ok":true,"saved":n}`; **无 409**); 重声明 `adjustmentConfirmPayload`/`*MasterPayload`/`*DetailPayload` 于 billcost namespace (不 import `billrecon/web`); `handleQuoteDispatchGet` (~2271) 加 `"adjustments": adjustments` (`ListAdjustments` 的非测 consumer, 满足 dead-field scanner; base 仍在 `phase_1_result`, overlay 分离). - `inmemory_test.go` + handler test (§5.1/§5.2). **request body** (镜像 bill-recon `{adjustments:[{status,master,details}]}`, 加 audit passthrough §2.5): `{adjustments:[{status:"confirm"|"reject", image_name, master:{...,vision_extracted_at,raw_extraction,summary}, details:[{...,is_delivery_fee,is_target_specific,target_sites,is_strip_fee,other_amount,start_date,end_date}]}]}`. dates `YYYY-MM-DD`, `""`→NULL. **carry audit 回** (bill-recon confirm 不 round-trip `raw_extraction`/`vision_extracted_at` 致 NULL = audit gap; billcost 作继任者 + verify-against-truth 文化, payload 带回, UI 已从 SSE 持有). ### 7.3 P3 — 统一分析视图 (依赖 P2 的 `ListAdjustments` 读回, 在 P2 后) 新端点 `GET /api/v1/billcost/dispatch/{id}/analysis` (邻 `handleQuoteDispatchGet`; 不往 frozen `GET /dispatch/{id}` 塞聚合, 后者承诺 "返 session 行当前状态"). **核心 plumbing**: overlay 今天只在 final NDJSON 行 + 内存, GET 看不到; 修读侧 (P2 落库 + 按 session `ListAdjustments` 读回) 是 P3 reload 后稳定呈现的前提. **信封** (base + overlay labeled layers, **不**合并): ```jsonc { "session_id":"…", "status":"done", "base": { /* phase_1_result 原样: {master, details[], return_fee} */ }, "overlay": { "layer":"temporary_surcharge", "bundles":[ /* []parser.AdjustmentBundle */ ], "source":"persisted", "image_count":2, "errors":[{"name":"image3.png","error":"…"}] } } ``` `source` 总 `persisted` (无落库 draft, §2.6/F-DRAFT; GET 返 persisted 可能空; `draft` 只活 live 未刷新 client). overlay **绝不**插进 `base.details[]`. **UI**: 上 base 价格表 (复用现 `final_json` 表渲染) / 下 临时涨价 overlay 面板 (醒目色带 + "临时加价·叠加在基础报价之上"). overlay 面板扩展 (非替换) `fmtBundle` (index.html:202-226): 顶部 group-by 切换 (默认时间期限, 可切 揽收/签收 / 目的地网点; 同行不同 group 重归位因是 facet 非分区, §2.1); 每行三 facet 全显 (period band + basis label + scope); **`↳ 影响基础行`** 旁注 overlay 影响哪条 base 行 (省/市+段匹配, 标 period+basis+scope+delta) + **三态 coverage verdict** (`matched`/`orphan`/`unverifiable`, §2.8, **match 前视图层展开全国** §2.4). 硬边界: **不**渲 `base_amount+other_amount=final` (那是 flatten); 可选 what-if 必须标 "示意·单例·非系统计算价" 永不权威永不落库. **可编辑回写 = 委托 P2 confirm 端点** (P3 是其第二 client, POST 整套, §2.6/F-PARTIAL-POST). P3 提供 approve/persist gate + bundle 级 include/reject toggle; **不**自有 cell 编辑面. **P1↔P3 关系**: P1 = 逐图单元格纠错 (对照图本身=ground truth, cell 编辑唯一入口); P3 = 跨图合并分析 + approve/落库闸 (操作同一 draft bundle 集非副本). 一份 draft, 一个 confirm 端点 (`adjEdits` 共享, 强制不变量 §2.6). **file-level**: `server.go` 注册 `/analysis` 路由; `quotedispatch_handler.go` `handleQuoteDispatchAnalysis` (先 `ListAdjustments` → 有则 source=persisted; 组装信封); `deploy/billcost-test/index.html` base 表 + 扩展 `fmtBundle` 为 faceted 面板 + coverage verdict 渲染 (复用 `parser.ChinaProvinces` 概念在前端展开全国, 或后端 analysis handler 预展开传 coverage). JSX 经 `jsxcheck`. ### 7.4 收尾 - `CHANGELOG.md` `Unreleased`: P1/P2/P3 条目. - `core/TODO.md`: 打勾已完成 + follow-up (overlay→base unified-view compute / 图字节 re-open serve / autosave 草稿 / sha256 dedup / WMS push / `standard_cost_sys_no` pin / `parser`/`llm` 中性路径 rename). **排序/依赖**: P1 (preview+edit, wire+UI, 无落库) → P2 (落库+confirm 端点, 依赖 P1 编辑 state shape) → P3 (统一视图, 依赖 P2 `ListAdjustments` 读回). 各阶段独立可验收 (P1 编辑存内存即过 / P2 落库+读回 round-trip / P3 视图+coverage). --- ## 8. 修订记录 / Revision history ### v1 (2026-06-02): 初版 - 内嵌图 overlay 端到端 (preview+edit / persist / unified-view), billcost 自有 flyto 表作 bill-recon 继任者 捕获自 PM 5 条 confirmed decision (overlay-not-flatten / billcost 自足 / 自有 flyto 表 / 吸收非依赖 / 3 维度 facet / 镜像 bill-recon UX) + 4 个草案设计 (data-model / P1 / P2 / P3) + 5 路 adversarial 取证. 确立: 单一 reconcile schema (双关系表 keyed session_id, 无 `approved`/`file_site`/`dest_site_no`, per-image 键 `image_name`, provenance `vision_extracted_at`) / 落库不 fan-out 全国 (视图层展开) / drafts ephemeral (= bill-recon parity, autosave 是 follow-up) / whole-replace 幂等 + confirm 整套契约 / 服务端权威 `IsTargetSpecific` + 抽取时写 `VisionAt` / coverage verdict 三态 + 全国视图层展开 / 多页完整度 badge + edit-one-reject-sibling. 实现 P1/P2/P3 属 roadmap, Range A (抽取作独立平行只读输出) 已建. **reconcile 的 3 个草案矛盾 (实现前以本 ADR §2.3 为唯一权威)**: (1) 全国 fan-out — 落库 NO (P2 对, data-model 草案的 "store 层 fan-out" strike), 视图层展开; (2) 列定名 — `image_name`/`vimage_name`/`file_site` 三名归一 `image_name` (load-bearing, `NoticeGroupID` 恒空), `vision_at`/`vision_extracted_at` 归一 `vision_extracted_at`, drop `file_site`+`dest_site_no`; (3) 持久化模型 — `approved` 列 + autosave-draft (data-model 草案) vs in-table=persisted/absent=draft (P2+P3) → 后者 (核实 bill-recon 草稿 ephemeral, parity), drop `approved` 列. **诚实记录的 gap/limitation (§4, 不埋)**: WMS terminal-sink (overlay 无自动 consumer, ADS replica read-only, base 也不在 WMS, `standard_cost_sys_no` 恒 0 dead FK; scrub "WMS computes at calc time"→"future calc 层未建") / reconciliation 不覆盖 (废 bill-recon 丢圆通 ingest + 对账 aspiration, 业务语言 flag) / drafts 刷新丢失 (= parity) / per-session-confirm-only 幂等 (无 cross-upload sha dedup) / delta-vs-全价 fixable-on-inspection 非 auto-detect / 并发靠 tx. flag: 180s+EOF-retry 已在 billcost (parity-plus, 别 re-absorb/回退 90s) + `reflect_tool.go` stale-comment trap (load-bearing 别凭注释删). ### v2 (2026-06-02): PM 两个 follow-up 决策落入 scope ADR 草稿过目后 PM 拍两件, 已并入正文: 1. **整个 bill-recon app 抛弃** (非仅内嵌图能力), **接受**丢失圆通 invoice ingest + 对账 score-intake (对账引擎本就没建完, F-RECON). 之前 F-RECON 把"删能力 vs 删 app"作开放问题, 现 close 为"删 app". 2. **承运商身份三元组必须移植** (新 §2.10, 从 F-WMS3 的 deferred 升为 in-scope): bill-recon 上传表单 "承运商成本配置 (上传前选定, vlm 不再抽)" 捕获的 `whs_id`/`ship_type_id`/`ship_type_msn`/`site_no` (三元组 = 承运商/月结/网点 + 仓库上下文), VLM 抽不到又是报价归属所必需, 必须搬进 billcost dispatch UI + stamp 到 overlay/base master. 四列已在 §2.3 DDL, 仅缺前端捕获+串接+stamp. `standard_cost_sys_no` 的 WMS-base-pin (linkability) 仍 deferred, 与 identity 别混.