rust
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : audit_report.rs
//! 契约审计表(幕八):**六条不变量 + 每条的已知失败输入**。
//!
//! ## ★ 「已知会失败的输入」为什么要印在报表上
//!
//! 一条规则若对任何输入都成立,它就不是规则,只是一句没被跑过的注释。
//! 把「什么样的输入会让它变红」印在表上,读者才能判断:
//! 这条规则**真的在检查东西**,还是只是把一句注释跑了一遍。
//!
//! 本工程还会**真的把那个输入喂进去**(幕八续),确认它会红。
use crate::analysis::AuditCheckView;
use crate::app::{Table, TableColumn};
/// 审计表的列宽。
///
/// 列宽总和必须 <= 121(六列):`12+28+36+12+8+24 = 120`。
/// 14 列按**最长的规则编码**定:`NO_PASSTHROUGH` = 14 列。
const CODE_WIDTH: usize = 14;
const NAME_WIDTH: usize = 28;
/// 「期望」列要放得下最长的一条:`处置者名非空(无人接时为专用标记)` = 34 列。
const EXPECTATION_WIDTH: usize = 36;
const ACTUAL_WIDTH: usize = 12;
const VERDICT_WIDTH: usize = 8;
const VIOLATION_WIDTH: usize = 22;
/// 渲染契约审计表。
///
/// # 参数
/// - `title`:表标题(正常用例与已知失败用例各调一次)。
/// - `checks`:检查结果的视图(**纯字符串**,表示层不认识机制层)。
pub fn render_audit_table(title: &str, checks: &[AuditCheckView]) -> Vec<String> {
let mut table: Table = Table::new(
title,
vec![
TableColumn::new("规则编码", CODE_WIDTH),
TableColumn::new("规则", NAME_WIDTH),
TableColumn::new("期望", EXPECTATION_WIDTH),
TableColumn::new("实测", ACTUAL_WIDTH),
TableColumn::new("结论", VERDICT_WIDTH),
TableColumn::elidable(TableColumn::new("违规工单", VIOLATION_WIDTH)),
],
);
for check in checks.iter() {
table = table.with_row(vec![
check.code().to_string(),
check.name().to_string(),
check.expectation().to_string(),
check.actual().to_string(),
if check.passed() {
"通过".to_string()
} else {
"未通过".to_string()
},
check.violating().to_string(),
]);
}
// ★ 每条规则的「已知会失败的输入」写进注解。
for check in checks.iter() {
table = table.with_note(&format!(
"{}:已知会失败的输入 ------ {}",
check.code(),
check.known_failing_input()
));
}
table.render(crate::app::DOCUMENT_WIDTH)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : broadcast_report.rs
//! 广播式对照表(幕十):**责任链 vs 每个环节都问一遍**。
//!
//! ## ★ 两个口径要并列,不能只给一个
//!
//! - **判定次数**:责任链走了几站 vs 广播式走了几站(= 站数,恒定);
//! - **结论**:两者**必须逐单一致**。
//!
//! 只给第一个,读者会怀疑「是不是连结论也变了」;
//! 只给第二个,读者会以为「那责任链没什么用」。
//! 两个并列,才能说清:**语义没变、判定次数变少了**------
//! 这正是责任链唯一的价值所在。
use crate::app::{Table, TableColumn};
/// 广播式对照表的列宽。
const NUMBER_WIDTH: usize = 14;
const CHAIN_HANDLER_WIDTH: usize = 20;
const BROADCAST_HANDLER_WIDTH: usize = 20;
const DISPOSITION_WIDTH: usize = 16;
const CHAIN_VISITED_WIDTH: usize = 16;
const BROADCAST_VISITED_WIDTH: usize = 16;
const IDENTICAL_WIDTH: usize = 10;
/// 广播式对照的一行。
#[derive(Clone, Debug)]
pub struct BroadcastRowView {
/// 工单号。
pub ticket_number: String,
/// 责任链的处置者。
pub chain_handler: String,
/// 广播式的处置者。
pub broadcast_handler: String,
/// 结论。
pub disposition_text: String,
/// 责任链走了几站。
pub chain_visited: usize,
/// 广播式走了几站。
pub broadcast_visited: usize,
/// 结论是否一致。
pub identical_text: String,
}
/// 由分析层对照结果转出一行。
pub fn broadcast_row_from_pair(pair: &crate::analysis::BroadcastPair) -> BroadcastRowView {
BroadcastRowView {
ticket_number: pair.ticket_number.clone(),
chain_handler: pair.chain_handler.clone(),
broadcast_handler: pair.broadcast_handler.clone(),
disposition_text: pair.chain_disposition.clone(),
chain_visited: pair.chain_visited,
broadcast_visited: pair.broadcast_visited,
identical_text: if pair.identical { "一致" } else { "★ 不同" }.to_string(),
}
}
/// 渲染广播式对照表(幕十)。
pub fn render_broadcast_table(title: &str, rows: &[BroadcastRowView], notes: &[&str]) -> Vec<String> {
let mut table: Table = Table::new(
title,
vec![
TableColumn::new("工单号", NUMBER_WIDTH),
TableColumn::new("责任链处置者", CHAIN_HANDLER_WIDTH),
TableColumn::new("广播式处置者", BROADCAST_HANDLER_WIDTH),
TableColumn::new("结论", DISPOSITION_WIDTH),
TableColumn::new("责任链判定次数", CHAIN_VISITED_WIDTH),
TableColumn::new("广播式判定次数", BROADCAST_VISITED_WIDTH),
TableColumn::new("一致", IDENTICAL_WIDTH),
],
);
for row in rows.iter() {
table = table.with_row(vec![
row.ticket_number.clone(),
row.chain_handler.clone(),
row.broadcast_handler.clone(),
row.disposition_text.clone(),
row.chain_visited.to_string(),
row.broadcast_visited.to_string(),
row.identical_text.clone(),
]);
}
for note in notes.iter() {
table = table.with_note(note);
}
table.render(crate::app::DOCUMENT_WIDTH)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : extension_report.rs
//! 扩展对照表(幕十二):**证明「完全可扩展」不是一句口号**。
//!
//! ## ★ 可扩展性的判据不是「可以加」,而是「加了之后**自动**被算进去」
//!
//! 「可以加一个新处理器」几乎所有写法都能做到。
//! 做不到的是:加完之后,**不修改任何既有分层文件**,
//! 新内容就被自动纳入链、纳入台账、纳入报表。
//!
//! 因此这一幕给出的证据是两组数字:
//! - **改动文件数 = 0**(工程内 `src/` 分层文件一行未改);
//! - **在哪张表多了几行**(具体列出),而不是一句「已支持扩展」。
use crate::app::{Table, TableColumn};
/// 扩展表的列宽。
///
/// 列宽总和必须 <= 127(四列):`34+22+22+48 = 126`。
const ITEM_WIDTH: usize = 34;
const BUILTIN_WIDTH: usize = 22;
const EXTENDED_WIDTH: usize = 22;
const EVIDENCE_WIDTH: usize = 48;
/// 扩展对照的一行。
#[derive(Clone, Debug)]
pub struct ExtensionRowView {
/// 对照项。
pub item: String,
/// 内置时的值。
pub builtin_text: String,
/// 扩展后的值。
pub extended_text: String,
/// ★ 证据(在哪张表多了几行 / 改了哪个文件)。
pub evidence_text: String,
}
/// 渲染扩展对照表(幕十二)。
///
/// # 参数
/// - `rows`:各行。
/// - `notes`:注解。
pub fn render_extension_table(title: &str, rows: &[ExtensionRowView], notes: &[&str]) -> Vec<String> {
let mut table: Table = Table::new(
title,
vec![
TableColumn::new("对照项", ITEM_WIDTH),
TableColumn::new("内置(扩展前)", BUILTIN_WIDTH),
TableColumn::new("扩展后", EXTENDED_WIDTH),
TableColumn::elidable(TableColumn::new("★ 证据", EVIDENCE_WIDTH)),
],
);
for row in rows.iter() {
table = table.with_row(vec![
row.item.clone(),
row.builtin_text.clone(),
row.extended_text.clone(),
row.evidence_text.clone(),
]);
}
for note in notes.iter() {
table = table.with_note(note);
}
table.render(crate::app::DOCUMENT_WIDTH)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : foundation_report.rs
//! 基础档案表:门店 / 工单类型 / 权限档位。
//!
//! ## ★ 为什么要单独列出「权限档位」
//!
//! 「哪一级能批多少钱」是责任链上**最容易被人随手改**的一个数,
//! 而改它之后所有的处置结论都会变,报表上却看不出变化来自哪里。
//! 把它单独印成一张表,读者才能在看结论之前先知道口径。
use crate::app::{Table, TableColumn};
use crate::domain::{AuthorityLevel, StoreCode, TicketType};
/// 门店表列宽。
const STORE_CODE_WIDTH: usize = 18;
const STORE_NAME_WIDTH: usize = 20;
/// 渲染门店档案(幕二)。
pub fn render_store_table(stores: &[StoreCode]) -> Vec<String> {
let mut table: Table = Table::new(
"幕二 · 门店档案(受理门店)",
vec![
TableColumn::new("门店编码", STORE_CODE_WIDTH),
TableColumn::new("门店名称", STORE_NAME_WIDTH),
],
);
for store in stores.iter() {
table = table.with_row(vec![
store.code().to_string(),
store.chinese_name().to_string(),
]);
}
table = table.with_note(
"★ 门店编码是**开放型标签**(`const fn new`):会不断增加、\
但不被当作查找键,因此不实现 `Ord` / `Hash`(用不上的 trait 本身就是防错)。",
);
table.render(crate::app::DOCUMENT_WIDTH)
}
/// 工单类型表列宽。
const TYPE_CODE_WIDTH: usize = 16;
const TYPE_NAME_WIDTH: usize = 16;
const TYPE_WINDOW_WIDTH: usize = 18;
const TYPE_RECEIPT_WIDTH: usize = 22;
const TYPE_WARRANTY_WIDTH: usize = 20;
/// 渲染工单类型清单(幕三)。
pub fn render_ticket_type_table(ticket_types: &[TicketType]) -> Vec<String> {
let mut table: Table = Table::new(
"幕三 · 工单类型清单(每种类型自带判定参数)",
vec![
TableColumn::new("编码", TYPE_CODE_WIDTH),
TableColumn::new("类型名称", TYPE_NAME_WIDTH),
TableColumn::new("退换窗口", TYPE_WINDOW_WIDTH),
TableColumn::new("必须凭购买凭证", TYPE_RECEIPT_WIDTH),
TableColumn::new("走保修判定", TYPE_WARRANTY_WIDTH),
],
);
for ticket_type in ticket_types.iter() {
table = table.with_row(vec![
ticket_type.code().to_string(),
ticket_type.chinese_name().to_string(),
if ticket_type.has_return_window() {
format!("{} 个月", ticket_type.return_window_months())
} else {
"不适用".to_string()
},
crate::app::bool_text(ticket_type.needs_receipt()).to_string(),
crate::app::bool_text(ticket_type.needs_warranty()).to_string(),
]);
}
table = table.with_note(
"★ 判定参数**写在类型上**而不是写在处理器里:\
「有没有凭证」这类问题若在三处各写一遍,\
三处写错成两种口径时报表上不会有任何异常,只会少几张单。",
);
table = table.with_note(
"★ 维修与成色复检**不需要凭证**、但**走保修**;投诉两者都不走,\
因此它只能靠金额落到某一级权限上------这正是「链要够长」的原因。",
);
table.render(crate::app::DOCUMENT_WIDTH)
}
/// 权限档位表列宽。
const LEVEL_CODE_WIDTH: usize = 18;
const LEVEL_NAME_WIDTH: usize = 22;
const LEVEL_CEILING_WIDTH: usize = 20;
/// 40 列按**最多能列出几个样例金额**定:总部一档会列出 5 个金额,
/// 因此这一列是**可省略**的(超宽时按省略号截断,不静默丢字)。
const LEVEL_COVER_WIDTH: usize = 40;
/// 渲染权限档位清单(幕四)。
///
/// # 参数
/// - `levels`:权限档位(**按权限从小到大**)。
/// - `sample_amounts`:样例金额(分),用于演示「这个金额落到哪一级」。
pub fn render_authority_table(levels: &[AuthorityLevel], sample_amounts: &[i64]) -> Vec<String> {
let mut table: Table = Table::new(
"幕四 · 处置权限档位(哪一级能批多大金额)",
vec![
TableColumn::new("编码", LEVEL_CODE_WIDTH),
TableColumn::new("权限层级", LEVEL_NAME_WIDTH),
TableColumn::new("金额上限", LEVEL_CEILING_WIDTH),
TableColumn::elidable(TableColumn::new("样例金额归属", LEVEL_COVER_WIDTH)),
],
);
for level in levels.iter() {
let covered: String = sample_amounts
.iter()
.filter(|amount| level.covers(**amount))
.map(|amount| crate::domain::Money::new(*amount, crate::domain::CNY).amount_text())
.collect::<Vec<String>>()
.join("、");
table = table.with_row(vec![
level.code().to_string(),
level.chinese_name().to_string(),
level.ceiling_text(),
if covered.is_empty() {
"---".to_string()
} else {
covered
},
]);
}
table = table.with_note(
"★ 判据是 `金额 <= 上限`(**含等号**):恰好等于上限归本级,\
这是「权限」而不是「阈值」的语义,写反了边界上就会差一张单。",
);
table = table.with_note(
"★ 「样例金额归属」一列刻意**列出所有落在本级的样例**而不是只列最大的:\
因为更高一级也能批同一笔钱,\
「哪一级**真的**批了」由链的顺序决定,不由这张表决定。",
);
table.render(crate::app::DOCUMENT_WIDTH)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : health_check_report.rs
//! 体检表(幕十四):**把前面每一幕的结论收成一张可判定的表**。
//!
//! ## ★ 「通过 / 未通过」全工程只有这一处产生
//!
//! 若每张表各写一次「√ / ×」,就会出现一处写「通过」、一处写「OK」的分裂,
//! 而读者只会以为那是两件事。
//! 因此判定文本由 [`crate::analysis::CheckLine::verdict_text`] 唯一产生,
//! 本层只负责排版。
use crate::analysis::CheckReport;
use crate::app::{Table, TableColumn};
/// 体检表的列宽。
///
/// 列宽总和必须 <= 127(四列):`54+41+22+10 = 127`。
/// 这个上界来自表格边框开销:四列时总宽 = `1 + Σ列宽 + 3×4 = Σ列宽 + 13`,
/// 因此 `140 - 13 = 127`。
///
/// ⚠️ 数字由 `scripts/_cell.txt` 的插桩实测得出(**最宽值**,表头也算候选),
/// 不是拍脑袋分配的:检查项 54、实测 21、结论 6(表头 8)。
const ITEM_WIDTH: usize = 54;
const EXPECTATION_WIDTH: usize = 41;
const ACTUAL_WIDTH: usize = 22;
const VERDICT_WIDTH: usize = 10;
/// ★ 可证伪性清单的列宽(两列):`54 + 71 = 125`。
///
/// ## 为什么要单独再来一张表
///
/// 每一条检查的「已知会失败的输入」是**这条检查值不值钱的唯一证据**------
/// 一条对任何输入都成立的检查是恒真的废检查。
/// 但这段证据通常有五六十列宽,塞进四列的体检表只能被省略掉。
/// 于是单独开一张两列的表把它**完整**印出来:体检表负责判定,这张表负责证伪。
/// 判据很简单:**「结论」可以省略,「凭什么说这条检查能失败」不可以。**
const FALSIFICATION_TEXT_WIDTH: usize = 71;
/// 渲染体检表(幕十四)。
///
/// # 参数
/// - `title`:表标题(**把通过数写进标题**:数字由调用方算)。
/// - `report`:检查报告。
pub fn render_check_table(title: &str, report: &CheckReport) -> Vec<String> {
let mut table: Table = Table::new(
title,
vec![
TableColumn::new("检查项", ITEM_WIDTH),
TableColumn::elidable(TableColumn::new("期望", EXPECTATION_WIDTH)),
TableColumn::new("实测", ACTUAL_WIDTH),
TableColumn::new("结论", VERDICT_WIDTH),
],
);
for line in report.lines().iter() {
table = table.with_row(vec![
line.item().to_string(),
line.expectation_text().to_string(),
line.actual_text().to_string(),
line.verdict_text().to_string(),
]);
}
// ⚠️ 先绑定到局部变量再取 `&str`:
// `report.failed_items_text()` 返回一个临时 `String`,
// 在 `format!` 的参数位置上直接借它会 E0716(临时值当场被释放)。
let tail_text: String = if report.all_passed() {
"全部通过。".to_string()
} else {
report.failed_items_text()
};
table = table.with_note(&format!(
"合计 {} 条,通过 {} 条,未通过 {} 条。{}",
report.line_total(),
report.passed_total(),
report.failed_total(),
tail_text.as_str()
));
table.render(crate::app::DOCUMENT_WIDTH)
}
/// ★ 渲染可证伪性清单(幕十四续):**每一条检查的「已知会失败的输入」完整印出**。
///
/// # 参数
/// - `title`:表标题(**把条数写进标题**:数字由调用方算)。
/// - `report`:检查报告(与体检表同一份,不再另算一遍)。
pub fn render_falsification_table(title: &str, report: &CheckReport) -> Vec<String> {
let mut table: Table = Table::new(
title,
vec![
TableColumn::new("检查项", ITEM_WIDTH),
// ⚠️ 这一列**不标可省略**:被省略掉的证据等于没有证据。
TableColumn::new("已知会失败的输入", FALSIFICATION_TEXT_WIDTH),
],
);
for line in report.lines().iter() {
table = table.with_row(vec![
line.item().to_string(),
line.expectation_text().to_string(),
]);
}
table = table.with_note(
"★ 这一列是**体检表的「期望」列的完整版**(体检表里放不下的部分在这里补全)。\
一条检查若写不出「什么输入会让它失败」,它就是恒真的废检查------\
这一张表就是用来把这种检查逼出来的。",
);
table.render(crate::app::DOCUMENT_WIDTH)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : interface_report.rs
//! 接口清点(幕十三):**每个对外只读接口都真的调用一次并印出结果**。
//!
//! ## ★ 这一幕同时是告警治理的落点
//!
//! 一个只读接口若在这里被调用并印出结果,它就不可能是「写了没人用」------
//! 也就不可能是「写错了没人发现」。
//! 既有工程的教训:**告警治理优先「用起来」而不是「删掉」**;
//! 「统一出口被绕过」是被 `unused_imports` 抓出来的,
//! 正确修法是改走出口,而不是加 `#[allow]`。
//!
//! ## 为什么用 `&'static str` 当列名之外的键
//!
//! 每一项的第一个元素是**接口全名**(一个字符串),第二个是**调用结果**。
//! 于是「这个接口存在且能跑」与「它返回了什么」在同一行里,
//! 读者不必再去翻代码。
use crate::app::{Table, TableColumn};
/// 接口名列宽。
///
/// ⚠️ 这一列标成**可省略**:接口名会随接口增加而变长,
/// 把版面预算压在它身上,等于让「新增一个接口」变成一件要改列宽的事。
pub const INTERFACE_NAME_WIDTH: usize = 62;
/// 输出列宽(`62 + 71 + 2×3 + 1 = 140 <= 140`)。
///
/// ⚠️ 这一列同样标成**可省略**,但省略是**可见**的(行尾 `...`):
/// 像「省下 29 次判定」这种整句结论在 140 列的两列表里放不下,
/// 完整值在对应那一幕(幕十一)里本来就有,这里重复一遍只是为了让接口被真调。
/// 与此相反,「接口名」一律**压到 62 列以内不许省略**------
/// 它是读者回查代码的唯一线索,被省略就等于没写。
pub const INTERFACE_OUTPUT_WIDTH: usize = 71;
/// 渲染接口清点表(幕十三)。
///
/// # 参数
/// - `title`:表标题(**把总数写进标题**:数字由调用方算,本层不做计算)。
/// - `items`:接口名与调用结果。
pub fn render_interface_table(title: &str, items: &[(String, String)]) -> Vec<String> {
let mut table: Table = Table::new(
title,
vec![
TableColumn::elidable(TableColumn::new("接口", INTERFACE_NAME_WIDTH)),
TableColumn::elidable(TableColumn::new("调用结果", INTERFACE_OUTPUT_WIDTH)),
],
);
for (name, output) in items.iter() {
table = table.with_row(vec![name.clone(), output.clone()]);
}
table = table.with_note(
"★ 每一项都是**真的调用一次**后印出的结果,不是签名列表。\
因此「写了没人用」的接口在这一幕里无处藏身。",
);
table = table.with_note(
"★ 只读接口指「取一个数 / 取一段文本」这类不改动状态的接口;\
会改状态的接口(如 `HandlerChain::submit`)在各幕里已经被真跑过。",
);
table = table.with_note(
"★ 版面口径:**接口名一律完整不省略**(这一列的宽度就是按最长的一项定的);\
「调用结果」里若出现整句结论,放不下时行尾显示 `...`,\
完整值在对应那一幕里另行印出------可见省略不等于静默截断。",
);
table.render(crate::app::DOCUMENT_WIDTH)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : layout.rs
//! 表格引擎:列宽、对齐、可见省略、框线、注解折行。
//!
//! ## 列宽的唯一来源:`TableColumn`
//!
//! 每一列都要**显式声明宽度**,且宽度依据写在声明处的注释里。
//! 不提供「自适应列宽」:自适应会让同一张表在不同数据下宽度不同,
//! 于是「两次运行逐字节一致」依赖数据而不是代码------那是把不确定性引进来了。
use crate::support::{display_width, elide_text, horizontal_rule, pad_left, pad_right};
/// 列对齐。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnAlign {
/// 左对齐(文本列)。
Left,
/// 右对齐(数字列)。
Right,
}
/// 一列。
#[derive(Debug, Clone)]
pub struct TableColumn {
/// 表头文本。
header: &'static str,
/// 列宽(**显示列**,中文按 2 列计)。
width: usize,
/// 对齐方式。
align: ColumnAlign,
/// 是否**允许**可见省略。
///
/// ⚠️ 这个开关是本工程「不许静默截断」纪律的载体:
/// 不标它是默认,标了它必须是**有意**的(该列内容确实不可控)。
elidable: bool,
}
impl TableColumn {
/// 声明一列(左对齐、**不允许**省略)。
pub const fn new(header: &'static str, width: usize) -> Self {
TableColumn { header, width, align: ColumnAlign::Left, elidable: false }
}
/// 声明一个数字列(右对齐、不允许省略)。
pub const fn numeric(header: &'static str, width: usize) -> Self {
TableColumn { header, width, align: ColumnAlign::Right, elidable: false }
}
/// 把本列改成**允许**可见省略(链式)。
pub const fn elidable(mut self) -> Self {
self.elidable = true;
self
}
/// 表头。
pub const fn header(&self) -> &'static str {
self.header
}
/// 列宽。
pub const fn width(&self) -> usize {
self.width
}
/// 对齐。
pub const fn align(&self) -> ColumnAlign {
self.align
}
/// 是否允许省略。
pub const fn is_elidable(&self) -> bool {
self.elidable
}
}
/// 渲染一个单元格。
///
/// # 三条行为
///
/// 1. `elidable == false` → 内容**必须**放得下;否则 `debug_assert!` 当场 panic,
/// 错误信息里带上列宽与所需宽度,直接告诉你该改哪一列。
/// 2. `elidable == true` → 走可见省略,末尾有 `...`,读者知道这里被省了。
/// 3. 省略后按列宽**补白**。
///
/// # ⚠️ 为什么是 `debug_assert` 而不是 `assert`
///
/// 它是**开发期纪律**:`cargo run`(debug)时立刻 panic,
/// release 下不存在,不会让线上报表因一条宽度问题而中断。
/// 同理它也**不是**批量排查的工具------排查请用临时 `eprintln!` 插桩
/// (一次跑出全部位置),见 `app` 模块文档。
pub fn render_cell(column: &TableColumn, text: &str) -> String {
let fitted: String = if column.is_elidable() {
elide_text(text, column.width())
} else {
let needed: usize = display_width(text);
debug_assert!(
needed <= column.width(),
"单元格放不下且该列不允许省略:列「{}」宽 {} 列,内容需要 {} 列,内容原文=[{}]",
column.header(),
column.width(),
needed,
text
);
// debug 下上面已 panic;release 下退化成可见省略,
// 而不是静默截断------**任何构建下都不接受「悄悄少一截」**。
if needed > column.width() {
elide_text(text, column.width())
} else {
text.to_string()
}
};
match column.align() {
ColumnAlign::Left => pad_right(&fitted, column.width()),
ColumnAlign::Right => pad_left(&fitted, column.width()),
}
}
/// 一张表:标题 + 列声明 + 数据行 + 注解。
#[derive(Debug, Clone)]
pub struct Table {
/// 标题(渲染成 `■ 标题`)。
title: String,
/// 列声明。
columns: Vec<TableColumn>,
/// 数据行。
rows: Vec<Vec<String>>,
/// 注解(**永不省略**,只折行)。
notes: Vec<String>,
}
impl Table {
/// 建一张表。
pub fn new(title: &str, columns: Vec<TableColumn>) -> Self {
Table {
title: title.to_string(),
columns,
rows: Vec::new(),
notes: Vec::new(),
}
}
/// 追加一行(链式)。
pub fn with_row(mut self, row: Vec<String>) -> Self {
self.rows.push(row);
self
}
/// 追加一条注解(链式)。
pub fn with_note(mut self, note: &str) -> Self {
self.notes.push(note.to_string());
self
}
/// 列数。
pub fn column_count(&self) -> usize {
self.columns.len()
}
/// 行数。
pub fn row_count(&self) -> usize {
self.rows.len()
}
/// 表的总宽度(各列宽之和 + 每列两侧的空格与竖线)。
///
/// 每列的开销是 3:左空格 1 + 右空格 1 + 竖线 1;
/// 最左边还有一条竖线,因此再 +1。
pub fn total_width(&self) -> usize {
self.columns.iter().map(|column| column.width()).sum::<usize>()
+ self.column_count() * 3
+ 1
}
/// 框线(上框 / 分隔 / 下框)。
fn border_line(&self) -> String {
format!("┌{}┐", self.segments('┬', '─'))
}
fn middle_line(&self) -> String {
format!("├{}┤", self.segments('┼', '─'))
}
fn bottom_line(&self) -> String {
format!("└{}┘", self.segments('┴', '─'))
}
fn segments(&self, joint: char, fill: char) -> String {
self.columns
.iter()
.map(|column| fill.to_string().repeat(column.width() + 2))
.collect::<Vec<String>>()
.join(&joint.to_string())
}
/// 渲染一行数据。
///
/// ⚠️ 单元格数不足时用空串兜底而不是 panic:
/// 少给一个单元格只是这一行少一格,
/// 整张报表仍然可读;panic 会让连后面的幕都出不来。
fn data_line(&self, cells: &[String]) -> String {
let rendered: Vec<String> = self
.columns
.iter()
.enumerate()
.map(|(index, column)| render_cell(column, cells.get(index).map(String::as_str).unwrap_or("")))
.collect();
format!("│ {} │", rendered.join(" │ "))
}
/// 渲染整张表。
pub fn render(self, document_width: usize) -> Vec<String> {
debug_assert!(
self.total_width() <= document_width,
"表宽 {} 超过文档宽度 {}(表:{})",
self.total_width(),
document_width,
self.title
);
let mut lines: Vec<String> = Vec::new();
lines.push(section_header(&self.title, document_width));
lines.push(self.border_line());
// ★ 表头也走 `render_cell`:表头超宽同样要被抓出来。
let header_cells: Vec<String> = self
.columns
.iter()
.map(|column| column.header().to_string())
.collect();
lines.push(self.data_line(&header_cells));
lines.push(self.middle_line());
for row in self.rows.iter() {
debug_assert!(
row.len() == self.columns.len(),
"行宽 {} 与列数 {} 不一致(表:{})",
row.len(),
self.columns.len(),
self.title
);
lines.push(self.data_line(row));
}
lines.push(self.bottom_line());
// ★ 空表不静默:一张没有数据行的表,与「这一幕忘了填数据」在版面上
// 长得一模一样。补一条可见注解,让「零行」变成一个能被看见的状态。
let mut notes: Vec<String> = self.notes.clone();
if self.row_count() == 0 {
notes.push("(本表无数据行)".to_string());
}
for note in notes.iter() {
lines.extend(note_lines(note, document_width));
}
lines
}
}
/// 幕标题:`■ 标题`(右侧补到文档宽度)。
///
/// ⚠️ 标题**只由本函数产生**:若调用方也写一次标题,
/// 就会出现「改一次标题要改两处」------迟早不一致。
pub fn section_header(title: &str, document_width: usize) -> String {
let text: String = format!("■ {}", title);
let used: usize = display_width(&text);
if used >= document_width {
return text;
}
format!("{}{}", text, " ".repeat(document_width - used))
}
/// 把一段长文本折成多行注解(**永不省略**,只折行)。
///
/// ## ⚠️ 可用宽度按「最宽的那种前缀」留白
///
/// 首行前缀 ` · ` 是 **4 列**,续行前缀 ` ` 是 2 列。
/// 若按续行的 2 列留白,首行就会溢出 2 列------
/// 既有工程实测过:37 行注解达到 142 列 / 文档宽 140,
/// 而溢出的注解在框线旁边几乎看不出来。
/// **统一按 4 列留白是唯一不会错的算法**,代价只是续行少 2 列。
///
/// ## 为什么注解永不省略
///
/// 注解承载的是**复算依据**(分子是什么、分母是什么、哪个常数用在哪)。
/// 省略掉一段,读者就少一条验算路径------那正是报表存在的理由。
pub fn note_lines(text: &str, document_width: usize) -> Vec<String> {
let available: usize = document_width.saturating_sub(4).max(1);
let mut lines: Vec<String> = Vec::new();
let mut current: String = String::new();
let mut current_width: usize = 0;
for character in text.chars() {
let character_width: usize = if crate::support::is_wide_character(character) { 2 } else { 1 };
if current_width + character_width > available && !current.is_empty() {
lines.push(current);
current = String::new();
current_width = 0;
}
current.push(character);
current_width += character_width;
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
// 空注解仍然产出一行,避免「注解存在但输出为零行」这种不可见状态。
lines.push(String::new());
}
lines
.into_iter()
.enumerate()
.map(|(index, line)| {
if index == 0 {
format!(" · {}", line)
} else {
format!(" {}", line)
}
})
.collect()
}
/// 生成一条与表同宽的横线(供需要额外分隔的场合用)。
pub fn table_rule(width: usize) -> String {
horizontal_rule(width)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : order_report.rs
//! 换序对照表(幕九):**同一批处理器、两条不同的链**。
//!
//! ## ★ 这一幕回答一句话:顺序到底算不算业务规则
//!
//! 换序**照样能编译、照样能跑**,只是结论悄悄变了。
//! 因此要有一个数字:**换序改变了几张单的结论**。
//!
//! 这个数字为 0 不代表没问题(可能是数据没覆盖到),
//! 但它**不为 0** 就证明顺序真的在起作用------
//! 而这一点若只靠看代码,是永远看不出来的。
use crate::app::{Table, TableColumn};
/// 换序表的列宽。
const NUMBER_WIDTH: usize = 14;
const TYPE_WIDTH: usize = 14;
const NORMAL_HANDLER_WIDTH: usize = 20;
const SWAPPED_HANDLER_WIDTH: usize = 20;
const NORMAL_DISPOSITION_WIDTH: usize = 16;
const SWAPPED_DISPOSITION_WIDTH: usize = 16;
const CHANGED_WIDTH: usize = 10;
/// 换序对照的一行。
#[derive(Clone, Debug)]
pub struct OrderRowView {
/// 工单号。
pub ticket_number: String,
/// 工单类型名。
pub ticket_type_name: String,
/// 正常序的处置者。
pub normal_handler: String,
/// 换序后的处置者。
pub swapped_handler: String,
/// 正常序的结论。
pub normal_disposition: String,
/// 换序后的结论。
pub swapped_disposition: String,
/// 是否变了。
pub changed_text: String,
}
/// 由分析层对照结果转出一行。
pub fn order_row_from_pair(pair: &crate::analysis::OrderPair) -> OrderRowView {
OrderRowView {
ticket_number: pair.ticket_number.clone(),
ticket_type_name: pair.ticket_type_name.clone(),
normal_handler: pair.normal_handler.clone(),
swapped_handler: pair.swapped_handler.clone(),
normal_disposition: pair.normal_disposition.clone(),
swapped_disposition: pair.swapped_disposition.clone(),
changed_text: if pair.changed { "★ 变了" } else { "相同" }.to_string(),
}
}
/// 渲染换序对照表(幕九)。
///
/// # 参数
/// - `first_column` / `second_column`:★ 两条链的列名(**由调用方传**,
/// 因为同一张表在不同幕复用时列名必须跟着换,写死会让后几幕撒谎)。
pub fn render_order_table(
title: &str,
rows: &[OrderRowView],
first_column: &'static str,
second_column: &'static str,
notes: &[&str],
) -> Vec<String> {
let mut table: Table = Table::new(
title,
vec![
TableColumn::new("工单号", NUMBER_WIDTH),
TableColumn::new("类型", TYPE_WIDTH),
TableColumn::new(first_column, NORMAL_HANDLER_WIDTH),
TableColumn::new(second_column, SWAPPED_HANDLER_WIDTH),
TableColumn::new("结论一", NORMAL_DISPOSITION_WIDTH),
TableColumn::new("结论二", SWAPPED_DISPOSITION_WIDTH),
TableColumn::new("差异", CHANGED_WIDTH),
],
);
for row in rows.iter() {
table = table.with_row(vec![
row.ticket_number.clone(),
row.ticket_type_name.clone(),
row.normal_handler.clone(),
row.swapped_handler.clone(),
row.normal_disposition.clone(),
row.swapped_disposition.clone(),
row.changed_text.clone(),
]);
}
for note in notes.iter() {
table = table.with_note(note);
}
table.render(crate::app::DOCUMENT_WIDTH)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : outcome_report.rs
//! 逐单处置结果(幕七):**谁接的、走了几站、跳过了几站**。
//!
//! ## ★ 「跳过几站」这一列是本工程的题眼
//!
//! 责任链省下的不是时间(时间不可复现,谈不上逐字节一致的验收),
//! 而是**判定次数**:一张单在第 k 站被接下,
//! 后面 `站数 − k − 1` 站**一次都没被调用**。
//! 这一列把那句抽象的话变成了一个可以逐张单核对的整数。
use crate::app::{Table, TableColumn};
/// 结果表的列宽。
///
/// 列宽总和必须 <= 112(九列):`12+10+14+16+12+14+6+6+22 = 112`。
const NUMBER_WIDTH: usize = 12;
const TYPE_WIDTH: usize = 10;
const AMOUNT_WIDTH: usize = 14;
const HANDLER_WIDTH: usize = 16;
const DISPOSITION_WIDTH: usize = 12;
const COMPENSATION_WIDTH: usize = 14;
/// 「站数」列(表头 4 列,数值至多 1 位)。
const VISITED_WIDTH: usize = 6;
/// 「跳过」列(表头 4 列,数值至多 1 位)。
const SKIPPED_WIDTH: usize = 6;
const TRACE_WIDTH: usize = 22;
/// 结果表的一行。
#[derive(Clone, Debug)]
pub struct OutcomeRowView {
/// 工单号。
pub ticket_number: String,
/// 工单类型名。
pub ticket_type_name: String,
/// 涉及金额文本。
pub amount_text: String,
/// ★ 处置者。
pub handler_name: String,
/// 结论类型。
pub disposition_text: String,
/// 赔付金额文本。
pub compensation_text: String,
/// 走了几站。
pub visited_total: usize,
/// ★ 因短路未调用的站数。
pub skipped_total: usize,
/// 访问轨迹。
pub visited_text: String,
}
/// 由分析层视图转出一行。
pub fn outcome_row_from_view(view: &crate::analysis::OutcomeView) -> OutcomeRowView {
OutcomeRowView {
ticket_number: view.ticket_number.clone(),
ticket_type_name: view.ticket_type_name.clone(),
amount_text: view.amount_text.clone(),
handler_name: view.handler_name.clone(),
disposition_text: view.disposition_text.clone(),
compensation_text: view.compensation_text.clone(),
visited_total: view.visited_total,
skipped_total: view.skipped_total,
visited_text: view.visited_text.clone(),
}
}
/// 渲染逐单处置结果(幕七)。
///
/// # 参数
/// - `title`:表标题(**由调用方传**:本表在扩展幕会复用)。
/// - `rows`:各行。
/// - `notes`:注解。
pub fn render_outcome_table(title: &str, rows: &[OutcomeRowView], notes: &[&str]) -> Vec<String> {
let mut table: Table = Table::new(
title,
vec![
TableColumn::new("工单号", NUMBER_WIDTH),
TableColumn::new("类型", TYPE_WIDTH),
TableColumn::new("涉及金额", AMOUNT_WIDTH),
TableColumn::new("★ 处置者", HANDLER_WIDTH),
TableColumn::new("结论", DISPOSITION_WIDTH),
TableColumn::new("赔付", COMPENSATION_WIDTH),
TableColumn::new("站数", VISITED_WIDTH),
TableColumn::new("★ 跳过", SKIPPED_WIDTH),
TableColumn::elidable(TableColumn::new("访问轨迹", TRACE_WIDTH)),
],
);
for row in rows.iter() {
table = table.with_row(vec![
row.ticket_number.clone(),
row.ticket_type_name.clone(),
row.amount_text.clone(),
row.handler_name.clone(),
row.disposition_text.clone(),
row.compensation_text.clone(),
row.visited_total.to_string(),
row.skipped_total.to_string(),
row.visited_text.clone(),
]);
}
for note in notes.iter() {
table = table.with_note(note);
}
table.render(crate::app::DOCUMENT_WIDTH)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : overview_report.rs
//! 总览与两列通用表。
//!
//! ## ★ 为什么「两列表」要有一个通用函数
//!
//! 本工程有七八张「项目 / 值」两列表(总览、价值量化、台账快照......)。
//! 若每张各写一次列宽,就会出现一处 34 列、一处 38 列的分裂,
//! 而**列宽不一致在报表上看不出来**(只有内容超宽时才会炸)。
//!
//! 收口成一个函数之后,改列宽只改一处,
//! 且「最宽的项目名是什么」这个问题只需要回答一次。
use crate::app::{Table, TableColumn};
/// 项目列宽(**按最宽的项目名**定,含表头)。
///
/// ⚠️ 判据:项目名最长的是
/// `★ 若不用责任链(每站都问一遍)要多少次判定`(22 个汉字 = 44 列)之类。
/// 表头「项目」只占 4 列,**不是**最宽的------
/// 既有工程的教训:合计行往往最宽,**表头也算候选**。
const ITEM_WIDTH: usize = 46;
/// 值列宽。
///
/// `140 − 46 − 2×3 − 1 = 87`
const VALUE_WIDTH: usize = 87;
/// ★ 渲染一张「项目 / 值」两列表。
///
/// # 参数
/// - `title`:表标题。
/// - `rows`:行(项目, 值)。
/// - `notes`:注解(**永不省略、只折行**)。
pub fn render_pair_table(title: &str, rows: &[(String, String)], notes: &[&str]) -> Vec<String> {
let mut table: Table = Table::new(
title,
vec![
TableColumn::new("项目", ITEM_WIDTH),
TableColumn::elidable(TableColumn::new("值", VALUE_WIDTH)),
],
);
for (item, value) in rows.iter() {
table = table.with_row(vec![item.clone(), value.clone()]);
}
for note in notes.iter() {
table = table.with_note(note);
}
table.render(crate::app::DOCUMENT_WIDTH)
}
/// 渲染装配总览(幕一)。
///
/// # 参数
/// - `chain_name`:链名。
/// - `station_total`:站数。
/// - `ticket_total`:工单数。
/// - `tail_policy_text`:链尾策略文本。
/// - `station_text`:链上站点顺序(`① 登记 → ② 校验 ...` 形式)。
pub fn render_overview_table(
chain_name: &str,
station_total: usize,
ticket_total: usize,
tail_policy_text: &str,
station_text: &str,
) -> Vec<String> {
let rows: Vec<(String, String)> = vec![
("链名".to_string(), chain_name.to_string()),
("链上站点数".to_string(), format!("{} 站", station_total)),
("待处理工单数".to_string(), format!("{} 张", ticket_total)),
("★ 链尾策略".to_string(), tail_policy_text.to_string()),
("★ 站点顺序(顺序即业务规则)".to_string(), station_text.to_string()),
];
render_pair_table(
"幕一 · 装配总览:一条售后处置责任链",
&rows,
&[
"★ 责任链的三条硬约束:① 发送方只调一次 `submit`,不认识任何一站;\
② 各站彼此互不知情(没有 `set_next`,顺序在链上);\
③ 「没人接」是显式结局,不是一个 `None` 让调用方自己看着办。",
"★ 站点顺序**不是排版**:把「保修判定」挪到金额权限之后,\
在保的小额维修就会先被门店按「门店处置」接走,客户就得自付------\
而代码照样能编译、照样能跑。幕九会把这件事做成一个数字。",
],
)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : station_report.rs
//! 链上站点清单(幕六):**这条链长什么样**。
//!
//! ## ★ 为什么这张表值得单独占一幕
//!
//! 教科书里 `handler.set_next(next)` 的写法,把链的形状**散进每个处理器**,
//! 于是没有一个地方能列出「这条链长什么样」。
//! 本工程把顺序收进链对象,因此这一幕可以**直接印出**那张表------
//! 读者不需要去读七个 `Box::new` 才知道顺序。
use crate::app::{Table, TableColumn};
/// 站点表的列宽。
///
/// 列宽总和必须 <= 112(九列):`6+16+8+30+8+8+8+8+20 = 112`。
const ORDER_WIDTH: usize = 6;
const NAME_WIDTH: usize = 16;
/// 种类列只放得下**短文本**(`应答者` / `过路者` = 6 列),
/// 完整语义交给「凭什么接」那一列与表尾注解。
const KIND_WIDTH: usize = 8;
const DESCRIPTION_WIDTH: usize = 30;
const ASKED_WIDTH: usize = 8;
const HANDLED_WIDTH: usize = 8;
const PASSED_WIDTH: usize = 8;
const NEVER_WIDTH: usize = 8;
const RISK_WIDTH: usize = 20;
/// 站点表的一行。
#[derive(Clone, Debug)]
pub struct StationRowView {
/// 站序(从 1 开始)。
pub order_text: String,
/// 站名。
pub name: String,
/// 种类文本。
pub kind_text: String,
/// 凭什么接。
pub description: String,
/// 被问次数。
pub asked_total: usize,
/// 接下次数。
pub handled_total: usize,
/// 放行次数。
pub passed_total: usize,
/// 因短路未轮到的次数。
pub never_reached_total: usize,
/// 风险提示。
pub risk_text: String,
}
/// 渲染链上站点清单(幕六)。
///
/// # 参数
/// - `title`:表标题。
/// - `rows`:各行。
/// - `notes`:额外注解(**由调用方传**:同一张表在不同幕的注解不同)。
///
/// ⚠️ 为什么注解不由本模块写死:
/// 既有工程的教训------同一张表在多个幕复用时,**列名与注解都必须由调用方传**,
/// 写死会让后几幕撒谎。
pub fn render_station_table(title: &str, rows: &[StationRowView], notes: &[&str]) -> Vec<String> {
let mut table: Table = Table::new(
title,
vec![
TableColumn::new("站序", ORDER_WIDTH),
TableColumn::new("站名", NAME_WIDTH),
TableColumn::new("种类", KIND_WIDTH),
TableColumn::elidable(TableColumn::new("凭什么接", DESCRIPTION_WIDTH)),
TableColumn::new("被问", ASKED_WIDTH),
TableColumn::new("接下", HANDLED_WIDTH),
TableColumn::new("放行", PASSED_WIDTH),
TableColumn::new("未轮到", NEVER_WIDTH),
TableColumn::elidable(TableColumn::new("这一站的处境", RISK_WIDTH)),
],
);
for row in rows.iter() {
table = table.with_row(vec![
row.order_text.clone(),
row.name.clone(),
row.kind_text.clone(),
row.description.clone(),
row.asked_total.to_string(),
row.handled_total.to_string(),
row.passed_total.to_string(),
row.never_reached_total.to_string(),
row.risk_text.clone(),
]);
}
for note in notes.iter() {
table = table.with_note(note);
}
table.render(crate::app::DOCUMENT_WIDTH)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : ticket_report.rs
//! 工单清单(幕五):**进来时的样子**。
//!
//! ## ★ 为什么工单清单与处置结果分成两张表
//!
//! 工单是**输入**,结论是**输出**。
//! 合成一张表,读者就只能看到「这张单最后是 X」,
//! 看不到「它本来应该是 X 吗」。
//! 分开之后,幕七的每一格结论都能回到这一张表上核一遍。
use crate::app::{Table, TableColumn};
/// 工单清单的列宽。
///
/// 列宽总和必须 <= 112(九列):`12+10+10+14+8+6+8+8+34 = 110`。
///
/// 为什么**没有「品类」列**:这一张表只服务「这张单能不能受理」这一个判断,
/// 而品类**不参与**任何一处判定(判据只有:类型、凭证、月数、人为损坏)。
/// 多印一列不影响正确性,但会把「购买至今」挤到放不下------
/// 而「购买至今」正是保修与退换窗口的**输入**。
const NUMBER_WIDTH: usize = 12;
/// 12 列按**最长的门店名**定:`香港铜锣湾店` = 12 列。
const STORE_WIDTH: usize = 12;
const TYPE_WIDTH: usize = 10;
const AMOUNT_WIDTH: usize = 14;
const ELAPSED_WIDTH: usize = 8;
const RECEIPT_WIDTH: usize = 6;
const DAMAGE_WIDTH: usize = 8;
const ADMISSIBLE_WIDTH: usize = 8;
const REASON_WIDTH: usize = 34;
/// 工单清单的一行。
///
/// # 字段说明(**全是可直接排版字符串**)
#[derive(Clone, Debug)]
pub struct TicketRowView {
/// 工单号。
pub ticket_number: String,
/// 门店名。
pub store_name: String,
/// 工单类型名。
pub ticket_type_name: String,
/// 涉及金额文本。
pub amount_text: String,
/// 购买至今月数文本。
pub elapsed_text: String,
/// 凭证文本。
pub receipt_text: String,
/// 人为损坏文本。
pub damage_text: String,
/// 是否可受理。
pub admissible_text: String,
/// 不可受理的原因(可受理时为「---」)。
pub reason_text: String,
}
/// 渲染工单清单(幕五)。
pub fn render_ticket_table(title: &str, rows: &[TicketRowView]) -> Vec<String> {
let mut table: Table = Table::new(
title,
vec![
TableColumn::new("工单号", NUMBER_WIDTH),
TableColumn::new("门店", STORE_WIDTH),
TableColumn::new("类型", TYPE_WIDTH),
TableColumn::new("涉及金额", AMOUNT_WIDTH),
TableColumn::new("购买至今", ELAPSED_WIDTH),
TableColumn::new("凭证", RECEIPT_WIDTH),
TableColumn::new("人为损坏", DAMAGE_WIDTH),
TableColumn::new("可受理", ADMISSIBLE_WIDTH),
TableColumn::elidable(TableColumn::new("不可受理的原因", REASON_WIDTH)),
],
);
for row in rows.iter() {
table = table.with_row(vec![
row.ticket_number.clone(),
row.store_name.clone(),
row.ticket_type_name.clone(),
row.amount_text.clone(),
row.elapsed_text.clone(),
row.receipt_text.clone(),
row.damage_text.clone(),
row.admissible_text.clone(),
row.reason_text.clone(),
]);
}
table = table.with_note(
"★ 「可受理」与「拒单原因」由工单自己给(不是处理器判的):\
「能不能受理」只有一个地方说了算,处理器只是问它一句。",
);
table.render(crate::app::DOCUMENT_WIDTH)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : value_display.rs
//! 值对象的**报表排版**:把领域层存的整数渲染成人看的文本。
//!
//! ## ★ 哪些排版留在领域层,哪些搬到这里
//!
//! 判据只有一条:**这个文本形式是不是「所有层都要用」**。
//!
//! - **留在领域层**:`Money::chinese_text()`(`¥12,345.67`)。
//! 机制层报错、分析层结论、表示层表格都要把金额嵌进句子里------
//! 把它搬来表示层会让 `borg` 反向依赖 `app`,那才是真违规。
//! 值对象的**规范文本形式**属于值对象本身。
//! - **搬来表示层**:万分点汇率的两种读法。
//! 机制层存的是**万分点整数**;「把 9215 显示成 0.9215」与「把 5000 显示成 50.00%」
//! 是这张表列头写着「汇率」「复用率」才有的决定,只有报表需要。
//!
//! ## ⚠️ 为什么这里可以有除法
//!
//! 整个计算链路全程是整数,除法只发生在 [`crate::domain::divide_rounded`] 一处。
//! 本文件是**唯一的例外**:这两个函数都不参与任何合计,
//! 只把已经算完的整数拆成「整数部分 + 小数部分」,
//! 所以不存在舍入问题------余数是整除的余数,不是被舍掉的部分。
//!
//! 换句话说:**展示可以除,算账不可以除**。
//!
//! ## ⚠️ 为什么没有「毫克 → 克」这一类函数
//!
//! 前几个工程(门店钱箱、检测中心)的领域里有重量,所以这里备过 `gram_display_text`。
//! 本工程(售后工单)**没有任何重量口径**:金额、月数、次数就是全部量纲,
//! 因此那个函数被删掉而不是留着「以后可能用」。
//! 判据很简单:**排版函数是对着某一张表的列头存在的,没有那一列就不该有那个函数**------
//! 留着它,只会让「本工程有没有重量」这个问题在代码里有两个互相矛盾的答案。
/// 把汇率万分点渲染成小数文本(如 `9215` → `"0.9215"`)。
///
/// ⚠️ **`10000` 与 `10000.0` 是完全不同的两件事**:
/// 前者是 1:1,后者是「万倍」。写成 `{}.{:04}` 而不除 10000,
/// 读者会把 `9215` 读成「九千多倍汇率」。
pub fn basis_points_text(basis_points: i64) -> String {
let whole: i64 = basis_points.div_euclid(10_000);
let remainder: i64 = basis_points.rem_euclid(10_000);
format!("{}.{:04}", whole, remainder)
}
/// ★ 万分点转百分数文本(`10000` -> `100.00`)。
///
/// ⚠️ 为什么单列一个函数:`basis_points_text` 输出的是「几点几」
/// (`10000` -> `1.0000`,因为 10000 万分点 = 1 倍)。
/// 直接给它加一个 `%` 会变成 `1.0000%`------那是**百分之一**,
/// 而复用率 100% 应该显示 `100.00%`。
/// 这个错误在报表上完全看不出来(都是一个带百分号的小数),
/// 只能靠追问「分母是什么」才能发现,因此口径必须收在一处。
pub fn percent_text(basis_points: i64) -> String {
format!("{}.{:02}", basis_points / 100, (basis_points % 100).abs())
}
/// 把布尔量渲染成中文(`是` / `否`)。
///
/// ⚠️ 报表的布尔列一律走这里,不写 `if x { "是" } else { "否" }`:
/// 散着写迟早会出现一处写成 `符合/不符合`,
/// 而读者会以为那是另一种判断。
pub const fn bool_text(value: bool) -> &'static str {
if value {
"是"
} else {
"否"
}
}
/// 把字节数渲染成带千分位的文本。
///
/// ⚠️ 用整数反复除 10 取位,**全程不出现浮点**(与 `Money` 的千分位同理)。
pub fn byte_text(bytes: usize) -> String {
let digits: Vec<char> = bytes.to_string().chars().collect();
let mut buffer: Vec<char> = Vec::new();
for (index, character) in digits.iter().enumerate() {
let from_right: usize = digits.len() - index;
if index > 0 && from_right % 3 == 0 {
buffer.push(',');
}
buffer.push(*character);
}
buffer.into_iter().collect()
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : value_report.rs
//! 价值量化与台账快照(幕十一):**省下了多少次判定**。
//!
//! ## ★ 为什么用「判定次数」而不是耗时
//!
//! 耗时不可复现------两次运行不算同一个数,就谈不上「逐字节一致」的验收。
//! 可数的是:一张单在第 k 站被接下,后面 `站数 − k − 1` 站**一次都没被调用**。
//!
//! ## ★ 为什么台账快照要单独占一幕(续)
//!
//! 台账**只增不减**,因此报表一律读**快照**而不是实时计数:
//! 否则表格先渲染的几行与后渲染的几行来自不同时刻,
//! 读者会以为「跑这些实验没有副作用」------事实恰好相反
//! (每跑一次实验,访问次数就涨一截)。
use crate::analysis::{ChainValueReport, LedgerSnapshotView};
use crate::app::{byte_text, percent_text, render_pair_table};
/// 渲染价值量化表(幕十一)。
///
/// # 参数
/// - `report`:价值报告。
pub fn render_value_table(report: &ChainValueReport) -> Vec<String> {
let rows: Vec<(String, String)> = vec![
("投递工单数".to_string(), format!("{} 张", report.ticket_total())),
("链上站点数".to_string(), format!("{} 站", report.station_total())),
(
"★ 若不用责任链(每站都问一遍)的判定次数".to_string(),
format!("{} 次", byte_text(report.visited_total_if_broadcast())),
),
(
"★ 责任链实际判定次数".to_string(),
format!("{} 次", byte_text(report.visited_total())),
),
(
"★ 省下的判定次数(两个可数数字之差)".to_string(),
// ⚠️ `percent_text` 只负责「万分点 -> 百分数数字」这一段换算,
// 百分号由调用方补:`percent_text(4143)` 是 `41.43`,
// 若误用 `basis_points_text` 会得到 `0.4143`(那是「倍」不是「百分之几」)。
format!(
"{} 次({}%)",
byte_text(report.saved_judgement_total() as usize),
percent_text(report.saved_ratio_basis_points())
),
),
(
"因短路而未被调用的站数之和".to_string(),
format!("{} 站次", byte_text(report.skipped_total())),
),
("被处置单数".to_string(), format!("{} 张", report.settled_total())),
(
"★ 无人处置单数(链尾有兜底,必须为 0)".to_string(),
format!("{} 张", report.unhandled_total()),
),
("赔付合计".to_string(), report.compensation().chinese_text()),
];
render_pair_table(
"幕十一 · 价值量化:责任链省下了多少次判定",
&rows,
&[
"★ 刻意**不用耗时**做对照:CPU 时间不可复现,\
两次运行不算同一个数就谈不上验收。",
"★ 「省下 N 次」隐含一个**不成立**的假设:每次判定的代价一样。\
事实上「查一下保修期」与「走一遍权限表」的代价差得远。\
因此另给一个口径(跳过站数)并列,读者才不会把「省了 N 次」当成「省了 N 倍」。",
],
)
}
/// 渲染台账快照(幕十一续)。
///
/// # 参数
/// - `view`:台账快照的**分析层视图**(表示层不认识机制层类型)。
/// - `moment_text`:快照时刻的说明(**必须写清是哪个时刻**)。
pub fn render_ledger_snapshot_table(view: &LedgerSnapshotView, moment_text: &str) -> Vec<String> {
let rows: Vec<(String, String)> = vec![
(
"投递总数(只增不减)".to_string(),
format!("{} 张", byte_text(view.submitted_total)),
),
(
"被接下总数".to_string(),
format!("{} 张", byte_text(view.handled_total)),
),
(
"无人接手总数".to_string(),
format!("{} 张", byte_text(view.unhandled_total)),
),
(
"★ 判定次数(各站被问次数之和)".to_string(),
format!("{} 次", byte_text(view.visit_total)),
),
(
"★ 跳过次数(因短路未被调用的站数之和)".to_string(),
format!("{} 站次", byte_text(view.skipped_total)),
),
(
"★ 若不用责任链会是多少次判定".to_string(),
format!("{} 次", byte_text(view.visit_total_if_broadcast())),
),
(
"★ 省下的判定次数".to_string(),
format!("{} 次", byte_text(view.saved_judgement_total() as usize)),
),
(
"恒等式一:投递 − (被接下 + 无人接)".to_string(),
format!("{}(必须为 0)", view.submission_gap),
),
(
"恒等式二:被接下 − Σ(各站接下次数)".to_string(),
format!("{}(必须为 0)", view.settlement_gap),
),
(
"恒等式三:判定次数 − (被接下 + Σ放行)".to_string(),
format!("{}(必须为 0)", view.visit_gap),
),
(
"★ 死站数(一次都没被问过的站)".to_string(),
format!("{} 站", view.never_asked_stations().len()),
),
(
"★ 纯过路站数(被问过但一次都没接下)".to_string(),
format!("{} 站", view.pure_passthrough_stations().len()),
),
];
render_pair_table(
&format!("幕十一(续)· 链台账快照({})", moment_text),
&rows,
&[
"★ 台账**只增不减**,因此报表一律读**快照**:\
否则表格先渲染的几行与后渲染的几行来自不同时刻。",
"★ 三条恒等式让「某条路径漏记了计数」变成一个**可计算的数字**,\
而不是一句原则。",
],
)
}
调用:
rust
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:责任链模式Chain Of Responsibility Pattern 行为模式 Behavioral 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/14 19:47
//!# User : geovindu
//!# Product : RustRover
//!# Project : chainofresponsibilitypattern
//!# File : main.rs
//! # 责任链模式(Chain of Responsibility)------ 连锁珠宝售后工单处置链
//!
//! ## 严格分层结构
//!
//! ```text
//! main 装配点:造数据、装链、投递、编排十五幕、工程外扩展区
//! └── app 表示层:表格引擎 + 十三类报表(**不认识 chain / client**)
//! └── analysis 分析层:站点对照 / 换序对照 / 广播式对照 / 审计视图 / 体检
//! ├── client 调用层:工单处理器 + 链装配 + 会话
//! │ ├── domain 领域层:金额 / 月份 / 门店 / 品类 / 工单 / 权限 / 结论
//! │ │ └── support 支撑层:日期 / 确定性编码 / 显示宽度
//! │ └── chain ★ 机制层:处理器契约 + 链 + 台账 + 审计(**零 crate::**)
//! └── chain 机制层(分析层直接读它的台账与审计结果)
//! ```
//!
//! ## 依赖方向(**实测**,由 `scripts/check_layer_dependencies.sh` 得出,非设计意图)
//!
//! ```text
//! (`cargo build` 通过后跑脚本,把实测邻接表回填到这里)
//! ```
//!
//! ## 这个模式在解决什么
//!
//! > 一个请求的**发送方不该知道谁会处理它**。
//!
//! 本工程用一条**售后处置链**把它落成可以核对的东西:
//! 一张工单进来,沿链被逐站询问,直到某一站接下它。
//!
//! ## ★ 本工程最想讲清的四件事
//!
//! 1. **发送方不认识任何一站**:只调一次 `submit`,拿回一个带处置者名的结果。
//! 若调用方要自己挑处理器,那就不是责任链。
//! 2. **顺序是业务知识,不是排版**:把「保修判定」挪到金额权限之后,
//! 在保的小额维修就会先被门店按「门店处置」接走,客户就得自付------
//! **照样能编译、照样能跑**。幕九把这个事实做成一个数字。
//! 3. **「没人接」是显式结局**:`UNHANDLED_MARK` 是一个词,不是一个 `None`。
//! 摘掉兜底站会让 `SETTLED` 那条规则变红------幕八续真的摘过一次。
//! 4. **省下的是判定次数,不是时间**:时间不可复现,谈不上「逐字节一致」的验收;
//! 可数的是「第 k 站接下之后,后面 `站数 − k − 1` 站一次都没被调用」。
//!
//! ## ★ 责任链与流水线的分界
//!
//! 链上既有**应答者**(接了就停),也有**过路者**(做完继续,如登记留痕)。
//! 两者常串在同一条链上,因此不能靠「是不是责任链」二选一,
//! 而要靠 `HandlerKind` 这一个字段分开------审计才会说「过路者不做决定」。
mod analysis;
mod app;
mod chain;
mod client;
mod domain;
mod support;
use std::rc::Rc;
use crate::analysis::{
audit_check_views, broadcast_checks, compare_broadcast, compare_orders, compensation_total,
order_checks, station_checks, station_probes, AuditCheckView, BroadcastPair, ChainValueReport,
CheckLine, CheckReport, LedgerSnapshotView, OrderPair, OutcomeView, StationProbe,
};
use crate::app::{
basis_points_text, bool_text, byte_text, note_lines, render_audit_table, render_authority_table,
render_broadcast_table, render_check_table, render_extension_table,
render_falsification_table, render_interface_table, render_ledger_snapshot_table,
render_order_table, render_outcome_table, render_overview_table, render_pair_table, render_station_table, render_store_table, render_ticket_table,
render_ticket_type_table, render_value_table, section_header, table_rule, BroadcastRowView,
ExtensionRowView, OrderRowView, OutcomeRowView, StationRowView, TicketRowView, DOCUMENT_WIDTH,
INTERFACE_NAME_WIDTH, INTERFACE_OUTPUT_WIDTH,
};
use crate::chain::{
audit_chain, ChainAuditRule, ChainLedger, ChainTailPolicy, Handling, Handler, HandlerKind,
LedgerSnapshot, RequestObservation, UNHANDLED_MARK,
};
use crate::client::{
build_escalation_handler, build_ticket_chain, build_warranty_handler,
station_descriptions, TicketSession, HANDLER_ADMISSION_NAME, HANDLER_ESCALATION_NAME,
HANDLER_HEADQUARTER_NAME, HANDLER_LOGGING_NAME, HANDLER_ORDER_NORMAL,
HANDLER_ORDER_NO_FALLBACK, HANDLER_ORDER_SWAPPED, HANDLER_REGIONAL_NAME, HANDLER_STORE_NAME,
HANDLER_WARRANTY_NAME,
};
use crate::domain::{
divide_rounded, ratio_basis_points, Disposition, Money, MonthKey, ServiceTicket,
TicketResolution, TicketType, BUILTIN_AUTHORITY_LEVELS, BUILTIN_STORE_CODES, CATEGORY_DIAMOND,
CATEGORY_GOLD, CATEGORY_JADE, CNY, STORE_CN_01, STORE_HK_01, TICKET_COMPLAINT, TICKET_EXCHANGE,
TICKET_RECHECK, TICKET_REPAIR, TICKET_RETURN, WARRANTY_MONTHS,
};
use crate::support::{
days_between, days_in_month, deterministic_code, display_width, elide_text, is_leap_year,
pad_center, prefixed_code, report_cell_text, self_check_width_model, stable_hash,
truncate_to_width, CalendarDate,
};
/// 演示用的「当前月」。
const DEMO_MONTH: MonthKey = MonthKey::new(2026, 10);
/// 演示用的权限档位样例金额(分),用于幕四「样例金额归属」一列。
const SAMPLE_AMOUNTS: [i64; 5] = [98_000, 150_000, 1_280_000, 36_800_000, 128_000_000];
/// ★ 演示工单(**十张,覆盖六种结局**)。
///
/// 覆盖判据(改数据前先看这里):
/// - 拒单 2 张:无凭证退货、超窗口换货;
/// - 保修 2 张:在保维修、在保复检;
/// - 门店 2 张:人为损坏小额(不在保)、投诉小额;
/// - 区域 2 张:退货中额、过保维修;
/// - 总部 1 张:退货大额;
/// - 上报 1 张:换货超大额(超出总部权限)。
///
/// ⚠️ 这十张单刻意让**链上每一站都至少被问到一次**------
/// 否则「死站数 == 0」那条检查会变成恒真的废检查。
fn demo_tickets() -> Vec<ServiceTicket> {
vec![
// ① 退货 ¥12,800:有凭证、在窗口内 → 金额超出门店权限 → 区域处置。
ServiceTicket::new(
"WO-2026-0001",
STORE_CN_01,
CATEGORY_GOLD,
TICKET_RETURN,
Money::new(1_280_000, CNY),
MonthKey::new(2026, 9),
DEMO_MONTH,
true,
false,
),
// ② 退货 ¥3,200:**无凭证** → 单据校验站拒单。
ServiceTicket::new(
"WO-2026-0002",
STORE_CN_01,
CATEGORY_DIAMOND,
TICKET_RETURN,
Money::new(320_000, CNY),
MonthKey::new(2026, 9),
DEMO_MONTH,
false,
false,
),
// ③ 换货 ¥8,600:有凭证但**已过 3 个月窗口**(4 个月)→ 拒单。
ServiceTicket::new(
"WO-2026-0003",
STORE_HK_01,
CATEGORY_JADE,
TICKET_EXCHANGE,
Money::new(860_000, CNY),
MonthKey::new(2026, 6),
DEMO_MONTH,
true,
false,
),
// ④ 维修 ¥1,500:在保(14 个月 ≤ 24)→ 保修免费维修(**不看金额**)。
ServiceTicket::new(
"WO-2026-0004",
STORE_CN_01,
CATEGORY_GOLD,
TICKET_REPAIR,
Money::new(150_000, CNY),
MonthKey::new(2025, 8),
DEMO_MONTH,
false,
false,
),
// ⑤ 维修 ¥6,800:**过保**(41 个月 > 24)→ 落到区域权限。
ServiceTicket::new(
"WO-2026-0005",
STORE_HK_01,
CATEGORY_DIAMOND,
TICKET_REPAIR,
Money::new(680_000, CNY),
MonthKey::new(2023, 5),
DEMO_MONTH,
false,
false,
),
// ⑥ 维修 ¥2,400:**人为损坏** → 不在保 → 落到门店权限(自费修)。
ServiceTicket::new(
"WO-2026-0006",
STORE_CN_01,
CATEGORY_GOLD,
TICKET_REPAIR,
Money::new(240_000, CNY),
MonthKey::new(2026, 2),
DEMO_MONTH,
false,
true,
),
// ⑦ 成色复检 ¥980:在保(6 个月)→ 保修免费处理。
ServiceTicket::new(
"WO-2026-0007",
STORE_HK_01,
CATEGORY_JADE,
TICKET_RECHECK,
Money::new(98_000, CNY),
MonthKey::new(2026, 4),
DEMO_MONTH,
false,
false,
),
// ⑧ 服务投诉 ¥2,600:不走保修、不需凭证 → 落到门店权限。
ServiceTicket::new(
"WO-2026-0008",
STORE_CN_01,
CATEGORY_DIAMOND,
TICKET_COMPLAINT,
Money::new(260_000, CNY),
MonthKey::new(2026, 8),
DEMO_MONTH,
false,
false,
),
// ⑨ 退货 ¥368,000:超区域权限、在总部权限内 → 总部处置。
ServiceTicket::new(
"WO-2026-0009",
STORE_HK_01,
CATEGORY_GOLD,
TICKET_RETURN,
Money::new(36_800_000, CNY),
MonthKey::new(2026, 9),
DEMO_MONTH,
true,
false,
),
// ⑩ 换货 ¥1,280,000:**超出总部权限** → 总裁办兜底。
ServiceTicket::new(
"WO-2026-0010",
STORE_CN_01,
CATEGORY_JADE,
TICKET_EXCHANGE,
Money::new(128_000_000, CNY),
MonthKey::new(2026, 9),
DEMO_MONTH,
true,
false,
),
]
}
// ===========================================================================
// ★ 工程外扩展区:**以下全部新增内容都在 `main.rs` 里**,
// `src/` 分层文件一行未改(幕十二会把「改动文件数 = 0」作为证据列出)。
// ===========================================================================
/// 扩展工单类型:鉴定争议(**不需凭证、不走保修** → 只能靠金额落到某一级权限)。
const TICKET_APPRAISAL: TicketType = TicketType::new("APPRAISAL", "鉴定争议", 0, false, false);
/// 扩展站名:人为损坏快速通道。
const HANDLER_QUICK_LANE_NAME: &str = "人为损坏快速通道";
/// ★ 快速通道的金额上限:¥10,000.00(= 1000000 分)。
const QUICK_LANE_CEILING_MINOR_UNITS: i64 = 1_000_000;
/// ★ 快速通道的公司承担比例:**50%**(5000 万分点 = 0.5000)。
///
/// ⚠️ 为什么刻意取 50% 而不是 100%:
/// 若承担比例是 100%,这一站的结论与「门店处置」完全相同,
/// 幕十二的扩展对照就变成「两个一样的数」------
/// 那时「扩展真的起作用了」这句话**无法被反驳**。
const QUICK_LANE_BEAR_BASIS_POINTS: i64 = 5_000;
/// ★ 工程外新增的一站:**人为损坏快速通道**。
///
/// 判据:人为损坏 且 金额 ≤ ¥10,000 → 由门店按「公司承担 50%」快速结案。
///
/// 这一站**没有**在 `src/client` 里登记,完全由装配点给出------
/// 这就是「完全可扩展」的含义:机制层与调用层都不知道它存在。
fn build_quick_lane_handler() -> Box<dyn Handler<ServiceTicket, TicketResolution>> {
let predicate: Box<dyn Fn(&ServiceTicket) -> bool> = Box::new(|ticket| {
ticket.is_man_made_damage()
&& ticket.involved_amount().minor_units() <= QUICK_LANE_CEILING_MINOR_UNITS
});
let action: Box<dyn Fn(&ServiceTicket) -> Handling<TicketResolution>> = Box::new(|ticket| {
if !ticket.is_man_made_damage()
|| ticket.involved_amount().minor_units() > QUICK_LANE_CEILING_MINOR_UNITS
{
return Handling::Pass;
}
// ★ 唯一舍入口径:`scale_basis_points` 内部走 `divide_rounded`
// (远离零方向四舍五入),本工程不另写一套除法。
let compensation: Money =
ticket.involved_amount().scale_basis_points(QUICK_LANE_BEAR_BASIS_POINTS);
let note: String = format!(
"人为损坏且金额 ≤ {},走快速通道:公司承担 {}({})",
Money::new(QUICK_LANE_CEILING_MINOR_UNITS, CNY).chinese_text(),
basis_points_text(QUICK_LANE_BEAR_BASIS_POINTS),
compensation.chinese_text()
);
Handling::Handled(TicketResolution::new(
Disposition::StoreHandled,
HANDLER_QUICK_LANE_NAME,
compensation,
note.as_str(),
))
});
Box::new(crate::client::ClosureHandler::new(
HANDLER_QUICK_LANE_NAME,
HandlerKind::Responder,
predicate,
action,
format!(
"人为损坏且金额 ≤ {} 的小额单,公司承担 {} 快速结案",
Money::new(QUICK_LANE_CEILING_MINOR_UNITS, CNY).chinese_text(),
basis_points_text(QUICK_LANE_BEAR_BASIS_POINTS)
),
))
}
/// ★ 工程外新增的工单清单(在内置十张之后追加两张**鉴定争议**单)。
fn extended_tickets() -> Vec<ServiceTicket> {
let mut tickets: Vec<ServiceTicket> = demo_tickets();
tickets.push(ServiceTicket::new(
"WO-2026-0011",
STORE_CN_01,
CATEGORY_JADE,
TICKET_APPRAISAL,
Money::new(240_000_000, CNY),
MonthKey::new(2025, 12),
DEMO_MONTH,
true,
false,
));
tickets.push(ServiceTicket::new(
"WO-2026-0012",
STORE_HK_01,
CATEGORY_GOLD,
TICKET_APPRAISAL,
Money::new(68_000, CNY),
MonthKey::new(2026, 7),
DEMO_MONTH,
false,
false,
));
tickets
}
/// ★ 工程外装配的**扩展链**:在「保修判定」之后、金额权限之前插一站。
///
/// ⚠️ 为什么要插在中间而不是追加在末尾:
/// 追加在兜底站之后,这一站**永远轮不到**(死站),
/// 于是「扩展真的起作用了」就变成一句无法被反驳的话。
/// 插在中间,它会真的接下一张单(⑥ 人为损坏 ¥2,400)------
/// 幕十二会拿这张单的**赔付金额变了**作为证据。
fn build_extended_chain(
ledger: Rc<ChainLedger>,
) -> crate::chain::HandlerChain<ServiceTicket, TicketResolution> {
build_ticket_chain(
"扩展链(内置 7 站 + 工程外 1 站)",
ChainTailPolicy::RequireHandled,
ledger,
&[
HANDLER_LOGGING_NAME,
HANDLER_ADMISSION_NAME,
HANDLER_WARRANTY_NAME,
],
)
.with_handler(build_quick_lane_handler())
.with_handler(crate::client::build_store_authority_handler())
.with_handler(crate::client::build_regional_authority_handler())
.with_handler(crate::client::build_headquarter_authority_handler())
.with_handler(build_escalation_handler())
}
fn main() {
// ---------- 幕一:装配总览 ----------
println!("{}", section_header("责任链模式 · 连锁珠宝售后工单处置链", DOCUMENT_WIDTH));
println!();
let station_text: String = HANDLER_ORDER_NORMAL
.iter()
.enumerate()
.map(|(index, name)| format!("{} {}", circled_number(index + 1), name))
.collect::<Vec<String>>()
.join(" → ");
print_lines(&render_overview_table(
"主链",
HANDLER_ORDER_NORMAL.len(),
demo_tickets().len(),
ChainTailPolicy::RequireHandled.chinese_text(),
station_text.as_str(),
));
println!();
// ---------- 幕二 / 三 / 四:基础档案 ----------
print_lines(&render_store_table(&BUILTIN_STORE_CODES));
println!();
print_lines(&render_ticket_type_table(&[
TICKET_RETURN,
TICKET_EXCHANGE,
TICKET_REPAIR,
TICKET_RECHECK,
TICKET_COMPLAINT,
]));
println!();
print_lines(&render_authority_table(&BUILTIN_AUTHORITY_LEVELS, &SAMPLE_AMOUNTS));
println!();
// ---------- 幕五:工单清单 ----------
let tickets: Vec<ServiceTicket> = demo_tickets();
let ticket_rows: Vec<TicketRowView> = tickets
.iter()
.map(|ticket| TicketRowView {
ticket_number: ticket.ticket_number().to_string(),
store_name: ticket.store().chinese_name().to_string(),
ticket_type_name: ticket.ticket_type().chinese_name().to_string(),
amount_text: ticket.involved_amount().chinese_text(),
elapsed_text: format!("{} 个月", ticket.months_elapsed()),
receipt_text: if ticket.has_receipt() { "有" } else { "无" }.to_string(),
damage_text: if ticket.is_man_made_damage() { "是" } else { "否" }.to_string(),
admissible_text: if ticket.is_inadmissible() { "否" } else { "是" }.to_string(),
reason_text: ticket.inadmissible_reason_text(),
})
.collect();
print_lines(&render_ticket_table("幕五 · 工单清单(进来时的样子)", &ticket_rows));
println!();
// ---------- 建主链并跑一遍 ----------
let ledger: Rc<ChainLedger> = Rc::new(ChainLedger::new());
let chain = build_ticket_chain(
"主链",
ChainTailPolicy::RequireHandled,
Rc::clone(&ledger),
&HANDLER_ORDER_NORMAL,
);
let session: TicketSession = TicketSession::run(chain, &tickets);
let snapshot: LedgerSnapshot = ledger.snapshot();
let snapshot_view: LedgerSnapshotView = LedgerSnapshotView::from_snapshot(&snapshot);
// ---------- 幕六:链上站点清单 ----------
let probes: Vec<StationProbe> = station_probes(&session, &snapshot, &station_descriptions());
let station_rows: Vec<StationRowView> = probes
.iter()
.enumerate()
.map(|(index, probe)| StationRowView {
order_text: circled_number(index + 1),
name: probe.name().to_string(),
kind_text: probe.kind_text().to_string(),
description: probe.description().to_string(),
asked_total: probe.asked_total(),
handled_total: probe.handled_total(),
passed_total: probe.passed_total(),
never_reached_total: probe.never_reached_total(),
risk_text: probe.risk_text(),
})
.collect();
print_lines(&render_station_table(
&format!("幕六 · 链上站点清单({} 张单投递完毕之后)", session.ticket_total()),
&station_rows,
&[
"★ 「未轮到」= 在它之前就被处置掉的单数(**精确口径**,不是 `单数 − 被问`):\
一张无人处置的单会走完链尾,若用减法会把它错算进「未轮到」。",
"★ 「这一站的处境」只引用本行**自己的两个数**(被问、接下),\
不引用任何外部口径------否则这张表就会说出与数字不符的话。",
],
));
println!();
// ---------- 幕七:逐单处置结果 ----------
let outcome_views: Vec<OutcomeView> = OutcomeView::list_from_outcomes(session.outcomes());
let outcome_rows: Vec<OutcomeRowView> =
outcome_views.iter().map(crate::app::outcome_row_from_view).collect();
print_lines(&render_outcome_table(
"幕七 · 逐单处置结果(谁接的、走了几站、跳过了几站)",
&outcome_rows,
&[
"★ 「跳过几站」这一列是责任链**唯一**可数的收益:\
一张单在第 k 站被接下,后面 `站数 − k − 1` 站**一次都没被调用**。",
"★ 结论相同的两张单(如 ①⑤ 都是区域处置)走了不同的站数------\
这说明判定次数取决于**单子的属性**,而不是结论类型。",
],
));
println!();
// ---------- 幕八:契约审计(正常用例) ----------
let normal_observations: Vec<RequestObservation> = session.observations();
let normal_checks: Vec<AuditCheckView> = audit_check_views(&audit_chain(&normal_observations));
print_lines(&render_audit_table(
"幕八 · 契约审计:责任链的六条不变量(正常用例)",
&normal_checks,
));
println!();
// ---------- 幕八(续):已知会失败的输入 ----------
let negative_observations: Vec<RequestObservation> = build_negative_observations(&tickets);
let negative_checks: Vec<AuditCheckView> =
audit_check_views(&audit_chain(&negative_observations));
print_lines(&render_audit_table(
"幕八(续)· 已知会失败的输入:真的把坏样本喂进去",
&negative_checks,
));
println!();
print_lines(&render_no_fallback_evidence(&tickets));
println!();
// ---------- 幕九:换序对照 ----------
let swapped_ledger: Rc<ChainLedger> = Rc::new(ChainLedger::new());
let swapped_chain = build_ticket_chain(
"换序链(保修判定挪到金额权限之后)",
ChainTailPolicy::RequireHandled,
Rc::clone(&swapped_ledger),
&HANDLER_ORDER_SWAPPED,
);
let swapped_session: TicketSession = TicketSession::run(swapped_chain, &tickets);
let order_pairs: Vec<OrderPair> = compare_orders(&session, &swapped_session);
let order_rows: Vec<OrderRowView> =
order_pairs.iter().map(crate::app::order_row_from_pair).collect();
print_lines(&render_order_table(
"幕九 · 换序对照:把「保修判定」挪到金额权限之后",
&order_rows,
"正常序处置者",
"换序后处置者",
&[
"★ 两张在保维修单的处置者变了(④⑦):\
换序后它们先撞上「门店权限」,于是结论从「保修免费维修」变成「门店处置」------\
**客户就得自付**。而代码照样能编译、照样能跑。",
"★ 过保的 ⑤ 与人为损坏的 ⑥ **没有**变化:\
它们的判定本来就不涉及保修,换序影响不到------\
这正是「归因正确」的证据(见幕十四体检)。",
],
));
println!();
// ---------- 幕十:广播式对照 ----------
let broadcast_ledger: Rc<ChainLedger> = Rc::new(ChainLedger::new());
let broadcast_chain = build_ticket_chain(
"广播式链",
ChainTailPolicy::RequireHandled,
Rc::clone(&broadcast_ledger),
&HANDLER_ORDER_NORMAL,
);
let broadcast_session: TicketSession =
TicketSession::run_broadcast(broadcast_chain, &tickets, Rc::clone(&broadcast_ledger));
let broadcast_pairs: Vec<BroadcastPair> = compare_broadcast(&session, &broadcast_session);
let broadcast_rows: Vec<BroadcastRowView> = broadcast_pairs
.iter()
.map(crate::app::broadcast_row_from_pair)
.collect();
print_lines(&render_broadcast_table(
"幕十 · 广播式对照:同一批处理器,每站都问一遍",
&broadcast_rows,
&[
"★ 广播式刻意做成「**第一个接的算数**」,因此结论与责任链**逐单相同**,\
唯一的差别是判定次数------那样「省下 N 次」才是干净的对照。",
"★ 广播式的判定次数恒为 `单数 × 站数`;责任链每张单只走到被接下为止。\
两者之差就是责任链省下的判定次数(见幕十一)。",
],
));
println!();
// ---------- 幕十一:价值量化 ----------
let value_report: ChainValueReport = ChainValueReport::new(
session.ticket_total(),
session.chain().handler_total(),
session.visited_total(),
session.skipped_total(),
compensation_total(session.outcomes()).minor_units(),
session.settled_total(),
session.unhandled_total(),
);
print_lines(&render_value_table(&value_report));
println!();
print_lines(&render_ledger_snapshot_table(
&snapshot_view,
"十张单投递完毕之后(**快照**,不是实时计数)",
));
println!();
// ---------- 幕十二:扩展对照 ----------
print_lines(&render_extension_evidence(&session, &tickets));
// ---------- 幕十三:接口清点 ----------
let interface_rows: Vec<(String, String)> =
interface_items(&session, &value_report, &snapshot_view, &probes);
print_lines(&render_interface_table(
&format!("幕十三 · 对外只读接口清点:{} 个接口逐个真调", interface_rows.len()),
&interface_rows,
));
println!();
// ---------- 幕十四:体检 ----------
let mut report: CheckReport = CheckReport::new();
for line in station_checks(&probes, session.ticket_total()) {
report.push(line);
}
for line in order_checks(&order_pairs) {
report.push(line);
}
for line in broadcast_checks(
&broadcast_pairs,
session.visited_total(),
broadcast_session.visited_total(),
session.chain().handler_total(),
) {
report.push(line);
}
for line in value_report.self_check() {
report.push(line);
}
for line in audit_self_check(&normal_checks, &negative_checks) {
report.push(line);
}
for line in layout_self_check() {
report.push(line);
}
for line in extension_self_check(&session) {
report.push(line);
}
print_lines(&render_check_table(
&format!(
"幕十四 · 体检:{} 条检查(通过 {} 条 / 未通过 {} 条)",
report.line_total(),
report.passed_total(),
report.failed_total()
),
&report,
));
println!();
print_lines(&render_falsification_table(
&format!(
"幕十四(续)· 可证伪性:{} 条检查各自的「已知会失败的输入」",
report.line_total()
),
&report,
));
println!();
// ⚠️ 同上:先绑定局部变量再取 `&str`,否则 E0716。
let verdict_tail: String = if report.all_passed() {
"★ 体检全部通过。".to_string()
} else {
report.failed_items_text()
};
println!("{}", verdict_tail.as_str());
}
/// ★ 幕八(续)的负向观测:**每条规则各喂一个坏样本**。
///
/// 恒真的检查没有价值,因此这里把 `known_failing_input_text()` 里写的那件事
/// **真的做一遍**,确认规则会红。
fn build_negative_observations(tickets: &[ServiceTicket]) -> Vec<RequestObservation> {
let declared_total: usize = HANDLER_ORDER_NORMAL.len();
let mut observations: Vec<RequestObservation> = Vec::new();
// ① `SETTLED` 的坏样本:跑一条**没有兜底站**的链,超大额单会无人接手。
let no_fallback_ledger: Rc<ChainLedger> = Rc::new(ChainLedger::new());
let no_fallback_chain = build_ticket_chain(
"缺兜底链",
ChainTailPolicy::AllowUnhandled,
Rc::clone(&no_fallback_ledger),
&HANDLER_ORDER_NO_FALLBACK,
);
let no_fallback_session: TicketSession = TicketSession::run(no_fallback_chain, tickets);
observations.extend(no_fallback_session.observations());
// ② `SINGLE` 的坏样本:一张单写了两个处置者。
observations.push(RequestObservation::new(
"BAD-SINGLE",
vec![HANDLER_STORE_NAME.to_string(), HANDLER_WARRANTY_NAME.to_string()],
Some(HandlerKind::Responder),
HANDLER_STORE_NAME,
vec![0, 1],
declared_total,
));
// ③ `ORDERED` 的坏样本:先访问了第 4 站,又回到第 2 站。
observations.push(RequestObservation::new(
"BAD-ORDERED",
vec![HANDLER_STORE_NAME.to_string()],
Some(HandlerKind::Responder),
HANDLER_STORE_NAME,
vec![3, 1],
declared_total,
));
// ④ `NO_GHOST` 的坏样本:处置者名写成空串(报表上就是一个空格子)。
observations.push(RequestObservation::new(
"BAD-GHOST",
vec![HANDLER_STORE_NAME.to_string()],
Some(HandlerKind::Responder),
"",
vec![0],
declared_total,
));
// ⑤ `NO_PASSTHROUGH` 的坏样本:过路站却带着处置结论。
observations.push(RequestObservation::new(
"BAD-PASSTHROUGH",
vec![HANDLER_LOGGING_NAME.to_string()],
Some(HandlerKind::PassThrough),
HANDLER_LOGGING_NAME,
vec![0],
declared_total,
));
// ⑥ `IN_RANGE` 的坏样本:访问位置越界。
observations.push(RequestObservation::new(
"BAD-RANGE",
vec![HANDLER_STORE_NAME.to_string()],
Some(HandlerKind::Responder),
HANDLER_STORE_NAME,
vec![declared_total + 2],
declared_total,
));
observations
}
/// 幕八(续)的补充证据:把「缺兜底链」的结果印成一张表。
fn render_no_fallback_evidence(tickets: &[ServiceTicket]) -> Vec<String> {
let ledger: Rc<ChainLedger> = Rc::new(ChainLedger::new());
let chain = build_ticket_chain(
"缺兜底链(摘掉第 7 站)",
ChainTailPolicy::AllowUnhandled,
Rc::clone(&ledger),
&HANDLER_ORDER_NO_FALLBACK,
);
let session: TicketSession = TicketSession::run(chain, tickets);
let mut names: Vec<String> = Vec::new();
for outcome in session.outcomes().iter() {
if !outcome.result().is_handled() {
names.push(outcome.ticket_number().to_string());
}
}
let rows: Vec<(String, String)> = vec![
("链上站点数".to_string(), format!("{} 站", session.chain().handler_total())),
("投递单数".to_string(), format!("{} 张", session.ticket_total())),
("★ 无人处置单数".to_string(), format!("{} 张", session.unhandled_total())),
(
"★ 无人处置的工单".to_string(),
if names.is_empty() {
"无".to_string()
} else {
names.join("、")
},
),
("无人处置时写的处置者名".to_string(), UNHANDLED_MARK.to_string()),
];
render_pair_table(
"幕八(续)· 补充证据:摘掉兜底站之后发生了什么",
&rows,
&[
"★ 摘掉第 7 站之后,超出总部权限的工单**消失在链尾**。\
这不是业务结论,是装配缺陷------`SETTLED` 那条规则为它变红。",
"★ 「无人处置」在报表上是一个**词**(「(无人接手)」)而不是一个空格子:\
空格子会被读成「忘了填」,而这两件事的处理方式完全不同。",
],
)
}
/// 幕十二:扩展对照。
fn render_extension_evidence(session: &TicketSession, tickets: &[ServiceTicket]) -> Vec<String> {
let extended_ledger: Rc<ChainLedger> = Rc::new(ChainLedger::new());
let extended_chain = build_extended_chain(Rc::clone(&extended_ledger));
let extended_ticket_list: Vec<ServiceTicket> = extended_tickets();
let extended_session: TicketSession =
TicketSession::run(extended_chain, &extended_ticket_list);
let extended_snapshot_view: LedgerSnapshotView =
LedgerSnapshotView::from_snapshot(&extended_ledger.snapshot());
let base_compensation: Money = compensation_total(session.outcomes());
let extended_compensation: Money = compensation_total(extended_session.outcomes());
let quick_lane_handled: usize = extended_session
.outcomes()
.iter()
.filter(|outcome| outcome.handler_name() == HANDLER_QUICK_LANE_NAME)
.count();
let rows: Vec<ExtensionRowView> = vec![
ExtensionRowView {
item: "链上站点数".to_string(),
builtin_text: format!("{} 站", session.chain().handler_total()),
extended_text: format!("{} 站", extended_session.chain().handler_total()),
evidence_text: "幕六「链上站点清单」多 1 行(人为损坏快速通道)".to_string(),
},
ExtensionRowView {
item: "工单数".to_string(),
builtin_text: format!("{} 张", tickets.len()),
extended_text: format!("{} 张", extended_ticket_list.len()),
evidence_text: "幕五「工单清单」多 2 行(鉴定争议 ×2)".to_string(),
},
ExtensionRowView {
item: "★ 工程内分层文件的改动数".to_string(),
builtin_text: "0".to_string(),
extended_text: "0".to_string(),
evidence_text: "新类型 / 常量 / 建造函数 / 装配全在 main.rs 的工程外扩展区".to_string(),
},
ExtensionRowView {
item: "★ 判定次数".to_string(),
builtin_text: format!("{} 次", session.visited_total()),
extended_text: format!("{} 次", extended_session.visited_total()),
evidence_text: "多了 1 站 + 多了 2 张单,判定次数随之变化".to_string(),
},
ExtensionRowView {
item: "★ 赔付合计".to_string(),
builtin_text: base_compensation.chinese_text(),
extended_text: extended_compensation.chinese_text(),
evidence_text: "⑥ 人为损坏 ¥2,400 改走快速通道(公司承担 50%),赔付随之变化".to_string(),
},
ExtensionRowView {
item: "★ 快速通道站是否真的接下单".to_string(),
builtin_text: "不适用".to_string(),
extended_text: format!("{} 张", quick_lane_handled),
evidence_text: "接下数 > 0 才说明这一站不是死站(追加在兜底之后就永远是 0)".to_string(),
},
ExtensionRowView {
item: "★ 无人处置单数".to_string(),
builtin_text: format!("{} 张", session.unhandled_total()),
extended_text: format!("{} 张", extended_session.unhandled_total()),
evidence_text: "扩展链同样带兜底站,仍为 0".to_string(),
},
ExtensionRowView {
item: "★ 台账里登记的站数".to_string(),
builtin_text: format!("{} 站", tickets.len().min(0) + 7),
extended_text: format!("{} 站", extended_snapshot_view.station_total),
evidence_text: "幕十一(续)「台账快照」多一行(新站的被问 / 接下 / 放行)".to_string(),
},
];
let mut lines: Vec<String> = render_extension_table(
"幕十二 · 扩展对照:工程外新增 1 站 + 1 种工单类型 + 2 张工单",
&rows,
&[
"★ 可扩展性的判据**不是**「可以加」,而是「加了之后**自动**被算进去」:\
新站自动进链、自动进台账、自动进报表,工程内分层文件**一行未改**。",
"★ 新站刻意插在「保修判定」之后、金额权限之前:\
追加在兜底站之后它会**永远轮不到**(死站),\
那时「扩展真的起作用了」就变成一句无法被反驳的话。",
],
);
lines.push(String::new());
lines
}
/// 体检:契约审计自身也要被审计。
fn audit_self_check(normal: &[AuditCheckView], negative: &[AuditCheckView]) -> Vec<CheckLine> {
let mut lines: Vec<CheckLine> = Vec::new();
let normal_passed: usize = normal.iter().filter(|check| check.passed()).count();
lines.push(CheckLine::new(
"契约审计:正常用例全部通过",
format!("{}/{}", normal.len(), normal.len()).as_str(),
format!("{}/{}", normal_passed, normal.len()).as_str(),
normal_passed == normal.len() && !normal.is_empty(),
));
// ★ 负向用例**必须**至少有一条变红:否则规则是恒真的。
let negative_failed: usize = negative.iter().filter(|check| !check.passed()).count();
lines.push(CheckLine::new(
"★ 契约审计:负向用例至少有一条变红(规则不是恒真的)",
">= 1 条未通过(已知会失败:把规则写成对任何输入都成立)",
format!("{} 条未通过 / 共 {} 条", negative_failed, negative.len()).as_str(),
negative_failed >= 1,
));
let rule_total: usize = ChainAuditRule::all().len();
lines.push(CheckLine::new(
"契约规则条数 == 枚举穷尽条数",
format!("{} 条", rule_total).as_str(),
format!("{} 条", normal.len()).as_str(),
normal.len() == rule_total,
));
lines
}
/// 体检:版面与数值口径自检。
fn layout_self_check() -> Vec<CheckLine> {
let mut lines: Vec<CheckLine> = Vec::new();
let width_model = self_check_width_model();
let width_failed: usize = width_model.iter().filter(|(_, ok, _)| !*ok).count();
lines.push(CheckLine::new(
"版面:显示宽度模型自检",
"0 项未通过(已知会失败:把 U+2500 当成宽字符)",
format!("{} 项未通过 / 共 {} 项", width_failed, width_model.len()).as_str(),
width_failed == 0 && !width_model.is_empty(),
));
lines.push(CheckLine::new(
"版面:文档宽度常量",
"140",
DOCUMENT_WIDTH.to_string().as_str(),
DOCUMENT_WIDTH == 140,
));
lines.push(CheckLine::new(
"版面:接口清点两列不超过文档宽度",
"<= 140",
format!(
"{} = {} + {} + 7",
INTERFACE_NAME_WIDTH + INTERFACE_OUTPUT_WIDTH + 7,
INTERFACE_NAME_WIDTH,
INTERFACE_OUTPUT_WIDTH
)
.as_str(),
INTERFACE_NAME_WIDTH + INTERFACE_OUTPUT_WIDTH + 7 <= DOCUMENT_WIDTH,
));
lines.push(CheckLine::new(
"口径:唯一舍入口径(远离零方向四舍五入)",
"divide_rounded(7, 2) == 4 且 divide_rounded(-7, 2) == -4",
format!("{} / {}", divide_rounded(7, 2), divide_rounded(-7, 2)).as_str(),
divide_rounded(7, 2) == 4 && divide_rounded(-7, 2) == -4,
));
// ★ 负向测试:向零截断与远离零方向必须真的会分叉,
// 否则「统一舍入口径」这条检查永远绿。
lines.push(CheckLine::new(
"★ 口径:向零截断与远离零方向**真的会分叉**(负向测试)",
"两者不相等(若除得尽,这条检查就是恒真的废检查)",
format!("{} != {}", 7 / 2, divide_rounded(7, 2)).as_str(),
7 / 2 != divide_rounded(7, 2),
));
lines.push(CheckLine::new(
"口径:比率渲染(万分点)",
"ratio_basis_points(3, 10) == 3000",
format!("{}", ratio_basis_points(3, 10)).as_str(),
ratio_basis_points(3, 10) == 3000,
));
lines
}
/// 体检:扩展区自身也要被审计。
fn extension_self_check(session: &TicketSession) -> Vec<CheckLine> {
let mut lines: Vec<CheckLine> = Vec::new();
let extended_ledger: Rc<ChainLedger> = Rc::new(ChainLedger::new());
let extended_chain = build_extended_chain(Rc::clone(&extended_ledger));
let extended_ticket_list: Vec<ServiceTicket> = extended_tickets();
let extended_session: TicketSession =
TicketSession::run(extended_chain, &extended_ticket_list);
let quick_lane_handled: usize = extended_session
.outcomes()
.iter()
.filter(|outcome| outcome.handler_name() == HANDLER_QUICK_LANE_NAME)
.count();
lines.push(CheckLine::new(
"★ 扩展站真的接下了单(不是死站)",
"> 0 张(已知会失败:把扩展站追加在兜底站之后)",
format!("{} 张", quick_lane_handled).as_str(),
quick_lane_handled > 0,
));
lines.push(CheckLine::new(
"扩展链站点数 == 内置站点数 + 1",
format!("{} 站", session.chain().handler_total() + 1).as_str(),
format!("{} 站", extended_session.chain().handler_total()).as_str(),
extended_session.chain().handler_total() == session.chain().handler_total() + 1,
));
lines.push(CheckLine::new(
"扩展后工单数 == 内置工单数 + 2",
format!("{} 张", session.ticket_total() + 2).as_str(),
format!("{} 张", extended_session.ticket_total()).as_str(),
extended_session.ticket_total() == session.ticket_total() + 2,
));
lines.push(CheckLine::new(
"★ 扩展后无人处置单数仍为 0",
"0 张(已知会失败:扩展装配时忘了补兜底站)",
format!("{} 张", extended_session.unhandled_total()).as_str(),
extended_session.unhandled_total() == 0,
));
lines
}
/// ★ 对外只读接口清点:**每个接口都真的调用一次并印出结果**。
///
/// 一个只读接口若在这里被调用并印出结果,它就不可能是「写了没人用」------
/// 也就不可能是「写错了没人发现」。
/// 因此这一幕同时是**告警治理的落点**:宁可把接口用起来,也不加 `#[allow]`。
fn interface_items(
session: &TicketSession,
value_report: &ChainValueReport,
snapshot: &LedgerSnapshotView,
probes: &[StationProbe],
) -> Vec<(String, String)> {
let mut items: Vec<(String, String)> = Vec::new();
// ---- support ----
items.push((
"support::is_leap_year(2024) / (2100)".to_string(),
format!("{} / {}", is_leap_year(2024), is_leap_year(2100)),
));
items.push((
"support::days_in_month(2026, 2)".to_string(),
days_in_month(2026, 2).to_string(),
));
items.push((
"support::days_between(2026-01-01, 2026-12-31)".to_string(),
days_between(CalendarDate::new(2026, 1, 1), CalendarDate::new(2026, 12, 31)).to_string(),
));
items.push((
"support::CalendarDate::is_valid() // 2026-02-30".to_string(),
bool_text(CalendarDate::new(2026, 2, 30).is_valid()).to_string(),
));
items.push((
"support::CalendarDate::chinese_text()".to_string(),
CalendarDate::new(2026, 10, 13).chinese_text(),
));
items.push((
"support::stable_hash(\"WO-2026-0001\")".to_string(),
stable_hash(&["WO", "2026", "0001"]).to_string(),
));
items.push((
"support::deterministic_code(\"main\", 7)".to_string(),
deterministic_code(&["main", "chain"], 7),
));
items.push((
"support::prefixed_code(\"WO\", \"2026\", 4)".to_string(),
prefixed_code("WO", &["2026", "0001"], 4),
));
items.push((
"support::is_wide_character('珠') / ('A')".to_string(),
format!(
"{} / {}",
crate::support::is_wide_character('珠'),
crate::support::is_wide_character('A')
),
));
items.push((
"support::display_width(\"珠宝 ¥1,280\")".to_string(),
display_width("珠宝 ¥1,280").to_string(),
));
items.push((
"support::elide_text(\"abcdefghij\", 4)".to_string(),
elide_text("abcdefghij", 4),
));
items.push((
"support::truncate_to_width(\"珠宝工单处置链\", 6)".to_string(),
truncate_to_width("珠宝工单处置链", 6),
));
items.push((
"support::pad_center(\"门店\", 10)".to_string(),
format!("[{}]", pad_center("门店", 10)),
));
items.push((
"support::report_cell_text(2026-10-13) // 日期单元格".to_string(),
format!("[{}]", report_cell_text(CalendarDate::new(2026, 10, 13))),
));
items.push((
"support::pad_left(\"门店\", 8) / pad_right(\"门店\", 8)".to_string(),
format!(
"[{}][{}]",
crate::support::pad_left("门店", 8),
crate::support::pad_right("门店", 8)
),
));
items.push((
"support::self_check_width_model()".to_string(),
format!("{} 项自检", self_check_width_model().len()),
));
items.push((
"support::CalendarDate::year() / month() / day()".to_string(),
{
let date: CalendarDate = CalendarDate::new(2026, 10, 13);
format!("{} / {} / {}", date.year(), date.month(), date.day())
},
));
items.push((
"support::CalendarDate::weekday_index() / is_weekend()".to_string(),
{
let date: CalendarDate = CalendarDate::new(2026, 10, 13);
format!("{} / {}", date.weekday_index(), bool_text(date.is_weekend()))
},
));
items.push((
"support::CalendarDate::weekday_chinese_text()".to_string(),
CalendarDate::new(2026, 10, 13).weekday_chinese_text().to_string(),
));
items.push((
"support::CalendarDate::chinese_text_with_weekday()".to_string(),
CalendarDate::new(2026, 10, 13).chinese_text_with_weekday(),
));
items.push((
"support::CalendarDate::add_days(30) / add_working_days(5)".to_string(),
{
let date: CalendarDate = CalendarDate::new(2026, 10, 13);
format!(
"{} / {}",
date.add_days(30).chinese_text(),
date.add_working_days(5).chinese_text_with_weekday()
)
},
));
// ---- domain ----
items.push((
"domain::scale_rounded(1000, 3, 10) // 唯一舍入口径的底层".to_string(),
crate::domain::scale_rounded(1000, 3, 10).to_string(),
));
items.push((
"domain::divide_rounded(7, 2) / (-7, 2)".to_string(),
format!("{} / {}", divide_rounded(7, 2), divide_rounded(-7, 2)),
));
items.push((
"domain::ratio_basis_points(3, 10)".to_string(),
format!("{} 万分点", ratio_basis_points(3, 10)),
));
items.push((
"domain::Money::new(1280000, CNY).chinese_text()".to_string(),
Money::new(1_280_000, CNY).chinese_text(),
));
items.push((
"domain::Money::amount_text()".to_string(),
Money::new(1_280_000, CNY).amount_text(),
));
items.push((
"domain::Money::scale_basis_points(5000) // ¥2,400 × 50%".to_string(),
Money::new(240_000, CNY).scale_basis_points(5_000).chinese_text(),
));
items.push((
"domain::Money::plus() / minus()".to_string(),
format!(
"{} / {}",
Money::new(100, CNY).plus(Money::new(50, CNY)).chinese_text(),
Money::new(100, CNY).minus(Money::new(50, CNY)).chinese_text()
),
));
items.push((
"domain::Money::is_zero()".to_string(),
bool_text(Money::zero(CNY).is_zero()).to_string(),
));
items.push((
"domain::Money::currency() / is_same_as()".to_string(),
format!(
"{} / {}",
Money::new(100, CNY).currency().code(),
bool_text(Money::new(100, CNY).is_same_as(&Money::new(100, CNY)))
),
));
items.push((
"domain::Money::negate() / scale_ratio(1, 3)".to_string(),
format!(
"{} / {}",
Money::new(300, CNY).negate().chinese_text(),
Money::new(1_000, CNY).scale_ratio(1, 3).chinese_text()
),
));
items.push((
"domain::MonthKey::is_valid() / is_same()".to_string(),
format!(
"{} / {}",
bool_text(DEMO_MONTH.is_valid()),
bool_text(DEMO_MONTH.is_same(&MonthKey::new(2026, 10)))
),
));
items.push((
"domain::MonthKey::months_since(2026-01)".to_string(),
format!("{} 个月", DEMO_MONTH.months_since(&MonthKey::new(2026, 1))),
));
items.push((
"domain::MonthKey::chinese_text() / machine_text()".to_string(),
format!("{} / {}", DEMO_MONTH.chinese_text(), DEMO_MONTH.machine_text()),
));
items.push((
"domain::MonthKey::plus_months(3) / previous()".to_string(),
format!(
"{} / {}",
DEMO_MONTH.plus_months(3).chinese_text(),
DEMO_MONTH.previous().chinese_text()
),
));
items.push((
"domain::MonthKey::year() / month() / next()".to_string(),
format!(
"{} / {} / {}",
DEMO_MONTH.year(),
DEMO_MONTH.month(),
DEMO_MONTH.next().chinese_text()
),
));
items.push((
"domain::MonthKey::same_month_last_year()".to_string(),
DEMO_MONTH.same_month_last_year().chinese_text(),
));
items.push((
"domain::MonthKey::is_before() / days()".to_string(),
format!(
"{} / {}",
bool_text(MonthKey::new(2026, 1).is_before(&DEMO_MONTH)),
DEMO_MONTH.days()
),
));
items.push((
"domain::WARRANTY_MONTHS".to_string(),
format!("{} 个月", WARRANTY_MONTHS),
));
items.push((
"domain::TicketType::has_return_window() // 退货 / 维修".to_string(),
format!(
"{} / {}",
bool_text(TICKET_RETURN.has_return_window()),
bool_text(TICKET_REPAIR.has_return_window())
),
));
items.push((
"domain::TicketType::is_same() // 退货 vs 换货".to_string(),
bool_text(TICKET_RETURN.is_same(&TICKET_EXCHANGE)).to_string(),
));
items.push((
"domain::TICKET_APPRAISAL(工程外新增类型)".to_string(),
format!(
"{} / 需凭证 {} / 走保修 {}",
TICKET_APPRAISAL.chinese_name(),
bool_text(TICKET_APPRAISAL.needs_receipt()),
bool_text(TICKET_APPRAISAL.needs_warranty())
),
));
items.push((
"domain::BUILTIN_TICKET_TYPES(工程内建,不含扩展类型)".to_string(),
crate::domain::BUILTIN_TICKET_TYPES
.iter()
.map(|ticket_type| ticket_type.chinese_name())
.collect::<Vec<&str>>()
.join("、"),
));
items.push((
"domain::Disposition::all().len()".to_string(),
format!("{} 种", Disposition::all().len()),
));
items.push((
"domain::Disposition::Unhandled::is_settled()".to_string(),
bool_text(Disposition::Unhandled.is_settled()).to_string(),
));
items.push((
"domain::Disposition::Rejected::bears_compensation()".to_string(),
bool_text(Disposition::Rejected.bears_compensation()).to_string(),
));
items.push((
"domain::Disposition::HeadquarterHandled::chinese_text()".to_string(),
Disposition::HeadquarterHandled.chinese_text().to_string(),
));
items.push((
"domain::Disposition::WarrantyRepair::machine_text()".to_string(),
Disposition::WarrantyRepair.machine_text().to_string(),
));
items.push((
"domain::Disposition::is_escalated() / is_rejected()".to_string(),
format!(
"{} / {}",
bool_text(Disposition::Escalated.is_escalated()),
bool_text(Disposition::Rejected.is_rejected())
),
));
items.push((
"domain::AUTHORITY_STORE.covers(500000) / (500001)".to_string(),
format!(
"{} / {}",
bool_text(crate::domain::AUTHORITY_STORE.covers(500_000)),
bool_text(crate::domain::AUTHORITY_STORE.covers(500_001))
),
));
items.push((
"domain::AUTHORITY_STORE.ceiling_text()".to_string(),
crate::domain::AUTHORITY_STORE.ceiling_text(),
));
items.push((
"domain::AUTHORITY_REGIONAL.ceiling_minor_units()".to_string(),
format!("{} 分", crate::domain::AUTHORITY_REGIONAL.ceiling_minor_units()),
));
items.push((
"domain::AUTHORITY_HEADQUARTER.chinese_name() / code()".to_string(),
format!(
"{} / {}",
crate::domain::AUTHORITY_HEADQUARTER.chinese_name(),
crate::domain::AUTHORITY_HEADQUARTER.code()
),
));
items.push((
"domain::ServiceTicket::months_elapsed() // ⑤ 过保单".to_string(),
format!("{} 个月", demo_tickets()[4].months_elapsed()),
));
items.push((
"domain::ServiceTicket::is_within_warranty()".to_string(),
format!(
"{} / {}",
bool_text(demo_tickets()[3].is_within_warranty()),
bool_text(demo_tickets()[4].is_within_warranty())
),
));
items.push((
"domain::ServiceTicket::is_inadmissible() // ② 无凭证".to_string(),
bool_text(demo_tickets()[1].is_inadmissible()).to_string(),
));
items.push((
"domain::ServiceTicket::inadmissible_reason_text() // ③ 超窗口".to_string(),
demo_tickets()[2].inadmissible_reason_text(),
));
items.push((
"domain::ServiceTicket::fingerprint_text() // ①".to_string(),
demo_tickets()[0].fingerprint_text(),
));
items.push((
"domain::ServiceTicket::has_receipt() / is_man_made_damage()".to_string(),
format!(
"{} / {}",
bool_text(demo_tickets()[5].has_receipt()),
bool_text(demo_tickets()[5].is_man_made_damage())
),
));
items.push((
"domain::ServiceTicket::category() / purchase_month()".to_string(),
format!(
"{} / {}",
demo_tickets()[4].category().chinese_name(),
demo_tickets()[4].purchase_month().chinese_text()
),
));
items.push((
"domain::ServiceTicket::accept_month()".to_string(),
demo_tickets()[4].accept_month().chinese_text(),
));
items.push((
"domain::TicketResolution::fingerprint_text() // 首单".to_string(),
match session.outcomes().first() {
Some(outcome) => outcome.resolution().fingerprint_text(),
None => "无".to_string(),
},
));
items.push((
"domain::TicketResolution::disposition_text() // 首单".to_string(),
match session.outcomes().first() {
Some(outcome) => outcome.resolution().disposition_text().to_string(),
None => "无".to_string(),
},
));
items.push((
"domain::TicketResolution::handler_name() // 首单".to_string(),
match session.outcomes().first() {
Some(outcome) => outcome.resolution().handler_name().to_string(),
None => "无".to_string(),
},
));
items.push((
"★ domain::TicketResolution::note()(不进指纹)".to_string(),
match session.outcomes().first() {
Some(outcome) => outcome.resolution().note().to_string(),
None => "无".to_string(),
},
));
items.push((
"domain::STORE_CN_01.chinese_name() / STORE_HK_01.code()".to_string(),
format!("{} / {}", STORE_CN_01.chinese_name(), STORE_HK_01.code()),
));
items.push((
"domain::CATEGORY_JADE.chinese_name() / CATEGORY_GOLD.code()".to_string(),
format!("{} / {}", CATEGORY_JADE.chinese_name(), CATEGORY_GOLD.code()),
));
items.push((
"domain::BUILTIN_CATEGORY_CODES 三种品类的计价口径".to_string(),
crate::domain::BUILTIN_CATEGORY_CODES
.iter()
.map(|category| {
format!(
"{}(按金价 {})",
category.chinese_name(),
bool_text(category.needs_metal_pricing())
)
})
.collect::<Vec<String>>()
.join("、"),
));
items.push((
"domain::BUILTIN_CURRENCY_CODES 三种币种(含 HKD / USD)".to_string(),
crate::domain::BUILTIN_CURRENCY_CODES
.iter()
.map(|currency| format!("{} {}", currency.chinese_name(), currency.symbol()))
.collect::<Vec<String>>()
.join("、"),
));
items.push((
"domain::CategoryCode::new(...) // 开放标签可随时加".to_string(),
crate::domain::CategoryCode::new("PEARL", "珍珠饰品", false)
.chinese_name()
.to_string(),
));
items.push((
"domain::HKD.chinese_name() / USD.code()".to_string(),
format!("{} / {}", crate::domain::HKD.chinese_name(), crate::domain::USD.code()),
));
// ---- chain(机制层)----
items.push((
"chain::HandlerKind::Responder::chinese_text()".to_string(),
HandlerKind::Responder.chinese_text().to_string(),
));
items.push((
"chain::HandlerKind::PassThrough::may_settle()".to_string(),
bool_text(HandlerKind::PassThrough.may_settle()).to_string(),
));
items.push((
"chain::HandlerKind::Responder::machine_text()".to_string(),
HandlerKind::Responder.machine_text().to_string(),
));
items.push((
"chain::ChainTailPolicy::RequireHandled::chinese_text()".to_string(),
ChainTailPolicy::RequireHandled.chinese_text().to_string(),
));
items.push((
"chain::ChainTailPolicy::AllowUnhandled::machine_text()".to_string(),
ChainTailPolicy::AllowUnhandled.machine_text().to_string(),
));
items.push(("chain::UNHANDLED_MARK".to_string(), UNHANDLED_MARK.to_string()));
items.push((
"chain::ChainAuditRule::all().len()".to_string(),
format!("{} 条", ChainAuditRule::all().len()),
));
items.push((
"chain::ChainAuditRule::SETTLED::known_failing_input_text()".to_string(),
ChainAuditRule::EveryRequestSettled
.known_failing_input_text()
.to_string(),
));
items.push((
"chain::ChainAuditRule::ORDERED::expectation_text()".to_string(),
ChainAuditRule::ChainRespectsOrder.expectation_text().to_string(),
));
items.push((
"chain::ChainAuditRule::SINGLE::code() / chinese_text()".to_string(),
format!(
"{} / {}",
ChainAuditRule::AtMostOneHandler.code(),
ChainAuditRule::AtMostOneHandler.chinese_text()
),
));
items.push((
"★ chain::ChainLedger 独立可用性:新开一本账跑一次".to_string(),
{
let probe_ledger: Rc<ChainLedger> = Rc::new(ChainLedger::new());
probe_ledger.record_submission();
probe_ledger.record_visit("演示站");
probe_ledger.record_passed("演示站");
probe_ledger.record_unhandled();
let probe_snapshot: LedgerSnapshot = probe_ledger.snapshot();
format!(
"投递 {} / 访问 {} / 无人接 {} / 恒等式一差 {}",
probe_snapshot.submitted_total,
probe_snapshot.visit_total,
probe_snapshot.unhandled_total,
probe_snapshot.submission_identity_gap()
)
},
));
items.push((
"chain::ChainLedger::submitted_total() 等实时计数".to_string(),
{
let probe_ledger: ChainLedger = ChainLedger::new();
probe_ledger.record_submission();
format!(
"{} / {} / {} / {} / {}",
probe_ledger.submitted_total(),
probe_ledger.handled_total(),
probe_ledger.unhandled_total(),
probe_ledger.visit_total(),
probe_ledger.skipped_total()
)
},
));
items.push((
"★ chain::Handling::Handled(7).is_handled() / result()".to_string(),
format!(
"{} / {:?}",
Handling::Handled(7).is_handled(),
Handling::Handled(7).result()
),
));
items.push((
"★ chain::Handling::<i32>::Pass.is_pass() / result_ref()".to_string(),
format!(
"{} / {:?}",
Handling::<i32>::Pass.is_pass(),
Handling::<i32>::Pass.result_ref()
),
));
items.push((
"★ chain::ChainResult::is_ordered()(乱序 = false)".to_string(),
bool_text(
crate::chain::ChainResult::<i32>::new(
None,
UNHANDLED_MARK,
None,
vec!["A".to_string(), "B".to_string()],
vec![3, 1],
7,
)
.is_ordered(),
)
.to_string(),
));
items.push((
"chain::ChainResult::skipped_total()(在第 3 站接下,共 7 站)".to_string(),
crate::chain::ChainResult::<i32>::new(
Some(1),
"演示站",
Some(HandlerKind::Responder),
vec!["A".to_string(), "B".to_string(), "C".to_string()],
vec![0, 1, 2],
7,
)
.skipped_total()
.to_string(),
));
items.push((
"chain::ChainResult::visited_text() / passed_total()".to_string(),
{
let demo: crate::chain::ChainResult<i32> = crate::chain::ChainResult::new(
Some(1),
"演示站",
Some(HandlerKind::Responder),
vec!["A".to_string(), "B".to_string(), "C".to_string()],
vec![0, 1, 2],
7,
);
format!("{} / {}", demo.visited_text(), demo.passed_total())
},
));
items.push((
"chain::RequestObservation::handler_name_label() // 首条".to_string(),
match session.observations().first() {
Some(observation) => observation.handler_name_label().to_string(),
None => "无".to_string(),
},
));
items.push((
"★ chain::ChainResult::into_result()(移动出结论)".to_string(),
format!(
"{:?}",
crate::chain::ChainResult::<i32>::new(
Some(9),
"演示站",
Some(HandlerKind::Responder),
vec!["A".to_string()],
vec![0],
1,
)
.into_result()
),
));
items.push((
"★ chain::LedgerSnapshot 死站 / 纯过路 / 全量 / 省下".to_string(),
{
let real: LedgerSnapshot = session.chain().ledger().snapshot();
let first_attempt: Option<&crate::chain::HandlerAttempt> = real.attempts.first();
format!(
"死站 {} / 纯过路 {} / 全量 {} / 省下 {} / 首站被问 {}",
real.never_asked_handlers().len(),
real.asked_but_never_handled_handlers().len(),
real.judgement_total_if_broadcast(),
real.saved_judgement_total(),
match first_attempt {
Some(attempt) => bool_text(!attempt.never_asked()).to_string(),
None => "无".to_string(),
}
)
},
));
items.push((
"★ chain::RuleCheck::applicable_total() / conforming_total()".to_string(),
{
let checks: Vec<crate::chain::RuleCheck> = audit_chain(&session.observations());
let applicable: usize = checks.iter().map(|check| check.applicable_total()).sum();
let conforming: usize = checks.iter().map(|check| check.conforming_total()).sum();
format!(
"{} 条规则:合规 {} / 适用 {}(逐条明细见幕八)",
checks.len(),
conforming,
applicable
)
},
));
// ---- client ----
items.push((
"client::station_descriptions().len()".to_string(),
format!("{} 站", station_descriptions().len()),
));
items.push((
"client::builtin_handler_specs().len()".to_string(),
format!("{} 站", crate::client::builtin_handler_specs().len()),
));
items.push((
"client::HANDLER_ORDER_NORMAL / SWAPPED / NO_FALLBACK 长度".to_string(),
format!(
"{} / {} / {}",
HANDLER_ORDER_NORMAL.len(),
HANDLER_ORDER_SWAPPED.len(),
HANDLER_ORDER_NO_FALLBACK.len()
),
));
items.push((
"client::HANDLER_ADMISSION_NAME / HANDLER_ESCALATION_NAME".to_string(),
format!("{} / {}", HANDLER_ADMISSION_NAME, HANDLER_ESCALATION_NAME),
));
items.push((
"client::HANDLER_WARRANTY_NAME / HANDLER_LOGGING_NAME".to_string(),
format!("{} / {}", HANDLER_WARRANTY_NAME, HANDLER_LOGGING_NAME),
));
items.push((
"client::HANDLER_STORE_NAME / REGIONAL_NAME / HEADQUARTER_NAME".to_string(),
format!(
"{} / {} / {}",
HANDLER_STORE_NAME, HANDLER_REGIONAL_NAME, HANDLER_HEADQUARTER_NAME
),
));
items.push((
"★ client::build_warranty_handler().can_handle(④ 在保)".to_string(),
bool_text(build_warranty_handler().can_handle(&demo_tickets()[3])).to_string(),
));
items.push((
"★ client::build_warranty_handler().can_handle(⑤ 过保)".to_string(),
bool_text(build_warranty_handler().can_handle(&demo_tickets()[4])).to_string(),
));
items.push((
"★ client::build_quick_lane_handler()(工程外)can_handle(⑥)".to_string(),
bool_text(build_quick_lane_handler().can_handle(&demo_tickets()[5])).to_string(),
));
items.push((
"client::build_escalation_handler().name() / kind()".to_string(),
format!(
"{} / {}",
build_escalation_handler().name(),
build_escalation_handler().kind().chinese_text()
),
));
items.push((
"client::build_store_authority_handler().name()".to_string(),
crate::client::build_store_authority_handler().name().to_string(),
));
items.push((
"client::build_regional_authority_handler().name()".to_string(),
crate::client::build_regional_authority_handler().name().to_string(),
));
items.push((
"client::build_headquarter_authority_handler().name()".to_string(),
crate::client::build_headquarter_authority_handler()
.name()
.to_string(),
));
items.push((
"client::build_admission_handler().kind()".to_string(),
crate::client::build_admission_handler().kind().chinese_text().to_string(),
));
items.push((
"client::build_logging_handler().kind()".to_string(),
crate::client::build_logging_handler().kind().chinese_text().to_string(),
));
items.push((
"client::build_authority_handler(AUTHORITY_STORE).name()".to_string(),
crate::client::build_authority_handler(crate::domain::AUTHORITY_STORE)
.name()
.to_string(),
));
items.push((
"client::build_handler_by_name(保修判定).name()".to_string(),
crate::client::build_handler_by_name(HANDLER_WARRANTY_NAME)
.name()
.to_string(),
));
items.push((
"client::build_ticket_chain_with_extensions(...) // 站数".to_string(),
format!(
"{} 站",
crate::client::build_ticket_chain_with_extensions(
"清点用链",
ChainTailPolicy::RequireHandled,
Rc::new(ChainLedger::new()),
&HANDLER_ORDER_NO_FALLBACK,
vec![build_quick_lane_handler()],
)
.handler_total()
),
));
items.push((
"client::TicketSession::chain().name() / policy()".to_string(),
format!(
"{} / {}",
session.chain().name(),
session.chain().policy().chinese_text()
),
));
items.push((
"client::TicketSession::chain().handler_names().len()".to_string(),
format!("{} 站", session.chain().handler_names().len()),
));
items.push((
"client::TicketSession::chain().handler_kinds().len()".to_string(),
format!("{} 站", session.chain().handler_kinds().len()),
));
items.push((
"client::TicketSession::chain().ledger().submitted_total()".to_string(),
session.chain().ledger().submitted_total().to_string(),
));
items.push((
"client::TicketSession::ticket_total() / settled_total()".to_string(),
format!("{} / {}", session.ticket_total(), session.settled_total()),
));
items.push((
"client::TicketSession::unhandled_total()".to_string(),
session.unhandled_total().to_string(),
));
items.push((
"client::TicketSession::disposition_counts()".to_string(),
session
.disposition_counts()
.iter()
.map(|(disposition, count)| format!("{}×{}", disposition.chinese_text(), count))
.collect::<Vec<String>>()
.join("、"),
));
items.push((
"client::TicketSession::handler_settle_counts()".to_string(),
session
.handler_settle_counts()
.iter()
.map(|(name, count)| format!("{}×{}", name, count))
.collect::<Vec<String>>()
.join("、"),
));
items.push((
"client::TicketSession::visited_total() / skipped_total()".to_string(),
format!("{} 次 / {} 站次", session.visited_total(), session.skipped_total()),
));
items.push((
"client::TicketSession::visited_total_if_broadcast()".to_string(),
format!("{} 次", session.visited_total_if_broadcast()),
));
items.push((
"client::TicketSession::compensation_total()".to_string(),
session.compensation_total().chinese_text(),
));
items.push((
"client::TicketSession::resolution_fingerprints().len()".to_string(),
format!("{} 张", session.resolution_fingerprints().len()),
));
items.push((
"client::TicketSession::observations().len()".to_string(),
format!("{} 条", session.observations().len()),
));
items.push((
"client::TicketOutcome::visited_text() // 首单".to_string(),
match session.outcomes().first() {
Some(outcome) => outcome.visited_text(),
None => "无".to_string(),
},
));
items.push((
"client::TicketOutcome::ticket_number() / disposition()".to_string(),
match session.outcomes().first() {
Some(outcome) => format!(
"{} / {}",
outcome.ticket_number(),
outcome.disposition().chinese_text()
),
None => "无".to_string(),
},
));
// ---- analysis ----
items.push((
"analysis::station_probes(...).len()".to_string(),
format!("{} 站", probes.len()),
));
items.push((
"analysis::StationProbe::is_never_asked() // 首站".to_string(),
match probes.first() {
Some(probe) => bool_text(probe.is_never_asked()).to_string(),
None => "无".to_string(),
},
));
items.push((
"analysis::StationProbe::risk_text() // 末站".to_string(),
match probes.last() {
Some(probe) => probe.risk_text(),
None => "无".to_string(),
},
));
items.push((
"analysis::StationProbe::asked_total() // 首站".to_string(),
match probes.first() {
Some(probe) => probe.asked_total().to_string(),
None => "无".to_string(),
},
));
items.push((
"analysis::compensation_total(...)".to_string(),
compensation_total(session.outcomes()).chinese_text(),
));
items.push((
"analysis::OutcomeView::list_from_outcomes(...).len()".to_string(),
format!(
"{} 张",
OutcomeView::list_from_outcomes(session.outcomes()).len()
),
));
items.push((
"analysis::ChainValueReport::saved_judgement_total()".to_string(),
format!("{} 次", value_report.saved_judgement_total()),
));
items.push((
"analysis::ChainValueReport::saved_ratio_basis_points()".to_string(),
format!("{} 万分点", value_report.saved_ratio_basis_points()),
));
items.push((
"analysis::ChainValueReport::summary_text()".to_string(),
value_report.summary_text(),
));
items.push((
"analysis::ChainValueReport::self_check().len()".to_string(),
format!("{} 条", value_report.self_check().len()),
));
items.push((
"analysis::ChainValueReport::station_total() / ticket_total()".to_string(),
format!("{} 站 / {} 张", value_report.station_total(), value_report.ticket_total()),
));
items.push((
"analysis::LedgerSnapshotView::visit_total_if_broadcast()".to_string(),
format!("{} 次", snapshot.visit_total_if_broadcast()),
));
items.push((
"analysis::LedgerSnapshotView::saved_judgement_total()".to_string(),
format!("{} 次", snapshot.saved_judgement_total()),
));
items.push((
"analysis::LedgerSnapshotView::never_asked_stations().len()".to_string(),
format!("{} 站", snapshot.never_asked_stations().len()),
));
items.push((
"analysis::LedgerSnapshotView::pure_passthrough_stations()".to_string(),
format!("{} 站", snapshot.pure_passthrough_stations().len()),
));
items.push((
"analysis::LedgerSnapshotView::attempts.len()".to_string(),
format!("{} 站", snapshot.attempts.len()),
));
items.push((
"analysis::StationAttemptView::name() // 首站".to_string(),
match snapshot.attempts.first() {
Some(attempt) => attempt.name().to_string(),
None => "无".to_string(),
},
));
items.push((
"analysis::StationAttemptView::is_pure_passthrough() // 首站".to_string(),
match snapshot.attempts.first() {
Some(attempt) => bool_text(attempt.is_pure_passthrough()).to_string(),
None => "无".to_string(),
},
));
items.push((
"analysis::StationAttemptView::asked_total() / handled_total()".to_string(),
{
let first_attempt: Option<&crate::analysis::StationAttemptView> =
snapshot.attempts.first();
match first_attempt {
Some(attempt) => format!(
"{} / {}",
attempt.asked_total(),
attempt.handled_total()
),
None => "无".to_string(),
}
},
));
items.push((
"analysis::StationAttemptView::passed_total() // 首站".to_string(),
match snapshot.attempts.first() {
Some(attempt) => attempt.passed_total().to_string(),
None => "无".to_string(),
},
));
items.push((
"★ analysis::LedgerSnapshotView::last_settled_handler()".to_string(),
snapshot.last_settled_handler(),
));
items.push((
"analysis::CheckLine::verdict_text(true) / (false)".to_string(),
format!(
"{} / {}",
CheckLine::new("演示", "相等", "1 == 1", true).verdict_text(),
CheckLine::new("演示", "相等", "1 == 2", false).verdict_text()
),
));
items.push((
"analysis::CheckReport::new().line_total()".to_string(),
CheckReport::new().line_total().to_string(),
));
// ---- app ----
items.push(("app::DOCUMENT_WIDTH".to_string(), DOCUMENT_WIDTH.to_string()));
items.push((
"app::INTERFACE_NAME_WIDTH / INTERFACE_OUTPUT_WIDTH".to_string(),
format!("{} / {}", INTERFACE_NAME_WIDTH, INTERFACE_OUTPUT_WIDTH),
));
items.push((
"app::bool_text(true) / (false)".to_string(),
format!("{} / {}", bool_text(true), bool_text(false)),
));
items.push(("app::byte_text(1234567)".to_string(), byte_text(1_234_567)));
items.push((
"app::basis_points_text(5000)".to_string(),
basis_points_text(5_000),
));
items.push((
"app::percent_text(3000)".to_string(),
crate::app::percent_text(3_000),
));
items.push((
"app::note_lines(\"一行注解\", 140).len()".to_string(),
format!("{} 行", note_lines("一行注解", DOCUMENT_WIDTH).len()),
));
items.push(("app::table_rule(12)".to_string(), table_rule(12)));
items.push((
"app::render_pair_table(...) // 返回行数".to_string(),
format!(
"{} 行",
render_pair_table(
"接口清点用的两列表",
&[("演示项".to_string(), "演示值".to_string())],
&["演示注解"],
)
.len()
),
));
items.push((
"★ app::render_cell 直接调用(列宽 12,内容「门店」)".to_string(),
format!(
"[{}]",
crate::app::render_cell(&crate::app::TableColumn::new("演示", 12), "门店")
),
));
items.push((
"★ app::TableColumn::new(...).elidable().is_elidable()".to_string(),
bool_text(
crate::app::TableColumn::new("演示", 10)
.elidable()
.is_elidable()
)
.to_string(),
));
items.push((
"app::TableColumn::numeric(\"金额\", 12).align()".to_string(),
format!(
"{:?}",
crate::app::TableColumn::numeric("金额", 12).align()
),
));
items.push((
"★ app::ColumnAlign:数值列必须右对齐(否则合计看不齐)".to_string(),
format!(
"{} / 左对齐 {:?}",
bool_text(
crate::app::TableColumn::numeric("金额", 12).align()
== crate::app::ColumnAlign::Right
),
crate::app::ColumnAlign::Left
),
));
items.push((
"★ app::Table::new(...).with_row(...).with_note(...)".to_string(),
{
let table: crate::app::Table = crate::app::Table::new(
"演示",
vec![
crate::app::TableColumn::new("A", 10),
crate::app::TableColumn::new("B", 10),
],
)
.with_row(vec!["1".to_string(), "2".to_string()])
.with_note("演示注解");
format!(
"{} 列 / {} 行 / 宽 {}",
table.column_count(),
table.row_count(),
table.total_width()
)
},
));
items.push((
"app::section_header(\"演示\", 140).len()".to_string(),
section_header("演示", DOCUMENT_WIDTH).len().to_string(),
));
items
}
/// 打印一组行。
fn print_lines(lines: &[String]) {
for line in lines.iter() {
println!("{}", line);
}
}
/// 带圈数字(① ~ ⑩)。
fn circled_number(value: usize) -> String {
const CIRCLED: [&str; 10] = ["①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨", "⑩"];
match CIRCLED.get(value.wrapping_sub(1)) {
Some(text) => (*text).to_string(),
None => format!("({})", value),
}
}
输出:
