rust: Simple Factory Pattern(续)
//!# 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 : creation_ledger_analysis.rs
//! # 创建账本分析 —— 自洽核对,以及两版机制的逐字段对照
//!
//! ## 本文件回答两个问题
//!
//! 1. **账本自己可信吗?**([`reconcile_ledger`])
//! 账本记录了「成功几张、被拒几笔、未知编码几次」,
//! 但账本只是一个累加器——它完全可能因为某处漏记而与实际发生的事不符。
//! 因此必须拿**原始结局**再去核对一遍账本,
//! 这是「独立第二来源」的核对,不是「再算一遍同样的东西」。
//!
//! 2. **两版分派机制的结果一样吗?**([`compare_runs`])
//! 这是本工程的核心论证。注意**比什么**是设计过的:
//! 只比「观察得到的东西」——逐条结局、账本计数、费用合计、工作量、
//! 认识的编码集合。**不比**「分派表内部长什么样」,
//! 因为那正是两版机制的差异所在,比它等于在比较「实现」而不是「行为」,
//! 而本工程要主张的是「两版在行为上不可区分」。
//!
//! ## 为什么对照结果要拆成「逐条」「账本字段」「汇总字段」三组
//!
//! 因为它们的**诊断价值**不同,混在一起会淹没信息:
//!
//! | 组 | 规模 | 失败时说明什么 |
//! |---|---|---|
//! | 逐条结局 | 14 条 | 精确定位到某一张送检单的差异 |
//! | 账本字段 | 4 项 | 差异发生在计数口径上(可能是漏记而非错造) |
//! | 汇总字段 | 4 项 | 差异发生在金额/工作量口径上 |
//!
//! 若全部压成 40 条平铺的检查项,读者看到「3 项未通过」时
//! 还得自己判断「这 3 项属于哪一类」。分组之后,
//! **未通过项落在哪一组,本身就是一条诊断线索**。
//!
//! ## 一个容易写错的细节:按什么配对
//!
//! 两次运行的逐条结果必须**按样品编号配对**,不能按下标配对。
//! 按下标配对在本工程里恰好也对(两版跑的是同一个批次、顺序相同),
//! 但它把「批次顺序」变成了一个隐含前提:一旦某天有人给某一版
//! 换了遍历顺序(比如登记表版本改成按登记顺序反序),
//! 按下标配对就会产出一堆**假的差异**,而与「机制是否等价」毫无关系。
//! 按样品编号配对则对顺序完全免疫。
use crate::analysis::{elide_text, CheckLine, CheckReport};
use crate::client::{CreationOutcome, RequestOutcome, WorkbenchRun};
use crate::domain::{Money, TestingOrderCode, CURRENCY_CHINESE_YUAN};
/// 把整数分格式化成带货币符号的文本。
///
/// 参数 `minor_units`:整数分。
/// 返回:如 `¥4,560.50`。
///
/// 单独抽出来是因为本文件与 `analysis` 层其他文件要反复把快照里的
/// 整数分转成可读文本。**格式化的口径只能有一处**——
/// 若各处自己拼「¥」和千位分隔,迟早会出现同一个金额在两张表上
/// 显示成不同字符串,而本工程要求两次运行逐字节一致。
pub fn fee_text(minor_units: i64) -> String {
Money::from_minor_units(minor_units, CURRENCY_CHINESE_YUAN).formatted()
}
// =============================================================================
// 一、账本自洽核对
// =============================================================================
/// 拿原始结局核对创建者的账本。
///
/// 参数 `run`:一次运行结果。
/// 返回:核对报告。
///
/// ## 核对项为什么是这 11 条
///
/// 它们覆盖三类可能出错的地方:
///
/// 1. **总数对不上**(第 1~4 项):账本记的次数与结局条数不符。
/// 这通常意味着某个创建路径**既没成功也没记失败**——账本会少一笔,
/// 而报表上只会表现为「合计比总数少 1」,很难发现。
/// 2. **明细对不上总计**(第 5~6 项):`created_by_code` 各分项之和
/// 与 `created_count` 不符。这是分项统计最常见的缺陷,
/// 而且它**只在新增编码时暴露**,平时的账是平的。
/// 3. **每张委托单自身的自洽性**(第 7~11 项):三项费用之和等于合计、
/// 合计为正、承诺工作日不超过标准周转天数、出证日不早于受理日。
/// 这些是「单张单据就错了」的错误,与前两类不同——
/// 即使账本完全准确,单张单据仍可能算错。
///
/// 第 3 类刻意做成**聚合**(`全部 7 条`),而不是逐条产出一条检查项:
/// 逐条会让报告从 11 条膨胀到 40 条,而读者对「7 条全部自洽」
/// 与「7 条逐条自洽」获得的信息量是一样的。
pub fn reconcile_ledger(run: &WorkbenchRun) -> CheckReport {
let ledger = run.creator_ledger();
// 结局侧的独立计数(不复用账本的任何数字)。
let created_from_outcomes: usize = run.created_outcomes().len();
// 被拒 = 失败且错误类型是「产品侧拒绝」。这里不用「失败总数 − 未知编码数」
// 推导,而是各自独立数一遍——理由与 CreationLedger 不用减法推导完全相同:
// 减法推导在有两条以上失败路径时容易失真,且失真的方式不显眼。
let rejected_from_outcomes: usize = run
.failed_outcomes()
.iter()
.filter(|outcome| {
outcome
.outcome()
.error()
.map(|error| !matches!(error, crate::factory::CreationError::UnknownOrderCode { .. }))
.unwrap_or(false)
})
.count();
let unknown_from_outcomes: usize = run
.failed_outcomes()
.iter()
.filter(|outcome| {
outcome
.outcome()
.error()
.map(|error| matches!(error, crate::factory::CreationError::UnknownOrderCode { .. }))
.unwrap_or(false)
})
.count();
let mut report: CheckReport = CheckReport::new("创建账本自洽核对");
// ---- 第 1 类:总数对不上 ----
report = report.with_line(CheckLine::new(
"账本尝试总数 = 请求条数",
ledger.attempted_count() as usize == run.request_count(),
&format!(
"账本 {}(= 成功 {} + 被拒 {} + 未知 {}),请求 {}",
ledger.attempted_count(),
ledger.created_count(),
ledger.rejected_count(),
ledger.unknown_code_count(),
run.request_count()
),
));
report = report.with_line(CheckLine::new(
"账本成功数 = 结局里成功条数",
ledger.created_count() as usize == created_from_outcomes,
&format!("账本 {},结局 {}", ledger.created_count(), created_from_outcomes),
));
report = report.with_line(CheckLine::new(
"账本被拒数 = 结局里被拒条数",
ledger.rejected_count() as usize == rejected_from_outcomes,
&format!("账本 {},结局 {}", ledger.rejected_count(), rejected_from_outcomes),
));
report = report.with_line(CheckLine::new(
"账本未知编码数 = 结局里未知编码条数",
ledger.unknown_code_count() as usize == unknown_from_outcomes,
&format!(
"账本 {},结局 {}",
ledger.unknown_code_count(),
unknown_from_outcomes
),
));
// ---- 第 2 类:明细对不上总计 ----
let created_detail_sum: u32 = ledger
.created_by_code()
.iter()
.map(|entry| entry.1)
.sum();
let rejected_detail_sum: u32 = ledger
.rejected_by_rule()
.iter()
.map(|entry| entry.1)
.sum();
report = report.with_line(CheckLine::new(
"成功明细之和 = 成功总数",
created_detail_sum == ledger.created_count(),
&format!(
"明细 {} 个编码合计 {},总数 {}",
ledger.created_by_code().len(),
created_detail_sum,
ledger.created_count()
),
));
report = report.with_line(CheckLine::new(
"拒绝明细之和 = 拒绝总数",
rejected_detail_sum == ledger.rejected_count(),
&format!(
"明细 {} 条规则合计 {},总数 {}",
ledger.rejected_by_rule().len(),
rejected_detail_sum,
ledger.rejected_count()
),
));
// ---- 第 3 类:单张委托单的自洽性(聚合核对)----
let snapshots = run.created_snapshots();
let fee_consistent_failures: Vec<String> = snapshots
.iter()
.filter(|snapshot| !snapshot.fees_are_consistent())
.map(|snapshot| snapshot.sample_code.clone())
.collect();
report = report.with_line(CheckLine::new(
"每张委托单:三项费用之和 = 合计",
fee_consistent_failures.is_empty(),
&format!(
"全部 {} 条;不一致 {} 条{}",
snapshots.len(),
fee_consistent_failures.len(),
listing_suffix(&fee_consistent_failures)
),
));
let non_positive_failures: Vec<String> = snapshots
.iter()
.filter(|snapshot| !snapshot.total_fee_is_positive())
.map(|snapshot| snapshot.sample_code.clone())
.collect();
report = report.with_line(CheckLine::new(
"每张委托单:合计检测费为正",
non_positive_failures.is_empty(),
&format!(
"全部 {} 条;非正 {} 条{}",
snapshots.len(),
non_positive_failures.len(),
listing_suffix(&non_positive_failures)
),
));
let empty_code_failures: Vec<String> = snapshots
.iter()
.filter(|snapshot| snapshot.sample_code.is_empty())
.map(|snapshot| snapshot.order_code.clone())
.collect();
report = report.with_line(CheckLine::new(
"每张委托单:样品编号非空",
empty_code_failures.is_empty(),
&format!(
"全部 {} 条;为空 {} 条{}",
snapshots.len(),
empty_code_failures.len(),
listing_suffix(&empty_code_failures)
),
));
let promise_failures: Vec<String> = snapshots
.iter()
.filter(|snapshot| snapshot.promised_working_days > snapshot.turnover_days)
.map(|snapshot| snapshot.sample_code.clone())
.collect();
report = report.with_line(CheckLine::new(
"每张委托单:承诺工作日 <= 标准周转天数",
promise_failures.is_empty(),
&format!(
"全部 {} 条;超出 {} 条{}",
snapshots.len(),
promise_failures.len(),
listing_suffix(&promise_failures)
),
));
let span_failures: Vec<String> = snapshots
.iter()
.filter(|snapshot| snapshot.calendar_span_days < snapshot.promised_working_days as u32)
.map(|snapshot| snapshot.sample_code.clone())
.collect();
report = report.with_line(CheckLine::new(
"每张委托单:日历跨度 >= 承诺工作日",
span_failures.is_empty(),
&format!(
"全部 {} 条;跨度不足 {} 条{}",
snapshots.len(),
span_failures.len(),
listing_suffix(&span_failures)
),
));
report
}
/// 给失败清单拼一个「(如:A、B)」后缀,空清单时返回空串。
///
/// 参数 `failures`:失败项的标识清单。
/// 返回:可读后缀。
///
/// ## 为什么最多列三个
///
/// 这一小段文本最终要进表格单元,列宽是按「可能值」定的。
/// 无上界地列出全部失败项,会让某一格的宽度取决于运行时数据,
/// 列宽就失去了可预测性——而**列宽可预测是本工程宽度模型的前提**。
/// 因此这里封顶三个,超出时用 `…` 说明还有更多。
/// 完整清单应当由报表的明细表承担,不该挤在体检行里。
fn listing_suffix(failures: &[String]) -> String {
/// 体检行里最多列出的失败项个数。
const MAXIMUM_LISTED_FAILURES: usize = 3;
if failures.is_empty() {
return String::new();
}
let listed: Vec<String> = failures
.iter()
.take(MAXIMUM_LISTED_FAILURES)
.cloned()
.collect();
let suffix: String = if failures.len() > MAXIMUM_LISTED_FAILURES {
"…".to_string()
} else {
String::new()
};
format!("(如:{}{})", listed.join("、"), suffix)
}
// =============================================================================
// 二、两版机制逐字段对照
// =============================================================================
/// 一条请求在两次运行里的结局对照。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PerRequestAgreement {
/// 样品编号(配对键)。
sample_code: String,
/// 检测类型编码(可读文本)。
order_code: String,
/// 左版结论。
left_conclusion: String,
/// 右版结论。
right_conclusion: String,
/// 是否一致。
agrees: bool,
}
impl PerRequestAgreement {
/// 样品编号。
pub fn sample_code(&self) -> &str {
&self.sample_code
}
/// 检测类型编码。
pub fn order_code(&self) -> &str {
&self.order_code
}
/// 左版结论。
pub fn left_conclusion(&self) -> &str {
&self.left_conclusion
}
/// 右版结论。
pub fn right_conclusion(&self) -> &str {
&self.right_conclusion
}
/// 是否一致。
pub fn agrees(&self) -> bool {
self.agrees
}
}
/// 一个字段在两次运行里的取值对照。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldAgreement {
/// 字段名(中文,直接进报表)。
field: String,
/// 左版取值文本。
left_text: String,
/// 右版取值文本。
right_text: String,
}
impl FieldAgreement {
/// 构造一段字段对照。
///
/// 参数 `field` / `left_text` / `right_text`。
/// 返回:对照。
pub fn new(field: &str, left_text: &str, right_text: &str) -> FieldAgreement {
FieldAgreement {
field: field.to_string(),
left_text: left_text.to_string(),
right_text: right_text.to_string(),
}
}
/// 字段名。
pub fn field(&self) -> &str {
&self.field
}
/// 左版取值文本。
pub fn left_text(&self) -> &str {
&self.left_text
}
/// 右版取值文本。
pub fn right_text(&self) -> &str {
&self.right_text
}
/// 是否一致。
///
/// 用**文本**而不是原值比较,是不是太弱了?
///
/// 不弱,而且更合适。本工程的报表本来就以文本呈现这些值,
/// 若两个值渲染成同一个字符串,读者看到的就是同一个东西——
/// 「观察上不可区分」在本工程里的定义正是**报表上不可区分**。
/// 若改用原值比较,反而会引入「文本相同但类型不同」这种
/// 对读者毫无意义却会让核对失败的差异。
pub fn agrees(&self) -> bool {
self.left_text == self.right_text
}
/// 转成一条体检结论。
///
/// ## 说明文本为什么要「压到上界内」
///
/// 有些字段的取值文本可以很长(例如「按编码的成功明细」可能是一串
/// `编码×张数`),直接进体检行会把表格撑破。
/// 因此这里**主动**把它压到 22 显示列以内(超长加 `…`)。
/// 这与「静默截断」不同:省略是看得见的。
///
/// 更完整的对照由专用的对照表承担(`app::comparison_report`),
/// 体检行只需要回答「一致吗」。
pub fn to_check_line(&self) -> CheckLine {
/// 体检行里单侧取值文本的最大显示列数。
const MAXIMUM_SIDE_WIDTH: usize = 22;
let detail: String = if self.agrees() {
format!("一致:{}", elide_text(&self.left_text, MAXIMUM_SIDE_WIDTH * 2))
} else {
format!(
"左 {} / 右 {}",
elide_text(&self.left_text, MAXIMUM_SIDE_WIDTH),
elide_text(&self.right_text, MAXIMUM_SIDE_WIDTH)
)
};
CheckLine::new(&self.field, self.agrees(), &detail)
}
}
/// 两版机制的完整对照结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunComparison {
/// 左版机制名。
left_mechanism: &'static str,
/// 右版机制名。
right_mechanism: &'static str,
/// 被比较的批次名。
batch_name: String,
/// 左版请求条数。
left_request_count: usize,
/// 右版请求条数。
right_request_count: usize,
/// 逐条结局对照(按样品编号排序)。
per_request: Vec<PerRequestAgreement>,
/// 账本计数字段对照。
ledger_fields: Vec<FieldAgreement>,
/// 账本明细字段对照。
ledger_detail_fields: Vec<FieldAgreement>,
/// 汇总字段对照。
summary_fields: Vec<FieldAgreement>,
/// 被逐字段比较过的快照配对数。
snapshot_pairs_compared: usize,
/// 其中完全相同(`ProductSnapshot` 整体相等)的配对数。
snapshot_pairs_identical: usize,
}
impl RunComparison {
/// 左版机制名。
pub fn left_mechanism(&self) -> &'static str {
self.left_mechanism
}
/// 右版机制名。
pub fn right_mechanism(&self) -> &'static str {
self.right_mechanism
}
/// 批次名。
pub fn batch_name(&self) -> &str {
&self.batch_name
}
/// 逐条结局对照。
pub fn per_request(&self) -> &[PerRequestAgreement] {
&self.per_request
}
/// 账本计数字段对照。
pub fn ledger_fields(&self) -> &[FieldAgreement] {
&self.ledger_fields
}
/// 账本明细字段对照。
pub fn ledger_detail_fields(&self) -> &[FieldAgreement] {
&self.ledger_detail_fields
}
/// 汇总字段对照。
pub fn summary_fields(&self) -> &[FieldAgreement] {
&self.summary_fields
}
/// 左版请求条数。
pub fn left_request_count(&self) -> usize {
self.left_request_count
}
/// 右版请求条数。
pub fn right_request_count(&self) -> usize {
self.right_request_count
}
/// 被逐字段比较过的快照配对数。
pub fn snapshot_pairs_compared(&self) -> usize {
self.snapshot_pairs_compared
}
/// 快照完全相同的配对数。
pub fn snapshot_pairs_identical(&self) -> usize {
self.snapshot_pairs_identical
}
/// 不一致的条数(三类相加)。
pub fn disagreement_count(&self) -> usize {
self.per_request.iter().filter(|entry| !entry.agrees()).count()
+ self
.ledger_fields
.iter()
.filter(|entry| !entry.agrees())
.count()
+ self
.ledger_detail_fields
.iter()
.filter(|entry| !entry.agrees())
.count()
+ self
.summary_fields
.iter()
.filter(|entry| !entry.agrees())
.count()
}
/// 是否完全一致。
pub fn all_agree(&self) -> bool {
self.disagreement_count() == 0
}
/// 转成体检报告。
///
/// ## 结构:「每个分组一条汇总 + 只为不一致的条目单独成行」
///
/// 本报告初版把逐条对照(14 条)、账本字段(4 条)、明细(3 条)、
/// 汇总(4 条)全部平铺成 25 条检查项。那样做有两个问题:
///
/// 1. **逐条说明文本太长**——每条要点出左右两版的结论加关键数值,
/// 14 条加起来会把体检表格撑破;
/// 2. **正常通过时信息量为零**——25 条「通过」淹没了真正需要看的东西。
///
/// 因此改成:**每个分组先给一条汇总行(`14/14 一致`),
/// 只有不一致的条目才单独成行**。
/// 全部通过时报告只有 5 行、一眼看完;
/// 一旦有差异,差异条目会立刻出现在汇总行后面,且带着具体数值。
///
/// 这与既有工程「拒绝说明要保留原值,可直接定位」是同一条纪律:
/// **细节不是不该有,而是应该只在该出现的时候出现。**
pub fn to_check_report(&self) -> CheckReport {
let mut report: CheckReport = CheckReport::new("两版分派机制结果对照");
// ---- 分组一:逐条结局 ----
let outcome_agreed: usize = self.per_request.iter().filter(|e| e.agrees()).count();
report = report.with_line(CheckLine::new(
"逐条结局一致",
outcome_agreed == self.per_request.len()
&& self.left_request_count == self.right_request_count,
&format!(
"{}/{} 一致(左 {} 条,右 {} 条)",
outcome_agreed,
self.per_request.len(),
self.left_request_count,
self.right_request_count
),
));
// 不一致的条目单独成行——用批量入口一次挂上,
// 避免在一个循环里反复重新绑定 `self`(可读性差别很明显)。
let disagreement_lines: Vec<CheckLine> = self
.per_request
.iter()
.filter(|entry| !entry.agrees())
.map(|entry| {
CheckLine::new(
&format!("逐条·{}", elide_text(&entry.sample_code, 16)),
false,
&format!(
"左 {} / 右 {}",
elide_text(&entry.left_conclusion, 20),
elide_text(&entry.right_conclusion, 20)
),
)
})
.collect();
report = report.with_lines(disagreement_lines);
// ---- 分组二:快照逐字段(最强的一条,文本对照都可能漏,它不会)----
report = report.with_line(CheckLine::new(
"快照逐字段完全相同",
self.snapshot_pairs_compared == self.snapshot_pairs_identical,
&format!(
"{}/{} 对快照整体相等(每个快照 26 个字段)",
self.snapshot_pairs_identical, self.snapshot_pairs_compared
),
));
// ---- 分组三:账本计数 ----
report = report.with_line(aggregate_line("账本计数一致", &self.ledger_fields));
// ---- 分组四:账本明细 ----
report = report.with_line(aggregate_line("账本明细一致", &self.ledger_detail_fields));
for field in self
.ledger_detail_fields
.iter()
.filter(|field| !field.agrees())
{
report = report.with_line(field.to_check_line());
}
// ---- 分组五:汇总字段 ----
report = report.with_line(aggregate_line("汇总字段一致", &self.summary_fields));
for field in self.summary_fields.iter().filter(|field| !field.agrees()) {
report = report.with_line(field.to_check_line());
}
report
}
}
/// 把一组字段对照压成一条汇总结论。
///
/// 参数 `name`:汇总项名称;`fields`:字段对照清单。
/// 返回:一条体检结论。
///
/// ## 说明文本里为什么要带一个「样例」
///
/// 只说 `4/4 一致` 时读者无法确认比较的是哪四个字段。
/// 因此这里附上第一个字段的取值作为样例——
/// **分母之外还要有「比的是什么」**,这是本工程对派生指标的第二条要求。
fn aggregate_line(name: &str, fields: &[FieldAgreement]) -> CheckLine {
let agreed: usize = fields.iter().filter(|field| field.agrees()).count();
let sample: String = fields
.first()
.map(|field| {
format!(
";如「{}」= {}",
field.field(),
elide_text(field.left_text(), 24)
)
})
.unwrap_or_default();
CheckLine::new(
name,
agreed == fields.len(),
&format!("{}/{} 一致{}", agreed, fields.len(), sample),
)
}
/// 比较两版机制在同批次上的结果。
///
/// 参数 `left` / `right`:两次运行结果。
/// 返回:对照结果。
///
/// ## 配对策略
///
/// 逐条按**样品编号**配对(见模块文档),账本与汇总按字段名配对。
/// 若右版缺少某个样品编号,该条记为不一致并在说明里注明「右版无此条」——
/// **不要跳过它**。跳过会让「某一版少跑了一条」这种最严重的差异
/// 表现为「配对数量不同」,而配对数量在报告里只是一个数字,
/// 远不如一条明确写着「右版无此条」的未通过项醒目。
pub fn compare_runs(left: &WorkbenchRun, right: &WorkbenchRun) -> RunComparison {
// ---- 逐条配对 ----
let mut per_request: Vec<PerRequestAgreement> = Vec::new();
for left_outcome in left.outcomes_sorted_by_sample_code() {
// 在右版里按样品编号找对应的一条。14 条以内线性查找足够,
// 而且不引入任何按顺序的隐含前提。
let right_match: Option<&RequestOutcome> = right
.request_outcomes()
.iter()
.find(|candidate| candidate.sample_code() == left_outcome.sample_code());
let left_conclusion: String = conclusion_with_detail(left_outcome.outcome());
let right_conclusion: String = match right_match {
// 两侧结论文本直接相比:文本相同即报表上不可区分。
Some(right_outcome) => conclusion_with_detail(right_outcome.outcome()),
// 右版没有这一条——用一句显式文本表达,而不是空串。
// 空串在表格里与「这一格没填」无法区分。
None => "<右版无此条>".to_string(),
};
per_request.push(PerRequestAgreement {
sample_code: left_outcome.sample_code().to_string(),
order_code: left_outcome.order_code().code().to_string(),
agrees: left_conclusion == right_conclusion,
left_conclusion,
right_conclusion,
});
}
// ---- 账本计数字段 ----
let left_ledger = left.creator_ledger();
let right_ledger = right.creator_ledger();
let ledger_fields: Vec<FieldAgreement> = vec![
FieldAgreement::new(
"账本·成功张数",
&left_ledger.created_count().to_string(),
&right_ledger.created_count().to_string(),
),
FieldAgreement::new(
"账本·被拒笔数",
&left_ledger.rejected_count().to_string(),
&right_ledger.rejected_count().to_string(),
),
FieldAgreement::new(
"账本·未知编码笔数",
&left_ledger.unknown_code_count().to_string(),
&right_ledger.unknown_code_count().to_string(),
),
FieldAgreement::new(
"账本·尝试总笔数",
&left_ledger.attempted_count().to_string(),
&right_ledger.attempted_count().to_string(),
),
];
// ---- 账本明细字段 ----
// 明细渲染成一行文本再比较。渲染顺序全部经过排序(见 CreationLedger
// 的 distinct_unknown_codes_sorted / rejected_by_rule_sorted),
// 因此「同样的内容 → 同样的文本」成立,比较不会因顺序抖动而失败。
let left_created_detail: String = left_ledger
.created_by_code()
.iter()
.map(|entry| format!("{}×{}", entry.0.code(), entry.1))
.collect::<Vec<String>>()
.join(" ");
let right_created_detail: String = right_ledger
.created_by_code()
.iter()
.map(|entry| format!("{}×{}", entry.0.code(), entry.1))
.collect::<Vec<String>>()
.join(" ");
let left_rejected_detail: String = left_ledger
.rejected_by_rule_sorted()
.iter()
.map(|entry| format!("{}×{}", entry.0, entry.1))
.collect::<Vec<String>>()
.join(" ");
let right_rejected_detail: String = right_ledger
.rejected_by_rule_sorted()
.iter()
.map(|entry| format!("{}×{}", entry.0, entry.1))
.collect::<Vec<String>>()
.join(" ");
let left_unknown_detail: String = left_ledger.distinct_unknown_codes_sorted().join(" ");
let right_unknown_detail: String = right_ledger.distinct_unknown_codes_sorted().join(" ");
let ledger_detail_fields: Vec<FieldAgreement> = vec![
FieldAgreement::new("明细·按编码成功数", &left_created_detail, &right_created_detail),
FieldAgreement::new(
"明细·按规则拒绝数",
&left_rejected_detail,
&right_rejected_detail,
),
FieldAgreement::new(
"明细·未知编码清单",
&left_unknown_detail,
&right_unknown_detail,
),
];
let summary_fields: Vec<FieldAgreement> = vec![
FieldAgreement::new(
"汇总·请求条数",
&left.request_count().to_string(),
&right.request_count().to_string(),
),
FieldAgreement::new(
"汇总·成功合计费用",
&fee_text(left.total_created_fee_minor_units()),
&fee_text(right.total_created_fee_minor_units()),
),
FieldAgreement::new(
"汇总·成功合计工作量",
&left.total_workload_units().to_string(),
&right.total_workload_units().to_string(),
),
FieldAgreement::new(
"汇总·认得的编码集合",
&code_set_text(left.supported_codes_before()),
&code_set_text(right.supported_codes_before()),
),
];
// ---- 快照逐字段配对 ----
let snapshot_pairs: SnapshotPairTally = compare_snapshots(left, right);
RunComparison {
left_mechanism: left.mechanism_name(),
right_mechanism: right.mechanism_name(),
batch_name: left.batch_name().to_string(),
left_request_count: left.request_count(),
right_request_count: right.request_count(),
per_request,
ledger_fields,
ledger_detail_fields,
summary_fields,
snapshot_pairs_compared: snapshot_pairs.compared,
snapshot_pairs_identical: snapshot_pairs.identical,
}
}
/// 快照配对比较的统计。
///
/// 把两个计数装进一个小结构,是为了让 [`compare_runs`] 里
/// 「比较了多少对、多少对相同」这两个数**必须一起出现**——
/// 若函数只返回「相同的对数」,调用方无法判断分母是多少,
/// 于是「7/7 相同」与「7/9 相同」在代码里长得一样。
/// 本工程的派生指标一律要求分母可见,这条纪律在代码结构上也照办。
#[derive(Debug, Clone, Copy)]
struct SnapshotPairTally {
/// 参与比较的配对数。
compared: usize,
/// 其中完全相同的配对数。
identical: usize,
}
/// 按样品编号配对,逐字段比较两版的快照。
///
/// 参数 `left` / `right`:两次运行结果。
/// 返回:配对统计。
///
/// ## 与逐条文本对照的分工
///
/// 文本对照([`conclusion_with_detail`])只挑了几个关键派生值,
/// 目的是让读者一眼看懂差异在哪;本函数用 `ProductSnapshot` 的派生相等
/// 比较**全部 26 个字段**,目的是不漏。
/// 两者都必须有:只有文本对照会漏字段,只有值对照则失败时无从定位。
fn compare_snapshots(left: &WorkbenchRun, right: &WorkbenchRun) -> SnapshotPairTally {
let mut tally: SnapshotPairTally = SnapshotPairTally {
compared: 0,
identical: 0,
};
for left_snapshot in left.created_snapshots() {
// 同样的按编号配对策略(与逐条配对一致),保证两个口径不会分叉。
let right_match: Option<&crate::client::ProductSnapshot> = right
.created_snapshots()
.into_iter()
.find(|candidate| candidate.sample_code == left_snapshot.sample_code);
let Some(right_snapshot) = right_match else {
continue;
};
tally.compared += 1;
if snapshots_are_identical(left_snapshot, right_snapshot) {
tally.identical += 1;
}
}
tally
}
/// 把一条结局渲染成「结论 + 关键数值」的对照文本。
///
/// 参数 `outcome`:结局。
/// 返回:对照文本。
///
/// ## 为什么成功要带上「合计 + 出证日」而不是只写「成功」
///
/// 只比「成功/失败」会漏掉本工程最想验证的东西:**两版造出来的东西是不是同一个**。
/// 两版都可能「成功」,但造出金额或排期不同的委托单——那才是最严重的差异。
/// 本工程的对照因此必须深入到快照的派生值。
///
/// 选「合计费用 + 出证日」而不是把 26 个快照字段全部拼进来,
/// 是因为逐条对照这一组有 14 条:把 26 个字段乘进去会得到
/// 一条长得没有读者能核对的说明文本。真正逐字段的完整性由
/// 另一条独立检查承担——**`ProductSnapshot` 整体派生 `PartialEq`**,
/// 因此只要两版产出的快照不同,`ProductSnapshot` 的比较就会不等。
/// 换句话说:文本对照用于给人看,值对照用于给机器判定,两者分工。
fn conclusion_with_detail(outcome: &CreationOutcome) -> String {
match outcome {
CreationOutcome::Created(snapshot) => format!(
"成功 {} / {} 个工作日 / {} 项",
fee_text(snapshot.total_fee_minor_units),
snapshot.promised_working_days,
snapshot.line_count
),
CreationOutcome::Failed(error) => {
format!("{}:{}", outcome.conclusion_text(), error.rule_code())
}
}
}
/// 编码集合的稳定文本。
///
/// 参数 `codes`:编码列表。
/// 返回:排序后以 `/` 连接的文本。
///
/// 排序是必需的:`supported_codes()` 返回的顺序在登记表版本里是
/// **登记顺序**,在白名单版本里是**白名单顺序**。两者恰好相同,
/// 但依赖这个巧合会让对照在扩展之后(登记表多了一项、
/// 白名单仍是五项)出现无意义的顺序差异。排序后比较集合内容。
fn code_set_text(codes: &[TestingOrderCode]) -> String {
let mut names: Vec<&str> = codes.iter().map(|code| code.code()).collect();
names.sort_unstable();
names.join("/")
}
/// 逐字段比较两个快照是否相等(用于体检里那条更强的检查)。
///
/// 参数 `left` / `right`:两个快照。
/// 返回:相等返回 `true`。
///
/// ## 为什么直接依赖派生相等而不是逐字段写
///
/// `ProductSnapshot` 的 26 个字段全部是纯值且派生 `PartialEq`,
/// 因此 `==` 就是「26 个字段全部相等」。手写逐字段比较的唯一后果是
/// **将来新增字段时漏比一项**,而漏比是静默的。
/// 这条检查与 [`conclusion_with_detail`] 的文本对照互补:
/// 文本对照负责让读者看懂差异在哪,这条负责不漏。
pub fn snapshots_are_identical(left: &crate::client::ProductSnapshot, right: &crate::client::ProductSnapshot) -> bool {
left == right
}
//!# 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_coverage.rs
//! # 分派覆盖率与失配体检 —— 把「沉默故障」变成一条可打印的清单
//!
//! ## 本文件要证明的核心命题
//!
//! 简单工厂的扩展成本不是「改一行」,而是**「改两处并保持同步」**:
//! 一份白名单(接受哪些编码)与一份构造器表(怎么造)必须手动对齐。
//!
//! 而两份清单会以**两个方向**失配,后果完全不对称:
//!
//! | 失配方向 | 症状 | 危险程度 |
//! |---|---|---|
//! | 白名单有、构造器表没有 | 运行期一创建就报「未知编码」 | **显性**,一测就炸 |
//! | 构造器表有、白名单没有 | **产品永远造不出来,且没有任何报错** | **隐性**,报表上一切正常 |
//!
//! 第二个方向是本文件存在的全部理由。它的可怕之处在于:
//!
//! 1. **不报错**——白名单才是入口,构造器表里的那一行永远不会被查到;
//! 2. **不缺失**——每条送检单都得到了一个「合理的」结局(要么成功,要么报未知编码);
//! 3. **不可推断**——从报表上看不出「本该有第 6 种产品」,
//! 因为报表能列出的只有「系统认识了什么」。
//!
//! 因此本工程把它做成一条**可以打印出来的清单**([`DispatchTableMismatch`]),
//! 并且让它适用于**任何**创建者——包括运行期登记表那一版。
//! 在登记表版上,这条检查的期望结果是「两个方向都为空」,
//! 且不存在「忘记同步」这种可能:**它只有一份清单**。
//!
//! ## 本文件为什么只依赖数据,不认识工厂类型
//!
//! 两个检查的入参都是**纯粹的编码列表**:
//!
//! - [`creator_coverage`] 拿「创建者」与「应当认识的编码全集」;
//! - [`dispatch_table_mismatch`] 拿「白名单」与「构造器表」。
//!
//! 因此本文件不需要认识 `TestingOrderFactory` 或 `TestingOrderRegistry`,
//! 也不需要认识 `builtin_product_builders()`——**「全集」是调用方给的策略输入**。
//! 这样做有两个好处:
//!
//! 1. 同一份判据可以套在**任意**创建者上(包括工程外实现),
//! 而不是只能套在某个具体类型上;
//! 2. `analysis` 层的依赖表因此保持干净(`client` / `domain`),
//! 没有为了「读一张表」而拖进 `factory` 或 `product`。
//!
//! ## 一个刻意的取舍:判据住在分析层,不住在工厂里
//!
//! 本检查初版曾作为 `TestingOrderFactory` 的方法存在。把它搬到这里的原因是:
//! **一个没人调用的检查等于不存在**。放在工厂里,它是「工厂的一个可选能力」;
//! 放在分析层,它是「每次体检都必然执行的一条」(见 `main.rs` 的体检幕)。
//! 位置决定了它会不会被跑。
use crate::analysis::{CheckLine, CheckReport};
use crate::client::WorkbenchRun;
use crate::domain::TestingOrderCode;
use crate::factory::{CreationError, FailureLayer, OrderCreator};
/// 一个创建者对「应当认识的编码全集」的覆盖情况。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatorCoverage {
/// 机制短名。
mechanism_name: &'static str,
/// 机制说明。
mechanism_note: &'static str,
/// 该机制「必须手动保持同步的清单份数」(由机制自己回答)。
dispatch_list_count: usize,
/// 该创建者认识的编码(按编码排序)。
recognized: Vec<TestingOrderCode>,
/// 全集里它**不认识**的编码(按编码排序)。
missing: Vec<TestingOrderCode>,
/// 它认识、但不在全集里的编码(即扩展进来的,按编码排序)。
extra: Vec<TestingOrderCode>,
/// 全集大小。
universe_size: usize,
}
impl CreatorCoverage {
/// 机制短名。
pub fn mechanism_name(&self) -> &'static str {
self.mechanism_name
}
/// 机制说明。
pub fn mechanism_note(&self) -> &'static str {
self.mechanism_note
}
/// 该机制「必须手动保持同步的清单份数」。
///
/// 这是本工程「简单工厂的代价」那个数字:2 = 白名单 + 构造器表,1 = 只有登记表。
pub fn dispatch_list_count(&self) -> usize {
self.dispatch_list_count
}
/// 认识的编码。
pub fn recognized(&self) -> &[TestingOrderCode] {
&self.recognized
}
/// 全集里不认识的编码。
pub fn missing(&self) -> &[TestingOrderCode] {
&self.missing
}
/// 认识但不在全集里的编码(扩展项)。
pub fn extra(&self) -> &[TestingOrderCode] {
&self.extra
}
/// 全集大小。
pub fn universe_size(&self) -> usize {
self.universe_size
}
/// 覆盖率文本,如 `5/5`。
///
/// ## 为什么分母必须出现
///
/// 只印「5」无法回答「一共有几种」。本工程对派生指标的纪律是
/// **除数要出现在标签或值里**——这条在报表上叫做「不要让人猜分母」,
/// 在代码里就是「别返回一个裸计数」。
pub fn coverage_text(&self) -> String {
format!(
"{}/{}",
self.recognized
.iter()
.filter(|code| !self.extra.contains(code))
.count(),
self.universe_size
)
}
/// 转成体检报告。
///
/// ## 为什么「不认识全集里的编码」这一条要单独成项
///
/// 因为它是本工程最关心的一类**业务可见的缺口**:
/// 送检单上出现了某种检测类型,而系统不认它。
/// 若覆盖率只是「4/5」这样一个数字,读者还得自己去数差的是哪一个。
/// 单独列一条「缺失编码」并在说明里把编码写出来,
/// 缺陷就变成可直接定位的。
pub fn to_check_report(&self) -> CheckReport {
let recognized_text: String = code_text_list(&self.recognized);
let mut report: CheckReport = CheckReport::new(&format!(
"分派覆盖率({})",
self.mechanism_name
));
report = report.with_line(CheckLine::new(
"认识的编码数量",
self.recognized.len() >= self.universe_size,
&format!(
"{} 种,全集 {} 种,覆盖率 {}",
self.recognized.len(),
self.universe_size,
self.coverage_text()
),
));
report = report.with_line(CheckLine::new(
"全集里没有缺失",
self.missing.is_empty(),
&format!(
"缺失 {} 种{}",
self.missing.len(),
code_suffix(&self.missing)
),
));
report = report.with_line(CheckLine::new(
"认识的编码清单",
true,
&format!(
"{} 种:{}",
self.recognized.len(),
if recognized_text.is_empty() {
"<无>".to_string()
} else {
recognized_text
}
),
));
// 扩展项的条数不做通过/不通过判断(它本身不是缺陷),
// 但必须被打印出来——否则读者会把「认识了 6 种而全集是 5 种」
// 当成覆盖率算错了。`passed = true` 表示「这条只是陈述事实」。
report = report.with_line(CheckLine::new(
"全集之外的扩展项",
true,
&format!(
"{} 种{}",
self.extra.len(),
code_suffix(&self.extra)
),
));
report
}
}
/// 统计一个创建者对编码全集的覆盖情况。
///
/// 参数 `creator`:创建者(trait 对象,两版机制通用);
/// `universe`:**应当认识的编码全集**(由调用方给出的策略输入,
/// 本工程取「内置构造器表的编码 ∪ 工程外扩展的编码」)。
/// 返回:覆盖情况。
///
/// ## 为什么用 `supports` 逐一询问,而不是取清单做集合差
///
/// 因为 `OrderCreator::supports` 是**对外承诺的口径**(「你认识它吗」),
/// 而 `supported_codes()` 是**自报的清单**。用自报清单去判断
/// 「认不认识」会让「清单与实现不一致」这类缺陷(比如清单里写了、
/// 查询时却查不到)完全隐藏起来。逐一询问则是在**验证那个承诺**。
///
/// 这也正是 `supports` 必须无副作用的原因:这里会对每个编码问一遍,
/// 若它顺手记账,创建账本里就会多出「全集大小 × 机制数」笔虚假尝试。
pub fn creator_coverage(
creator: &dyn OrderCreator,
universe: &[TestingOrderCode],
) -> CreatorCoverage {
// 认识哪些:对着全集逐一问。
let mut missing: Vec<TestingOrderCode> = Vec::new();
for code in universe {
if !creator.supports(code) {
missing.push(*code);
}
}
// 自报清单。
let mut recognized: Vec<TestingOrderCode> = creator.supported_codes();
// 排序必须做:登记表版返回的是登记顺序,白名单版返回的是白名单顺序。
// 不排序的话,同一份集合在不同实现下会渲染成不同文本,
// 破坏「两次运行逐字节一致」(以及两版对照的可比性)。
recognized.sort_by(|left, right| left.code().cmp(right.code()));
// 认识但不在全集里 = 自报清单 − 全集。
let mut extra: Vec<TestingOrderCode> = recognized
.iter()
.filter(|code| !universe.contains(code))
.copied()
.collect();
extra.sort_by(|left, right| left.code().cmp(right.code()));
// 缺失清单也排序:它直接进报表,顺序必须由内容决定。
missing.sort_by(|left, right| left.code().cmp(right.code()));
CreatorCoverage {
mechanism_name: creator.mechanism_name(),
mechanism_note: creator.mechanism_note(),
dispatch_list_count: creator.dispatch_list_count(),
recognized,
missing,
extra,
universe_size: universe.len(),
}
}
/// 两份分派清单的失配情况。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DispatchTableMismatch {
/// 检查标签(说明这两份清单分别来自哪里)。
label: String,
/// 白名单有、构造器表没有(**显性**失配)。
accepted_without_builder: Vec<TestingOrderCode>,
/// 构造器表有、白名单没有(**隐性**失配 = 沉默故障)。
builder_without_accepted: Vec<TestingOrderCode>,
}
impl DispatchTableMismatch {
/// 检查标签。
pub fn label(&self) -> &str {
&self.label
}
/// 显性失配清单。
pub fn accepted_without_builder(&self) -> &[TestingOrderCode] {
&self.accepted_without_builder
}
/// 隐性失配清单(沉默故障)。
pub fn builder_without_accepted(&self) -> &[TestingOrderCode] {
&self.builder_without_accepted
}
/// 两份清单是否完全同步。
pub fn is_in_sync(&self) -> bool {
self.accepted_without_builder.is_empty() && self.builder_without_accepted.is_empty()
}
/// 沉默故障(隐性失配)的条数。
///
/// 单独给它一个方法,是因为它是本工程最想量化出来的那个数:
/// **每一笔都代表「一种产品写好了却永远造不出来」**。
pub fn silent_failure_count(&self) -> usize {
self.builder_without_accepted.len()
}
/// 显性失配的条数。
pub fn explicit_failure_count(&self) -> usize {
self.accepted_without_builder.len()
}
/// 转成体检报告。
///
/// ## 两条检查分别单独成项,且危险的那条排在后面
///
/// 排列顺序是刻意的:显性失配在前(它一跑就炸,不紧急),
/// 隐性失配在后(它永远沉默,最紧急)。
/// 报表上「最后一条未通过」往往最容易被读到。
pub fn to_check_report(&self) -> CheckReport {
let mut report: CheckReport = CheckReport::new(&format!("分派表同步({})", self.label));
report = report.with_line(CheckLine::new(
"显性失配:白名单有、构造器表没有",
self.accepted_without_builder.is_empty(),
&format!(
"{} 项{}",
self.accepted_without_builder.len(),
code_suffix(&self.accepted_without_builder)
),
));
report = report.with_line(CheckLine::new(
"隐性失配:构造器表有、白名单没有(沉默故障)",
self.builder_without_accepted.is_empty(),
&format!(
"{} 项{}",
self.builder_without_accepted.len(),
code_suffix(&self.builder_without_accepted)
),
));
report = report.with_line(CheckLine::new(
"两份清单完全同步",
self.is_in_sync(),
&format!(
"失配合计 {} 项(显性 {} + 隐性 {})",
self.explicit_failure_count() + self.silent_failure_count(),
self.explicit_failure_count(),
self.silent_failure_count()
),
));
report
}
}
/// 比较两份分派清单。
///
/// 参数 `label`:检查标签;`accepted_codes`:白名单(「接受哪些编码」);
/// `builder_codes`:构造器表(「怎么造」)。
/// 返回:失配情况。
///
/// ## 为什么这个函数是纯数据的
///
/// 它只做两次集合差,不接触任何具体类型。因此它既能用于
/// 编译期白名单工厂(`supported_codes` vs 内置构造器表),
/// 也能用于**工程外构造的反例工厂**(见 `main.rs` 第九幕)——
/// 而后者正是本工程要演示「沉默故障会被抓到」的地方。
/// 若把这段逻辑写死在某个工厂类里,演示时就得再写一遍同样的差集。
pub fn dispatch_table_mismatch(
label: &str,
accepted_codes: &[TestingOrderCode],
builder_codes: &[TestingOrderCode],
) -> DispatchTableMismatch {
let mut accepted_without_builder: Vec<TestingOrderCode> = accepted_codes
.iter()
.filter(|code| !builder_codes.contains(code))
.copied()
.collect();
let mut builder_without_accepted: Vec<TestingOrderCode> = builder_codes
.iter()
.filter(|code| !accepted_codes.contains(code))
.copied()
.collect();
// 排序:两份清单直接进报表,顺序必须由内容决定。
accepted_without_builder.sort_by(|left, right| left.code().cmp(right.code()));
builder_without_accepted.sort_by(|left, right| left.code().cmp(right.code()));
DispatchTableMismatch {
label: label.to_string(),
accepted_without_builder,
builder_without_accepted,
}
}
/// 对创建者的「能力声明」与「实际行为」做一致性审计。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SupportClaimAudit {
/// 被审计的请求条数。
checked_count: usize,
/// 声明与实际**矛盾**的样品编号。
contradictions: Vec<String>,
/// 矛盾里属于「声明支持、实际失败」方向的条数。
claimed_but_failed: usize,
/// 矛盾里属于「声明不支持、实际成功」方向的条数。
unclaimed_but_created: usize,
/// **不算矛盾**的那一类失败:编码认得、承诺已兑现,但样品规格被产品侧拒绝。
specification_rejected: usize,
}
impl SupportClaimAudit {
/// 被审计的请求条数。
pub fn checked_count(&self) -> usize {
self.checked_count
}
/// 矛盾的样品编号。
pub fn contradictions(&self) -> &[String] {
&self.contradictions
}
/// 「声明支持、实际失败」方向的条数。
pub fn claimed_but_failed(&self) -> usize {
self.claimed_but_failed
}
/// 「声明不支持、实际成功」方向的条数。
pub fn unclaimed_but_created(&self) -> usize {
self.unclaimed_but_created
}
/// 「承诺已兑现、但单子本身不合规」的条数。
///
/// ## 为什么这个数必须被印出来,而不是被吞掉
///
/// 它是**假阳性的防线**。若不把它单独计数,那么「产品侧拒绝了 5 张单子」
/// 这件事在审计结果里就彻底消失了——读者会以为审计只看了 9 条,
/// 而实际上它看了 14 条、放过了 5 条。放过的理由必须显式写在报表上,
/// 否则「放过了什么」就成了只有作者知道的事。
pub fn specification_rejected(&self) -> usize {
self.specification_rejected
}
/// 是否存在矛盾。
pub fn is_consistent(&self) -> bool {
self.contradictions.is_empty()
}
/// 转成体检报告。
pub fn to_check_report(&self) -> CheckReport {
let mut report: CheckReport = CheckReport::new("能力声明与行为自洽");
report = report.with_line(CheckLine::new(
"声明支持却创建失败(声明与行为矛盾)",
self.claimed_but_failed == 0,
&format!(
"{} 条{}",
self.claimed_but_failed,
short_code_suffix(&self.contradictions)
),
));
report = report.with_line(CheckLine::new(
"声明不支持却创建成功(声明与行为矛盾)",
self.unclaimed_but_created == 0,
&format!("{} 条", self.unclaimed_but_created),
));
// 这一条是**陈述事实**,不做通过与否的判断:产品侧拒绝本身
// 可能是缺陷(送检单填错),但那不是 `supports` 的承诺落空。
// `passed = true` 表示「这条只是把审计放过了什么交代清楚」。
report = report.with_line(CheckLine::new(
"承诺已兑现、产品侧拒绝了样品规格(不计入矛盾)",
true,
&format!(
"{} 条:失败在**产品层**(编码认得),因此不构成对 `supports` 的反证",
self.specification_rejected
),
));
report = report.with_line(CheckLine::new(
"能力声明与行为整体自洽",
self.is_consistent(),
&format!(
"审计 {} 条请求,矛盾 {} 条(产品层拒绝 {} 条另行交代)",
self.checked_count,
self.contradictions.len(),
self.specification_rejected
),
));
report
}
}
/// 审计创建者的「能力声明」与「实际行为」是否一致。
///
/// 参数 `creator`:创建者;`run`:一次运行结果(提供逐条的编码与结局)。
/// 返回:审计结果。
///
/// ## 这个审计为什么必须双向
///
/// 它对每一条请求问两件事:
///
/// | 声明(`supports`) | 实际(创建成功?) | 判定 |
/// |---|---|---|
/// | 支持 | 成功 | 自洽 |
/// | 不支持 | 失败 | 自洽 |
/// | **支持** | **失败** | **矛盾**:承诺了却做不到 |
/// | **不支持** | **成功** | **矛盾**:没承诺却做到了(更少见,但同样说明两者脱节) |
///
/// ## ★ 一条被实测纠正的判据:「失败」还要看**失败在哪一层**
///
/// 上表第三行的「失败」二字,初版被直接当成 `!is_created()`。
/// 那是个**假阳性来源**:`supports` 承诺的是「我认识这个编码」,
/// 不是「你这张单子一定造得出来」。送检单本身不合规
/// (样品类别不符 / 参数缺失 / 参数越界)时,工厂照样会拒绝——
/// 而它对编码的承诺**已经兑现了**。
///
/// 实测的代价很清楚:标准工厂在标准批次上被误判出 5 条「矛盾」,
/// 而报表注解里那句「反例 B 的矛盾为 0」与它自己的表格对不上
/// (表里 B 是 5)。**一句与相邻数字矛盾的注解,比没有注解更糟**——
/// 它会让读者怀疑整张表。
///
/// 修法不是把这 5 条藏起来,而是**按失败层次分流**:
///
/// | 失败原因 | 层次 | 是否矛盾 | 去处 |
/// |---|---|---|---|
/// | `UnknownOrderCode`(不认识编码) | 工厂层 | **是** | `claimed_but_failed` |
/// | `Rejected`(规格被产品侧拒绝) | 产品层 | 否 | `specification_rejected` |
///
/// 第二类**照样被印在报表上**(`specification_rejected`),
/// 只是不再冒充「矛盾」。审计放过了什么,必须自己交代清楚。
///
/// ## 它为什么能区分两种失配方向——这是本工程的关键论证
///
/// 本工程初稿曾断言「构造器表有、白名单没有」这种失配
/// **完全不报错**。那句话**不准确**:若送检单里恰好有这种编码,
/// 运行期会报「未知编码」,症状与「该功能未上线」一模一样。
///
/// 实测下来的真相更精确,也更有说服力:
///
/// | 失配方向 | `supports` 的回答 | 创建的结果 | 是否矛盾 |
/// |---|---|---|---|
/// | 白名单有、构造器表没有 | `true`(承诺支持) | 失败(工厂层) | **矛盾** |
/// | 构造器表有、白名单没有 | `false`(不承诺) | 失败(工厂层) | **自洽** |
///
/// 也就是说:第一种失配会让工厂**自相矛盾**,任何「声明与实际对照」的检查
/// 都能抓到;第二种失配**对内完全自洽**——声明说不支持,事实上也真的不支持,
/// 一切都对得上。从对外行为上看,它和「这个功能本来就没做」**不可区分**。
///
/// 这才是那半句话的准确版本:**不是「没有任何报错」,
/// 而是「报的错看起来完全合理」**。要发现它,只能去比那两份清单——
/// 而「知道有两份清单要比」本身,就是简单工厂转嫁给维护者的成本。
///
/// ⚠️ 注意上面那个表能成立,靠的正是「失败必须落在工厂层」这个限定。
/// 若把产品层的拒绝也算进来,两个方向就都会出现「矛盾」,
/// 那条「不对称」的结论会**被假阳性淹没**——本工程就白做了。
pub fn audit_support_claims(creator: &dyn OrderCreator, run: &WorkbenchRun) -> SupportClaimAudit {
let mut contradictions: Vec<String> = Vec::new();
let mut claimed_but_failed: usize = 0;
let mut unclaimed_but_created: usize = 0;
let mut specification_rejected: usize = 0;
for outcome in run.request_outcomes() {
let claimed: bool = creator.supports(&outcome.order_code());
let actual: bool = outcome.outcome().is_created();
if claimed && !actual {
// ★ 关键的一步:失败究竟发生在哪一层?
// 成功时 `error()` 是 `None`,而这里 `actual == false` 已经排除了成功,
// 因此 `map(..)` 之后的 `None` 分支在逻辑上不可达;
// 为不让「不可达」变成一次 panic 风险,这里按「非工厂层」处理,
// 并在下面的 `debug_assert` 里把它暴露给 debug 构建。
let layer: Option<FailureLayer> =
outcome.outcome().error().map(CreationError::layer);
debug_assert!(
layer.is_some(),
"结局未创建却拿不到错误对象:{}",
outcome.sample_code()
);
if layer == Some(FailureLayer::Factory) {
// 承诺了「我认识它」,结果连构造器都没找到 —— 真矛盾。
claimed_but_failed += 1;
contradictions.push(outcome.sample_code().to_string());
} else {
// 认识编码、承诺已兑现,是这张单子本身不合规。
// 这是**另一个问题**(送检单质量),不冒充「声明矛盾」。
specification_rejected += 1;
}
} else if !claimed && actual {
unclaimed_but_created += 1;
contradictions.push(outcome.sample_code().to_string());
}
}
// 排序:这份清单直接进报表。
contradictions.sort();
contradictions.dedup();
SupportClaimAudit {
checked_count: run.request_count(),
contradictions,
claimed_but_failed,
unclaimed_but_created,
specification_rejected,
}
}
/// 给编码清单拼一个「(如:A、B)」后缀,最多列三个。
fn short_code_suffix(items: &[String]) -> String {
/// 体检行里最多列出的条目个数。
const MAXIMUM_LISTED_ITEMS: usize = 3;
if items.is_empty() {
return String::new();
}
let listed: Vec<String> = items.iter().take(MAXIMUM_LISTED_ITEMS).cloned().collect();
let suffix: String = if items.len() > MAXIMUM_LISTED_ITEMS {
"…".to_string()
} else {
String::new()
};
format!("(如:{}{})", listed.join("、"), suffix)
}
/// 把编码列表渲染成 `A、B` 形式。
///
/// 参数 `codes`:编码列表。
/// 返回:以「、」分隔的编码文本。
fn code_text_list(codes: &[TestingOrderCode]) -> String {
codes
.iter()
.map(|code| code.code())
.collect::<Vec<&str>>()
.join("、")
}
/// 给编码清单拼一个「(A、B)」后缀,最多列三个。
///
/// ## 为什么必须封顶
///
/// 这一段文本要进表格单元,而列宽是按「该列可能出现的最大宽度」定的。
/// 不封顶意味着某一格的宽度取决于运行时的清单长度——
/// 一旦清单变长,整列就会溢出,而溢出在定宽表里表现为**相邻列被挤掉**。
/// 这是既有工程里「静默截断」的头号来源,因此封顶要写在源头。
fn code_suffix(codes: &[TestingOrderCode]) -> String {
/// 体检行里最多列出的编码个数。
const MAXIMUM_LISTED_CODES: usize = 3;
if codes.is_empty() {
return String::new();
}
let listed: Vec<&str> = codes
.iter()
.take(MAXIMUM_LISTED_CODES)
.map(|code| code.code())
.collect();
let suffix: String = if codes.len() > MAXIMUM_LISTED_CODES {
"…".to_string()
} else {
String::new()
};
format!("({}{})", listed.join("、"), suffix)
}
//!# 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)
}
哲学管理(学)人生, 文学艺术生活, 自动(计算机学)物理(学)工作, 生物(学)化学逆境, 历史(学)测绘(学)时间, 经济(学)数学金钱(理财), 心理(学)医学情绪, 诗词美容情感, 美学建筑(学)家园, 解构建构(分析)整合学习, 智商情商(IQ、EQ)运筹(学)生存.---Geovin Du(涂聚文)
浙公网安备 33010602011771号