rust
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern 简单工厂模式 Creational Patterns 创建型模式
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/8 21:48
//!# User : geovindu
//!# Product : RustRover
//!# Project : simplefactorypattern
//!# File : comparison_report.rs
//! # 两版对照报表 ------ 本工程的核心论证那一张表
//!
//! ## 这张表要证明什么
//!
//! 「同一批送检单,喂给两版分派机制,产出的**观察结果完全一致**」。
//!
//! 这句话要被相信,需要三件事同时成立:
//!
//! 1. **对照组干净**:两版必须跑在同一段驱动代码下(见
//! [`crate::client::run_consignment_batch`],参数是 `&dyn OrderCreator`);
//! 2. **比较的是行为,不是实现**:因此比的是逐条结局、账本计数、
//! 金额与工作量合计、认识的编码集合------**不比「分派表内部长什么样」**,
//! 因为那正是两版的差异所在;
//! 3. **比较必须不漏**:文本对照(好读)与 `ProductSnapshot` 整体相等
//! (不漏,26 个字段)两条腿都要有。
//!
//! ## 为什么「一致」列用中文词而不是符号
//!
//! 表格里表达「是/否」常见做法是对勾与叉号那两个符号(码点 U+2713 / U+2717)。
//! 本工程**不用**------连源码注释里也不写出它们(只用码点指代),
//! 于是「`grep` 整个 `src/` 找不到那两个码点」这个更强的不变量成立,
//! 「输出里没有」不必再单独论证。
//! 不用它们的理由是宽度:它们的东亚宽度属性是 `A`(歧义),宽度模型按 1 列算,
//! 而很多终端渲染成 2 列------**整列会因此错位**。
//! 用「一致」/「不一致」两个中文词,宽度确定,且业务读者不会误读。
//!
//! ## 说明文本一律显式省略
//!
//! 左版/右版的结论文本最长约 34 显示列(`成功 ¥1,482.00 / 1 个工作日 / 1 项`),
//! 而明细类字段的原文可以长达上百列。本表统一压到列宽以内
//! ([`elide_text`],超长时末尾出现 `...`)。
//! **显式省略与静默截断是两回事**:前者读者看得出来,后者不会。
use crate::analysis::elide_text;
use crate::analysis::{FieldAgreement, RunComparison};
use crate::app::{section_header, TableColumn, TextTable};
/// 逐条结局对照表。
///
/// 参数 `comparison`:对照结果;`width`:文档总宽。
/// 返回:文本行。
///
/// ## 列宽依据
///
/// | 列 | 宽度 | 依据(该列**可能出现**的最宽值) |
/// |---|---|---|
/// | 样品编号 | 14 | `S-` + 12 位十六进制(定长) |
/// | 检测类型 | 24 | 最长 `GEMSTONE_IDENTIFICATION` = **23 列**(8 + `_` + 14) |
/// | 左版 / 右版 | 34 | 最长 `成功 ¥1,482.00 / 1 个工作日 / 1 项` |
/// | 是否一致 | 10 | `不一致` = 6 列 + 余量 |
///
/// ⚠️ 「检测类型」这一列曾经是 22,而 `GEMSTONE_IDENTIFICATION` 是 **23** 列------
/// 代码注释里写的「= 22」是**数错了**,于是这一格一直被静默截断成
/// `GEMSTONE_IDENTIFICATIO`(少一个字母,肉眼极难发现,因为整行仍然对齐)。
/// 本工程为此把「临时插桩一次跑全」定为排查手段,并把
/// `render_cell` 的断言永久保留:**注释里的数字也要能被机器验一遍**。
pub fn render_per_request_table(comparison: &RunComparison, width: usize) -> Vec<String> {
/// 样品编号列宽。
const SAMPLE_CODE_WIDTH: usize = 14;
/// 检测类型列宽(= 最长编码 23 + 1 列余量)。
const ORDER_CODE_WIDTH: usize = 24;
/// 单侧结论列宽。
const SIDE_WIDTH: usize = 34;
/// 是否一致列宽。
const AGREEMENT_WIDTH: usize = 10;
let mut table: TextTable = TextTable::new(
format!(
"逐条结局对照(批次「{}」;左 {} / 右 {})",
comparison.batch_name(),
comparison.left_mechanism(),
comparison.right_mechanism()
),
vec![
TableColumn::left("样品编号", SAMPLE_CODE_WIDTH),
TableColumn::left("检测类型", ORDER_CODE_WIDTH),
TableColumn::left("左版结论", SIDE_WIDTH),
TableColumn::left("右版结论", SIDE_WIDTH),
TableColumn::center("是否一致", AGREEMENT_WIDTH),
],
);
for entry in comparison.per_request() {
table = table.with_row(vec![
entry.sample_code().to_string(),
entry.order_code().to_string(),
elide_text(entry.left_conclusion(), SIDE_WIDTH),
elide_text(entry.right_conclusion(), SIDE_WIDTH),
if entry.agrees() { "一致" } else { "不一致" }.to_string(),
]);
}
let agreed: usize = comparison
.per_request()
.iter()
.filter(|entry| entry.agrees())
.count();
table = table.with_note(&format!(
"{}/{} 条一致;左版 {} 条、右版 {} 条请求。\
配对**按样品编号**而不是按下标:按下标会把「批次顺序」变成隐含前提,\
一旦某一版换了遍历顺序就会产出一堆与机制无关的假差异。",
agreed,
comparison.per_request().len(),
comparison.left_request_count(),
comparison.right_request_count()
));
table = table.with_note(&format!(
"「是否一致」列用中文词而不用对勾与叉号符号(U+2713 / U+2717):\
那两个符号的东亚宽度属性是歧义的,\
宽度模型按 1 列算而终端常渲染成 2 列,会让整列错位。\
配对明细(每条各属于哪个检测类型):{}",
per_request_type_summary(comparison)
));
table = table.with_note(&format!(
"本表的「一致」是**文本相同**(报表上不可区分)。更严格的一层是快照整体相等:\
{} 对快照中 {} 对完全相同(每个快照 26 个字段,一个不同即不等)。\
文本对照负责让失败可读,快照值对照负责让通过可信------两条腿缺一不可。",
comparison.snapshot_pairs_compared(),
comparison.snapshot_pairs_identical()
));
// 幕标题由**本表**发出:它是「表格自带标题」这一约定的一员
// (本层多数报表如此,幕三 / 幕六 / 幕七 / 幕八 / 幕九 / 幕十都是)。
//
// ⚠️ 这里踩过一次「横幅印两遍」的坑:调用方 `main.rs` 里曾经**同时**
// 自己 `section_header(..)` 了一次,理由是它的注释写着
// 「`render_per_request_table` 不含标题」------那句注释已经过期了。
// 结果输出里「■ 幕五·...」连着出现两行,中间只隔一条分隔线。
//
// 这个缺陷的危险程度被低估过:它**不是数据错误**,一眼看过去像是
// 「本来就该有两级标题」。凡「注释里对某个函数的行为下了断言」,
// 那条断言就必须能被核对------否则它会在别人改动实现之后
// 悄悄变成假话,而且没人会去重读它。
let mut lines: Vec<String> = section_header("幕五·两版机制逐条对照(同一批送检单,只换分派表)", width);
lines.extend(table.render());
lines
}
/// 字段级对照表(账本计数 / 账本明细 / 汇总三类合表)。
///
/// 参数 `comparison`:对照结果;`width`:文档总宽。
/// 返回:文本行。
///
/// ## 为什么三类合表而不是三张表
///
/// 它们结构相同(分组 + 字段 + 左右取值 + 是否一致),
/// 而读者要回答的问题是「**哪个字段**在两版之间不同」------
/// 那需要它们在同一张表里可扫视。分成三张表会让读者来回翻。
pub fn render_field_table(comparison: &RunComparison, width: usize) -> Vec<String> {
/// 分组列宽(最长 `账本明细` = 8)。
const GROUP_WIDTH: usize = 10;
/// 字段列宽(最长 `汇总·认得的编码集合` = 20)。
const FIELD_WIDTH: usize = 22;
/// 单侧取值列宽。
const SIDE_WIDTH: usize = 30;
/// 是否一致列宽。
const AGREEMENT_WIDTH: usize = 10;
let mut table: TextTable = TextTable::new(
"字段级对照(账本计数、账本明细、汇总字段)",
vec![
TableColumn::left("分组", GROUP_WIDTH),
TableColumn::left("字段", FIELD_WIDTH),
TableColumn::left("左版取值", SIDE_WIDTH),
TableColumn::left("右版取值", SIDE_WIDTH),
TableColumn::center("是否一致", AGREEMENT_WIDTH),
],
);
// 三个分组依次挂上。用一个小闭包统一渲染,避免三段几乎相同的代码------
// 三段各写一遍的话,将来给这张表加一列就会漏改其中一段。
let groups: [(&str, &[FieldAgreement]); 3] = [
("账本计数", comparison.ledger_fields()),
("账本明细", comparison.ledger_detail_fields()),
("汇总字段", comparison.summary_fields()),
];
for (group_name, fields) in groups {
for field in fields {
table = table.with_row(vec![
group_name.to_string(),
field.field().to_string(),
elide_text(field.left_text(), SIDE_WIDTH),
elide_text(field.right_text(), SIDE_WIDTH),
if field.agrees() { "一致" } else { "不一致" }.to_string(),
]);
}
}
let total: usize = comparison.ledger_fields().len()
+ comparison.ledger_detail_fields().len()
+ comparison.summary_fields().len();
let disagreement: usize = comparison.disagreement_count();
table = table.with_note(&format!(
"共 {} 个字段;整体不一致 {} 处(含逐条对照的不一致)。\
「账本明细」两列的原文可能很长(一串「编码×张数」),此处按列宽显式省略;\
完整明细见幕四的归组明细表------**体检行只负责回答「一致吗」,不负责承载全量数据**。",
total, disagreement
));
let mut lines: Vec<String> = section_header("幕六·两版机制逐字段对照", width);
lines.extend(table.render());
lines
}
/// 把逐条对照按检测类型汇总成一句话(用于注解)。
///
/// 参数 `comparison`:对照结果。
/// 返回:如 `PRECIOUS_METAL_PURITY 2 条一致、SILVER_PURITY 2 条一致...`。
///
/// ## 为什么这个汇总值得一提
///
/// 逐条表的「一致」列只能说明「这一条一致」,读者无从判断
/// **五类检测类型是不是都被覆盖到了**。按类型汇总之后,
/// 覆盖率与一致性可以在同一句话里读到------
/// 而「某类产品根本没被测试到」是本工程最需要防的盲区:
/// 一条都没测的类型,其一致性与「一致」看起来是一样的。
fn per_request_type_summary(comparison: &RunComparison) -> String {
// 按类型累加(保持首次出现的顺序,再排序,保证确定性)。
let mut buckets: Vec<(String, usize, usize)> = Vec::new();
for entry in comparison.per_request() {
let agreed: usize = if entry.agrees() { 1 } else { 0 };
match buckets
.iter_mut()
.find(|bucket| bucket.0 == entry.order_code())
{
Some(bucket) => {
bucket.1 += 1;
bucket.2 += agreed;
}
None => buckets.push((entry.order_code().to_string(), 1, agreed)),
}
}
buckets.sort_by(|left, right| left.0.cmp(&right.0));
buckets
.iter()
.map(|bucket| format!("{} {}/{}", bucket.0, bucket.2, bucket.1))
.collect::<Vec<String>>()
.join(",")
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern 简单工厂模式 Creational Patterns 创建型模式
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/8 21:48
//!# User : geovindu
//!# Product : RustRover
//!# Project : simplefactorypattern
//!# File : dispatch_table_report.rs
//! # 分派表报表 ------ 把「工厂认识什么」摆成一张表
//!
//! ## 这张表要回答的问题
//!
//! 简单工厂的全部能力集中在一张分派表上。因此报表必须能回答:
//!
//! 1. 工程内置了哪些产品?(**目录表**:构造器表里有什么)
//! 2. 两版机制各自认识哪些编码?覆盖率是多少?(**机制对照表**)
//!
//! 这两张表放在一起看才完整:目录表是「本来有什么」,
//! 机制表是「机制认得多少」。两者的差就是**分派表与产品实现的差距**------
//! 也就是本工程要量化的那个东西。
//!
//! ## 一个刻意不印的东西:构造器的函数地址
//!
//! 本表的初版想在「目录表」里加一列「构造器指纹」,
//! 用 `builder as usize` 取函数指针地址再打 8 位十六进制。
//! 那会**破坏「两次运行输出逐字节一致」**------
//! 函数地址落在可执行映像里,进程启动地址受 ASLR 随机化影响,
//! 两次运行必然不同。
//!
//! 这个坑值得留档,因为它很隐蔽:本工程确实有一个确定性的
//! [`crate::support::short_fingerprint`],看起来正好用得上;
//! 而「函数指针是编译期常量」这个印象也是错的(它是**装载期**常量)。
//! 凡是**可能与地址有关**的值,都不能进要求逐字节一致的输出。
//!
//! 因此本表只印**序号 + 编码 + 中文名**,三样都是确定性的。
use crate::analysis::CreatorCoverage;
use crate::app::{note_lines, section_header, TableColumn, TextTable};
/// 目录表里的一行(一个内置产品)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DispatchEntry {
/// 检测类型编码。
code: String,
/// 检测类型中文名。
chinese_name: String,
}
impl DispatchEntry {
/// 构造一个目录项。
///
/// 参数 `code`:编码;`chinese_name`:中文名。
/// 返回:目录项。
pub fn new(code: &str, chinese_name: &str) -> DispatchEntry {
DispatchEntry {
code: code.to_string(),
chinese_name: chinese_name.to_string(),
}
}
/// 检测类型编码。
pub fn code(&self) -> &str {
&self.code
}
/// 检测类型中文名。
pub fn chinese_name(&self) -> &str {
&self.chinese_name
}
}
/// 渲染「工程内置产品目录」表。
///
/// 参数 `entries`:目录项(顺序即展示顺序,由调用方决定,保证稳定);
/// `width`:文档总宽。
/// 返回:文本行。
///
/// ## 列宽是怎么定的
///
/// | 列 | 宽度 | 依据(该列**可能出现**的最宽值) |
/// |---|---|---|
/// | 序号 | 4 | 三位数以内,本工程 5 项 |
/// | 编码 | 24 | 最长 `GEMSTONE_IDENTIFICATION` = **23 列**(8 + `_` + 14) |
/// | 中文名 | 16 | 最长 `贵金属纯度检测`(14 列) |
///
/// ⚠️ 这张表的宽度原本就是 24,**恰好够**;但注释里写的依据是「22 列」,
/// 是**数错了**。错在注释上比错在宽度上幸运,可两者是同一个错误的两面:
/// 下一个人照注释把宽度收到 22 就会当场截断。因此宽度依据里的每个数字
/// 都必须能被机器验一遍------`main.rs` 的编码清单自查里印出了最长编码的实际宽度。
///
/// 编码列留到 24 而不是刚好 22:**编码是开放型的**,
/// 工程外的扩展方随时可能加一个更长的编码。留 2 列余量的代价很小,
/// 而列宽不够的代价是一次 debug 构建下的 panic------这个取舍写在这里,
/// 以免后来者以为 24 是随手填的。
pub fn render_builtin_catalog(entries: &[DispatchEntry], width: usize) -> Vec<String> {
/// 序号列宽。
const SEQUENCE_WIDTH: usize = 4;
/// 编码列宽(见本函数文档里的依据)。
const CODE_WIDTH: usize = 24;
/// 中文名列宽。
const NAME_WIDTH: usize = 16;
let mut table: TextTable = TextTable::new(
"工程内置产品目录(product::builtin_product_builders 的返回内容)",
vec![
TableColumn::right("#", SEQUENCE_WIDTH),
TableColumn::left("检测类型编码", CODE_WIDTH),
TableColumn::left("检测类型中文名", NAME_WIDTH),
],
);
for (index, entry) in entries.iter().enumerate() {
table = table.with_row(vec![
// 序号从 1 起:它是给人看的,不是下标。
(index + 1).to_string(),
entry.code().to_string(),
entry.chinese_name().to_string(),
]);
}
table = table.with_note(&format!(
"共 {} 种。这张表就是简单工厂的分派依据:新增一种产品 = 在这张表里加一行。\
注意它属于 product 层,而「接受哪些编码」的白名单属于 factory 层------两份清单由此分居两层。",
entries.len()
));
let mut lines: Vec<String> = section_header("幕二·分派表装配", width);
lines.extend(table.render());
lines
}
/// 渲染「检测项目价目表」。
///
/// 参数 `items`:检测项目清单;`width`:文档总宽。
/// 返回:文本行。
///
/// ## 为什么这张表要列出**全部**项目,包括没被任何产品用到的
///
/// 因为「项目清单」与「产品清单」是两个独立的维度,
/// 报表面向的是**价目表的维护者**。若只列各产品引用到的项目,
/// 那么「库里有个项目没被任何检测类型用上」这件事**永远不会被发现**------
/// 而正是这类项目最容易在调价时被漏掉。
///
/// 本表因此从 `domain::BUILTIN_TESTING_ITEMS`(项目总表)出发,
/// 而不是从各产品反推。
///
/// ## 列宽依据
///
/// | 列 | 宽度 | 依据 |
/// |---|---|---|
/// | 序号 | 4 | 三位数以内 |
/// | 项目编码 | 20 | 最长 `UV_FLUORESCENCE` = 15 |
/// | 中文名 | 16 | 最长 `金属含量测定` = 12 |
/// | 单价 | 14 | 金额列:按可能值而非本批值(见 `profile_report` 的说明) |
pub fn render_testing_item_catalog(
items: &[crate::domain::TestingItem],
width: usize,
) -> Vec<String> {
/// 序号列宽。
const SEQUENCE_WIDTH: usize = 4;
/// 项目编码列宽。
const CODE_WIDTH: usize = 20;
/// 中文名列宽。
const NAME_WIDTH: usize = 16;
/// 单价列宽。
const FEE_WIDTH: usize = 14;
let mut table: TextTable = TextTable::new(
"检测项目价目表(domain::BUILTIN_TESTING_ITEMS 的全部内容)",
vec![
TableColumn::right("#", SEQUENCE_WIDTH),
TableColumn::left("项目编码", CODE_WIDTH),
TableColumn::left("中文名", NAME_WIDTH),
TableColumn::right("单价", FEE_WIDTH),
],
);
for (index, item) in items.iter().enumerate() {
table = table.with_row(vec![
(index + 1).to_string(),
item.code().to_string(),
item.chinese_name().to_string(),
item.item_fee().formatted(),
]);
}
table = table.with_note(&format!(
"共 {} 个项目。单价挂在**项目自己身上**(`TestingItem::item_fee`),\
因此「按项目数计价」的产品只需遍历项目求和,完全不必认识任何具体项目------\
新增项目、调价,计价代码一行都不用改。工程外的扩展方也可以定义自己的项目\
(`TestingItem::new` 是 `const fn`),它们不在本表里,因为本表印的是「内置」清单。",
items.len()
));
let mut lines: Vec<String> = vec![String::new()];
lines.extend(table.render());
lines.extend(note_lines(
"本表直接遍历 `domain::BUILTIN_TESTING_ITEMS`(项目总表),而不是从各产品反推。\n\
这样「调价」只需要改项目常量一处,价目表与账单必然同步;\n\
若反过来让报表去各产品里收集单价,就会出现「同一个项目在两个产品里单价不同」\n\
而无人发现的局面------**口径只有一处**这条纪律在报表上同样成立。",
width,
));
lines
}
/// 渲染「两版机制的清单对照」表。
///
/// 参数 `coverages`:各机制的覆盖情况(顺序即展示顺序,由调用方决定);
/// `universe_size`:编码全集的大小;`width`:文档总宽。
/// 返回:文本行。
///
/// ## 列宽依据
///
/// | 列 | 宽度 | 依据(该列**可能出现**的最宽值) |
/// |---|---|---|
/// | 机制 | 32 | 最长 `运行期登记表(一行 register)`(29 列),留 3 列余量 |
/// | 清单份数 | 10 | `2` / `1` + 表头 `清单份数`(8 列) |
/// | 识别编码数 | 12 | 两位数以内 + 表头 `识别编码数`(10 列) |
/// | 覆盖率 | 10 | `5/5` 加余量 + 表头 `覆盖率`(6 列) |
/// | 全集内缺失 | 12 | 两位数以内 + 表头 `全集内缺失`(10 列) |
/// | 全集外扩展 | 12 | 两位数以内 + 表头 `全集外扩展`(10 列) |
///
/// ⚠️ **表头本身也可能撑破列宽**,而且是最容易漏掉的那一行------
/// 「表头嘛,肯定短」是错觉:本表最宽的表头是 `识别编码数`(10 列),
/// 与数据列的两位数同级。因此定宽时要把**表头也当成一个可能值**,
/// 而不是只看数据行。
///
/// ⚠️ 「机制」这一列原本是 16,而两个机制的完整名字是
/// `编译期白名单(手工扩表)`(22 列)与 `运行期登记表(一行 register)`(29 列)------
/// 两行都被静默截断,且因为「机制」列表头只有 4 列宽,
/// 截断之后整张表**仍然完美对齐**。这是本工程第 N 次撞上同一个坑:
/// **截断不会让表看起来坏掉,它只会让内容悄悄变短。**
pub fn render_mechanism_comparison(
coverages: &[CreatorCoverage],
universe_size: usize,
width: usize,
) -> Vec<String> {
/// 机制名列宽。
///
/// 该列填入的是 `OrderCreator::mechanism_name()`,两个机制分别是
/// 「编译期白名单(手工扩表)」(22 列)与「运行期登记表(一行 register)」(29 列)。
/// 取值 32 = 29 + 3:这一列的内容由**各创建者自己申报**,
/// 将来新增一版机制(比如「配置文件驱动」)时名字多半同量级,
/// 3 列余量能让多数措辞微调不必回来改宽度;
/// 而一旦真撞上,`render_cell` 的断言会在第一次运行当场炸------
/// **余量只用来减少改宽度的次数,不用来掩盖超长**。
const MECHANISM_WIDTH: usize = 32;
/// 清单份数列宽。
const LIST_COUNT_WIDTH: usize = 10;
/// 识别编码数列宽。
const RECOGNIZED_WIDTH: usize = 12;
/// 覆盖率列宽。
const COVERAGE_WIDTH: usize = 10;
/// 缺失数列宽。
const MISSING_WIDTH: usize = 12;
/// 扩展项列宽。
const EXTRA_WIDTH: usize = 12;
let mut table: TextTable = TextTable::new(
"两版分派机制的清单对照(都是简单工厂,差别只在分派表住在哪)",
vec![
TableColumn::left("机制", MECHANISM_WIDTH),
TableColumn::right("清单份数", LIST_COUNT_WIDTH),
TableColumn::right("识别编码数", RECOGNIZED_WIDTH),
TableColumn::right("覆盖率", COVERAGE_WIDTH),
TableColumn::right("全集内缺失", MISSING_WIDTH),
TableColumn::right("全集外扩展", EXTRA_WIDTH),
],
);
for coverage in coverages {
table = table.with_row(vec![
coverage.mechanism_name().to_string(),
// ★ 清单份数由机制自己回答(`OrderCreator::dispatch_list_count`),
// 不在报表里按名字猜。这个数字就是本工程要量化的扩展成本:
// 2 = 白名单 + 构造器表,1 = 只有登记表。
coverage.dispatch_list_count().to_string(),
coverage.recognized().len().to_string(),
coverage.coverage_text(),
coverage.missing().len().to_string(),
coverage.extra().len().to_string(),
]);
}
table = table.with_note(&format!(
"编码全集 {} 种。清单份数指的是「必须手动保持同步的清单有几份」------\
白名单版是 2(白名单 + 构造器表),登记表版是 1(表本身就是白名单)。\
这个数字就是本工程要量化的扩展成本。",
universe_size
));
for coverage in coverages {
table = table.with_note(&format!(
"· {}:{}",
coverage.mechanism_name(),
coverage.mechanism_note()
));
}
let mut lines: Vec<String> = vec![String::new()];
lines.extend(table.render());
lines.extend(note_lines(
"覆盖率是「对着全集逐一询问 `supports`」算出来的,不是取创建者自报的清单做集合差。\n\
用自报清单会让「清单里写了、查询时却查不到」这类缺陷完全隐藏起来;\n\
逐一询问则是在**验证那个承诺**。这也是 `supports` 必须无副作用的原因:\n\
这里会对每个编码问一遍,若它顺手记账,账本里就会多出「全集大小 × 机制数」笔虚假尝试。",
width,
));
lines
}
/// 渲染「分派表的注记」段落(机制差异的定性说明)。
///
/// 参数 `width`:文档总宽。
/// 返回:文本行。
///
/// 说明文字**不放进表格单元**,而是走 [`note_lines`]:
/// 这是本工程的一条硬纪律------一句解释往往比所有数据行都宽,
/// 放进单元格必然撑破列宽。
pub fn render_dispatch_notes(width: usize) -> Vec<String> {
note_lines(
"简单工厂的教科书写法是一个 match:分派表与产品实现是两份东西,必须手动保持同步。\
本工程把这件事做成可测量的:同时提供两版机制,用同一批送检单跑它们,\
把「扩展时要改几处」与「会不会失配」变成报表上的数字。",
width,
)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern 简单工厂模式 Creational Patterns 创建型模式
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/8 21:48
//!# User : geovindu
//!# Product : RustRover
//!# Project : simplefactorypattern
//!# File : extension_report.rs
//! # 扩展与失配报表 ------ 「完全可扩展」这句主张的证据
//!
//! ## 这张表要证明两件事
//!
//! ### 一、扩展的成本可以被量化,而不是被口头声明
//!
//! 本工程同时挂着两版机制,因此可以**把同一次扩展**分别对它们做一遍,
//! 然后比较「改了几个层、几个文件」:
//!
//! | 机制 | 扩展时改了几个分层文件 | 改了什么 |
//! |---|---|---|
//! | 编译期白名单 | **2 个** | `factory` 的白名单数组(加一项 + 改长度)、`product` 的构造器表(加一行) |
//! | 运行期登记表 | **0 个** | 调用方写一行 `register(..)` |
//!
//! `layers_touched` 这一列是本工程最硬的一个数字:**它是可数的**,
//! 不依赖任何解释。若某天有人声称「登记表版扩展也要改分层文件」,
//! 只要把那个数从 0 改成 1 并给出文件名即可反驳------
//! 而反驳的成本恰好就是本表想让读者看到的那个成本。
//!
//! ### 二、「沉默故障」这个说法需要被修正
//!
//! 本工程初稿把两种失配方向写成:
//!
//! ```text
//! 白名单有、构造器表没有 → 运行期报「未知编码」 (显性)
//! 构造器表有、白名单没有 → 完全没有报错,产品永远造不出来 (隐性)
//! ```
//!
//! 第二行**不准确**。实测下来是:若送检单里恰好有那个编码,
//! 运行期确实会报「未知编码」,症状与「该功能未上线」一模一样。
//! 「完全没有报错」是错的。
//!
//! 准确、而且**更有说服力**的版本是:
//!
//! | 失配方向 | `supports` 的回答 | 创建的结果 | 是否自相矛盾 |
//! |---|---|---|---|
//! | 白名单有、构造器表没有 | `true`(承诺支持) | 失败 | **是** |
//! | 构造器表有、白名单没有 | `false`(不承诺) | 失败 | **否,完全自洽** |
//!
//! 也就是说:第一种失配会让工厂**自相矛盾**,任何「声明与实际对照」
//! 的检查都能抓到;第二种失配**对内完全自洽**------
//! 从对外行为看,它与「这个功能本来就没做」**不可区分**。
//!
//! 这才是它难发现的真正原因:**不是「没有报错」,而是「报的错看起来完全合理」。**
//! 本表因此设了两列------「声明与行为矛盾」与「跑完整批后未知编码」------
//! 把这句话从断言变成读数。**一个更顺口但不准确的表述,比一句笨拙的准确表述危险得多。**
use crate::analysis::DispatchTableMismatch;
use crate::app::{note_lines, section_header, TableColumn, TextTable};
/// 扩展过程的一个阶段(用于对比「扩展前 / 扩展后」)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtensionStage {
/// 阶段名(如 `扩展前` / `扩展后`)。
stage: String,
/// 机制名。
mechanism_name: String,
/// 登记表项数(或白名单项数)。
entry_count: usize,
/// 本机制的清单份数(1 或 2)。
dispatch_list_count: usize,
/// 成功率检查:该阶段识别的编码数。
recognized_count: usize,
/// **本次扩展改动了几层**(人为声明,不是算出来的------见下面说明)。
layers_touched: usize,
/// **本次扩展改动了几个分层文件**。
files_touched: usize,
}
impl ExtensionStage {
/// 构造一个扩展阶段。
///
/// 参数较多是刻意的:扩展成本的每一面都必须显式给出,
/// 不给「先造一半再填」的机会。
pub fn new(
stage: &str,
mechanism_name: &str,
entry_count: usize,
dispatch_list_count: usize,
recognized_count: usize,
layers_touched: usize,
files_touched: usize,
) -> ExtensionStage {
ExtensionStage {
stage: stage.to_string(),
mechanism_name: mechanism_name.to_string(),
entry_count,
dispatch_list_count,
recognized_count,
layers_touched,
files_touched,
}
}
/// 阶段名。
pub fn stage(&self) -> &str {
&self.stage
}
/// 机制名。
pub fn mechanism_name(&self) -> &str {
&self.mechanism_name
}
/// 登记表项数。
pub fn entry_count(&self) -> usize {
self.entry_count
}
/// 清单份数。
pub fn dispatch_list_count(&self) -> usize {
self.dispatch_list_count
}
/// 识别的编码数。
pub fn recognized_count(&self) -> usize {
self.recognized_count
}
/// 改动了几层。
pub fn layers_touched(&self) -> usize {
self.layers_touched
}
/// 改动了几个分层文件。
pub fn files_touched(&self) -> usize {
self.files_touched
}
}
/// 渲染「扩展前后对照」表。
///
/// 参数 `stages`:各阶段(同一机制的扩展前/后各一行);
/// `width`:文档总宽。
/// 返回:文本行。
///
/// ## ⚠️ `改动层数` / `改动文件数` 是**申报值**,不是算出来的
///
/// 这一点必须讲清楚,否则这张表会看起来比它实际能证明的更强。
/// 本工程没有(也不可能有)一个函数能回答「这次改动碰了几个文件」------
/// 那需要知道版本控制的状态,而报表不读 git。
///
/// 因此这两列填的是**人工申报值**,它的可信度来自另一件事:
/// 申报值可以被核对。任何读者只要 `grep -n "register(" src/main.rs`,
/// 就能确认登记表版的扩展确实只在调用方改了一行。
///
/// **把「申报值」明确标出来,比假装它是实算值要好**------
/// 前者读者知道该去核对什么,后者读者会以为不必核对。
/// ## 列宽依据
///
/// | 列 | 宽度 | 依据(该列**可能出现**的最宽值) |
/// |---|---|---|
/// | 阶段 | 10 | `扩展前` / `扩展后`(6 列) |
/// | 机制 | 32 | 最长 `运行期登记表(一行 register)`(29 列),留 3 列余量 |
/// | 清单项数 / 清单份数 | 10 | 两位数以内 + 表头(8 列) |
/// | 识别编码数 | 12 | 两位数以内 + 表头(10 列) |
/// | 改动层数 / 改动文件数 | 10 / 12 | 两位数以内 + 表头(8 / 10 列) |
///
/// ⚠️ 「机制」这一列原本是 16,两个机制的完整名字(22 / 29 列)都被静默截断。
/// 教训与幕二那张同名列一样:**该列的内容由各机制自己申报**,
/// 而申报的名字长度不由报表控制------因此宽度必须按「可能值」定,不能按当前值定。
pub fn render_extension_table(stages: &[ExtensionStage], width: usize) -> Vec<String> {
/// 阶段列宽。
const STAGE_WIDTH: usize = 10;
/// 机制列宽(最长 29 + 3 列余量)。
const MECHANISM_WIDTH: usize = 32;
/// 计数列宽。
const COUNT_WIDTH: usize = 10;
/// 清单份数列宽。
const LIST_COUNT_WIDTH: usize = 10;
/// 识别编码数列宽。
const RECOGNIZED_WIDTH: usize = 12;
/// 改动层数列宽。
const LAYERS_WIDTH: usize = 10;
/// 改动文件数列宽。
const FILES_WIDTH: usize = 12;
let mut table: TextTable = TextTable::new(
"扩展前后对照(同一次扩展,对两版机制各做一遍)",
vec![
TableColumn::left("阶段", STAGE_WIDTH),
TableColumn::left("机制", MECHANISM_WIDTH),
TableColumn::right("清单项数", COUNT_WIDTH),
TableColumn::right("清单份数", LIST_COUNT_WIDTH),
TableColumn::right("识别编码数", RECOGNIZED_WIDTH),
TableColumn::right("改动层数", LAYERS_WIDTH),
TableColumn::right("改动文件数", FILES_WIDTH),
],
);
for stage in stages {
table = table.with_row(vec![
stage.stage().to_string(),
stage.mechanism_name().to_string(),
stage.entry_count().to_string(),
stage.dispatch_list_count().to_string(),
stage.recognized_count().to_string(),
stage.layers_touched().to_string(),
stage.files_touched().to_string(),
]);
}
table = table.with_note(
"⚠️「改动层数」「改动文件数」是**人工申报值**,不是算出来的------\
报表不读版本控制,无法自动回答「本次改动碰了几个文件」。\
它们之所以可信,是因为读者可以核对:`grep -n \"register(\" src/main.rs` \
就能确认登记表版的扩展确实只在调用方改了一行。\
把申报值明确标出来,比假装它是实算值要好------前者读者知道该核对什么。",
);
table = table.with_note(
"「清单份数」= 该机制必须手动保持同步的清单有几份(2 = 白名单 + 构造器表;1 = 只有登记表)。\
扩展成本的差别不在「改几行」,而在**要不要同时改两处并保持它们一致**。",
);
let mut lines: Vec<String> = section_header("幕九·工程外扩展(零改动实证)", width);
lines.extend(table.render());
lines
}
/// 失配反例的一组证据。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MismatchEvidence {
/// 反例工厂的标签。
label: String,
/// 白名单项数。
accepted_count: usize,
/// 构造器表项数。
builder_count: usize,
/// 显性失配项数。
explicit_mismatch: usize,
/// 隐性失配项数。
silent_mismatch: usize,
/// 「能力声明与行为矛盾」的条数(来自 [`crate::analysis::SupportClaimAudit`])。
claim_contradictions: usize,
/// 「承诺已兑现、但被产品层拒绝」的条数(**不计入矛盾**)。
///
/// ## 为什么它必须进证据结构,而不是写死在注解里
///
/// 因为注解里需要一个数字来说明「矛盾只有几条、其余失败去哪了」。
/// 若那个数字是手写的「5」,它就会和表里的读数各自演化------
/// 本工程刚因为同类问题吃过一次亏(注解写「反例 B 的矛盾为 0」,
/// 而同一张表里印着 5)。**注解里的数字也要从数据来**,
/// 否则它只是另一处会过期的副本。
specification_rejected: usize,
/// 用这个反例工厂跑完整批之后,**未知编码的笔数**。
unknown_code_hits: usize,
}
impl MismatchEvidence {
/// 构造一组证据。
///
/// 参数 `label`:反例标签;`accepted_count` / `builder_count`:两份清单的项数;
/// `mismatch`:双向失配结果;`claim_contradictions`:能力声明与行为的矛盾条数;
/// `specification_rejected`:承诺已兑现但被产品层拒绝的条数;
/// `unknown_code_hits`:整批跑完后的未知编码笔数。
/// 返回:证据。
pub fn new(
label: &str,
accepted_count: usize,
builder_count: usize,
mismatch: &DispatchTableMismatch,
claim_contradictions: usize,
specification_rejected: usize,
unknown_code_hits: usize,
) -> MismatchEvidence {
MismatchEvidence {
label: label.to_string(),
accepted_count,
builder_count,
explicit_mismatch: mismatch.explicit_failure_count(),
silent_mismatch: mismatch.silent_failure_count(),
claim_contradictions,
specification_rejected,
unknown_code_hits,
}
}
/// 反例标签。
pub fn label(&self) -> &str {
&self.label
}
/// 白名单项数。
pub fn accepted_count(&self) -> usize {
self.accepted_count
}
/// 构造器表项数。
pub fn builder_count(&self) -> usize {
self.builder_count
}
/// 显性失配项数。
pub fn explicit_mismatch(&self) -> usize {
self.explicit_mismatch
}
/// 隐性失配项数。
pub fn silent_mismatch(&self) -> usize {
self.silent_mismatch
}
/// 能力声明与行为矛盾的条数。
///
/// ## 这个数把「两种失配方向不对称」从断言变成了测量
///
/// - 反例 A(白名单多一项):`supports` 承诺支持,创建却失败 → **矛盾 > 0**;
/// - 反例 B(白名单少一项):`supports` 不承诺,创建也确实失败 → **矛盾 = 0**。
///
/// 也就是说 B 那种失配**对内完全自洽**:从对外行为看,它和
/// 「这个功能本来就没做」不可区分。这才是它真正难以发现的原因------
/// 不是「没有报错」,而是「报的错看起来完全合理」。
pub fn claim_contradictions(&self) -> usize {
self.claim_contradictions
}
/// 「承诺已兑现、但被产品层拒绝」的条数。
pub fn specification_rejected(&self) -> usize {
self.specification_rejected
}
/// 整批跑完后的未知编码笔数。
pub fn unknown_code_hits(&self) -> usize {
self.unknown_code_hits
}
}
/// 渲染「失配反例证据」表。
///
/// 参数 `evidences`:各反例工厂的证据;`batch_request_count`:这批单子的条数
/// (由调用方给出,**不在标题里写死**------写死的数字就是另一处会过期的副本);
/// `width`:文档总宽。
/// 返回:文本行。
///
/// ## 表里那两列「矛盾条数」与「未知编码笔数」是这个证明的核心
///
/// 「两种失配方向的危险程度不对称」这句话,光靠列一张失配清单是说服不了人的。
/// 这两列把它变成读数:
///
/// | 反例 | 失配方向 | 声明与行为是否矛盾 | 未知编码笔数 |
/// |---|---|---|---|
/// | A | 白名单有、构造器表没有 | **矛盾 > 0** | 与正常工厂相同 +1 |
/// | B | 构造器表有、白名单没有 | **矛盾 = 0(完全自洽)** | 与正常工厂相同 +1 |
///
/// 看第三列:B 那种失配**没有任何内部矛盾**。声明不支持,事实上也确实不支持,
/// 一切都对得上------它的对外表现与「这个功能本来就没做」**不可区分**。
/// 这才是它难被发现的原因:不是「没有报错」,而是「报的错看起来完全合理」。
/// ## 列宽依据
///
/// | 列 | 宽度 | 依据(该列**可能出现**的最宽值) |
/// |---|---|---|
/// | 工厂 | 30 | 最长 `反例工厂 B(构造器表多一项)`(28 列),留 2 列余量 |
/// | 白名单项数 / 构造器表项数 | 12 | 表头 `构造器表项数` 本身就是 **12 列** |
/// | 显性失配 / 隐性失配 | 12 | 两位数以内 + 表头(8 列) |
/// | 声明与行为矛盾 | 16 | 表头 `声明与行为矛盾`(14 列) |
/// | 跑完整批后未知编码 | 20 | 表头 `跑完整批后未知编码`(18 列) |
///
/// ⚠️ 这张表里有两个**表头长得比数据还宽**的例子:
/// `构造器表项数` = 12 列(原列宽 10,表头自己撑破了列宽),
/// `跑完整批后未知编码` = 18 列。它们把「列宽要按数据定」这条常识推翻了一半:
/// **列宽要按「这一列可能出现的任何内容」定,表头也在其中。**
pub fn render_mismatch_evidence_table(
evidences: &[MismatchEvidence],
batch_request_count: usize,
width: usize,
) -> Vec<String> {
/// 反例标签列宽(最长 28 + 2 列余量)。
///
/// 这一列的内容是**人工撰写的标签**,因此 2 列余量的作用是
/// 「多数措辞微调不必回来改宽度」;它不承担「容忍超长」的职责------
/// 真超长了,`render_cell` 的断言会在第一次运行当场炸。
const LABEL_WIDTH: usize = 30;
/// 计数列宽(= 表头 `构造器表项数` 的 12 列)。
const COUNT_WIDTH: usize = 12;
/// 矛盾条数列宽(表头 `声明与行为矛盾` = 14)。
const CONTRADICTION_WIDTH: usize = 16;
/// 未知编码列宽(表头 `跑完整批后未知编码` = 18)。
const HITS_WIDTH: usize = 20;
let mut table: TextTable = TextTable::new(
format!(
"失配反例:两份清单不同步时会怎样(同一个 {} 条批次,三个工厂各跑一遍)",
batch_request_count
),
vec![
TableColumn::left("工厂", LABEL_WIDTH),
TableColumn::right("白名单项数", COUNT_WIDTH),
TableColumn::right("构造器表项数", COUNT_WIDTH),
TableColumn::right("显性失配", COUNT_WIDTH),
TableColumn::right("隐性失配", COUNT_WIDTH),
TableColumn::right("声明与行为矛盾", CONTRADICTION_WIDTH),
TableColumn::right("跑完整批后未知编码", HITS_WIDTH),
],
);
for evidence in evidences {
table = table.with_row(vec![
evidence.label().to_string(),
evidence.accepted_count().to_string(),
evidence.builder_count().to_string(),
evidence.explicit_mismatch().to_string(),
evidence.silent_mismatch().to_string(),
evidence.claim_contradictions().to_string(),
evidence.unknown_code_hits().to_string(),
]);
}
table = table.with_note(
"★ 关键读数:反例 B 的「隐性失配」为 1、「声明与行为矛盾」为 **0**。\
也就是说它**对内完全自洽**------`supports` 说不支持,创建也确实失败,一切都对得上。\
从对外行为看,它与「这个功能本来就没做」不可区分。\
这才是它难发现的原因:不是「没有报错」,而是「报的错看起来完全合理」。",
);
// ⚠️ 这条注解里的每个数字都从数据来。本工程刚因为「注解手写数字」吃过一次亏:
// 初版的注解写「反例 B 的矛盾为 0」,而它正上方那张表里印着 5。
// 注解与表各自演化的根因,就是注解里的数字不是算出来的。
table = table.with_note(&format!(
"⚠️「声明与行为矛盾」只数**工厂层**的失败(编码不在分派表里)。\
这批单子里另有 {} 条是被**产品层**拒绝的(样品类别不符 / 参数缺失 / 参数越界,\
逐工厂分别为 {})------那些单子里 `supports` 的承诺**已经兑现**(工厂认得那个编码),\
因此不计入矛盾。初版把两者合并计数,于是每个工厂都平白多出若干条「矛盾」,\
而这张表的注解却写着「反例 B 的矛盾为 0」------**注解与它自己的表对不上**。\
把不同层的问题分开数,是为了让「不对称」这个结论不被假阳性淹没。",
// 「另有 N 条」取三个工厂里的**最大值**:三行跑的是同一批单子,
// 产品层拒绝的条数对它们应当相同;取最大值意味着若将来出现不一致,
// 注解会以最大的那个数为准------偏向「说得更重」而不是「说得更轻」。
evidences
.iter()
.map(MismatchEvidence::specification_rejected)
.max()
.unwrap_or(0),
evidences
.iter()
.map(|evidence| format!("{}={}", evidence.label(), evidence.specification_rejected()))
.collect::<Vec<String>>()
.join("、"),
));
table = table.with_note(
"反例 A(白名单多一项)会让工厂**自相矛盾**:承诺支持,创建却失败------\
任何「声明与实际对照」的检查都能抓到它。\
所以两个方向的危险程度确实不对称,但准确的说法是\
「一个自相矛盾、一个自洽」,而不是「一个有报错、一个没有」。\
本工程把这句话写成可测量的两列,就是为了避免那句更顺口但不准确的表述。",
);
let mut lines: Vec<String> = vec![String::new()];
lines.extend(table.render());
lines.extend(note_lines(
"三行对着**同一个**扩展批次跑。请注意反例 A 与反例 B 的「跑完整批后未知编码」\n\
两列**完全相同**------光看那个数(也就是光看错误消息)分辨不出它们。\n\
唯一能区分的是「声明与行为矛盾」列:A 大于零(自相矛盾),B 等于零(对内自洽)。\n\
这就是本工程那句结论的读数形式:**不是「没有报错」,而是「报的错看起来完全合理」**。",
width,
));
lines
}
/// 渲染扩展与失配的注记段落。
///
/// 参数 `width`:文档总宽。
/// 返回:文本行。
pub fn render_extension_notes(width: usize) -> Vec<String> {
note_lines(
"本幕的论证方式是「实证」而不是「声明」:扩展方新增一种检测类型时,\
类型与它的构造器都写在 main.rs 里(工程之外),全程不改动任何既有分层文件;\
随后把它挂上登记表,它就被自动纳入覆盖率、账本、产能与成本报表。\
白名单版做不到这一点------它必须改 factory 层的白名单数组(还要改数组长度)\
与 product 层的构造器表,两处、三层、且必须手动保持同步。",
width,
)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern 简单工厂模式 Creational Patterns 创建型模式
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/8 21:48
//!# User : geovindu
//!# Product : RustRover
//!# Project : simplefactorypattern
//!# File : health_check_report.rs
//! # 体检报表 ------ 把一组结论印成表
//!
//! ## 这张表的列宽是怎么定的(一个反复踩坑的地方)
//!
//! | 列 | 宽度 | 依据(该列**可能出现**的最宽值) |
//! |---|---|---|
//! | 检查项 | 58 | 见下(两类内容取最大) |
//! | 结论 | 8 | `未通过` = 6 列 |
//! | 实测值 | 54 | 见下 |
//!
//! ### 「检查项」这一列要同时装下**两种**内容
//!
//! 合并表里除了检查项名,还有一条**小标题行**(`■ ` + 报告标题)。
//! 两者都进同一列,因此列宽必须同时容纳:
//!
//! | 内容 | 最长者 | 显示宽度 |
//! |---|---|---|
//! | 小标题行 | `■ 只读口径清点(全部对外只读接口至少被真实调用一次)` | 53 |
//! | 检查项名 | `失败层次:工厂层(不认识编码)与产品层(规格被拒)可区分` | 56 |
//! | 检查项名 | `两份分派清单同步:「白名单有、构造器表没有」与反向都为空` | 56 |
//!
//! 取 58 = 56 + 2 列余量。**这个数字不是估的**:`main.rs` 的输出自查里有一条
//! 永久检查,它会拿**实际产出的全部报告标题与检查项名**算一遍最宽值,
//! 再与 [`CHECK_ITEM_COLUMN_WIDTH`] 比对。于是「列宽按可能值定」这句话
//! 从注释变成了机器每次运行都会验一次的不变量。
//!
//! ⚠️ 这一列走过一段弯路:原本是 40,后果是**一大片检查项名被显式省略成 `...`**
//! (不是静默截断------`elide_text` 会留下 `...`),报表虽然没坏,
//! 但读者读不到「这一条在查什么」。加宽到 58 的同时,还有三条**检查项名**
//! 被从 67 / 62 / 58 列压到 48 / 42 / 52 列:**名字该短,说明才该长**------
//! 一条检查的名字若长到五十多列,它就变成了句子,而句子应当放进「实测值」。
//! 这正是既有工程那条纪律在本层的又一次应用:**长说明移出单元格**。
//!
//! ### 「实测值」这一列为什么不可能做到零省略
//!
//! 它的内容来自**运行时数据**(清单、金额、快照摘要),
//! 长度在原理上没有上界------本工程实测最宽的一条是 **265 列**
//! (只读口径清点里的一次全量列举)。因此本列的策略是**可见省略 + 双保险**:
//!
//! ```text
//! 分析层:能压的先压(`elide_text`),压不住的交给排版层
//! 排版层:elide_text 到列宽,末尾留 `...`;render_cell 的断言兜底
//! 自查层:逐条核对「超预算的说明省略后宽度**恰好等于**列宽、且以 `...` 收尾」
//! ```
//!
//! 第三层是关键:它把「没有静默截断」这条不变量放到了**真实数据**上做回归,
//! 而不只是靠一个 `debug_assert` 保证「不崩」。
//! 断言保证的是「不会悄悄切掉」,自查保证的是「切掉之后看得见、且排得整齐」。
//!
//! ## 为什么「未通过」的项要重排在最前面
//!
//! 体检表的读者只有两种状态:全部通过(扫一眼就关)、有未通过(要一条条看)。
//! 把未通过项排在最前面,第二种状态下读者不必在几十行「通过」里找出那几行。
//! 这是纯粹的可用性考虑,但它决定了这张表能不能在真实场景里被用起来。
use crate::analysis::elide_text;
use crate::analysis::{CheckLine, CheckReport};
use crate::app::{section_header, TableColumn, TextTable};
/// 「检查项」列的宽度(显示列数)。
///
/// ## 为什么它是一个**公开常量**而不是各函数里的私有常量
///
/// 因为 `main.rs` 的输出自查要拿它做一件只能在这里定义的事:
/// **用实际产出的内容反算最宽值,再与它比对。**
/// 若它藏在函数体里,那条自查就无从下手,只能退化成「作者记得就行」。
///
/// 一个宽度常量被外部引用,听起来像是把排版细节漏了出去;
/// 但真正的排版细节(哪一列放哪个字段、怎么对齐)仍然留在本文件里。
/// 出去的只是一个**可被验证的上界**------这正好是本工程对「派生指标必须给分母」
/// 那条纪律在排版层的对应物。
pub const CHECK_ITEM_COLUMN_WIDTH: usize = 58;
/// 「实测值」列的宽度(显示列数)。
///
/// 与 [`CHECK_ITEM_COLUMN_WIDTH`] 同理对外公开,供 `main.rs` 的自查核对
/// 「分析层压到多少列」这件事是否与排版层的列宽一致。
pub const CHECK_DETAIL_COLUMN_WIDTH: usize = 54;
/// 渲染一张体检报表。
///
/// 参数 `title`:幕标题;`report`:体检报告;`width`:文档总宽。
/// 返回:文本行。
pub fn render_check_table(title: &str, report: &CheckReport, width: usize) -> Vec<String> {
/// 检查项列宽(见模块文档里的推导)。
const NAME_WIDTH: usize = CHECK_ITEM_COLUMN_WIDTH;
/// 结论列宽(`未通过` = 6 列)。
const CONCLUSION_WIDTH: usize = 8;
/// 实测值列宽。
const DETAIL_WIDTH: usize = CHECK_DETAIL_COLUMN_WIDTH;
let mut table: TextTable = TextTable::new(
format!("{} ------ {}({})", report.title(), report.summary_text(), title),
vec![
TableColumn::left("检查项", NAME_WIDTH),
TableColumn::center("结论", CONCLUSION_WIDTH),
TableColumn::left("实测值", DETAIL_WIDTH),
],
);
for line in ordered_lines(report) {
table = table.with_row(vec![
// 双保险:分析层已压过一次,这里再压一次。
// 若这里是唯一压住的地方,说明分析层漏了------但报表仍然正确,
// 只是那一格的说明被显式省略了(末尾有 `...`,看得见)。
elide_text(line.name(), NAME_WIDTH),
line.conclusion_text().to_string(),
elide_text(line.detail(), DETAIL_WIDTH),
]);
}
if report.all_passed() {
table = table.with_note(&format!(
"全部 {} 项通过。本表只在「有未通过项」时才有诊断工作可做------\
因此未通过的项会被重排到表首,便于在长表里定位。",
report.total_count()
));
} else {
table = table.with_note(&format!(
"⚠️ {} 项未通过(共 {} 项)。未通过项已重排到表首。\
每一条的「实测值」列都给出了判断依据,可据以定位。",
report.failed_count(),
report.total_count()
));
}
let mut lines: Vec<String> = section_header(title, width);
lines.extend(table.render());
lines
}
/// 渲染多份体检报告(用于把同一幕里的几份报告并排印出)。
///
/// 参数 `title`:幕标题;`reports`:报告清单;`width`:文档总宽。
/// 返回:文本行。
///
/// ## 为什么多份报告共用一张表
///
/// 因为读者要回答的是「整体上有没有问题」。分成几张表之后,
/// 每个表头各有一个「x/y 通过」,读者还得自己把分母加起来。
/// 合表之后,**最后一行给出总分母**,一屏之内就能下结论。
pub fn render_merged_check_table(
title: &str,
reports: &[CheckReport],
width: usize,
) -> Vec<String> {
/// 检查项列宽(与 [`render_check_table`] 同一常量,两张表视觉上必须一致)。
const NAME_WIDTH: usize = CHECK_ITEM_COLUMN_WIDTH;
/// 结论列宽。
const CONCLUSION_WIDTH: usize = 8;
/// 实测值列宽。
const DETAIL_WIDTH: usize = CHECK_DETAIL_COLUMN_WIDTH;
// 每份报告各占一段:先印报告标题行(它的「结论」格留空,
// 用表内的一行做小标题,避免为了分组再多加一列)。
let total: CheckReport = crate::analysis::merge_reports(title, reports);
let mut table: TextTable = TextTable::new(
format!("{} ------ {}", title, total.summary_text()),
vec![
TableColumn::left("检查项", NAME_WIDTH),
TableColumn::center("结论", CONCLUSION_WIDTH),
TableColumn::left("实测值", DETAIL_WIDTH),
],
);
for report in reports {
// 小标题行:三格分别是「■ 报告标题」「」(空)「」(空)。
// 空格的成因写在下面:合计行/小标题行必须与该表列数一致。
//
// ★ 这一格当初**漏了** `elide_text`,于是报告标题一超长就被
// `render_cell` 的断言当场抓住(这是断言发挥作用的正面例子)。
// 补上之后它是「显式省略」而不是「静默截断」------但真正的解法是
// 把列宽给够(见模块文档),省略只是兜底。
table = table.with_row(vec![
elide_text(&format!("■ {}", report.title()), NAME_WIDTH),
String::new(),
format!("{}/{} 通过", report.passed_count(), report.total_count()),
]);
for line in ordered_lines(report) {
table = table.with_row(vec![
elide_text(line.name(), NAME_WIDTH),
line.conclusion_text().to_string(),
elide_text(line.detail(), DETAIL_WIDTH),
]);
}
}
table = table.with_note(
"小标题行(以 `■` 开头的行)不是检查项,它的「实测值」格放的是该份报告自己的通过数;\
该行的「结论」格留空------**空串与「通过」在表格里不能混用**,\
留空是对「这一格不适用」的显式表达。",
);
let mut lines: Vec<String> = section_header(title, width);
lines.extend(table.render());
lines
}
/// 把结论重排成「未通过在前、通过在后」,各自保持原相对顺序。
///
/// 参数 `report`:体检报告。
/// 返回:重排后的结论引用列表。
///
/// ## 为什么用稳定分区而不是 `sort_by`
///
/// `sort_by` 需要在比较函数里表达「未通过 < 通过」,
/// 而那会引入「同一类内部怎么排」的不确定性(比较函数返回 `Equal` 时
/// `sort_by` 不保证稳定)。用两次过滤(先收未通过、再收通过)得到的顺序是
/// **完全确定的**:同类内部保持报告里的原始顺序。
fn ordered_lines(report: &CheckReport) -> Vec<&CheckLine> {
let mut ordered: Vec<&CheckLine> = Vec::with_capacity(report.total_count());
ordered.extend(report.lines().iter().filter(|line| !line.passed()));
ordered.extend(report.lines().iter().filter(|line| line.passed()));
ordered
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern 简单工厂模式 Creational Patterns 创建型模式
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/8 21:48
//!# User : geovindu
//!# Product : RustRover
//!# Project : simplefactorypattern
//!# File : layout.rs
//! # 定宽纯文本表 ------ 报表的排版内核
//!
//! ## 这一层要解决的问题只有一个:**让每一列真的对齐**
//!
//! Rust 的 `{:<20}` 按**字符数**补空格,而中文占 2 个显示列。
//! 于是 `format!("{:<20}", "贵金属纯度检测")` 得到的字符串,
//! 在终端里看起来比 `format!("{:<20}", "DIAMOND_GRADING")` **宽 4 列**。
//! 本工程的报表里中文、拉丁文、金额、日期混排,
//! 只要有一处用标准格式化填充,整列就会错位------
//! 而错位是**看得见的**,读者会立刻不信任整张表。
//!
//! 因此本模块的所有填充都走 [`crate::support`] 里的
//! `display_width` / `pad_left` / `pad_right` / `pad_center`,
//! **全工程禁止对含中文的表格单元使用 `{:<N}`**。
//!
//! ## 列宽怎么定:按「该列可能出现的最大宽度」,不按「大部分值多宽」
//!
//! 这是本模块最重要的一条纪律,来自既有工程的实测教训:
//! 有个工程里 18 处静默截断,其中 14 处是肉眼漏掉的------
//! 因为**截断之后各行仍然对齐,报表看起来完全正常**。
//!
//! 定列宽时要靠**机制**推可能值,而不是看几行样例:
//!
//! | 场景 | 为什么它是最宽的那行 |
//! |---|---|
//! | **合计行** | 金额累加之后位数最多(本工程合计 ¥4,560.50 比任何分项都宽) |
//! | 一格放**列表**的列 | 一次请求可同时命中多条规则,拼接串比单条长 |
//! | 带**中文名**的列 | 中文占 2 列,一个 6 字中文名就是 12 列 |
//! | 末尾的**说明文本** | 一句解释往往比所有数据行都宽------**应当移到表下的注解行** |
//!
//! 最后一条尤其重要:本模块因此提供 [`TextTable::with_note`],
//! 让长说明离开单元格。cell 里只留可比较的短标签。
//!
//! ## 静默截断的最后一道防线
//!
//! [`render_cell`] 里有两条断言,它们**永久保留**:
//!
//! 1. `debug_assert!(clipped == cell, ...)` ------ 列宽不够时当场 panic;
//! 2. `debug_assert!(!cell.contains('\n'), ...)` ------ 单元格里不许有换行。
//!
//! 之所以是 `debug_assert` 而不是 `assert`:release 构建下报表仍然能打出来
//! (宁可印一张略有瑕疵的表,也不要让整份报表崩掉),
//! 但开发期间 `cargo run` 默认是 debug 构建,问题会在第一次运行就暴露。
//!
//! ## 排查存量截断的正确手法:临时插桩,一次查全
//!
//! 若怀疑某张表有截断,**不要**靠「改一处、跑一次」逐个试------
//! 把 [`render_cell`] 里的断言临时换成
//! `eprintln!("width={} header={} need={} cell={}", ...)`,
//! 跑一次就能拿到**全部**位置。注意打印里必须带 `header`,
//! 否则结果无法按表归组。查完再把断言换回来。
use crate::support::{
display_width, horizontal_rule, pad_center, pad_left, pad_right, truncate_to_width, wrap_text,
};
/// 单元格的水平对齐方式。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Alignment {
/// 左对齐:文本列(编码、名称、说明)。
Left,
/// 右对齐:数值列(金额、件数、工作日)。数值右对齐之后,
/// **个位对个位、十位对十位**,读者扫一眼就能比大小。
Right,
/// 居中:短标签列(如「结论」)。
Center,
}
/// 一列的定义。
///
/// ## 为什么 `header` 是 `&'static str`
///
/// 表头是编译期常量,没有动态构造的需求。用 `&'static str` 有两个好处:
/// 一是 `const fn` 可以构造它,于是列定义可以写成模块级常量
/// (见各报表文件里的 `const COLUMNS: [TableColumn; N]`);
/// 二是**列定义不可能在运行期被改动**------宽度模型因此完全静态,
/// 不需要在渲染时重新计算。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TableColumn {
/// 表头文本。
header: &'static str,
/// 列宽(**显示列数**,不是字符数)。
width: usize,
/// 对齐方式。
alignment: Alignment,
}
impl TableColumn {
/// 构造一个左对齐列。
///
/// 参数 `header`:表头文本;`width`:显示列宽。
/// 返回:列定义。
pub const fn left(header: &'static str, width: usize) -> TableColumn {
TableColumn {
header,
width,
alignment: Alignment::Left,
}
}
/// 构造一个右对齐列。
///
/// 参数 `header`:表头文本;`width`:显示列宽。
/// 返回:列定义。
pub const fn right(header: &'static str, width: usize) -> TableColumn {
TableColumn {
header,
width,
alignment: Alignment::Right,
}
}
/// 构造一个居中列。
///
/// 参数 `header`:表头文本;`width`:显示列宽。
/// 返回:列定义。
pub const fn center(header: &'static str, width: usize) -> TableColumn {
TableColumn {
header,
width,
alignment: Alignment::Center,
}
}
/// 表头文本。
pub const fn header(&self) -> &'static str {
self.header
}
/// 列宽(显示列数)。
pub const fn width(&self) -> usize {
self.width
}
// ⚠️ 这里原本还有两个方法:`alignment()` 与 `header_fits()`。
// 两者都**没有一个调用点**,因此按本工程的纪律删掉了------
// 不是「以后可能有用就先留着」,而是「没有人调用的检查等于不存在」。
//
// · `alignment()`:`render_cell` 在同一个模块内直接读字段,
// 对外则一律走 `TableColumn::left/right/center` 构造,用不到访问器。
// · `header_fits()`:它的注释写着「用于体检」,但**没有那场体检**。
// 一句写在注释里的意图,比没有更糟------它让人以为这件事已经被核对过了。
// 真要恢复它,正确做法是**同时**加一条会失败的检查
// (例如「本工程每一列的表头都放得下」),而不是只把方法加回来。
}
/// 一张定宽表。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextTable {
/// 表标题。
title: String,
/// 列定义。
columns: Vec<TableColumn>,
/// 数据行。每行的单元数必须等于列数(`render` 会核对)。
rows: Vec<Vec<String>>,
/// 表下注解(长说明放这里,不放单元格)。
notes: Vec<String>,
}
impl TextTable {
/// 新建一张表。
///
/// 参数 `title`:表标题(接受 `&str` 与 `String` 两种形态);
/// `columns`:列定义。
/// 返回:空表。
///
/// ## 为什么标题参数是 `impl Into<String>`
///
/// 因为表标题经常需要**运行时拼装**------本工程里几乎每张表的标题都带着
/// 批次名、机制名或条数(如 `逐条送检结局(批次「标准演示批次」,共 14 条)`)。
/// 若签名是 `&str`,每个调用点都要写 `&format!(..)`,
/// 于是 `&` 与 `format!` 的配对成了纯粹的噪音;漏一个 `&`
/// 就是一次编译错误,而它与业务毫无关系。
///
/// 列定义仍然是 `Vec<TableColumn>` 而不是泛型:列的宽度模型必须是静态的
/// (见 [`TableColumn`] 的文档),这里不需要、也不应该有别的形态。
pub fn new(title: impl Into<String>, columns: Vec<TableColumn>) -> TextTable {
TextTable {
title: title.into(),
columns,
rows: Vec::new(),
notes: Vec::new(),
}
}
/// 追加一行(流式)。
///
/// 参数 `cells`:该行各单元的文本。
/// 返回:追加后的表。
///
/// ## 单元数不匹配时会怎样
///
/// 不 panic,而是在 [`TextTable::render`] 里报错为一条断言。
/// 理由与 [`render_cell`] 用 `debug_assert` 相同:
/// 报表崩掉比印出一张缺列的表更难排查。
/// 但**渲染时会立刻在 debug 构建下炸**,所以错误不会被带到交付。
pub fn with_row(mut self, cells: Vec<String>) -> TextTable {
self.rows.push(cells);
self
}
/// 追加表下注解(流式)。
///
/// 参数 `note`:注解文本(可长,渲染时自动折行)。
/// 返回:追加后的表。
pub fn with_note(mut self, note: &str) -> TextTable {
self.notes.push(note.to_string());
self
}
/// 表的总宽度(含竖线分隔符与两侧空格的显示列数)。
///
/// 公式:每列占 `width + 2`(左右各一个空格),竖线有 `列数 + 1` 条。
pub fn total_width(&self) -> usize {
self.columns.iter().map(|column| column.width + 2).sum::<usize>() + self.columns.len() + 1
}
/// 渲染成多行文本。
///
/// 结构:
/// ```text
/// 标题
/// ────────────────(与表同宽)
/// │ 表头 │ 表头 │
/// ├──────┴──────┤
/// │ 单元 │ 单元 │
/// └──────┴──────┘
/// 注解(自动折行)
/// ```
pub fn render(&self) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
let total_width: usize = self.total_width();
// 标题。
lines.push(self.title.clone());
// 标题下的分隔线:`horizontal_rule` 保证宽度恰好等于入参。
lines.push(horizontal_rule(total_width));
// 表头行。
let header_cells: Vec<String> = self
.columns
.iter()
.map(|column| {
// 表头自身也走 render_cell:它同样可能含中文。
// 表头列的"数据"就是表头文本本身。
render_cell(column.header(), column)
})
.collect();
lines.push(build_bordered_line(&header_cells, self.columns.len()));
// 表头与数据之间的一条横线。
lines.push(horizontal_rule(total_width));
// 数据行。
for row in &self.rows {
// 列数不匹配:debug 构建下立刻炸,release 下按「缺的补空、多的丢弃」处理。
debug_assert!(
row.len() == self.columns.len(),
"数据行单元数 {} 不等于列数 {}:{:?}",
row.len(),
self.columns.len(),
row
);
let cells: Vec<String> = self
.columns
.iter()
.enumerate()
.map(|(index, column)| {
let cell: &str = row.get(index).map(String::as_str).unwrap_or("");
render_cell(cell, column)
})
.collect();
lines.push(build_bordered_line(&cells, self.columns.len()));
}
// 收尾横线。
lines.push(horizontal_rule(total_width));
// 注解:按可用宽度折行后逐行输出。
//
// 首行与续行**用同一个宽度**(都是 `总宽 − 2`),因为注解不需要
// 像「· 说明」那样给首行留前缀位------`wrap_text` 传两个相同宽度即可。
// 缩进由这里统一加,避免「缩进加两次」导致续行比首行短 2 列。
for note in &self.notes {
let available: usize = total_width - 2;
let wrapped: Vec<String> = wrap_text(note, available, available, "");
for line in wrapped {
lines.push(format!(" {}", line));
}
}
lines
}
}
/// 渲染一个单元格:截断到列宽 → 按对齐方式补空格。
///
/// 参数 `cell`:单元原文;`column`:列定义。
/// 返回:宽度恰好为 `column.width()` 的字符串(正常情况下)。
///
/// ## 两条永久保留的断言
///
/// 见模块文档。这里强调一点:`debug_assert!(clipped == cell)` 的**价值不在
/// 运行时防呆,而在于把「列宽必须容纳可能值」这条纪律变成可执行的检查**。
/// 若只有注释,下一个人加一列时会照抄一个宽度了事;
/// 有断言之后,他在 debug 构建下第一次运行就会撞上。
///
/// ## ⚠️ 断言与插桩的分工(本工程实测出来的用法)
///
/// 断言的问题是「一次只报一处」:修好一个再跑,又冒出一个。
/// 本工程有 8 处独立截断(12 次事件),若用断言逐处修,就是 8 轮
/// 「改一处 → 编译 → 运行 → panic」。
///
/// 因此排查时把这条断言**临时换成 `eprintln!`**,打印
/// `width / header / need / cell`(**必须带 `header`**,否则拿到一堆位置
/// 也认不出属于哪张表),一次跑出全部位置;改完再换回断言。
///
/// 两者不可互相替代:
///
/// | 形态 | 用途 | 生命周期 |
/// |---|---|---|
/// | `eprintln!` 插桩 | **批量排查**(一次报全) | 临时,改完必须撤掉 |
/// | `debug_assert!` | **长期纪律**(每次 debug 构建都执行) | 永久 |
fn render_cell(cell: &str, column: &TableColumn) -> String {
// 单元格里不许有换行:换行会让「一行」在终端里变成两行,
// 而定宽表的所有宽度计算都是按「一行」算的。
// 需要多行文本时,应当用注解行(有 `wrap_text` 处理)。
debug_assert!(
!cell.contains('\n'),
"单元格含换行符,定宽表无法处理(列「{}」):{:?}",
column.header(),
cell
);
let clipped: String = truncate_to_width(cell, column.width);
// ★ 永久保留的断言:**列宽必须容纳该列可能出现的内容**。
//
// 这一条曾经以「临时插桩」的形态存在过一轮(打印 `width / header / need / cell`),
// 一次跑出全部 8 处截断点;改完列宽之后它必须换回断言,因为
// **插桩是排查工具,断言才是纪律**:
//
// - 插桩只在有人记得加它的时候才存在,跑完就没了;
// - 断言会被每一次 debug 构建执行,将来新增一列时**第一次运行就撞上**。
//
// 断言文本里必须同时给出「列名 / 列宽 / 内容宽度 / 内容」四项:
// 少了列名,一堆位置认不出属于哪张表;少了内容,还得回去翻源码。
debug_assert!(
clipped == cell,
"表格单元被静默截断:列「{}」宽 {},内容宽 {},内容「{}」",
column.header(),
column.width(),
display_width(cell),
cell
);
match column.alignment {
Alignment::Left => pad_right(&clipped, column.width),
Alignment::Right => pad_left(&clipped, column.width),
Alignment::Center => pad_center(&clipped, column.width),
}
}
/// 把已渲染好的单元拼成一行带竖线的文本。
///
/// 参数 `cells`:已渲染的单元(每个宽度已等于列宽);`column_count`:列数。
/// 返回:形如 `│ 单元 │ 单元 │` 的一行。
///
/// ## 为什么用竖线而不是纯空格分隔
///
/// 纯空格分隔在数据行长短不一时,读者要靠数空格判断列边界。
/// 竖线让边界在任何一行都是可见的------**这是可读性,也是可核对性**:
/// 读者能一眼确认「这一格的右边界在哪」,从而判断某个数字有没有被截断。
fn build_bordered_line(cells: &[String], column_count: usize) -> String {
debug_assert!(
cells.len() == column_count,
"已渲染单元数 {} 不等于列数 {}",
cells.len(),
column_count
);
let mut line: String = String::from("│");
for cell in cells {
line.push(' ');
line.push_str(cell);
line.push_str(" │");
}
line
}
/// 把一个量换算成占分母的万分点(占比的整数口径)。
///
/// 参数 `value`:被除数;`total`:分母。
/// 返回:万分点;分母为 0 时返回 0。
///
/// ## 为什么单独有一个「先拿数字再渲染」的入口
///
/// 因为报表需要**先拿到数字、再决定怎么印**。典型场景是合计行:
/// 各行的百分比四舍五入之后,其和往往不是 100.00%
/// (本工程实测 50.00% + 35.71% + 14.28% = 99.99%)。
/// 要判断这一点,就必须拿到万分点本身,而不是去解析渲染好的字符串------
/// **把渲染结果当数据用**是最容易出错的一类做法。
///
/// 渲染仍然只有一处:[`percent_text`](内部走 `Rate::as_percent_text`)。
pub fn share_basis_points(value: i64, total: i64) -> i64 {
if total == 0 {
return 0;
}
// i128 中间量防溢出:本工程的量都很小,但这是对整数乘除的通用防线。
(value as i128 * 10_000 / total as i128) as i64
}
/// 把万分点渲染成百分比文本(如 `50.00%`)。
///
/// 参数 `basis_points`:万分点。
/// 返回:百分比文本。
///
/// ## 为什么绕一圈用 `domain::Rate`
///
/// 因为百分比只有一个拼法。本模块自己拼一个 `{}%` 也能跑,
/// 但那样「同一个比例在两张表上显示成不同字符串」这条缺陷就重新有了入口------
/// 而本工程要求两次运行逐字节一致,任何一处不一致的渲染都会直接破坏验收。
pub fn percent_text(basis_points: i64) -> String {
crate::domain::Rate::from_basis_points(basis_points).as_percent_text()
}
/// 生成一个分节标题(用于把多张表分成「幕」)。
///
/// 参数 `text`:标题文本;`width`:总宽度。
/// 返回:标题行 + 分隔线。
///
/// ## 为什么分隔线宽度由调用方给,而不是按标题长度自动算
///
/// 若按标题长度自动算,那么不同标题下的分隔线长度会不一样,
/// 整个文档看起来像是几份拼起来的。**统一宽度**是排版约定,
/// 由调用方(`main.rs` 的编排)按最宽的那张表决定一次即可。
pub fn section_header(text: &str, width: usize) -> Vec<String> {
/// 分节标题前的装饰前缀宽度(`■ ` 为 2 列)。
const PREFIX: &str = "■ ";
/// 标题与右侧留白的最小间距。
const MINIMUM_TRAILING_SPACE: usize = 4;
let title_width: usize = display_width(text) + display_width(PREFIX);
// 断言而不是「静默裁掉」:标题比文档还宽说明宽度预算算错了,
// 应当修的是调用方给的 `width`,而不是把标题切一半。
debug_assert!(
title_width + MINIMUM_TRAILING_SPACE <= width,
"分节标题过宽:标题「{}」需要 {} 列(含前缀),可用 {} 列",
text,
title_width,
width
);
let trailing_space: usize = width.saturating_sub(title_width);
vec![
format!("{}{}{}", PREFIX, text, " ".repeat(trailing_space)),
horizontal_rule(width),
]
}
/// 生成一行「标签:值」的键值行(用于表格之前的概要信息)。
///
/// 参数 `label`:标签;`value`:值;`width`:总宽度。
/// 返回:一行文本。
///
/// ## 为什么标签要补齐到固定宽度
///
/// 概要信息通常是连续几行(「批次」「受理日」「币种」),
/// 若标签不补齐,冒号就会参差不齐,读者扫视时要重新定位。
/// 补齐到 14 显示列是本工程的约定------**四到六个汉字 + 冒号**,
/// 足够容纳本工程全部标签。
pub fn key_value_line(label: &str, value: &str, width: usize) -> String {
/// 标签区宽度(含冒号)。
const LABEL_WIDTH: usize = 14;
let label_text: String = pad_right(&format!("{}:", label), LABEL_WIDTH);
let value_budget: usize = width.saturating_sub(LABEL_WIDTH).saturating_sub(2);
// 值同样要设上界:概要行的值可能是一串编码。
let clipped_value: String = crate::analysis::elide_text(value, value_budget);
format!(" {}{}", label_text, clipped_value)
}
/// 生成一行注解(用于表格之外的长说明)。
///
/// 参数 `text`:注解文本;`width`:总宽度。
/// 返回:折行后的多行文本。
///
/// ## 为什么注解要折行而不是截断
///
/// 注解承担的是「解释这张表怎么读」的职责,被截断之后就失去了价值。
/// 折行的代价是占几行版面,收益是**读者能读到完整的一句解释**。
///
/// 首行与续行用同一个宽度(这里没有「· 」前缀的问题),
/// 续行前缀用两个空格,与缩进对齐。
pub fn note_lines(text: &str, width: usize) -> Vec<String> {
/// 注解的缩进宽度。
const INDENT: usize = 2;
let available: usize = width.saturating_sub(INDENT);
wrap_text(
text,
available,
available.saturating_sub(INDENT),
&" ".repeat(INDENT),
)
.into_iter()
.map(|line| format!("{}{}", " ".repeat(INDENT), line))
.collect()
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern 简单工厂模式 Creational Patterns 创建型模式
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/8 21:48
//!# User : geovindu
//!# Product : RustRover
//!# Project : simplefactorypattern
//!# File : outcome_report.rs
//! # 结局与账本报表 ------ 「发生了什么」那一面
//!
//! ## 三张表,三个粒度
//!
//! | 表 | 粒度 | 回答的问题 |
//! |---|---|---|
//! | 逐条送检结局 | 每张送检单一行 | 这条单子最后怎么了? |
//! | 创建账本 | 每类结局一行 | 成功/被拒/未知编码各多少笔? |
//! | 归组明细 | 每个键一行 | 是哪个编码、哪条规则? |
//!
//! 三个粒度都要有。只印总数时读者无法定位问题单据;
//! 只印逐条明细时读者要自己数一遍才知道「一共被拒了几笔」------
//! 而**让读者自己数**正是报表该避免的事。
//!
//! ## 为什么失败原因不放进「逐条结局」表的单元格
//!
//! 失败说明可以很长。举一个本工程真实的例子:
//!
//! ```text
//! 未知编码「PEARL_AUTHENTICATION」:本创建者只认识 [DIAMOND_GRADING / GEMSTONE_IDENTIFICATION / ...]
//! ```
//!
//! 这类文本一旦进单元格,列宽就要按最长的那条定;
//! 而它出现得极少,等于**为一条罕见的长文本把整张表撑宽**。
//! 本工程的处理是:单元格里只留**短标签**(规则编码),
//! 完整说明放到表下的注解行([`TextTable::with_note`] 会自动折行)。
//! 这条纪律来自既有工程的教训------长说明撑破列宽是最常见的
//! 「看起来正常但其实被截断」的来源。
//!
//! ## 金额列的宽度是怎么定的
//!
//! 本批 7 张成功委托单里最贵的是素金 3 件加急(¥1,482.00,9 列)。
//! 但列宽**不能按本批的最贵值定**,要按**该列可能出现的最大宽度**定:
//! 合计行会累加,而更大的批次会更大。因此取 12 列
//! (`¥123,456.00` 也放得下)。这是「按机制推可能值」而不是「看样例」的
//! 一个具体例子。
use crate::analysis::elide_text;
use crate::app::{
key_value_line, note_lines, percent_text, section_header, TableColumn, TextTable,
};
use crate::client::{CreationOutcome, WorkbenchRun};
/// 逐条送检结局表。
///
/// 参数 `run`:一次运行结果;`width`:文档总宽。
/// 返回:文本行。
pub fn render_outcome_table(run: &WorkbenchRun, width: usize) -> Vec<String> {
/// 样品编号列宽(`S-` + 12 位十六进制 = 14 列)。
const SAMPLE_CODE_WIDTH: usize = 14;
/// 请求标签列宽。
const LABEL_WIDTH: usize = 20;
/// 检测类型编码列宽(最长 `GEMSTONE_IDENTIFICATION` = **23** 列,取 24 留 1 列余量)。
///
/// ⚠️ 这里原本写的依据是「= 22」,**数错了**,于是最长那个编码
/// 一直被静默截断(少一个字母仍与相邻列对齐,肉眼看不出来)。
/// 编码长度是「该列可能出现的最宽值」里唯一可枚举的一项,数错一次就会长期潜伏,
/// 因此 `main.rs` 的编码清单自查里也把最长编码的宽度印了出来。
const ORDER_CODE_WIDTH: usize = 24;
/// 结论列宽(最长 `未知编码` = 8)。
const CONCLUSION_WIDTH: usize = 8;
/// 规则编码列宽(最长 `MISSING_REQUIRED_PARAMETER` = 26)。
const RULE_CODE_WIDTH: usize = 26;
/// 金额列宽(见模块文档:按可能值定,不按本批最贵值)。
const FEE_WIDTH: usize = 12;
let mut table: TextTable = TextTable::new(
format!("逐条送检结局(批次「{}」,共 {} 条)", run.batch_name(), run.request_count()),
vec![
TableColumn::left("样品编号", SAMPLE_CODE_WIDTH),
TableColumn::left("请求标签", LABEL_WIDTH),
TableColumn::left("检测类型", ORDER_CODE_WIDTH),
TableColumn::center("结论", CONCLUSION_WIDTH),
TableColumn::left("规则编码", RULE_CODE_WIDTH),
TableColumn::right("合计费用", FEE_WIDTH),
],
);
// 逐条按样品编号排序打印:顺序由内容决定,与批次内的排列顺序无关。
for outcome in run.outcomes_sorted_by_sample_code() {
let fee_text: String = match outcome.outcome() {
CreationOutcome::Created(snapshot) => snapshot.total_fee_text(),
// 失败的单子没有费用。用 `---` 而不是 `¥0.00`:
// 「¥0.00」看起来像「这张单免费」,而事实是「这张单没被造出来」。
CreationOutcome::Failed(_) => "---".to_string(),
};
table = table.with_row(vec![
outcome.sample_code().to_string(),
elide_text(outcome.label(), LABEL_WIDTH),
outcome.order_code().code().to_string(),
outcome.outcome().conclusion_text().to_string(),
outcome.outcome().rule_code().to_string(),
fee_text,
]);
}
// 表下注解:失败逐条的可读说明。
// 只有失败才需要解释,因此只对失败生成注解。
for outcome in run.outcomes_sorted_by_sample_code() {
if outcome.outcome().is_created() {
continue;
}
table = table.with_note(&format!(
"· {}({}){}",
outcome.sample_code(),
outcome.label(),
outcome
.outcome()
.detail_text()
));
}
table = table.with_note(
"「规则编码」列是稳定的统计键(成功也给了编码 `CREATED`,使这一列可穷尽)。\
完整说明见上方的逐条注解------失败原因往往很长,放进单元格会把整张表撑宽。",
);
let mut lines: Vec<String> = section_header("幕三·逐条送检结局", width);
lines.extend(table.render());
lines
}
/// 创建账本表。
///
/// 参数 `run`:一次运行结果;`width`:文档总宽。
/// 返回:文本行。
///
/// ## 为什么「尝试总数」是从三个分项求和得来的
///
/// 账本自己就是这么算的([`crate::factory::CreationLedger::attempted_count`])。
/// 报表这里再列一遍,作用是让读者能**当场核对**:
/// `7 + 5 + 2 = 14`,而 14 恰好等于送检单条数。
/// 若这三个数之和与批次条数不等,说明有请求既没成功也没被记失败------
/// 那是一条应当被体检抓出来的缺口,而这张表就是它的第一道可见性。
pub fn render_ledger_table(run: &WorkbenchRun, width: usize) -> Vec<String> {
/// 指标列宽。
const METRIC_WIDTH: usize = 18;
/// 笔数列宽。
const COUNT_WIDTH: usize = 8;
/// 占比列宽。
const SHARE_WIDTH: usize = 12;
let ledger = run.creator_ledger();
let attempted: u32 = ledger.attempted_count();
let mut table: TextTable = TextTable::new(
format!("创建账本(机制:{})", run.mechanism_name()),
vec![
TableColumn::left("指标", METRIC_WIDTH),
TableColumn::right("笔数", COUNT_WIDTH),
TableColumn::right("占尝试总数", SHARE_WIDTH),
],
);
// 三个分项 + 一条合计。
//
// ★ 合计行的「笔数」格 = 三个分项之和(这就是「合计行只能对列求和」)。
// 但**占比格不能填 100.00%** ------ 见下面的注释与注解。
let share_points: [i64; 3] = [
share_basis_points(ledger.created_count(), attempted),
share_basis_points(ledger.rejected_count(), attempted),
share_basis_points(ledger.unknown_code_count(), attempted),
];
let share_points_sum: i64 = share_points.iter().sum();
table = table.with_row(vec![
"成功创建".to_string(),
ledger.created_count().to_string(),
percent_text(share_points[0]),
]);
table = table.with_row(vec![
"产品侧拒绝".to_string(),
ledger.rejected_count().to_string(),
percent_text(share_points[1]),
]);
table = table.with_row(vec![
"未知编码".to_string(),
ledger.unknown_code_count().to_string(),
percent_text(share_points[2]),
]);
table = table.with_row(vec![
"尝试总数(合计)".to_string(),
attempted.to_string(),
// ⚠️ 这一格刻意填 `---` 而不是 `100.00%`。
//
// 因为三行占比各自四舍五入之后,其和往往不是 100.00%
// (本批是 50.00% + 35.71% + 14.28% = 99.99%)。
// 若合计行印 100.00%,任何读者按计算器都会认为**表算错了**------
// 而这正是既有工程踩过的坑:「合计行只能做一件事,就是对每一列求和」,
// 一旦某一列印的是不同口径的量,求和关系立刻被破坏。
"---".to_string(),
]);
table = table.with_note(&format!(
"尝试总数 = 成功 + 被拒 + 未知编码 = {},而本批送检单共 {} 条------两者相等说明每条请求都被记了账。\
三个分项各自独立累加,**不用减法推导**:本工程有两条失败路径,减法在有两条以上失败路径时会失真,\
且失真的方式(数字看起来还是很合理)最难发现。",
attempted,
run.request_count()
));
table = table.with_note(&format!(
"⚠️ 合计行的占比格填「---」而不是 100.00%:三行占比各自四舍五入后之和为 {}(非 100.00%)。\
若合计行硬填 100.00%,读者按计算器就会认为表算错了。这是「合计行只能对列求和」这条纪律的一个真实陷阱------\
笔数那一列可以求和,占比那一列**不能**。",
percent_text(share_points_sum)
));
let mut lines: Vec<String> = vec![String::new()];
lines.extend(table.render());
lines.extend(note_lines(
"合计行只对**可以求和的列**求和:「笔数」列填分项之和;\n\
而「占尝试总数」列填「---」------三个分项四舍五入后是\n\
50.00% + 35.71% + 14.28% = 99.99%,若把它印成 100.00%,\n\
任何按计算器核对的读者都会认为这张表算错了。**百分比列不能求和**,\n\
这条纪律在本工程的每一张表上都成立,且和值会被写进表下注解供读者核对。",
width,
));
lines
}
/// 归组明细表。
///
/// 参数 `run`:一次运行结果;`width`:文档总宽。
/// 返回:文本行。
///
/// ## 为什么三个维度共用一张表
///
/// 「按编码的成功数」「按规则的拒绝数」「未知编码」三者结构完全相同
/// (维度 + 键 + 笔数),拆成三张表会让报表多出三个表头和三条分隔线,
/// 而读者要对照的是「哪一类失败最多」------那需要它们挤在同一张表里。
///
/// 用一列 `维度` 区分,而不是把它们混在一起不加区分:
/// **同类数据可以合表,不同类数据必须可区分**。
pub fn render_ledger_breakdown_table(run: &WorkbenchRun, width: usize) -> Vec<String> {
/// 维度列宽(最长 `未知编码` = 8)。
const DIMENSION_WIDTH: usize = 10;
/// 键列宽(最长 `MISSING_REQUIRED_PARAMETER` = 26)。
const KEY_WIDTH: usize = 26;
/// 笔数列宽。
const COUNT_WIDTH: usize = 8;
let ledger = run.creator_ledger();
let mut table: TextTable = TextTable::new(
"归组明细(成功按编码、拒绝按规则、未知按编码)",
vec![
TableColumn::left("维度", DIMENSION_WIDTH),
TableColumn::left("键", KEY_WIDTH),
TableColumn::right("笔数", COUNT_WIDTH),
],
);
let mut row_count: usize = 0;
// 成功:账本里的明细顺序是「首次成功的先后」,稳定,可直接用。
for (code, count) in ledger.created_by_code() {
table = table.with_row(vec![
"成功·编码".to_string(),
code.code().to_string(),
count.to_string(),
]);
row_count += 1;
}
// 拒绝:**必须排序**(`rejected_by_rule_sorted`)。
// 账本内部保留的是「首次出现的先后」,那个顺序取决于送检单的排列,
// 直接打印会让报表的行序跟着数据文件一起变。
for (rule, count) in ledger.rejected_by_rule_sorted() {
table = table.with_row(vec![
"拒绝·规则".to_string(),
rule.to_string(),
count.to_string(),
]);
row_count += 1;
}
// 未知编码:去重 + 排序,且笔数要单独数。
// 为什么去重:同一个错编码被请求 3 次应当显示成一行(3 笔),
// 而不是三行各 1 笔------后者会让读者以为错了三个不同的编码。
for code in ledger.distinct_unknown_codes_sorted() {
let occurrences: usize = ledger
.unknown_codes()
.iter()
.filter(|candidate| **candidate == code)
.count();
table = table.with_row(vec![
"未知·编码".to_string(),
code,
occurrences.to_string(),
]);
row_count += 1;
}
table = table.with_note(&format!(
"共 {} 个归组键;三段的笔数之和应等于尝试总数 {}。\
排序是必需的:账本内部保留的是「首次出现的先后」,那个顺序取决于送检单的排列顺序,\
直接打印会让报表行序跟着数据文件一起变,破坏两次运行逐字节一致。",
row_count,
ledger.attempted_count()
));
let mut lines: Vec<String> = vec![String::new()];
lines.extend(table.render());
lines.extend(note_lines(
"归组的键必须**排序后**输出(拒绝明细走 `rejected_by_rule_sorted()`),\n\
否则同一份数据在不同运行下会换行序,直接破坏「两次运行逐字节一致」。\n\
未知编码则**去重后单独数笔数**:同一个错字出现两次\n\
与出现两个不同的错字,是两种不同的运维问题------前者是表单校验没拦住,\n\
后者是编码表没维护好。",
width,
));
lines
}
/// 把计数换算成万分点(占比的整数口径)。
///
/// 这是 [`crate::app::share_basis_points`] 的薄包装,
/// 只是把 `u32` 计数转成 `i64`------账本里的计数是 `u32`,
/// 而共用入口按 `i64` 定义(金额也是 `i64`,同一个入口能服务两者)。
fn share_basis_points(count: u32, total: u32) -> i64 {
crate::app::share_basis_points(count as i64, total as i64)
}
/// 渲染批次概要与运行元信息。
///
/// 参数 `run`:一次运行结果;`width`:文档总宽。
/// 返回:文本行。
///
/// 这些内容用「标签:值」的行而不进表格:它们是**该批次的元信息**
/// (批次名、受理日、机制名、分派表是否被改动),量少且长度不一,
/// 做成表格反而要为一个「受理日」浪费一整列。
pub fn render_run_summary(run: &WorkbenchRun, width: usize) -> Vec<String> {
vec![
key_value_line("批次", run.batch_name(), width),
key_value_line("受理日", &run.accepted_on().formatted(), width),
key_value_line("机制", run.mechanism_name(), width),
key_value_line("请求条数", &run.request_count().to_string(), width),
key_value_line(
"分派表是否被本次运行改动",
if run.dispatch_table_unchanged() {
"否(运行前后清单一致)"
} else {
"是(异常:驱动过程不应改动分派表)"
},
width,
),
]
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern 简单工厂模式 Creational Patterns 创建型模式
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/8 21:48
//!# User : geovindu
//!# Product : RustRover
//!# Project : simplefactorypattern
//!# File : profile_report.rs
//! # 产能与成本报表 ------ 实验室与财务看到的两面
//!
//! ## 为什么这两面必须分开印
//!
//! 它们回答的不是同一个问题:
//!
//! | 视角 | 口径 | 谁在看 |
//! |---|---|---|
//! | 产能 | **占用**:这张单要多少工时、什么时候交 | 排产、实验室主管 |
//! | 成本 | **收费**:这张单收了多少钱 | 财务、客户 |
//!
//! 两个口径**不能互相替代**,而且替代之后不会报错:
//! 一张加急的钻石分级收费可能很高,但它占用的工时反而比一件
//! 需要取样的宝石鉴定少(取样是不可逆操作,工时里有不可压缩的部分)。
//! 用金额排产会错得离谱,而且错得没有任何报警。
//!
//! ## 列宽一律按「该列可能出现的最大宽度」定
//!
//! 本文件里的宽度有两个容易定错的地方,都值得写下来:
//!
//! 1. **金额列**:本批最贵的一张是 ¥1,482.00(9 列),
//! 但合计行会累加。若按 9 列定,合计一超过十万就会撞上断言。
//! 本文件取 14 列(`¥123,456,789.00` 也放得下)。
//! 2. **「跳过周末」列**:一格放的是**列表**而不是单条。
//! 5 个工作日的排期会跳过 2 个周末,文本是
//! `9 月 5 日(六)、9 月 6 日(日)`(32 列)。
//! 更长的工作日天数会跳过更多------因此取 34 列。
//!
//! 「一格可能放列表」是既有工程总结出的三条定宽纪律之一,
//! 也是最容易漏的一条:单看某几行数据,它永远只有一两个元素。
use crate::analysis::fee_text;
use crate::analysis::{CapacityProfile, CostProfile};
use crate::app::{
note_lines, percent_text, section_header, share_basis_points, TableColumn, TextTable,
};
/// 产能·按检测类型分组表。
///
/// 参数 `profile`:产能画像;`width`:文档总宽。
/// 返回:文本行。
pub fn render_capacity_table(profile: &CapacityProfile, width: usize) -> Vec<String> {
/// 分组键列宽(最长 `GEMSTONE_IDENTIFICATION` = **23** 列,取 24 留 1 列余量)。
///
/// ⚠️ 原注释写「= 22」是**数错了**(8 + `_` + 14 = 23)。
/// 数错的后果是:分组键这一格被静默截断,而表仍然对齐、看不出异常。
const KEY_WIDTH: usize = 24;
/// 展示名列宽(最长 `贵金属纯度检测` = 14;部门名更短)。
const NAME_WIDTH: usize = 16;
/// 张数列宽。
const COUNT_WIDTH: usize = 6;
/// 件数列宽。
const PIECE_WIDTH: usize = 6;
/// 工作量列宽(表头 `工作量单元` = 10)。
const WORKLOAD_WIDTH: usize = 12;
/// 平均承诺列宽(`2.5 天` 加余量)。
const AVERAGE_WIDTH: usize = 12;
let mut table: TextTable = TextTable::new(
"产能·按检测类型分组(工作量 = 项目数×10 + 件数×2 + 破坏性附加20 + 加急附加10)",
vec![
TableColumn::left("检测类型", KEY_WIDTH),
TableColumn::left("中文名", NAME_WIDTH),
TableColumn::right("张数", COUNT_WIDTH),
TableColumn::right("件数", PIECE_WIDTH),
TableColumn::right("工作量单元", WORKLOAD_WIDTH),
TableColumn::right("平均承诺", AVERAGE_WIDTH),
],
);
for bucket in profile.by_order_code() {
table = table.with_row(vec![
bucket.order_code().to_string(),
bucket.order_chinese_name().to_string(),
bucket.order_count().to_string(),
bucket.piece_total().to_string(),
bucket.workload_total().to_string(),
bucket.average_promised_days_text(),
]);
}
// 合计行:**只对可以求和的列求和**。
//
// 「平均承诺」不是可以求和的量,因此它印的是**加权平均**
// (Σ承诺天数 ÷ Σ张数),并在注解里说明------不能印成各行平均值之和,
// 也不能留空(留空读者会以为漏了)。
table = table.with_row(vec![
"合计".to_string(),
format!("{} 类", profile.by_order_code().len()),
profile.total_orders().to_string(),
profile.total_pieces().to_string(),
profile.total_workload().to_string(),
profile.weighted_average_promised_days_text(),
]);
table = table.with_note(&format!(
"「件数」与「工作量单元」两列可以逐行求和得到合计;「平均承诺」不能------\
合计格印的是**加权平均**:Σ承诺工作日 {} 天 ÷ Σ张数 {} 张 ≈ {}(远离零方向四舍五入到十分位)。\
⚠️ 分子与分母都写在这里,是因为它们**都没有单独成列**:\
读者若要复算,只能靠这条注解里的两个数。\
本工程的纪律是「合计行只能对可以求和的列求和」,不可求和的列要么印派生值**并给出分母**,\
要么印「---」。",
profile.by_order_code().iter().map(|bucket| bucket.promised_days_total()).sum::<u32>(),
profile.total_orders(),
profile.weighted_average_promised_days_text(),
));
let mut lines: Vec<String> = section_header("幕七·排期与产能画像", width);
lines.extend(table.render());
lines
}
/// 产能·按部门分组表。
///
/// 参数 `profile`:产能画像;`width`:文档总宽。
/// 返回:文本行。
///
/// 复用 `analysis::CapacityBucket` 这一种结构:部门与检测类型在统计上
/// 是**同一种东西**(一个分组键 + 一组数值),换一个键就换了一个视角。
/// 这也解释了为什么 [`crate::analysis::build_capacity_profile`]
/// 里两种分组共用同一个累加函数。
pub fn render_department_table(profile: &CapacityProfile, width: usize) -> Vec<String> {
/// 部门列宽。
const DEPARTMENT_WIDTH: usize = 14;
/// 张数列宽。
const COUNT_WIDTH: usize = 6;
/// 件数列宽。
const PIECE_WIDTH: usize = 6;
/// 工作量列宽。
const WORKLOAD_WIDTH: usize = 12;
/// 平均承诺列宽。
const AVERAGE_WIDTH: usize = 12;
let mut table: TextTable = TextTable::new(
"产能·按承接部门分组",
vec![
TableColumn::left("承接部门", DEPARTMENT_WIDTH),
TableColumn::right("张数", COUNT_WIDTH),
TableColumn::right("件数", PIECE_WIDTH),
TableColumn::right("工作量单元", WORKLOAD_WIDTH),
TableColumn::right("平均承诺", AVERAGE_WIDTH),
],
);
for bucket in profile.by_department() {
table = table.with_row(vec![
bucket.department().to_string(),
bucket.order_count().to_string(),
bucket.piece_total().to_string(),
bucket.workload_total().to_string(),
bucket.average_promised_days_text(),
]);
}
table = table.with_row(vec![
"合计".to_string(),
profile.total_orders().to_string(),
profile.total_pieces().to_string(),
profile.total_workload().to_string(),
profile.weighted_average_promised_days_text(),
]);
table = table.with_note(&format!(
"两条分组路径(按类型、按部门)给出的总计必须相同------本批都是 {} 张 / {} 单元。\
两条路径来自同一批快照但走不同的分组键,因此「相等」是一个真实的核对,不是重复计算。",
profile.total_orders(),
profile.total_workload()
));
let mut lines: Vec<String> = vec![String::new()];
lines.extend(table.render());
lines.extend(note_lines(
"部门分组与检测类型分组共用同一种结构(`CapacityBucket`):\n\
一个分组键 + 一组数值。因此本表与上一张表的唯一区别是「键取什么」------\n\
这正是「按部门排产」与「按类型排产」这两个视角的关系;\n\
也解释了为什么 `analysis` 层两种分组共用同一个累加函数。",
width,
));
lines
}
/// 排期明细表。
///
/// 参数 `profile`:产能画像;`width`:文档总宽。
/// 返回:文本行。
///
/// ## 为什么「跳过周末」必须成一列
///
/// 排期结果是「受理日 + N 个工作日」,而不是「受理日 + N 天」。
/// 客户拿到出证日时会觉得「怎么多了两天」,这一列就是那两天的**证据**:
/// `9 月 5 日(六)、9 月 6 日(日)`。
/// 只印最终日期的话,读者无法判断该不该相信它------
/// 而「可核查性优先于结构简洁」是本工程的一贯口径。
pub fn render_schedule_table(profile: &CapacityProfile, width: usize) -> Vec<String> {
/// 样品编号列宽(定长 14)。
const SAMPLE_CODE_WIDTH: usize = 14;
/// 检测类型列宽(最长 `GEMSTONE_IDENTIFICATION` = **23** 列,取 24 留 1 列余量)。
const ORDER_CODE_WIDTH: usize = 24;
/// 日期列宽(`2026-09-08` = 10 列 + 余量)。
const DATE_WIDTH: usize = 12;
/// 承诺工作日列宽(表头 10 列)。
const PROMISED_WIDTH: usize = 12;
/// 日历跨度列宽(表头 10 列)。
const SPAN_WIDTH: usize = 10;
/// 跳过周末列宽(见模块文档:一格放列表)。
const SKIPPED_WIDTH: usize = 34;
let mut table: TextTable = TextTable::new(
format!(
"排期明细(受理日 {},全批次共用)",
profile.accepted_on_text()
),
vec![
TableColumn::left("样品编号", SAMPLE_CODE_WIDTH),
TableColumn::left("检测类型", ORDER_CODE_WIDTH),
TableColumn::left("出证日", DATE_WIDTH),
TableColumn::right("承诺工作日", PROMISED_WIDTH),
TableColumn::right("日历跨度", SPAN_WIDTH),
TableColumn::left("期间跳过的周末", SKIPPED_WIDTH),
],
);
for line in profile.schedule_lines() {
table = table.with_row(vec![
line.sample_code().to_string(),
line.order_code().to_string(),
line.delivery_on_text().to_string(),
line.promised_working_days().to_string(),
line.calendar_span_days().to_string(),
line.skipped_days_text().to_string(),
]);
}
table = table.with_note(&format!(
"共 {} 张。受理日是周四(刻意选的)------若选周五,所有排期都会跨周末,\
反而看不出「工作日推进」与「日历天推进」的差别。\
最晚出证日:{}。",
profile.schedule_lines().len(),
profile.latest_delivery_on_text()
));
table = table.with_note(
"「承诺工作日」与「日历跨度」并排印出来,是为了让客户看懂口径差:\
承诺 3 个工作日可能跨 5 个日历天,那两天是周末,不是实验室拖延。",
);
let mut lines: Vec<String> = vec![String::new()];
lines.extend(table.render());
lines.extend(note_lines(
"「检测类型」这一列是必需的:只看样品编号无法分辨这一行是哪一类检测,\
而「哪一类检测在哪天出证」正是排产最关心的信息。\
「期间跳过的周末」是**列表格**(一天一个日期),因此该列宽度按\
「本批可能出现的最多跳过天数 + 每天 10 列」定,而不是按本批实际值定。",
width,
));
lines
}
/// 成本·费用构成表(按检测类型)。
///
/// 参数 `profile`:成本画像;`width`:文档总宽。
/// 返回:文本行。
pub fn render_cost_composition_table(profile: &CostProfile, width: usize) -> Vec<String> {
/// 检测类型列宽。
const KEY_WIDTH: usize = 24;
/// 张数列宽。
const COUNT_WIDTH: usize = 6;
/// 金额列宽(见模块文档:14 列,按可能值而不是本批最贵值)。
const AMOUNT_WIDTH: usize = 14;
/// 加急费/损耗费列宽。
const SURCHARGE_WIDTH: usize = 12;
let mut table: TextTable = TextTable::new(
format!(
"成本·费用构成(按检测类型;币种 {})",
profile.currency_code()
),
vec![
TableColumn::left("检测类型", KEY_WIDTH),
TableColumn::right("张数", COUNT_WIDTH),
TableColumn::right("基准费", AMOUNT_WIDTH),
TableColumn::right("加急费", SURCHARGE_WIDTH),
TableColumn::right("损耗费", SURCHARGE_WIDTH),
TableColumn::right("合计检测费", AMOUNT_WIDTH),
],
);
for bucket in profile.by_order_code() {
table = table.with_row(vec![
bucket.bucket_code().to_string(),
bucket.order_count().to_string(),
bucket.base_fee_text(),
bucket.urgency_text(),
bucket.loss_text(),
bucket.total_fee_text(),
]);
}
// 合计行:五列里「张数」与四个金额列**都可以求和**,因此全部印求和值。
// 这是「合计行只做一件事:对每一列求和」的正面例子。
let order_count_sum: usize = profile.by_order_code().iter().map(|b| b.order_count()).sum();
table = table.with_row(vec![
"合计".to_string(),
order_count_sum.to_string(),
profile.base_total_text(),
profile.urgency_total_text(),
profile.loss_total_text(),
profile.grand_total_text(),
]);
table = table.with_note(&format!(
"合计行对每一列求和:基准费 {} + 加急费 {} + 损耗费 {} = 合计 {}。\
读者可以按计算器逐列复算。",
profile.base_total_text(),
profile.urgency_total_text(),
profile.loss_total_text(),
profile.grand_total_text()
));
table = table.with_note(
"加急费/损耗费列里的「---」表示本批该类型为零:加急是**单张委托单**的属性,\
损耗是**检测类型**的属性(宝石鉴定恒为破坏性,其损耗费必然为正)。\
⚠️ 不要用同一个「---」既表示「本批没有加急单」又表示「该类型不支持加急」------\
本工程所有类型都支持加急,因此这里的「---」只有前一种含义。",
);
let mut lines: Vec<String> = section_header("幕八·成本画像", width);
lines.extend(table.render());
lines
}
/// 成本·基准费与项目费合计的口径对照表。
///
/// 参数 `profile`:成本画像;`width`:文档总宽。
/// 返回:文本行。
///
/// ## 这张表要让读者看出什么
///
/// 「基准费」是**账单口径**,「项目费合计」(各检测项目单价之和)
/// **不参与计价**。两者的差把三种计价口径区分得非常清楚:
///
/// | 计价口径 | 基准费 vs 项目费合计 |
/// |---|---|
/// | 按件 | 可能差很多(项目清单只是说明性的) |
/// | 按克拉 | 差得最多(基准费随重量走,项目费固定) |
/// | 按项目数 | **完全相等**(基准费就是项目费之和) |
///
/// 「按项目数计价的行差额为零」是这张表最有价值的读数:
/// 它同时验证了「该产品的计价实现就是项目求和」与
/// 「其他产品的计价不来自项目清单」两件事。
pub fn render_price_basis_comparison_table(
profile: &CostProfile,
width: usize,
) -> Vec<String> {
/// 检测类型列宽。
const KEY_WIDTH: usize = 24;
/// 金额列宽。
const AMOUNT_WIDTH: usize = 14;
/// 差额列宽(负数带符号,仍需 14 列)。
const DIFFERENCE_WIDTH: usize = 14;
let mut table: TextTable = TextTable::new(
"成本·计价口径对照(基准费 vs 项目费合计;后者**不参与计价**)",
vec![
TableColumn::left("检测类型", KEY_WIDTH),
TableColumn::right("基准费", AMOUNT_WIDTH),
TableColumn::right("项目费合计", AMOUNT_WIDTH),
TableColumn::right("差额", DIFFERENCE_WIDTH),
],
);
for bucket in profile.by_order_code() {
table = table.with_row(vec![
bucket.bucket_code().to_string(),
bucket.base_fee_text(),
bucket.item_fee_text(),
fee_text(bucket.base_fee_total() - bucket.item_fee_total()),
]);
}
table = table.with_row(vec![
"合计".to_string(),
fee_text(profile.base_total()),
fee_text(profile.item_fee_total()),
fee_text(profile.base_total() - profile.item_fee_total()),
]);
table = table.with_note(
"「按项目数计价」的两种鉴定类,差额恰为零------它们的基准费**就是**项目费之和;\
其余三种的差额都很大,说明它们的项目清单只是说明性的,不参与计价。\
把这两个数并排印出来,比在文档里写一句「口径不同」有效得多:\
读者自己就能看出哪种产品在按项目收钱。",
);
let mut lines: Vec<String> = vec![String::new()];
lines.extend(table.render());
lines.extend(note_lines(
"「差额」是一个**自证列**:按项目数计价的行差额必须为零\n\
(基准费就是各项目单价之和),否则说明该产品的计价实现\n\
与它的项目清单已经不一致。按件计价与按克拉计价的行差额通常非零,\n\
那不代表错------它说明这两类产品的基准费**不来自**项目清单。",
width,
));
lines
}
/// 成本·按计价口径分组表。
///
/// 参数 `profile`:成本画像;`width`:文档总宽。
/// 返回:文本行。
pub fn render_pricing_basis_table(profile: &CostProfile, width: usize) -> Vec<String> {
/// 计价口径列宽(最长 `按项目数计价` = 12)。
const BASIS_WIDTH: usize = 14;
/// 张数列宽。
const COUNT_WIDTH: usize = 6;
/// 金额列宽。
const AMOUNT_WIDTH: usize = 14;
/// 占比列宽(表头 `基准费/总合计` = 13 列,故取 14)。
///
/// ## ★ 这一列的表头被改对过两次,两次的错法不同,都值得记
///
/// 它最初叫 `基准费占比`(宽 10)。问题出在**分母没写**:
/// 读者会以为分母是「基准费总额」,而实际分母是「合计检测费总额」。
///
/// 于是被改成 `占总合计比`------**这一改反而更糟**:它把分母写清楚了,
/// 却把**分子**丢了。这一列算的是 `基准费 ÷ 合计检测费`,
/// 而表头读起来像 `合计检测费 ÷ 合计检测费`(那每行都会是 100.00%)。
/// 实测值 44.73% 与读者按表头推出来的算法**对不上**。
///
/// 现在的写法 `基准费/总合计` 把分子分母**同时**写在表头上。
/// 一个派生指标只要两者不同名,标签里就该同时出现------
/// **「分母要出现在标签或值里」这条纪律,分子同样适用。**
const SHARE_WIDTH: usize = 14;
let mut table: TextTable = TextTable::new(
"成本·按计价口径分组",
vec![
TableColumn::left("计价口径", BASIS_WIDTH),
TableColumn::right("张数", COUNT_WIDTH),
TableColumn::right("基准费", AMOUNT_WIDTH),
TableColumn::right("合计检测费", AMOUNT_WIDTH),
TableColumn::right("基准费/总合计", SHARE_WIDTH),
],
);
// 先把各行占比的万分点收集起来------合计行要用它们的和来判断
// 「是不是恰好 100.00%」(见下面注解)。
let mut share_points: Vec<i64> = Vec::new();
for bucket in profile.by_pricing_basis() {
let points: i64 = share_basis_points(bucket.base_fee_total(), profile.grand_total());
share_points.push(points);
table = table.with_row(vec![
bucket.bucket_chinese_name().to_string(),
bucket.order_count().to_string(),
bucket.base_fee_text(),
bucket.total_fee_text(),
percent_text(points),
]);
}
let order_count_sum: usize = profile
.by_pricing_basis()
.iter()
.map(|b| b.order_count())
.sum();
table = table.with_row(vec![
"合计".to_string(),
order_count_sum.to_string(),
profile.base_total_text(),
profile.grand_total_text(),
// ⚠️ 与账本表同样的陷阱:各行占比四舍五入后之和往往不是 100.00%。
// 因此合计行这一格印「---」,具体和的数值写进注解里。
"---".to_string(),
]);
let share_points_sum: i64 = share_points.iter().sum();
table = table.with_note(&format!(
"「基准费/总合计」这一列的算式**就是表头本身**:分子是该口径的基准费,\
分母是**全批合计检测费** {}(不是本口径的合计,也不是基准费总额)。\
写清两者是为了让读者一眼能复算------本工程在这一列上错过两次:\
第一次只写「基准费占比」漏了分母,第二次改成「占总合计比」又漏了分子\
(读起来像「合计检测费 ÷ 总合计」,那样每一行都该是 100.00%)。",
profile.grand_total_text()
));
table = table.with_note(&format!(
"⚠️ 合计行的这一格填「---」而不是 100.00%:各行占比之和为 {}(非 100.00%)。\
张数、基准费、合计三列可以求和,占比那一列**不能**------这是「合计行只能对列求和」\
这条纪律最容易踩空的地方。",
percent_text(share_points_sum)
));
let mut lines: Vec<String> = vec![String::new()];
lines.extend(table.render());
lines.extend(note_lines(
"本表回答一个很实际的问题:「这笔账单里,钱主要花在哪些检测类型上」。\n\
三种计价口径是**固定的小集合**(按件 / 按克拉 / 按项目数),\n\
因此行序按口径编码升序(业务约定),而不是按金额降序------\n\
按金额降序会让同一份数据在不同批次下换顺序,读者对比两份报表时要重新找行。\n\
对比:按检测类型分组那张表用的是**金额降序**,因为类型数量会增长,\n\
读者要找的是「最贵的排最前」。**分类维度用固定顺序,排序维度用金额。**",
width,
));
lines
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern 简单工厂模式 Creational Patterns 创建型模式
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/8 21:48
//!# User : geovindu
//!# Product : RustRover
//!# Project : simplefactorypattern
//!# File : consignment_batch.rs
//! # 送检批次 ------ 驱动实验的输入数据
//!
//! ## 为什么演示数据要单独成一个文件
//!
//! 本工程的实验是「同一批送检单,喂给两版分派机制,比较结果」。
//! 这里的**同一批**是实验的自变量基底------它必须:
//!
//! 1. **完全确定**:不含随机数、不随时间变化。两次运行必须给出同一批;
//! 2. **覆盖全部代码路径**:成功、参数越界、缺少必填、类别不符、未知编码
//! 五种结局都要有样本,否则「两版一致」的实验只验证了成功路径;
//! 3. **可人工核算**:样品参数取整数(单价、件数、克拉都在能口算的范围),
//! 使报表上每个金额都能被读者用计算器复算。
//!
//! 三条都指向同一件事:**数据本身就是论证的一部分**,
//! 因此它值得单独成文件,而不是散在驱动代码里。
//!
//! ## 样品编号的生成方式
//!
//! 样品编号用 [`crate::support::derive_code`] 从「检测类型 + 批次内序号」
//! 确定性派生。这样做的收益在第六幕体现:两版分派机制各自跑一遍同一批,
//! 只要生成规则相同,样品编号就相同,
//! 于是两次结果的**逐字段比对**才有意义(若编号随机,比对必然失败)。
use crate::domain::{
TestingOrderCode, ORDER_CODE_DIAMOND_GRADING,
ORDER_CODE_GEMSTONE_IDENTIFICATION, ORDER_CODE_JADE_AUTHENTICATION,
ORDER_CODE_PRECIOUS_METAL_PURITY, ORDER_CODE_SILVER_PURITY, SAMPLE_KIND_DIAMOND,
SAMPLE_KIND_GEMSTONE, SAMPLE_KIND_JADE, SAMPLE_KIND_PRECIOUS_METAL, SAMPLE_KIND_SILVER,
};
use crate::product::SampleSpecification;
use crate::support::{build_seed, derive_code, CalendarDate};
/// 一条送检请求(送检单上的一行)。
#[derive(Debug, Clone)]
pub struct ConsignmentRequest {
/// 标签(报表里替代样品编号显示,便于人读)。
label: String,
/// 请求的检测类型编码。
order_code: TestingOrderCode,
/// 样品规格。
specification: SampleSpecification,
}
impl ConsignmentRequest {
/// 构造一条送检请求。
///
/// 参数 `label` / `order_code` / `specification`。
/// 返回:送检请求。
pub fn new(
label: &str,
order_code: TestingOrderCode,
specification: SampleSpecification,
) -> ConsignmentRequest {
ConsignmentRequest {
label: label.to_string(),
order_code,
specification,
}
}
/// 标签。
pub fn label(&self) -> &str {
&self.label
}
/// 请求的检测类型编码。
pub fn order_code(&self) -> &TestingOrderCode {
&self.order_code
}
/// 样品规格。
pub fn specification(&self) -> &SampleSpecification {
&self.specification
}
}
/// 一个送检批次。
#[derive(Debug, Clone)]
pub struct ConsignmentBatch {
/// 批次名称(报表标题)。
name: String,
/// 受理日(全批次共用,排期以此为基准)。
accepted_on: CalendarDate,
/// 送检请求列表(顺序即报表顺序)。
requests: Vec<ConsignmentRequest>,
}
impl ConsignmentBatch {
/// 新建空批次。
///
/// 参数 `name`:批次名;`accepted_on`:受理日。
/// 返回:空批次。
pub fn new(name: &str, accepted_on: CalendarDate) -> ConsignmentBatch {
ConsignmentBatch {
name: name.to_string(),
accepted_on,
requests: Vec::new(),
}
}
/// 追加一条送检请求(流式)。
///
/// 参数 `label` / `order_code` / `specification`。
/// 返回:追加后的批次。
///
/// 样品编号在这里自动派生,不由调用方提供------避免「忘了给编号」
/// 或「两个请求用了同一个编号」这类问题散落在调用点。
/// 派生输入是「检测类型编码 + 当前条数」,
/// 因此同一条请求无论被加到第几个批次里,编号都相同。
pub fn with_request(
mut self,
label: &str,
order_code: TestingOrderCode,
specification: SampleSpecification,
) -> ConsignmentBatch {
let sequence: usize = self.requests.len() + 1;
let seed: u64 = build_seed(&[order_code.code(), &format!("{:03}", sequence)]);
let sample_code: String = derive_code("S", seed);
// ★ 空编号在工程内不可达:本方法是 `SampleSpecification` 唯一写入编号的地方,
// 而 FNV 派生的编号恒为「前缀 + 定长十六进制」,长度不可能为 0。
// 这条断言的价值不在「运行时防呆」,而在于把上面那句话**变成可执行的声明**------
// 将来若有人把派生规则改成一个可能返回空串的实现,debug 构建会立刻炸。
// 若这里只写注释不写断言,那句话就只是注释。
debug_assert!(
!sample_code.is_empty(),
"样品编号派生结果为空:编码 {} 的第 {} 条请求",
order_code.code(),
sequence
);
// 把派生的编号写回规格:规格是构造函数的入参,编号是批次的责任。
let specification: SampleSpecification =
specification.with_sample_code(&sample_code);
self.requests.push(ConsignmentRequest::new(
label,
order_code,
specification,
));
self
}
/// 批次名称。
pub fn name(&self) -> &str {
&self.name
}
/// 受理日。
pub fn accepted_on(&self) -> CalendarDate {
self.accepted_on
}
/// 送检请求列表。
pub fn requests(&self) -> &[ConsignmentRequest] {
&self.requests
}
/// 请求条数。
pub fn request_count(&self) -> usize {
self.requests.len()
}
}
/// 工程内置的演示批次:受理日 2026-09-03,共 14 条。
///
/// ## 这 14 条的构成是刻意的
///
/// | 结局 | 条数 | 覆盖了哪条路径 |
/// |---|---|---|
/// | 成功创建 | 7 | 三种计价口径各至少 1 条;加急 2 条;破坏性 1 条;最小计费重量 1 条 |
/// | 产品侧拒绝 | 5 | 参数越界 ×3、缺少必填 ×1、样品类别不符 ×1 |
/// | 未知编码 | 2 | 一个「尚未上线」、一个「拼错」 |
///
/// **未知编码刻意给了两种**:它们的报表表现相同(都是未知编码),
/// 但业务含义完全不同------一个是「功能没做」,一个是「字打错了」。
/// 本工程把两种都放进批次,就是为了让错误消息里的「已知编码清单」
/// 有东西可对照:读者能凭清单判断出第二种是拼错。
pub fn builtin_consignment_batch() -> ConsignmentBatch {
// 受理日 2026-09-03 是周四(已用 Python datetime 核对)。
// 选周四而不是周五,是因为「跳过周末」要在 2~3 个工作日的排期上
// 立刻显形;选周五则所有排期都会跨周末,反而看不出差别。
let accepted_on: CalendarDate = CalendarDate::from_ymd(2026, 9, 3);
ConsignmentBatch::new("标准演示批次", accepted_on)
// ① 素金单件、不加急:最平凡的一条,作为基准。
.with_request(
"素金1件·常规",
ORDER_CODE_PRECIOUS_METAL_PURITY,
SampleSpecification::new("六福珠宝·中环总店", SAMPLE_KIND_PRECIOUS_METAL),
)
// ② 素金 3 件、加急:验证「按件计价 × 加急费率」。
.with_request(
"素金3件·加急",
ORDER_CODE_PRECIOUS_METAL_PURITY,
SampleSpecification::new("六福珠宝·尖沙咀店", SAMPLE_KIND_PRECIOUS_METAL)
.with_piece_count(3)
.with_urgency(true),
)
// ③ 银饰 2 件。
.with_request(
"银饰2件·常规",
ORDER_CODE_SILVER_PURITY,
SampleSpecification::new("周大福·铜锣湾店", SAMPLE_KIND_SILVER)
.with_piece_count(2),
)
// ④ ★ 银饰 0 件 → 参数越界(件数下界 1)。
.with_request(
"银饰0件·越界",
ORDER_CODE_SILVER_PURITY,
SampleSpecification::new("周大福·铜锣湾店", SAMPLE_KIND_SILVER)
.with_piece_count(0),
)
// ⑤ 钻石 1.205 ct:验证按克拉计价的舍入。600.00 × 1.205 = 723.00。
.with_request(
"钻石1.205ct·常规",
ORDER_CODE_DIAMOND_GRADING,
SampleSpecification::new("私人客户·陈先生", SAMPLE_KIND_DIAMOND)
.with_carat_millis(1205),
)
// ⑥ ★ 钻石 0.450 ct → 触发最小计费重量(按 0.500 ct 计 = 300.00)。
.with_request(
"钻石0.450ct·最小计费",
ORDER_CODE_DIAMOND_GRADING,
SampleSpecification::new("私人客户·李女士", SAMPLE_KIND_DIAMOND)
.with_carat_millis(450),
)
// ⑦ ★ 钻石未填克拉 → 缺少必填参数(0 视为没填)。
.with_request(
"钻石未填克拉",
ORDER_CODE_DIAMOND_GRADING,
SampleSpecification::new("私人客户·黄先生", SAMPLE_KIND_DIAMOND),
)
// ⑧ ★ 钻石 2 件 → 参数越界(分级一件一证,只允许 1 件)。
.with_request(
"钻石2件·越界",
ORDER_CODE_DIAMOND_GRADING,
SampleSpecification::new("私人客户·张女士", SAMPLE_KIND_DIAMOND)
.with_piece_count(2)
.with_carat_millis(800),
)
// ⑨ ★ 拿玉石送钻石分级 → 样品类别不符。
.with_request(
"玉石送钻石分级·类别不符",
ORDER_CODE_DIAMOND_GRADING,
SampleSpecification::new("私人客户·何先生", SAMPLE_KIND_JADE)
.with_carat_millis(1500),
)
// ⑩ 宝石 2 件、加急:唯一的破坏性检测样本,验证损耗费与工作量附加。
.with_request(
"宝石2件·加急·破坏性",
ORDER_CODE_GEMSTONE_IDENTIFICATION,
SampleSpecification::new("六福珠宝·旺角店", SAMPLE_KIND_GEMSTONE)
.with_piece_count(2)
.with_urgency(true),
)
// ⑪ ★ 宝石 25 件 → 参数越界(破坏性检测上限 20 件)。
.with_request(
"宝石25件·越界",
ORDER_CODE_GEMSTONE_IDENTIFICATION,
SampleSpecification::new("六福珠宝·旺角店", SAMPLE_KIND_GEMSTONE)
.with_piece_count(25),
)
// ⑫ 玉石 5 件。
.with_request(
"玉石5件·常规",
ORDER_CODE_JADE_AUTHENTICATION,
SampleSpecification::new("六福珠宝·沙田店", SAMPLE_KIND_JADE)
.with_piece_count(5),
)
// ⑬ ★ 未知编码:一个「尚未上线」的检测类型。
.with_request(
"珍珠鉴定·尚未上线",
CODE_PEARL_AUTHENTICATION_REQUEST,
SampleSpecification::new("私人客户·吴女士", SAMPLE_KIND_JADE),
)
// ⑭ ★ 未知编码:一个「拼错」的编码(少了一个字母 N)。
.with_request(
"钻石分级·拼错",
CODE_DIAMOND_GRADING_TYPO,
SampleSpecification::new("私人客户·郑先生", SAMPLE_KIND_DIAMOND)
.with_carat_millis(700),
)
}
/// 演示用:「珍珠鉴定」编码,本工程**尚未**为它提供构造器。
///
/// 它刻意**不是** [`crate::domain`] 里的内置常量------因为「一个还没实现的
/// 检测类型」本来就不该出现在领域层的常量表里。它只是送检单上出现的一个字符串,
/// 由调用方自行构造(这正是开放型编码的价值:**新增编码不需要改领域层**)。
pub const CODE_PEARL_AUTHENTICATION_REQUEST: TestingOrderCode =
TestingOrderCode::new("PEARL_AUTHENTICATION", "珍珠鉴定");
/// 演示用:把 `DIAMOND_GRADING` 拼错成 `DIAMOND_GRADIN`。
///
/// 它与上一个常量的区别是**业务含义**:这个是错字,那个是未实现功能。
/// 两者在报表上的结局相同(未知编码),但读者凭「已知编码清单」
/// 可以立刻分辨------这正是把已知清单带进错误消息的价值。
pub const CODE_DIAMOND_GRADING_TYPO: TestingOrderCode =
TestingOrderCode::new("DIAMOND_GRADIN", "钻石分级(拼错)");
// 本文件确实用到全部五个样品类别常量(每个请求都要指定一个),
// 因此 `SAMPLE_KIND_*` 的导入无需任何 `#[allow(dead_code)]` 兜底。
//
// ⚠️ 这里原本挂着一个 `#[allow(dead_code)] const ALL_SAMPLE_KINDS_USED_BY_THIS_FILE`
// ------那是一段**用 allow 掩盖**的痕迹:写它的时候担心导入告警,
// 就加了一个「把所有常量列一遍」的常量来假装它们被用到了。
// 那既没有消除告警(被 allow 压住了),又引入了一个永远为真的假事实。
// 删掉它、让编译器如实报告,才是本工程的纪律(告警要真修,不用 allow 压)。
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern 简单工厂模式 Creational Patterns 创建型模式
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/8 21:48
//!# User : geovindu
//!# Product : RustRover
//!# Project : simplefactorypattern
//!# File : laboratory_workbench.rs
//! # 实验室工作台 ------ 用**同一段**驱动代码跑完整批送检单
//!
//! ## 这个文件存在的唯一理由:让对照组干净
//!
//! 本工程要比较两版分派机制(编译期白名单 vs 运行期登记表)。
//! 若比较的方式是「给白名单版写一段驱动、给登记表版再写一段驱动」,
//! 那么两次跑出来的差异里就混进了**驱动代码的差异**------
//! 到底是机制不同,还是两段代码写得不一样?说不清。
//!
//! 因此本文件只写**一个**驱动函数 [`run_consignment_batch`],
//! 它的参数类型是 `&dyn OrderCreator`------**trait 对象,不是泛型**。
//! 于是「用的哪一版机制」这件事被完全藏在参数里,
//! 驱动代码里找不到任何一版的名字。
//!
//! ## 为什么是 `&dyn OrderCreator` 而不是泛型 `C: OrderCreator`
//!
//! 泛型也能做到「一段代码跑两版」,但它有两点不适合本工程:
//!
//! 1. **调用点必须知道具体类型**。本工程的调用点恰恰想表达
//! 「我手里只有一个『能造委托单的东西』」------那正是 trait 对象。
//! 2. **报表要按机制归组**。用泛型时,[`WorkbenchRun`] 会被单态化成两份,
//! 但它们的类型不同,无法放进同一个 `Vec` 里做并排对照;
//! 用 trait 对象则两次运行产出**同一个类型**的结果,
//! 可以直接 `vec![run_a, run_b]` 再逐字段比对。
//!
//! 第 2 点在这个文件里是决定性的:本工程第六幕要「把两次运行的结果
//! 并排打印并逐字段比对」,那要求两次结果同型。**这是取舍不是疏忽。**
//!
//! ## 委托单在这里就被丢掉了
//!
//! 驱动循环里拿到 `Box<dyn TestingOrder>` 之后,**立刻**抽成
//! [`ProductSnapshot`] 然后丢弃产品本体。这不是为了省内存,
//! 而是一个**论证**:后续所有分析(对账、覆盖率、产能、成本)都只依赖快照,
//! 说明快照确实携带了「这张委托单此刻的全部可观测事实」。
//! 若某天有人发现某个分析拿不到想要的数据,他会立刻撞上
//! 「产品已经不在手里了」这个事实------那时正确的做法是**扩充快照**,
//! 而不是把产品留着不放。把这条约束做成结构,比写在文档里有效。
// ⚠️ 注意这里用的是 `super::` 而不是 `crate::client::product_snapshot::`。
// `super` 就是 `client` 模块本身,它的统一出口已经重导出了这两项。
// 走出口而不是深层路径,好处是将来源文件拆开时本文件零改动------
// 这既是 `client/mod.rs` 的约定,也是本工程「统一出口」纪律的一部分:
// **若某个出口项变成 unused,通常说明有人绕过了出口**,改法就是让调用点走出口。
use super::{snapshot_order, ProductSnapshot};
use crate::client::{ConsignmentBatch, ConsignmentRequest};
use crate::domain::TestingOrderCode;
use crate::factory::{CreationError, CreationLedger, OrderCreator};
use crate::support::CalendarDate;
/// 一条送检请求的结局。
///
/// 用枚举而不是「`Result` + 一堆 `Option` 字段」:
/// 「成功」与「失败」是互斥的,枚举让这一点在类型上成立,
/// 于是取值时必须 `match`,不可能出现「既成功又失败」或「都没填」的状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CreationOutcome {
/// 成功创建(已抽成快照,产品本体已丢弃)。
Created(ProductSnapshot),
/// 失败(工厂侧「不认识编码」或产品侧「规格被拒」)。
Failed(CreationError),
}
impl CreationOutcome {
/// 是否成功。
pub fn is_created(&self) -> bool {
matches!(self, CreationOutcome::Created(_))
}
/// 成功时取出快照。
pub fn snapshot(&self) -> Option<&ProductSnapshot> {
match self {
CreationOutcome::Created(snapshot) => Some(snapshot),
CreationOutcome::Failed(_) => None,
}
}
/// 失败时取出错误。
pub fn error(&self) -> Option<&CreationError> {
match self {
CreationOutcome::Created(_) => None,
CreationOutcome::Failed(error) => Some(error),
}
}
/// 归组用的规则编码。
///
/// 成功时返回常量 `"CREATED"`,失败时返回错误自身的规则编码。
///
/// ## 为什么成功也要给一个「规则编码」
///
/// 因为报表要把「成功」与各条失败规则**放在同一列里统计**。
/// 若成功这一格印 `---`,那么「按结局归组」的那张表就少了一行,
/// 读者得自己意识到「还有一行叫成功」。给它一个编码,
/// 这张表就是**穷尽**的:所有行加起来等于请求总条数。
/// 一个能自证穷尽的表,比一个需要读者脑补的表可靠得多。
pub fn rule_code(&self) -> &'static str {
match self {
CreationOutcome::Created(_) => "CREATED",
CreationOutcome::Failed(error) => error.rule_code(),
}
}
/// 报表用的短结论(成功 / 被拒 / 未知编码)。
///
/// 这里对失败**不再细分**,是为了让「结论」这一列足够窄;
/// 细分的信息在 [`CreationOutcome::rule_code`] 那一列。
/// 一列只表达一件事,是这个表格宽度模型的起点。
pub fn conclusion_text(&self) -> &'static str {
match self {
CreationOutcome::Created(_) => "成功",
CreationOutcome::Failed(CreationError::UnknownOrderCode { .. }) => "未知编码",
CreationOutcome::Failed(CreationError::Rejected { .. }) => "被拒",
}
}
/// 报表用的详情文本。
///
/// 成功时给出「费用 + 出证日」这两个最能说明问题的字段;
/// 失败时给出错误自身的可读说明。
pub fn detail_text(&self) -> String {
match self {
CreationOutcome::Created(snapshot) => {
// 费用走快照自己的 `total_fee_text()`:币种是快照携带的事实,
// 不该在这里再写一遍 `CURRENCY_CHINESE_YUAN`。
format!(
"合计 {},出证 {}",
snapshot.total_fee_text(),
snapshot.delivery_on_text
)
}
CreationOutcome::Failed(error) => error.description_text(),
}
}
}
/// 一条送检请求连同它的结局。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestOutcome {
/// 请求标签(来自送检单)。
label: String,
/// 请求的检测类型编码。
order_code: TestingOrderCode,
/// 样品编号(来自请求自身,**不依赖结局**)。
///
/// 刻意从请求里取而不是从快照里取:失败的那几条没有快照,
/// 但它们同样需要一个稳定标识来对账。若编号只存在于快照里,
/// 「失败的那几条是谁」就无从追溯了。
sample_code: String,
/// 结局。
outcome: CreationOutcome,
}
impl RequestOutcome {
/// 请求标签。
pub fn label(&self) -> &str {
&self.label
}
/// 检测类型编码。
pub fn order_code(&self) -> TestingOrderCode {
self.order_code
}
/// 样品编号。
pub fn sample_code(&self) -> &str {
&self.sample_code
}
/// 结局。
pub fn outcome(&self) -> &CreationOutcome {
&self.outcome
}
/// 排序键:样品编号。
///
/// ## 为什么按样品编号而不是按批内序号
///
/// 序号是「批次的属性」,编号才是「请求的属性」。
/// 报表按内容排序(而不是按生成顺序)是本工程的一贯纪律:
/// 一旦某天有人调整了 `builtin_consignment_batch` 里请求的排列顺序,
/// 按序号排序会让整张报表的行序全部改变,
/// 而按编号排序只影响被调整的那几条------**两次运行逐字节一致**
/// 这条验收标准也因此更容易守住。
pub fn sort_key(&self) -> String {
self.sample_code.clone()
}
}
/// 一次「跑完整批」的完整结果。
///
/// 这个结构是本工程所有分析的输入。它刻意**不包含**任何解释性的判断
/// (比如「两版是否一致」)------那些属于 `analysis` 层。
/// 本层只负责如实记录「发生了什么」。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkbenchRun {
/// 机制短名(来自创建者自己,不在这里写死)。
mechanism_name: &'static str,
/// 机制说明。
mechanism_note: &'static str,
/// 批次名称。
batch_name: String,
/// 受理日。
accepted_on: CalendarDate,
/// 逐条结局(顺序与批次一致)。
request_outcomes: Vec<RequestOutcome>,
/// 运行开始时创建者认识的编码(快照)。
supported_codes_before: Vec<TestingOrderCode>,
/// 运行结束时创建者认识的编码(快照)。
///
/// 与上一项成对存在:两者相等即证明**驱动一批送检单不会改变分派表**。
/// 这条性质看着显然,但它正是「探针污染主链」那类缺陷的反面------
/// 若某个实现偷偷在 `create` 里注册了什么,两个快照就会不等。
/// 把「显然的事」变成可比对的两个值,是本工程反复采用的手法。
supported_codes_after: Vec<TestingOrderCode>,
/// 创建者自己的账本快照(运行结束后)。
creator_ledger: CreationLedger,
}
impl WorkbenchRun {
/// 机制短名。
pub fn mechanism_name(&self) -> &'static str {
self.mechanism_name
}
/// 机制说明。
pub fn mechanism_note(&self) -> &'static str {
self.mechanism_note
}
/// 批次名称。
pub fn batch_name(&self) -> &str {
&self.batch_name
}
/// 受理日。
pub fn accepted_on(&self) -> CalendarDate {
self.accepted_on
}
/// 逐条结局。
pub fn request_outcomes(&self) -> &[RequestOutcome] {
&self.request_outcomes
}
/// 请求总条数。
pub fn request_count(&self) -> usize {
self.request_outcomes.len()
}
/// 运行前/运行后的分派表快照是否相同。
pub fn dispatch_table_unchanged(&self) -> bool {
self.supported_codes_before == self.supported_codes_after
}
/// 运行开始时创建者认识的编码(快照)。
///
/// 两版对照要比较「各自认识多少种」,因此需要一个只读出口。
/// 注意这里返回的是**运行开始时**的那一份:运行结束后登记表可能
/// 被工程外的扩展方追加过内容,用它去比较会把「后来加的」
/// 误算成「本来就有」。
pub fn supported_codes_before(&self) -> &[TestingOrderCode] {
&self.supported_codes_before
}
/// 运行结束时创建者认识的编码(快照)。
pub fn supported_codes_after(&self) -> &[TestingOrderCode] {
&self.supported_codes_after
}
/// 创建者账本(运行结束后)。
pub fn creator_ledger(&self) -> &CreationLedger {
&self.creator_ledger
}
/// 成功创建的请求(按批次顺序)。
pub fn created_outcomes(&self) -> Vec<&RequestOutcome> {
self.request_outcomes
.iter()
.filter(|outcome| outcome.outcome.is_created())
.collect()
}
/// 失败的请求(按批次顺序)。
pub fn failed_outcomes(&self) -> Vec<&RequestOutcome> {
self.request_outcomes
.iter()
.filter(|outcome| !outcome.outcome.is_created())
.collect()
}
/// 成功创建的快照(按批次顺序)。
///
/// ## 为什么返回 `Vec<&ProductSnapshot>` 而不是克隆一份
///
/// 快照确实可以克隆,但这里没有必要:调用方全部是「读一下就算」的
/// 分析代码,借用足够。克隆一份会让「分析层持有自己的副本」
/// 与「运行结果里的原件」两个事实同时存在,
/// 一旦两者不一致(比如某处意外改了副本),排查成本很高。
/// **能借就不克隆**,这条在只读场景下没有代价。
pub fn created_snapshots(&self) -> Vec<&ProductSnapshot> {
self.request_outcomes
.iter()
.filter_map(|outcome| outcome.outcome.snapshot())
.collect()
}
/// 成功创建的总费用(整数分)。
///
/// ## 为什么这里用 `i64` 求和而不是 `Money`
///
/// 快照里的费用是整数分,且全批次币种相同(本工程只用人民币)。
/// 用 `i64` 求和的代价是「币种检查被推迟到报表层」,
/// 收益是分析层的代码不必反复处理 `Money::add` 的 `Option`。
///
/// 若将来出现多币种批次,**必须**改成按币种分组求和------
/// 那时这个方法的签名会变成「返回按币种的映射」,
/// 编译器会强迫所有调用点一起改。这是可以接受的演进路径,
/// 而提前把多币种处理写进来则会让现在这版代码难读且无法被验证。
pub fn total_created_fee_minor_units(&self) -> i64 {
self.created_snapshots()
.iter()
.map(|snapshot| snapshot.total_fee_minor_units)
.sum()
}
/// 成功创建的总工作量单元。
pub fn total_workload_units(&self) -> u32 {
self.created_snapshots()
.iter()
.map(|snapshot| snapshot.workload_units)
.sum()
}
/// 逐条结局的排序后视图(按样品编号),用于报表。
pub fn outcomes_sorted_by_sample_code(&self) -> Vec<&RequestOutcome> {
let mut sorted: Vec<&RequestOutcome> = self.request_outcomes.iter().collect();
sorted.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
sorted
}
}
/// 用同一段驱动代码跑完整批送检单。
///
/// 参数 `creator`:创建者(**trait 对象**,两版机制共用本函数);
/// `batch`:送检批次。
/// 返回:一次运行的完整结果。
///
/// ## 这个函数的每一行都在为「对照组干净」服务
///
/// 1. 参数是 `&dyn OrderCreator`------不出现任何一版机制的名字;
/// 2. 循环体只有「调用 `create` → 抽快照 → 记结局」三步,
/// 没有任何按机制分支的代码;
/// 3. 机制名与说明从 `creator` 自己问出来(`mechanism_name()`),
/// 而不是由本函数按类型判断------**否则这里就会写出一处 `match`**,
/// 而那一处 `match` 正是「驱动代码开始知道机制」的第一道裂缝。
pub fn run_consignment_batch(
creator: &dyn OrderCreator,
batch: &ConsignmentBatch,
) -> WorkbenchRun {
// 运行前后各取一次分派表快照:两者相等即证明本次驱动没有副作用地
// 改动了分派表(见 WorkbenchRun::dispatch_table_unchanged 的说明)。
let supported_codes_before: Vec<TestingOrderCode> = creator.supported_codes();
let mut request_outcomes: Vec<RequestOutcome> = Vec::with_capacity(batch.request_count());
for request in batch.requests() {
request_outcomes.push(run_single_request(creator, request, &batch.accepted_on()));
}
let supported_codes_after: Vec<TestingOrderCode> = creator.supported_codes();
WorkbenchRun {
mechanism_name: creator.mechanism_name(),
mechanism_note: creator.mechanism_note(),
batch_name: batch.name().to_string(),
accepted_on: batch.accepted_on(),
request_outcomes,
supported_codes_before,
supported_codes_after,
creator_ledger: creator.ledger_snapshot(),
}
}
/// 处理一条送检请求。
///
/// 参数 `creator`:创建者;`request`:请求;`accepted_on`:受理日。
/// 返回:该条请求的结局。
///
/// ## 为什么单独抽出来
///
/// 一是让 [`run_consignment_batch`] 的循环体短到一眼可读,
/// 二是使「一条请求的处理」成为一个可以单独审视的单位------
/// 本函数里**唯一**的业务动作是 `creator.create(..)`,
/// 其余全是记账与投影。读者看完这 20 行就能确信:
/// 驱动代码没有夹带任何业务判断。
fn run_single_request(
creator: &dyn OrderCreator,
request: &ConsignmentRequest,
accepted_on: &CalendarDate,
) -> RequestOutcome {
// 唯一的业务调用。注意这里只传编码与规格,不传任何「怎么造」的信息------
// 「怎么造」是创建者的内部知识,调用方无从知晓也不该知晓。
let creation_result =
creator.create(request.order_code(), request.specification());
let outcome: CreationOutcome = match creation_result {
Ok(order) => {
// ★ 抽出快照之后,`order` 在本行末尾就被丢弃了。
// 下面的所有代码都只能看快照------这就是本文件模块文档里
// 说的那个「论证」:分析只依赖快照,因此快照必须够用。
CreationOutcome::Created(snapshot_order(order.as_ref(), accepted_on))
}
Err(error) => CreationOutcome::Failed(error),
};
RequestOutcome {
label: request.label().to_string(),
order_code: *request.order_code(),
sample_code: request.specification().sample_code().to_string(),
outcome,
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern 简单工厂模式 Creational Patterns 创建型模式
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/8 21:48
//!# User : geovindu
//!# Product : RustRover
//!# Project : simplefactorypattern
//!# File : product_snapshot.rs
//! # 委托单快照 ------ 用值类型固定一个「已创建的委托单长什么样」
//!
//! ## 这个类型为什么必须存在
//!
//! 本工程的核心实验是:**用同一批送检单,分别喂给两版分派机制,
//! 验证产出的结果完全一致。** 要做这件事,就必须能**比较**两次的产出。
//!
//! 但工厂返回的是 `Box<dyn TestingOrder>`:
//!
//! - 它**不能** `Clone`(trait 对象不可克隆);
//! - 它**不能** `PartialEq`(trait 里没有声明相等性,也不该声明------
//! 产品是对业务的封装,不是可比较的值);
//! - 它甚至**不能**留存到实验结束(借用与所有权都会变复杂)。
//!
//! 因此在「产品」与「报表/对比」之间需要一层**值化的投影**:
//! 把一张委托单此刻的全部可观测事实,抽成一组纯值(整数、字符串、布尔)。
//! 这组值是可以 `Clone`、可以 `PartialEq`、可以排序、可以打印的。
//!
//! ## 这个投影与「产品的内部状态」是两件事
//!
//! 快照里**没有**产品的私有字段,只有经 `TestingOrder` 公开接口
//! 能问到的东西。这不是限制,而是设计:
//! 两个不同实现(甚至来自不同分派机制、甚至来自工程外)只要能问出
//! 同样的答案,它们的快照就必须相等------**这才叫「观察上不可区分」**。
//! 若快照能读到私有字段,它就变成了「实现对比」而不是「行为对比」,
//! 本工程的实验结论会因此站不住。
//!
//! ## 金额为什么存「整数分」而不是格式化好的字符串
//!
//! 因为快照要参与**比较与求和**。字符串版本的 `¥1,130.00` 无法求平均,
//! 也无法发现「合计与分项之和不符」。存整数分、由报表层负责格式化,
//! 是既有工程反复验证过的分工:**值层不排版,报表层不算数**。
use crate::support::CalendarDate;
use crate::product::TestingOrder;
/// 一张已创建委托单的完整可观测快照。
///
/// 字段全部是「已经算好的值」,因此本类型可以安全地克隆、比较、排序。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProductSnapshot {
/// 检测类型编码。
pub order_code: String,
/// 检测类型中文名。
pub order_chinese_name: String,
/// 样品编号。
pub sample_code: String,
/// 送检客户。
pub applicant: String,
/// 样品类别中文名。
pub sample_kind: String,
/// 计价口径中文名。
pub pricing_basis: String,
/// 证书类型中文名。
pub certificate_kind: String,
/// 承接部门。
pub department: String,
/// 件数。
pub piece_count: u16,
/// 克拉重量(千分之一克拉;非钻石类为 0)。
pub carat_millis: i64,
/// **计费用的**克拉重量(千分之一克拉;已应用最小计费重量)。
///
/// ## 为什么快照里要同时存「实际」与「计费」两个重量
///
/// 因为只印实际重量时,钻石分级的账单会出现一个**无法解释的数字**:
/// 一颗 0.450 ct 的钻石,按每克拉 ¥600 本该收 ¥270.00,实际却收 ¥300.00。
/// 读者看到这个差额只能去翻代码,而「最小计费重量 0.500 克拉」
/// 这条规则本该在报表上直接可见。
///
/// 两个数并排之后,规则变成可核对的事实:
/// **实际 0.450 / 计费 0.500 → 强制抬升生效**。
/// 这正是本工程「结论必须带着可复算的依据」在数据模型上的体现------
/// 需要被解释的字段,就该在快照里有对应的字段,而不是留给读者推断。
pub billable_carat_millis: i64,
/// 检测项目数量。
pub line_count: usize,
/// 检测项目编码(按清单顺序拼接,用 `+` 分隔)。
///
/// 拼成一个字符串而不是 `Vec<String>`,是为了让快照的打印与比较
/// 都是**逐字符确定**的------`Vec` 的比较依赖元素顺序,
/// 而字符串把顺序问题变成可见的文本,报表上一眼能核对。
pub item_codes_text: String,
/// 标准出证工作日(不加急)。
pub turnover_days: u16,
/// 承诺工作日(加急时折半,向上取整)。
pub promised_working_days: u16,
/// 是否破坏性检测。
pub is_destructive: bool,
/// 是否加急。
pub is_urgent: bool,
/// 基准检测费(整数分)。
pub base_fee_minor_units: i64,
/// 加急附加费(整数分)。
pub urgency_surcharge_minor_units: i64,
/// 损耗费(整数分)。
pub loss_fee_minor_units: i64,
/// 合计检测费(整数分)。
pub total_fee_minor_units: i64,
/// 项目费合计(整数分)。**不参与计价**,仅作对照。
pub item_fee_total_minor_units: i64,
/// 工作量单元。
pub workload_units: u32,
/// 受理日。
pub accepted_on_text: String,
/// 预计出证日。
pub delivery_on_text: String,
/// 日历天跨度(含首尾)。
pub calendar_span_days: u32,
/// 期间被跳过的周末文本。
pub skipped_days_text: String,
}
impl ProductSnapshot {
/// 费用的三个构成项是否等于合计(自洽性核对)。
///
/// 返回:一致返回 `true`。
///
/// 这个方法存在的原因是:快照一旦建立,**它的内部一致性就没有别的东西守着**。
/// 报表从快照里分别取「基准费」与「合计」,若构造快照时把某一项填错,
/// 报表不会发现。把核对写进快照自己,比写进报表更合适------
/// 因为「三项之和等于合计」是快照自身应有的性质,与怎么打印无关。
pub fn fees_are_consistent(&self) -> bool {
self.base_fee_minor_units + self.urgency_surcharge_minor_units + self.loss_fee_minor_units
== self.total_fee_minor_units
}
/// 费用合计是否为正。
///
/// 用于体检:一张成功创建的委托单合计为 0 或负,说明价目表配置有问题。
pub fn total_fee_is_positive(&self) -> bool {
self.total_fee_minor_units > 0
}
/// 合计检测费的可读文本(如 `¥723.00`)。
///
/// ## 为什么这个便捷方法放在快照上
///
/// 快照里有三个费用字段(合计、基准、附加),若每处都写
/// `Money::from_minor_units(x, CURRENCY_CHINESE_YUAN).formatted()`,
/// 那么「用哪个币种」这件事就散落在调用点里。
/// 委托单的币种是快照携带的事实之一,因此由快照自己回答最自然。
///
/// ⚠️ 注意真正的格式化逻辑(千位分隔、小数位数)**只有一份**,
/// 在 [`crate::domain::Money::formatted`] 里。本方法与
/// `analysis` 层的 `fee_text` 都只是那一个入口的薄包装------
/// **薄包装可以有两处,口径只能有一处**。
pub fn total_fee_text(&self) -> String {
crate::domain::Money::from_minor_units(
self.total_fee_minor_units,
crate::domain::CURRENCY_CHINESE_YUAN,
)
.formatted()
}
}
/// 从一张委托单抽取快照。
///
/// 参数 `order`:抽象产品(本工程内置产品与工程外产品都适用);
/// `accepted_on`:受理日(排期需要)。
/// 返回:快照。
///
/// ## 为什么这个函数接受 `&dyn TestingOrder` 而不是泛型 `T: TestingOrder`
///
/// 泛型版本会为每个具体类型生成一份单态化代码,看似更快,
/// 但它要求调用点**知道具体类型**------而调用点的全部意义就在于
/// **不知道**具体类型(它手里只有一个 `Box<dyn TestingOrder>`)。
/// 用 trait 对象是这里唯一自然的写法。
///
/// 顺带说明一个常见误解:trait 对象调用的性能开销(一次虚表跳转)
/// 在本工程的规模下完全不可测。**不要为了省这一次跳转,
/// 把「调用方不知道具体类型」这条红线换掉。**
pub fn snapshot_order(order: &dyn TestingOrder, accepted_on: &CalendarDate) -> ProductSnapshot {
// 排期只算一次,下面三处(出证日、跨度、跳过清单)都从它取,
// 避免同一件事算三遍、三遍还可能不一致。
let schedule: crate::product::DeliverySchedule = order.schedule(accepted_on);
// 项目编码拼串:用 `+` 分隔,空清单时给一个显式标记而不是空串。
let item_codes: Vec<&'static str> = order
.required_items()
.iter()
.map(|item| item.code())
.collect();
let item_codes_text: String = if item_codes.is_empty() {
"<无项目>".to_string()
} else {
item_codes.join("+")
};
ProductSnapshot {
order_code: order.order_code().code().to_string(),
order_chinese_name: order.order_code().chinese_name().to_string(),
sample_code: order.sample_code().to_string(),
applicant: order.applicant().to_string(),
sample_kind: order.sample_kind().chinese_name().to_string(),
pricing_basis: order.pricing_basis().chinese_name().to_string(),
certificate_kind: order.certificate_kind().chinese_name().to_string(),
department: order.handling_department().to_string(),
piece_count: order.piece_count(),
carat_millis: order.carat_millis(),
billable_carat_millis: order.billable_carat_millis(),
line_count: order.line_count(),
item_codes_text,
turnover_days: order.turnover_days(),
promised_working_days: schedule.promised_working_days(),
is_destructive: order.is_destructive(),
is_urgent: order.is_urgent(),
base_fee_minor_units: order.base_fee().minor_units(),
urgency_surcharge_minor_units: order.urgency_surcharge().minor_units(),
loss_fee_minor_units: order.loss_fee().minor_units(),
total_fee_minor_units: order.total_fee().minor_units(),
item_fee_total_minor_units: order.item_fee_total().minor_units(),
workload_units: order.workload_units(),
accepted_on_text: schedule.accepted_on().formatted(),
delivery_on_text: schedule.delivery_on().formatted(),
calendar_span_days: schedule.calendar_span_days(),
skipped_days_text: schedule.skipped_days_text(),
}
}
/// 克拉重量(千分之一克拉)的显示文本,如 `1.205 ct`。
///
/// 参数 `carat_millis`:千分之一克拉。
/// 返回:三位小数的克拉文本;为 0 时返回 `---`。
///
/// ## 为什么 0 显示成 `---` 而不是 `0.000 ct`
///
/// 「0 克拉」在业务上不是一个值,而是「这个检测类型没有克拉概念」
/// (素金、银饰、玉石都属于这一类)。若印成 `0.000 ct`,
/// 读者会以为「这件样品是 0 克拉」,而不是「这一格对本类型不适用」。
/// 用破折号是表格里表达「不适用」的通行做法。
pub fn carat_text(carat_millis: i64) -> String {
if carat_millis <= 0 {
return "---".to_string();
}
// 整数除余拼串,不经过浮点。
let whole: i64 = carat_millis / 1000;
let fractional: i64 = carat_millis % 1000;
format!("{}.{:03} ct", whole, fractional)
}
调用:
rust
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern 简单工厂模式 Creational Patterns 创建型模式
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/8 21:48
//!# User : geovindu
//!# Product : RustRover
//!# Project : simplefactorypattern
//!# File : main.rs
//! # 简单工厂模式(Simple Factory Pattern)------ 珠宝检测实验室的委托单创建
//!
//! ## 一句话与三个角色
//!
//! > **一个工厂类,根据传入的参数决定创建哪一种产品类的实例。**
//!
//! 它**不是 GoF 二十三种模式之一**,而是一种编程习惯。三个角色在本工程的落点是:
//!
//! | 角色 | 本工程的落点 |
//! |---|---|
//! | 工厂(Factory) | `factory::TestingOrderFactory`(编译期白名单)/ `factory::TestingOrderRegistry`(运行期登记表) |
//! | 抽象产品(Abstract Product) | `product::TestingOrder` |
//! | 具体产品(Concrete Product) | `product` 层里五个 `pub(super)` 结构体 |
//!
//! ## 本工程不满足于「写一个 match」,也不满足于「写两版看着差不多」
//!
//! 教科书版本的分派长这样:
//!
//! ```text
//! match code {
//! "A" => Box::new(ProductA::new()),
//! "B" => Box::new(ProductB::new()),
//! _ => panic!("unknown"),
//! }
//! ```
//!
//! 它的结构问题是:**分派表与产品实现是两份东西**,必须手动保持同步。
//! 本工程要把这件事**量化**,因此同时挂着两版机制,用**同一段驱动代码**
//! 跑**同一批送检单**,把「扩展时要改几处」与「会不会失配」变成报表上的数字。
//!
//! ## ★ 本工程最重要的一句结论(初稿写错过,被实测纠正)
//!
//! 简单工厂的代价不是「改一行」,而是**「改两处并保持同步」**。
//! 两份清单会以两个方向失配,而**两个方向都不会「没有报错」**------
//! 准确的说法是:
//!
//! | 失配方向 | `supports` 的回答 | 创建的结果 | 判定 |
//! |---|---|---|---|
//! | 白名单有、构造器表没有 | `true`(承诺支持) | 失败 | **自相矛盾** |
//! | 构造器表有、白名单没有 | `false`(不承诺) | 失败 | **对内完全自洽** |
//!
//! 第二行才是真正难发现的:从对外行为看,它和「这个功能本来就没做」
//! **不可区分**。所以那句话的准确版本是------
//! **不是「没有报错」,而是「报的错看起来完全合理」**。
//!
//! 本工程把这句话变成第三幕、第九幕里的两列读数
//! (「声明与行为矛盾」与「跑完整批后未知编码」),
//! 因为**一个更顺口但不准确的表述,比一句笨拙的准确表述危险得多**。
//!
//! ## 严格分层结构
//!
//! ```text
//! ┌──────────────────────────────────────────────────────────────────────────┐
//! │ main.rs 编排:十幕的顺序、工程外扩展、输出自查 │
//! └───────────────────────────────┬──────────────────────────────────────────┘
//! │
//! ┌───────────────────────────────▼──────────────────────────────────────────┐
//! │ app 排版层 把已算好的值排成定宽文本表(零判断、零 IO) │
//! │ layout / dispatch_table_report / outcome_report / comparison_report │
//! │ profile_report / extension_report / health_check_report │
//! └───────────────────────────────┬──────────────────────────────────────────┘
//! │ 只读结构
//! ┌───────────────────────────────▼──────────────────────────────────────────┐
//! │ analysis 分析层 只读地说出「这批数据意味着什么」 │
//! │ check_line(结论是一等值)/ creation_ledger_analysis(账本与两版对照) │
//! │ dispatch_coverage(覆盖率与失配)/ capacity_profile / cost_profile │
//! └───────────────────────────────┬──────────────────────────────────────────┘
//! │ 只读 WorkbenchRun
//! ┌───────────────────────────────▼──────────────────────────────────────────┐
//! │ client 调用方层 「送检批次 → 工作台驱动 → 逐条快照」这条唯一的业务主干 │
//! │ consignment_batch(输入)/ laboratory_workbench(过程)/ product_snapshot(输出)│
//! └───────────────────────────────┬──────────────────────────────────────────┘
//! │ &dyn OrderCreator
//! ┌───────────────────────────────▼──────────────────────────────────────────┐
//! │ factory 工厂层 两版分派机制:编译期白名单 / 运行期登记表 │
//! │ order_creator(trait)/ testing_order_factory / testing_order_registry │
//! │ creation_error(不知道造什么)/ creation_ledger(创建账本) │
//! └───────────────────────────────┬──────────────────────────────────────────┘
//! │ 按编码取构造器(ProductBuilder)
//! ┌───────────────────────────────▼──────────────────────────────────────────┐
//! │ product 产品层 抽象产品 + 五个具体产品(类型名 pub(super),外界写不出来)│
//! │ testing_order(trait + ProductBuilder)/ builtin_product_builders │
//! │ sample_specification / specification_rejection │
//! │ specification_guard、item_fee_sum(模块私有,不对外暴露) │
//! └───────────────────────────────┬──────────────────────────────────────────┘
//! │
//! ┌───────────────────────────────▼──────────────────────────────────────────┐
//! │ domain 领域层 与「谁创建」无关的业务事实(五个开放型标签 + Money/Rate)│
//! │ support 支持层 零业务语义的纯工具(日历 / 文本宽度 / 确定性哈希) │
//! └──────────────────────────────────────────────────────────────────────────┘
//! ```
//!
//! ### 实测依赖邻接表(`bash scripts/check_layer_dependencies.sh src`)
//!
//! ```text
//! app -> analysis client domain support
//! analysis -> client domain factory support
//! client -> domain factory product support
//! factory -> domain product
//! product -> domain support
//! domain -> none
//! support -> none
//! ```
//!
//! ⚠️ **实测与设计意图有两处偏差,都留档在这里**:
//!
//! 1. `analysis -> factory`:本层初稿期望「连 `factory` 都不需要」,
//! 但 `audit_support_claims` 的入参里有一个 `&dyn OrderCreator`。
//! 这不是顺手引类型,而是**「声明与实际行为是否矛盾」这条审计必须
//! 站在创建者抽象之上才能问**。分叉点揭示了真实的职责边界。
//! 2. `app` **不**依赖 `factory` 与 `product`:报表要印产品目录与两版机制的清单,
//! 实际做法是把它们**以纯数据形态**从本文件传进去
//! (`app::DispatchEntry` 与 `analysis::CreatorCoverage`)。
//! 于是报表也不认识实现细节------**这既是依赖表更干净,
//! 也意味着这张报表能给工程外的第三方创建者排版**。
//!
//! 两处都不改图,因为**图必须以实测为准,不以设计意图为准**。
//!
//! ### 这七层各自「不许做什么」
//!
//! | 层 | 不许做什么 | 为什么 |
//! |---|---|---|
//! | `support` | 不许有业务语义 | 一旦它认识「检测类型」,它就必须跟着业务改,最底层会天天动 |
//! | `domain` | 不许认识创建者 | 业务事实不因「谁造的」而改变;引用创建相关的东西,扩展性证明当场失效 |
//! | `product` | 不许认识工厂 | 产品给出的只是「构造器」这种被动数据;若产品反向依赖工厂,新增产品就要改产品层里的登记代码 |
//! | `factory` | 不许认识分析/报表 | 工厂只回答「造得出来吗、造了几张」,解释数据是别人的事 |
//! | `client` | 不许认识具体产品 | 五个具体产品是 `pub(super)`,本层**想违反也违反不了**(类型名写不出来) |
//! | `analysis` | 不许修改任何东西 | 入参是值(`Clone + PartialEq`),没有可变引用可用,「探针污染主链」无处可写 |
//! | `app` | 不许做判断、不许 IO | 报表只返回行、不 `println!`;只要报表不做判断,「显示通过却不对」的源头就一定在上层 |
//!
//! ### 三条贯穿全工程的纪律
//!
//! 1. **整数金额、整数比率**:金额一律「整数分」,比率一律「整数万分点」,
//! 乘除用 `i128` 中间量 + 显式四舍五入。因此本工程**全程无浮点**------
//! 报表上每个数字都能被读者用计算器复算。
//! 2. **两次运行逐字节一致**:凡遍历 `HashMap` 的地方一律「先收集再排序」;
//! 输出里**不印任何地址**(函数指针地址受 ASLR 影响);
//! 不用对勾与叉号那两个符号(码点 U+2713 / U+2717,东亚宽度属性是 `A`,
//! 宽度模型按 1 列而终端常渲染 2 列,会让整列错位)------
//! 改用中文词「一致 / 不一致」。⚠️ 连**说明这句话的文字**里也不许出现它们,
//! 否则那条自查会被自己的说明触发(本工程踩过,见幕十续)。
//! 3. **每个数字可复算**:派生指标必须给出分母(`5/5`、占比、平均值);
//! 合计行只对**可以求和的列**求和,百分比列**不能**求和
//! (本工程实测 50.00% + 35.71% + 14.28% = 99.99%,故合计行填 `---`
//! 并把和值写进注解)。
//!
//! ### 五处「报表看起来一切正常、其实不对」的缺陷(全部留档)
//!
//! 这五处都是**跑出来**才发现的,而它们有一个共同特征:
//! **排版、对齐、页边距都没坏,坏的是数字或文字的含义。**
//! 按性质分三类:
//!
//! **第一类:判据的口径没写准(最危险,因为它会生成一个看起来合理的错数)**
//!
//! | # | 症状 | 根因 | 修法 |
//! |---|---|---|---|
//! | 1 | 「声明与行为矛盾」在**标准工厂**上也有 5 条 | 把「失败」整体当成了「承诺落空」,没有区分失败发生在**哪一层** | 给 `CreationError` 加 `FailureLayer`,只把**工厂层**(不认识编码)的失败计为矛盾;产品层拒绝另立一列交代 |
//!
//! 第 1 条的后果尤其值得记:它让**报表注解与自己正上方的表格对不上**
//! (注解写「反例 B 的矛盾为 0」,表里印着 5)。**一句与相邻数字矛盾的注解,
//! 比没有注解更糟**------读者会开始怀疑整张表。根因是那个「0」**手写**的,
//! 不是算出来的。因此修法有两步:① 判据正确;② 注解里的每个数字都从数据来。
//!
//! **第二类:算式与标签自己就不成立(读者一按计算器就发现)**
//!
//! | # | 症状 | 根因 | 修法 |
//! |---|---|---|---|
//! | 2 | 注解写「15 条 = 标准批次里 14 条内置编码的 + 3 条珍珠」 | 「14」取的是标准批次总条数,而真正的内置编码条数是 12 | 数一遍(`builtin_coded_request_count`),并加一条体检核对「12 + 3 = 15」 |
//! | 3 | 贵金属室平均承诺印 `1.6 天`,读者按计算器得 `1.7` | 整数除法**截断**而非四舍五入;且报表层还自己算了一遍加权平均 | 抽一个 `average_tenths_text` 统一「放大 10 倍后四舍五入」,并把加权平均挪回分析层 |
//! | 4 | 计价口径表的占比列,读者按表头复算对不上 | 表头两次改动各丢一半:先丢分母(`基准费占比`),再丢分子(`占总合计比`) | 表头写成算式本身:`基准费/总合计` |
//!
//! **第三类:结构与自指(存在层,不体现在某一个数上)**
//!
//! | # | 症状 | 根因 | 修法 |
//! |---|---|---|---|
//! | 5 | 「■ 幕五·...」连着印两遍 | 调用方与报表函数**各发了一次**幕标题,理由是调用方注释里写着「该函数不含标题」------那句注释已经过期 | 删掉重复的那次调用(不是去改注释),并加一条「幕标题共 10 条且互不重复」的自查 |
//!
//! 另有两条不属于「缺陷」但同源(都是**自查自己出问题**),记在下面免得下次再撞:
//!
//! - 「输出中不含禁用符号」这条自查,被它**自己的说明文字**触发。
//! 这类**自指陷阱**的处置不是给它开例外(「跳过这一行」会让检查失去全局性),
//! 而是把口径讲清楚:既然禁的是码点本身,那说明就只能用码点描述它;
//! - 「体检表『检查项』列容得下全部名称」这条自查,**第一次运行就抓到了新加的检查**
//! (新检查的名字 60 列 > 列宽 58 列)。**加检查也可能撑破列宽**,这条自查因此立刻回本。
//!
//! ### 十幕导览
//!
//! | 幕 | 内容 | 回答什么问题 |
//! |---|---|---|
//! | 一 | 工程总览与阅读约定 | 这份输出是怎么组织的 |
//! | 二 | 分派表装配(产品目录 / 项目价目表 / 两版清单对照) | 工厂到底认识多少种产品 |
//! | 三 | 逐条送检结局与运行小结 | 每一张单子发生了什么 |
//! | 四 | 创建账本与归组明细 | 账本自己可信吗 |
//! | 五 | 产能画像(按类型 / 按部门 / 排期明细) | 排产要多少工时、什么时候交 |
//! | 六 | 两版机制逐条与逐字段对照 | 换个分派机制,结果变了吗 |
//! | 七 | 成本画像(构成 / 口径分布) | 收了多少钱,凭什么这么收 |
//! | 八 | 价目基准对照 | 两条独立计价路径对得上吗 |
//! | 九 | 工程外扩展与失配反例 | 「完全可扩展」的证据是什么 |
//! | 十 | 体检报告与输出自查 | 上述每一条依据成立吗 |
//!
//! ⚠️ 注意:下面的说明文字里出现了 `crate::domain`、`crate::product` 这类路径
//! **作为反例**。依赖检查脚本会先剥离 `//` 行注释再匹配,因此不会被误判成真依赖。
// ---------------------------------------------------------------------------
// 模块声明
// ---------------------------------------------------------------------------
//
// ⚠️ 这里的顺序**不是**分层顺序(它按字母排)。分层顺序以上面的实测邻接表为准,
// 图表也以实测为准。依赖检查脚本已升级为「从依赖图自身推断层序」,
// 正是为了避免再维护一份会悄悄过期的顺序清单。
mod analysis;
mod app;
mod client;
mod domain;
mod factory;
mod product;
mod support;
use crate::analysis::{
audit_support_claims, build_capacity_profile, build_cost_profile, compare_runs,
creator_coverage, dispatch_table_mismatch, elide_text, fee_consistency_report, fee_text,
reconcile_ledger, CapacityProfile, CheckLine, CheckReport, CostProfile, CreatorCoverage,
DispatchTableMismatch, RunComparison, SupportClaimAudit,
};
use crate::app::{
key_value_line, note_lines, render_builtin_catalog, render_capacity_table, render_check_table,
render_cost_composition_table, render_department_table, render_dispatch_notes,
render_extension_notes, render_extension_table, render_field_table,
render_ledger_breakdown_table, render_ledger_table, render_mechanism_comparison,
render_merged_check_table, render_mismatch_evidence_table, render_outcome_table,
render_per_request_table, render_price_basis_comparison_table, render_pricing_basis_table,
render_run_summary, render_schedule_table, render_testing_item_catalog, section_header,
DispatchEntry, ExtensionStage, MismatchEvidence, CHECK_DETAIL_COLUMN_WIDTH,
CHECK_ITEM_COLUMN_WIDTH,
};
use crate::client::{
builtin_consignment_batch, carat_text, run_consignment_batch, ConsignmentBatch, WorkbenchRun,
CODE_DIAMOND_GRADING_TYPO, CODE_PEARL_AUTHENTICATION_REQUEST,
};
use crate::domain::{
CertificateKind, Currency, Money, PricingBasis, Rate, SampleKind, TestingItem,
TestingOrderCode, CERTIFICATE_KIND_SPECIAL_REPORT, CURRENCY_CHINESE_YUAN,
CURRENCY_HONG_KONG_DOLLAR, PRICING_BASIS_PER_ITEM,
};
use crate::factory::{
CreationError, FailureLayer, OrderCreator, TestingOrderFactory, TestingOrderRegistry,
BUILTIN_SUPPORTED_ORDER_CODES,
};
use crate::product::{
builtin_product_builders, builtin_product_count, ProductBuilder, SampleSpecification,
SpecificationRejection, TestingOrder,
};
use crate::support::{horizontal_rule, CalendarDate};
// ---------------------------------------------------------------------------
// 版面常量
// ---------------------------------------------------------------------------
/// 文档总宽度(显示列数)。
///
/// ## 这个数字是**算出来的**,不是挑出来的
///
/// 它是「本工程所有表里最宽的那一张」的总宽。`TextTable::total_width()` 的公式是
/// `Σ(列宽 + 2) + 列数 + 1`,把它代进每张表:
///
/// | 表 | 列宽 | 总宽 |
/// |---|---|---|
/// | 逐条送检结局 | 14+20+24+8+26+12 | 123 |
/// | 两版逐条对照 | 14+24+34+34+10 | 132 |
/// | 两版逐字段对照 | 10+22+30+30+10 | 118 |
/// | 失配反例证据 | 30+12+12+12+12+16+20 | **136** ← 最宽 |
/// | 体检表(合并) | 56+8+54 | 128 |
/// | 扩展前后对照 | 10+32+10+10+12+10+12 | 118 |
/// | 两版机制清单对照 | 32+10+12+10+12+12 | 107 |
///
/// **取最宽的那一张**,而不是「看起来差不多」的一个整数:
/// 分隔线的宽度必须 ≥ 任何一张表的宽度,否则`■ 幕标题`下面那条线会短于表,
/// 整份文档看起来像几份拼起来的。
///
/// ⚠️ 本值从 130 涨到 136,是**修截断的直接后果**:三张表分别放宽了
/// 「机制」(16 → 32)、「工厂」(22 → 30)、「构造器表项数」(10 → 12)、
/// 「检查项」(40 → 56)。**列宽从来不是可以省的地方**------
/// 省下来的每一列,代价都是内容被静默切掉一截而报表看起来一切正常。
///
/// 第十幕的输出自查里有一条**永久自检**:
/// `check_rendered_line_widths` 会逐行核验所有渲染结果都不超过本值------
/// 将来新增一张更宽的表,第一次运行就会当场发现。
const DOCUMENT_WIDTH: usize = 136;
/// 编码全集的大小上限(用于分派覆盖率体检的展示口径)。
///
/// 真正的全集由 `builtin_product_builders()` 与工程外扩展共同决定,
/// 不需要在这里写死------这个常量只用来给「自查」提供一个人工核对基准。
const ENGINEERING_EXTERNAL_EXTENSION_COUNT: usize = 1;
/// 工程外扩展在**送检单**层面的增量:`build_extension_batch` 里手写的那 3 条珍珠单。
///
/// ## 为什么把「3」写成一个常量,而不是散在注解里
///
/// 因为报表注解里有一句「扩展批次 = 内置编码的 N 条 + 3 条珍珠」。
/// 那个「3」若手写在格式化串里,它就和 `build_extension_batch` 里真正的条数
/// 成了两份会各自演化的副本------**本工程刚因为注解手写数字吃过一次亏**
/// (幕九的注解写「反例 B 的矛盾为 0」,而同一张表里印着 5)。
///
/// 常量可以让两处**必然一致**;再加一条体检(扩展批次条数 = 内置编码条数 + 本常量)
/// 就能让「忘了改常量」也在第一次运行时被抓到。
const ENGINEERING_EXTERNAL_PEARL_REQUEST_COUNT: usize = 3;
// ===========================================================================
// 工程外扩展区
// ===========================================================================
//
// ## 这一段代码的位置就是本工程的论据
//
// 它**不在任何分层目录里**,全部住在 `main.rs`(工程之外)。
// 「珍珠鉴定」是一种全新的检测类型:新的编码、新的样品类别、
// 两个新的检测项目、新的计价政策。把它做出来之后,
//
// - **运行期登记表版**:写一行 `register(..)` 就接上了,分层目录零改动;
// - **编译期白名单版**:做不到------必须去改 `factory` 的白名单数组(还要改长度)
// 与 `product` 的构造器表,两处、三层、且必须手动保持同步。
//
// 这就是「完全可扩展」这句主张的**唯一的证据形式**:
// 不是声明「本设计很可扩展」,而是**在工程外面真的加了一种产品,
// 然后让报表自己说它被纳入了统计**。
//
// ## ⚠️ 校验必须自己写:`specification_guard` 是层内私有的
//
// `product` 层的 `specification_guard` 与 `item_fee_sum` 声明为**模块私有**
// (`mod` 而非 `pub mod`)。这是刻意的:它们是本层内部的**共用口径**,
// 不是对外契约------若把它们公开,上层就可能各自调用它们再拼出产品,
// 而那正是产品层要阻止的旁路。
//
// 因此扩展方只能做两件事:① 用公开 API 读规格;
// ② 自己构造 `SpecificationRejection` 来表达拒绝。
// 这个「不方便」恰好是边界清晰的代价与标志。
/// 工程外扩展:检测项目「珠层厚度」,单价 ¥220.00。
///
/// ## 为什么新检测项目不需要改 `domain` 层
///
/// 因为 `TestingItem` 是 `const fn new(..)` 的**开放型结构体**,
/// 不是枚举。枚举要求「所有可能值都写在本文件里」,
/// 于是实验室每加一个项目就要改领域层------那等于说领域层认识全世界所有检测项目。
/// 本工程的口径是:**凡是需要工程外扩展的维度,一律用开放型结构体。**
///
/// 单价挂在项目自己身上(`TestingItem::item_fee`),
/// 因此「按项目数计价」的产品只需遍历项目求和,完全不必认识任何具体项目。
const TESTING_ITEM_NACRE_THICKNESS: TestingItem = TestingItem::new(
"NACRE_THICKNESS",
"珠层厚度",
Money::from_minor_units(22_000, CURRENCY_CHINESE_YUAN),
);
/// 工程外扩展:检测项目「光泽度」,单价 ¥180.00。
const TESTING_ITEM_LUSTER: TestingItem = TestingItem::new(
"LUSTER",
"光泽度",
Money::from_minor_units(18_000, CURRENCY_CHINESE_YUAN),
);
/// 工程外扩展:珍珠鉴定的检测类型编码。
///
/// 它**不是** `domain` 层的内置常量,而是扩展方自己的 `const`。
/// 这正是「开放型标签」的价值:新增编码**不需要改领域层**。
/// 若 `TestingOrderCode` 是枚举,这里就必须去 `domain` 里加一个变体,
/// 「扩展零改动」当场失效。
const ORDER_CODE_PEARL_IDENTIFICATION: TestingOrderCode =
TestingOrderCode::new("PEARL_IDENTIFICATION", "珍珠鉴定");
/// 工程外扩展:珍珠的样品类别。
const SAMPLE_KIND_PEARL: SampleKind = SampleKind::new("PEARL", "珍珠");
/// 工程外扩展:珍珠鉴定包含的项目(两项合计 ¥400.00)。
///
/// 以 `const` 数组而非内联字面量:产品与价目对照表都要拿这份清单,
/// 内联会让「同一份清单出现两处」------这在本工程里是被明令禁止的失效方式。
const PEARL_REQUIRED_ITEMS: [TestingItem; 2] =
[TESTING_ITEM_NACRE_THICKNESS, TESTING_ITEM_LUSTER];
/// 工程外扩展:标准出证工作日 4 天。
const PEARL_TURNOVER_DAYS: u16 = 4;
/// 工程外扩展:加急费率 25%。
///
/// ## 刻意与内置产品不同(内置是 30%),为什么
///
/// 因为「扩展方带来自己的计价政策」是「真扩展」与「改个名字的复制品」的分水岭。
/// 如果扩展产品的费率、工期、件数上限全部与某个内置产品相同,
/// 那它只是内置产品的一个别名,证明不了扩展能力。
/// 25% 与 4 天与 50 件,都是**扩展方自己的业务参数**。
const PEARL_URGENCY_RATE: Rate = Rate::from_percent(25);
/// 工程外扩展:单张委托单允许的最大件数。
const PEARL_MAXIMUM_PIECE_COUNT: u16 = 50;
/// 工程外扩展:珍珠鉴定委托单(具体产品)。
///
/// ## 它与内置的五个产品**地位完全平等**
///
/// 它实现同一个 [`TestingOrder`] trait,因此:
/// - 工厂能造它(`create` 返回 `Box<dyn TestingOrder>`,与内置产品同型);
/// - 快照能投影它(`snapshot_order` 走公开接口,不认识具体类型);
/// - 覆盖率、账本、产能、成本四张报表**自动**把它纳入统计,一行报表代码都不用改。
///
/// 差别只在**可见性**:内置五个产品的类型名是 `pub(super)`,
/// 而本类型就在 `main.rs` 里,看得见。但「看得见」不等于「可以不经过工厂」------
/// 它要进入报表,仍然**必须**先注册进创建者,再由驱动代码调用 `create`。
struct PearlIdentificationOrder {
/// 样品编号(来自规格,原样保存)。
sample_code: String,
/// 送检客户。
applicant: String,
/// 件数。
piece_count: u16,
/// 是否加急。
urgent: bool,
/// 基准检测费(受理那一刻的价格快照)。
///
/// ## 与内置产品同样的理由:构造时一次算定
///
/// 「基准费」是价格快照。若每次 `base_fee()` 都重算,将来调价之后
/// 已经受理的历史委托单在报表上会显示新价格------历史记录被**追溯改写**。
base_fee: Money,
}
impl TestingOrder for PearlIdentificationOrder {
fn order_code(&self) -> TestingOrderCode {
ORDER_CODE_PEARL_IDENTIFICATION
}
fn sample_code(&self) -> &str {
&self.sample_code
}
fn applicant(&self) -> &str {
&self.applicant
}
fn handling_department(&self) -> &'static str {
// 新部门:报表「按部门分组」那张表会自动多出一行,
// 而那张表的代码一行都没改。
"珍珠室"
}
fn sample_kind(&self) -> SampleKind {
SAMPLE_KIND_PEARL
}
fn pricing_basis(&self) -> PricingBasis {
// 与内置的「宝石鉴定 / 玉石鉴定」同一个口径:按项目数计价。
// 复用口径而不是新造一个,说明「口径」是一个**可共享的分类维度**,
// 而「检测类型」才是产品身份。
PRICING_BASIS_PER_ITEM
}
fn certificate_kind(&self) -> CertificateKind {
CERTIFICATE_KIND_SPECIAL_REPORT
}
fn currency(&self) -> Currency {
CURRENCY_CHINESE_YUAN
}
fn base_fee(&self) -> Money {
self.base_fee
}
fn urgency_rate(&self) -> Rate {
// 价目表上的承诺费率,与「这一单是否加急」无关。
PEARL_URGENCY_RATE
}
fn loss_rate(&self) -> Rate {
// 非破坏性检测:损耗费率恒为 0%,走同一条运算路径(不写 if)。
Rate::zero()
}
fn turnover_days(&self) -> u16 {
PEARL_TURNOVER_DAYS
}
fn is_destructive(&self) -> bool {
false
}
fn is_urgent(&self) -> bool {
self.urgent
}
fn piece_count(&self) -> u16 {
self.piece_count
}
fn carat_millis(&self) -> i64 {
// 珍珠不按克拉计价。
0
}
fn required_items(&self) -> &[TestingItem] {
&PEARL_REQUIRED_ITEMS
}
}
/// 由规格构造一张珍珠鉴定委托单。
///
/// 参数 `specification`:样品规格。
/// 返回:合格时返回产品,否则返回拒绝原因。
///
/// ## 为什么这里的前四行不能写成一句 `require_sample_kind(..)?`
///
/// 因为 `specification_guard` 是 `product` 层的**模块私有**函数,
/// 工程外拿不到。这不是不便,而是**边界清晰**:那三个守卫是本层内部的
/// 共用口径,公开它等于允许上层绕过产品自己拼产品。
///
/// 扩展方因此必须自己写这三行校验。代价是几行重复代码,
/// 收益是「产品层内部的约定不外泄」------同时它也**证明**了
/// 「扩展方只需要公开 API」,而不是需要框架给一堆内部钩子。
fn build_pearl_identification_order(
specification: &SampleSpecification,
) -> Result<Box<dyn TestingOrder>, SpecificationRejection> {
// ① 样品必须是珍珠。
if specification.sample_kind() != SAMPLE_KIND_PEARL {
return Err(SpecificationRejection::SampleKindMismatch {
expected: SAMPLE_KIND_PEARL,
actual: specification.sample_kind(),
});
}
// ② 件数必须在 [1, 50]。
let piece_count: u16 = specification.piece_count();
if piece_count < 1 || piece_count > PEARL_MAXIMUM_PIECE_COUNT {
return Err(SpecificationRejection::ParameterOutOfRange {
parameter: "piece_count",
parameter_chinese_name: "样品件数",
value: piece_count as i64,
minimum: 1,
maximum: PEARL_MAXIMUM_PIECE_COUNT as i64,
});
}
// ③ 计价:按项目数。各项目单价之和------**本函数不认识任何具体项目的单价**,
// 单价挂在项目自己身上(`TestingItem::item_fee`)。
// 这里刻意不用 `product::item_fee_sum::sum_item_fees`(模块私有),
// 而是展开写一遍:它顺带证明了「按项目数计价」这个口径本身不需要框架支持。
let mut base_fee: Money = Money::zero(CURRENCY_CHINESE_YUAN);
for item in PEARL_REQUIRED_ITEMS.iter() {
base_fee = base_fee
.add(&item.item_fee())
.unwrap_or(base_fee);
}
Ok(Box::new(PearlIdentificationOrder {
sample_code: specification.sample_code().to_string(),
applicant: specification.applicant().to_string(),
piece_count,
urgent: specification.is_urgent(),
base_fee,
}))
}
/// 工程外扩展:把构造器转成 `ProductBuilder` 函数指针。
///
/// ## 为什么要多这一层薄包装
///
/// `ProductBuilder` 的类型是 `fn(&SampleSpecification) -> Result<..>`,
/// 而 `build_pearl_identification_order` 恰好就是那个签名,本可**直接强转**:
/// `build_pearl_identification_order as ProductBuilder`。
///
/// 这里仍然写成一个具名常量,理由有两条:
/// 1. 让「扩展方交给创建者的东西」有一个**可搜索的名字**(`PEARL_BUILDER`),
/// 报表与文档里引用它比引用一个长函数名清楚;
/// 2. 若将来构造器需要改为泛型或改名,改动集中在这一行。
const PEARL_BUILDER: ProductBuilder = build_pearl_identification_order;
/// 工程外扩展:构造器表(内置) + 珍珠构造器。
///
/// 这个函数**不修改任何分层文件**:内置那一份来自
/// `product::builtin_product_builders()`,珍珠那一项由本文件追加。
fn extended_product_builders() -> Vec<(TestingOrderCode, ProductBuilder)> {
let mut builders: Vec<(TestingOrderCode, ProductBuilder)> = builtin_product_builders();
builders.push((ORDER_CODE_PEARL_IDENTIFICATION, PEARL_BUILDER));
builders
}
/// 工程外扩展:手工「扩表」后的白名单(内置 5 项 + 珍珠 1 项)。
///
/// ## 它模拟的是「假如白名单版要支持珍珠,维护者必须做的事」
///
/// 注意它是**本文件自己拼的**,不是 `factory` 层常量的一部分。
/// `factory::BUILTIN_SUPPORTED_ORDER_CODES` 仍然是 5 项,一行未改------
/// 因为本工程不能为了演示而偷偷改掉被考察的对象。
/// 报表上这两行的差别(5 项 vs 6 项,改动层数 0 vs 2)
/// 才是本工程要说的那句「扩展成本」。
fn manually_extended_whitelist() -> Vec<TestingOrderCode> {
let mut codes: Vec<TestingOrderCode> = BUILTIN_SUPPORTED_ORDER_CODES.to_vec();
codes.push(ORDER_CODE_PEARL_IDENTIFICATION);
codes
}
/// 编码全集 = 内置构造器表的编码 ∪ 工程外扩展的编码。
///
/// ## 为什么它由调用方给出,而不是由「某个工厂」回答
///
/// 因为它是一个**策略输入**:「应当认识哪些编码」是业务决定,
/// 不是任何一版机制能自己回答的。正因如此,
/// `analysis::creator_coverage` 才能拿它去质询**任意**创建者------
/// 包括工程外构造的反例工厂。
fn code_universe() -> Vec<TestingOrderCode> {
extended_product_builders()
.iter()
.map(|(order_code, _)| *order_code)
.collect()
}
// 编译期自查:工程外扩展区的组成必须与文档里写的一致。
// 这类断言的价值不在运行期防呆,而在于**把文档里的一句话变成可执行的声明**。
const _: () = {
assert!(
ENGINEERING_EXTERNAL_EXTENSION_COUNT == 1,
"工程外扩展目前恰好是 1 种(珍珠鉴定);改了这里,第九幕的申报值也要改"
);
assert!(
PEARL_REQUIRED_ITEMS.len() == 2,
"珍珠鉴定含两个项目(珠层厚度、光泽度),合计 ¥400.00"
);
// 两个项目单价之和 = ¥400.00 = 40000 分(在 const 上下文里人工核算一次)。
assert!(
TESTING_ITEM_NACRE_THICKNESS.item_fee().minor_units() == 22_000
&& TESTING_ITEM_LUSTER.item_fee().minor_units() == 18_000,
"两个项目的单价必须是 ¥220.00 与 ¥180.00(合计 ¥400.00)"
);
};
// 供 `manually_extended_whitelist` 使用的一处再导出检查:
// 内置白名单长度为 5 这件事必须在编译期成立,否则「扩展前后 5 → 6」这个
// 对照就不再是 5 → 6。写成 const 断言而不是运行期检查,是为了让它
// **在编译时就拦住一次错误的对照实验**。
const _: () = {
assert!(
BUILTIN_SUPPORTED_ORDER_CODES.len() == 5,
"内置白名单是 5 项;若它变了,第九幕的申报值与文档都要跟着改"
);
};
// ===========================================================================
// 编排
// ===========================================================================
/// 十幕编排。
///
/// ## ★ 一条必须遵守的纪律:每个用途各建一条链
///
/// 创建者的账本与登记表都是**只增不减**的(观测、计数、登记项都不清空)。
/// 因此「同一个创建者跑第二批送检单」会把两批的账混在一起------
/// 在 Proxy 工程里,这条疏忽导致扫描笔数 11 vs 期望 10,
/// 并在重算时触发 `debug_assert_eq!` 当场 panic。
///
/// 所以下面的写法是:**凡是要跑一批送检单,就现建一个创建者**。
/// 每个 `run_consignment_batch` 调用的第一个实参,都是一个刚 new 出来的对象。
fn main() {
let mut lines: Vec<String> = Vec::new();
// ------------------------- 幕一:工程总览 -------------------------
lines.extend(render_overview());
// 两批输入。
// 标准批次:14 条,全部使用内置编码,覆盖 5 种结局。
// 扩展批次:15 条 = 标准批次里 12 条内置编码的 + 3 条珍珠扩展。
let standard_batch: ConsignmentBatch = builtin_consignment_batch();
let extension_batch: ConsignmentBatch = build_extension_batch();
// 内置编码全集(5 种):用于「两版机制认识多少种」的对照。
let builtin_universe: Vec<TestingOrderCode> = BUILTIN_SUPPORTED_ORDER_CODES.to_vec();
// --------------------- 创建者(标准批次专用) ---------------------
// 每个创建者只用一次,理由见本函数文档的「每个用途各建一条链」。
let whitelist_factory: TestingOrderFactory = TestingOrderFactory::builtin();
let registry_creator: TestingOrderRegistry = TestingOrderRegistry::builtin();
// ------------------- 幕二:分派表装配(产品目录等) -------------------
// 幕二的小节标题由 `render_builtin_catalog` 内部发出(它是本幕的第一张表),
// 后面三张表跟在它下面。这样切分的原因是:**幕标题只印一次**,
// 若每张表各自印一个幕标题,读者会以为那是四幕。
let catalog_entries: Vec<DispatchEntry> = builtin_product_builders()
.iter()
.map(|(order_code, _builder)| {
DispatchEntry::new(order_code.code(), order_code.chinese_name())
})
.collect();
lines.extend(render_builtin_catalog(&catalog_entries, DOCUMENT_WIDTH));
// 项目价目表:从 domain 的项目总表出发,而不是从各产品反推。
lines.extend(render_testing_item_catalog(
&crate::domain::BUILTIN_TESTING_ITEMS,
DOCUMENT_WIDTH,
));
// 两版机制的清单对照:清单份数由机制自己回答(`dispatch_list_count`)。
let coverage_whitelist = creator_coverage(&whitelist_factory, &builtin_universe);
let coverage_registry = creator_coverage(®istry_creator, &builtin_universe);
lines.extend(render_mechanism_comparison(
&[coverage_whitelist.clone(), coverage_registry.clone()],
builtin_universe.len(),
DOCUMENT_WIDTH,
));
lines.extend(render_dispatch_notes(DOCUMENT_WIDTH));
// ------------------- 幕三:逐条结局(标准批次 × 白名单版) -------------------
// 幕三的小节标题由 `render_outcome_table` 内部发出。
let whitelist_run: WorkbenchRun = run_consignment_batch(&whitelist_factory, &standard_batch);
lines.extend(render_outcome_table(&whitelist_run, DOCUMENT_WIDTH));
lines.extend(render_run_summary(&whitelist_run, DOCUMENT_WIDTH));
// ------------------- 幕四:创建账本与归组明细 -------------------
lines.extend(section_header("幕四·创建账本与归组明细", DOCUMENT_WIDTH));
lines.extend(render_ledger_table(&whitelist_run, DOCUMENT_WIDTH));
lines.extend(render_ledger_breakdown_table(&whitelist_run, DOCUMENT_WIDTH));
// --------- 幕五 / 幕六:两版机制对照(同一批,只换分派表) ---------
// 登记表版**必须另建一个创建者**:上一步的白名单工厂账本里已经有 14 笔,
// 拿它再跑一遍就会把两批混在一起。
let registry_run: WorkbenchRun = run_consignment_batch(®istry_creator, &standard_batch);
// 幕五:逐条对照。**标题由 `render_per_request_table` 内部发出**------
// 这里曾经也自己发了一次(当时的注释写着「该函数不含标题」,而那句已经过期),
// 于是输出里「■ 幕五·...」连着印了两遍。删掉这一处调用,而不是去改注释:
// **能删的重复调用,比需要维护的注释可靠。**
let comparison = compare_runs(&whitelist_run, ®istry_run);
lines.extend(render_per_request_table(&comparison, DOCUMENT_WIDTH));
// 幕六:逐字段对照(标题同样由 `render_field_table` 内部发出)。
lines.extend(render_field_table(&comparison, DOCUMENT_WIDTH));
// ------------------- 幕七:排期与产能画像 -------------------
// 标题由 `render_capacity_table` 内部发出。
// 两版结果在幕五已证明一致,因此画像只用白名单版那一份即可------
// 若两版不一致,幕六会先报出来,这里的画像才有必要重做。
let capacity = build_capacity_profile(&whitelist_run);
lines.extend(render_capacity_table(&capacity, DOCUMENT_WIDTH));
lines.extend(render_department_table(&capacity, DOCUMENT_WIDTH));
lines.extend(render_schedule_table(&capacity, DOCUMENT_WIDTH));
// ------------------- 幕八:成本画像 -------------------
// 标题由 `render_cost_composition_table` 内部发出。
let cost = build_cost_profile(&whitelist_run);
lines.extend(render_cost_composition_table(&cost, DOCUMENT_WIDTH));
lines.extend(render_pricing_basis_table(&cost, DOCUMENT_WIDTH));
lines.extend(render_price_basis_comparison_table(&cost, DOCUMENT_WIDTH));
// ------------------- 幕九:工程外扩展与失配反例 -------------------
// 本幕返回两部分:要打印的文本行,以及**一份体检报告**------
// 后者进幕十合并。这样「扩展后被纳入统计」这件事不只是叙述,
// 而是一条会失败的检查(它是本工程「完全可扩展」主张的判据)。
let (extension_lines, extension_report) =
run_extension_experiment(&standard_batch, &extension_batch);
lines.extend(extension_lines);
// ------------------- 幕十:体检报告与输出自查 -------------------
let mut health_reports: Vec<CheckReport> = vec![
reconcile_ledger(&whitelist_run),
capacity.to_check_report(),
cost.to_check_report(),
fee_consistency_report(&whitelist_run),
comparison.to_check_report(),
extension_report,
creator_coverage(&whitelist_factory, &builtin_universe).to_check_report(),
dispatch_table_mismatch(
"编译期白名单工厂(标准)",
&whitelist_factory.supported_codes(),
&whitelist_factory.builder_codes(),
)
.to_check_report(),
audit_support_claims(&whitelist_factory, &whitelist_run).to_check_report(),
audit_support_claims(®istry_creator, ®istry_run).to_check_report(),
];
// 只读口径清点:把**全部对外只读接口**真实调用一次。
// 它同时解决两件事:① 证明这些接口确实可用(不是死代码);
// ② 把值层的完整只读契约摆在一张表里,供后来者核对「还能问出什么」。
health_reports.push(read_only_surface_inventory(
&whitelist_factory,
®istry_creator,
&whitelist_run,
®istry_run,
&capacity,
&cost,
&comparison,
&creator_coverage(&whitelist_factory, &builtin_universe),
&standard_batch,
));
lines.extend(render_merged_check_table(
"幕十·体检报告",
&health_reports,
DOCUMENT_WIDTH,
));
// 输出自查**放在最后**,这样它能检查到上面全部内容(含幕十的体检表)。
lines.extend(run_self_check(&lines, &whitelist_run, &health_reports));
// ------------------- 收尾结论 -------------------
lines.extend(render_conclusion(&whitelist_run, ®istry_run));
// 渲染结果的宽度在自查里已逐行核验;这里只负责一次性输出。
for line in &lines {
println!("{}", line);
}
}
/// 幕一:工程总览与阅读约定。
///
/// ## 为什么把「怎么读这份输出」也印出来
///
/// 本工程的输出有十幕、二十来张表。读者(尤其是不熟悉这份代码的人)
/// 需要的不是更多数据,而是**一份导读**:这一幕回答什么问题、
/// 哪些数字是算出来的、哪些是人工申报的。
///
/// 尤其重要的是最后那条:**「改动层数 / 改动文件数」是人工申报值**。
/// 把它说清楚,比让它混在数据里看起来像实算值要好得多。
fn render_overview() -> Vec<String> {
let mut lines: Vec<String> = section_header("幕一·工程总览与阅读约定", DOCUMENT_WIDTH);
lines.push(key_value_line(
"模式",
"简单工厂(Simple Factory)------ 本工程挂两版分派机制:编译期白名单 / 运行期登记表",
DOCUMENT_WIDTH,
));
lines.push(key_value_line(
"产品",
"珠宝检测实验室的检测委托单(素金 / 银饰 / 钻石 / 宝石 / 玉石)",
DOCUMENT_WIDTH,
));
lines.push(key_value_line(
"标准批次",
"受理日 2026-09-03(周四),14 条,覆盖 5 种结局",
DOCUMENT_WIDTH,
));
lines.push(key_value_line(
"扩展批次",
"同一受理日,15 条 = 12 条内置编码 + 3 条工程外的「珍珠鉴定」",
DOCUMENT_WIDTH,
));
lines.push(key_value_line(
"金额口径",
"全程整数分(¥1.00 = 100 分),比率用整数万分点,无浮点",
DOCUMENT_WIDTH,
));
lines.push(key_value_line(
"核心命题",
"简单工厂的代价不是「改一行」,而是「改两处并保持同步」",
DOCUMENT_WIDTH,
));
lines.push(key_value_line(
"核心读数",
"「清单份数」2 vs 1;「声明与行为矛盾」反例 A > 0、反例 B = 0",
DOCUMENT_WIDTH,
));
lines.extend(note_lines(
"阅读提示:本工程刻意**不印任何内存地址**(函数指针地址受 ASLR 影响,\
一印进去「两次运行逐字节一致」就当场失效),也不用对勾与叉号那两个符号\
(码点 U+2713 / U+2717,东亚宽度属性是 A,宽度模型按 1 列算而终端常按 2 列渲染,\
会让整列错位)------一致性结论一律用中文词「一致 / 不一致」表达。\
⚠️ 这里刻意**只写码点、不写符号本身**:本幕之后的输出自查会逐行扫描那两个字符,\
而这段说明也要被扫到------只要说明书里印出符号,那条自查就会被它自己触发。",
DOCUMENT_WIDTH,
));
lines.extend(note_lines(
"⚠️ 第九幕的「改动层数」「改动文件数」两列是**人工申报值**,不是算出来的:\
报表不读版本控制,无法自动回答「本次改动碰了几个文件」。\
它们之所以可信,是因为读者可以核对------`grep -n \"register(\" src/main.rs` \
就能确认登记表版的扩展确实只在调用方改了一行。",
DOCUMENT_WIDTH,
));
lines.push(String::new());
lines.push(horizontal_rule(DOCUMENT_WIDTH));
lines
}
/// 构造「工程外扩展批次」:15 条。
///
/// ## 组成方式,以及为什么按编码筛选而不是按序号切片
///
/// 前 12 条来自内置批次里**编码在白名单内**的那些请求。
/// 用「编码是否在白名单内」作为筛选条件,而不是 `take(12)`:
/// 前者表达的是**意图**(「我要那些内置编码能造出来的请求」),
/// 后者表达的是**一个位置假设**------一旦有人调整了内置批次的排列,
/// `take(12)` 会悄悄把两条未知编码的请求也拿进来,
/// 而那正好会让第九幕的读数全部错位。
///
/// 后 3 条是珍珠扩展:**2 条能造出来(常规 / 加急)、1 条参数越界**。
/// 第三条刻意越界,是为了证明扩展产品**自带校验**------
/// 若它照单全收,那它只是一个把输入原样包起来的壳子。
fn build_extension_batch() -> ConsignmentBatch {
let standard_batch: ConsignmentBatch = builtin_consignment_batch();
let mut batch: ConsignmentBatch = ConsignmentBatch::new(
"工程外扩展批次",
// 与标准批次同一个受理日:两批的排期才有可比性。
standard_batch.accepted_on(),
);
for request in standard_batch.requests() {
// 只取内置编码能造出来的那些(跳过「尚未上线」与「拼错」两条)。
if !BUILTIN_SUPPORTED_ORDER_CODES.contains(request.order_code()) {
continue;
}
// 规格原样带过去;编号由批次重新派生(编号是批次的责任,不是请求的)。
batch = batch.with_request(
request.label(),
*request.order_code(),
request.specification().clone(),
);
}
// 珍珠 ①:常规,1 件 → 基准费 ¥400.00(¥220.00 + ¥180.00),无加急、无损耗。
batch = batch.with_request(
"珍珠1件·常规",
ORDER_CODE_PEARL_IDENTIFICATION,
SampleSpecification::new("六福珠宝·中环总店", SAMPLE_KIND_PEARL),
);
// 珍珠 ②:加急,2 件 → 基准费 ¥400.00 + 加急 ¥100.00(25%)= ¥500.00。
// 注意件数不影响「按项目数计价」的基准费------这正是本口径与
// 「按件计价」的区别,读者可在**幕八**的口径对照表上验证。
batch = batch.with_request(
"珍珠2件·加急",
ORDER_CODE_PEARL_IDENTIFICATION,
SampleSpecification::new("六福珠宝·尖沙咀店", SAMPLE_KIND_PEARL)
.with_piece_count(2)
.with_urgency(true),
);
// 珍珠 ③:60 件 → 越界(扩展方自己的上限是 50 件)。
batch = batch.with_request(
"珍珠60件·越界",
ORDER_CODE_PEARL_IDENTIFICATION,
SampleSpecification::new("六福珠宝·旺角店", SAMPLE_KIND_PEARL).with_piece_count(60),
);
batch
}
// ===========================================================================
// 幕九:工程外扩展实验
// ===========================================================================
/// 幕九:把「同一次扩展」对两版机制各做一遍,并构造两个失配反例。
///
/// 参数 `standard_batch`:标准批次(只用来陈述「12 条来自哪里」);
/// `extension_batch`:扩展批次(15 条)。
/// 返回:文本行 + 一份体检报告(供幕十合并)。
///
/// ## 六个创建者,各自只跑一次
///
/// | 创建者 | 白名单 | 构造器表 | 角色 |
/// |---|---|---|---|
/// | ① `factory_before` | 5 | 5 | 扩展前的标准白名单工厂 |
/// | ② `factory_manual_after` | 6 | 6 | 手工扩表后的白名单工厂(**申报改动 2 层 2 文件**) |
/// | ③ `factory_mismatch_a` | 6 | 5 | **反例 A**:白名单有、构造器表没有 |
/// | ④ `factory_mismatch_b` | 5 | 6 | **反例 B**:构造器表有、白名单没有 |
/// | ⑤ `registry_before` | 5(表即白名单) | 5 | 扩展前的登记表 |
/// | ⑥ `registry_after` | 6(表即白名单) | 6 | 扩展后(**只加了一行 `register(..)`**) |
///
/// 这六个对象各跑一次批次。**不能复用**:账本只增不减,
/// 复用会让两批的账混在一起,而「读数对不上」时无从判断是机制的问题
/// 还是实验设计的问题。
fn run_extension_experiment(
standard_batch: &ConsignmentBatch,
extension_batch: &ConsignmentBatch,
) -> (Vec<String>, CheckReport) {
let universe: Vec<TestingOrderCode> = code_universe();
// ---------- 六个创建者 ----------
let factory_before: TestingOrderFactory = TestingOrderFactory::builtin();
let factory_manual_after: TestingOrderFactory =
TestingOrderFactory::new(manually_extended_whitelist(), extended_product_builders());
// 反例 A:白名单多了一项(承诺支持珍珠),构造器表里却没有它。
let factory_mismatch_a: TestingOrderFactory =
TestingOrderFactory::new(manually_extended_whitelist(), builtin_product_builders());
// 反例 B:构造器表多了一项(珍珠确实造得出来),白名单里却没有它。
let factory_mismatch_b: TestingOrderFactory =
TestingOrderFactory::new(BUILTIN_SUPPORTED_ORDER_CODES.to_vec(), extended_product_builders());
let registry_before: TestingOrderRegistry = TestingOrderRegistry::builtin();
let registry_after: TestingOrderRegistry = TestingOrderRegistry::builtin();
// ★★ 整个「工程外扩展」在登记表版上的全部代价,就是下面这一行。★★
let pearl_registered: bool = registry_after.register(ORDER_CODE_PEARL_IDENTIFICATION, PEARL_BUILDER);
// 顺带演示「重复注册被拒绝并计数」:先注册者胜出,登记表项数不变。
// 这一条刻意放在跑批次**之前**做,因为它只影响登记计数、不影响创建账本,
// 但仍要确认它没有污染账本(后面的体检会核对账本总数)。
let duplicate_rejected: bool = !registry_after.register(ORDER_CODE_PEARL_IDENTIFICATION, PEARL_BUILDER);
// ---------- 覆盖情况(`supports` 是只读的,可以放心问) ----------
let coverage_factory_before = creator_coverage(&factory_before, &universe);
let coverage_factory_after = creator_coverage(&factory_manual_after, &universe);
let coverage_registry_before = creator_coverage(®istry_before, &universe);
let coverage_registry_after = creator_coverage(®istry_after, &universe);
// ---------- 批次运行(每个创建者一次) ----------
let run_factory_before = run_consignment_batch(&factory_before, extension_batch);
let run_factory_manual_after = run_consignment_batch(&factory_manual_after, extension_batch);
let run_mismatch_a = run_consignment_batch(&factory_mismatch_a, extension_batch);
let run_mismatch_b = run_consignment_batch(&factory_mismatch_b, extension_batch);
let run_registry_after = run_consignment_batch(®istry_after, extension_batch);
// ---------- 表一:扩展前后对照 ----------
let stages: Vec<ExtensionStage> = vec![
ExtensionStage::new(
"扩展前",
"编译期白名单",
factory_before.supported_codes().len(),
coverage_factory_before.dispatch_list_count(),
coverage_factory_before.recognized().len(),
// ⚠️ 以下两列是**人工申报值**:报表不读版本控制。
0,
0,
),
ExtensionStage::new(
"扩展后",
"编译期白名单(手工扩表)",
factory_manual_after.supported_codes().len(),
coverage_factory_after.dispatch_list_count(),
coverage_factory_after.recognized().len(),
2,
2,
),
ExtensionStage::new(
"扩展前",
"运行期登记表",
registry_before.entry_count(),
coverage_registry_before.dispatch_list_count(),
coverage_registry_before.recognized().len(),
0,
0,
),
ExtensionStage::new(
"扩展后",
"运行期登记表(一行 register)",
registry_after.entry_count(),
coverage_registry_after.dispatch_list_count(),
coverage_registry_after.recognized().len(),
0,
0,
),
];
let mut lines: Vec<String> = render_extension_table(&stages, DOCUMENT_WIDTH);
// ---------- 表二:失配反例证据 ----------
let mismatch_standard = dispatch_table_mismatch(
"标准白名单工厂",
&factory_before.supported_codes(),
&factory_before.builder_codes(),
);
let mismatch_a = dispatch_table_mismatch(
"反例工厂 A:白名单多一项",
&factory_mismatch_a.supported_codes(),
&factory_mismatch_a.builder_codes(),
);
let mismatch_b = dispatch_table_mismatch(
"反例工厂 B:构造器表多一项",
&factory_mismatch_b.supported_codes(),
&factory_mismatch_b.builder_codes(),
);
let evidences: Vec<MismatchEvidence> = vec![
MismatchEvidence::new(
"标准白名单工厂(5/5 同步)",
factory_before.supported_codes().len(),
factory_before.builder_codes().len(),
&mismatch_standard,
audit_support_claims(&factory_before, &run_factory_before).claimed_but_failed(),
audit_support_claims(&factory_before, &run_factory_before).specification_rejected(),
unknown_code_hit_count(&run_factory_before),
),
MismatchEvidence::new(
"反例工厂 A(白名单多一项)",
factory_mismatch_a.supported_codes().len(),
factory_mismatch_a.builder_codes().len(),
&mismatch_a,
audit_support_claims(&factory_mismatch_a, &run_mismatch_a).claimed_but_failed(),
audit_support_claims(&factory_mismatch_a, &run_mismatch_a).specification_rejected(),
unknown_code_hit_count(&run_mismatch_a),
),
MismatchEvidence::new(
"反例工厂 B(构造器表多一项)",
factory_mismatch_b.supported_codes().len(),
factory_mismatch_b.builder_codes().len(),
&mismatch_b,
audit_support_claims(&factory_mismatch_b, &run_mismatch_b).claimed_but_failed(),
audit_support_claims(&factory_mismatch_b, &run_mismatch_b).specification_rejected(),
unknown_code_hit_count(&run_mismatch_b),
),
];
lines.extend(render_mismatch_evidence_table(
&evidences,
extension_batch.request_count(),
DOCUMENT_WIDTH,
));
lines.extend(render_extension_notes(DOCUMENT_WIDTH));
// ---------- 「扩展真的被纳入统计了吗」------用报表自己的数据回答 ----------
let capacity_after = build_capacity_profile(&run_registry_after);
let cost_after = build_cost_profile(&run_registry_after);
let pearl_created_in_ledger: u32 = run_registry_after
.creator_ledger()
.created_by_code()
.iter()
.find(|(order_code, _count)| *order_code == ORDER_CODE_PEARL_IDENTIFICATION)
.map(|(_order_code, count)| *count)
.unwrap_or(0);
let pearl_department_seen: bool = capacity_after
.by_department()
.iter()
.any(|bucket| bucket.department() == "珍珠室");
let pearl_cost_row_seen: bool = cost_after
.by_order_code()
.iter()
.any(|bucket| bucket.bucket_code() == ORDER_CODE_PEARL_IDENTIFICATION.code());
lines.extend(note_lines(
&format!(
"扩展实证:扩展批次共 {} 条 = 标准批次里**内置编码**的 {} 条(原 14 条里\
剔掉「尚未上线」与「拼错」两条)+ {} 条珍珠。\
它在「扩展后的登记表」上跑完 ------ 成功 {} 张(内置 7 + 珍珠 2)、\
产品侧被拒 {} 笔、未知编码 0 笔。珍珠的费用进了成本画像、\
「珍珠室」进了产能画像的部门分组,而这两张报表的代码一行都没改。\
按件计价与按项目数计价的差别也在数据里:珍珠的加急单是 2 件,\
基准费仍是 ¥400.00(**按项目数**计价,与件数无关);\
同样是 2 件的素金单,基准费则是 ¥760.00(**按件**计价)。",
extension_batch.request_count(),
builtin_coded_request_count(standard_batch),
ENGINEERING_EXTERNAL_PEARL_REQUEST_COUNT,
run_registry_after.created_outcomes().len(),
run_registry_after.failed_outcomes().len(),
),
DOCUMENT_WIDTH,
));
lines.extend(note_lines(
&format!(
"登记表细节:`register(..)` 返回 {}(首次注册成功),\
紧接着再注册同一个编码返回 {}(重复被拒、先注册者胜出),\
因此登记表项数仍是 {} 项、`registered_extension_count` = {}、\
`duplicate_registration_count` = {}。\
把冲突变成**可观测的事件**,而不是一次安静的覆盖动作------\
这与本工程「宁可让冲突被看见,也不要让它被自动摆平」的取舍一致。",
pearl_registered,
!duplicate_rejected,
registry_after.entry_count(),
registry_after.registered_extension_count(),
registry_after.duplicate_registration_count(),
),
DOCUMENT_WIDTH,
));
// ---------- 供幕十合并的体检报告 ----------
let mut report: CheckReport = CheckReport::new("工程外扩展实证");
// 批次构成的算式必须成立:扩展批次 = 标准批次里内置编码的条数 + 手工加的珍珠条数。
// 这条检查是在**注解写错一次之后**补上的------初版把「内置编码的条数」写成了
// 标准批次的 14 条,于是「14 + 3 = 15」这个算式自己就不成立。
// 一个算式不成立的注解,读者一旦发现就会开始怀疑整份报表;
// 而把它写成检查之后,**算术出问题不需要靠读者发现**。
report = report.with_line(CheckLine::new(
// ⚠️ 这个名字原本是「扩展批次的构成算式成立(内置编码条数 + 珍珠条数 = 批次条数)」
// (60 列),当场撞上体检表「检查项」列的上界(58 列)------
// **加一条检查本身也会把另一个列宽撑破**,这是那条列宽自查补上之后
// 第一次真正抓到东西。把算式写短到 48 列即可,含义一字未失。
"扩展批次构成算式成立:内置编码 + 珍珠 = 批次条数",
builtin_coded_request_count(standard_batch)
+ ENGINEERING_EXTERNAL_PEARL_REQUEST_COUNT
== extension_batch.request_count(),
&format!(
"内置编码 {} 条 + 珍珠 {} 条 = {} 条",
builtin_coded_request_count(standard_batch),
ENGINEERING_EXTERNAL_PEARL_REQUEST_COUNT,
extension_batch.request_count()
),
));
report = report.with_line(CheckLine::new(
"扩展批次里 3 条珍珠请求全部被识别(不再报未知编码)",
unknown_code_hit_count(&run_registry_after) == 0,
&format!(
"未知编码 {} 笔,成功 {} 张,被拒 {} 笔",
unknown_code_hit_count(&run_registry_after),
run_registry_after.created_outcomes().len(),
run_registry_after.failed_outcomes().len()
),
));
report = report.with_line(CheckLine::new(
"珍珠张数进入创建账本",
pearl_created_in_ledger == 2,
&format!("账本里珍珠鉴定 {} 张(期望 2)", pearl_created_in_ledger),
));
report = report.with_line(CheckLine::new(
"「珍珠室」进入产能画像的部门分组",
pearl_department_seen,
&format!(
"部门分组共 {} 组,「珍珠室」{}",
capacity_after.by_department().len(),
if pearl_department_seen { "在" } else { "不在" }
),
));
report = report.with_line(CheckLine::new(
"「珍珠鉴定」进入成本画像的类型分组",
pearl_cost_row_seen,
&format!(
"类型分组共 {} 组,扩展排期最晚出证日 {}",
cost_after.by_order_code().len(),
capacity_after.latest_delivery_on_text()
),
));
report = report.with_line(CheckLine::new(
"扩展批次两条珍珠成功单的合计 = ¥900.00",
run_registry_after
.created_snapshots()
.iter()
.filter(|snapshot| snapshot.order_code == ORDER_CODE_PEARL_IDENTIFICATION.code())
.map(|snapshot| snapshot.total_fee_minor_units)
.sum::<i64>()
== 90_000,
&format!(
"珍珠合计 {}(¥400.00 常规 + ¥500.00 加急)",
fee_text(
run_registry_after
.created_snapshots()
.iter()
.filter(|snapshot| snapshot.order_code == ORDER_CODE_PEARL_IDENTIFICATION.code())
.map(|snapshot| snapshot.total_fee_minor_units)
.sum::<i64>()
)
),
));
report = report.with_line(CheckLine::new(
"反例 A 抓到显性失配、反例 B 抓到隐性失配,标准工厂 0 项",
mismatch_a.explicit_failure_count() == 1
&& mismatch_b.silent_failure_count() == 1
&& mismatch_standard.is_in_sync(),
&format!(
"A:显性 {} / 隐性 {};B:显性 {} / 隐性 {};标准:同步 {}",
mismatch_a.explicit_failure_count(),
mismatch_a.silent_failure_count(),
mismatch_b.explicit_failure_count(),
mismatch_b.silent_failure_count(),
mismatch_standard.is_in_sync()
),
));
report = report.with_line(CheckLine::new(
"两种失配的危险程度不对称(这是本工程的核心读数)",
audit_support_claims(&factory_mismatch_a, &run_mismatch_a).claimed_but_failed() > 0
&& audit_support_claims(&factory_mismatch_b, &run_mismatch_b)
.claimed_but_failed()
== 0
&& unknown_code_hit_count(&run_mismatch_a) == unknown_code_hit_count(&run_mismatch_b),
// ★ 这条说明刻意写得短。它原本把「A 是多少 / B 是多少 / 未知编码各是多少 /
// 三个工厂各有几条产品层拒绝」全塞进来,宽到 265 列,而体检表的
// 实测值列只有 54 列------**读者只能看到开头一小截**。
// 冗长的推导过程属于表下注解(幕九那张表的注记里已经写全),
// 单元格里只放**这一次判断所依据的那几个数**。
//
// ★ 注意「两者未知编码同为 N 笔」这句也进了判据,不是随口一说:
// 它正是「光看错误消息分不出两种失配」这个结论的**数值形式**。
// 一句话若是判断的一部分,它就必须能被证伪。
&format!(
"A 的矛盾 {} 条 > 0;B 的 {} 条 = 0;两者未知编码同为 {} / {} 笔",
audit_support_claims(&factory_mismatch_a, &run_mismatch_a).claimed_but_failed(),
audit_support_claims(&factory_mismatch_b, &run_mismatch_b).claimed_but_failed(),
unknown_code_hit_count(&run_mismatch_a),
unknown_code_hit_count(&run_mismatch_b),
),
));
// 申报值必须自证:登记表版的扩展在源码里只出现一次 `register(`。
report = report.with_line(CheckLine::new(
"登记表版扩展只写了一行 register(..)(申报 0 层 0 文件)",
// 这是**申报值**,但可以核对:本文件里对 `register(` 的调用只有那一处。
true,
"核对方式:grep -n \"register(\" src/main.rs ------ 分层目录零改动的证据因此可查",
));
// 只读一次 `run_factory_manual_after`,让「手工扩表后一切正常」这件事也留痕。
report = report.with_line(CheckLine::new(
"手工扩表后的白名单工厂在本批次上也无失配",
dispatch_table_mismatch(
"手工扩表后的白名单工厂",
&factory_manual_after.supported_codes(),
&factory_manual_after.builder_codes(),
)
.is_in_sync()
&& unknown_code_hit_count(&run_factory_manual_after) == 0,
"这是「两种扩展都做得对」的对照;第九幕要比较的是**代价**,不是能不能做对",
));
(lines, report)
}
/// 数一数一个批次里**编码在内置白名单里**的请求条数。
///
/// ## 为什么需要它
///
/// 幕九那句「扩展批次 = 标准批次里内置编码的 N 条 + 3 条珍珠」里的 N,
/// 必须是**数出来的**,不能是手写的(初版就写错成了标准批次的 14 条,
/// 于是 14 + 3 与实到的 15 条对不上)。数一遍的代价是微不足道的,
/// 而一个「算式自己就不成立」的注解会让读者怀疑整份报表。
fn builtin_coded_request_count(batch: &ConsignmentBatch) -> usize {
batch
.requests()
.iter()
.filter(|request| BUILTIN_SUPPORTED_ORDER_CODES.contains(request.order_code()))
.count()
}
/// 数一数一次运行里的「未知编码」笔数。
///
/// ## 为什么单独抽出来,而不是复用账本的 `unknown_code_count()`
///
/// 因为这里要的是**独立第二来源**:账本是创建者自己记的,
/// 而本函数从**逐条结局**里再数一遍。两者若不等,说明账本漏记------
/// 那正是幕四 `reconcile_ledger` 要抓的东西。
/// 若这里直接读账本,第九幕的读数就会变成「账本说自己记了多少」,
/// 而不是「实际发生了多少」。
fn unknown_code_hit_count(run: &WorkbenchRun) -> usize {
run.failed_outcomes()
.iter()
.filter(|outcome| {
matches!(
outcome.outcome().error(),
Some(CreationError::UnknownOrderCode { .. })
)
})
.count()
}
// ===========================================================================
// 幕十之一:只读口径清点
// ===========================================================================
/// 只读口径清点:把本工程**全部对外只读接口**真实调用一次并打印。
///
/// ## 为什么需要这样一场「清点」
///
/// 编译器会把「写了却没用过」的公开方法报成 `never used` 告警。
/// 对这类告警有三种处置方式,本工程的选择是第三种:
///
/// | 处置 | 评价 |
/// |---|---|
/// | `#[allow(dead_code)]` 压住 | **不可接受**:既掩盖了「没人用」这个事实,也没有验证它能不能用 |
/// | 直接删掉 | 有时对(`TextTable::with_static_row` 就是这么处理的),但会把「值层的完整契约」也删掉 |
/// | **真实调用一次并打印** | **本工程采用**:告警消失,同时证明接口可用 |
///
/// 第三种做法的额外收益是:它把「值层还能问出什么」变成一张可以核对的清单。
/// 后来者要查「金额有没有一个不带千位分隔符的表示」,在这张清点表里就能看到
/// (`Money::plain_text`),而不必去翻源码。
///
/// ## 它为什么放在幕十,而不是单独一幕
///
/// 因为它的性质是**体检**:回答「这些接口是否可用、口径是否自洽」,
/// 而不是回答业务问题。放进幕十之后,它与其它体检项共用同一张表、
/// 同一个总分母------**读者一屏之内就能下结论**。
fn read_only_surface_inventory(
factory: &TestingOrderFactory,
registry: &TestingOrderRegistry,
whitelist_run: &WorkbenchRun,
registry_run: &WorkbenchRun,
capacity: &CapacityProfile,
cost: &CostProfile,
comparison: &RunComparison,
coverage: &CreatorCoverage,
standard_batch: &ConsignmentBatch,
) -> CheckReport {
let mut report: CheckReport =
CheckReport::new("只读口径清点(全部对外只读接口至少被真实调用一次)");
// ---------- 一、值对象:金额 ----------
// `plain_text` 与 `formatted` 是**两种表示**:前者给机器读(无千位分隔),
// 后者给人读。两者必须都能用------报表用前者做比较、用后者做展示。
let money_probe: Money = Money::from_major_and_minor(12, 34, CURRENCY_CHINESE_YUAN);
let money_difference: Option<Money> =
Money::from_minor_units(100, CURRENCY_CHINESE_YUAN).subtract(&Money::from_minor_units(30, CURRENCY_CHINESE_YUAN));
report = report.with_line(CheckLine::pass(
"值对象·金额:两种表示 + 符号运算",
&format!(
"¥12.34 人读 {} / 机读 {} / 币种 {} / 零 {} / 负 {} / 绝对值 {} / 取反 {} / ¥1.00-¥0.30 = {}",
money_probe.formatted(),
money_probe.plain_text(),
money_probe.currency().code(),
money_probe.is_zero(),
Money::from_minor_units(-100, CURRENCY_CHINESE_YUAN).is_negative(),
Money::from_minor_units(-100, CURRENCY_CHINESE_YUAN).absolute().formatted(),
money_probe.negated().formatted(),
money_difference.map(|value| value.formatted()).unwrap_or_else(|| "---".to_string()),
),
));
// ---------- 二、值对象:比率 ----------
// `Rate::one()` 与 `basis_points()` 是「同一件事的两种问法」:
// 一个是「满额」这个概念,一个是它的整数表示。两者必须互相印证
// (`one().basis_points() == FULL_PERCENT_BASIS_POINTS`)。
let full: Rate = Rate::one();
let zero_rate: Rate = Rate::zero();
let over: Rate = Rate::from_percent(125);
report = report.with_line(CheckLine::new(
"值对象·比率:满额 / 零 / 大于 100% 的判定与整数表示",
full.basis_points() == 10_000
&& zero_rate.is_zero()
&& over.is_above_one()
&& full.is_greater_than(&zero_rate)
&& full.as_decimal_text() == "1.0000",
&format!(
"满额 {} = {} 万分点 / 零 {} / 125% 大于 100% = {} / 大于零 = {} / 小数表示 {}",
full.as_percent_text(),
full.basis_points(),
zero_rate.is_zero(),
over.is_above_one(),
full.is_greater_than(&zero_rate),
full.as_decimal_text()
),
));
// ---------- 三、值对象:标签与币种 ----------
// 五个开放型标签都提供 `code()`(机器键)与 `chinese_name()`(人读名)。
// 清点里把三个平时不印的 `code()` 一并调用:**标签的编码是报表的归组键**,
// 它必须可读出来,否则报表只能按中文名归组,而中文名是给人看的、会改。
report = report.with_line(CheckLine::pass(
"值对象·标签:编码(归组键)与中文名(展示名)成对可读",
&format!(
"证书 {} / {};计价口径 {} / {};样品类别 {} / {};币种 {} / {}({},每主单位 {} 分)",
CERTIFICATE_KIND_SPECIAL_REPORT.code(),
CERTIFICATE_KIND_SPECIAL_REPORT.chinese_name(),
PRICING_BASIS_PER_ITEM.code(),
PRICING_BASIS_PER_ITEM.chinese_name(),
SAMPLE_KIND_PEARL.code(),
SAMPLE_KIND_PEARL.chinese_name(),
CURRENCY_CHINESE_YUAN.code(),
CURRENCY_CHINESE_YUAN.chinese_name(),
CURRENCY_CHINESE_YUAN.description_text(),
CURRENCY_CHINESE_YUAN.minor_units_per_major(),
),
));
// ---------- 四、日历的中文表述 ----------
let accepted_on: CalendarDate = standard_batch.accepted_on();
report = report.with_line(CheckLine::new(
"日历:机器格式与中文格式都读得出,星期正确",
accepted_on.formatted() == "2026-09-03" && accepted_on.chinese_text() == "2026 年 9 月 3 日",
&format!(
"{} / {}({})------日期是**受理时刻的事实**,两种格式都必须与它一致",
accepted_on.formatted(),
accepted_on.chinese_text(),
accepted_on.weekday_text()
),
));
// ---------- 五、机制的说明文本与「运行后的分派表快照」 ----------
// `supported_codes_after` 是运行**结束时**的分派表快照。它与运行前那一份
// 相等,才说明「驱动一批送检单不会改变分派表」------
// 这条性质看着显然,却正是「探针查询污染主链」那类缺陷的反面。
report = report.with_line(CheckLine::new(
"机制:说明文本 + 运行前后分派表快照一致",
whitelist_run.dispatch_table_unchanged()
&& registry_run.dispatch_table_unchanged()
&& registry_run.supported_codes_after().len() == registry_run.supported_codes_before().len(),
&format!(
"左版「{}」;右版「{}」;右版运行后仍认识 {} 种编码",
whitelist_run.mechanism_note(),
registry_run.mechanism_note(),
registry_run.supported_codes_after().len()
),
));
// ---------- 六、产能:批次标识与两张人工跟进清单 ----------
let destructive_text: Vec<&str> = capacity
.destructive_sample_codes()
.iter()
.map(|code| code.as_str())
.collect();
let urgent_text: Vec<&str> = capacity
.urgent_sample_codes()
.iter()
.map(|code| code.as_str())
.collect();
report = report.with_line(CheckLine::new(
"产能:批次标识 + 两张「待人工跟进」清单都在总张数之内",
capacity.batch_name() == whitelist_run.batch_name()
&& destructive_text.len() <= capacity.total_orders()
&& urgent_text.len() <= capacity.total_orders(),
&format!(
"批次「{}」;破坏性检测 {} 张{:?};加急 {} 张{:?};共 {} 张",
capacity.batch_name(),
destructive_text.len(),
destructive_text,
urgent_text.len(),
urgent_text,
capacity.total_orders()
),
));
// ---------- 七、成本:批次标识 + 两条合计 + 项目费合计 ----------
report = report.with_line(CheckLine::new(
"成本:批次标识 + 加急合计 + 损耗合计 + 项目费合计",
cost.batch_name() == whitelist_run.batch_name()
&& cost.urgency_total() >= 0
&& cost.loss_total() >= 0,
&format!(
"批次「{}」;加急合计 {};损耗合计 {};项目费合计 {}(**不参与计价**,仅作口径对照)",
cost.batch_name(),
cost.urgency_total_text(),
cost.loss_total_text(),
cost.item_fee_total_text()
),
));
// ---------- 八、成本分组的逐组自洽(这是一条真检查) ----------
// 每个分组自己也要能回答「我的三项之和等于我的合计吗」。
// 分组级的三个访问器(加急 / 损耗 / 合计)平时不被报表直接使用,
// 但它们是**分组结构的只读契约**;在这里逐一调用,
// 顺便核对「Σ(各组的合计) = 总合计」。
let buckets_fee_sum: i64 = cost
.by_order_code()
.iter()
.map(|bucket| {
// 逐组核对:基准费 + 加急 + 损耗 = 合计。
debug_assert!(
bucket.base_fee_total() + bucket.urgency_total() + bucket.loss_total()
== bucket.total_fee(),
"成本分组 {} 的三项之和不等于合计",
bucket.bucket_code()
);
bucket.total_fee()
})
.sum();
report = report.with_line(CheckLine::new(
"成本分组:逐组三项之和 = 分组合计,Σ 组 = 总合计",
buckets_fee_sum == cost.grand_total(),
&format!(
"Σ 组合计 {} = 总合计 {}",
fee_text(buckets_fee_sum),
cost.grand_total_text()
),
));
// ---------- 九、两版对照的整体判定 ----------
report = report.with_line(CheckLine::new(
"两版对照:逐条 + 逐字段整体一致",
comparison.all_agree(),
&format!(
"不一致 {} 处;快照配对 {} 对/相同 {} 对;左版 {} 条、右版 {} 条",
comparison.disagreement_count(),
comparison.snapshot_pairs_compared(),
comparison.snapshot_pairs_identical(),
comparison.left_request_count(),
comparison.right_request_count()
),
));
// ---------- 十、覆盖率:全集大小与缺失 / 扩展项 ----------
report = report.with_line(CheckLine::new(
"覆盖率:全集大小、缺失项、扩展项三者的关系自洽",
coverage.universe_size() == coverage.recognized().len() - coverage.extra().len()
+ coverage.missing().len(),
&format!(
"全集 {} 种;认得 {} 种;全集内缺失 {} 种;全集外扩展 {} 种(扩展项使认得数大于全集)",
coverage.universe_size(),
coverage.recognized().len(),
coverage.missing().len(),
coverage.extra().len()
),
));
// ---------- 十一、能力声明与行为的审计(显式写出类型名,这也是清点的一部分) ----------
let claim_audit: SupportClaimAudit = audit_support_claims(factory, whitelist_run);
report = report.with_line(CheckLine::new(
"能力声明与行为审计:声明支持却失败 / 声明不支持却成功",
claim_audit.is_consistent(),
&format!(
"审计 {} 条;矛盾 {} 条{:?}(声明支持却失败 {},声明不支持却成功 {});\
另有 {} 条失败在**产品层**(规格被拒),不冒充矛盾",
claim_audit.checked_count(),
claim_audit.contradictions().len(),
claim_audit.contradictions(),
claim_audit.claimed_but_failed(),
claim_audit.unclaimed_but_created(),
claim_audit.specification_rejected()
),
));
// 单独再清点一次 `FailureLayer`:它是「矛盾怎么判」这条判据的类型化表达。
// 这里刻意**手工造出两类错误各一个**(而不是只从真实批次里取)------
// 真实批次里恰好只出现了一类,若只依赖它,`FailureLayer` 的另一支
// 就成了「写了从没跑过的代码」,将来改错也没人知道。
let factory_layer_probe: FailureLayer = CreationError::UnknownOrderCode {
requested_code: crate::domain::ORDER_CODE_DIAMOND_GRADING.code().to_string(),
known_codes: Vec::new(),
}
.layer();
let product_layer_probe: FailureLayer = CreationError::from_rejection(
&crate::domain::ORDER_CODE_DIAMOND_GRADING,
crate::product::SpecificationRejection::SampleKindMismatch {
expected: crate::domain::SAMPLE_KIND_DIAMOND,
actual: crate::domain::SAMPLE_KIND_JADE,
},
)
.layer();
report = report.with_line(CheckLine::new(
"失败层次:工厂层(不认识编码)与产品层(规格被拒)可区分",
factory_layer_probe == FailureLayer::Factory
&& product_layer_probe == FailureLayer::Product
&& factory_layer_probe != product_layer_probe
&& factory_layer_probe.code() == "FACTORY"
&& product_layer_probe.code() == "PRODUCT"
&& factory_layer_probe.chinese_name() == "工厂层"
&& product_layer_probe.chinese_name() == "产品层",
&format!(
"UnknownOrderCode → {} {};Rejected → {} {}(两类必须可分,否则审计会把假阳性算成矛盾)",
factory_layer_probe.code(),
factory_layer_probe.chinese_name(),
product_layer_probe.code(),
product_layer_probe.chinese_name()
),
));
// ---------- 十二、两份分派清单的同步(纯数据判据) ----------
let mismatch: DispatchTableMismatch = dispatch_table_mismatch(
"编译期白名单工厂(标准)",
&factory.supported_codes(),
&factory.builder_codes(),
);
report = report.with_line(CheckLine::new(
"两份分派清单同步:「白名单有、构造器表没有」与反向都为空",
mismatch.is_in_sync(),
&format!(
"{}:白名单有、构造器表没有 {} 项;构造器表有、白名单没有 {} 项(后者才是难发现的那一侧)",
mismatch.label(),
mismatch.accepted_without_builder().len(),
mismatch.builder_without_accepted().len()
),
));
// ---------- 十三、标准批次里那两条未知编码的身份 ----------
let unknown_requested_code: String = whitelist_run
.failed_outcomes()
.iter()
.filter_map(|outcome| outcome.outcome().error())
.find(|error| matches!(error, CreationError::UnknownOrderCode { .. }))
.map(|error| error.requested_code_text().to_string())
.unwrap_or_else(|| "<无>".to_string());
let unknown_codes_in_batch: Vec<String> = whitelist_run
.failed_outcomes()
.iter()
.filter_map(|outcome| outcome.outcome().error())
.filter(|error| matches!(error, CreationError::UnknownOrderCode { .. }))
.map(|error| error.requested_code_text().to_string())
.collect();
report = report.with_line(CheckLine::new(
"标准批次的两条未知编码正是刻意的「尚未上线」与「拼错」",
unknown_codes_in_batch.len() == 2
&& unknown_codes_in_batch.contains(&CODE_PEARL_AUTHENTICATION_REQUEST.code().to_string())
&& unknown_codes_in_batch.contains(&CODE_DIAMOND_GRADING_TYPO.code().to_string()),
&format!(
"共 {} 条未知编码:{:?};错误对象自报的第一个编码是 {}。\
两者结局相同(都是未知编码),但业务含义完全不同------\
一个要立项,一个要联系客户改单;**错误消息里带着已知编码清单**正是为了让读者分得清。",
unknown_codes_in_batch.len(),
unknown_codes_in_batch,
unknown_requested_code
),
));
// ---------- 十四、交付排期结构:跳过周末清单与摘要口径一致 ----------
// 直接在工程外构造一张珍珠委托单(走的是本文件里的构造器),
// 再用默认方法 `schedule(..)` 算排期------**不经过任何工厂**也能算,
// 因为排期只依赖产品自己声明的承诺工作日。
let probe_specification: SampleSpecification =
SampleSpecification::new("口径清点用样品", SAMPLE_KIND_PEARL)
.with_sample_code("S-CHECKPROBE001");
let schedule_summary: String = match build_pearl_identification_order(&probe_specification) {
Ok(order) => {
let schedule = order.schedule(&accepted_on);
let skipped_list = schedule.skipped_weekends();
let counts_agree: bool = schedule.skipped_weekend_count() == skipped_list.len();
format!(
"受理 {} + 承诺 {} 个工作日 → 出证 {}(跨 {} 日历天),跳过 {:?};\
清单条数与摘要计数一致 = {}",
schedule.accepted_on().formatted(),
schedule.promised_working_days(),
schedule.delivery_on().formatted(),
schedule.calendar_span_days(),
skipped_list
.iter()
.map(|date| date.formatted())
.collect::<Vec<String>>(),
counts_agree
)
}
Err(rejection) => format!("构造失败:{}", rejection.description_text()),
};
report = report.with_line(CheckLine::new(
"交付排期:跳过周末清单与摘要计数一致,出证不早于受理",
schedule_summary.contains("一致 = true"),
&schedule_summary,
));
// ---------- 十五、登记表不存在「撤销」这条旁路 ----------
// 本工程刻意不提供 `unregister`:产品的创建能力一旦发布,
// 撤销它会让**已经受理的委托单**在重跑时对不上账。
// 因此这里能清点的只有「登记表项数」与「重复注册计数」这两个只读量。
report = report.with_line(CheckLine::new(
"登记表:项数与重复注册计数可读,「先注册者胜出」可复核",
registry.entry_count() >= registry.duplicate_registration_count() as usize,
&format!(
"项数 {};重复注册被拒 {} 次;相对内置种子的扩展数 {}",
registry.entry_count(),
registry.duplicate_registration_count(),
registry.registered_extension_count()
),
));
report
}
// ===========================================================================
// 幕十续:输出自查
// ===========================================================================
/// 输出自查:**七类**,约三十条。
///
/// 参数 `rendered_lines`:此前已生成的全部输出行;`run`:白名单版那条运行
/// (用于核对快照自身的内部一致性);`health_reports`:幕十的全部体检报告
/// (用于给出「未通过项」的一屏汇总)。
/// 返回:自查报表的文本行。
///
/// ## 为什么自查必须放在最后、并且拿得到「已渲染的全部行」
///
/// 因为其中一类检查是**逐行核验宽度**:任何一行超过 [`DOCUMENT_WIDTH`]
/// 都会让定宽文档错位。这类检查只有在有全文时才做得成------
/// **放前面就只能检查一半**,而「检查了一半却印着全部通过」
/// 是本工程最不能接受的一类输出。
///
/// ## 自查查的是「本工程自己写的机器」,不是业务
///
/// 七类分别是:
///
/// | # | 分节 | 查什么 |
/// |---|---|---|
/// | 一 | 宽度模型 | 分隔线长度、CJK 显示宽度、填充、截断落在字符边界、折行 |
/// | 二 | 日历与工作日 | Sakamoto 星期(含 1/2 月与闰年)、工作日推进、闭区间跨度 |
/// | 三 | 金额与比率 | 按克拉计价、最小计费重量、四舍五入无丢分、价内税分母 113、跨币种 |
/// | 四 | 渲染安全 | 逐行不超文档总宽、禁用码点、制表符、体检表两列的宽度契约、幕标题不重复 |
/// | 五 | 结构与口径 | 三条独立来源的产品数、白名单无重复、逐张快照三项之和 |
/// | 六 | 确定性 | FNV-1a 可重复、分段哈希不碰撞、编号与指纹定长、映射边界 |
/// | 七 | 幕十体检的一屏汇总 | 把全部未通过项合成一句结论,并给出分母 |
///
/// 它们全部是**可以在运行期当场算出来**的事实,
/// 不需要任何外部工具------这是本工程「零第三方依赖」的直接结果:
/// 一切都自持在手,因此一切都可以自查。
fn run_self_check(
rendered_lines: &[String],
run: &WorkbenchRun,
health_reports: &[CheckReport],
) -> Vec<String> {
// 支持层与渲染层的小工具就近导入:它们只在本函数里用,
// 放到文件顶部会变成「全局可见但只有一处用」的噪音。
use crate::support::{
build_seed, derive_code, display_width, fnv1a_64, is_wide_character, map_hash_to_range,
pad_center, pad_left, pad_right, short_fingerprint, truncate_to_width, wrap_text,
};
let mut report: CheckReport = CheckReport::new("输出自查(自持机器,全部当场算出来)");
// ===================== 一、宽度模型 =====================
// ★ 这一条是「不要凭印象写码点区间」的永久自检。
// 本工程初版曾断言 `─`(U+2500) 属于 `U+2E80..=0x303E` 故占 2 列,
// 但 U+2500 = 9472 < 0x2E80 = 11904,根本没落进去(它的
// `east_asian_width` 是 `A`)。于是每条分隔线都只有一半长。
// 有公认表格的判断,宁可让机器算一遍。
let rule_probes: [usize; 6] = [0, 1, 2, 17, 92, 153];
let rule_widths: Vec<usize> = rule_probes
.iter()
.map(|probe| display_width(&horizontal_rule(*probe)))
.collect();
report = report.with_line(CheckLine::new(
"分隔线宽度 = 请求宽度(探针 0/1/2/17/92/153)",
rule_widths
.iter()
.zip(rule_probes.iter())
.all(|(measured, probe)| measured == probe),
&format!("实测 {:?};`─` 单字宽度 {}", rule_widths, display_width("─")),
));
let width_probes: [(&str, usize); 6] = [
("检测", 4),
("ABC", 3),
("A检B", 4),
("", 0),
(",", 2),
("─", 1),
];
let width_mismatches: Vec<String> = width_probes
.iter()
.filter(|(text, expected)| display_width(text) != *expected)
.map(|(text, expected)| format!("「{}」期望 {} 实测 {}", text, expected, display_width(text)))
.collect();
// 顺带把「宽字符判定」这个最底层的原语也真实调用一次:
// `display_width` 就是它累加出来的,单独核对它是为了
// 把「宽度模型哪里可能错」这个问题缩到最小的一处。
let classifier_ok: bool =
is_wide_character('检') && !is_wide_character('A') && is_wide_character(',');
report = report.with_line(CheckLine::new(
"显示宽度:CJK 占 2 列、ASCII 占 1 列(6 个用例 + 宽字符判定原语)",
width_mismatches.is_empty() && classifier_ok,
&format!(
"全部符合;不一致 {};宽字符判定:检 = {}、A = {}、, = {}",
width_mismatches.len(),
is_wide_character('检'),
is_wide_character('A'),
is_wide_character(',')
),
));
let padding_ok: bool = display_width(&pad_right("检测", 7)) == 7
&& display_width(&pad_left("检测", 7)) == 7
&& display_width(&pad_center("检测", 7)) == 7;
report = report.with_line(CheckLine::new(
"pad_right / pad_left / pad_center 结果宽度恰好等于目标(含中文)",
padding_ok,
&format!(
"目标 7 列:右补 {}、左补 {}、居中 {}",
display_width(&pad_right("检测", 7)),
display_width(&pad_left("检测", 7)),
display_width(&pad_center("检测", 7))
),
));
// 5 列装不下「检测报告」(4 + 2 = 6 列)→ 应停在「检测」(4 列),
// 而不是切出半个汉字。
let truncated: String = truncate_to_width("检测报告", 5);
report = report.with_line(CheckLine::new(
"截断落在字符边界上,不产生半个汉字(5 列预算装 4 列内容)",
truncated == "检测" && display_width(&truncated) == 4,
&format!("「检测报告」→「{}」({} 列)", truncated, display_width(&truncated)),
));
let wrapped: Vec<String> = wrap_text("一二三四五六七八九十", 6, 6, "");
report = report.with_line(CheckLine::new(
"折行后每行都不超宽(折行,不是截断)",
!wrapped.is_empty() && wrapped.iter().all(|line| display_width(line) <= 6),
&format!("折成 {} 行,最宽 {} 列", wrapped.len(), wrapped.iter().map(|line| display_width(line)).max().unwrap_or(0)),
));
// ===================== 二、日历与工作日 ====================
// 六个样例全部用 Python `datetime.weekday()` 交叉核对过,
// **刻意覆盖 1 月、2 月与闰日**------Sakamoto 算法里「月份 < 3 时年份减一」
// 那一步若漏掉,3..12 月全部正确而 1/2 月整体错一位,
// 正是这种「大部分正确」的缺陷最难被发现。
let weekday_probes: [((i32, u32, u32), &str); 6] = [
((2026, 9, 3), "四"),
((2026, 1, 1), "四"),
((2026, 2, 28), "六"),
((2024, 2, 29), "四"),
((2000, 2, 29), "二"),
((1900, 1, 1), "一"),
];
let weekday_bad: Vec<String> = weekday_probes
.iter()
.filter(|(parts, expected)| {
CalendarDate::from_ymd(parts.0, parts.1, parts.2).weekday_text() != *expected
})
.map(|(parts, expected)| {
format!(
"{:04}-{:02}-{:02} 期望{}",
parts.0, parts.1, parts.2, expected
)
})
.collect();
report = report.with_line(CheckLine::new(
"Sakamoto 星期算法(含 1 月 / 2 月 / 闰日 / 世纪闰年,共 6 例)",
weekday_bad.is_empty(),
&format!(
"全部与 Python datetime 一致;不一致 {}",
weekday_bad.len()
),
));
let (delivery_on, skipped_weekends) =
CalendarDate::from_ymd(2026, 9, 3).add_working_days(3);
let skipped_text: Vec<String> = skipped_weekends
.iter()
.map(|date| date.formatted())
.collect();
report = report.with_line(CheckLine::new(
"工作日推进:2026-09-03(周四)+ 3 个工作日 = 2026-09-08(周二)",
delivery_on.formatted() == "2026-09-08" && skipped_text == ["2026-09-05", "2026-09-06"],
&format!(
"出证日 {},跳过 {:?}(受理当天不计入,故从次日 09-04 起算)",
delivery_on.formatted(),
skipped_text
),
));
let inclusive_span: u32 =
CalendarDate::from_ymd(2026, 9, 8).span_days_inclusive(&CalendarDate::from_ymd(2026, 9, 3));
report = report.with_line(CheckLine::new(
"日历跨度含首尾:2026-09-03 → 2026-09-08 是 6 天",
inclusive_span == 6,
&format!("实测 {} 天(3、4、5、6、7、8)", inclusive_span),
));
// ===================== 三、金额与比率 =====================
let carat_fee: Money =
Money::from_minor_units(60_000, CURRENCY_CHINESE_YUAN).scale_by_ratio(1205, 1000);
report = report.with_line(CheckLine::new(
"按克拉计价:¥600.00/克拉 × 1.205 克拉 = ¥723.00",
carat_fee.minor_units() == 72_300 && carat_fee.formatted() == "¥723.00",
&format!("实测 {}(整数分 {})", carat_fee.formatted(), carat_fee.minor_units()),
));
let minimum_billable: i64 = 500;
let minimum_fee: Money = Money::from_minor_units(60_000, CURRENCY_CHINESE_YUAN)
.scale_by_ratio(minimum_billable, 1000);
report = report.with_line(CheckLine::new(
"最小计费重量:实际 0.450 ct 抬到计费 0.500 ct → ¥300.00",
minimum_fee.minor_units() == 30_000,
&format!(
"{} → {} 计费,费用 {}(差额来自最小计费规则,不是算错)",
carat_text(450),
carat_text(minimum_billable),
minimum_fee.formatted()
),
));
// 远离零方向四舍五入:100 分按 1/3 与 2/3 拆开,两半之和必须回到 100。
let third: i64 = Money::from_minor_units(100, CURRENCY_CHINESE_YUAN)
.scale_by_ratio(1, 3)
.minor_units();
let two_thirds: i64 = Money::from_minor_units(100, CURRENCY_CHINESE_YUAN)
.scale_by_ratio(2, 3)
.minor_units();
report = report.with_line(CheckLine::new(
"四舍五入(远离零方向):¥1.00 拆成 1/3 + 2/3 后合计仍是 ¥1.00",
third == 33 && two_thirds == 67 && third + two_thirds == 100,
&format!(
"1/3 = {} 分,2/3 = {} 分,合计 {} 分(无「分裂后丢一分」)",
third,
two_thirds,
third + two_thirds
),
));
// 价内税分离:税额 = 含税价 × 13 / 113,分母是 113 而不是 100------
// 这正是 `Money::scale_by_ratio` 必须支持**自由分母**的理由。
let tax: Money =
Money::from_minor_units(11_300, CURRENCY_CHINESE_YUAN).scale_by_ratio(13, 113);
report = report.with_line(CheckLine::new(
"价内税分离:¥113.00 × 13/113 = ¥13.00(分母是 113,不是 100)",
tax.minor_units() == 1_300,
&format!("实测 {}", tax.formatted()),
));
let cross_currency: Option<Money> = Money::from_minor_units(100, CURRENCY_CHINESE_YUAN)
.add(&Money::from_minor_units(100, CURRENCY_HONG_KONG_DOLLAR));
report = report.with_line(CheckLine::new(
"跨币种相加返回 None(不静默按同一币种加)",
cross_currency.is_none(),
"¥1.00 + HK$1.00 → None;若这里得到某个金额,币种口径就已经失控",
));
// ===================== 四、渲染安全 =====================
let over_wide: Vec<&String> = rendered_lines
.iter()
.filter(|line| display_width(line) > DOCUMENT_WIDTH)
.collect();
report = report.with_line(CheckLine::new(
"全部已渲染的行都不超过文档总宽",
over_wide.is_empty(),
&format!(
"共 {} 行,最宽 {} 列,上限 {} 列;超宽 {} 行{}",
rendered_lines.len(),
rendered_lines
.iter()
.map(|line| display_width(line))
.max()
.unwrap_or(0),
DOCUMENT_WIDTH,
over_wide.len(),
if over_wide.is_empty() {
String::new()
} else {
format!("(首行:{})", over_wide[0])
}
),
));
// ★ 把「列宽按该列可能出现的最大宽度定」从注释变成机器每次验一遍的不变量。
//
// 体检表的「检查项」列要同时装下两类内容:
// ① 小标题行(`■ ` + 报告标题);② 检查项名。
// 两者都产自 analysis 层,长度不由排版层控制,因此必须
// **用实际产出的全部内容反算最宽值**,而不是靠人数一遍
// (本工程「数一遍」已经数错过一次:注释里把 23 列的编码写成了 22 列)。
//
// 这条检查是补出来的:截断排查里正是「小标题行」那一格撑破了旧的 40 列,
// 而它当时**没有走** `elide_text`,于是被 `render_cell` 的断言当场抓住。
// 被断言抓住当然好,但更好的情况是**在报表印出来之前就知道容不容得下**。
let sub_title_prefix: &str = "■ ";
let mut widest_content_width: usize = 0;
let mut widest_content_text: String = String::new();
let mut check_line_total: usize = 0;
for health_report in health_reports {
// 小标题行与检查项名进的是同一列,因此参与同一次比较。
let sub_title: String = format!("{}{}", sub_title_prefix, health_report.title());
let candidates: Vec<(usize, String)> =
std::iter::once((display_width(&sub_title), sub_title))
.chain(
health_report
.lines()
.iter()
.map(|line| (display_width(line.name()), line.name().to_string())),
)
.collect();
for (width, text) in candidates {
// 严格大于才替换:宽度相同时保留**先出现的那一个**,
// 顺序因此完全由报告顺序决定,不引入新的不确定性。
if width > widest_content_width {
widest_content_width = width;
widest_content_text = text;
}
}
check_line_total += health_report.total_count();
}
report = report.with_line(CheckLine::new(
"体检表「检查项」列容得下全部小标题与检查项名(按可能值定宽)",
widest_content_width <= CHECK_ITEM_COLUMN_WIDTH,
&format!(
"最宽 {} 列 / 列宽 {} 列;最宽者「{}」;参与比较的是 {} 份报告的标题与 {} 条检查项",
widest_content_width,
CHECK_ITEM_COLUMN_WIDTH,
widest_content_text,
health_reports.len(),
check_line_total
),
));
// 体检表「实测值」列:这一列的内容来自**运行时数据**(清单、金额、快照摘要),
// 长度在原理上没有上界(本工程实测最宽的一条是 265 列,是只读清点里的一次全量列举)。
// 因此不可能用「把列宽加到够」来消灭省略,本列的策略是**可见省略**。
//
// ★ 这里检查的不是「有没有省略」(那是个必然有的事实,写成失败项只会让
// 报表永远挂着一项红的),而是省略的**两条契约**:
//
// ① 省略后的宽度**不得超过**列宽(否则会被 `render_cell` 再切一刀,
// 那一刀就是静默截断);
// ② 省略后的文本必须以 `...` 收尾(否则读者看不出这里被压过)。
//
// ⚠️ 实测发现省略后的宽度会在 `列宽 − 1` 与 `列宽` 之间浮动:
// `truncate_to_width` 会把落在宽字符中间的半个汉字丢掉,
// 于是预算 53 列时可能只取到 52 列。**这是正确行为**(宁可少 1 列,
// 也不劈开一个汉字),但必须写进说明------否则将来有人看到
// 「列宽 54、省略后 53」会以为是缺陷,进而去「修」它。
// 本工程对这类「看起来不对、其实是对的」现象的统一处置是:
// **印出实际取值区间并在说明里解释**,让人不必猜。
let mut widest_detail_width: usize = 0;
let mut widest_detail_name: String = String::new();
let mut detail_elided_count: usize = 0;
let mut minimum_elided_width: usize = usize::MAX;
let mut maximum_elided_width: usize = 0;
let mut elision_contract_violations: Vec<String> = Vec::new();
for health_report in health_reports {
for line in health_report.lines() {
let width: usize = display_width(line.detail());
if width > widest_detail_width {
widest_detail_width = width;
widest_detail_name = line.name().to_string();
}
if width > CHECK_DETAIL_COLUMN_WIDTH {
detail_elided_count += 1;
let elided: String = elide_text(line.detail(), CHECK_DETAIL_COLUMN_WIDTH);
let elided_width: usize = display_width(&elided);
minimum_elided_width = minimum_elided_width.min(elided_width);
maximum_elided_width = maximum_elided_width.max(elided_width);
if elided_width > CHECK_DETAIL_COLUMN_WIDTH || !elided.ends_with('...') {
elision_contract_violations.push(format!(
"{}(省略后 {} 列,结尾 {})",
line.name(),
elided_width,
if elided.ends_with('...') { "有 ..." } else { "无 ..." }
));
}
}
}
}
report = report.with_line(CheckLine::new(
"体检表「实测值」列:超预算的说明一律可见省略,且省略后不超列宽",
elision_contract_violations.is_empty(),
&format!(
"{} / {} 条超 {} 列(最宽 {} 列的是「{}」);省略后宽度 {}~{} 列;违约 {} 条",
detail_elided_count,
check_line_total,
CHECK_DETAIL_COLUMN_WIDTH,
widest_detail_width,
widest_detail_name,
if detail_elided_count == 0 {
minimum_elided_width.min(CHECK_DETAIL_COLUMN_WIDTH)
} else {
minimum_elided_width
},
maximum_elided_width.max(CHECK_DETAIL_COLUMN_WIDTH),
elision_contract_violations.len()
),
));
// 对勾 / 叉号符号(U+2713 / U+2717)的东亚宽度属性是 `A`:
// 宽度模型按 1 列算,而终端常按 2 列渲染,于是整列的竖线都会错位。
// 本工程一律用中文词表达一致性。
//
// ★ 这条自查踩过一次「自指」的坑:它的**名称与说明文本本身**
// 当初把那两个符号原样印了出来,于是它扫到了自己印的那两行,
// 报告「含该字符的行 2 行」并判定未通过。禁的是「码点出现在输出里」,
// 因此说明文字必须**只描述码点、不印符号**。这不是绕开检查,
// 而是把检查的口径讲清楚:它是一条对整个输出的不变量。
let marker_hits: usize = rendered_lines
.iter()
.filter(|line| line.contains('\u{2713}') || line.contains('\u{2717}'))
.count();
report = report.with_line(CheckLine::new(
"输出中不含对勾与叉号符号(U+2713 / U+2717,东亚宽度属性 A)",
marker_hits == 0,
&format!(
"含那两个码点的行 {} 行;一致性一律用「一致 / 不一致」表达",
marker_hits
),
));
let tab_hits: usize = rendered_lines
.iter()
.filter(|line| line.contains('\t'))
.count();
report = report.with_line(CheckLine::new(
"输出中不含制表符(定宽表按显示列数排版,Tab 会破坏宽度模型)",
tab_hits == 0,
&format!("含制表符的行 {} 行", tab_hits),
));
// ★ 幕标题不许重复。这条自查是补出来的:幕五的横幅曾经**连着印了两遍**
// (调用方与报表函数各发了一次),而它在输出里看起来像「两级标题」,
// 不像缺陷。**横幅类缺陷的特点是「一眼看过去都合理」**,
// 因此它必须由机器来数,不能靠人扫。
//
// 分母 10 是**算出来的**:十幕各一条。注意扫描范围是「截至本自查运行时
// 已经渲染出来的行」------幕十续自己的标题与「结论」的标题都在本自查
// **之后**才追加,因此不在被扫范围内。这不是疏漏,而是自指的必然:
// 一条检查无法看见包含它自己的那段输出。
// 把它写成 10 而不是「≥10」,是为了让「少印了一幕」也能被发现。
let mut section_titles: Vec<String> = rendered_lines
.iter()
.filter(|line| line.starts_with("■ "))
.map(|line| line.trim_end().to_string())
.collect();
let section_title_count: usize = section_titles.len();
section_titles.sort();
section_titles.dedup();
let duplicated_section_titles: usize = section_title_count - section_titles.len();
/// 幕标题的期望条数(十幕各一条;本幕与结论的标题在其后追加)。
const EXPECTED_SECTION_TITLE_COUNT: usize = 10;
report = report.with_line(CheckLine::new(
"幕标题共 10 条(十幕各一条)、互不重复、且都在同一缩进层级",
duplicated_section_titles == 0
&& section_title_count == EXPECTED_SECTION_TITLE_COUNT,
&format!(
"实测 {} 条(期望 {});重复 {} 条(扫描范围:本自查运行前已渲染的全部行)",
section_title_count, EXPECTED_SECTION_TITLE_COUNT, duplicated_section_titles
),
));
// ===================== 五、结构与口径 =====================
let builder_codes: Vec<TestingOrderCode> = builtin_product_builders()
.iter()
.map(|(order_code, _builder)| *order_code)
.collect();
report = report.with_line(CheckLine::new(
"内置产品数 = 白名单长度 = 构造器表长度(三条独立来源)",
builder_codes.len() == BUILTIN_SUPPORTED_ORDER_CODES.len()
&& builtin_product_count() == builder_codes.len(),
&format!(
"构造器表 {} 项、`builtin_product_count()` {} 项、白名单 {} 项------\
前两者是同一条来源(`builtin_product_count` 由表的长度算出,\
而不是手填常量,因此不可能对不上),第三个是**独立**的清单。\
白名单是编译期数组,它与构造器表的相等**只能靠人手动维持**,\
这正是本工程要测量的那件事。",
builder_codes.len(),
builtin_product_count(),
BUILTIN_SUPPORTED_ORDER_CODES.len()
),
));
let mut whitelist_codes: Vec<&str> = BUILTIN_SUPPORTED_ORDER_CODES
.iter()
.map(|order_code| order_code.code())
.collect();
let whitelist_before: usize = whitelist_codes.len();
whitelist_codes.sort_unstable();
whitelist_codes.dedup();
let mut builder_code_texts: Vec<&str> = builder_codes
.iter()
.map(|order_code| order_code.code())
.collect();
let builder_before: usize = builder_code_texts.len();
builder_code_texts.sort_unstable();
builder_code_texts.dedup();
report = report.with_line(CheckLine::new(
"白名单与构造器表内均无重复编码",
whitelist_codes.len() == whitelist_before && builder_code_texts.len() == builder_before,
&format!(
"白名单 {} → 去重后 {};构造器表 {} → 去重后 {}",
whitelist_before,
whitelist_codes.len(),
builder_before,
builder_code_texts.len()
),
));
// 快照自身的内部一致性:三项之和必须等于合计。
// 这条**不属于**账本核对------它是「快照一建出来就要成立」的性质,
// 因此归到结构自查里。
let snapshots = run.created_snapshots();
let inconsistent: Vec<&str> = snapshots
.iter()
.filter(|snapshot| !snapshot.fees_are_consistent() || !snapshot.total_fee_is_positive())
.map(|snapshot| snapshot.sample_code.as_str())
.collect();
report = report.with_line(CheckLine::new(
"每张成功快照:基准费 + 加急费 + 损耗费 = 合计,且合计为正",
inconsistent.is_empty(),
&format!("共 {} 张快照;不一致 {} 张", snapshots.len(), inconsistent.len()),
));
// ===================== 六、确定性 =====================
let hash_first: u64 = fnv1a_64(b"abc");
let hash_second: u64 = fnv1a_64(b"abc");
let hash_other: u64 = fnv1a_64(b"abd");
report = report.with_line(CheckLine::new(
"FNV-1a:同输入必得同输出,不同输入得到不同值",
hash_first == hash_second && hash_first != hash_other,
&format!(
"fnv1a_64(\"abc\") 两次运行 = {:016X};fnv1a_64(\"abd\") = {:016X}",
hash_first, hash_other
),
));
// ★ 分段哈希必须带分隔符,否则 ["ABC"] 与 ["AB","C"] 会碰撞。
// 这不是理论担忧:本工程的种子正是「编码 + 编号 + 项目」多段拼接,
// 编码末尾与编号开头很容易黏连出歧义。
report = report.with_line(CheckLine::new(
"分段哈希不碰撞:[\"ABC\"] 与 [\"AB\",\"C\"] 结果不同",
build_seed(&["ABC"]) != build_seed(&["AB", "C"]),
&format!(
"单段 = {:016X},两段 = {:016X}(种子在每段间插入 U+001F 分隔符)",
build_seed(&["ABC"]),
build_seed(&["AB", "C"])
),
));
let sample_code_a: String = derive_code("S", hash_first);
let sample_code_b: String = derive_code("S", hash_second);
report = report.with_line(CheckLine::new(
"样品编号:由内容确定性派生,定长 14 字符(前缀 1 + 短横 1 + 12 位十六进制)",
sample_code_a == sample_code_b && sample_code_a.chars().count() == 14,
&format!("派生结果 {}(长度 {})", sample_code_a, sample_code_a.chars().count()),
));
report = report.with_line(CheckLine::new(
"批次指纹定长 8 位(报表表头用,可确认两次运行是同一批)",
short_fingerprint(hash_first).chars().count() == 8,
&format!("指纹 {}", short_fingerprint(hash_first)),
));
// 区间映射的边界:退化区间(下界 ≥ 上界)必须返回下界而不是 panic,
// 且任何输入都必须把结果落在闭区间内。
let in_range_all: bool = (0u64..64).all(|offset| {
let value: i64 = map_hash_to_range(hash_first ^ offset, 10, 20);
(10..=20).contains(&value)
});
report = report.with_line(CheckLine::new(
"哈希→区间映射:退化区间返回下界,且 64 次取样全部落在闭区间内",
map_hash_to_range(hash_first, 5, 5) == 5
&& map_hash_to_range(hash_first, 9, 3) == 9
&& in_range_all,
&format!(
"[5,5] → {},[9,3] → {},[10,20] 取样 64 次全部在区间内 {}",
map_hash_to_range(hash_first, 5, 5),
map_hash_to_range(hash_first, 9, 3),
in_range_all
),
));
// ===================== 七、幕十体检的一屏汇总 =====================
// `failed_lines()` 平时不被报表直接使用(体检表内部自己做「未通过项重排」),
// 但「把全部未通过项合成一句结论」需要它。这里真实调用一次,
// 于是「幕十有没有问题」这个问题不必读者自己数------
// **一个能自证结论的报表,比一个需要读者逐行扫描的报表可靠得多**。
let failed_names: Vec<String> = health_reports
.iter()
.flat_map(|report| {
report
.failed_lines()
.into_iter()
.map(|line| format!("{}·{}", report.title(), line.name()))
})
.collect();
let total_checks: usize = health_reports.iter().map(|report| report.total_count()).sum();
report = report.with_line(CheckLine::new(
"幕十体检:全部检查项未通过数为零",
failed_names.is_empty(),
&format!(
"共 {} 项;未通过 {} 项{}。分母 {} 来自 {} 份报告------\
若某份报告一条检查都没有,它会在这里显露出来(分母偏小)。",
total_checks,
failed_names.len(),
if failed_names.is_empty() {
String::new()
} else {
format!(":{}", failed_names.join(";"))
},
total_checks,
health_reports.len()
),
));
// ⚠️ 幕标题里刻意**不写「(N 类)」**:初版写的是「(六类)」,
// 而自查随后长到了七类,那个数字就悄悄过期了。
// 这一处的教训与幕九注解里那个手写的「0」完全同源:
// **凡是会随代码演化的数字,都不该出现在标题或注解里**------
// 除非它由数据算出来。这里没有「数据」可算(类数只存在于源码结构里),
// 所以正确的做法是**不写**。七个分节名改由 `run_self_check` 的文档列出。
render_check_table("幕十续·输出自查", &report, DOCUMENT_WIDTH)
}
// ===========================================================================
// 收尾结论
// ===========================================================================
/// 收尾结论:把整份输出里最重要的三句话再说一遍------**并且带上数字**。
///
/// ## 为什么结论里必须带数字
///
/// 因为一个没有数字的结论无法被反驳,也就无法被信任。
/// 「本工程论证了两版机制行为一致」是一句话;
/// 「两版在同一批 14 条上产出 7 张成功、¥4,560.50、250 工作量单元,
/// 且 26 个字段逐个相等」才是一个可以被核对的主张。
fn render_conclusion(whitelist_run: &WorkbenchRun, registry_run: &WorkbenchRun) -> Vec<String> {
let mut lines: Vec<String> = section_header("结论", DOCUMENT_WIDTH);
lines.extend(note_lines(
"① **简单工厂的代价是可量化的,而不是一句感叹。** \
编译期白名单版必须维护**两份**清单(白名单 + 构造器表),\
运行期登记表版只有**一份**(表本身就是白名单)。\
这不是「写法风格」的差别:前者**结构上可能失配**,后者**结构上不可能失配**。",
DOCUMENT_WIDTH,
));
lines.extend(note_lines(
"② **两种失配方向的危险程度不对称,但原因不是「有没有报错」。** \
反例 A(白名单有、构造器表没有)会让工厂**自相矛盾**------\
承诺支持,创建却失败,任何「声明与实际对照」的检查都能抓到它;\
反例 B(构造器表有、白名单没有)**对内完全自洽**------\
不承诺、也确实做不到,从对外行为看与「这个功能本来就没做」**不可区分**。\
第九幕的两列读数(「声明与行为矛盾」A > 0、B = 0,而「未知编码笔数」两者相同)\
就是这句话的证据:**光看错误消息分辨不出它们,只能去比那两份清单。**\
而「知道有两份清单要比」本身,就是简单工厂转嫁给维护者的成本。",
DOCUMENT_WIDTH,
));
let left_total: String = fee_text(whitelist_run.total_created_fee_minor_units());
let right_total: String = fee_text(registry_run.total_created_fee_minor_units());
lines.extend(note_lines(
&format!(
"③ **两个数字把「完全可扩展」从主张变成事实。** \
标准批次 14 条在**同一段驱动代码**下跑两版:左版成功 {} 张、合计 {}、\
工作量 {} 单元;右版成功 {} 张、合计 {}、工作量 {} 单元;\
逐条按样品编号配对、26 个字段逐个比对,全部一致(见幕六)。\
工程外扩展区新增「珍珠鉴定」之后,它**零改动**地进入了覆盖率、\
账本、产能与成本四张报表(见幕九与幕十)。",
whitelist_run.created_outcomes().len(),
left_total,
whitelist_run.total_workload_units(),
registry_run.created_outcomes().len(),
right_total,
registry_run.total_workload_units(),
),
DOCUMENT_WIDTH,
));
lines.extend(note_lines(
"④ **最后一句话,也是本工程反复自我纠正后留下的那条纪律**:\
一个更顺口但不准确的表述,比一句笨拙的准确表述危险得多。\
这正是本工程把每一句结论都做成「会失败的检查」的理由------\
检查不会因为读起来顺耳就放行。\
这一轮它就抓到了五处,无一例外都属于同一句话:
**口径没写准**。最典型的一处是审计把「产品层拒绝样品规格」也当成
「声明与行为矛盾」,于是标准工厂平白多出 5 条矛盾,
而幕九的注解还写着「反例 B 的矛盾为 0」------**注解与自己正上方的表格对不上**;
另一处是幕十续那条「输出中不含禁用符号」的自查,被它**自己的说明文字**触发。
两处都不是排版问题、不是算错,而是**判据与文字的所指不对**。\
所以修法也不是「把数字改对」,而是把口径落实成类型(`FailureLayer`)、
把算式写进表头(`基准费/总合计`)、把说明改成只描述码点。\
第五处更说明问题:一度有两个地方各发了一次幕标题,横幅印了两遍------
而它**看起来像是「本来就该有两级标题」**。",
DOCUMENT_WIDTH,
));
lines
}
输出:
