Files
jiu/docs/runbooks/fix-history-prices-20260710.html
T
wangjia 658df690f6
Design Source Checks / design-source (push) Successful in 47s
docs: 历史导入出库价格错列修复复盘 runbook(2026-07-10)
根因/修复设计/执行结果/遗留脏数据清单/回滚方式,登记 docs/index.html

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:29:19 +08:00

131 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>历史导入出库价格错列修复复盘(2026-07-10) — 酒库管理系统</title>
<style>
:root{
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
--success:#2E8B57; --success-bg:#E6F3EC; --warn:#B45309; --warn-bg:#FFF4E5; --accent:#8B2331;
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
}
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.65;}
h1{font-size:22px;margin:0 0 4px;}
h2{font-size:17px;margin:30px 0 12px;color:var(--primary-dark);border-bottom:2px solid var(--head);padding-bottom:6px;}
h3{font-size:14px;margin:18px 0 8px;color:var(--accent);}
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;margin:14px 0;}
.lead{font-size:13.5px;color:var(--ink);}
table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0;}
th{background:var(--head);color:var(--primary-dark);font-weight:600;font-size:12px;text-align:left;padding:9px 10px;border-bottom:1px solid var(--border);}
td{padding:8px 10px;border-bottom:1px solid #EEF1F5;vertical-align:top;}
.mono{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;}
code{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
pre{background:#1E2430;color:#D8E0EC;border-radius:8px;padding:12px 14px;overflow:auto;font-size:12px;line-height:1.55;}
pre .c{color:#7A8699;}
.tag{display:inline-block;font-size:11px;padding:1px 8px;border-radius:10px;font-weight:600;}
.tag.risk{background:var(--danger-bg);color:var(--danger);}
.tag.ok{background:var(--success-bg);color:var(--success);}
.tag.warn{background:var(--warn-bg);color:var(--warn);}
.callout{border-left:4px solid var(--primary);background:#F0F6FF;padding:10px 14px;border-radius:4px;margin:12px 0;font-size:13px;}
.callout.danger{border-color:var(--danger);background:var(--danger-bg);}
.callout.warn{border-color:var(--warn);background:var(--warn-bg);}
.callout.ok{border-color:var(--success);background:var(--success-bg);}
.callout b{color:var(--ink);}
ul,ol{margin:6px 0;padding-left:22px;}
li{margin:3px 0;font-size:13px;}
</style>
</head>
<body>
<h1>历史导入出库价格错列修复复盘</h1>
<div class="sub">门店:鼎晟酒行(shop_id=8, S000008)· 生产库 ali/jiu_mysql · 2026-07-10 执行 · 修复工具 <code>backend/cmd/fix-history-prices</code> · commit <code>e19c498</code></div>
<div class="callout ok"><b>一句话结论:</b>2026-06-20 历史进销存导入(<code>cmd/import-history</code>)把旧系统<b>出库明细的「单价/金额」(售价口径)误写入成本列</b> <code>cost_price/cost_amount</code>,售价列留 0,导致全部历史出库单显示「待定价」、成本列实为售价。本次用一次性工具修复 <b>26,335 行明细 / 4,927 张出库单</b>:售价归位、成本按序列号回查真实进价回填、利润重算;应收合计不动,财务零影响。</div>
<h2>一、症状与发现路径</h2>
<div class="card lead">
<ul>
<li>用户报障:出库单详情里售价/总售价/利润整列「待定价」(首例即历史导入单,成本价 2,130 实为售价)。</li>
<li>关键实证(用户比对截图):出库单 <b>CK202606150001</b> 明细「成本价」14,300,而同一序列号 <b>ZXZ027628 茅台铁盖1995五星</b> 在库存/入库侧的真实进价是 <b>13,600</b>——出库"成本"14,300 就是卖给客户的售价。</li>
<li>旁证:该单 9 瓶 × 14,300 = 128,700 恰好等于单头「合计金额」(应收 <code>sale_total</code>)——单头是售价口径、明细却落在成本列,自相矛盾。</li>
</ul>
</div>
<h2>二、根因</h2>
<div class="card lead">
<p><code>backend/cmd/import-history/main.go</code> 的出库明细映射(修复前):</p>
<pre>CostPrice: parseFloatLoose(get(dr, dCols, "单价")), <span class="c">// ✗ 源出库「单价」是售价口径</span>
CostAmount: parseFloatLoose(get(dr, dCols, "金额")), <span class="c">// ✗ 同上;sale_price / sale_amount 留 0</span></pre>
<ul>
<li>旧系统出库单的「单价/金额」= 卖给客户的价(应收侧);入库单的「单价/金额」= 进价(应付侧)。<b>入库映射本来就对,只有出库错列。</b></li>
<li>单头处理却是对的:源「单据金额」→ <code>sale_total</code>(应收合计),因此财务 KPI(直接 SUM 单头列)一直正确,问题只在明细两列 + 利润。</li>
<li>显示层连锁:明细 <code>sale_price=0</code> → 前端按「待定价」渲染(<code>order_detail_drawer.dart</code> 口径 <code>sale_price&lt;=0</code>);成本列展示的是售价,管理员看到的"成本/利润"失真。</li>
</ul>
<p>映射已随修复同步改对(出库明细「单价/金额」→ <code>sale_price/sale_amount</code>,成本留 0 待回查),以后再导不会复发。</p>
</div>
<h2>三、修复设计(cmd/fix-history-prices</h2>
<div class="card lead">
<table>
<tr><th>要点</th><th>说明</th></tr>
<tr><td>修复范围</td><td>仅历史导入明细:<code>stock_out_items.product_id = 占位商品 HIST-PLACEHOLDERshop8 id=4809</code><code>sale_price=0 AND cost_price&gt;0</code><b>注意占位商品已被界面软删</b>(2026-06-20),工具定位时不过滤 <code>deleted_at</code></td></tr>
<tr><td>售价归位</td><td><code>sale_price ← 原 cost_price</code><code>sale_amount ← 原 cost_amount</code>(忠实保留源售价,不做任何归一)。</td></tr>
<tr><td>成本回填</td><td>按商品编号(序列号,一物一码)逐级回查:① <code>stock_in_items</code> 同店同编号入库价 → ② <code>inventories.unit_price</code> 库存快照价(快照列或经 product 关联)→ ③ <code>products.purchase_price</code>;均查不到 → 0(成本待定)。与库存列表展示链 <code>COALESCE(sii.cost_price, inv.unit_price, p.purchase_price)</code> 同序。</td></tr>
<tr><td>利润重算</td><td>涉及单据 <code>profit_total</code><code>service.recalcStockOutProfit</code> 同口径:<code>Σ(sale_price&gt;0 ? (sale-cost)×qty : 0)</code>。成本待定行暂按全售价计(与系统既有"成本待定"语义一致)。</td></tr>
<tr><td>应收不动</td><td><code>sale_total</code> 保持源单头合计——财务应收/KPI 直接 SUM 单头列,<b>修复对财务零影响、无需冲账</b>。修复后校验 Σ明细售价小计 vs 单头合计,不一致只报不改。</td></tr>
<tr><td>安全机制</td><td>默认 dry-run(事务内试算后回滚);<code>--apply</code> 才落库;幂等(已修行 <code>sale_price&gt;0</code> 自动跳过,可重跑)。SQLite 内存库单测覆盖四种成本来源 / dry-run 回滚 / 幂等 / 非占位明细不波及 / 占位商品软删仍可定位。</td></tr>
</table>
<pre><span class="c"># 用法(本机交叉编译 → scp 到 ali 宿主执行;jiu_mysql 容器映射 127.0.0.1:3306</span>
cd backend && GOOS=linux GOARCH=amd64 go build -o /tmp/fix-history-prices ./cmd/fix-history-prices
scp /tmp/fix-history-prices ali:/tmp/
ssh ali '/tmp/fix-history-prices --shop-id 8 --env-file /opt/jiu/config/production.env' <span class="c"># dry-run</span>
ssh ali '/tmp/fix-history-prices --shop-id 8 --env-file /opt/jiu/config/production.env --apply' <span class="c"># 落库</span></pre>
</div>
<h2>四、执行记录与结果(2026-07-10)</h2>
<div class="card lead">
<table>
<tr><th>步骤</th><th>结果</th></tr>
<tr><td>1. 全库备份(改库前)</td><td><span class="tag ok">完成</span> 本机 <code>~/jiu-db-backups/jiu_db_prefix_fix_20260710.sql.gz</code>2.8MBgzip -t 校验通过)</td></tr>
<tr><td>2. dry-run</td><td>待修 26,335 行 / 4,927 单;成本回填:入库价 25,139 / 库存价 32 / 商品进价 0 / 查无 1,164;合计不一致单 2 张</td></tr>
<tr><td>3. --apply 落库</td><td><span class="tag ok">完成</span> 数字与 dry-run 完全一致</td></tr>
<tr><td>4. 核验</td><td><span class="tag ok">全绿</span> 待修行剩 0;抽查 ZXZ027628:成本 13,600 / 售价 14,300 ✓;CK202606150001:应收 128,700 / 利润 22,900 ✓</td></tr>
</table>
<p>成本回填命中率 95.5%25,139/26,335 走入库明细价,最精确来源);1,164 行查无进价 = 该序列号在导出的历史窗口内无入库记录(更早的老库存),成本置 0 待定。</p>
</div>
<h2>五、遗留脏数据清单(源数据问题,忠实保留,待业务决策)</h2>
<div class="card lead">
<table>
<tr><th>#</th><th>问题</th><th>影响</th><th>建议处置</th></tr>
<tr><td>1</td><td><span class="tag risk">数量脏</span> <b>CK202403050009</b> 行 ZXZ010370 茅台白皮2003 <b>数量=50,902</b>(该序列号入库仅 1 瓶),明细金额 2.62 亿</td><td>该单 <code>profit_total</code> 虚高至 3,312 万;单头应收 542,950 正常</td><td>业务核实真实数量(大概率 1)后单行修正并重算该单利润。同族问题见「装箱数当数量」脏数据(库存侧 784 行,另案待决策)</td></tr>
<tr><td>2</td><td><span class="tag warn">合计差</span> CK202401170005 单头 95,400 vs 明细合计 95,500(差 100</td><td>轻微,应收按单头</td><td>可不处理;或对账时人工核</td></tr>
<tr><td>3</td><td><span class="tag warn">零额单</span> 出库 47 张 / 入库 13 张 合计 ≤ 0</td><td>可能是赠送/内部调拨/源缺失</td><td>保留原貌</td></tr>
<tr><td>4</td><td><span class="tag warn">成本待定</span> 出库明细 1,164 行查无进价(cost=0);入库明细 59 行进价=0</td><td>所在单利润暂按全售价计,偏高</td><td>后续补录进价可自动纠正利润(确认进价/再跑回填)</td></tr>
</table>
</div>
<h2>六、回滚方式</h2>
<div class="card lead">
<div class="callout danger"><b>回滚会连带覆盖备份时刻之后的所有新业务数据</b>,仅在修复本身被证实有误时使用;局部纠错优先用幂等工具或单行 SQL。</div>
<pre><span class="c"># 全库恢复(备份 = 修复前全库快照)</span>
gunzip -c ~/jiu-db-backups/jiu_db_prefix_fix_20260710.sql.gz | ssh ali 'PW=...; docker exec -i -e MYSQL_PWD="$PW" jiu_mysql mysql -uroot jiu_db'</pre>
<p>密码取法同 <code>scripts/ci/backup-db.sh</code>:从 <code>/opt/jiu/config/production.env</code><code>DATABASE_DSN</code> 解析。</p>
</div>
<h2>七、关联索引</h2>
<div class="card lead">
<ul>
<li>修复工具与测试:<code>backend/cmd/fix-history-prices/</code>main.go + main_test.go</li>
<li>导入映射修正:<code>backend/cmd/import-history/main.go</code>(出库明细改落 sale 列)</li>
<li>提交:<code>e19c498</code> fix(backend): 历史导入出库明细价格错列——售价误落成本列,新增修复工具</li>
<li>历次同族修复:库存商品编号重复(2026-06-21,<code>cmd/fix-inventory-products</code>,按名合并 → 按编号重指);导入实跑记录见项目记忆 <code>project_history_import</code></li>
<li>定价字段口径(cost/sale 分列、待定价、利润公式):见项目 <code>CLAUDE.md</code>「定价字段口径」节与开发手册</li>
</ul>
</div>
</body>
</html>