Repository navigation
Releases
v1.6.0 — 装箱出单前预算:PI/CI/PL 同源,不再依赖「PL 必须先跑」
把装箱计算改成「出单前先算一次」,PI / CI / PL 三份单据从同一份数字取数。
先说人话
以前是「谁先生成谁算数」:CI 上的重量是读 PL 写下来的,所以 PL 必须先跑。这是个没写在任何地方、但一旦被打破就出错的前提。
打破的后果:CI 会退回用一个经验系数估算(净重 × 1.036),而 PL 用的是真算出来的数——两份单据的重量就对不上了,而且没有任何提示。
现在改成:出单前先把装箱算一遍、存好,三份单据都读这一份。 谁先谁后都一样。
实测效果
| 场景 | 结果 |
|---|---|
| 正常出单 | CI 毛重 = 真实算出的 598.00 kg(不再是估算的 590.52)✅ |
| 装箱配置写错 | 拦下,不出任何正式单据,并说明原因 |
| 重量信息没填 | CI 照常出,但明确提示「这个毛重是估算的」 |
⚠️ 一处行为变化(升级前请读)
重量齐全、但装箱参数非法(比如每托盘箱数设成 0)时:
- 以前:PL 报个警告,CI 照样生成(毛重走估算),单据照样发出去
- 现在:记错误,拦住所有正式单据(PI / PL / CI 都不出)
理由:重量齐全却算不出来,一定是数据异常或代码 bug。出一套数字可能错的单据,比不出更糟——毛重错了在清关和收汇上都是实际问题。报价单不受影响,报错会说明具体原因。
技术细节
- 装箱计算收敛成单一入口
compute_packing_summary()(缺重量检查 + 参数解析 + 计算)。PL writer 不再自己解析一遍参数——两处各自解析参数正是过去两单分叉的根因之一。 - 参数优先级明确为:人工补录(
review.pallet)> 配置(config.packing)> 模块默认值。 - 两条路径都接入:主流程与价格回写。少接一处,那条路径上就又会各自算一遍、回到分叉状态。
- 那条
×1.036兜底保留但透明化:不再默默使用,会带标记并由流程提示用户。
新增测试(11 个)
- 参数优先级(人工补录 > 配置 > 默认值)
- 预算与 PL writer 算出同一份数(同源的基础)
- 回写不覆盖
total_qty/ 港口等字段 - 顺序无关性:PL 完全不跑时,CI 仍能拿到真值
- fail-loud 拦住全部正式单据 / 重量不全时预算正确跳过
- 估算值被正确标记
同时给两条既有测试补了说明:它们直接调 writer、绕过流程,现在测的是 writer 层契约而非生产路径;生产路径由新测试覆盖。
验证
355 passed(原 344 + 新增 11),覆盖率 82.02% ≥ 门槛 80%,ruff 通过;CI 在 Python 3.11 / 3.12 / 3.13 三个版本上全绿。
详见 CHANGELOG.md。
v1.5.0 — 新增三条出单前门禁:买方 / 条款 / 收款银行不全就不让出单
新增三道「出单前的门禁」:买方名称、贸易条款、收款银行,任何一项缺失就不让出正式单据。
先说人话
一份 PI 或 CI 发出去,上面买方抬头是空的、或者少了收款账号——这不是排版问题,是客户真的汇不出钱、清关真的会卡。
以前这种问题不会报错,只会默默印出一份有问题的单据。现在改成出单前先检查这三样:
| 规则 | 检查什么 |
|---|---|
| R011 | 买方英文名称有没有 |
| R012 | 付款 / 交货条款有没有 |
| R013 | 收款银行名 / 账号 / SWIFT 全不全,SWIFT 格式对不对 |
任何一项不过 → 拦住不让出单,并告诉你缺什么、去哪个配置键补。
⚠️ 升级提示(请先读)
这三条是拦截级。如果你的配置里这三样有缺失,以前能出的单据现在会被拦下。
这是本次改动的目的,但升级后第一次运行可能遇到「以前能出、现在出不了」。按报错提示到配置中心补齐即可——每条规则都会指出缺什么、去哪补。
只出报价单不受影响:quote_only=True 不触发这三条门禁。报价阶段还没走到正式单据,不该被正式单据的门禁卡住,否则你连报价都出不了。
为什么做成「拦截」而不是「提醒」
这三样和别的字段不同:其他字段错了,后果是内部返工;这三样错了,是出到客户或海关手上才暴露。等客户来问「你们账号呢」,已经晚了。
好消息是它们都能在出单前确定性地检查出来,不需要人工判断——所以没有理由不做。
技术细节
- 三条规则都先看
model.resolved;未解析时直接放行,否则报价阶段会被卡住(先有鸡还是先有蛋) - 规则顺序插在 R003 之后、R004 之前,与「先验单据要素完整性、再验具体字段」的阅读顺序一致
- 规则总数 10 → 13
- SWIFT 格式校验复用已有的
config_service.validate_seller,不另造一套
新增测试(10 个)
- 规则层 5 个:含两个边界——「未走
resolve_entities的模型不应被误拦」与「SWIFT 格式错也要拦,不只是缺」 - CLI 层 5 个:验证不只是规则报错,而是确实拦住了落盘(PI / CI 不生成);含 quote-only 不被误伤的边界
验证
344 passed(原 334 + 新增 10),覆盖率 81.96% ≥ 门槛 80%,ruff 通过;CI 在 Python 3.11 / 3.12 / 3.13 三个版本上全绿。
详见 CHANGELOG.md。
v1.4.4 — 单据日期不再跟着运行机器时区走(UTC 服务器上会印错一天)
修掉一个会让单据印错日期的缺陷,并加固 CI。
先说人话
报价单、PI、CI、装箱单上的日期,之前是「看这台电脑现在几点」。听起来没毛病——问题是这台电脑可能不在你所在的时区。
生产环境常见的是 UTC 服务器(云主机、CI runner 基本都是 UTC)。而中国是 UTC+8、日本是 UTC+9,这意味着:当地凌晨 0 点到早上 8、9 点之间开出来的单据,日期会印成前一天。
这个错很难发现——只在特定时段出现,白天测试永远测不出来,等客户或海关看到才暴露。
现在改成:可以在配置里写明业务时区,日期就跟这台机器在哪、几点没有关系了。不填则保持原样,不改变已有用户的结果。
复现验证
把宿主机冻结在 UTC 2026-07-26 23:30:
| 单据日期 | |
|---|---|
| 修复前 | 26 July 2026 ❌ |
修复后(Asia/Shanghai) |
27 July 2026 ✅ |
四种时区实测得出不同日期,证明配置真的生效(America/New_York 正确得出 26 日)。
技术细节
- 修复(
understanding/assembler.py):order.date此前由裸datetime.now()生成,取的是运行机器时区。 - 新增配置项
defaults.business_timezone(IANA 时区名):- 配置了 → 日期与运行环境无关,始终是业务所在地的那一天
- 未配置 → 保持旧的本地时区行为,不静默改变已有用户的结果
- 非法值 → 响亮失败并指明配置项;静默回退会重新制造正好在修的 bug
- 初始化向导新增一问引导填写(默认
Asia/Shanghai);内置config/config.yaml同步写出该键并说明不配置的后果。 - 新增
tests/test_business_timezone.py(23 个用例):resolve_business_tz的三态单测,加上走真实assemble()路径的跨日历日断言、未配置时保持旧行为、date_format不受影响、配置模板与向导均写出该键。
测试把宿主机冻结成一台 UTC 机器,且必须在跨日历日的时刻(UTC 23:30)取值才有判别力——否则开发机本身在亚洲时区时,旧实现也会印出正确日期,测试便恒过。
CI 加固(同批)
- 覆盖率门槛 80%:此前 CI 只跑
--cov-report=term把覆盖率打印到日志却不强制,覆盖率可以一路下滑而构建依然是绿的。 - lint 改为检查全仓库:此前
scripts/与examples/下 4 个 Python 文件不被检查,其中scripts/check_version.py正是版本一致性检查自身。 - Actions 升级:
actions/checkoutv4 → v7、actions/setup-pythonv5 → v7。旧版 action 依赖的运行时会被逐步淘汰,届时会出现与代码改动无关的构建失败。 - 矩阵加
fail-fast: false:此前一个 Python 版本失败会直接取消另一个。 - 矩阵新增 Python 3.13:
pyproject.toml声明requires-python = ">=3.11",但此前只测到 3.12。
验证
334 passed(原 311 + 新增 23),覆盖率 81.79% ≥ 门槛 80%,ruff 通过;CI 在 Python 3.11 / 3.12 / 3.13 三个版本上全绿。
详见 CHANGELOG.md。
v1.4.3 — 截断/降级可见化 + 吨数量格式 + 欧洲数字(审计遗留清单清零)
第三批外部审计修复(C 系列)。至此 2026-08 审计记录的全部遗留问题清零。
- C1 LLM 4000 字符截断不再静默:WARNING +
_llm_truncated标记进 meta - C2 解析降级标记不再被 assembler 丢弃:parser_model/review_status/parser_notes 如实反映
- C3 CI 吨计价数量列改两位小数(与 PI 一致,不再 12,345.6→12,346)
- C4 欧洲数字格式支持("1.234,56"→1234.56),解析失败打 WARNING
311 测试全绿(296+15);ruff 全过。详见 CHANGELOG 与 docs/adversarial-review-2608-external.md。
v1.4.2 — buyer 匹配碰撞修复 / PL 单据自洽 / 版本一致性检查
第二批外部审计修复(B 系列,完整记录见 docs/adversarial-review-2608-external.md)。
Fixed
- buyer 匹配中间剥除碰撞(B1)(
understanding/buyer_matcher.py):_core_name此前从名字任意位置剥法律后缀 token,"Alpha Co Trading Ltd"中间的co被剥后核心名与另一家"Alpha Trading LLC"相等,唯一命中即接受——A 客户的单据抬头会写上 B 客户。现改为只剥开头连续段(俄语前缀惯例,如ООО)+ 结尾连续段(英语后缀链,如 Limited Liability Company),中间的法律词保留。行为变化:部分此前静默匹配通过的输入现在会硬阻断进 review.json 人工确认——错配风险交还给人,这是修复目的。 - PL 每箱重与净重舍入不自洽(B3)(
writers/pl_writer_lite.py、writers/ci_writer.py、validation/cross_doc.py、pipeline/main.py):此前行净重=真实净重、每箱重=round(净重/箱数),100kg 分 3 箱时 33.33×3=99.99≠100,报关算术核查看的正是这组关系。现改为行净重=每箱重×箱数(单据内KGS/CTN×CTS=NET、Σ行净重=合计恒成立;与磅秤实重偏差 ≤0.005×箱数 kg,低于称重精度);CI 页脚净重改为优先读 PL 回写的derived.total_net_weight(与毛重 T1 的同源模式对称),PL 未运行时回退独立累加;新增跨单净重校验check_ci_pl_net_weight(0.01 容差)接入两处生成流程。
Added
- 版本一致性检查(B2)(
scripts/check_version.py、.github/workflows/ci.yml):CI 新增独立 job,强制pyproject.toml↔.claude-plugin/plugin.json版本相等、且 CHANGELOG 有对应版本小节,违反即失败。防的是 v1.4.1 收尾时发现的真实事故——plugin.json 落后两个版本无人察觉。 - 20 个新测试(276 → 296):中间剥除碰撞负样本("Alpha Co/Holdings/Limited Trading")、核心名新语义固化、俄语前缀模糊回归、版本检查脚本(含对仓库自身)、PL 行自洽与整除不变、Excel 单元格级 D×C==F 断言(此前无任何 D 列断言)、CI 读 PL 回写净重与回退、端到端两单净重相等、跨单净重校验矩阵。
v1.4.1 — 外部审计修复:金额与重量口径统一
第三轮外部审计修复(问题编号 A1-A7,完整审计记录见 docs/adversarial-review-2608-external.md;与 2026-07 的两轮 T 系列审计不重叠)。本轮主题:金额与重量路径上的字段口径一致性、输出校验——这类路径错一个数,落到真实单据上就是直接的金钱或清关风险。
Fixed
- 吨计价下 PI 重量列单位混淆(A1)(
models/order_model.py、writers/pi_writer.py、writers/ci_writer.py、writers/pl_writer_lite.py):weight_kg缺失时,PI 会把kg_mpcs(每千件重量)原值写进 "Weight (kgs)" 列,而吨计价的金额公式=M/1000*N引用该列——等于用"每千件重"当"总重"算钱;同一行 CI 走compute_amount只认weight_kg得 0.0,PI 与 CI 尽管共享amounts.py单一来源仍会分叉。新增effective_weight_kg()统一派生(weight_kg → 单件重×数量 → kg_mpcs×数量/1000 → None,0.0 视为合法重量不被吞掉),PI / CI / PL lite 三处共用同一来源。 - CI 与 PL 净重口径分裂(A2)(
writers/ci_writer.py):CI 净重合计只认weight_kg,PL 却按三个来源派生——只有kg_mpcs的商品,PL 打印真实净重、CI 打N.W.: 0.00KGS,清关单证对不上。两单现在都走effective_weight_kg()。 - LLM 返回无 schema 校验(A3)(
understanding/llm_parser.py、understanding/canonicalizer.py、understanding/assembler.py):解析成功的 LLM 输出此前只查一层isinstance(dict)就写入缓存——items: null会让 canonicalizer 崩溃(.get的默认值对显式 null 不生效)、字符串数字数量会让 assembler 的sum()崩溃,坏结果还会被缓存持续污染重跑。新增_validate_rfq_schema()(结构校验 + 数值字符串规范化(含千分位逗号)+ 负数量拒绝),校验失败走既有的显式降级路径(打标记、不进缓存);下游补or []空值防护。 - 不可信隔离标签可被闭合逃逸(A4)(
understanding/llm_parser.py):询盘内容里出现字面量</untrusted_document_content>即可闭合 T4 建立的提示词隔离标签,让其后的伪指令逃出隔离区。_strip_untrusted_tags()现于包裹前剥离内容中的标签字面量。 price_unit/currency为显式 null 时 PI 崩溃、CI 渲染 "None"(A5)(understanding/assembler.py、writers/pi_writer.py、writers/ci_writer.py):.get()的默认值不覆盖显式 null(LLM 返回"price_unit": null即可触达),PI 的.upper()直接 AttributeError;CI 无币种兜底会打出None/ FOB PRICE、SAY: NONE … ONLY.。根因在 assembler 修复(or兜底),writer 层再加第二道防线,与 quote_writer 口径一致。- PL 空订单 / 除零无守卫(A7)(
writers/pl_writer_lite.py):items 为空会静默产出"1 个托盘、毛重=托盘自重"的空货装箱单;cartons_per_pallet=0除零崩溃。现在入口处双守卫,均以 ValueError 拒绝生成。
Changed
- 报价单 Amount 列改为活公式(A6)(
writers/quote_writer.py):非重量模式下表头写着 "Amount(币种)"、数据列却固定写weight_kg——金额从未被计算,列恒为空(有重量数据时甚至显示公斤数)。现在复用amounts.amount_formula()(=数量×单价/=数量/1000×单价`),销售填价后金额即时算出;吨计价缺重量数据时诚实留空。 - 新增 27 个测试(249 → 276,
tests/test_audit_fixes_2608.py):重量派生的优先级/0.0 合法性/兜底链、吨计价 PI 列值、null price_unit/currency 存活性、CI↔PL 净重一致、schema 校验接受/拒绝矩阵、标签剥离、报价单公式模式、PL 守卫。