项目结构:

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 : arithmetic.rs
//! 整数算术:全工程**唯一**的舍入与比率口径。
//!
//! ## 为什么单独一个文件
//!
//! 「算账的除法」在工程里只能有一处实现。散在各处写 `a / b` 会带来两个后果:
//! ① 整数除法向零截断,与手工口径差 1 分,而这种差 1 分在报表上**看不出来**;
//! ② 以后想统一改成「银行家舍入」要改十几个地方。
//! 因此本文件收口,其余各层一律调用 [`divide_rounded`]。
//!
//! ## 为什么用 `i128` 做中间量
//!
//! 金额用「分」存储,金重用「毫克」,比率用「万分点」。
//! 「分 × 万分点」已经是 10^6 量级,再乘一个金重(毫克,可达 10^6)
//! 会到 10^12------`i64` 还剩得下,但**再来一次乘法就悬了**。
//! 中间量一律 `i128`,出口处再受控地转回 `i64`。
//!
//! ## ★ 舍入方向:远离零
//!
//! `divide_rounded(-a, b) == -divide_rounded(a, b)` 是本层的恒等式,
//! 工程验收会真的跑它(`main.rs` 的体检表)。
//! 向零截断(Rust 的 `/` 默认行为)**不满足**这条恒等式,
//! 于是「退 1 分」与「收 1 分」的舍入方向不一致,差额会在合计里累积。
/// 把 `numerator / denominator` 按**远离零方向四舍五入**成一个 `i64`。
///
/// # 参数
/// - `numerator`:分子(可为负)。
/// - `denominator`:分母(**必须非 0**)。
///
/// # Panics
/// 分母为 0 时 panic。这是**调用方的逻辑错误**,不是可以降级的业务情况------
/// 返回 0 会让「这一项除不出来」变成「这一项是 0 元」,而报表看起来完全正常。
pub fn divide_rounded(numerator: i128, denominator: i128) -> i64 {
assert!(
denominator != 0,
"除数为零:这不是可降级的业务情况,静默返回 0 会让报表看起来正常"
);
// 符号单独记:只在最后乘回去,中间全按正数算,避免负号干扰取余。
let is_negative: bool = (numerator < 0) != (denominator < 0);
// ⚠️ 用 `unsigned_abs` 而不是 `abs`:`i128::MIN` 的 `abs()` 会溢出 panic。
let dividend: u128 = numerator.unsigned_abs();
let divisor: u128 = denominator.unsigned_abs();
let quotient: u128 = dividend / divisor;
let remainder: u128 = dividend % divisor;
// 远离零方向:余数达到除数的一半就进 1。
let adjusted: u128 = if remainder * 2 >= divisor {
quotient + 1
} else {
quotient
};
let magnitude: i128 = i128::try_from(adjusted).expect("除法结果超出 i128 可表示范围");
let signed: i128 = if is_negative { -magnitude } else { magnitude };
i64::try_from(signed).expect("除法结果超出 i64 可表示范围")
}
/// 比率转万分点:`numerator / denominator × 10000`,四舍五入。
///
/// ## 为什么比率一律用「万分点」整数
///
/// 毛利率 13.00% 与 13.005% 在浮点里都能表示,但**能不能精确比较**是另一回事:
/// 两个「看起来相等」的浮点比率在报表上可能因 1e-15 的差而分叉。
/// 万分点整数没有这个问题,且 `1300` 这种数字读者能直接读成 `13.00%`。
pub fn ratio_basis_points(numerator: i128, denominator: i128) -> i64 {
divide_rounded(numerator * 10_000, denominator)
}
/// 按整数比例缩放:`value × numerator / denominator`,四舍五入。
///
/// 「金重(毫克) × 金价(分/克) / 1000」这类算式一律走这里,
/// 不在业务代码里裸写乘除。
pub fn scale_rounded(value: i64, numerator: i128, denominator: i128) -> i64 {
divide_rounded(i128::from(value) * numerator, denominator)
}
//!# 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 : authority_level.rs
//! 处置权限档位:**哪一级有权批多大金额**。
//!
//! ## 为什么是开放型标签(`const fn new`)
//!
//! 判据同 [`super::ticket_type::TicketType`]:
//!
//! - 权限层级**会**不断增加(今天三级,明天加「大区总监」档很自然);
//! - 但**不**被当作查找键(链是**线性**查找的,见机制层文档);
//! - 因此 `const fn new` + 不实现 `PartialEq`/`Ord`/`Hash`。
//!
//! ## ★ 为什么「金额上限」放在档位上而不是放在处理器上
//!
//! 处理器(机制层的 `Handler`)**不应该知道**「门店权限是 ¥5,000」------
//! 那是业务口径。放在这里,机制层就只是一个「按序问、问到能处理为止」的壳,
//! 换一家公司只要换这张档位表,一个字符都不用改机制层。
//! (机制层**零 `crate::` 依赖**正是为了让这一点可被依赖图证明。)
/// 一级处置权限。
///
/// # 字段说明
/// - `code`:机读编码。
/// - `chinese_name`:中文名(如「门店店长」)。
/// - `ceiling_minor_units`:★ 金额上限(**整数分**)。
/// 涉及金额 `<=` 上限即属于本档。
#[derive(Clone, Copy, Debug)]
pub struct AuthorityLevel {
/// 机读编码。
code: &'static str,
/// 中文名。
chinese_name: &'static str,
/// 金额上限(整数分)。
ceiling_minor_units: i64,
}
impl AuthorityLevel {
/// 声明一级权限。
///
/// # 参数
/// - `code`:机读编码。
/// - `chinese_name`:中文名。
/// - `ceiling_minor_units`:金额上限(整数分,`>= 0`)。
pub const fn new(code: &'static str, chinese_name: &'static str, ceiling_minor_units: i64) -> Self {
Self {
code,
chinese_name,
ceiling_minor_units,
}
}
/// 机读编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名。
pub const fn chinese_name(&self) -> &'static str {
self.chinese_name
}
/// 金额上限(整数分)。
pub const fn ceiling_minor_units(&self) -> i64 {
self.ceiling_minor_units
}
/// ★ 本档能否批这个金额。
///
/// 判据是 `amount <= ceiling`(**含等号**:恰好等于上限归本级,
/// 这是「权限」而非「阈值」的语义,写反了边界上就会差一张单)。
///
/// # 参数
/// - `amount_minor_units`:涉及金额(整数分)。
pub const fn covers(&self, amount_minor_units: i64) -> bool {
amount_minor_units <= self.ceiling_minor_units
}
/// 上限文本(`¥5,000.00` 形式,交给 [`super::money::Money`] 格式化)。
pub fn ceiling_text(&self) -> String {
super::money::Money::new(self.ceiling_minor_units, super::currency_code::CNY).chinese_text()
}
}
/// 门店店长:上限 ¥5,000.00(= 500000 分)。
pub const AUTHORITY_STORE: AuthorityLevel = AuthorityLevel::new("STORE", "门店店长", 500_000);
/// 区域中心经理:上限 ¥80,000.00(= 8000000 分)。
pub const AUTHORITY_REGIONAL: AuthorityLevel = AuthorityLevel::new("REGIONAL", "区域中心经理", 8_000_000);
/// 总部售后总监:上限 ¥500,000.00(= 50000000 分)。
pub const AUTHORITY_HEADQUARTER: AuthorityLevel =
AuthorityLevel::new("HEADQUARTER", "总部售后总监", 50_000_000);
/// 内置权限档位清单(**按权限从小到大**,链就是按这个顺序装配的)。
///
/// ⚠️ 顺序本身就是业务知识:把「总部」放在「门店」前面,
/// 所有单都会被总部批掉------而代码**照样能编译、照样能跑**。
/// 本工程在幕九专门做了「换序对照」来证明这一点。
pub const BUILTIN_AUTHORITY_LEVELS: [AuthorityLevel; 3] = [
AUTHORITY_STORE,
AUTHORITY_REGIONAL,
AUTHORITY_HEADQUARTER,
];
//!# 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 : category_code.rs
//! 品类编码:开放型标签,并自带一个**影响算法分支**的属性。
//!
//! ## ★ `needs_metal_pricing` 为什么放在编码上
//!
//! 黄金、钻石(镶嵌托)的**成本要按成色 × 金价**算;
//! 翡翠、珍珠按件计价,没有金属成本这一项。
//! 这个差异不是「某个报表的选择」,而是**这个品类本身的性质**,
//! 所以它属于品类编码,而不是散在工厂函数里写 `if category == ...`。
//! 散着写的结果一定是:新增一个品类时,有地方忘了补这个判断,
//! 而漏掉的那处会算出「金属成本 = 0」,报表看起来完全正常。
/// 品类编码。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct CategoryCode {
/// 机器编码,如 `"GOLD"`。
code: &'static str,
/// 中文名称,如 `"黄金饰品"`。
chinese_name: &'static str,
/// ★ 该品类的成本是否要按「成色 × 金价」计算。
needs_metal_pricing: bool,
}
impl CategoryCode {
/// 声明一个品类。
pub const fn new(code: &'static str, chinese_name: &'static str, needs_metal_pricing: bool) -> Self {
Self {
code,
chinese_name,
needs_metal_pricing,
}
}
/// 机器编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn chinese_name(&self) -> &'static str {
self.chinese_name
}
/// 是否按「成色 × 金价」计算金属成本。
pub const fn needs_metal_pricing(&self) -> bool {
self.needs_metal_pricing
}
}
/// 黄金饰品:按成色 × 金价计价。
pub const CATEGORY_GOLD: CategoryCode = CategoryCode::new("GOLD", "黄金饰品", true);
/// 钻石饰品:镶嵌托是 18K 金,同样按成色 × 金价计价。
pub const CATEGORY_DIAMOND: CategoryCode = CategoryCode::new("DIAMOND", "钻石饰品", true);
/// 翡翠饰品:按件计价,无金属成本。
pub const CATEGORY_JADE: CategoryCode = CategoryCode::new("JADE", "翡翠饰品", false);
/// 内置品类全集。
pub const BUILTIN_CATEGORY_CODES: [CategoryCode; 3] = [CATEGORY_GOLD, CATEGORY_DIAMOND, CATEGORY_JADE];
//!# 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 : currency_code.rs
//! 币种:开放型标签(`const fn new` 结构体,不是 `enum`)。
//!
//! ## ★ 判据:会不会被当作查找键 + 会不会不断增加
//!
//! - **会不断增加**(接新地区就多一个币种)→ 开放型结构体,
//! 工程外新增只需在装配点声明一个 `const`,**不用回来改本文件**;
//! - **会被当作查找键** → 实现 `PartialEq`(汇率表按币种查);
//! - 但**不实现 `Ord` / `Hash`**:本工程从不排序币种、也不拿币种做哈希键,
//! 不实现用不上的 trait 本身就是一种防错(多实现一个,就多一处「看起来能用」的用法)。
//!
//! ## ⚠️ 为什么字段是 `&'static str`
//!
//! `String` 会让 `CurrencyCode` 不能 `Copy`,于是 `Money` 也不能 `Copy`,
//! 而金额在本工程里到处按值传递。币种编码是编译期常量,
//! 用 `&'static str` 换 `Copy` 是划算的。
/// 币种:`code` 是机器编码,`chinese_name` 给人读,`symbol` 用于排版。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct CurrencyCode {
/// 机器编码,如 `"CNY"`。
code: &'static str,
/// 中文名称,如 `"人民币"`。
chinese_name: &'static str,
/// 金额符号,如 `"¥"`。
symbol: &'static str,
}
impl CurrencyCode {
/// 声明一个币种。`const fn` 使得它可以写进 `const` 常量里。
pub const fn new(code: &'static str, chinese_name: &'static str, symbol: &'static str) -> Self {
Self {
code,
chinese_name,
symbol,
}
}
/// 机器编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn chinese_name(&self) -> &'static str {
self.chinese_name
}
/// 金额符号。
pub const fn symbol(&self) -> &'static str {
self.symbol
}
}
/// 人民币:集团合并口径的记账本位币。
pub const CNY: CurrencyCode = CurrencyCode::new("CNY", "人民币", "¥");
/// 港币:中国香港门店的本位币。
pub const HKD: CurrencyCode = CurrencyCode::new("HKD", "港币", "HK$");
/// 美元:跨境采购结算币。
pub const USD: CurrencyCode = CurrencyCode::new("USD", "美元", "US$");
/// 集团允许出现的币种全集。
///
/// ⚠️ 这张表只用于**校验**(流水的币种必须在这里面),
/// 不用于「枚举有哪些币种」------那会把新增币种变成改领域层文件。
pub const BUILTIN_CURRENCY_CODES: [CurrencyCode; 3] = [CNY, HKD, USD];
//!# 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 : disposition.rs
//! 处置结论:**一张工单最后是怎么了结的**。
//!
//! ## 为什么 `Disposition` 是封闭 `enum`
//!
//! 判据与 [`super::ticket_type::TicketType`] 恰好相反:
//!
//! - 处置结论**不会**不断增加(就这七种,加一种意味着流程本身变了);
//! - 报表要对它做**穷尽**处理(每一种都要有一列说明、一条审计规则);
//! - 因此要**编译期的穷尽性检查**:漏掉一种,`match` 就编译不过去。
//!
//! 这正是「开放型 vs 封闭」的判据:**会不会被当作查找键 + 会不会不断增加**。
/// ★ 处置结论的类型(**封闭集合**)。
///
/// ⚠️ 顺序不是随便排的:它同时是**严重度**的顺序(越靠后越严重),
/// 报表里按这个顺序输出,读者就能一眼看出「最严重的在最后」。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Disposition {
/// 门店直接处置(金额在门店权限内)。
StoreHandled,
/// 区域中心处置(金额在区域权限内)。
RegionalHandled,
/// 总部处置(金额在总部权限内)。
HeadquarterHandled,
/// 保修期内免费维修(**不看金额**)。
WarrantyRepair,
/// 超出所有权限 → 上报总裁办。
Escalated,
/// 拒单(无凭证 / 超窗口 / 人为损坏不在保修内)。
Rejected,
/// ★ 链上**没有任何一个处理器**接下它。
///
/// 这一档必须存在,理由见 [`super::authority_level`] 之外更基本的道理:
/// 「没人处理」若用 `Option::None` 表示,调用方就会写出
/// `if let Some(r) = result { ... }` 然后**什么都不做**------
/// 那时一张工单消失了,而账上没有任何一个数字会说「少了一张」。
Unhandled,
}
impl Disposition {
/// 中文名。
///
/// ⚠️ 全工程「处置结论叫什么」只有这一处产生:
/// 若每张表各写一次,一处写「门店处置」、一处写「门店处理」,
/// 读者只会以为那是两件事。
pub const fn chinese_text(&self) -> &'static str {
match self {
Disposition::StoreHandled => "门店直接处置",
Disposition::RegionalHandled => "区域中心处置",
Disposition::HeadquarterHandled => "总部处置",
Disposition::WarrantyRepair => "保修免费维修",
Disposition::Escalated => "上报总裁办",
Disposition::Rejected => "拒单",
Disposition::Unhandled => "无人处置",
}
}
/// 机读编码(写进指纹)。
pub const fn machine_text(&self) -> &'static str {
match self {
Disposition::StoreHandled => "STORE",
Disposition::RegionalHandled => "REGIONAL",
Disposition::HeadquarterHandled => "HEADQUARTER",
Disposition::WarrantyRepair => "WARRANTY",
Disposition::Escalated => "ESCALATED",
Disposition::Rejected => "REJECTED",
Disposition::Unhandled => "UNHANDLED",
}
}
/// ★ 是否算「有人接下了」(处置掉了,哪怕结论是拒单)。
///
/// ⚠️ 拒单**也算有人处理**:拒单是一个明确的业务结论,
/// 「没人处理」才是系统缺陷。把两者混为一谈,
/// 「链尾没人接」这个真问题就会被「我们拒了很多单」掩盖掉。
pub const fn is_settled(&self) -> bool {
!matches!(self, Disposition::Unhandled)
}
/// 是否属于「赔钱了」的处置(用于统计赔付总额)。
///
/// 拒单与无人处置不产生赔付;保修维修由厂商承担,赔付为 0
/// (但仍要走统一路径,理由见 [`super::money::Money::zero`])。
pub const fn bears_compensation(&self) -> bool {
matches!(
self,
Disposition::StoreHandled
| Disposition::RegionalHandled
| Disposition::HeadquarterHandled
| Disposition::Escalated
)
}
/// 是否走到了「上报」这一级(本工程用来做体检的**存在性**检查)。
pub const fn is_escalated(&self) -> bool {
matches!(self, Disposition::Escalated)
}
/// 是否被拒。
pub const fn is_rejected(&self) -> bool {
matches!(self, Disposition::Rejected)
}
/// 全部取值(**穷尽**,用于报表与自检)。
pub const fn all() -> [Disposition; 7] {
[
Disposition::StoreHandled,
Disposition::RegionalHandled,
Disposition::HeadquarterHandled,
Disposition::WarrantyRepair,
Disposition::Escalated,
Disposition::Rejected,
Disposition::Unhandled,
]
}
}
//!# 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 : money.rs
//! 金额:整数最小单位(分)+ 自带币种。
//!
//! ## ★ 自带币种是类型安全,不是装饰
//!
//! 跨币种相加(人民币 + 港币)在类型上是**两个数相加**,编译器看不出错,
//! 报表也看不出错------只有做集团合并的人会在月底发现差了几万。
//! 因此 [`Money::plus`] 里放一条 `debug_assert!`:
//! 币种不同就在调试构建里立刻 panic,而不是返回一个「看起来对」的数。
//!
//! ## ★ 规范文本形式留在领域层
//!
//! `chinese_text()`(`¥12,345.67`)之所以不放表示层,
//! 判据是「这个文本形式是不是所有层都要用」:
//! 机制层报错、分析层结论、表示层表格都要把金额嵌进句子里。
//! 若搬去 `app`,`pool` / `analysis` 就得反向依赖 `app`------那才是真违规。
//!
//! ## ⚠️ 全程不出现浮点
//!
//! 千分位用整数反复除 10 取位拼出来。借道 `f64` 会在 10^15 量级上
//! 出现「同一笔金额两种写法」。
use crate::domain::arithmetic::scale_rounded;
use crate::domain::CurrencyCode;
/// 千分位分隔符。
const THOUSANDS_SEPARATOR: char = ',';
/// 一笔金额:**分**为单位的整数 + 币种。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Money {
/// 以「分」为单位的整数金额。
minor_units: i64,
/// 币种。
currency: CurrencyCode,
}
impl Money {
/// 由「分」整数与币种构造。
pub const fn new(minor_units: i64, currency: CurrencyCode) -> Self {
Self {
minor_units,
currency,
}
}
/// 零额。
///
/// ⚠️ 「零」也必须走统一构造路径:散着写 `Money::new(0, c)` 时,
/// 币种迟早会被写错一次,而 0 元在报表上**看不出币种错了**。
pub const fn zero(currency: CurrencyCode) -> Self {
Self::new(0, currency)
}
/// 以「分」为单位的整数。
pub const fn minor_units(&self) -> i64 {
self.minor_units
}
/// 币种。
pub const fn currency(&self) -> CurrencyCode {
self.currency
}
/// 是否为零额。
pub const fn is_zero(&self) -> bool {
self.minor_units == 0
}
/// 同币种相加。
///
/// # Panics
/// 币种不同时 `debug_assert!` 失败。
/// 这是**接线错误**(把两本账加在了一起),不是业务情况。
pub fn plus(&self, other: Money) -> Money {
debug_assert!(
self.currency == other.currency,
"跨币种相加:{} + {} ------ 这两本账不能相加,先折算再求和",
self.currency.code(),
other.currency.code()
);
Money::new(self.minor_units + other.minor_units, self.currency)
}
/// 同币种相减(`self - other`)。
pub fn minus(&self, other: Money) -> Money {
debug_assert!(
self.currency == other.currency,
"跨币种相减:{} - {}",
self.currency.code(),
other.currency.code()
);
Money::new(self.minor_units - other.minor_units, self.currency)
}
/// 取相反数(用于把「成本」折成「对毛利的贡献」)。
pub fn negate(&self) -> Money {
Money::new(-self.minor_units, self.currency)
}
/// 按万分点缩放:`self × basis_points / 10000`。
///
/// 用于「按占比分摊」------分摊口径也走唯一舍入口径,
/// 不会出现「分摊后各份之和 ≠ 总额」之外的第二种差法。
pub fn scale_basis_points(&self, basis_points: i64) -> Money {
Money::new(
scale_rounded(self.minor_units, i128::from(basis_points), 10_000),
self.currency,
)
}
/// 按整数比例缩放:`self × numerator / denominator`。
pub fn scale_ratio(&self, numerator: i128, denominator: i128) -> Money {
Money::new(
scale_rounded(self.minor_units, numerator, denominator),
self.currency,
)
}
/// 纯数字文本(`12,345.67`),不含币种符号。
///
/// 用于「金额 ÷ 克数」这类已经把币种写进列头的场合。
pub fn amount_text(&self) -> String {
// 符号单独处理:负号要放在最前面,不能夹在千分位里。
// ⚠️ 先升到 i128 再取绝对值:`i64::MIN` 的 `abs()` 会溢出。
let is_negative: bool = self.minor_units < 0;
let magnitude: i128 = i128::from(self.minor_units).unsigned_abs() as i128;
let yuan: i128 = magnitude / 100;
let cents: i128 = magnitude % 100;
let sign: &str = if is_negative { "-" } else { "" };
format!("{}{}.{:02}", sign, group_thousands(yuan), cents)
}
/// 规范文本(`¥12,345.67`):符号 + 千分位。
pub fn chinese_text(&self) -> String {
format!("{}{}", self.currency.symbol(), self.amount_text())
}
/// 两个金额是否相等(含币种)。
///
/// 用于审计比对:金额相等但币种不同,在本工程里是**不相等**。
pub fn is_same_as(&self, other: &Money) -> bool {
self.minor_units == other.minor_units && self.currency == other.currency
}
}
/// 把一个非负整数的十进制表示插上千分位。
///
/// ⚠️ 用整数反复除 10 取位,全程不出现浮点:
/// `f64` 在 10^15 以上会开始丢低位,而金额合计确实会到那个量级。
fn group_thousands(value: i128) -> String {
let digits: Vec<char> = value.to_string().chars().collect();
let mut buffer: Vec<char> = Vec::with_capacity(digits.len() + digits.len() / 3);
for (index, character) in digits.iter().enumerate() {
let from_right: usize = digits.len() - index;
if index > 0 && from_right % 3 == 0 {
buffer.push(THOUSANDS_SEPARATOR);
}
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 : month_key.rs
//! 会计月份:`年 + 月` 的值对象。
//!
//! ## 为什么不用 `CalendarDate`
//!
//! 月度经营分析的粒度是**月**,不是日。用「某月 1 号」代表一个月会带来
//! 「代表日是哪天」这种本不该存在的问题(月末调整单会挂在代表日上)。
//! 因此单独一个 `MonthKey`。
//!
//! ## ★ `month_index` 是排序与比较的唯一口径
//!
//! 跨年比较不能写 `year < year || (year == year && month < month)`------
//! 这种写法迟早会被复制漏改一处。折成 `year * 12 + (month - 1)` 一个整数后,
//! 「先后」「相差几个月」都是一次减法。
/// 会计月份。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct MonthKey {
/// 年份(如 `2026`)。
year: i32,
/// 月份(`1..=12`)。
month: u32,
}
impl MonthKey {
/// 声明一个会计月份。
pub const fn new(year: i32, month: u32) -> Self {
Self { year, month }
}
/// 年份。
pub const fn year(&self) -> i32 {
self.year
}
/// 月份。
pub const fn month(&self) -> u32 {
self.month
}
/// 月份是否合法(`1..=12`)。
pub const fn is_valid(&self) -> bool {
self.month >= 1 && self.month <= 12
}
/// 是否与另一个月份相同。
///
/// ⚠️ 单独一个 `const fn` 而不是用 `==`:
/// `PartialEq` 的 `==` **不是 const fn**,在 `const fn` 里用不了。
pub const fn is_same(&self, other: &MonthKey) -> bool {
self.year == other.year && self.month == other.month
}
/// ★ 折成一个整数的月份序号:`year * 12 + (month - 1)`。
///
/// 「先后」「相差几个月」全部由它算,不写复合比较。
pub const fn month_index(&self) -> i32 {
self.year * 12 + (self.month as i32) - 1
}
/// 上一个会计月份。
pub fn previous(&self) -> MonthKey {
let index: i32 = self.month_index() - 1;
MonthKey::new(index.div_euclid(12), index.rem_euclid(12) as u32 + 1)
}
/// 下一个会计月份。
pub fn next(&self) -> MonthKey {
let index: i32 = self.month_index() + 1;
MonthKey::new(index.div_euclid(12), index.rem_euclid(12) as u32 + 1)
}
/// 去年同月(同比的分母所在月份)。
pub const fn same_month_last_year(&self) -> MonthKey {
MonthKey::new(self.year - 1, self.month)
}
/// ★ 往后推 `count` 个月(校准到期月顺延用)。
///
/// ⚠️ 为什么不用 `year*12+month` 手算再拆:
/// 那种算式在跨年处容易漏 `-1`,而漏了之后**只在 12 月出错**------
/// 一年只错一次的错误最难被发现。复用 `month_index` 是唯一的写法。
///
/// # 参数
/// - `count`:要往后推几个月(`0` 表示就是当月)。
pub fn plus_months(&self, count: u32) -> MonthKey {
let index: i32 = self.month_index() + count as i32;
MonthKey::new(index.div_euclid(12), index.rem_euclid(12) as u32 + 1)
}
/// 与另一个月份相差几个月(`self - other`)。
pub const fn months_since(&self, other: &MonthKey) -> i32 {
self.month_index() - other.month_index()
}
/// 是否早于另一个月份。
pub const fn is_before(&self, other: &MonthKey) -> bool {
self.month_index() < other.month_index()
}
/// 机器文本(`2026-10`)。定宽,可直接进表格列。
pub fn machine_text(&self) -> String {
format!("{:04}-{:02}", self.year, self.month)
}
/// 中文文本(`2026年10月`)。
pub fn chinese_text(&self) -> String {
format!("{}年{}月", self.year, self.month)
}
/// 该月的自然天数(用于把「月」折算成「日均」这类派生口径)。
///
/// ⚠️ 闰年判据走 `support` 层,不在本文件重写一遍------
/// 重写一次就多一处「两处算法不一致」的风险。
pub fn days(&self) -> u32 {
crate::support::days_in_month(self.year, self.month)
}
}
//!# 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 : service_ticket.rs
//! 售后工单:一张单进来时**带着什么**。
//!
//! ## ★ 为什么判据(能不能退、在不在保)写在工单上,而不是写在处理器里
//!
//! 处理器的职责是「**我能不能接这一单**」,
//! 而「这一单能不能退」是**这一单自己的属性**。
//! 若把判据写进处理器,那么同一个问题(比如「有没有凭证」)
//! 会在单据校验、保修判定、权限审批三处各写一遍------
//! 三处写错成两种口径时,报表上不会有任何异常,只会少几张单。
//!
//! ## 为什么工单是**不可变**的
//!
//! 工单一旦受理,它的属性就不该变(金额、购买月、有没有凭证都是历史事实)。
//! 处置结论是**另一件东西**(见 [`super::ticket_resolution::TicketResolution`]),
//! 不写回工单。这样「同一批工单跑两条不同的链」才真的可比------
//! 本工程幕九的换序对照正是靠这一点成立的。
use super::category_code::CategoryCode;
use super::month_key::MonthKey;
use super::money::Money;
use super::store_code::StoreCode;
use super::ticket_type::TicketType;
/// ★ 保修期(月)。
///
/// 为什么是 24:珠宝的镶嵌牢固度与电镀层保修通常给两年。
///
/// ⚠️ 这个常量必须被「真的走到」:本工程的演示数据里
/// 既要有**在保**的维修单,也要有**过保**的维修单,
/// 否则「保修判定」这个处理器在报表上永远只走一个分支------
/// 那就没法证明它真的在判定。
pub const WARRANTY_MONTHS: i32 = 24;
/// 一张售后工单。
///
/// # 字段说明
/// - `ticket_number`:工单号(**唯一**,写进指纹)。
/// - `store`:受理门店。
/// - `category`:涉及品类。
/// - `ticket_type`:工单类型(决定走哪条判定)。
/// - `involved_amount`:涉及金额(**整数分**,决定走哪级权限)。
/// - `purchase_month`:购买月份。
/// - `accept_month`:受理月份(本工程的「当前月」)。
/// - `has_receipt`:是否附购买凭证。
/// - `is_man_made_damage`:是否人为损坏(**人为损坏不在保修内**)。
#[derive(Clone, Debug)]
pub struct ServiceTicket {
/// 工单号。
ticket_number: String,
/// 受理门店。
store: StoreCode,
/// 涉及品类。
category: CategoryCode,
/// 工单类型。
ticket_type: TicketType,
/// 涉及金额(整数分)。
involved_amount: Money,
/// 购买月份。
purchase_month: MonthKey,
/// 受理月份。
accept_month: MonthKey,
/// 是否附购买凭证。
has_receipt: bool,
/// 是否人为损坏。
is_man_made_damage: bool,
}
impl ServiceTicket {
/// 登记一张工单。
///
/// # 参数
/// - `ticket_number`:工单号(**必须唯一**,重号会让「逐单对照」失去意义)。
/// - `store` / `category` / `ticket_type`:维度。
/// - `involved_amount`:涉及金额。
/// - `purchase_month` / `accept_month`:购买月与受理月。
/// - `has_receipt` / `is_man_made_damage`:两个影响判定的事实。
pub fn new(
ticket_number: &str,
store: StoreCode,
category: CategoryCode,
ticket_type: TicketType,
involved_amount: Money,
purchase_month: MonthKey,
accept_month: MonthKey,
has_receipt: bool,
is_man_made_damage: bool,
) -> Self {
Self {
ticket_number: ticket_number.to_string(),
store,
category,
ticket_type,
involved_amount,
purchase_month,
accept_month,
has_receipt,
is_man_made_damage,
}
}
/// 工单号。
pub fn ticket_number(&self) -> &str {
self.ticket_number.as_str()
}
/// 受理门店。
pub const fn store(&self) -> StoreCode {
self.store
}
/// 涉及品类。
pub const fn category(&self) -> CategoryCode {
self.category
}
/// 工单类型。
pub const fn ticket_type(&self) -> TicketType {
self.ticket_type
}
/// 涉及金额。
pub const fn involved_amount(&self) -> Money {
self.involved_amount
}
/// 购买月份。
pub const fn purchase_month(&self) -> MonthKey {
self.purchase_month
}
/// 受理月份。
pub const fn accept_month(&self) -> MonthKey {
self.accept_month
}
/// 是否附购买凭证。
pub const fn has_receipt(&self) -> bool {
self.has_receipt
}
/// 是否人为损坏。
pub const fn is_man_made_damage(&self) -> bool {
self.is_man_made_damage
}
/// ★ 自购买月到受理月过了几个月(**含 0**:当月购买当月受理 = 0)。
pub const fn months_elapsed(&self) -> i32 {
self.accept_month.months_since(&self.purchase_month)
}
/// ★ 是否还在退换窗口内。
///
/// 三个条件**同时**成立才算:
/// ① 这类工单**适用**退换窗口(维修/投诉不适用);
/// ② 有购买凭证;
/// ③ 月数 `<=` 窗口。
///
/// ⚠️ 条件①不能省:投诉**没有凭证**也要受理,
/// 若只看 ②,所有无凭证投诉都会被误拒------
/// 而「投诉被拒」在报表上长得跟「退货无凭证被拒」一模一样。
pub const fn is_within_return_window(&self) -> bool {
self.ticket_type.has_return_window()
&& self.has_receipt
&& self.months_elapsed() <= self.ticket_type.return_window_months() as i32
}
/// ★ 是否还在保修期内。
///
/// 三个条件:① 这类工单走保修判定;② **非**人为损坏;③ 月数 `<=` 保修月数。
pub const fn is_within_warranty(&self) -> bool {
self.ticket_type.needs_warranty()
&& !self.is_man_made_damage
&& self.months_elapsed() <= WARRANTY_MONTHS
}
/// ★ 是否**连受理资格都没有**(无凭证 / 超窗口)。
///
/// 这是「单据校验」处理器的判据,放在这里是为了让
/// **同一条判据**在工单清单表与处置结果表里给出同一个答案。
pub const fn is_inadmissible(&self) -> bool {
(self.ticket_type.needs_receipt() && !self.has_receipt)
|| (self.ticket_type.has_return_window() && !self.is_within_return_window())
}
/// ★ 拒单原因(**只在 [`ServiceTicket::is_inadmissible`] 为真时才有意义**)。
///
/// ⚠️ 为什么不用 `Option<String>`:
/// 报表要的是「一张表里每格都有字」,`None` 会逼着表示层再写一个「---」,
/// 那时「---」有两处来源,改一处漏一处就分裂了。
pub fn inadmissible_reason_text(&self) -> String {
if self.ticket_type.needs_receipt() && !self.has_receipt {
return format!("{} 需凭购买凭证受理(本单无凭证)", self.ticket_type.chinese_name());
}
if self.ticket_type.has_return_window() && !self.is_within_return_window() {
return format!(
"{} 窗口 {} 个月,本单已过 {} 个月(凭证:{})",
self.ticket_type.chinese_name(),
self.ticket_type.return_window_months(),
self.months_elapsed(),
if self.has_receipt { "有" } else { "无" }
);
}
"---".to_string()
}
/// ★ 指纹:一张工单的**身份**。
///
/// 格式:`工单号|门店|品类|类型|金额分|购买月|受理月|凭证|人为`
///
/// 用途:幕九的「两条链逐单对照」比对的是指纹而不是金额,
/// 这样「处置者变了」与「金额变了」是两件可分开看的事。
pub fn fingerprint_text(&self) -> String {
format!(
"{}|{}|{}|{}|{}|{}|{}|{}|{}",
self.ticket_number,
self.store.code(),
self.category.code(),
self.ticket_type.code(),
self.involved_amount.minor_units(),
self.purchase_month.machine_text(),
self.accept_month.machine_text(),
if self.has_receipt { "R" } else { "N" },
if self.is_man_made_damage { "M" } else { "W" }
)
}
}
//!# 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 : store_code.rs
//! 门店编码:开放型标签。
//!
//! ## 为什么是 `const fn new` 结构体而不是 `enum`
//!
//! 判据只有一条:**这个维度会不会不断增加**。
//! 门店会(开新店就多一个),所以必须开放------
//! 否则工程外新增一家店要回来改领域层文件,
//! 而「可扩展性必须被实证」这条硬纪律要求:**扩展只能发生在装配点**。
//!
//! ## ⚠️ 为什么不实现 `Ord`
//!
//! 本工程从不对门店排序,只按**声明顺序**输出(声明顺序稳定 → 两次运行逐字节一致)。
//! 实现一个用不上的 `Ord`,等于给「按门店排序」这种没被设计过的用法开了口子。
/// 门店编码。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct StoreCode {
/// 机器编码,如 `"STORE-CN-01"`。
code: &'static str,
/// 中文名称,如 `"广州天河店"`。
chinese_name: &'static str,
}
impl StoreCode {
/// 声明一家门店。
pub const fn new(code: &'static str, chinese_name: &'static str) -> Self {
Self { code, chinese_name }
}
/// 机器编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn chinese_name(&self) -> &'static str {
self.chinese_name
}
}
/// 广州天河店(内置)。
pub const STORE_CN_01: StoreCode = StoreCode::new("STORE-CN-01", "广州天河店");
/// 香港铜锣湾店(内置)。
pub const STORE_HK_01: StoreCode = StoreCode::new("STORE-HK-01", "香港铜锣湾店");
/// 内置门店全集。
///
/// ⚠️ 这张表只用于**装配**(建图时遍历),不用于「校验某店是否合法」------
/// 那会把工程外新增门店挡在门外。
pub const BUILTIN_STORE_CODES: [StoreCode; 2] = [STORE_CN_01, STORE_HK_01];
//!# 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_resolution.rs
//! 处置结论:一张工单被了结之后**留下了什么**。
//!
//! ## ★ 为什么结论里必须带上「谁处置的」
//!
//! 责任链最容易被误用成「反正有人处理了」。
//! 若结论里没有处理者名字,那么「门店处置」与「总部处置」
//! 在报表上都只是一行「已处置」------
//! 而**恰恰是「哪一级处置的」**才是这个模式要说的事
//! (把 80% 的单拦在门店,总部才能只处理真正的大单)。
//!
//! 因此 `handler_name` 是**必填**,不是可选。
use super::disposition::Disposition;
use super::money::Money;
/// 一张工单的处置结论。
///
/// # 字段说明
/// - `disposition`:结论类型。
/// - `handler_name`:★ 处置者(处理器名;无人处置时为「---」)。
/// - `compensation`:★ 公司承担的赔付金额(整数分;拒单/保修为 0)。
/// - `note`:一句话说明(**必须能解释为什么是这个结论**)。
#[derive(Clone, Debug)]
pub struct TicketResolution {
/// 结论类型。
disposition: Disposition,
/// 处置者名。
handler_name: String,
/// 赔付金额。
compensation: Money,
/// 说明。
note: String,
}
impl TicketResolution {
/// 产生一条结论。
///
/// # 参数
/// - `disposition`:结论类型。
/// - `handler_name`:处置者名(**不允许空串**,空串会让报表出现空单元格)。
/// - `compensation`:赔付金额。
/// - `note`:说明。
pub fn new(
disposition: Disposition,
handler_name: &str,
compensation: Money,
note: &str,
) -> Self {
assert!(
!handler_name.is_empty(),
"处置结论必须写明处置者:否则「谁处理了这张单」就不可知"
);
Self {
disposition,
handler_name: handler_name.to_string(),
compensation,
note: note.to_string(),
}
}
/// 结论类型。
pub const fn disposition(&self) -> Disposition {
self.disposition
}
/// 处置者名。
pub fn handler_name(&self) -> &str {
self.handler_name.as_str()
}
/// 赔付金额。
pub const fn compensation(&self) -> Money {
self.compensation
}
/// 说明。
pub fn note(&self) -> &str {
self.note.as_str()
}
/// 结论中文名(转发 [`Disposition::chinese_text`])。
pub const fn disposition_text(&self) -> &'static str {
self.disposition.chinese_text()
}
/// ★ 指纹:只比**可核对**的三件事。
///
/// ⚠️ `note` **不进指纹**:说明是给人看的,措辞改了不该让恒等检查失败------
/// 否则「改进了一句文案」会被报表报成「两条路径结果不一致」。
pub fn fingerprint_text(&self) -> String {
format!(
"{}|{}|{}",
self.disposition.machine_text(),
self.handler_name,
self.compensation.minor_units()
)
}
}
//!# 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_type.rs
//! 工单类型:一张售后工单「是什么事」。
//!
//! ## 为什么是开放型标签(`const fn new`)而不是 `enum`
//!
//! 判据是「会不会被当作查找键 + 会不会不断增加」:
//!
//! - 工单类型**会**不断增加(今天五种,明天加「以旧换新争议」是很自然的事);
//! - 但**不会**被当作查找键(没有 `HashMap<TicketType, _>`,只有线性查找);
//! - 因此按既有工程的判据:`const fn new` 结构体 + 自带业务参数,
//! **不实现 `PartialEq` / `Ord` / `Hash`**(不实现的本身就是防错:
//! 用不上的 trait 一旦实现,将来就有人说「反正有 `Ord`,排个序吧」)。
//!
//! ⚠️ 注意:本工程**确实**需要「两张工单的类型是不是同一种」这个判断,
//! 但那是**编码相等**,用 `code() == other.code()` 表达(与 `MonthKey::is_same` 同理------
//! `PartialEq` 的 `==` 不是 `const fn`,而 `const fn` 上下文里也要比)。
/// 一张售后工单的类型。
///
/// # 字段说明
/// - `code`:机读编码(**稳定**、不随中文名变化,写进指纹)。
/// - `chinese_name`:中文名(给人看)。
/// - `return_window_months`:★ 退换窗口(自购买月起算,含购买当月)。
/// `0` 表示**不适用**(维修、投诉不看退换窗口)。
/// - `needs_receipt`:★ 是否**必须**有购买凭证才能受理。
/// - `needs_warranty`:★ 是否走**保修**判定(不看金额,只看保修期与是否人为损坏)。
#[derive(Clone, Copy, Debug)]
pub struct TicketType {
/// 机读编码。
code: &'static str,
/// 中文名。
chinese_name: &'static str,
/// 退换窗口(月)。`0` = 不适用。
return_window_months: u32,
/// 是否必须有购买凭证。
needs_receipt: bool,
/// 是否走保修判定。
needs_warranty: bool,
}
impl TicketType {
/// 声明一种工单类型。
///
/// # 参数
/// - `code`:机读编码(**必须是 ASCII**,否则指纹宽度会算错)。
/// - `chinese_name`:中文名。
/// - `return_window_months`:退换窗口(月),`0` 表示不适用。
/// - `needs_receipt`:是否必须有凭证。
/// - `needs_warranty`:是否走保修判定。
pub const fn new(
code: &'static str,
chinese_name: &'static str,
return_window_months: u32,
needs_receipt: bool,
needs_warranty: bool,
) -> Self {
Self {
code,
chinese_name,
return_window_months,
needs_receipt,
needs_warranty,
}
}
/// 机读编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名。
pub const fn chinese_name(&self) -> &'static str {
self.chinese_name
}
/// 退换窗口(月)。`0` 表示不适用。
pub const fn return_window_months(&self) -> u32 {
self.return_window_months
}
/// 是否必须有购买凭证。
pub const fn needs_receipt(&self) -> bool {
self.needs_receipt
}
/// 是否走保修判定。
pub const fn needs_warranty(&self) -> bool {
self.needs_warranty
}
/// ★ 是否**适用**退换窗口(窗口 > 0 才适用)。
///
/// ⚠️ 为什么不直接用 `return_window_months() > 0`:
/// 「不适用」是一个**业务判断**,写进字段语义里,
/// 调用方就不必各自去记「0 是什么意思」。
pub const fn has_return_window(&self) -> bool {
self.return_window_months > 0
}
/// 编码是否相同(**刻意不实现 `PartialEq`**,理由见模块文档)。
pub const fn is_same(&self, other: &TicketType) -> bool {
// `const fn` 里不能调用 `str::eq`(不是 const fn),
// 因此逐字节比------编码都是 ASCII 短串,成本可忽略。
let left: &[u8] = self.code.as_bytes();
let right: &[u8] = other.code.as_bytes();
if left.len() != right.len() {
return false;
}
let mut index: usize = 0;
while index < left.len() {
if left[index] != right[index] {
return false;
}
index += 1;
}
true
}
}
/// 退货:必须有凭证,退换窗口 1 个月。
pub const TICKET_RETURN: TicketType = TicketType::new("RETURN", "退货退款", 1, true, false);
/// 换货:必须有凭证,退换窗口 3 个月。
pub const TICKET_EXCHANGE: TicketType = TicketType::new("EXCHANGE", "换货", 3, true, false);
/// 维修:**不需要**凭证,走保修判定(不看退换窗口)。
pub const TICKET_REPAIR: TicketType = TicketType::new("REPAIR", "维修", 0, false, true);
/// 成色复检:不需要凭证,走保修判定。
pub const TICKET_RECHECK: TicketType = TicketType::new("RECHECK", "成色复检", 0, false, true);
/// 投诉:**不需要**凭证,也**不走**保修(纯服务态度类,按金额走权限审批)。
pub const TICKET_COMPLAINT: TicketType = TicketType::new("COMPLAINT", "服务投诉", 0, false, false);
/// 内置工单类型清单。
///
/// ⚠️ 顺序 = 声明顺序(报表按这个顺序输出,不用排序,避免引入 `Ord`)。
pub const BUILTIN_TICKET_TYPES: [TicketType; 5] = [
TICKET_RETURN,
TICKET_EXCHANGE,
TICKET_REPAIR,
TICKET_RECHECK,
TICKET_COMPLAINT,
];
//!# 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 : calendar_date.rs
//! ★ Sakamoto 算法求星期 + 逐日推进 + 工作日推进。
//!
//! ## ⚠️ 既有工程的一条硬教训(本文件照做)
//!
//! **`(26 * (m + 1)) / 10` 不能代替月份偏移表**。它只是 `floor(2.6 * (m + 1))`
//! 的近似,与偏移表 `[0,3,2,5,0,3,5,1,4,6,2,4]` 在 3 月、9 月等多个月份不等,
//! 会导致**整年错一位**。既有工程因此吃过亏。
//! **有公认表格的算法,宁可把表写出来,也不要用「看起来等价」的算式。**
//!
//! ## 为什么「推进」用逐日循环而不是儒略日公式
//!
//! 逐日推进显然正确(只需处理「月末进位」「年末进位」「闰年 2 月」三件事);
//! 儒略日公式是 O(1),但多一个可能写错的公式。
//! 本工程的日期跨度都在一年以内,调用次数是个位数量级,性能无关紧要。
//! **当「明显正确的笨办法」与「可能写错的聪明办法」成本相当时,选笨办法。**
/// ★ 月份偏移量表(Sakamoto 算法)。
///
/// 下标 = 月份 − 1。⚠️ **不要**把它化简成 `(26 * (m + 1)) / 10` 之类,
/// 二者在 3 月、9 月不等价(见模块文档)。
const SAKAMOTO_MONTH_OFFSETS: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
/// 中文星期名,下标与 [`CalendarDate::weekday_index`] 一致:`0 = 周日`。
const WEEKDAY_CHINESE_NAMES: [&str; 7] = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
/// 一个公历日期(年 / 月 / 日,三个整数)。
///
/// 刻意**不派生** `Default`:一个「默认日期」在业务上没有意义,
/// 能让调用方写出 `CalendarDate::default()` 只会掩盖「忘了传日期」这类错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CalendarDate {
/// 公历年(如 2026)。用 `i32` 是因为要参与 `year - 1` 的中间运算。
year: i32,
/// 公历月,取值 `1..=12`。
month: u32,
/// 公历日,取值 `1..=31`(具体上界随年月变化)。
day: u32,
}
impl CalendarDate {
/// 构造。**故意不校验**------校验在 [`Self::is_valid`],
/// 因为「构造一个待校验的日期」本身是合法需求(本工程用它做反例)。
pub const fn new(year: i32, month: u32, day: u32) -> Self {
CalendarDate { year, month, day }
}
/// 年份。
pub const fn year(&self) -> i32 {
self.year
}
/// 月份。
pub const fn month(&self) -> u32 {
self.month
}
/// 日。
pub const fn day(&self) -> u32 {
self.day
}
/// 判断年月日组合是否落在合法范围内。
pub fn is_valid(&self) -> bool {
if self.month < 1 || self.month > 12 {
return false;
}
if self.day < 1 {
return false;
}
self.day <= days_in_month(self.year, self.month)
}
/// 返回**星期索引**:`0 = 周日, 1 = 周一, ... 6 = 周六`(Sakamoto 算法)。
///
/// ⚠️ 最后用 `rem_euclid(7)` 而不是 `% 7`:
/// `%` 对负数会得到负数(Rust 与 C 一致),当作索引用会在公元前的年份上
/// 直接 panic。虽然本工程用不到那些年份,但 `rem_euclid` 是正确的写法,
/// **不依赖调用方的取值范围假设**。
pub fn weekday_index(&self) -> usize {
let month: i32 = self.month as i32;
let day: i32 = self.day as i32;
// ★ 1 月 / 2 月必须借上一年的年份------这一步不可省略。
let adjusted_year: i32 = if month < 3 { self.year - 1 } else { self.year };
// 月份偏移量查表(★ 不化简成算式)。
let month_offset: i32 = SAKAMOTO_MONTH_OFFSETS[(self.month - 1) as usize];
let raw_index: i32 = adjusted_year
+ adjusted_year / 4
- adjusted_year / 100
+ adjusted_year / 400
+ month_offset
+ day;
raw_index.rem_euclid(7) as usize
}
/// 中文星期名(如「周三」)。
pub fn weekday_chinese_text(&self) -> &'static str {
WEEKDAY_CHINESE_NAMES[self.weekday_index()]
}
/// 是否周末(周六或周日)。
///
/// ⚠️ 写成 `index == 0 || index == 6` 而不是 `index >= 5`:
/// 索引 0 是**周日**,用 `>= 5` 会把周五也算成周末。
pub fn is_weekend(&self) -> bool {
let index: usize = self.weekday_index();
index == 0 || index == 6
}
/// 机器格式:`2026-10-07`(定长 10 列,适合进表格)。
///
/// 定长是本函数存在的理由:`{}-{}-{}` 直接拼会让 2026-1-7 变成 8 列,
/// 与 `2026-10-07`(10 列)宽度不同,表格立刻歪掉。
pub fn machine_text(&self) -> String {
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
/// 中文格式:`2026年10月7日`(**不定长**,仅用于正文说明,不进定宽列)。
pub fn chinese_text(&self) -> String {
format!("{}年{}月{}日", self.year, self.month, self.day)
}
/// 中文格式 + 星期:`2026年10月7日(周三)`。
pub fn chinese_text_with_weekday(&self) -> String {
format!("{}({})", self.chinese_text(), self.weekday_chinese_text())
}
/// 返回当天的前一天 / 后一天(由 `step` 决定,取值 `+1` 或 `-1`)。
fn step_one_day(&self, step: i32) -> CalendarDate {
let mut year: i32 = self.year;
let mut month: u32 = self.month;
let mut day: i32 = self.day as i32 + step;
if day < 1 {
// 向前借一个月。
month -= 1;
if month < 1 {
month = 12;
year -= 1;
}
day = days_in_month(year, month) as i32;
} else if day > days_in_month(year, month) as i32 {
// 向后进一个月。
day = 1;
month += 1;
if month > 12 {
month = 1;
year += 1;
}
}
CalendarDate::new(year, month, day as u32)
}
/// 加 / 减若干**自然日**。
///
/// ⚠️ 循环带硬上限:算错的日期会变成**卡住的程序**,
/// 那是比「算错」更糟的失败形态(既有工程的教训)。
pub fn add_days(&self, delta: i32) -> CalendarDate {
let mut current: CalendarDate = *self;
let step: i32 = if delta >= 0 { 1 } else { -1 };
let mut remaining: i32 = delta.abs();
let mut guard: i32 = 0;
while remaining > 0 && guard < 100_000 {
current = current.step_one_day(step);
remaining -= 1;
guard += 1;
}
current
}
/// 推进若干**工作日**(跳过周末)。
///
/// ★ **起点当天不计入**:`add_working_days(1)` 得到「下一个工作日」,
/// 这正是「受理日的次日开工」的语义。
///
/// ⚠️ 因此「工期 N 个工作日的完成日」要写成 `add_working_days(N - 1)`------
/// 那个 `− 1` 不是笔误,是**把起点那天收回来**。这两个 `±1` 必须成对理解。
pub fn add_working_days(&self, delta: i32) -> CalendarDate {
let mut current: CalendarDate = *self;
let step: i32 = if delta >= 0 { 1 } else { -1 };
let mut remaining: i32 = delta.abs();
let mut guard: i32 = 0;
while remaining > 0 && guard < 100_000 {
current = current.step_one_day(step);
if !current.is_weekend() {
remaining -= 1;
}
guard += 1;
}
current
}
}
/// 是否闰年。
///
/// ★ 三条规则,**第三条最容易被漏**:
/// ① 能被 4 整除;② 不能被 100 整除;③ **除非**能被 400 整除。
/// 因此校验时必须用 **2100 年**做探针(2000 年是闰年,发现不了漏了第三条)。
pub const fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
/// 某年某月的天数。
pub const fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if is_leap_year(year) {
29
} else {
28
}
}
// 非法月份返回 0:让 `is_valid` 判 false,而不是 panic。
_ => 0,
}
}
/// 两个日期之间的天数差(`to - from`,可为负)。
///
/// ⚠️ 同样带上界,理由同 [`CalendarDate::add_days`]。
pub fn days_between(from: CalendarDate, to: CalendarDate) -> i64 {
let mut count: i64 = 0;
let mut current: CalendarDate = from;
let mut guard: i64 = 0;
if from <= to {
while current < to && guard < 100_000 {
current = current.step_one_day(1);
count += 1;
guard += 1;
}
} else {
while current > to && guard < 100_000 {
current = current.step_one_day(-1);
count -= 1;
guard += 1;
}
}
count
}
/// 把日期渲染成**表格单元格**文本。
///
/// ⚠️ 刻意走 [`CalendarDate::machine_text`](定长 10 列)而不是中文格式:
/// 中文格式不定长(`2026年10月7日` 与 `2026年1月7日` 宽度不同),
/// 进表格会歪。中文格式只在注解里出现。
pub fn report_cell_text(date: CalendarDate) -> String {
date.machine_text()
}
//!# 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 : deterministic_code.rs
//! 确定性编码:FNV-1a 64 位 + base36 定长输出。
//!
//! ## 为什么不用 `DefaultHasher`
//!
//! `std::collections::hash_map::DefaultHasher` 的值**跨 Rust 版本不保证稳定**,
//! 而本工程的验收线是「两次运行逐字节一致」------跨版本不稳定意味着
//! 换一次工具链,输出的编号就全变了。
//! 更实际的问题是:**读者无法复算**。自己实现 FNV-1a 后,
//! 编号的生成规则写在这 80 行里,任何人都能手算验一遍。
//!
//! ## 为什么要段间分隔符
//!
//! 把 `["AB", "C"]` 与 `["A", "BC"]` 直接拼起来都是 `"ABC"`,会**撞哈希**。
//! 段间插入 `0x1f`(ASCII Unit Separator)之后两者不同。
//! 这是既有工程的实测教训:**拼接型哈希必须有分隔符**。
/// FNV-1a 64 位偏移基址。
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
/// FNV-1a 64 位质数。
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
/// 段间分隔符:ASCII Unit Separator(**选它的理由就是它几乎不会出现在文本里**)。
const SEGMENT_SEPARATOR: u8 = 0x1f;
/// 把若干段拼成一个**稳定的 64 位哈希**。
///
/// 段与段之间插入 [`SEGMENT_SEPARATOR`],防止 `["AB","C"]` 与 `["A","BC"]` 碰撞。
pub fn stable_hash(parts: &[&str]) -> u64 {
let mut hash: u64 = FNV_OFFSET_BASIS;
for (index, part) in parts.iter().enumerate() {
// 除第一段外,先喂一个分隔符。
if index > 0 {
hash ^= u64::from(SEGMENT_SEPARATOR);
hash = hash.wrapping_mul(FNV_PRIME);
}
for byte in part.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
}
hash
}
/// base36 字符集(数字 + 大写字母)。
const BASE36_ALPHABET: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
/// 由若干段生成**定长**的 base36 编码。
///
/// 定长是刻意的:编号要进定宽表格列,长度不定会把列顶歪。
///
/// ⚠️ `width` 取值 `1..=13`:64 位无符号数在 base36 下最多 13 位,
/// 超过 13 位高位全是 `0`,写出来是**假精度**(看起来信息量大,其实都是零)。
pub fn deterministic_code(parts: &[&str], width: usize) -> String {
debug_assert!(width >= 1 && width <= 13, "base36 最多 13 位,width={} 会产出假精度", width);
let mut value: u64 = stable_hash(parts);
let mut buffer: Vec<char> = Vec::with_capacity(width);
for _ in 0..width {
let digit: usize = (value % 36) as usize;
buffer.push(BASE36_ALPHABET[digit] as char);
value /= 36;
}
buffer.iter().rev().collect()
}
/// 带前缀的确定性编码:`前缀-XXXXXX`。
///
/// 前缀让编号**可人读**(`CARD-` / `TPL-` / `FIX-`),
/// 后面那段保证唯一且可复算。
pub fn prefixed_code(prefix: &str, parts: &[&str], width: usize) -> String {
format!("{}-{}", prefix, deterministic_code(parts, 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 : text_layout.rs
//! 显示宽度与版面工具:**中文占 2 列**这一事实的全部实现都在这里。
//!
//! ## 为什么不能直接用 `{:<N}` 或 `str::len()`
//!
//! `{:<N}` 按**字符数**填充,而终端里一个汉字占 2 个显示列。
//! 于是「工序段」(3 字 = 6 列) 与 `STAGE`(5 列) 在同一格里会错开 1 列,
//! 整张表立刻歪掉------**而且歪得很整齐**,看不出来是错的。
//! `str::len()` 是**字节数**,中文是 3 字节,更离谱。
//!
//! ## 宽度模型:按 `east_asian_width` 的 `W` / `F` 判 2 列
//!
//! 这是 Unicode 的标准属性。⚠️ 但要注意两类**看起来像宽字符其实不是**的:
//! - `─`(U+2500,制表符):属性是 `A`(歧义),本工程按 **1 列**计;
//! - `...`(U+2026,省略号):属性是 `A`,本工程按 **1 列**计。
//!
//! ⚠️ 既有工程踩过的坑:曾凭印象写「U+2500 落在 CJK 区间 `U+2E80..=0x303E` 里」,
//! 而 **U+2500 = 9472 < 0x2E80 = 11904**,根本没落进去。
//! 结论:**有公认表格的判断,宁可让机器算一遍**------所以本文件有
//! [`self_check_width_model`] 这条永久自检。
/// 判断一个字符是否占 **2 个显示列**。
///
/// 区间表按 Unicode `East_Asian_Width` 的 `W` / `F` 摘取,
/// 各区间**互不重叠**(写重叠区间不会有错,但会让读者以为有优先级)。
pub const fn is_wide_character(character: char) -> bool {
let code: u32 = character as u32;
matches!(code,
// CJK 统一汉字及扩展(不含后面单独列出的兼容区)
0x1100..=0x115F // 谚文首字母
| 0x2E80..=0x303E // CJK 部首、康熙部首、CJK 符号与标点
| 0x3041..=0x33FF // 平假名、片假名、注音、谚文兼容、CJK 兼容
| 0x3400..=0x4DBF // CJK 扩展 A
| 0x4E00..=0x9FFF // CJK 基本区(最常用的汉字都在这里)
| 0xA000..=0xA4CF // 彝文
| 0xAC00..=0xD7A3 // 谚文音节
| 0xF900..=0xFAFF // CJK 兼容汉字
| 0xFE10..=0xFE19 // 竖排标点
| 0xFE30..=0xFE6F // CJK 兼容形式
| 0xFF00..=0xFF60 // 全角 ASCII、全角标点
| 0xFFE0..=0xFFE6 // 全角符号
| 0x1F300..=0x1F64F // 表情符号(常用段)
| 0x1F900..=0x1F9FF // 补充表情符号
| 0x20000..=0x2FFFD // CJK 扩展 B..F
| 0x30000..=0x3FFFD // CJK 扩展 G
)
}
/// 计算一个字符串的**显示宽度**(中文 2 列、英文 1 列)。
pub fn display_width(text: &str) -> usize {
text.chars().map(|character| if is_wide_character(character) { 2 } else { 1 }).sum()
}
/// 右填充(左对齐):不足补空格,**超出不截断**。
///
/// 不截断是刻意的:调用方要截断请显式调 [`truncate_to_width`],
/// 让「这里可能被截」这件事在代码里看得见。
pub fn pad_right(text: &str, width: usize) -> String {
let used: usize = display_width(text);
if used >= width {
return text.to_string();
}
format!("{}{}", text, " ".repeat(width - used))
}
/// 左填充(右对齐):数字列用这个。
pub fn pad_left(text: &str, width: usize) -> String {
let used: usize = display_width(text);
if used >= width {
return text.to_string();
}
format!("{}{}", " ".repeat(width - used), text)
}
/// 居中填充:**左侧少、右侧多**(既有多余的一列给右边)。
///
/// 为什么必须定死「奇数给右边」:不定死时,同一个表头在不同行
/// 可能一个偏左一个偏右,看起来像内容变了。
pub fn pad_center(text: &str, width: usize) -> String {
let used: usize = display_width(text);
if used >= width {
return text.to_string();
}
let total: usize = width - used;
let left: usize = total / 2;
let right: usize = total - left;
format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
}
/// 按显示宽度截断,**不劈开半个汉字**。
///
/// ⚠️ 返回值宽度可能在 `width - 1 ..= width` 之间浮动:
/// 放不下一个 2 列宽的汉字时,它会少补 1 列。
/// 因此**任何「必须恰好等于 width」的断言都是错的**。
pub fn truncate_to_width(text: &str, width: usize) -> String {
let mut used: usize = 0;
let mut result: String = String::new();
for character in text.chars() {
let character_width: usize = if is_wide_character(character) { 2 } else { 1 };
if used + character_width > width {
break;
}
result.push(character);
used += character_width;
}
result
}
/// 可见省略:放不下时截断并追加 `...`,**让读者知道这里被省了**。
///
/// ⚠️ 必须先给 `...` **留 1 列**:写成 `truncate(text, width) + "..."`
/// 会让结果变成 `width + 1` 列,把整行顶歪。
pub fn elide_text(text: &str, width: usize) -> String {
if display_width(text) <= width {
return text.to_string();
}
// width 为 0 时无法放省略号,退化成空串(不 panic:这是排版,不是逻辑错误)。
if width == 0 {
return String::new();
}
let keep: usize = width - 1;
format!("{}...", truncate_to_width(text, keep))
}
/// 生成一条指定宽度的分隔线。
///
/// ⚠️ 拼 `width` 个 `─`,**不是** `width / 2` 个。
/// `─`(U+2500) 按 1 列计(见模块文档),写 `width / 2` 会得到一半长的线。
pub fn horizontal_rule(width: usize) -> String {
"─".repeat(width)
}
/// ★ 宽度模型的**永久自检**:返回 `(探针名, 是否通过, 实测说明)`。
///
/// 为什么要有它:宽度常量写错时,报表**看起来完全正常**(只是分隔线短一截、
/// 某一列窄一点),肉眼几乎不可能发现。自检把「看起来正常」变成「有数字对不上」。
///
/// 探针的选择:`0 / 1 / 2` 覆盖边界;`17 / 92 / 153` 覆盖奇偶与较大值。
pub fn self_check_width_model() -> Vec<(String, bool, String)> {
let mut probes: Vec<(String, bool, String)> = Vec::new();
// 探针一:`horizontal_rule(n)` 的宽度必须**恰好**是 n(制表符按 1 列计)。
for width in [0usize, 1, 2, 17, 92, 153] {
let rule: String = horizontal_rule(width);
let actual: usize = display_width(&rule);
probes.push((
format!("分隔线宽度 {}", width),
actual == width,
format!("期望 {} 列,实测 {} 列", width, actual),
));
}
// 探针二:`elide_text` 的结果**绝不超过**目标宽度(省略号占 1 列)。
for width in [1usize, 2, 17, 92] {
let source: String = "工序段".repeat(60);
let elided: String = elide_text(&source, width);
let actual: usize = display_width(&elided);
probes.push((
format!("省略后宽度 ≤ {}", width),
actual <= width,
format!("原串 {} 列,省略后 {} 列", display_width(&source), actual),
));
}
// 探针三:够宽时 `elide_text` 必须**原样返回**(一个字都不能少)。
let untouched: &str = "镶嵌工艺路线卡";
let pass_through: String = elide_text(untouched, 40);
probes.push((
"够宽时原样返回".to_string(),
pass_through == untouched,
format!("原串 {} 列", display_width(untouched)),
));
probes
}
//!# 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 : chain_audit.rs
//! 责任链的契约审计:**五条不变量 + 每条的已知失败输入**。
//!
//! ## ★ 恒真的检查没有价值
//!
//! 一条规则若对**任何**输入都成立,它就不是规则,只是一句没被跑过的注释。
//! 因此本模块每条规则都必须写出「已知会失败的输入」------
//! 本工程的幕八会把那个输入真的喂进去,确认它会红。
//!
//! ## ★ 为什么观测是**结构体**而不是「从链上直接读」
//!
//! 若观测只能由链产生,那么规则永远只能看到**链自己写出来的**数据,
//! 于是「链的记账写错了」这类问题永远查不出来。
//! 让观测可以**手工组装**,规则才第一次有可能被喂进一个坏样本。
//! (代价是:手工组装的观测也可能是错的------所以每条规则都配了「已知失败输入」。)
//!
//! ## ⚠️ 本层同样零 `crate::` 依赖
//!
//! 观测里的请求标识只是一个字符串标签(`request_label`),
//! 不是任何业务类型------机制层不需要知道「工单号」是什么。
use super::chain_result::ChainResult;
use super::handler_kind::HandlerKind;
/// ★ 一次投递的观测(**可手工组装**,理由见模块文档)。
///
/// # 字段说明
/// - `request_label`:请求标识(只是个标签,机制层不解释它)。
/// - `settled_by`:★ 处置者名单(正常是 0 个或 1 个;**2 个以上就是违规**)。
/// - `settled_kind`:处置者的种类(无人处置时为 `None`)。
/// - `handler_name_label`:★ 报表上写出来的处置者名(无人处置时应是
/// [`super::chain_result::UNHANDLED_MARK`],**不得是空串**)。
/// - `visited_position`:被访问处理器在链上的位置(按序)。
/// - `declared_total`:链上处理器总数。
#[derive(Clone, Debug)]
pub struct RequestObservation {
/// 请求标识。
request_label: String,
/// 报表上写出来的处置者名。
handler_name_label: String,
/// 处置者名单。
settled_by: Vec<String>,
/// 处置者种类。
settled_kind: Option<HandlerKind>,
/// 被访问处理器在链上的位置(按序)。
visited_position: Vec<usize>,
/// 链上处理器总数。
declared_total: usize,
}
impl RequestObservation {
/// ★ 手工组装一条观测(**审计的负向用例靠这个入口**)。
///
/// # 参数
/// - `request_label`:请求标识。
/// - `settled_by`:处置者名单(可为空、可为多个------后者就是违规样本)。
/// - `settled_kind`:处置者种类。
/// - `handler_name_label`:报表上写出来的处置者名(**空串即违规样本**)。
/// - `visited_position`:被访问位置序列(可乱序------后者也是违规样本)。
/// - `declared_total`:链上处理器总数。
pub fn new(
request_label: &str,
settled_by: Vec<String>,
settled_kind: Option<HandlerKind>,
handler_name_label: &str,
visited_position: Vec<usize>,
declared_total: usize,
) -> Self {
Self {
request_label: request_label.to_string(),
handler_name_label: handler_name_label.to_string(),
settled_by,
settled_kind,
visited_position,
declared_total,
}
}
/// 从一次真实投递的结果组装观测。
///
/// # 参数
/// - `request_label`:请求标识(由调用方给,机制层不知道它是什么)。
/// - `result`:链返回的结果。
pub fn from_result<R>(request_label: &str, result: &ChainResult<R>) -> Self {
let settled_by: Vec<String> = match result.result_ref() {
Some(_) => vec![result.handler_name().to_string()],
None => Vec::new(),
};
Self {
request_label: request_label.to_string(),
handler_name_label: result.handler_name().to_string(),
settled_by,
settled_kind: result.handler_kind(),
visited_position: result.visited_position().to_vec(),
declared_total: result.declared_total(),
}
}
/// 请求标识。
pub fn request_label(&self) -> &str {
self.request_label.as_str()
}
/// 是否被处置。
pub fn is_settled(&self) -> bool {
!self.settled_by.is_empty()
}
/// 报表上写出来的处置者名。
pub fn handler_name_label(&self) -> &str {
self.handler_name_label.as_str()
}
/// 处置者名单。
pub fn settled_by(&self) -> &[String] {
self.settled_by.as_slice()
}
/// 处置者种类。
pub const fn settled_kind(&self) -> Option<HandlerKind> {
self.settled_kind
}
/// 被访问位置序列。
pub fn visited_position(&self) -> &[usize] {
self.visited_position.as_slice()
}
/// 链上处理器总数。
pub const fn declared_total(&self) -> usize {
self.declared_total
}
}
/// ★ 责任链的契约规则(**封闭集合**:漏一条 `match` 就编译不过去)。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ChainAuditRule {
/// 每张请求都必须被处置(不允许消失在链尾)。
EveryRequestSettled,
/// 一张请求至多一个处置者。
AtMostOneHandler,
/// 访问顺序必须**不减**(链不能往回走)。
ChainRespectsOrder,
/// ★ 处置者名**不得是空串**(否则报表上就是一个空格子)。
UnhandledReportsNoHandler,
/// 过路处理器(`PassThrough`)不出现在处置者里。
PassThroughNeverSettles,
/// 被访问的位置必须都在链上(不得越界)。
VisitWithinDeclared,
}
impl ChainAuditRule {
/// 规则编码(写进报表)。
pub const fn code(&self) -> &'static str {
match self {
ChainAuditRule::EveryRequestSettled => "SETTLED",
ChainAuditRule::AtMostOneHandler => "SINGLE",
ChainAuditRule::ChainRespectsOrder => "ORDERED",
ChainAuditRule::UnhandledReportsNoHandler => "NO_GHOST",
ChainAuditRule::PassThroughNeverSettles => "NO_PASSTHROUGH",
ChainAuditRule::VisitWithinDeclared => "IN_RANGE",
}
}
/// 规则中文名。
pub const fn chinese_text(&self) -> &'static str {
match self {
ChainAuditRule::EveryRequestSettled => "每张请求都有处置结论",
ChainAuditRule::AtMostOneHandler => "一张请求至多一个处置者",
ChainAuditRule::ChainRespectsOrder => "访问顺序不回头(不能往回走)",
ChainAuditRule::UnhandledReportsNoHandler => "处置者名不得是空串",
ChainAuditRule::PassThroughNeverSettles => "过路处理器不做决定",
ChainAuditRule::VisitWithinDeclared => "被访问的位置都在链上",
}
}
/// ★ 期望文本(写进报表的「期望」列)。
pub const fn expectation_text(&self) -> &'static str {
match self {
ChainAuditRule::EveryRequestSettled => "处置者数 >= 1",
ChainAuditRule::AtMostOneHandler => "处置者数 <= 1",
ChainAuditRule::ChainRespectsOrder => "位置序列单调不减",
ChainAuditRule::UnhandledReportsNoHandler => "处置者名非空(无人接时为专用标记)",
ChainAuditRule::PassThroughNeverSettles => "处置者种类 != 过路者",
ChainAuditRule::VisitWithinDeclared => "所有位置 < 链上处理器数",
}
}
/// ★ 已知会失败的输入(**没有这一条,规则就是一句注释**)。
pub const fn known_failing_input_text(&self) -> &'static str {
match self {
// ⚠️ 措辞里**不许出现业务词**:这一层连「工单」都不认识,
// 它只知道「请求」。写成「超大额工单」的话,
// 换一个业务就要改机制层,机制就不再是机制
// (`scripts/check_layer_redlines.sh` 的 R9 专门扫这件事)。
ChainAuditRule::EveryRequestSettled => {
"把链尾的兜底处理器摘掉,再投一个谁都接不住的请求(无人接手)"
}
ChainAuditRule::AtMostOneHandler => {
"手工造一条观测,把处置者名单写成两个处理器名"
}
ChainAuditRule::ChainRespectsOrder => {
"手工造一条观测,把访问位置写成 [3, 1](先访问了后面的站)"
}
ChainAuditRule::UnhandledReportsNoHandler => {
"手工造一条观测:把处置者名写成空串(报表上就是一个空格子)"
}
ChainAuditRule::PassThroughNeverSettles => {
"手工造一条观测:处置者种类写成过路者,却仍带着处置结论"
}
ChainAuditRule::VisitWithinDeclared => {
"手工造一条观测:访问位置写成 [9](超出链上处理器总数)"
}
}
}
/// 判断一条观测是否合规。
///
/// # 参数
/// - `observation`:待判的观测。
///
/// # 返回
/// `true` = 合规。
fn conforms(&self, observation: &RequestObservation) -> bool {
match self {
ChainAuditRule::EveryRequestSettled => observation.is_settled(),
ChainAuditRule::AtMostOneHandler => observation.settled_by().len() <= 1,
ChainAuditRule::ChainRespectsOrder => {
let mut previous: usize = 0;
for position in observation.visited_position().iter() {
if *position < previous {
return false;
}
previous = *position;
}
true
}
ChainAuditRule::UnhandledReportsNoHandler => {
!observation.handler_name_label().trim().is_empty()
}
ChainAuditRule::PassThroughNeverSettles => match observation.settled_kind() {
Some(HandlerKind::PassThrough) => !observation.is_settled(),
_ => true,
},
ChainAuditRule::VisitWithinDeclared => observation
.visited_position()
.iter()
.all(|position| *position < observation.declared_total()),
}
}
/// 全部规则(**穷尽**,用于报表与自检)。
pub const fn all() -> [ChainAuditRule; 6] {
[
ChainAuditRule::EveryRequestSettled,
ChainAuditRule::AtMostOneHandler,
ChainAuditRule::ChainRespectsOrder,
ChainAuditRule::UnhandledReportsNoHandler,
ChainAuditRule::PassThroughNeverSettles,
ChainAuditRule::VisitWithinDeclared,
]
}
}
/// 一条规则对一批观测的检查结果。
#[derive(Clone, Debug)]
pub struct RuleCheck {
/// 规则。
rule: ChainAuditRule,
/// 适用条数(**为 0 则不算通过**:恒真的检查没有价值)。
applicable_total: usize,
/// 合规条数。
conforming_total: usize,
/// 违规的请求标识。
violating_labels: Vec<String>,
/// 实测文本。
actual_text: String,
}
impl RuleCheck {
/// ★ 是否通过:**适用条数 > 0** 且**全部合规**。
///
/// ⚠️ `applicable_total > 0` 这一半不能省:
/// 一批空观测会让每条规则都「通过」,而报表上看起来像检查过了。
pub fn passed(&self) -> bool {
self.applicable_total > 0 && self.conforming_total == self.applicable_total
}
/// 规则。
pub const fn rule(&self) -> ChainAuditRule {
self.rule
}
/// 适用条数。
pub const fn applicable_total(&self) -> usize {
self.applicable_total
}
/// 合规条数。
pub const fn conforming_total(&self) -> usize {
self.conforming_total
}
/// 违规的请求标识。
pub fn violating_labels(&self) -> &[String] {
self.violating_labels.as_slice()
}
/// 实测文本。
pub fn actual_text(&self) -> &str {
self.actual_text.as_str()
}
}
/// ★ 跑一遍全部规则。
///
/// # 参数
/// - `observations`:观测集合(正常用例或负向用例各跑一次)。
pub fn audit_chain(observations: &[RequestObservation]) -> Vec<RuleCheck> {
ChainAuditRule::all()
.iter()
.map(|rule| {
let mut applicable_total: usize = 0;
let mut conforming_total: usize = 0;
let mut violating_labels: Vec<String> = Vec::new();
for observation in observations.iter() {
applicable_total += 1;
if rule.conforms(observation) {
conforming_total += 1;
} else {
violating_labels.push(observation.request_label().to_string());
}
}
RuleCheck {
rule: *rule,
applicable_total,
conforming_total,
violating_labels,
actual_text: format!("{}/{}", conforming_total, applicable_total),
}
})
.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 : chain_ledger.rs
//! 链台账:**只增不减**的一次性计数。
//!
//! ## ★ 为什么「只增不减」要单独拿出来说
//!
//! 台账的每一个数都是「从开机到现在累计发生了几次」。
//! 一旦提供 `reset` 或 `set`,报表上就会出现一件无法解释的事:
//! 先渲染的那几行与后渲染的那几行**来自不同时刻**,
//! 而读者只会以为「跑这些实验没有副作用」------事实恰好相反
//! (每跑一次实验,访问次数就涨一截)。
//!
//! 因此本层**不提供任何清零方法**(这也是一条红线,见 `scripts/check_layer_redlines.sh`),
//! 报表一律读 [`ChainLedger::snapshot`]。
//!
//! ## 恒等式(可计算,不是原则)
//!
//! ```text
//! 投递数 = 被接下 + 无人接
//! 被接下 = Σ(每个处理器的接下次数)
//! 访问次数 = 被接下 + Σ(每个处理器的放行次数)
//! ```
//!
//! 这三条一旦有一条差一个数,「某条路径漏记了」就变成一个具体的数字,
//! 而不是一句「应该没问题」。
use std::cell::{Cell, RefCell};
/// 一个处理器累计的尝试情况。
///
/// # 字段说明
/// - `name`:处理器名。
/// - `asked_total`:★ `can_handle` 被调用了几次(**被问了几次**)。
/// - `handled_total`:接下了几次。
/// - `passed_total`:放行了几次。
#[derive(Clone, Debug)]
pub struct HandlerAttempt {
/// 处理器名。
name: String,
/// 被问了几次。
asked_total: usize,
/// 接下了几次。
handled_total: usize,
/// 放行了几次。
passed_total: usize,
}
impl HandlerAttempt {
/// 处理器名。
pub fn name(&self) -> &str {
self.name.as_str()
}
/// 被问了几次。
pub const fn asked_total(&self) -> usize {
self.asked_total
}
/// 接下了几次。
pub const fn handled_total(&self) -> usize {
self.handled_total
}
/// 放行了几次。
pub const fn passed_total(&self) -> usize {
self.passed_total
}
/// ★ 是否**一次都没被问过**(即「死站」)。
///
/// 死站有两种可能:① 这一站多余(可以删);
/// ② 前面某一站的判据写宽了,把本该到这一站的单全接走了。
/// 两种情况报表都值得单独列出来------它们都不会自己说话。
pub const fn never_asked(&self) -> bool {
self.asked_total == 0
}
}
/// 链台账(**只增不减**)。
#[derive(Debug)]
pub struct ChainLedger {
/// 累计投递数。
submitted_total: Cell<usize>,
/// 累计被接下数。
handled_total: Cell<usize>,
/// 累计无人接数。
unhandled_total: Cell<usize>,
/// 累计访问处理器次数。
visit_total: Cell<usize>,
/// ★ 累计**跳过**的判定次数(排在处置者之后、一次都没被调用的站数之和)。
skipped_total: Cell<usize>,
/// 每个处理器的尝试情况(**按首次被问的顺序**,线性查找,不用哈希)。
attempts: RefCell<Vec<HandlerAttempt>>,
/// 处置顺序(谁接了第几单的**处理器名**序列)。
settle_order: RefCell<Vec<String>>,
}
impl ChainLedger {
/// 建一本空台账。
pub fn new() -> Self {
Self {
submitted_total: Cell::new(0),
handled_total: Cell::new(0),
unhandled_total: Cell::new(0),
visit_total: Cell::new(0),
skipped_total: Cell::new(0),
attempts: RefCell::new(Vec::new()),
settle_order: RefCell::new(Vec::new()),
}
}
/// 记一次投递。
pub fn record_submission(&self) {
self.submitted_total.set(self.submitted_total.get() + 1);
}
/// ★ 记一次「被问」(`can_handle` 被调用)。
///
/// # 参数
/// - `handler_name`:处理器名(**首次出现时**在这里建档)。
pub fn record_visit(&self, handler_name: &str) {
self.visit_total.set(self.visit_total.get() + 1);
let mut attempts = self.attempts.borrow_mut();
for attempt in attempts.iter_mut() {
if attempt.name == handler_name {
attempt.asked_total += 1;
return;
}
}
attempts.push(HandlerAttempt {
name: handler_name.to_string(),
asked_total: 1,
handled_total: 0,
passed_total: 0,
});
}
/// 记一次接下。
pub fn record_handled(&self, handler_name: &str) {
self.handled_total.set(self.handled_total.get() + 1);
let mut attempts = self.attempts.borrow_mut();
for attempt in attempts.iter_mut() {
if attempt.name == handler_name {
attempt.handled_total += 1;
break;
}
}
self.settle_order.borrow_mut().push(handler_name.to_string());
}
/// 记一次放行。
pub fn record_passed(&self, handler_name: &str) {
let mut attempts = self.attempts.borrow_mut();
for attempt in attempts.iter_mut() {
if attempt.name == handler_name {
attempt.passed_total += 1;
return;
}
}
// ⚠️ 走到这里说明「放行」发生在「被问」之前------记账顺序反了。
// 不静默补一条:那会让「被问次数」凭空少一次,而恒等式正好差 1。
panic!("处理器 {} 还没被问过就记了一次放行(记账顺序反了)", handler_name);
}
/// 记一次无人接手。
pub fn record_unhandled(&self) {
self.unhandled_total.set(self.unhandled_total.get() + 1);
}
/// 记一次跳过(**整段**跳过多少站)。
pub fn record_skipped(&self, skipped: usize) {
self.skipped_total.set(self.skipped_total.get() + skipped);
}
/// ★ 快照(**报表一律读快照**,理由见模块文档)。
pub fn snapshot(&self) -> LedgerSnapshot {
LedgerSnapshot {
submitted_total: self.submitted_total.get(),
handled_total: self.handled_total.get(),
unhandled_total: self.unhandled_total.get(),
visit_total: self.visit_total.get(),
skipped_total: self.skipped_total.get(),
attempts: self.attempts.borrow().clone(),
settle_order: self.settle_order.borrow().clone(),
}
}
/// 实时投递数(**只给自检用**;报表请读快照)。
pub fn submitted_total(&self) -> usize {
self.submitted_total.get()
}
/// 实时被接下数。
pub fn handled_total(&self) -> usize {
self.handled_total.get()
}
/// 实时无人接数。
pub fn unhandled_total(&self) -> usize {
self.unhandled_total.get()
}
/// 实时访问次数。
pub fn visit_total(&self) -> usize {
self.visit_total.get()
}
/// 实时跳过次数。
pub fn skipped_total(&self) -> usize {
self.skipped_total.get()
}
}
impl Default for ChainLedger {
fn default() -> Self {
Self::new()
}
}
/// 台账快照(**不可变**,报表读它)。
#[derive(Clone, Debug)]
pub struct LedgerSnapshot {
/// 投递数。
pub submitted_total: usize,
/// 被接下数。
pub handled_total: usize,
/// 无人接数。
pub unhandled_total: usize,
/// 访问次数。
pub visit_total: usize,
/// ★ 跳过次数(省下的判定次数)。
pub skipped_total: usize,
/// 每个处理器的尝试情况。
pub attempts: Vec<HandlerAttempt>,
/// 处置顺序。
pub settle_order: Vec<String>,
}
impl LedgerSnapshot {
/// ★ 恒等式一:`投递 == 被接下 + 无人接`。
pub fn submission_identity_gap(&self) -> i64 {
self.submitted_total as i64 - (self.handled_total + self.unhandled_total) as i64
}
/// ★ 恒等式二:`被接下 == Σ(每个处理器接下次数)`。
pub fn settlement_identity_gap(&self) -> i64 {
let summed: usize = self.attempts.iter().map(|a| a.handled_total).sum();
self.handled_total as i64 - summed as i64
}
/// ★ 恒等式三:`访问 == 被接下 + Σ(放行次数)`。
pub fn visit_identity_gap(&self) -> i64 {
let passed: usize = self.attempts.iter().map(|a| a.passed_total).sum();
self.visit_total as i64 - (self.handled_total + passed) as i64
}
/// ★ 一次都没被问过的处理器(**死站**)。
pub fn never_asked_handlers(&self) -> Vec<&HandlerAttempt> {
self.attempts.iter().filter(|a| a.never_asked()).collect()
}
/// ★ 被问过但一次都没接下的处理器(**纯过路**)。
///
/// 与死站不同:它确实被问了、也确实做了事,只是从不做决定。
/// 报表要把这两种分开------「删掉它」的结论只对死站成立。
pub fn asked_but_never_handled_handlers(&self) -> Vec<&HandlerAttempt> {
self
.attempts
.iter()
.filter(|a| !a.never_asked() && a.handled_total == 0)
.collect()
}
/// ★ 全量判定次数(**若不用责任链、每个处理器都问一遍**会是多少次)。
///
/// 这是「省下多少」的分母:`投递数 × 处理器数`。
pub fn judgement_total_if_broadcast(&self) -> usize {
self.submitted_total * self.attempts.len()
}
/// ★ 省下的判定次数(全量 − 实际访问)。
pub fn saved_judgement_total(&self) -> i64 {
self.judgement_total_if_broadcast() as i64 - self.visit_total as i64
}
}
//!# 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 : chain_result.rs
//! 一次投递的完整结果:**谁接的、经过了谁、结论是什么**。
//!
//! ## ★ 为什么结果里必须带上「经过了谁」
//!
//! 只给结论(`Option<R>`)的话,责任链与一个巨大的 `if-else` 在外部**完全无法区分**------
//! 两者都只是「给一个输入、得一个输出」。
//!
//! 带上「经过了谁」之后,下面这些话才第一次变得可说:
//! - 「这张单在第 2 站就被拦下了,后面 4 站一次都没被调用」→ **短路的价值**;
//! - 「把第 3 站挪到第 1 站,有 3 张单的处置者变了」→ **顺序是业务知识**;
//! - 「第 5 站一次都没被访问过」→ **这一站是死的**(要么多余,要么前面的判据写宽了)。
use super::handler_kind::HandlerKind;
/// ★ 无人接手时 [`ChainResult::handler_name`] 的取值。
///
/// ⚠️ 为什么不用空串:空串在报表上是一个**空格子**,
/// 读者分不清「没人接」与「忘了填」。
/// 一个明确的标记让「无人处置」在表格上是一个**词**。
pub const UNHANDLED_MARK: &str = "(无人接手)";
/// 一次投递的结果。
///
/// # 字段说明
/// - `result`:结论(`None` = 链上没人接)。
/// - `handler_name`:处置者名(无人接时为 [`UNHANDLED_MARK`])。
/// - `handler_kind`:处置者的种类(无人接时为 `None`)。
/// - `visited`:★ 被访问过的处理器名(**按访问顺序**)。
/// - `visited_position`:★ 被访问处理器在链上的**位置**(与 `visited` 一一对应,
/// 用于检查「链是否按顺序走」)。
/// - `declared_total`:链上处理器总数(用于算「后面几站没被调用」)。
#[derive(Clone, Debug)]
pub struct ChainResult<R> {
/// 结论。
result: Option<R>,
/// 处置者名。
handler_name: String,
/// 处置者种类。
handler_kind: Option<HandlerKind>,
/// 被访问过的处理器名(按序)。
visited: Vec<String>,
/// 被访问处理器在链上的位置(按序,与 `visited` 一一对应)。
visited_position: Vec<usize>,
/// 链上处理器总数。
declared_total: usize,
}
impl<R> ChainResult<R> {
/// 组装一次结果。
///
/// # 参数
/// - `result`:结论(`None` 表示无人接)。
/// - `handler_name`:处置者名(无人接时传 [`UNHANDLED_MARK`])。
/// - `handler_kind`:处置者种类。
/// - `visited` / `visited_position`:访问轨迹(**两者长度必须相等**)。
/// - `declared_total`:链上处理器总数。
///
/// # Panics
/// `visited` 与 `visited_position` 长度不等时 panic------
/// 轨迹对不上说明机制层的记账写岔了,静默继续会让顺序检查永远通过。
pub fn new(
result: Option<R>,
handler_name: &str,
handler_kind: Option<HandlerKind>,
visited: Vec<String>,
visited_position: Vec<usize>,
declared_total: usize,
) -> Self {
assert_eq!(
visited.len(),
visited_position.len(),
"访问轨迹的「名字」与「位置」必须一一对应:前者 {} 个、后者 {} 个",
visited.len(),
visited_position.len()
);
Self {
result,
handler_name: handler_name.to_string(),
handler_kind,
visited,
visited_position,
declared_total,
}
}
/// 是否有人接手。
pub const fn is_handled(&self) -> bool {
self.result.is_some()
}
/// 处置者名(无人接时为 [`UNHANDLED_MARK`])。
pub fn handler_name(&self) -> &str {
self.handler_name.as_str()
}
/// 处置者种类(无人接时为 `None`)。
pub const fn handler_kind(&self) -> Option<HandlerKind> {
self.handler_kind
}
/// 结论(借用)。
pub fn result_ref(&self) -> Option<&R> {
self.result.as_ref()
}
/// 结论(移动)。
pub fn into_result(self) -> Option<R> {
self.result
}
/// 被访问过的处理器名(按序)。
pub fn visited(&self) -> &[String] {
self.visited.as_slice()
}
/// 被访问处理器在链上的位置(按序)。
pub fn visited_position(&self) -> &[usize] {
self.visited_position.as_slice()
}
/// ★ 被访问了几站(= 有几个处理器被调用了 `can_handle`)。
pub fn visited_total(&self) -> usize {
self.visited.len()
}
/// ★ 放行了几次(= 被访问 − 接下了 1 次)。
///
/// 这是「短路」的量度:走了 3 站才处理掉 = 放行了 2 次。
pub fn passed_total(&self) -> usize {
self.visited_total().saturating_sub(if self.is_handled() { 1 } else { 0 })
}
/// ★ 链上**排在处置者之后、因而一次都没被调用**的站数。
///
/// ⚠️ 这才是「责任链省下了什么」的可数形式:
/// 不是省了时间(不可复现),而是**省了 N 次判定**。
pub fn skipped_total(&self) -> usize {
match self.visited_position().last() {
Some(last_position) => self.declared_total.saturating_sub(*last_position + 1),
None => self.declared_total,
}
}
/// 链上处理器总数。
pub const fn declared_total(&self) -> usize {
self.declared_total
}
/// ★ 访问轨迹是否**单调不减**(即确实按链的顺序走)。
///
/// 恒为真的检查没有价值;但这条的**输入**可以是手工造出来的观测
/// (见 [`super::chain_audit`] 的已知失败用例),因此它真的会红。
pub fn is_ordered(&self) -> bool {
let mut previous: usize = 0;
for position in self.visited_position().iter() {
if *position < previous {
return false;
}
previous = *position;
}
true
}
/// 轨迹文本(`登记 → 单据校验 → 门店权限` 形式)。
pub fn visited_text(&self) -> String {
if self.visited().is_empty() {
return "(空链)".to_string();
}
self.visited().join(" → ")
}
}
//!# 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 : chain_tail_policy.rs
//! 链尾策略:**走到最后都没人接,算不算错**。
//!
//! ## ★ 为什么这是一个策略而不是一个 `if`
//!
//! 「没人接」有两种完全正当的立场:
//!
//! - [`ChainTailPolicy::AllowUnhandled`]:有些流程本来就该有单子漏出去
//! (比如「无人认领的咨询」,第二天会有客服再捞一遍);
//! - [`ChainTailPolicy::RequireHandled`]:有些流程**绝不允许**
//! (本工程的售后工单:一张单消失在链尾,客户会一直等)。
//!
//! 若写成一个 `if`,这两种立场就变成代码里的一个布尔量,
//! 而**没人会在改装配时想起它**。
//! 写成一个有名字、有中文说明的枚举,它才能在报表上被列出来、
//! 才能被审计规则盯住。
//!
//! ⚠️ 唯一不可接受的是「不知道自己用的是哪一种」。
/// 链尾策略。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ChainTailPolicy {
/// 允许无人接手(结论为 `None`,由调用方决定怎么办)。
AllowUnhandled,
/// ★ 必须有人接手:走到链尾还没人接就 **panic**。
///
/// 为什么是 panic 而不是返回错误:`RequireHandled` 的语义是
/// 「这件事**在装配上**不该发生」------
/// 缺了兜底处理器是装配错误,不是业务分支。
/// 返回 `Result` 会逼着每个调用点写 `?`,
/// 而真正该做的是**把兜底处理器装上去**。
RequireHandled,
}
impl ChainTailPolicy {
/// 中文名。
pub const fn chinese_text(&self) -> &'static str {
match self {
ChainTailPolicy::AllowUnhandled => "允许无人接手",
ChainTailPolicy::RequireHandled => "必须有人接手(否则视为装配错误)",
}
}
/// 机读编码。
pub const fn machine_text(&self) -> &'static str {
match self {
ChainTailPolicy::AllowUnhandled => "ALLOW_UNHANDLED",
ChainTailPolicy::RequireHandled => "REQUIRE_HANDLED",
}
}
}
//!# 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 : handler.rs
//! 处理器契约:链上的一个环节必须会做的三件事。
//!
//! ## ★ 为什么分成 `can_handle` 与 `handle` 两步
//!
//! 只写一个 `handle(&self, q) -> Handling<R>` 也能跑(接不了就返回 `Pass`)。
//! 但那样**「能不能接」这件事就没有任何一个地方能说清**:
//! 报表想知道「这单为什么给它而不是给下一个」,
//! 只能去读处理器的代码。
//!
//! 拆成两步之后:
//! - `can_handle` 是**判据**(纯问题,不该有副作用);
//! - `handle` 是**动作**(只在 `can_handle` 为真时被调用);
//! - 台账能分别记「问了几次」与「接了几次」,
//! 于是「判据写宽了导致某级接走了本不该接的单」变成一个可算的数。
//!
//! ## ★ 为什么没有 `set_next`
//!
//! 教科书里常见的写法是 `handler.set_next(next)`------**处理器持有下一个处理器**。
//! 那样的话:
//! - 链的形状散落在各个处理器里,没有一个地方能列出「这条链长什么样」;
//! - 换序要改每个处理器的装配代码;
//! - 「第 3 站被跳过了」无法被观测(跳过是处理器内部的事)。
//!
//! 本工程把**顺序收进链对象**([`super::handler_chain::HandlerChain`]),
//! 处理器彼此互不知情------这正是「顺序是业务知识、传递是机制」的分界。
//!
//! ## ⚠️ 为什么泛型参数放在 trait 上而不是方法上
//!
//! `dyn Handler<Q, R>` 要能用,trait 就**不能**有泛型方法
//! (有泛型方法就不是对象安全的,`dyn` 用不了)。
//! 把 `Q`(请求类型)与 `R`(结论类型)放在 trait 上,
//! 每个具体组合各自单态化,`dyn Handler<ServiceTicket, TicketResolution>` 完全合法。
use super::handling::Handling;
use super::handler_kind::HandlerKind;
/// 链上的一个处理器。
///
/// # 类型参数
/// - `Q`:请求类型。
/// - `R`:结论类型。
pub trait Handler<Q, R> {
/// ★ 处理器名(**链内唯一**,写进台账与报表)。
///
/// 为什么唯一很重要:台账按名字聚合计数,
/// 两个同名处理器会被合成一行,于是「哪一级接得多」看不出来。
fn name(&self) -> &str;
/// 处理器种类(应答者 / 过路者)。
fn kind(&self) -> HandlerKind;
/// ★ 判据:这个请求归我管吗?
///
/// ⚠️ 必须**无副作用**(只问不做事):
/// 链会先问一遍再决定调不调 `handle`,
/// 若这里偷偷记了数,「问了几次」与「做了几次」就会对不上。
fn can_handle(&self, request: &Q) -> bool;
/// 动作:处理它(**只在 [`Handler::can_handle`] 为真时被调用**)。
///
/// 返回 [`Handling::Pass`] 表示「我做完了我那部分,交给下一个」。
fn handle(&self, request: &Q) -> Handling<R>;
}
//!# 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 : handler_chain.rs
//! 责任链本体:**按序问、问到能接为止**。
//!
//! ## ★ 顺序在这里,不在处理器里
//!
//! 教科书里常见的 `handler.set_next(next)` 把链的形状**散进每个处理器**,
//! 于是没有一个地方能列出「这条链长什么样」。
//! 本工程把顺序收进这个对象:
//!
//! - 换序 = 换这里的装配顺序(处理器本身一行不改);
//! - 「第 k 站被跳过了」可观测(跳过是链的事,不是处理器的事);
//! - 同一批处理器可以装成**两条不同的链**------本工程幕九正是靠这一点做换序对照。
//!
//! ## ★ 链完全不知道请求是什么
//!
//! `Q` / `R` 是泛型参数,本层**零 `crate::` 依赖**(与 `support` 同秩)。
//! 这不是洁癖:一旦这里写了 `use crate::domain::ServiceTicket`,
//! 「换一个业务要用责任链」就得改机制层------那时它就不是机制了。
use std::rc::Rc;
use super::chain_ledger::ChainLedger;
use super::chain_result::{ChainResult, UNHANDLED_MARK};
use super::chain_tail_policy::ChainTailPolicy;
use super::handling::Handling;
use super::handler::Handler;
use super::handler_kind::HandlerKind;
/// 一条责任链。
///
/// # 类型参数
/// - `Q`:请求类型。
/// - `R`:结论类型。
pub struct HandlerChain<Q, R> {
/// 链名(写在报表上)。
name: String,
/// 链尾策略。
policy: ChainTailPolicy,
/// 处理器序列(**顺序即业务知识**)。
handlers: Vec<Box<dyn Handler<Q, R>>>,
/// 共享台账(**只增不减**)。
ledger: Rc<ChainLedger>,
}
impl<Q, R> HandlerChain<Q, R> {
/// 建一条空链。
///
/// # 参数
/// - `name`:链名。
/// - `policy`:链尾策略(**必须显式给**,不允许默认值------
/// 「不知道自己用的是哪一种」正是本工程要防的事)。
/// - `ledger`:共享台账(多条链可以共用一本,以便合计)。
pub fn new(name: &str, policy: ChainTailPolicy, ledger: Rc<ChainLedger>) -> Self {
Self {
name: name.to_string(),
policy,
handlers: Vec::new(),
ledger,
}
}
/// ★ 追加一个处理器(**链式装配**:`new(...).with_handler(a).with_handler(b)`)。
///
/// 顺序 = 调用顺序。这是**唯一的**装配入口,
/// 因此「这条链长什么样」只需看这一段代码。
pub fn with_handler(mut self, handler: Box<dyn Handler<Q, R>>) -> Self {
self.handlers.push(handler);
self
}
/// 链名。
pub fn name(&self) -> &str {
self.name.as_str()
}
/// 链尾策略。
pub const fn policy(&self) -> ChainTailPolicy {
self.policy
}
/// 处理器总数。
pub fn handler_total(&self) -> usize {
self.handlers.len()
}
/// 处理器名清单(**按链上顺序**)。
pub fn handler_names(&self) -> Vec<String> {
self.handlers.iter().map(|h| h.name().to_string()).collect()
}
/// 处理器名与种类(**按链上顺序**)。
pub fn handler_kinds(&self) -> Vec<(String, HandlerKind)> {
self.handlers
.iter()
.map(|h| (h.name().to_string(), h.kind()))
.collect()
}
/// 共享台账。
pub fn ledger(&self) -> &Rc<ChainLedger> {
&self.ledger
}
/// ★ 投递一个请求。
///
/// ## 逐步做什么
///
/// 1. 记一次投递;
/// 2. **按序**问每个处理器 `can_handle`;
/// - 答「不能」→ 记一次放行,问下一个;
/// - 答「能」→ 调 `handle`:
/// - 返回 `Handled` → 记一次接下、**记跳过后面几站**、链终止;
/// - 返回 `Pass` → 记一次放行,问下一个;
/// 3. 走完链尾:按 [`ChainTailPolicy`] 处理。
///
/// # 参数
/// - `request`:请求。
///
/// # Panics
/// 策略为 [`ChainTailPolicy::RequireHandled`] 且无人接手时 panic------
/// 缺兜底处理器是**装配错误**,不是业务分支
/// (理由见 [`super::chain_tail_policy`])。
pub fn submit(&self, request: &Q) -> ChainResult<R> {
self.ledger.record_submission();
let mut visited: Vec<String> = Vec::new();
let mut visited_position: Vec<usize> = Vec::new();
for (position, handler) in self.handlers.iter().enumerate() {
// ⚠️ 先记「被问」再问:否则一个 panic 的处理器的访问数会少一次,
// 而恒等式三正好差 1,且差在报表上没有任何解释。
self.ledger.record_visit(handler.name());
visited.push(handler.name().to_string());
visited_position.push(position);
if !handler.can_handle(request) {
self.ledger.record_passed(handler.name());
continue;
}
match handler.handle(request) {
Handling::Handled(result) => {
self.ledger.record_handled(handler.name());
// ★ 后面这几站**一次都没被调用**------这就是短路的可数形式。
let skipped: usize = self.handlers.len().saturating_sub(position + 1);
self.ledger.record_skipped(skipped);
return ChainResult::new(
Some(result),
handler.name(),
Some(handler.kind()),
visited,
visited_position,
self.handlers.len(),
);
}
Handling::Pass => {
self.ledger.record_passed(handler.name());
}
}
}
match self.policy {
ChainTailPolicy::RequireHandled => panic!(
"链「{}」要求每张请求都必须有人接手,但链上 {} 个处理器都没接下它\
(链尾缺少兜底处理器:这是装配错误,不是业务分支)",
self.name,
self.handlers.len()
),
ChainTailPolicy::AllowUnhandled => {
self.ledger.record_unhandled();
ChainResult::new(
None,
UNHANDLED_MARK,
None,
visited,
visited_position,
self.handlers.len(),
)
}
}
}
/// ★ 广播式投递:**每个处理器都问一遍,但仍是第一个接的算数**。
///
/// ## 为什么要有这个方法
///
/// 「责任链省了什么」需要一个**干净的对照**。
/// 不用责任链的写法是「把每张单交给每一个环节各看一遍」(广播),
/// 那时判定次数 = `投递数 × 处理器数`,与单子被谁接走无关。
///
/// ⚠️ 这里刻意做成「**第一个接的算数**」:
/// 于是广播式与责任链跑同一批处理器会得到**完全相同的结论**,
/// 唯一的差别就是**判定次数**。
/// 那样「省下 N 次判定」才是干净的------
/// 若结论也不同,读者就无法判断差的值来自短路还是来自语义。
///
/// # 参数
/// - `request`:请求。
/// - `ledger`:★ **独立的**台账。
///
/// ⚠️ 为什么必须另给一本:广播式的访问次数必然是 `N × 站数`,
/// 若记进主台账,「省下多少次判定」这个数就被自己的对照实验污染了。
/// (既有工程的纪律:**每个用途各建一张图/一本账**。)
pub fn submit_broadcast_first(&self, request: &Q, ledger: &Rc<ChainLedger>) -> ChainResult<R> {
ledger.record_submission();
let mut visited: Vec<String> = Vec::new();
let mut visited_position: Vec<usize> = Vec::new();
let mut first_handled: Option<(String, HandlerKind, R)> = None;
for (position, handler) in self.handlers.iter().enumerate() {
ledger.record_visit(handler.name());
visited.push(handler.name().to_string());
visited_position.push(position);
match handler.handle(request) {
Handling::Handled(result) => {
if first_handled.is_none() {
// 第一个接下的算数(与责任链同语义)。
ledger.record_handled(handler.name());
first_handled = Some((handler.name().to_string(), handler.kind(), result));
} else {
// ★ 后面这些**已经知道答案了却还在判**------这就是广播式的代价。
ledger.record_passed(handler.name());
}
}
Handling::Pass => {
ledger.record_passed(handler.name());
}
}
}
match first_handled {
Some((name, kind, result)) => ChainResult::new(
Some(result),
name.as_str(),
Some(kind),
visited,
visited_position,
self.handlers.len(),
),
None => {
ledger.record_unhandled();
ChainResult::new(
None,
UNHANDLED_MARK,
None,
visited,
visited_position,
self.handlers.len(),
)
}
}
}
}
//!# 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 : handler_kind.rs
//! 处理器种类:**接了就终止**还是**做完了继续**。
//!
//! ## ★ 这一层区别正是「责任链」与「流水线」的分界
//!
//! - [`HandlerKind::Responder`]:能接就接,**接了链就停**------这是责任链。
//! - [`HandlerKind::PassThrough`]:**永远放行**,但在放行前做一件必做的事
//! (登记台账、通知客户、留痕)------这是流水线的一环。
//!
//! 两者常常**串在同一条链上**(本工程就是:先登记,再逐级审批),
//! 因此不能靠「是不是责任链」来二选一,而要靠这一个字段把两者分开:
//!
//! - 审计要能说「`PassThrough` 处理器从不产生结论」;
//! - 报表要能说「这一站只做登记,不做决定」。
//!
//! 若没有这个字段,这两句话都只能靠**看代码**才知道,无法被检查。
/// 处理器的种类。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HandlerKind {
/// ★ 应答者:能接就接,接了链终止。
Responder,
/// ★ 过路者:永远放行,只在路过时做一件事。
PassThrough,
}
impl HandlerKind {
/// 中文名。
pub const fn chinese_text(&self) -> &'static str {
match self {
HandlerKind::Responder => "应答者(接了就终止)",
HandlerKind::PassThrough => "过路者(做完继续)",
}
}
/// ★ 短中文名(`应答者` / `过路者`)。
///
/// ⚠️ 为什么需要两种文本:站点清单表里「种类」这一列只有 8 列的预算
/// (九个列要挤进 140 列文档宽度),放不下 `应答者(接了就终止)`。
/// 完整语义由「凭什么接」那一列与表尾注解承担。
pub const fn short_text(&self) -> &'static str {
match self {
HandlerKind::Responder => "应答者",
HandlerKind::PassThrough => "过路者",
}
}
/// 机读编码。
pub const fn machine_text(&self) -> &'static str {
match self {
HandlerKind::Responder => "RESPONDER",
HandlerKind::PassThrough => "PASSTHROUGH",
}
}
/// ★ 是否**允许**产生结论(即:接下请求)。
///
/// `PassThrough` 返回 `false`------一条审计规则会检查这一点,
/// 于是「某个过路处理器偷偷做了决定」会变成一个可计算的数字。
pub const fn may_settle(&self) -> bool {
matches!(self, HandlerKind::Responder)
}
}
//!# 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 : handling.rs
//! 单次处理的结果:**接下了**还是**放行**。
//!
//! ## ★ 为什么只有两种而不是三种
//!
//! 常见的写法是 `{ 处理, 放行, 拒绝 }`。
//! 但「拒绝」与「处理」在**链的行为上完全一样**------都会终止传递。
//! 区别只在结论的内容里(`Disposition::Rejected` 还是 `StoreHandled`)。
//!
//! 若把「拒绝」做成第三种枚举值,就会出现一件荒唐事:
//! 一个处理器返回 `Reject`,而它的结论对象里却写着 `StoreHandled`------
//! 两个地方各说一次「这单怎么样了」,不一致时毫无迹象。
//!
//! 因此:**枚举只表达链的走向**(终止 / 放行),
//! **结论内容**统一由 `R` 携带,一处说话。
/// 一个处理器对一次请求的处理结果。
///
/// - [`Handling::Handled`]:**接下了**,链到此终止(`R` 即结论)。
/// - [`Handling::Pass`]:**不归我管**(或我做完了该做的部分),交给下一个。
#[derive(Clone, Debug)]
pub enum Handling<R> {
/// 接下了(携带结论,链终止)。
Handled(R),
/// 放行(交给下一个处理器)。
Pass,
}
impl<R> Handling<R> {
/// 是否接下了。
pub const fn is_handled(&self) -> bool {
matches!(self, Handling::Handled(_))
}
/// 是否放行了。
pub const fn is_pass(&self) -> bool {
matches!(self, Handling::Pass)
}
/// 取出结论(**只有接下时才有**)。
pub fn result(self) -> Option<R> {
match self {
Handling::Handled(result) => Some(result),
Handling::Pass => None,
}
}
/// 借用结论(不移动)。
pub fn result_ref(&self) -> Option<&R> {
match self {
Handling::Handled(result) => Some(result),
Handling::Pass => 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 : closure_handler.rs
//! 闭包处理器:**一个处理器就是两条闭包 + 一个名字**。
//!
//! ## ★ 为什么处理器做成「数据」而不是「每种一个 struct」
//!
//! 若每一站都写一个 `struct XxxHandler`,那么:
//! - 链的装配代码里会出现七个类型名,「这条链长什么样」要连着看七行 `Box::new`;
//! - 新增一站要写一个新类型(扩展的代价被抬高了);
//! - 「这两个处理器判据相同、只是结论不同」这类重复看不出来。
//!
//! 做成闭包处理器之后,一站 = 一个 `(名字, 种类, 判据, 动作)` 四元组,
//! 于是「这条链长什么样」可以列成一张**表**,
//! 而「换序」就是调整这张表的顺序。
//!
//! ## ★ 为什么 `action` 内部必须**再判一次**
//!
//! 责任链保证「只在 `can_handle` 为真时调 `handle`」。
//! 但广播式([`super::super::chain::HandlerChain::submit_broadcast_first`])
//! 会**无条件**调 `handle`。
//! 若 `action` 自己不判,那么同一批处理器在两种投递方式下会给出不同结论,
//! 「省下的判定次数」这个对照就被污染了------
//! 读者会以为是短路改变了结果,其实是处理器不够结实。
//!
//! 因此本工程的每个 `action` 都是
//! `if !判据 { return Pass }`(自我保护),责任链下这行永远多余、广播式下它是救命的。
use crate::chain::{Handling, Handler, HandlerKind};
/// 一个由闭包构成的处理器。
///
/// # 类型参数
/// - `Q`:请求类型。
/// - `R`:结论类型。
pub struct ClosureHandler<Q, R> {
/// 处理器名(**链内唯一**)。
name: String,
/// 处理器种类。
kind: HandlerKind,
/// 判据:这个请求归我管吗(**必须无副作用**)。
predicate: Box<dyn Fn(&Q) -> bool>,
/// 动作:处理它(返回 [`Handling::Pass`] 表示放行)。
action: Box<dyn Fn(&Q) -> Handling<R>>,
/// 一句话说明(写进报表,说明这一站凭什么接)。
description: String,
}
impl<Q, R> ClosureHandler<Q, R> {
/// 组装一个处理器。
///
/// # 参数
/// - `name`:处理器名(链内唯一)。
/// - `kind`:种类(应答者 / 过路者)。
/// - `predicate`:判据闭包(**无副作用**)。
/// - `action`:动作闭包(**内部必须自己再判一次**,理由见模块文档)。
/// - `description`:一句话说明(**传 `String`** 而不是 `&'static str`:
/// 说明里往往要带上金额上限这类**算出来的**文本,写不进常量)。
pub fn new(
name: &str,
kind: HandlerKind,
predicate: Box<dyn Fn(&Q) -> bool>,
action: Box<dyn Fn(&Q) -> Handling<R>>,
description: String,
) -> Self {
Self {
name: name.to_string(),
kind,
predicate,
action,
description,
}
}
/// 一句话说明(这一站凭什么接)。
pub fn description(&self) -> &str {
self.description.as_str()
}
}
impl<Q, R> Handler<Q, R> for ClosureHandler<Q, R> {
fn name(&self) -> &str {
self.name.as_str()
}
fn kind(&self) -> HandlerKind {
self.kind
}
fn can_handle(&self, request: &Q) -> bool {
(self.predicate)(request)
}
fn handle(&self, request: &Q) -> Handling<R> {
(self.action)(request)
}
}
//!# 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_chain.rs
//! 链的装配:**把处理器按一个顺序串起来**。
//!
//! ## ★ 顺序是数据,不是代码
//!
//! 本模块的核心是一个 [`&[&str]`](站名序列)------
//! 链的装配函数按它去造处理器。
//! 于是「换序」就是换一个数组,**处理器本身一行不改**。
//!
//! 这带来两件别处做不到的事:
//! - 幕九的换序对照是**同一批处理器**的两条链,
//! 因此结论的差别**只**可能来自顺序;
//! - 「这条链长什么样」可以印成一张表(站名 + 种类 + 凭什么接),
//! 而不是散在七个 `Box::new` 里。
//!
//! ## ⚠️ 两条链不能共用同一台处理器实例
//!
//! 处理器持有 `Box<dyn Fn>`,不能复制。
//! 因此每次装配都**重新造一批**。
//! 这不是浪费:本工程的处理器都是**无状态**的(判据与动作只读取工单),
//! 若某天某个处理器带了可变状态,共用实例会让两条链互相污染------
//! 而那时「换序对照」的结论就不再是顺序造成的了。
use std::rc::Rc;
use crate::chain::{ChainLedger, ChainTailPolicy, Handler, HandlerChain};
use crate::domain::{ServiceTicket, TicketResolution};
use super::ticket_handler_factory::{
build_handler_by_name, HANDLER_ADMISSION_NAME, HANDLER_ESCALATION_NAME,
HANDLER_HEADQUARTER_NAME, HANDLER_LOGGING_NAME, HANDLER_REGIONAL_NAME, HANDLER_STORE_NAME,
HANDLER_WARRANTY_NAME,
};
/// ★ 正常顺序:登记 → 校验 → **保修** → 门店 → 区域 → 总部 → 兜底。
///
/// 保修在金额之前:**在保的维修不看金额**,
/// 若先走金额,小额维修会被门店按「门店处置」接走,客户就得自付。
pub const HANDLER_ORDER_NORMAL: [&str; 7] = [
HANDLER_LOGGING_NAME,
HANDLER_ADMISSION_NAME,
HANDLER_WARRANTY_NAME,
HANDLER_STORE_NAME,
HANDLER_REGIONAL_NAME,
HANDLER_HEADQUARTER_NAME,
HANDLER_ESCALATION_NAME,
];
/// ★ 换序顺序:把「保修判定」挪到**金额权限之后**。
///
/// 用于幕九的对照:同一批工单、同一批处理器,只换这一处,
/// 看有几张单的处置者变了------那个数就是「顺序作为业务知识」的量度。
pub const HANDLER_ORDER_SWAPPED: [&str; 7] = [
HANDLER_LOGGING_NAME,
HANDLER_ADMISSION_NAME,
HANDLER_STORE_NAME,
HANDLER_REGIONAL_NAME,
HANDLER_HEADQUARTER_NAME,
HANDLER_WARRANTY_NAME,
HANDLER_ESCALATION_NAME,
];
/// ★ 缺兜底顺序(**负向用例**):摘掉第 7 站。
///
/// 用于幕八:确认 `SETTLED` 那条规则真的会红。
/// 摘掉之后,超出总部权限的单会消失在链尾。
pub const HANDLER_ORDER_NO_FALLBACK: [&str; 6] = [
HANDLER_LOGGING_NAME,
HANDLER_ADMISSION_NAME,
HANDLER_WARRANTY_NAME,
HANDLER_STORE_NAME,
HANDLER_REGIONAL_NAME,
HANDLER_HEADQUARTER_NAME,
];
/// ★ 按站名序列装配一条链。
///
/// # 参数
/// - `name`:链名。
/// - `policy`:链尾策略。
/// - `ledger`:共享台账(**只增不减**)。
/// - `order`:站名序列(顺序即业务知识)。
///
/// # Panics
/// 序列里出现不认识的站名时 panic------
/// 「写错站名」若静默跳过,链就少了一站,而报表上只会少几张单。
pub fn build_ticket_chain(
name: &str,
policy: ChainTailPolicy,
ledger: Rc<ChainLedger>,
order: &[&str],
) -> HandlerChain<ServiceTicket, TicketResolution> {
let mut chain: HandlerChain<ServiceTicket, TicketResolution> =
HandlerChain::new(name, policy, ledger);
for station_name in order.iter() {
chain = chain.with_handler(build_handler_by_name(station_name));
}
chain
}
/// ★ 装配一条**工程外扩展**的链:在 `order` 之后追加扩展站。
///
/// 这就是「完全可扩展」的落点:扩展既不改机制层、也不改内置工厂,
/// 只在这一层把新站挂到序列末尾。
///
/// # 参数
/// - `name`:链名。
/// - `policy`:链尾策略。
/// - `ledger`:共享台账。
/// - `order`:内置站名序列。
/// - `extra_handlers`:★ 扩展站(**由 `main.rs` 的工程外扩展区给出**)。
pub fn build_ticket_chain_with_extensions(
name: &str,
policy: ChainTailPolicy,
ledger: Rc<ChainLedger>,
order: &[&str],
extra_handlers: Vec<Box<dyn Handler<ServiceTicket, TicketResolution>>>,
) -> HandlerChain<ServiceTicket, TicketResolution> {
let mut chain: HandlerChain<ServiceTicket, TicketResolution> =
HandlerChain::new(name, policy, ledger);
for station_name in order.iter() {
chain = chain.with_handler(build_handler_by_name(station_name));
}
for handler in extra_handlers.into_iter() {
chain = chain.with_handler(handler);
}
chain
}
//!# 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_handler_factory.rs
//! 处置处理器的建造厂:**每一站凭什么接、接了给什么结论**。
//!
//! ## ★ 为什么「凭什么接」在这里而不是在机制层
//!
//! 机制层只知道「按序问、问到能接为止」;
//! 「有没有凭证」「在不在保修期」「金额超没超权限」全是**业务口径**。
//! 放在这一层,机制层才能零 `crate::` 依赖(可被测出来),
//! 换一家公司只需换这一张表。
//!
//! ## 链上各站(**顺序即业务知识**)
//!
//! | # | 站名 | 种类 | 凭什么接 |
//! |---|---|---|---|
//! | 1 | 工单登记留痕 | 过路者 | 恒接(**永远放行**) |
//! | 2 | 单据合规校验 | 应答者 | 工单**不可受理**(无凭证 / 超窗口) |
//! | 3 | 保修判定 | 应答者 | 走保修且**在保**且非人为损坏 |
//! | 4 | 门店店长审批 | 应答者 | 金额 ≤ 门店权限 |
//! | 5 | 区域经理审批 | 应答者 | 金额 ≤ 区域权限 |
//! | 6 | 总部总监审批 | 应答者 | 金额 ≤ 总部权限 |
//! | 7 | 总裁办兜底 | 应答者 | 恒接(**保证链尾不空**) |
//!
//! ⚠️ 第 7 站不是「可以省」的:摘掉它,
//! 超大额工单就会消失在链尾------本工程幕八**真的摘过一次**,
//! 为的是确认 `SETTLED` 那条规则会红。
use crate::chain::{Handling, Handler, HandlerKind};
use crate::domain::{
AuthorityLevel, Disposition, Money, ServiceTicket, TicketResolution, CNY,
};
use super::closure_handler::ClosureHandler;
/// 工单登记留痕(**过路者**)。
pub const HANDLER_LOGGING_NAME: &str = "工单登记留痕";
/// 单据合规校验。
pub const HANDLER_ADMISSION_NAME: &str = "单据合规校验";
/// 保修判定。
pub const HANDLER_WARRANTY_NAME: &str = "保修判定";
/// 门店店长审批。
pub const HANDLER_STORE_NAME: &str = "门店店长审批";
/// 区域经理审批。
pub const HANDLER_REGIONAL_NAME: &str = "区域经理审批";
/// 总部总监审批。
pub const HANDLER_HEADQUARTER_NAME: &str = "总部总监审批";
/// 总裁办兜底。
pub const HANDLER_ESCALATION_NAME: &str = "总裁办兜底";
/// ★ 第 1 站:登记留痕(**恒放行**)。
///
/// 这一站存在的意义:证明「责任链上可以有不做决定的站」。
/// 它**永远**返回 [`Handling::Pass`],但每次投递都会经过它------
/// 于是台账上它的「被问次数 = 投递数、接下次数 = 0」,
/// 这正是 [`crate::chain::HandlerAttempt::never_asked`] 之外的另一种「纯过路」。
pub fn build_logging_handler() -> Box<ClosureHandler<ServiceTicket, TicketResolution>> {
let predicate: Box<dyn Fn(&ServiceTicket) -> bool> = Box::new(|_ticket| true);
let action: Box<dyn Fn(&ServiceTicket) -> Handling<TicketResolution>> = Box::new(|ticket| {
// ⚠️ 过路者**永远**放行:它只做登记,不做决定。
// (审计规则 `NO_PASSTHROUGH` 会盯住这一点:过路者不许出现在处置者里。)
let _ = ticket.ticket_number();
Handling::Pass
});
Box::new(ClosureHandler::new(
HANDLER_LOGGING_NAME,
HandlerKind::PassThrough,
predicate,
action,
"每一张工单都要留痕,但不做任何决定".to_string(),
))
}
/// ★ 第 2 站:单据合规校验。
///
/// 判据直接问工单自己([`ServiceTicket::is_inadmissible`]),
/// 不在这一站重写一遍------「能不能受理」只有一个地方说了算。
pub fn build_admission_handler() -> Box<ClosureHandler<ServiceTicket, TicketResolution>> {
let predicate: Box<dyn Fn(&ServiceTicket) -> bool> =
Box::new(|ticket| ticket.is_inadmissible());
let action: Box<dyn Fn(&ServiceTicket) -> Handling<TicketResolution>> = Box::new(|ticket| {
// ★ 自我保护:广播式会无条件调 `handle`,因此这里要再判一次。
if !ticket.is_inadmissible() {
return Handling::Pass;
}
let resolution: TicketResolution = TicketResolution::new(
Disposition::Rejected,
HANDLER_ADMISSION_NAME,
Money::zero(CNY),
ticket.inadmissible_reason_text().as_str(),
);
Handling::Handled(resolution)
});
Box::new(ClosureHandler::new(
HANDLER_ADMISSION_NAME,
HandlerKind::Responder,
predicate,
action,
"无凭证或超窗口的工单在此拒单(不占上级审批资源)".to_string(),
))
}
/// ★ 第 3 站:保修判定。
///
/// ⚠️ 这一站**不看金额**:一张 30 元的维修在保就该免费修,
/// 若它要先走金额权限,那么「小额维修」会因为门店权限而变成「门店处置」,
/// 客户就得自付------而保修判定本该在金额之前。
/// 这正是「顺序是业务知识」最具体的一处证据(幕九换序会证明它)。
pub fn build_warranty_handler() -> Box<ClosureHandler<ServiceTicket, TicketResolution>> {
let predicate: Box<dyn Fn(&ServiceTicket) -> bool> =
Box::new(|ticket| ticket.is_within_warranty());
let action: Box<dyn Fn(&ServiceTicket) -> Handling<TicketResolution>> = Box::new(|ticket| {
if !ticket.is_within_warranty() {
return Handling::Pass;
}
let note: String = format!(
"购买 {} 个月,在 {} 个月保修期内且非人为损坏,免费维修(厂商承担)",
ticket.months_elapsed(),
crate::domain::WARRANTY_MONTHS
);
let resolution: TicketResolution = TicketResolution::new(
Disposition::WarrantyRepair,
HANDLER_WARRANTY_NAME,
Money::zero(CNY),
note.as_str(),
);
Handling::Handled(resolution)
});
Box::new(ClosureHandler::new(
HANDLER_WARRANTY_NAME,
HandlerKind::Responder,
predicate,
action,
"在保且非人为损坏的维修/复检,免费处理(不看金额)".to_string(),
))
}
/// ★ 第 4~6 站:按权限档位审批。
///
/// # 参数
/// - `level`:权限档位(上限金额 + 中文名 + 对应结论)。
///
/// # Panics
/// 档位编码不认识时 panic------
/// 「加了一级权限却忘了给它配结论」必须在装配时炸,
/// 不能等到报表上出现一个空白的处置结论。
pub fn build_authority_handler(
level: AuthorityLevel,
) -> Box<ClosureHandler<ServiceTicket, TicketResolution>> {
let disposition: Disposition = disposition_for_authority(level);
let handler_name: String = level.chinese_name().to_string();
let ceiling: i64 = level.ceiling_minor_units();
let predicate: Box<dyn Fn(&ServiceTicket) -> bool> = Box::new(move |ticket| {
level.covers(ticket.involved_amount().minor_units())
});
let action: Box<dyn Fn(&ServiceTicket) -> Handling<TicketResolution>> =
Box::new(move |ticket| {
let amount: i64 = ticket.involved_amount().minor_units();
if !level.covers(amount) {
return Handling::Pass;
}
let note: String = format!(
"涉及 {} <= {} 权限上限 {},由{}处置",
ticket.involved_amount().chinese_text(),
handler_name,
Money::new(ceiling, CNY).chinese_text(),
handler_name
);
let resolution: TicketResolution =
TicketResolution::new(disposition, handler_name.as_str(), ticket.involved_amount(), note.as_str());
Handling::Handled(resolution)
});
Box::new(ClosureHandler::new(
level.chinese_name(),
HandlerKind::Responder,
predicate,
action,
format!(
"金额 ≤ {} 的单由{}直接处置",
Money::new(ceiling, CNY).chinese_text(),
level.chinese_name()
),
))
}
/// ★ 第 7 站:总裁办兜底(**恒接**)。
///
/// 存在的理由只有一句:**链尾不允许空**。
/// 摘掉它,`SETTLED` 那条规则会红------本工程真的摘过一次。
pub fn build_escalation_handler() -> Box<ClosureHandler<ServiceTicket, TicketResolution>> {
let predicate: Box<dyn Fn(&ServiceTicket) -> bool> = Box::new(|_ticket| true);
let action: Box<dyn Fn(&ServiceTicket) -> Handling<TicketResolution>> = Box::new(|ticket| {
let note: String = format!(
"涉及 {} 超出全部审批权限,上报总裁办(按涉及金额计入待赔付)",
ticket.involved_amount().chinese_text()
);
let resolution: TicketResolution = TicketResolution::new(
Disposition::Escalated,
HANDLER_ESCALATION_NAME,
ticket.involved_amount(),
note.as_str(),
);
Handling::Handled(resolution)
});
Box::new(ClosureHandler::new(
HANDLER_ESCALATION_NAME,
HandlerKind::Responder,
predicate,
action,
"超出所有权限的单在此收口,保证链尾不空".to_string(),
))
}
/// ★ 权限档位 → 处置结论的映射。
///
/// ⚠️ 为什么不用「链上位置」来推(第 4 站 = 门店、第 5 站 = 区域):
/// 那样换序就会换掉结论的语义------把总部挪到第 4 站,
/// 它给出的结论会变成「门店处置」,而报表上看不出这是错的。
/// 按**档位编码**映射,结论才跟着权限走而不是跟着位置走。
fn disposition_for_authority(level: AuthorityLevel) -> Disposition {
match level.code() {
"STORE" => Disposition::StoreHandled,
"REGIONAL" => Disposition::RegionalHandled,
"HEADQUARTER" => Disposition::HeadquarterHandled,
"SUPERVISOR" => Disposition::Escalated,
other => panic!("权限档位 {} 没有配置对应的处置结论(加档位时要在这里补)", other),
}
}
/// 门店权限站的建造函数(**把档位烧进函数指针**,便于写进规格表)。
pub fn build_store_authority_handler() -> Box<ClosureHandler<ServiceTicket, TicketResolution>> {
build_authority_handler(crate::domain::AUTHORITY_STORE)
}
/// 区域权限站的建造函数。
pub fn build_regional_authority_handler() -> Box<ClosureHandler<ServiceTicket, TicketResolution>> {
build_authority_handler(crate::domain::AUTHORITY_REGIONAL)
}
/// 总部权限站的建造函数。
pub fn build_headquarter_authority_handler() -> Box<ClosureHandler<ServiceTicket, TicketResolution>> {
build_authority_handler(crate::domain::AUTHORITY_HEADQUARTER)
}
/// ★ 内置处理器的**规格表**(**按链上顺序**)。
///
/// 返回 `(站名, 种类, 建造函数)` 三元组。
///
/// ## ★ 为什么规格表里**没有**「凭什么接」
///
/// 「这一站凭什么接」这句话要出现在两个地方:装配链时、印站点清单时。
/// 若两处各写一遍,改一处漏一处就会分裂------
/// 而那种分裂在报表上**看起来完全正常**(两处都是通顺的句子)。
///
/// 因此说明**只写在每台处理器自己的 `description` 里**(`build_*` 函数内),
/// 报表要印时现造一台实例读出来(见 [`station_descriptions`])。
/// 规格表只管「有哪些站、什么种类、怎么造」。
///
/// ⚠️ 建造函数的返回类型是**具体类型** `Box<ClosureHandler<..>>` 而不是
/// `Box<dyn Handler<..>>`:报表要读 `description`,而 trait 对象读不到它
/// (`description` 是 `ClosureHandler` 的固有方法,不在 `Handler` trait 上------
/// 机制层不该知道「说明」这回事)。
pub fn builtin_handler_specs() -> Vec<(
&'static str,
HandlerKind,
fn() -> Box<ClosureHandler<ServiceTicket, TicketResolution>>,
)> {
vec![
(HANDLER_LOGGING_NAME, HandlerKind::PassThrough, build_logging_handler),
(HANDLER_ADMISSION_NAME, HandlerKind::Responder, build_admission_handler),
(HANDLER_WARRANTY_NAME, HandlerKind::Responder, build_warranty_handler),
(HANDLER_STORE_NAME, HandlerKind::Responder, build_store_authority_handler),
(HANDLER_REGIONAL_NAME, HandlerKind::Responder, build_regional_authority_handler),
(
HANDLER_HEADQUARTER_NAME,
HandlerKind::Responder,
build_headquarter_authority_handler,
),
(HANDLER_ESCALATION_NAME, HandlerKind::Responder, build_escalation_handler),
]
}
/// ★ 站名 → 「凭什么接」的清单(**现造一台实例读它的 `description`**)。
///
/// ⚠️ 为了印一张表而造一批处理器看起来浪费,但这是**唯一**能让
/// 「说明」只存在一处的办法。这些处理器**没有状态**(闭包只读工单),
/// 造出来读一句说明就丢掉,成本可忽略。
pub fn station_descriptions() -> Vec<(String, String)> {
builtin_handler_specs()
.into_iter()
.map(|(name, _kind, builder)| (name.to_string(), builder().description().to_string()))
.collect()
}
/// ★ 按站名造一台处理器(**从规格表派生**)。
///
/// # Panics
/// 站名不认识时 panic------
/// 「写错站名」若静默跳过,链就少了一站,而报表上只会少几张单。
pub fn build_handler_by_name(
station_name: &str,
) -> Box<dyn Handler<ServiceTicket, TicketResolution>> {
for (name, _kind, builder) in builtin_handler_specs().into_iter() {
if name == station_name {
// 具体类型 → trait 对象(Rust 的 unsized coercion)。
return builder();
}
}
panic!(
"不认识的处置站名:{}(加了新站要在 `builtin_handler_specs` 里登记)",
station_name
);
}
//!# 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_session.rs
//! 会话:把一批工单**投进一条链**,并留下逐单可查的结果。
//!
//! ## ★ 为什么「工单」与「结论」分开存
//!
//! 工单是**进来时的样子**,结论是**出去时的样子**。
//! 分开存,同一批工单才能跑两条不同的链并逐单比对------
//! 本工程幕九(换序对照)与幕十(广播式对照)都靠这一点成立。
//!
//! ## ★ 为什么会话自己不记账
//!
//! 计数全在共享台账 [`crate::chain::ChainLedger`] 里(只增不减)。
//! 会话只保存**逐单结果**。
//! 若会话再记一份,就会出现「两个数字都能回答『处理了几张』」的情况,
//! 而它们不一致时读者不知道该信哪个。
use crate::chain::{ChainResult, HandlerChain, RequestObservation, UNHANDLED_MARK};
use crate::domain::{Disposition, Money, ServiceTicket, TicketResolution, CNY};
/// 一张工单的投递结果。
///
/// # 字段说明
/// - `ticket`:工单本体(**进来时的样子**)。
/// - `result`:链返回的结果(结论 + 访问轨迹)。
#[derive(Clone, Debug)]
pub struct TicketOutcome {
/// 工单本体。
ticket: ServiceTicket,
/// 链返回的结果。
result: ChainResult<TicketResolution>,
}
impl TicketOutcome {
/// 由工单与结果组装。
pub fn new(ticket: ServiceTicket, result: ChainResult<TicketResolution>) -> Self {
Self { ticket, result }
}
/// 工单本体。
pub fn ticket(&self) -> &ServiceTicket {
&self.ticket
}
/// 链返回的结果。
pub fn result(&self) -> &ChainResult<TicketResolution> {
&self.result
}
/// 工单号。
pub fn ticket_number(&self) -> &str {
self.ticket.ticket_number()
}
/// ★ 处置结论(**无人处置时给出一个显式的 `Unhandled` 结论**)。
///
/// ⚠️ 为什么不返回 `Option`:报表要的是「每格都有字」。
/// 若这里返回 `None`,表示层就得自己写一个「---」,
/// 那时「无人处置」的写法有两处来源,改一处漏一处就分裂了。
pub fn resolution(&self) -> TicketResolution {
match self.result.result_ref() {
Some(resolution) => resolution.clone(),
None => TicketResolution::new(
Disposition::Unhandled,
UNHANDLED_MARK,
Money::zero(CNY),
"链上没有任何一站接下这张单(**这是装配缺陷,不是业务结论**)",
),
}
}
/// 结论类型。
pub fn disposition(&self) -> Disposition {
self.resolution().disposition()
}
/// 处置者名。
pub fn handler_name(&self) -> String {
self.result.handler_name().to_string()
}
/// 赔付金额。
pub fn compensation(&self) -> Money {
self.resolution().compensation()
}
/// 走了几站。
pub fn visited_total(&self) -> usize {
self.result.visited_total()
}
/// ★ 因为短路而**一次都没被调用**的站数。
pub fn skipped_total(&self) -> usize {
self.result.skipped_total()
}
/// 访问轨迹文本。
pub fn visited_text(&self) -> String {
self.result.visited_text()
}
}
/// 一次会话:一批工单 × 一条链。
pub struct TicketSession {
/// 链。
chain: HandlerChain<ServiceTicket, TicketResolution>,
/// 逐单结果(**按投递顺序**)。
outcomes: Vec<TicketOutcome>,
}
impl TicketSession {
/// ★ 跑一遍。
///
/// # 参数
/// - `chain`:链(**跑完之后链就不能再改了**,因此按值传入)。
/// - `tickets`:工单序列(按投递顺序)。
pub fn run(
chain: HandlerChain<ServiceTicket, TicketResolution>,
tickets: &[ServiceTicket],
) -> Self {
let mut outcomes: Vec<TicketOutcome> = Vec::new();
for ticket in tickets.iter() {
let result: ChainResult<TicketResolution> = chain.submit(ticket);
outcomes.push(TicketOutcome::new(ticket.clone(), result));
}
Self { chain, outcomes }
}
/// ★ 跑一遍**广播式**对照(每个处理器都问一遍,仍是第一个接的算数)。
///
/// # 参数
/// - `chain`:链(同一批处理器)。
/// - `tickets`:同一批工单。
/// - `broadcast_ledger`:★ **独立的**台账。
///
/// ⚠️ 为什么必须另给一本:广播式的访问次数必然是 `单数 × 站数`,
/// 若记进主台账,「省下多少次判定」这个数就被自己的对照实验污染了。
/// (既有工程的纪律:**每个用途各建一张图 / 一本账**。)
pub fn run_broadcast(
chain: HandlerChain<ServiceTicket, TicketResolution>,
tickets: &[ServiceTicket],
broadcast_ledger: std::rc::Rc<crate::chain::ChainLedger>,
) -> Self {
let mut outcomes: Vec<TicketOutcome> = Vec::new();
for ticket in tickets.iter() {
let result: ChainResult<TicketResolution> =
chain.submit_broadcast_first(ticket, &broadcast_ledger);
outcomes.push(TicketOutcome::new(ticket.clone(), result));
}
Self { chain, outcomes }
}
/// 链。
pub fn chain(&self) -> &HandlerChain<ServiceTicket, TicketResolution> {
&self.chain
}
/// 逐单结果。
pub fn outcomes(&self) -> &[TicketOutcome] {
self.outcomes.as_slice()
}
/// 工单总数。
pub fn ticket_total(&self) -> usize {
self.outcomes.len()
}
/// ★ 被处置的单数。
pub fn settled_total(&self) -> usize {
self.outcomes.iter().filter(|o| o.result.is_handled()).count()
}
/// ★ 无人处置的单数(**正常链上必须是 0**)。
pub fn unhandled_total(&self) -> usize {
self.outcomes.iter().filter(|o| !o.result.is_handled()).count()
}
/// ★ 各结论类型的单数(**按 [`Disposition::all`] 的顺序**,含 0 的也列出)。
///
/// ⚠️ 为什么把 0 也列出来:「拒单 0 张」与「根本没有拒单这一档」
/// 在报表上是两件不同的事,只列非 0 的会让读者以为后者。
pub fn disposition_counts(&self) -> Vec<(Disposition, usize)> {
Disposition::all()
.iter()
.map(|disposition| {
let count: usize = self
.outcomes
.iter()
.filter(|o| o.disposition() == *disposition)
.count();
(*disposition, count)
})
.collect()
}
/// ★ 各站的接下次数(**按链上顺序**,含 0 的也列出)。
pub fn handler_settle_counts(&self) -> Vec<(String, usize)> {
self.chain
.handler_names()
.into_iter()
.map(|name| {
let count: usize = self
.outcomes
.iter()
.filter(|o| o.handler_name() == name)
.count();
(name, count)
})
.collect()
}
/// ★ 赔付总额。
pub fn compensation_total(&self) -> Money {
let mut total: Money = Money::zero(CNY);
for outcome in self.outcomes.iter() {
total = total.plus(outcome.compensation());
}
total
}
/// ★ 累计走了几站(判定次数)。
pub fn visited_total(&self) -> usize {
self.outcomes.iter().map(|o| o.visited_total()).sum()
}
/// ★ 累计跳过几站(**这就是责任链省下的判定次数**)。
pub fn skipped_total(&self) -> usize {
self.outcomes.iter().map(|o| o.skipped_total()).sum()
}
/// ★ 若不用责任链(每个都问一遍)会是多少次判定。
pub fn visited_total_if_broadcast(&self) -> usize {
self.ticket_total() * self.chain.handler_total()
}
/// ★ 逐单观测(喂给契约审计)。
pub fn observations(&self) -> Vec<RequestObservation> {
self.outcomes
.iter()
.map(|outcome| RequestObservation::from_result(outcome.ticket_number(), outcome.result()))
.collect()
}
/// ★ 逐单「工单号 → 结论指纹」(喂给恒等对照)。
///
/// ⚠️ 只比结论指纹(结论类型 + 处置者 + 赔付),**不比说明文案**------
/// 措辞改了不该让恒等检查失败。
pub fn resolution_fingerprints(&self) -> Vec<(String, String)> {
self.outcomes
.iter()
.map(|outcome| {
(
outcome.ticket_number().to_string(),
outcome.resolution().fingerprint_text(),
)
})
.collect()
}
}
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_check_view.rs
//! 契约审计结果的**分析层视图**。
//!
//! ## ★ 为什么表示层不直接用 `crate::chain::RuleCheck`
//!
//! 机制层的 `RuleCheck` 带着 `ChainAuditRule`(一个机制层的枚举)。
//! 表示层一旦 import 它,`app -> chain` 这条边就出现了------
//! 依赖实测会当场抓到,而「这一列叫什么」也就押在了机制层的一个字段名上。
//!
//! 正确做法不是放宽红线,而是**补一层视图**:
//! 这里把规则转成**纯字符串**,表示层只认识字符串。
//! (既有工程的教训:★ 表示层 import 机制层时,正确修法是补视图而不是放宽红线。)
use crate::chain::RuleCheck;
/// 一条契约检查的视图(**全是字符串**,表示层不认识机制层类型)。
///
/// # 字段说明
/// - `code` / `name`:规则编码与中文名。
/// - `expectation`:期望文本。
/// - `actual`:实测文本。
/// - `passed`:是否通过。
/// - `violating`:违规的请求标识(用「、」连接后的文本)。
/// - `known_failing_input`:★ 已知会失败的输入(**没有它,规则就是一句注释**)。
#[derive(Clone, Debug)]
pub struct AuditCheckView {
/// 规则编码。
code: String,
/// 规则中文名。
name: String,
/// 期望文本。
expectation: String,
/// 实测文本。
actual: String,
/// 是否通过。
passed: bool,
/// 违规请求标识(连接后的文本)。
violating: String,
/// 已知会失败的输入。
known_failing_input: String,
}
impl AuditCheckView {
/// 由机制层的一条检查结果转出。
pub fn from_check(check: &RuleCheck) -> Self {
Self {
code: check.rule().code().to_string(),
name: check.rule().chinese_text().to_string(),
expectation: check.rule().expectation_text().to_string(),
actual: check.actual_text().to_string(),
passed: check.passed(),
violating: if check.violating_labels().is_empty() {
"---".to_string()
} else {
check.violating_labels().join("、")
},
known_failing_input: check.rule().known_failing_input_text().to_string(),
}
}
/// 规则编码。
pub fn code(&self) -> &str {
self.code.as_str()
}
/// 规则中文名。
pub fn name(&self) -> &str {
self.name.as_str()
}
/// 期望文本。
pub fn expectation(&self) -> &str {
self.expectation.as_str()
}
/// 实测文本。
pub fn actual(&self) -> &str {
self.actual.as_str()
}
/// 是否通过。
pub const fn passed(&self) -> bool {
self.passed
}
/// 违规请求标识文本。
pub fn violating(&self) -> &str {
self.violating.as_str()
}
/// 已知会失败的输入。
pub fn known_failing_input(&self) -> &str {
self.known_failing_input.as_str()
}
}
/// 把一批检查结果转成视图。
pub fn audit_check_views(checks: &[RuleCheck]) -> Vec<AuditCheckView> {
checks.iter().map(AuditCheckView::from_check).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 : broadcast_census.rs
//! 广播式对照:**责任链 vs 每个环节都问一遍**。
//!
//! ## ★ 「省下了什么」必须有一个对照才成立
//!
//! 「我们用了责任链所以很快」是一句**无法被反驳**的话------
//! 因为它没有对照。
//! 可数的对照是:
//!
//! - 责任链:第 k 站接下之后,**后面 `站数 − k − 1` 站一次都没被调用**;
//! - 广播式:每一站都问一遍(判定次数 = `单数 × 站数`)。
//!
//! 两者跑的是**同一批处理器**、给出**完全相同的结论**
//! ([`crate::chain::HandlerChain::submit_broadcast_first`] 刻意做成「第一个接的算数」),
//! 因此唯一的差别就是判定次数------
//! 于是「省下 N 次判定」是一个干净的、可复算的整数。
//!
//! ⚠️ 刻意**不用耗时**做对照:CPU 时间不可复现,
//! 两次运行不算同一个数就谈不上「逐字节一致」的验收。
use crate::analysis::CheckLine;
use crate::client::TicketSession;
/// 一张单在两种投递方式下的对照。
///
/// # 字段说明
/// - `ticket_number`:工单号。
/// - `chain_handler` / `broadcast_handler`:两种方式的处置者(**必须相同**)。
/// - `chain_disposition` / `broadcast_disposition`:两种方式的结论(**必须相同**)。
/// - `chain_visited` / `broadcast_visited`:★ 判定次数(**这个才应该不同**)。
#[derive(Clone, Debug)]
pub struct BroadcastPair {
/// 工单号。
pub ticket_number: String,
/// 责任链的处置者。
pub chain_handler: String,
/// 广播式的处置者。
pub broadcast_handler: String,
/// 责任链的结论。
pub chain_disposition: String,
/// 广播式的结论。
pub broadcast_disposition: String,
/// 责任链走了几站。
pub chain_visited: usize,
/// 广播式走了几站。
pub broadcast_visited: usize,
/// 结论是否一致。
pub identical: bool,
}
/// ★ 逐单比对两种投递方式。
pub fn compare_broadcast(chain: &TicketSession, broadcast: &TicketSession) -> Vec<BroadcastPair> {
assert_eq!(
chain.ticket_total(),
broadcast.ticket_total(),
"两种投递方式的工单数不同,无法逐单比对"
);
chain
.outcomes()
.iter()
.zip(broadcast.outcomes().iter())
.map(|(left, right)| {
let identical: bool =
left.resolution().fingerprint_text() == right.resolution().fingerprint_text();
BroadcastPair {
ticket_number: left.ticket_number().to_string(),
chain_handler: left.handler_name(),
broadcast_handler: right.handler_name(),
chain_disposition: left.resolution().disposition_text().to_string(),
broadcast_disposition: right.resolution().disposition_text().to_string(),
chain_visited: left.visited_total(),
broadcast_visited: right.visited_total(),
identical,
}
})
.collect()
}
/// ★ 广播式对照的检查。
///
/// # 参数
/// - `pairs`:逐单对照结果。
/// - `chain_visit_total`:责任链的实际判定次数。
/// - `broadcast_visit_total`:广播式的判定次数。
/// - `station_total`:站数。
pub fn broadcast_checks(
pairs: &[BroadcastPair],
chain_visit_total: usize,
broadcast_visit_total: usize,
station_total: usize,
) -> Vec<CheckLine> {
let mut lines: Vec<CheckLine> = Vec::new();
// ① ★ 逐单结论完全一致(语义相同,只是判定次数不同)。
// 已知会失败的输入:广播式改成「最后一个接的算数」------那时结论会全变。
let identical_total: usize = pairs.iter().filter(|pair| pair.identical).count();
lines.push(CheckLine::new(
"★ 两种投递方式逐单结论一致(语义相同)",
format!(
"{} 张全部一致(已知会失败:广播式改成「最后一个接的算数」)",
pairs.len()
)
.as_str(),
format!("{}/{}", identical_total, pairs.len()).as_str(),
!pairs.is_empty() && identical_total == pairs.len(),
));
// ② 广播式的判定次数 == 单数 × 站数(它是「每个都问一遍」的定义)。
let expected_broadcast: usize = pairs.len() * station_total;
lines.push(CheckLine::new(
"广播式判定次数 == 单数 × 站数",
format!("{} 次", expected_broadcast).as_str(),
format!("{} 次", broadcast_visit_total).as_str(),
broadcast_visit_total == expected_broadcast,
));
// ③ ★ 责任链的判定次数**严格少于**广播式(真的省了)。
// 已知会失败的输入:把所有站的判据都写成「恒不接」,
// 于是每张单都要走完链尾,判定次数与广播式相同。
lines.push(CheckLine::new(
"★ 责任链判定次数 < 广播式(真的短路了)",
"严格小于(已知会失败:所有站判据都写成恒不接,每张单都走完链尾)",
format!("{} < {}", chain_visit_total, broadcast_visit_total).as_str(),
chain_visit_total < broadcast_visit_total,
));
// ④ ★ 两张表的**结论文本**逐单相同(不只是指纹相同)。
// 已知会失败的输入:广播式改成「最后一个接的算数」------
// 那时结论文本会全变,而指纹比对也会变红;两条互为印证。
let disposition_identical_total: usize = pairs
.iter()
.filter(|pair| pair.chain_disposition == pair.broadcast_disposition)
.count();
lines.push(CheckLine::new(
"★ 结论文本逐单相同(不只指纹相同)",
format!("{} 张全部相同", pairs.len()).as_str(),
format!("{}/{}", disposition_identical_total, pairs.len()).as_str(),
!pairs.is_empty() && disposition_identical_total == pairs.len(),
));
// ⑤ 每一张单:责任链走的站数 <= 广播式走的站数。
let monotone_total: usize = pairs
.iter()
.filter(|pair| pair.chain_visited <= pair.broadcast_visited)
.count();
lines.push(CheckLine::new(
"每张单:责任链走的站数 <= 广播式",
format!("{} 张全部成立", pairs.len()).as_str(),
format!("{}/{}", monotone_total, pairs.len()).as_str(),
monotone_total == pairs.len(),
));
lines
}
//!# 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 : check_line.rs
//! 检查项与检查报告:全工程「应当 == 实测」的**统一形状**。
//!
//! ## 为什么单独一个文件
//!
//! 工程里有六处独立的检查(求值纪律、读取时刻、契约审计、恒等、拓扑、价值量化),
//! 若各写一套「期望/实测/结论」,最后一张体检表就得认识六种类型。
//! 统一成 [`CheckLine`] 之后,体检表只有一个来源,
//! 而**新增一处检查 = 多几行 `push`**,不需要改体检表。
//!
//! ## ★ 为什么 `verdict_text()` 只在这里产生一次
//!
//! 「通过 / 未通过」的写法若散在各处,迟早会出现一处写成「符合/不符合」,
//! 而读者会以为那是另一种判断。判据只有一个:`passed`。
/// 一条检查项。
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CheckLine {
/// 检查项名称。
item: String,
/// 期望文本(**应当**是什么)。
expectation_text: String,
/// 实测文本(**实际**是什么)。
actual_text: String,
/// 是否通过。
passed: bool,
}
impl CheckLine {
/// 由四个字段构造。
pub fn new(item: &str, expectation_text: &str, actual_text: &str, passed: bool) -> Self {
Self {
item: item.to_string(),
expectation_text: expectation_text.to_string(),
actual_text: actual_text.to_string(),
passed,
}
}
/// 检查项名称。
pub fn item(&self) -> &str {
&self.item
}
/// 期望文本。
pub fn expectation_text(&self) -> &str {
&self.expectation_text
}
/// 实测文本。
pub fn actual_text(&self) -> &str {
&self.actual_text
}
/// 是否通过。
pub const fn passed(&self) -> bool {
self.passed
}
/// ★ 结论文本:全工程唯一一处产生「通过 / 未通过」的地方。
pub const fn verdict_text(&self) -> &'static str {
if self.passed {
"通过"
} else {
"未通过"
}
}
}
/// 一组检查项。
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CheckReport {
/// 检查项清单。
lines: Vec<CheckLine>,
}
impl CheckReport {
/// 空报告。
pub fn new() -> Self {
Self { lines: Vec::new() }
}
/// 追加一条。
pub fn push(&mut self, line: CheckLine) {
self.lines.push(line);
}
/// 全部检查项。
pub fn lines(&self) -> &[CheckLine] {
&self.lines
}
/// 条数。
pub fn line_total(&self) -> usize {
self.lines.len()
}
/// 通过条数。
pub fn passed_total(&self) -> usize {
self.lines.iter().filter(|line| line.passed()).count()
}
/// 未通过条数。
pub fn failed_total(&self) -> usize {
self.line_total() - self.passed_total()
}
/// 是否全部通过。
pub fn all_passed(&self) -> bool {
self.passed_total() == self.line_total()
}
/// 未通过的检查项名称(拼成一串,供注解使用)。
///
/// ⚠️ 全部通过时返回「无」而不是空串:空串在报表上看起来像「忘了填」。
pub fn failed_items_text(&self) -> String {
let failed: Vec<&str> = self
.lines
.iter()
.filter(|line| !line.passed())
.map(|line| line.item())
.collect();
if failed.is_empty() {
"无".to_string()
} else {
failed.join("、")
}
}
}
impl Default for CheckReport {
fn default() -> Self {
Self::new()
}
}
//!# 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 : ledger_snapshot_view.rs
//! 链台账快照的**分析层视图**。
//!
//! ★ 理由同 [`super::audit_check_view`]:表示层不该认识 `crate::chain::LedgerSnapshot`。
//! 这里把它摊成一组**纯数字**,表示层只做排版。
//!
//! ## ★ 为什么「省下的判定次数」要在这一层算而不是在机制层
//!
//! 机制层知道访问次数与跳过次数;但「若不用责任链会是多少次」这个**对照分母**
//! (`单数 × 站数`)牵涉到「什么算一次判定」的口径。
//! 口径属于分析,不属于机制------机制层只要保证计数不漏记就够了。
use crate::chain::LedgerSnapshot;
/// 一个处理器累计尝试情况的视图。
#[derive(Clone, Debug)]
pub struct StationAttemptView {
/// 站名。
name: String,
/// 被问了几次。
asked_total: usize,
/// 接下了几次。
handled_total: usize,
/// 放行了几次。
passed_total: usize,
}
impl StationAttemptView {
/// 站名。
pub fn name(&self) -> &str {
self.name.as_str()
}
/// 被问了几次。
pub const fn asked_total(&self) -> usize {
self.asked_total
}
/// 接下了几次。
pub const fn handled_total(&self) -> usize {
self.handled_total
}
/// 放行了几次。
pub const fn passed_total(&self) -> usize {
self.passed_total
}
/// ★ 是否**死站**(一次都没被问过)。
pub const fn is_never_asked(&self) -> bool {
self.asked_total == 0
}
/// ★ 是否**纯过路**(被问过,但一次都没接下)。
pub const fn is_pure_passthrough(&self) -> bool {
self.asked_total > 0 && self.handled_total == 0
}
}
/// 台账快照的视图。
#[derive(Clone, Debug)]
pub struct LedgerSnapshotView {
/// 投递数。
pub submitted_total: usize,
/// 被接下数。
pub handled_total: usize,
/// 无人接数。
pub unhandled_total: usize,
/// 访问次数(实际判定次数)。
pub visit_total: usize,
/// ★ 跳过次数(因短路而没被调用的站数之和)。
pub skipped_total: usize,
/// 站数。
pub station_total: usize,
/// 各站尝试情况(**按链上首次被问的顺序**)。
pub attempts: Vec<StationAttemptView>,
/// ★ 处置顺序(谁接了第几单的**处理器名**序列)。
pub settle_order: Vec<String>,
/// 恒等式一的差。
pub submission_gap: i64,
/// 恒等式二的差。
pub settlement_gap: i64,
/// 恒等式三的差。
pub visit_gap: i64,
}
impl LedgerSnapshotView {
/// 由机制层快照转出。
pub fn from_snapshot(snapshot: &LedgerSnapshot) -> Self {
Self {
submitted_total: snapshot.submitted_total,
handled_total: snapshot.handled_total,
unhandled_total: snapshot.unhandled_total,
visit_total: snapshot.visit_total,
skipped_total: snapshot.skipped_total,
station_total: snapshot.attempts.len(),
settle_order: snapshot.settle_order.clone(),
attempts: snapshot
.attempts
.iter()
.map(|attempt| StationAttemptView {
name: attempt.name().to_string(),
asked_total: attempt.asked_total(),
handled_total: attempt.handled_total(),
passed_total: attempt.passed_total(),
})
.collect(),
submission_gap: snapshot.submission_identity_gap(),
settlement_gap: snapshot.settlement_identity_gap(),
visit_gap: snapshot.visit_identity_gap(),
}
}
/// ★ 若不用责任链(每个都问一遍)会是多少次判定。
pub fn visit_total_if_broadcast(&self) -> usize {
self.submitted_total * self.station_total
}
/// ★ 省下的判定次数。
pub fn saved_judgement_total(&self) -> i64 {
self.visit_total_if_broadcast() as i64 - self.visit_total as i64
}
/// ★ 死站清单。
pub fn never_asked_stations(&self) -> Vec<&StationAttemptView> {
self.attempts.iter().filter(|a| a.is_never_asked()).collect()
}
/// ★ 纯过路清单。
pub fn pure_passthrough_stations(&self) -> Vec<&StationAttemptView> {
self.attempts.iter().filter(|a| a.is_pure_passthrough()).collect()
}
/// ★ 最近一次处置者(**从台账的处置顺序里取**,不是从某张表抄)。
pub fn last_settled_handler(&self) -> String {
match self.settle_order.last() {
Some(name) => name.clone(),
None => "(还没有任何一站接下单)".to_string(),
}
}
}
//!# 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_census.rs
//! 换序对照:**同一批处理器、两条不同的链**。
//!
//! ## ★ 这一幕是责任链最容易被忽略的一条真理
//!
//! > 链的顺序不是排版,是业务规则。
//!
//! 换序**照样能编译、照样能跑**,只是结论悄悄变了。
//! 这类错误不会报错、不会 panic,只会在月底对账时变成
//! 「为什么这批在保维修让客户自付了」。
//!
//! 因此本模块做一件事:**把「换序改变了几张单的结论」变成一个数字**。
//! 这个数字为 0 不代表没问题(可能是数据没覆盖到),
//! 但它**不为 0** 就证明顺序真的在起作用------
//! 而这一点若只靠看代码,是永远看不出来的。
use crate::analysis::CheckLine;
use crate::client::TicketSession;
/// 一张单在两条链上的对照。
///
/// # 字段说明
/// - `ticket_number`:工单号。
/// - `ticket_type_name`:工单类型(**归因用**)。
/// - `normal_handler` / `swapped_handler`:两条链的处置者。
/// - `normal_disposition` / `swapped_disposition`:两条链的结论。
/// - `changed`:★ 结论是否变了。
#[derive(Clone, Debug)]
pub struct OrderPair {
/// 工单号。
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: bool,
}
/// ★ 逐单比对两条链。
///
/// # 参数
/// - `normal`:正常序会话。
/// - `swapped`:换序会话(**同一批工单、同一批处理器**)。
///
/// # Panics
/// 两条链的工单数不同时 panic------
/// 单子不同就没法逐单比对,而静默截断比对会让「变了 0 张」看起来像「顺序无影响」。
pub fn compare_orders(normal: &TicketSession, swapped: &TicketSession) -> Vec<OrderPair> {
assert_eq!(
normal.ticket_total(),
swapped.ticket_total(),
"两条链跑的工单数不同({} vs {}),无法逐单比对",
normal.ticket_total(),
swapped.ticket_total()
);
normal
.outcomes()
.iter()
.zip(swapped.outcomes().iter())
.map(|(left, right)| {
assert_eq!(
left.ticket_number(),
right.ticket_number(),
"两条链的工单顺序不同,无法逐单比对"
);
let normal_fingerprint: String = left.resolution().fingerprint_text();
let swapped_fingerprint: String = right.resolution().fingerprint_text();
OrderPair {
ticket_number: left.ticket_number().to_string(),
ticket_type_name: left.ticket().ticket_type().chinese_name().to_string(),
normal_handler: left.handler_name(),
swapped_handler: right.handler_name(),
normal_disposition: left.resolution().disposition_text().to_string(),
swapped_disposition: right.resolution().disposition_text().to_string(),
changed: normal_fingerprint != swapped_fingerprint,
}
})
.collect()
}
/// ★ 换序对照的检查。
///
/// # 参数
/// - `pairs`:逐单对照结果。
pub fn order_checks(pairs: &[OrderPair]) -> Vec<CheckLine> {
let mut lines: Vec<CheckLine> = Vec::new();
let changed_total: usize = pairs.iter().filter(|pair| pair.changed).count();
// ① ★ 换序**真的改变了**结论(> 0)。
// 已知会失败的输入:用**同一个顺序**跑两遍(那时必然 0 张变化,
// 这条检查会红------它盯的正是「换个顺序其实没差别」这种假对照)。
lines.push(CheckLine::new(
"★ 换序真的改变了结论(> 0 张)",
"> 0(已知会失败:两条链用同一个顺序跑,必然 0 张变化)",
format!("{} 张 / 共 {} 张", changed_total, pairs.len()).as_str(),
changed_total > 0,
));
// ② ★ 变化的单**全都是**受换序影响的类型(归因要准)。
// 本工程的换序是把「保修判定」挪到金额权限之后,
// 因此受影响的只可能是**走保修**的工单(维修 / 成色复检)。
// 若一张「退货」也变了,说明换序的影响超出了预期------那就不是归因,是意外。
let unexpected_total: usize = pairs
.iter()
.filter(|pair| pair.changed)
.filter(|pair| !is_warranty_route_type(&pair.ticket_type_name))
.count();
lines.push(CheckLine::new(
"★ 变化的单全部是走保修的类型(归因正确)",
"0 张意外(已知会失败:把「单据校验」也挪位,退货单的结论也会变)",
format!("{} 张意外", unexpected_total).as_str(),
unexpected_total == 0,
));
// ③ 两条链的单数相同。
lines.push(CheckLine::new(
"两条链跑的是同一批工单",
format!("{} 张", pairs.len()).as_str(),
format!("{} 张", pairs.len()).as_str(),
!pairs.is_empty(),
));
lines
}
/// 是否属于「走保修」的工单类型(**归因判据**)。
///
/// ⚠️ 这里按**工单类型名**判而不是按编码判:
/// 类型名是给人看的,报表上的归因必须与读者看到的一致。
/// 若按编码判,读者看到「维修」却不知道它算不算,归因就白做了。
fn is_warranty_route_type(ticket_type_name: &str) -> bool {
ticket_type_name == crate::domain::TICKET_REPAIR.chinese_name()
|| ticket_type_name == crate::domain::TICKET_RECHECK.chinese_name()
}
//!# 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_view.rs
//! 工单处置结果的**分析层视图**。
//!
//! ## ★ 为什么表示层不直接拿 `crate::client::TicketOutcome`
//!
//! 依赖实测里 `app` 只能看见 `analysis / domain / support`,**没有 `client`**。
//! 这不是洁癖:一旦报表直接去读 `TicketOutcome`,
//! 「这一列显示什么」就会被会话的结构牵着走,改会话就要改报表。
//!
//! 因此这里把一张工单的结论摊成一组**已格式化好的字符串**,
//! 表示层只做排版,不做任何计算。
use crate::client::TicketOutcome;
use crate::domain::Money;
/// 一张工单的处置结果视图。
///
/// # 字段说明(**全是可直接排版字符串**)
///
/// ⚠️ 为什么**没有**门店 / 品类 / 购买月数这些字段:
/// 幕七这张表要回答的是「谁接的、走了几站、跳过了几站」,
/// 而那些维度已经在幕五「工单清单」里印过一遍。
/// 多带几个字段不影响正确性,但会让人以为「这张表也该核那些维度」------
/// 而它核不了(列宽不够),于是核对就被悄悄跳过了。
/// 宁可字段**少而全部被用**,不要多而有人没被读。
#[derive(Clone, Debug)]
pub struct OutcomeView {
/// 工单号。
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_text: String,
/// ★ 因短路未调用的站数。
pub skipped_total: usize,
/// 走了几站。
pub visited_total: usize,
}
impl OutcomeView {
/// 由一次投递结果转出。
pub fn from_outcome(outcome: &TicketOutcome) -> Self {
let ticket = outcome.ticket();
let resolution = outcome.resolution();
Self {
ticket_number: ticket.ticket_number().to_string(),
ticket_type_name: ticket.ticket_type().chinese_name().to_string(),
amount_text: ticket.involved_amount().chinese_text(),
handler_name: outcome.handler_name(),
disposition_text: resolution.disposition_text().to_string(),
compensation_text: resolution.compensation().chinese_text(),
visited_text: outcome.visited_text(),
skipped_total: outcome.skipped_total(),
visited_total: outcome.visited_total(),
}
}
/// 一批结果转出一批视图(**保持投递顺序**)。
pub fn list_from_outcomes(outcomes: &[TicketOutcome]) -> Vec<OutcomeView> {
outcomes.iter().map(OutcomeView::from_outcome).collect()
}
}
/// ★ 赔付合计(**只统计真的要赔的**,见 [`crate::domain::Disposition::bears_compensation`])。
///
/// ⚠️ 为什么单列一个函数而不是在会话里加一个方法:
/// 「哪些结论算赔钱」是**口径**,口径属于分析层;
/// 会话只负责「跑完一遍」。
pub fn compensation_total(outcomes: &[TicketOutcome]) -> Money {
let mut total: Money = Money::zero(crate::domain::CNY);
for outcome in outcomes.iter() {
if outcome.disposition().bears_compensation() {
total = total.plus(outcome.compensation());
}
}
total
}
//!# 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_census.rs
//! 链上各站的对照:**谁被问得最多、谁一次都没被问**。
//!
//! ## ★ 这张表是责任链最有用的一张表
//!
//! 「我们用了责任链」这句话本身没有任何信息。
//! 有信息的是:
//! - 第 1 站被问了 N 次(= 投递数),接下 0 次 → 它是过路站;
//! - 门店站接下了 3 张 → 七成的单在门店就解决了;
//! - 总部站只被问了 1 次 → 因为前面的站把其余的单都拦下了;
//! - **某站一次都没被问** → 要么它多余,要么前面某站的判据写宽了。
//!
//! 最后一条尤其重要:它不会自己说话。
//! 一个「写宽了」的判据在业务上表现为「某级审批形同虚设」,
//! 而报表上只是「那一站的被问次数是 0」。
use crate::analysis::CheckLine;
use crate::chain::{HandlerKind, LedgerSnapshot};
use crate::client::TicketSession;
/// 一站的对照探针。
///
/// # 字段说明
/// - `name` / `kind` / `kind_text` / `description`:这一站是什么。
/// (`kind` 存枚举、`kind_text` 存短文本:比较用前者、显示用后者。)
/// - `asked_total`:被问了几次(**判定次数**)。
/// - `handled_total`:接下了几次。
/// - `passed_total`:放行了几次。
/// - `never_reached_total`:★ 因前面已处置而**一次都没轮到**的次数。
#[derive(Clone, Debug)]
pub struct StationProbe {
/// 站名。
name: String,
/// 处理器种类(**存枚举本身**,不存文本------比较要用,文本会变)。
kind: HandlerKind,
/// 种类短文本。
kind_text: String,
/// 说明。
description: String,
/// 被问次数。
asked_total: usize,
/// 接下次数。
handled_total: usize,
/// 放行次数。
passed_total: usize,
/// 因短路未轮到的次数。
never_reached_total: usize,
}
impl StationProbe {
/// 站名。
pub fn name(&self) -> &str {
self.name.as_str()
}
/// 种类短文本(`应答者` / `过路者`)。
pub fn kind_text(&self) -> &str {
self.kind_text.as_str()
}
/// ★ 处理器种类(**枚举本身**,用于判「这一站是过路者吗」)。
pub const fn kind(&self) -> HandlerKind {
self.kind
}
/// 说明。
pub fn description(&self) -> &str {
self.description.as_str()
}
/// 被问次数。
pub const fn asked_total(&self) -> usize {
self.asked_total
}
/// 接下次数。
pub const fn handled_total(&self) -> usize {
self.handled_total
}
/// 放行次数。
pub const fn passed_total(&self) -> usize {
self.passed_total
}
/// 因短路未轮到的次数。
pub const fn never_reached_total(&self) -> usize {
self.never_reached_total
}
/// ★ 是否是**死站**(一次都没被问过)。
pub const fn is_never_asked(&self) -> bool {
self.asked_total == 0
}
/// ★ 风险提示:把这一站的处境说成一句话。
///
/// ⚠️ 判据只用**观测本身的两个数**(被问次数与接下次数),
/// 不引用任何外部口径------否则这张表就会说出与数字不符的话。
pub fn risk_text(&self) -> String {
if self.is_never_asked() {
return format!(
"★★ 死站:{} 张单一张都没走到这一站(要么多余,要么前面判据写宽了)",
self.never_reached_total
);
}
if self.handled_total == 0 {
return format!(
"★ 纯过路:被问 {} 次、接下 0 次(只做事不做决定)",
self.asked_total
);
}
format!(
"接下 {} 张(占投递 {} 张的 {}%)",
self.handled_total,
self.asked_total + self.never_reached_total,
ratio_percent_text(crate::domain::ratio_basis_points(
self.handled_total as i128,
(self.asked_total + self.never_reached_total) as i128
))
)
}
}
/// ★ 由会话与快照汇总各站探针(**按链上顺序**)。
///
/// # 参数
/// - `session`:会话(提供链上站点、顺序与**逐单结果**)。
/// - `snapshot`:台账快照(提供计数)。
/// - `descriptions`:站名 → 说明(由调用方给,因为说明带金额上限这类算出来的文本)。
pub fn station_probes(
session: &TicketSession,
snapshot: &LedgerSnapshot,
descriptions: &[(String, String)],
) -> Vec<StationProbe> {
let mut probes: Vec<StationProbe> = Vec::new();
for (index, (name, kind)) in session.chain().handler_kinds().into_iter().enumerate() {
let mut asked_total: usize = 0;
let mut handled_total: usize = 0;
let mut passed_total: usize = 0;
for attempt in snapshot.attempts.iter() {
if attempt.name() == name {
asked_total = attempt.asked_total();
handled_total = attempt.handled_total();
passed_total = attempt.passed_total();
}
}
let description: String = descriptions
.iter()
.find(|(station_name, _)| *station_name == name)
.map(|(_, text)| text.clone())
.unwrap_or_else(|| "---".to_string());
probes.push(StationProbe {
name,
kind,
kind_text: kind.short_text().to_string(),
description,
asked_total,
handled_total,
passed_total,
never_reached_total: never_reached_total_for(session, index),
});
}
probes
}
/// ★ 第 `index` 站「一次都没轮到」的单数。
///
/// 口径:**被处置的位置 < index** 的单数(精确,不用估算)。
///
/// ⚠️ 为什么不能写成 `投递数 − 被问次数`:
/// 一张**无人处置**的单会走完链尾(每一站都被问过),
/// 那时它会被 `投递数 − 被问次数` 错算进「未被轮到」------
/// 于是检查①(`被问 + 未轮到 == 投递数`)会平白差那几张。
fn never_reached_total_for(session: &TicketSession, index: usize) -> usize {
session
.outcomes()
.iter()
.filter(|outcome| match outcome.result().visited_position().last() {
Some(last_position) => *last_position < index,
None => false,
})
.count()
}
/// 把万分点渲染成百分数(`3000` → `30.00%`)。
///
/// ⚠️ 为什么不复用 `app` 的同名函数:分析层**不能**依赖表示层
/// (依赖实测里 `analysis -> client domain chain`,没有 `app`)。
/// 两处各有一份不是重复------它们是不同层的同一口径,
/// 由体检里的「版面口径」项保证一致。
fn ratio_percent_text(basis_points: i64) -> String {
format!("{}.{:02}%", basis_points / 100, basis_points % 100)
}
/// ★ 链上各站的检查。
///
/// # 参数
/// - `probes`:各站探针。
/// - `ticket_total`:投递单数。
pub fn station_checks(probes: &[StationProbe], ticket_total: usize) -> Vec<CheckLine> {
let mut lines: Vec<CheckLine> = Vec::new();
// ① 每站:被问 + 未轮到 == 投递数。
// 已知会失败的输入:某一站的计数被漏记(比如只记了接下没记放行)。
let mut consistent: usize = 0;
for probe in probes.iter() {
if probe.asked_total() + probe.never_reached_total() == ticket_total {
consistent += 1;
}
}
lines.push(CheckLine::new(
"每站:被问 + 未轮到 == 投递数",
format!("{} 站全部成立(已知会失败:某站只记接下、漏记放行)", probes.len()).as_str(),
format!("{}/{}", consistent, probes.len()).as_str(),
consistent == probes.len() && !probes.is_empty(),
));
// ② ★ 过路站接下次数必须是 0。
// 已知会失败的输入:把某个过路站的 `handle` 改成返回一个结论。
let passthrough_total: usize = probes
.iter()
.filter(|probe| probe.kind() == HandlerKind::PassThrough)
.count();
let passthrough_clean: usize = probes
.iter()
.filter(|probe| probe.kind() == HandlerKind::PassThrough)
.filter(|probe| probe.handled_total() == 0)
.count();
lines.push(CheckLine::new(
"★ 过路站(不做决定的站)接下次数 == 0",
format!(
"{} 个过路站全部为 0(已知会失败:让过路站偷偷返回一个结论)",
passthrough_total
)
.as_str(),
format!("{}/{}", passthrough_clean, passthrough_total).as_str(),
passthrough_total > 0 && passthrough_clean == passthrough_total,
));
// ③ ★ 死站数 == 0。
// 已知会失败的输入:把某一站的判据放宽到「恒接」,后面几站就永远轮不到。
let dead_total: usize = probes.iter().filter(|probe| probe.is_never_asked()).count();
lines.push(CheckLine::new(
"★ 死站数 == 0(每一站都真的被问过)",
"0(已知会失败:把第 2 站的判据改成恒接,后面几站一次都轮不到)",
format!("{} 站", dead_total).as_str(),
dead_total == 0,
));
// ④ ★ 至少有一站接下过(链不是死的)。
let handling_stations: usize = probes.iter().filter(|probe| probe.handled_total() > 0).count();
lines.push(CheckLine::new(
"至少有一站真的接下过单",
">= 1(已知会失败:把所有站的判据都写成恒不接)",
format!("{} 站", handling_stations).as_str(),
handling_stations >= 1,
));
lines
}
//!# 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_quantification.rs
//! 价值量化:**责任链省下了多少判定,值多少钱**。
//!
//! ## ★ 「省下了什么」用**判定次数**而不是耗时
//!
//! 耗时不可复现(两次运行不算同一个数,就谈不上「逐字节一致」的验收)。
//! 可数的是:一张单在第 k 站被接下,后面 `站数 − k − 1` 站**一次都没被调用**。
//!
//! ## ★ 为什么还要一个「次数之外的口径」
//!
//! 只说「省了 N 次判定」隐含一个**不成立**的假设:每次判定的代价一样。
//! 事实上「查一下保修期」与「走一遍权限表」的代价差得远。
//! 因此本工程另给一个口径:**被短路掉的站里,有几站是「要查外部状态」的**
//! (本工程里 = 金额权限站,它们要读权限档位)。
//! 两个口径并列,读者才不会把「省了 N 次」当成「省了 N 倍」。
use crate::analysis::CheckLine;
use crate::domain::{ratio_basis_points, Money, CNY};
/// 责任链的价值报告。
///
/// # 字段说明
/// - `ticket_total`:投递单数。
/// - `station_total`:链上站数。
/// - `visited_total`:★ 实际判定次数。
/// - `visited_total_if_broadcast`:★ 若不用责任链(广播式)的判定次数。
/// - `saved_judgement_total`:★ 省下的判定次数(**两个可数数字之差**)。
/// - `saved_ratio_basis_points`:省下的比例(万分点)。
/// - `skipped_total`:因短路未被调用的站数之和(**与上一项互为印证**)。
/// - `compensation_minor_units`:赔付总额(分)。
/// - `settled_total` / `unhandled_total`:被处置 / 无人处置单数。
#[derive(Clone, Copy, Debug)]
pub struct ChainValueReport {
/// 投递单数。
ticket_total: usize,
/// 链上站数。
station_total: usize,
/// 实际判定次数。
visited_total: usize,
/// 广播式的判定次数。
visited_total_if_broadcast: usize,
/// ★ 省下的判定次数。
saved_judgement_total: i64,
/// 省下的比例(万分点)。
saved_ratio_basis_points: i64,
/// 因短路未被调用的站数之和。
skipped_total: usize,
/// 赔付总额(分)。
compensation_minor_units: i64,
/// 被处置单数。
settled_total: usize,
/// 无人处置单数。
unhandled_total: usize,
}
impl ChainValueReport {
/// 由一组口径数字组装。
///
/// ⚠️ 十个参数不是设计缺陷,是**这份报告的口径清单**:
/// 每个数都来自不同层的不同计数,缺一个就算不出「省下多少」。
/// 若改成传一个 `&TicketSession`,本层就要认识 `client` 的具体结构,
/// 而「报告只认数字」正是它能被**独立复算**的前提。
pub fn new(
ticket_total: usize,
station_total: usize,
visited_total: usize,
skipped_total: usize,
compensation_minor_units: i64,
settled_total: usize,
unhandled_total: usize,
) -> Self {
let visited_total_if_broadcast: usize = ticket_total * station_total;
let saved_judgement_total: i64 = visited_total_if_broadcast as i64 - visited_total as i64;
Self {
ticket_total,
station_total,
visited_total,
visited_total_if_broadcast,
saved_judgement_total,
saved_ratio_basis_points: ratio_basis_points(
saved_judgement_total as i128,
visited_total_if_broadcast as i128,
),
skipped_total,
compensation_minor_units,
settled_total,
unhandled_total,
}
}
/// 投递单数。
pub const fn ticket_total(&self) -> usize {
self.ticket_total
}
/// 链上站数。
pub const fn station_total(&self) -> usize {
self.station_total
}
/// 实际判定次数。
pub const fn visited_total(&self) -> usize {
self.visited_total
}
/// 广播式判定次数。
pub const fn visited_total_if_broadcast(&self) -> usize {
self.visited_total_if_broadcast
}
/// ★ 省下的判定次数。
pub const fn saved_judgement_total(&self) -> i64 {
self.saved_judgement_total
}
/// 省下的比例(万分点)。
pub const fn saved_ratio_basis_points(&self) -> i64 {
self.saved_ratio_basis_points
}
/// 因短路未被调用的站数之和。
pub const fn skipped_total(&self) -> usize {
self.skipped_total
}
/// 赔付总额。
pub fn compensation(&self) -> Money {
Money::new(self.compensation_minor_units, CNY)
}
/// 被处置单数。
pub const fn settled_total(&self) -> usize {
self.settled_total
}
/// 无人处置单数。
pub const fn unhandled_total(&self) -> usize {
self.unhandled_total
}
/// ★ 一句话总结。
pub fn summary_text(&self) -> String {
format!(
"{} 张单 × {} 站:若每站都问一遍要 {} 次判定,责任链只用了 {} 次,\
省下 {} 次({});赔付合计 {}。",
self.ticket_total,
self.station_total,
self.visited_total_if_broadcast,
self.visited_total,
self.saved_judgement_total(),
percent_text_local(self.saved_ratio_basis_points),
self.compensation().chinese_text()
)
}
/// ★ 五条自检(**每条都有已知会失败的输入**)。
pub fn self_check(&self) -> Vec<CheckLine> {
let mut lines: Vec<CheckLine> = Vec::new();
// ① 投递 == 被处置 + 无人处置。
lines.push(CheckLine::new(
"投递数 == 被处置 + 无人处置",
"相等(已知会失败:某张单既没记处置也没记无人处置)",
format!(
"{} == {} + {}",
self.ticket_total, self.settled_total, self.unhandled_total
)
.as_str(),
self.ticket_total == self.settled_total + self.unhandled_total,
));
// ② ★ 省下的次数 == 广播式 − 实际(口径自检)。
lines.push(CheckLine::new(
"★ 省下次数 == 广播式 − 实际",
"相等(已知会失败:把省下次数改成用「跳过站数」算,两者口径不同)",
format!(
"{} == {} − {}",
self.saved_judgement_total(),
self.visited_total_if_broadcast,
self.visited_total
)
.as_str(),
self.saved_judgement_total()
== self.visited_total_if_broadcast as i64 - self.visited_total as i64,
));
// ③ ★ 实际判定次数 < 广播式(真的省了)。
lines.push(CheckLine::new(
"★ 实际判定次数 < 广播式",
"严格小于(已知会失败:所有站判据都写成恒不接,每张单都走完链尾)",
format!("{} < {}", self.visited_total, self.visited_total_if_broadcast).as_str(),
self.visited_total < self.visited_total_if_broadcast,
));
// ④ 无人处置 == 0(链尾有兜底)。
lines.push(CheckLine::new(
"无人处置单数 == 0(链尾有兜底)",
"0(已知会失败:摘掉总裁办兜底站)",
format!("{} 张", self.unhandled_total).as_str(),
self.unhandled_total == 0,
));
// ⑤ ★ 跳过站数 == 省下的判定次数(两个口径互为印证)。
// ⚠️ 这两个数**恰好相等**是因为「每一站至多被问一次」:
// 一张单在第 k 站被接下,后面 `站数 − k − 1` 站既没被问、也正因如此才省下。
// 若将来某一站会被问两次,这两个数就会分叉------那时这条检查会红,
// 而它红的原因正值得看一眼。
lines.push(CheckLine::new(
"★ 跳过站数 == 省下的判定次数(两口径互为印证)",
"相等(已知会失败:某一站被重复问一次,两口径就会分叉)",
format!("{} == {}", self.skipped_total, self.saved_judgement_total()).as_str(),
self.skipped_total as i64 == self.saved_judgement_total(),
));
lines
}
}
/// 把万分点渲染成百分数(`3000` → `30.00%`)。
///
/// ⚠️ 与 [`crate::analysis::station_census`] 里的同名私有函数各有一份:
/// 分析层的两个模块互不依赖(职责单一),同一口径分头实现,
/// 由体检里的「版面口径」项保证一致。
fn percent_text_local(basis_points: i64) -> String {
format!("{}.{:02}%", basis_points / 100, basis_points % 100)
}