rust: Simple Factory Pattern

项目结构:

ff51d5db-9c42-4cae-84b7-51f56ad8efb9

 

//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : certificate_kind.rs
//! # 证书类型 —— 出证形式
//!
//! ## 为什么它是一个独立的开放型类型
//!
//! 「证书类型」看上去只是检测类型的一个属性,直接在每个具体产品里
//! 写一个 `&'static str` 就够了。本工程仍把它做成独立类型,理由是**分组**:
//! 报表要回答「本批委托里有多少张会出**分级证书**」——这是按证书类型
//! 分组统计,而不是按检测类型。若证书类型只是一个裸字符串,
//! 分组键就没有类型可依赖,将来某处改成 `"分级证书 "`(多一个空格)
//! 就会静默地分裂成两组。
//!
//! 有类型的常量能在编译期统一,这是裸字符串做不到的。
 
/// 证书类型(开放型结构体)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CertificateKind {
    /// 编码,如 `"GRADING_CERTIFICATE"`。
    code: &'static str,
    /// 中文名,如 `"分级证书"`。
    chinese_name: &'static str,
}
 
impl CertificateKind {
    /// 构造一个证书类型。
    ///
    /// 参数 `code` / `chinese_name`。
    /// 返回:证书类型值对象。
    pub const fn new(code: &'static str, chinese_name: &'static str) -> CertificateKind {
        CertificateKind { 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 CERTIFICATE_KIND_TEST_REPORT: CertificateKind =
    CertificateKind::new("TEST_REPORT", "检测报告");
 
/// 鉴定证书(给出物件定名与是否经过处理)。
pub const CERTIFICATE_KIND_IDENTIFICATION: CertificateKind =
    CertificateKind::new("IDENTIFICATION_CERTIFICATE", "鉴定证书");
 
/// 分级证书(给出 4C 等分级结论)。
pub const CERTIFICATE_KIND_GRADING: CertificateKind =
    CertificateKind::new("GRADING_CERTIFICATE", "分级证书");
 
/// 专项报告(针对单一问题出具,如「是否经过充填处理」)。
pub const CERTIFICATE_KIND_SPECIAL_REPORT: CertificateKind =
    CertificateKind::new("SPECIAL_REPORT", "专项报告");
 
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : money.rs
//! # 金额 —— 整数最小单位,全程无浮点
//!
//! ## 为什么金额必须是整数
//!
//! 本工程的每一个金额都会被**累加**(一张委托单的检测费 = 若干项目的费用之和;
//! 一批委托单的总费用 = 各张之和),并且报表上印出来的数字必须能被读者
//! 用计算器**逐项复算**。
//!
//! 只要金额用 `f64` 表示,`0.1 + 0.2 != 0.3` 这类误差就会在累加中累积,
//! 最后报表上会出现「分项加起来比合计少一分」的现象——
//! 而读者按计算器会认为**表算错了**。在成本报表里,
//! 「有一个说不清的差额」是最该拒绝交付的状态。
//!
//! 因此本工程遵循与既有八个工程一致的纪律:
//! **金额存整数的最小单位(分)**,货币符号只在格式化层拼接。
//!
//! ## 为什么货币是「开放型结构体」而不是枚举
//!
//! 枚举要求「所有可能值都在本文件里写全」。而本工程要实证「完全可扩展」——
//! 若货币是枚举,实验室新增一个结算币种就必须修改**领域层文件**,
//! 而领域层是本工程宣称「扩展不需要碰」的层之一。因此货币写成
//! `const fn new(...)` 的开放型结构体,新增币种只是新增一个 `const`。
 
/// 货币种类(开放型结构体)。
///
/// 字段是 `&'static str` 而非 `String`:货币是**编译期常量**,
/// 没有运行期动态构造的需求,用字符串切片可以避免堆分配,
/// 也让 `Money` 保持 `Copy`。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Currency {
    /// ISO 4217 货币代码,如 `"CNY"`。
    code: &'static str,
    /// 中文名,如 `"人民币"`。
    chinese_name: &'static str,
    /// 货币符号,如 `"¥"`。
    symbol: &'static str,
    /// 一个主单位等于多少个次单位(分)。
    ///
    /// 写成字段而非常量,是因为并非所有货币都是 100 进制:
    /// 日元、韩元没有小数位(1 主单位 = 1 次单位)。
    /// 若把 100 写死,将来新增这类币种就要改本文件的算法,
    /// 而本工程的口径是「新增币种只加一个常量」。
    minor_units_per_major: i64,
}
 
impl Currency {
    /// 构造一个货币。
    ///
    /// 参数 `code` / `chinese_name` / `symbol` / `minor_units_per_major`。
    /// 返回:货币值对象。
    ///
    /// `const fn` 是关键:它让「新增币种」可以在常量区完成
    /// (`pub const CURRENCY_XXX: Currency = Currency::new(...);`),
    /// 不需要任何运行期初始化代码。
    pub const fn new(
        code: &'static str,
        chinese_name: &'static str,
        symbol: &'static str,
        minor_units_per_major: i64,
    ) -> Currency {
        Currency {
            code,
            chinese_name,
            symbol,
            minor_units_per_major,
        }
    }
 
    /// 货币代码(如 `"CNY"`)。
    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 fn minor_units_per_major(&self) -> i64 {
        self.minor_units_per_major
    }
 
    /// 组合描述,如 `CNY(人民币,符号 ¥,1 主单位 = 100 次单位)`。
    ///
    /// ## 为什么这个方法放在领域层而不是报表层
    ///
    /// 它是**纯数据描述**:把本类型自己的四个字段如实串起来,不含任何
    /// 列宽、对齐、缩进之类的排版决策。报表层若需要不同的排法,
    /// 完全可以自己读 `code()` 等四个出口再拼。
    /// 一致的设计口径是:**领域层只负责「是什么」,报表层只负责「怎么摆」**。
    pub fn description_text(&self) -> String {
        format!(
            "{}({},符号 {},1 主单位 = {} 次单位)",
            self.code, self.chinese_name, self.symbol, self.minor_units_per_major
        )
    }
}
 
/// 人民币。本工程的主要结算币种。
pub const CURRENCY_CHINESE_YUAN: Currency = Currency::new("CNY", "人民币", "¥", 100);
 
/// 港币。用于演示「跨币种运算必须显式拒绝」。
pub const CURRENCY_HONG_KONG_DOLLAR: Currency = Currency::new("HKD", "港币", "HK$", 100);
 
/// 一笔金额。
///
/// 内部只存 `minor_units`(分)与币种,不存小数——
/// 小数只在 [`Money::formatted`] 里由整数除余拼出,全程不经过浮点。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Money {
    /// 金额的**次单位**数量(人民币即「分」)。可以为负(退款、冲销)。
    minor_units: i64,
    /// 币种。
    currency: Currency,
}
 
impl Money {
    /// 由次单位数量与币种构造。
    ///
    /// 参数 `minor_units`:次单位数量(人民币即分);`currency`:币种。
    /// 返回:金额值对象。
    pub const fn from_minor_units(minor_units: i64, currency: Currency) -> Money {
        Money {
            minor_units,
            currency,
        }
    }
 
    /// 由主单位与次单位构造,如 `from_major_and_minor(1130, 0, CNY)` = ¥1,130.00。
    ///
    /// 参数 `major_units`:主单位(元);`minor_units`:次单位(分);
    /// `currency`:币种。
    /// 返回:金额值对象。
    ///
    /// ⚠️ 两个入参的**符号必须一致**(都正或都负),否则会得到
    /// 一个语义含混的结果(`-88` 元 `+50` 分既不是 -87.50 也不是 -88.50)。
    /// 这里按「主单位决定符号,次单位取其绝对值」处理并注释在此——
    /// 因为唯一的负金额用例是退款(`from_major_and_minor(-88, 0, ..)`),
    /// 显式规定比让调用方各自猜要好。
    pub fn from_major_and_minor(
        major_units: i64,
        minor_units: i64,
        currency: Currency,
    ) -> Money {
        let sign: i64 = if major_units < 0 { -1 } else { 1 };
        let total: i64 =
            major_units * currency.minor_units_per_major() + sign * minor_units.abs();
        Money {
            minor_units: total,
            currency,
        }
    }
 
    /// 零金额。
    pub const fn zero(currency: Currency) -> Money {
        Money {
            minor_units: 0,
            currency,
        }
    }
 
    /// 次单位数量(人民币即分)。
    pub const fn minor_units(&self) -> i64 {
        self.minor_units
    }
 
    /// 币种。
    pub const fn currency(&self) -> Currency {
        self.currency
    }
 
    /// 是否为零。
    pub const fn is_zero(&self) -> bool {
        self.minor_units == 0
    }
 
    /// 是否为负。
    pub const fn is_negative(&self) -> bool {
        self.minor_units < 0
    }
 
    /// 取绝对值。
    pub fn absolute(&self) -> Money {
        Money {
            minor_units: self.minor_units.abs(),
            currency: self.currency,
        }
    }
 
    /// 取相反数。
    pub fn negated(&self) -> Money {
        Money {
            minor_units: -self.minor_units,
            currency: self.currency,
        }
    }
 
    /// 同币种相加。
    ///
    /// 参数 `other`:另一个金额。
    /// 返回:同币种时返回 `Some(和)`;**跨币种返回 `None`**。
    ///
    /// ## 为什么跨币种不给兜底
    ///
    /// 给兜底(比如「按 1:1 相加」或「按某个汇率换算」)会产出一个
    /// **看着完全合理的错账**。而 `None` 会让调用方必须显式处理,
    /// 缺陷在编译期/测试期就暴露。本工程的口径是:
    /// **宁可让调用方多写三行,也不要让一个错金额流进报表**。
    pub fn add(&self, other: &Money) -> Option<Money> {
        if self.currency != other.currency {
            return None;
        }
        Some(Money {
            minor_units: self.minor_units + other.minor_units,
            currency: self.currency,
        })
    }
 
    /// 同币种相减(`self - other`)。
    ///
    /// 参数 `other`:被减金额。
    /// 返回:同币种时返回 `Some(差)`;跨币种返回 `None`(理由同 [`Money::add`])。
    pub fn subtract(&self, other: &Money) -> Option<Money> {
        if self.currency != other.currency {
            return None;
        }
        Some(Money {
            minor_units: self.minor_units - other.minor_units,
            currency: self.currency,
        })
    }
 
    /// 乘以一个整数倍数。
    ///
    /// 参数 `quantity`:倍数(如样品件数、项目数)。
    /// 返回:乘后的金额。
    ///
    /// 用 `i128` 做中间量再落回 `i64`:本工程的倍数不大,但
    /// **中间量放大是金额计算的通用防线**——一旦将来有人传进一个很大的倍数,
    /// `i64` 直接相乘会静默溢出成一个负数金额,而负数金额在报表上
    /// 看起来像是「退款」,非常难发现。这里宁可多写一次转换。
    pub fn multiply_by_quantity(&self, quantity: i64) -> Money {
        let product: i128 = self.minor_units as i128 * quantity as i128;
        Money {
            minor_units: product as i64,
            currency: self.currency,
        }
    }
 
    /// 按 `分子/分母` 比例缩放(分)。
    ///
    /// 参数 `numerator`:分子;`denominator`:分母(不得为 0)。
    /// 返回:缩放后的金额。
    ///
    /// ## 为什么分母是自由的,而不是固定 100
    ///
    /// 珠宝检测行业里有「损耗率」「加急附加费」这类口径,
    /// 其分母并不固定。更重要的是**价内税分离的分母是 113 而不是 100**:
    /// 税额 = 含税价 × 13/113。用 `Rate`(分母固定 10000)表达不了,
    /// 必须有一个「自由分母」的入口。
    ///
    /// ## 舍入口径
    ///
    /// 用「远离零方向四舍五入」:`(分子绝对值 + 分母/2) / 分母` 再补回符号。
    /// 不用 `f64`,不用 `round()`。这个口径要**写在这里、只写一次**,
    /// 否则「同一个比例在计价处与报表处算出不同的分」这种缺陷必然出现。
    pub fn scale_by_ratio(&self, numerator: i64, denominator: i64) -> Money {
        if denominator == 0 {
            // 分母为 0 不是「可以糊过去」的情况:返回零并保留币种,
            // 让报表上出现一个 0 而不是 panic 或一个荒谬的巨大值。
            return Money::zero(self.currency);
        }
        let product: i128 = self.minor_units as i128 * numerator as i128;
        let divisor: i128 = denominator as i128;
        // 远离零方向四舍五入:先把绝对值加上半个分母再整除。
        let rounded: i128 = if product >= 0 {
            (product + divisor / 2) / divisor
        } else {
            (product - divisor / 2) / divisor
        };
        Money {
            minor_units: rounded as i64,
            currency: self.currency,
        }
    }
 
    /// 格式化为 `¥1,130.00` 形式(带千位分隔符)。
    ///
    /// ## 千位分隔符必须自己写
    ///
    /// Rust 标准格式化没有千位分隔(`{:?}` 是 Debug、`{:,}` 不存在)。
    /// 报表上「¥286094.68」与「¥286,094.68」的差别不是好不好看:
    /// 前者读者要一位一位数,后者一眼可读。金额报表的可读性直接影响
    /// 「读者能不能当场发现算错了」。
    pub fn formatted(&self) -> String {
        let per_major: i64 = self.currency.minor_units_per_major();
        let sign_text: &str = if self.minor_units < 0 { "-" } else { "" };
        let absolute_units: i64 = self.minor_units.abs();
        let major_part: i64 = absolute_units / per_major;
        let minor_part: i64 = absolute_units % per_major;
 
        // 主单位部分加千位分隔符:从右往左每三位插一个逗号。
        let major_text: String = major_part.to_string();
        let mut grouped: String = String::new();
        let digits: Vec<char> = major_text.chars().collect();
        for (position, digit) in digits.iter().enumerate() {
            // 还有多少位数字没处理(含当前位):是 3 的倍数且不是第一位时插逗号。
            let remaining: usize = digits.len() - position;
            if position > 0 && remaining % 3 == 0 {
                grouped.push(',');
            }
            grouped.push(*digit);
        }
 
        // 次单位:按「一个主单位等于几个次单位」决定需要几位小数。
        // 用 `{}` 定位宽度,兼容「1 主单位 = 1 次单位」(日元)这类无小数币种。
        let minor_width: usize = format!("{}", per_major - 1).len();
        format!(
            "{}{}{}.{:0width$}",
            sign_text,
            self.currency.symbol(),
            grouped,
            minor_part,
            width = minor_width
        )
    }
 
    /// 格式化为不带货币符号的纯数字文本(如 `1,130.00`)。
    ///
    /// 用于同一列内币种相同、不想重复印符号的场景。
    pub fn plain_text(&self) -> String {
        let formatted: String = self.formatted();
        // 去掉前缀里的货币符号(符号本身可能含多个字符,如 `HK$`)。
        let symbol: &str = self.currency.symbol();
        formatted
            .strip_prefix('-')
            .map(|rest| rest.strip_prefix(symbol).unwrap_or(rest))
            .map(|rest| {
                if self.minor_units < 0 {
                    format!("-{}", rest)
                } else {
                    rest.to_string()
                }
            })
            .unwrap_or(formatted)
    }
}
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : pricing_basis.rs
//! # 计价口径 —— 检测费按什么算
//!
//! ## 这个类型为什么不做成「算法分派键」
//!
//! 「按件计价 / 按克拉计价 / 按项目数计价」听起来像是要对它做 `match`,
//! 从而选出计价函数——但那正好会**再引入一个编译期分派点**,
//! 与本工程「把分派集中到一处」的主张冲突(多一个 `match` 就多一个
//! 「新增产品时要改的地方」)。
//!
//! 本工程的做法是:**计价逻辑由每个具体产品自己实现**
//! ([`crate::product::TestingOrder::estimated_fee`]),
//! `PricingBasis` 只作为**一个可供分组统计的标签**存在。
//! 分析层可以问「按克拉计价的委托单贡献了多少金额」,
//! 但它永远不需要 `match` 这个标签——分组用的是哈希表,不是分支。
//!
//! 这个区分很重要:**「可分类的标签」与「可分派的键」是两件事**。
//! 前者只需要相等性,后者会引入编译期耦和。
 
/// 计价口径(开放型结构体)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PricingBasis {
    /// 编码,如 `"PER_PIECE"`。
    code: &'static str,
    /// 中文名,如 `"按件计价"`。
    chinese_name: &'static str,
}
 
impl PricingBasis {
    /// 构造一个计价口径。
    ///
    /// 参数 `code` / `chinese_name`。
    /// 返回:计价口径值对象。
    pub const fn new(code: &'static str, chinese_name: &'static str) -> PricingBasis {
        PricingBasis { 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 PRICING_BASIS_PER_PIECE: PricingBasis = PricingBasis::new("PER_PIECE", "按件计价");
 
/// 按克拉计价(钻石分级按克拉重量线性计价)。
pub const PRICING_BASIS_PER_CARAT: PricingBasis = PricingBasis::new("PER_CARAT", "按克拉计价");
 
/// 按检测项目数计价(每个检测项目一个固定单价)。
pub const PRICING_BASIS_PER_ITEM: PricingBasis = PricingBasis::new("PER_ITEM", "按项目数计价");
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : rate.rs
//! # 比率 —— 整数万分点,全程无浮点
//!
//! ## 为什么比率也必须是整数
//!
//! 本工程用比率表达两类业务口径:
//! 1. **加急附加费率**:「加急检测加收 30%」;
//! 2. **损耗率**:「破坏性检测的样品损耗按 5% 计入成本」。
//!
//! 与金额同样的理由:比率一旦用 `f64`,`30%` 就变成 `0.30000000000000004`,
//! 再乘上金额、累加若干次之后,最后一位就会漂移。
//! 报表上会出现「按 30% 算出来是 339.00,表上印的是 338.99」这种
//! 读者一看就认为**表算错了**的现象。
//!
//! 因此本工程把比率存成**整数万分点**(basis points,`1% = 100` 万分点),
//! 与既有八个工程口径一致。
//!
//! ## 为什么需要两个入口(`apply_to` 与 `scale_by_ratio`)
//!
//! `Rate` 的分母固定为 10000,因此它能表达百分数、千分数、万分数。
//! 但珠宝检测行业里有一个**分母不固定**的口径:**价内税分离**
//! (税额 = 含税价 × 13/113)。13/113 无法用「分母 10000」近似到精确,
//! 因此金额上必须另有一个自由分母的入口 [`crate::domain::Money::scale_by_ratio`]。
//!
//! 本模块提供一个[`Rate::apply_to`],它是「分母固定 10000」这条口径的
//! **唯一**入口;自由分母的场景一律走 `Money::scale_by_ratio`。
//! 两条路径都只留一个入口,避免「同一个比例在两处各写一遍」。
 
use crate::domain::money::{Currency, Money};
 
/// 一个比率(整数万分点)。
///
/// `basis_points = 10000` 表示 100%,`= 3000` 表示 30%,`= 50` 表示 0.5%。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Rate {
    /// 万分点数量。可以为负(表示下调)。
    basis_points: i64,
}
 
impl Rate {
    /// 万分点总量(100% = 10000)。
    ///
    /// 这个常量是「百分数 ↔ 万分点」换算的唯一依据,不要在多处各写 10000。
    pub const FULL_PERCENT_BASIS_POINTS: i64 = 10_000;
 
    /// 由万分点构造。
    ///
    /// 参数 `basis_points`:万分点(10000 = 100%)。
    /// 返回:比率。
    pub const fn from_basis_points(basis_points: i64) -> Rate {
        Rate { basis_points }
    }
 
    /// 由整数百分点构造,如 `from_percent(30)` = 30%。
    ///
    /// 参数 `percent`:百分点。
    /// 返回:比率。
    ///
    /// 这个入口存在的意义是**让调用点写出的数字与业务口径完全一致**:
    /// 业务上说的是「加收 30%」,代码里就写 `from_percent(30)`,
    /// 不必让读者在脑子里做一次 ×100 的换算。
    /// 换算只在这里发生一次。
    pub const fn from_percent(percent: i64) -> Rate {
        Rate {
            basis_points: percent * 100,
        }
    }
 
    /// 零比率(0%)。
    pub const fn zero() -> Rate {
        Rate { basis_points: 0 }
    }
 
    /// 百分之百(100%)。
    pub const fn one() -> Rate {
        Rate {
            basis_points: Rate::FULL_PERCENT_BASIS_POINTS,
        }
    }
 
    /// 万分点数量。
    pub const fn basis_points(&self) -> i64 {
        self.basis_points
    }
 
    /// 是否为零。
    pub const fn is_zero(&self) -> bool {
        self.basis_points == 0
    }
 
    /// 是否大于 100%。
    ///
    /// 用于体检:附加费率或损耗率超过 100% 是几乎必然的配置错误,
    /// 应当被报表标出来。
    pub const fn is_above_one(&self) -> bool {
        self.basis_points > Rate::FULL_PERCENT_BASIS_POINTS
    }
 
    /// 与另一个比率比较大小。
    ///
    /// 参数 `other`:另一个比率。
    /// 返回:`self` 严格大于 `other` 时为 `true`。
    pub const fn is_greater_than(&self, other: &Rate) -> bool {
        self.basis_points > other.basis_points
    }
 
    /// 作用到一笔金额上(`金额 × 比率`)。
    ///
    /// 参数 `amount`:被作用的金额。
    /// 返回:结果金额(币种与入参一致)。
    ///
    /// 实现直接复用 [`Money::scale_by_ratio`](分子 = 万分点,分母 = 10000),
    /// 因此舍入口径只有一个。**不要**在这里另写一套乘除——
    /// 那会让「同一个比率在计价处与报表处算出不同的分」重新出现。
    pub fn apply_to(&self, amount: &Money) -> Money {
        amount.scale_by_ratio(self.basis_points, Rate::FULL_PERCENT_BASIS_POINTS)
    }
 
    /// 格式化为 `30.00%` 形式。
    ///
    /// 用整数除余拼串,全程不经过浮点:
    /// 万分点先整除 100 得到整数百分点,余数就是小数部分。
    pub fn as_percent_text(&self) -> String {
        let sign_text: &str = if self.basis_points < 0 { "-" } else { "" };
        let absolute_points: i64 = self.basis_points.abs();
        let whole_percent: i64 = absolute_points / 100;
        let fractional: i64 = absolute_points % 100;
        format!("{}{}.{:02}%", sign_text, whole_percent, fractional)
    }
 
    /// 格式化为 `0.3000` 形式的小数文本(保留 4 位)。
    ///
    /// 用于报表里并排展示「万分点」与「小数」两种口径——
    /// 读者可以据此确认 `3000 万分点 == 0.3000`,而不是只能相信一个数字。
    pub fn as_decimal_text(&self) -> String {
        let sign_text: &str = if self.basis_points < 0 { "-" } else { "" };
        let absolute_points: i64 = self.basis_points.abs();
        let whole: i64 = absolute_points / Rate::FULL_PERCENT_BASIS_POINTS;
        let fractional: i64 = absolute_points % Rate::FULL_PERCENT_BASIS_POINTS;
        format!("{}{}.{:04}", sign_text, whole, fractional)
    }
}
 
/// 一个「带币种的零金额」的便捷构造。
///
/// 单独提出来是因为本工程多处需要「先给一个 0,再累加」的初值,
/// 而每次都要写 `Money::zero(CURRENCY_CHINESE_YUAN)` 有点吵。
/// 但**不**把它做成默认参数——默认币种是隐患:
/// 一旦某处忘了改,就会静默地用人民币给港币单据记账。
///
/// 参数 `currency`:币种。
/// 返回:零金额。
pub const fn zero_money(currency: Currency) -> Money {
    Money::from_minor_units(0, currency)
}
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : sample_kind.rs
//! # 样品类别 —— 送检物件的大类
//!
//! ## 为什么它必须是独立类型,而不是复用编码类型
//!
//! 「样品类别」与「检测类型编码」都是「编码 + 中文名」的开放型结构体,
//! 形状完全一样。但**不能**合并成同一个类型:一旦合并,
//! 「把样品类别传给需要检测类型编码的位置」就会**静默编译通过**,
//! 而两者恰好都是短字符串,出错的报表看起来完全正常
//! (只是把「素金」印在了「检测类型」那一列)。
//!
//! 拆成两个类型之后,这类错误在编译期就被挡住。
//! 「形状相同就合并」是一种只在代码行数上有收益的优化,
//! 代价是丢掉一类最隐蔽的错误防线——本工程不接受这个交换。
 
/// 样品类别(开放型结构体)。
///
/// 与 [`crate::domain::TestingOrderCode`] 一样是「编码 + 中文名」,
/// 但**刻意**不共用同一个类型,理由见本模块文档。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SampleKind {
    /// 编码,如 `"PRECIOUS_METAL"`。
    code: &'static str,
    /// 中文名,如 `"素金"`。
    chinese_name: &'static str,
}
 
impl SampleKind {
    /// 构造一个样品类别。
    ///
    /// 参数 `code` / `chinese_name`。
    /// 返回:样品类别值对象。
    pub const fn new(code: &'static str, chinese_name: &'static str) -> SampleKind {
        SampleKind { 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 SAMPLE_KIND_PRECIOUS_METAL: SampleKind = SampleKind::new("PRECIOUS_METAL", "素金");
 
/// 钻石(裸石或镶嵌钻石首饰)。
pub const SAMPLE_KIND_DIAMOND: SampleKind = SampleKind::new("DIAMOND", "钻石");
 
/// 彩色宝石。
pub const SAMPLE_KIND_GEMSTONE: SampleKind = SampleKind::new("GEMSTONE", "宝石");
 
/// 玉石。
pub const SAMPLE_KIND_JADE: SampleKind = SampleKind::new("JADE", "玉石");
 
/// 银饰。
pub const SAMPLE_KIND_SILVER: SampleKind = SampleKind::new("SILVER", "银饰");
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : testing_item.rs
//! # 检测项目 —— 委托单上的一行
//!
//! ## 为什么项目费要放进「项目」自己身上
//!
//! 「按项目数计价」的检测类型,其费用 = Σ 各项目的单价。
//! 一个自然的错误做法是:把单价写在各具体产品的计价函数里
//! (`if item.code() == "CLARITY" { ... }`),那又是一个 `match`。
//!
//! 本工程把单价**挂在项目自己身上**([`TestingItem::item_fee`]),
//! 于是「按项目数计价」的产品只需要遍历项目列表求和,
//! 完全不需要认识任何一个具体项目。新增一个检测项目时,
//! **计价代码一行都不用改**——这与本工程「扩展零改动」的主张一致。
//!
//! ## 单价是「值」而不是「率」
//!
//! 项目费是固定金额(不随样品重量变化),因此用 [`Money`] 而不是 `Rate`。
//! 若将来出现「按克拉的项目费」,那时应当为该项目新增一个字段,
//! 而不是把它硬塞进费率——「单价」与「费率」是两种不同的量。
 
use crate::domain::money::{Money, CURRENCY_CHINESE_YUAN};
 
/// 一个检测项目。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TestingItem {
    /// 项目编码,如 `"CLARITY"`。
    code: &'static str,
    /// 中文名,如 `"净度分级"`。
    chinese_name: &'static str,
    /// 该项目的固定单价。
    ///
    /// 放进结构体(而不是用一张全局价格表)的理由见本模块文档:
    /// 让「按项目数计价」的产品无需认识任何具体项目。
    item_fee: Money,
}
 
impl TestingItem {
    /// 构造一个检测项目。
    ///
    /// 参数 `code` / `chinese_name` / `item_fee`。
    /// 返回:检测项目值对象。
    pub const fn new(
        code: &'static str,
        chinese_name: &'static str,
        item_fee: Money,
    ) -> TestingItem {
        TestingItem {
            code,
            chinese_name,
            item_fee,
        }
    }
 
    /// 项目编码。
    pub const fn code(&self) -> &'static str {
        self.code
    }
 
    /// 中文名。
    pub const fn chinese_name(&self) -> &'static str {
        self.chinese_name
    }
 
    /// 该项目的固定单价。
    pub const fn item_fee(&self) -> Money {
        self.item_fee
    }
}
 
// ---------------------------------------------------------------------------
// 内置检测项目
// ---------------------------------------------------------------------------
// 单价全部是「整数分」,且都取整到「元」——不是为了好看,而是为了让
// 读者能用计算器**口算**复核报表(本工程要求每个数字可复核)。
// 例如宝石鉴定的四项合计 = 150 + 180 + 90 + 110 = 530.00(元),一眼可验。
 
/// 成色测定(贵金属纯度检测的核心项目)。
pub const TESTING_ITEM_PURITY_ASSAY: TestingItem =
    TestingItem::new("PURITY_ASSAY", "成色测定", Money::from_minor_units(38_000, CURRENCY_CHINESE_YUAN));
 
/// 金属含量测定(银饰纯度检测的核心项目)。
pub const TESTING_ITEM_METAL_CONTENT: TestingItem =
    TestingItem::new("METAL_CONTENT", "金属含量测定", Money::from_minor_units(26_000, CURRENCY_CHINESE_YUAN));
 
/// 克拉重量(钻石 4C 之一)。
pub const TESTING_ITEM_CARAT_WEIGHT: TestingItem =
    TestingItem::new("CARAT_WEIGHT", "克拉重量", Money::from_minor_units(12_000, CURRENCY_CHINESE_YUAN));
 
/// 颜色分级(钻石 4C 之一)。
pub const TESTING_ITEM_COLOR_GRADE: TestingItem =
    TestingItem::new("COLOR_GRADE", "颜色分级", Money::from_minor_units(26_000, CURRENCY_CHINESE_YUAN));
 
/// 净度分级(钻石 4C 之一)。
pub const TESTING_ITEM_CLARITY_GRADE: TestingItem =
    TestingItem::new("CLARITY_GRADE", "净度分级", Money::from_minor_units(28_000, CURRENCY_CHINESE_YUAN));
 
/// 切工分级(钻石 4C 之一)。
pub const TESTING_ITEM_CUT_GRADE: TestingItem =
    TestingItem::new("CUT_GRADE", "切工分级", Money::from_minor_units(24_000, CURRENCY_CHINESE_YUAN));
 
/// 折射率测定(宝石/玉石鉴定的常规项目)。
pub const TESTING_ITEM_REFRACTIVE_INDEX: TestingItem =
    TestingItem::new("REFRACTIVE_INDEX", "折射率测定", Money::from_minor_units(15_000, CURRENCY_CHINESE_YUAN));
 
/// 密度测定(宝石/玉石鉴定的常规项目)。
pub const TESTING_ITEM_SPECIFIC_GRAVITY: TestingItem =
    TestingItem::new("SPECIFIC_GRAVITY", "密度测定", Money::from_minor_units(18_000, CURRENCY_CHINESE_YUAN));
 
/// 紫外荧光观察(判断是否经过处理的常用手段)。
pub const TESTING_ITEM_UV_FLUORESCENCE: TestingItem =
    TestingItem::new("UV_FLUORESCENCE", "紫外荧光观察", Money::from_minor_units(9_000, CURRENCY_CHINESE_YUAN));
 
/// 放大检查(十倍放大镜下观察内部特征)。
pub const TESTING_ITEM_MAGNIFICATION: TestingItem =
    TestingItem::new("MAGNIFICATION", "放大检查", Money::from_minor_units(11_000, CURRENCY_CHINESE_YUAN));
 
/// 全部内置检测项目。
///
/// ## 为什么需要这张总表
///
/// 报表里有一张「检测项目价目表」,它必须列出**全部**项目及其单价——
/// 包括那些当前没有任何检测类型使用的项目(例如将来新增的「红外光谱」)。
/// 若靠遍历各产品去反推项目清单,未被使用的项目就不会出现在报表上,
/// 于是「库里有个项目没被任何类型用上」这件事永远无法被发现。
///
/// 这张表同时也是「项目单价唯一来源」:各具体产品引用本表里的常量,
/// 因此改价只改一处。**不要**在各产品文件里重新写一遍单价数字。
pub const BUILTIN_TESTING_ITEMS: [TestingItem; 10] = [
    TESTING_ITEM_PURITY_ASSAY,
    TESTING_ITEM_METAL_CONTENT,
    TESTING_ITEM_CARAT_WEIGHT,
    TESTING_ITEM_COLOR_GRADE,
    TESTING_ITEM_CLARITY_GRADE,
    TESTING_ITEM_CUT_GRADE,
    TESTING_ITEM_REFRACTIVE_INDEX,
    TESTING_ITEM_SPECIFIC_GRAVITY,
    TESTING_ITEM_UV_FLUORESCENCE,
    TESTING_ITEM_MAGNIFICATION,
];
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : testing_order_code.rs
//! # 检测类型编码 —— 本工程「分派」的关键字
//!
//! ## 这个类型为什么是整个工程的枢纽
//!
//! 简单工厂模式的全部逻辑就是**「按一个关键字选出一个产品」**。
//! 那个关键字就是本类型。因此它有两个必须做对的地方:
//!
//! ### 1. 必须是「开放型结构体」,不能是枚举
//!
//! 若写成 `enum TestingOrderCode { Purity, Grading, ... }`,
//! 那么实验室新增一种检测类型时,**必须修改本文件**。
//! 而本工程要实证的正是「新增一种检测类型**不需要**改分层文件」——
//! 枚举会让这个主张当场失效(不是因为写不出来,而是因为每次扩展
//! 都要碰领域层,扩展成本就不再是零)。
//!
//! 写成 `const fn new(...)` 的结构体之后,新增类型只是新增一个 `const`,
//! 而且这个 `const` 可以写在**工程外**(`main.rs` 的扩展区)。
//!
//! ### 2. 相等性必须只依据编码字符串
//!
//! 派生 `PartialEq` 会连同 `chinese_name` 一起比较。这在演示里恰好不会出错
//! (同名同码),但它埋了一个隐患:一旦有人给同一个编码写了两个不同的中文名,
//! 分派表就会认为「这是两种产品」,于是报表上出现两条同名不同源的记录。
//! 因此本类型**手写** `PartialEq` / `Eq` / `Hash`,只比较 `code`。
//! 这是刻意的,不是偷懒。
 
use std::fmt;
 
/// 检测类型编码(开放型结构体)。
#[derive(Debug, Clone, Copy)]
pub struct TestingOrderCode {
    /// 编码本身,如 `"PRECIOUS_METAL_PURITY"`。**相等性只看这个字段。**
    code: &'static str,
    /// 中文名,如 `"贵金属纯度检测"`。仅用于展示,不参与相等性比较。
    chinese_name: &'static str,
}
 
impl TestingOrderCode {
    /// 构造一个检测类型编码。
    ///
    /// 参数 `code`:大写蛇形编码;`chinese_name`:中文名。
    /// 返回:编码值对象。
    ///
    /// `const fn` 是关键:它让**工程外**可以用
    /// `const MY_CODE: TestingOrderCode = TestingOrderCode::new(...);`
    /// 直接定义新类型,不需要任何运行期初始化。
    pub const fn new(code: &'static str, chinese_name: &'static str) -> TestingOrderCode {
        TestingOrderCode { code, chinese_name }
    }
 
    /// 编码字符串。
    pub const fn code(&self) -> &'static str {
        self.code
    }
 
    /// 中文名。
    pub const fn chinese_name(&self) -> &'static str {
        self.chinese_name
    }
}
 
/// 只比较编码:见类型文档里「相等性必须只依据编码字符串」的说明。
impl PartialEq for TestingOrderCode {
    fn eq(&self, other: &TestingOrderCode) -> bool {
        self.code == other.code
    }
}
 
/// 见 [`PartialEq`] 的手写实现说明。
impl Eq for TestingOrderCode {}
 
/// 哈希也只依据编码,必须与 [`PartialEq`] 保持同一口径——
/// 否则 `HashMap` 会出现「相等但哈希不同」的荒谬状态,
/// 表现为「明明登记过的编码查不到」。
impl std::hash::Hash for TestingOrderCode {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        self.code.hash(state);
    }
}
 
/// 展示为 `CODE(中文名)`。
///
/// 这个格式在报表里被大量使用(分派表、覆盖率表、错误消息),
/// 放在类型自己的 `Display` 里可以保证**全工程口径一致**。
/// 若让各处自己拼,迟早会出现「有的地方带空格有的不带」这种
/// 破坏「两次运行逐字节一致」的差异。
impl fmt::Display for TestingOrderCode {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}({})", self.code, self.chinese_name)
    }
}
 
// ---------------------------------------------------------------------------
// 内置的五种检测类型
// ---------------------------------------------------------------------------
// 用 `const` 而不是 `static`:它们是编译期常量,`const` 允许编译器在
// 使用处内联,也允许它们出现在常量表达式里(如数组初始化)。
 
/// 贵金属纯度检测(素金首饰的成色判定)。
pub const ORDER_CODE_PRECIOUS_METAL_PURITY: TestingOrderCode =
    TestingOrderCode::new("PRECIOUS_METAL_PURITY", "贵金属纯度检测");
 
/// 钻石分级(4C 分级:克拉重量、颜色、净度、切工)。
pub const ORDER_CODE_DIAMOND_GRADING: TestingOrderCode =
    TestingOrderCode::new("DIAMOND_GRADING", "钻石分级");
 
/// 宝石鉴定(确定宝石种类与是否经过处理)。
pub const ORDER_CODE_GEMSTONE_IDENTIFICATION: TestingOrderCode =
    TestingOrderCode::new("GEMSTONE_IDENTIFICATION", "宝石鉴定");
 
/// 玉石鉴定(确定玉种与产地特征)。
pub const ORDER_CODE_JADE_AUTHENTICATION: TestingOrderCode =
    TestingOrderCode::new("JADE_AUTHENTICATION", "玉石鉴定");
 
/// 银饰纯度检测。
pub const ORDER_CODE_SILVER_PURITY: TestingOrderCode =
    TestingOrderCode::new("SILVER_PURITY", "银饰纯度检测");
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : creation_error.rs
//! # 创建错误 —— 工厂侧的两类失败
//!
//! ## 本类型只装「工厂的失败」,产品的失败另有一处
//!
//! 一个完整的创建失败信息由两层组成,本文件负责把它们**合成一个对外类型**,
//! 但两类失败的归属层次不同(见 [`crate::product::SpecificationRejection`] 的
//! 模块文档):
//!
//! - [`CreationError::UnknownOrderCode`] —— **工厂的失败**:
//!   编码不在分派表里。工厂不知道「钻石分级」是什么,没得商量。
//! - [`CreationError::Rejected`] —— **产品的失败**:
//!   工厂认识这个编码,但拿到的样品规格不合格。
//!
//! ## 为什么要把「已知的编码清单」带进错误里
//!
//! 「未知编码」这条消息如果只印 `未知编码:PEARL_AUTHENTICATION`,
//! 读者(以及日志)无法判断到底是**拼错了**还是**这个功能还没上线**。
//! 把工厂当前认识的编码清单一起带上,读者一眼就能做出判断:
//! 清单里有相近的 `DIAMOND_GRADING`,那就是拼错;
//! 清单里根本没有鉴定类,那就是没上线。
//!
//! 代价是错误对象变大(多一个 `Vec<String>`)。这个代价值得——
//! 创建失败本来就不该频繁发生,而每一次发生都需要被快速定位。
 
use crate::domain::TestingOrderCode;
use crate::product::SpecificationRejection;
 
/// 创建失败**发生在哪一层**。
///
/// ## 为什么必须把这个区分做成一个类型,而不是让调用方各自 `match`
///
/// 这个区分在报表上是有后果的,而且后果不小:
/// `OrderCreator::supports` 承诺的是「我认识这个编码」,
/// **不是**「你这张单子一定造得出来」。因此
///
/// - 失败在 [`FailureLayer::Factory`](不认识编码)→ **与 `supports` 的承诺直接冲突**,
///   是一处真正的「声明与行为矛盾」;
/// - 失败在 [`FailureLayer::Product`](认得编码,但样品规格不合格)→
///   承诺仍然成立,**不构成矛盾**。
///
/// 本工程初版把两者合并计数,于是标准工厂平白多出 5 条「矛盾」
/// (5 张单子因为样品类别不符/参数缺失/参数越界被产品侧拒绝),
/// 连报表注解里那句「反例 B 的矛盾为 0」都和它自己上面的表格对不上。
/// 这正是把「语义区分」留给调用方各自 `match` 的代价——
/// 会有人在某一天漏掉其中一支,而漏掉之后报表**看起来仍然正常**。
///
/// 做成类型之后,取值必须 `match`,编译器会替我们记着这件事。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureLayer {
    /// 工厂层:编码不在分派表里(`supports` 承诺过的编码竟然不认识)。
    Factory,
    /// 产品层:编码认得,但样品规格被产品侧拒绝(承诺并未落空)。
    Product,
}
 
impl FailureLayer {
    /// 稳定的层次编码(报表按它归组统计)。
    pub fn code(&self) -> &'static str {
        match self {
            FailureLayer::Factory => "FACTORY",
            FailureLayer::Product => "PRODUCT",
        }
    }
 
    /// 中文名(报表展示用)。
    pub fn chinese_name(&self) -> &'static str {
        match self {
            FailureLayer::Factory => "工厂层",
            FailureLayer::Product => "产品层",
        }
    }
}
 
/// 创建一张委托单时可能出现的失败。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CreationError {
    /// 工厂不认识这个编码。
    UnknownOrderCode {
        /// 请求的编码(原样保留,便于直接定位送检单)。
        requested_code: String,
        /// 该创建者当前认识的**全部**编码(顺序稳定,便于打印)。
        known_codes: Vec<String>,
    },
 
    /// 工厂认识这个编码,但样品规格被产品侧拒绝。
    Rejected {
        /// 被请求的编码。
        requested_code: String,
        /// 产品侧给出的拒绝原因。
        rejection: SpecificationRejection,
    },
}
 
impl CreationError {
    /// 这次失败发生在哪一层。
    ///
    /// 判据只有一条:**失败的原因是「不认识编码」还是「规格不合格」**。
    /// 这条判据的全部使用者是 `analysis::audit_support_claims`——
    /// 它要回答「`supports` 的承诺有没有落空」,
    /// 而只有工厂层的失败才意味着承诺落空。
    ///
    /// 注意这里**不是**「错误严重程度」的分级:产品层的拒绝同样是真实缺陷
    /// (送检单填错了),只是它不构成对 `supports` 的反证。
    /// 两个问题的答案不同,所以不能合并成一个布尔值。
    pub fn layer(&self) -> FailureLayer {
        match self {
            CreationError::UnknownOrderCode { .. } => FailureLayer::Factory,
            CreationError::Rejected { .. } => FailureLayer::Product,
        }
    }
 
    /// 稳定的规则编码(报表按它归组统计)。
    ///
    /// 与 [`SpecificationRejection::rule_code`] 同一口径:
    /// 编码用于统计,一经发布不再改动;含义变化应当新增编码而不是改旧编码。
    pub fn rule_code(&self) -> &'static str {
        match self {
            CreationError::UnknownOrderCode { .. } => "UNKNOWN_ORDER_CODE",
            // 产品侧的拒绝原因沿用其自身编码,这样报表上
            // 「样品类别不符」这类业务问题不会被折叠成一个笼统的「被拒」。
            CreationError::Rejected { rejection, .. } => rejection.rule_code(),
        }
    }
 
    /// 可读的中文说明。
    ///
    /// ## 已知编码清单的截断口径
    ///
    /// 清单最多列出前 12 个(本工程目前 5~6 个,远不会触发),
    /// 超出时补一个省略号并说明总数。这样做是为了让错误文本有**上界**——
    /// 若将来产品数量增长到几十个,一条错误消息会撑爆整张报表的列宽。
    /// 「错误消息也要有长度上界」这条经验来自报表被长文本撑破的教训。
    pub fn description_text(&self) -> String {
        /// 错误消息里最多列出的已知编码数量。
        const MAXIMUM_LISTED_CODES: usize = 12;
 
        match self {
            CreationError::UnknownOrderCode {
                requested_code,
                known_codes,
            } => {
                let listed: Vec<String> = known_codes
                    .iter()
                    .take(MAXIMUM_LISTED_CODES)
                    .cloned()
                    .collect();
                let suffix: String = if known_codes.len() > MAXIMUM_LISTED_CODES {
                    format!("…(共 {} 个)", known_codes.len())
                } else {
                    String::new()
                };
                format!(
                    "未知编码「{}」:本创建者只认识 [{}]{}",
                    requested_code,
                    listed.join(" / "),
                    suffix
                )
            }
            CreationError::Rejected {
                requested_code,
                rejection,
            } => format!("编码「{}」{}", requested_code, rejection.description_text()),
        }
    }
 
    /// 被请求的编码文本。
    ///
    /// 两类失败都携带请求编码,因此这个方法总是有值——
    /// 报表要按「是哪一个编码出的问题」归组时用它。
    pub fn requested_code_text(&self) -> &str {
        match self {
            CreationError::UnknownOrderCode { requested_code, .. } => requested_code,
            CreationError::Rejected { requested_code, .. } => requested_code,
        }
    }
 
    /// 从产品侧拒绝原因构造。
    ///
    /// 参数 `requested_code`:被请求的编码;`rejection`:产品侧拒绝原因。
    /// 返回:创建错误。
    ///
    /// 提供这个构造入口,是为了让两个创建者(编译期白名单与运行期登记表)
    /// 在「构造器返回 `Err`」时用**同一段代码**做包装——
    /// 否则两处各写一遍 `CreationError::Rejected { .. }`,
    /// 将来给枚举加字段就会漏改一处。
    pub fn from_rejection(requested_code: &TestingOrderCode, rejection: SpecificationRejection) -> CreationError {
        CreationError::Rejected {
            requested_code: requested_code.code().to_string(),
            rejection,
        }
    }
}
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : creation_ledger.rs
//! # 创建账本 —— 每个创建者只记自己的账
//!
//! ## 为什么账本属于「创建者」而不是「调用方」
//!
//! 本工程要比较两版分派机制(编译期白名单 vs 运行期登记表)在**同一批委托单**
//! 上的表现。若把「创建了几张」记在调用方身上,那么当两版被串在同一段驱动代码里
//! 依次跑时,账会混在一起,比较就失去了基准。
//!
//! 因此在既有工程里已经验证过的那条纪律在这里同样适用:
//! **谁付出代价,谁记账。** 创建这件事由创建者完成,
//! 于是「成功几次、被拒几次、遇到几个未知编码」全部记在创建者自己的账本上。
//!
//! ## 为什么四个计数分开、绝不用减法推导
//!
//! 本账本有四个基础计数:
//! `created_count` / `rejected_count` / `unknown_code_count`,
//! 以及由它们相加得到的 `attempted_count`。
//!
//! **不从「总调用数 − 成功数」推失败数**。原因是本工程的失败有**两条路径**
//! (未知编码、产品侧拒绝),减法推导在有两条以上失败路径时立刻失真——
//! 而且失真的方式是「数字看起来还是很合理」,最难发现。
//!
//! ## 为什么按编码/规则的明细要用 `Vec` 而不是 `HashMap`
//!
//! 因为 `HashMap` 的遍历顺序随哈希种子变化,而本工程要求
//! **两次运行输出逐字节一致**。用 `Vec` 累加、打印前再排序,
//! 顺序就完全由内容决定。这条经验在既有工程里被反复验证过。
 
use crate::domain::TestingOrderCode;
 
/// 一个创建者的创建账本。
///
/// ## 为什么派生 `PartialEq`
///
/// 本工程要把两版分派机制的账本**逐字段比对**(第六幕)。
/// 若账本不可比较,就得在分析层手写一长串字段比较,
/// 而那段代码在给账本加第五个计数时会**静默漏比一项**——
/// 于是「两版一致」的结论就建立在一个漏了字段的比较之上。
/// 派生实现随结构体自动更新,不可能漏。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CreationLedger {
    /// 成功创建的张数。
    created_count: u32,
    /// 被产品侧拒绝的笔数。
    rejected_count: u32,
    /// 遇到未知编码的笔数。
    unknown_code_count: u32,
    /// 按编码统计的成功创建张数。
    created_by_code: Vec<(TestingOrderCode, u32)>,
    /// 按规则编码统计的拒绝笔数。
    rejected_by_rule: Vec<(&'static str, u32)>,
    /// 被请求过但不认识的编码(按首次出现的顺序保留,可能有重复)。
    unknown_codes: Vec<String>,
}
 
impl CreationLedger {
    /// 新建一个空账本。
    pub fn new() -> CreationLedger {
        CreationLedger::default()
    }
 
    /// 记一笔成功创建。
    ///
    /// 参数 `order_code`:被创建的检测类型编码。
    pub fn record_created(&mut self, order_code: &TestingOrderCode) {
        self.created_count += 1;
        // 在明细里找到该编码并 +1;没有就新增一条。
        // 线性查找:本工程产品数量是个位数,查找成本远低于 HashMap 的哈希开销,
        // 而且顺序天然稳定。
        for entry in self.created_by_code.iter_mut() {
            if entry.0 == *order_code {
                entry.1 += 1;
                return;
            }
        }
        self.created_by_code.push((*order_code, 1));
    }
 
    /// 记一笔产品侧拒绝。
    ///
    /// 参数 `rule_code`:拒绝规则编码(来自
    /// [`crate::product::SpecificationRejection::rule_code`])。
    pub fn record_rejected(&mut self, rule_code: &'static str) {
        self.rejected_count += 1;
        for entry in self.rejected_by_rule.iter_mut() {
            if entry.0 == rule_code {
                entry.1 += 1;
                return;
            }
        }
        self.rejected_by_rule.push((rule_code, 1));
    }
 
    /// 记一笔未知编码请求。
    ///
    /// 参数 `requested_code`:被请求的编码文本。
    pub fn record_unknown_code(&mut self, requested_code: &str) {
        self.unknown_code_count += 1;
        self.unknown_codes.push(requested_code.to_string());
    }
 
    /// 成功创建的张数。
    pub fn created_count(&self) -> u32 {
        self.created_count
    }
 
    /// 被拒的笔数。
    pub fn rejected_count(&self) -> u32 {
        self.rejected_count
    }
 
    /// 未知编码的笔数。
    pub fn unknown_code_count(&self) -> u32 {
        self.unknown_code_count
    }
 
    /// 总尝试笔数 = 成功 + 被拒 + 未知编码。
    ///
    /// 这是**求和**而不是减法推导:三个加数都是各自独立累计的实测值。
    /// 若三者之和与调用方统计的请求笔数不等,说明有请求既没成功也没被记失败——
    /// 那是一个应当被体检抓出来的缺口(见分析层的对账规则)。
    pub fn attempted_count(&self) -> u32 {
        self.created_count + self.rejected_count + self.unknown_code_count
    }
 
    /// 按编码的成功创建明细(顺序为首次成功创建的先后)。
    pub fn created_by_code(&self) -> &[(TestingOrderCode, u32)] {
        &self.created_by_code
    }
 
    /// 按规则的拒绝明细(顺序为首次出现的先后)。
    pub fn rejected_by_rule(&self) -> &[(&'static str, u32)] {
        &self.rejected_by_rule
    }
 
    /// 被请求过的未知编码(可能有重复,按请求顺序)。
    pub fn unknown_codes(&self) -> &[String] {
        &self.unknown_codes
    }
 
    /// 去重并排序后的未知编码清单。
    ///
    /// 报表用它,因为「同一个错编码被请求了 3 次」应当显示成一行而不是三行。
    /// 排序保证两次运行的输出一致。
    pub fn distinct_unknown_codes_sorted(&self) -> Vec<String> {
        let mut distinct: Vec<String> = self.unknown_codes.clone();
        distinct.sort();
        distinct.dedup();
        distinct
    }
 
    /// 按规则编码排序后的拒绝明细。
    ///
    /// 参数省略——排序键就是规则编码本身。
    /// 返回:`(规则编码, 笔数)` 的升序列表。
    pub fn rejected_by_rule_sorted(&self) -> Vec<(&'static str, u32)> {
        let mut sorted: Vec<(&'static str, u32)> = self.rejected_by_rule.clone();
        sorted.sort_by(|left, right| left.0.cmp(right.0));
        sorted
    }
}
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : order_creator.rs
//! # 创建者抽象 —— 让「分派机制」成为一个可替换的维度
//!
//! ## 为什么本工程需要一个 trait,而「简单工厂」本身并不要求它
//!
//! 教科书上的简单工厂就是一个具体类(`XxxFactory`)加一个静态方法。
//! 本工程额外抽出一个 trait,**不是为了套设计模式**,而是一个可检验的动机:
//!
//! > 本工程要论证「简单工厂的代价是扩展必须修改分派表」。
//!
//! 要论证这句话,就必须能**把分派表换掉、其余代码一行不改**,
//! 然后比较两次的结果。若没有这个 trait,两版工厂就要在驱动代码里
//! 各写一套调用逻辑,那么「结果差异」到底来自分派机制还是来自驱动代码,
//! 就说不清了——**对照组不干净,实验结论就不可信**。
//!
//! 有了 trait,同一段驱动代码可以对两版运行,
//! 唯一的自变量就是 `Box<dyn OrderCreator>` 里装的是谁。
//!
//! ## 这个 trait 的签名是刻意「窄」的
//!
//! 全部方法都用 `&self`,且只接受 `domain` 与 `product` 的值对象。
//! 没有任何方法暴露「分派表长什么样」「表存在哪」——
//! 因为那正是两版机制的差异所在,把它抬进接口就等于把差异变成了契约。
//! 上层能问的只有:能造吗?认识哪些?这次的账本是什么?
//!
//! ## 为什么 `create` 取 `&self` 而不是 `&mut self`
//!
//! 因为计数是**观测副作用**,不是业务状态变更。若签名要 `&mut self`,
//! 调用方就必须为「只想查一下能不能造」这种只读操作也拿可变借用,
//! 于是整条调用链都被污染成可变——这是既有工程里反复踩到的坑,
//! 处置办法是内部用 `Cell`/`RefCell`,接口保持 `&self`。
 
use crate::domain::TestingOrderCode;
use crate::factory::creation_error::CreationError;
use crate::factory::creation_ledger::CreationLedger;
use crate::product::{SampleSpecification, TestingOrder};
 
/// 一个「按编码造委托单」的创建者。
///
/// 本工程有两个实现:
/// - [`crate::factory::TestingOrderFactory`]:分派表在**编译期**写死;
/// - [`crate::factory::TestingOrderRegistry`]:分派表在**运行期**可追加。
///
/// 两者的差异就是本工程要量化的「简单工厂的代价」。
pub trait OrderCreator {
    /// 按编码与样品规格创建一张委托单。
    ///
    /// 参数 `order_code`:检测类型编码(分派键);
    /// `specification`:样品规格。
    /// 返回:成功时返回产品,失败时返回 [`CreationError`]。
    fn create(
        &self,
        order_code: &TestingOrderCode,
        specification: &SampleSpecification,
    ) -> Result<Box<dyn TestingOrder>, CreationError>;
 
    /// 是否认识该编码(**不产生**任何创建副作用)。
    ///
    /// 参数 `order_code`:检测类型编码。
    /// 返回:认识为 `true`。
    ///
    /// ## 为什么这个方法必须「无副作用」
    ///
    /// 报表要做「覆盖率检查」:把**全部已知编码**逐一带进创建者问一遍
    /// 「你认识它吗」。若 `supports` 会记账,覆盖率检查本身就会污染账本,
    /// 于是「创建了几张」这个数字里混进了检查动作——这正是既有工程里
    /// 「探针查询污染主链」那类缺陷的同构版本。
    /// 因此本方法**只读**,且在实现里明确不触碰账本。
    fn supports(&self, order_code: &TestingOrderCode) -> bool;
 
    /// 该创建者当前认识的**全部**编码(顺序稳定)。
    ///
    /// 返回 `Vec` 而非 `&[..]`:运行期登记表的清单存在 `RefCell` 里,
    /// 无法安全地借出一个长生命周期切片。代价是一次小分配,可接受。
    fn supported_codes(&self) -> Vec<TestingOrderCode>;
 
    /// 机制的短名(报表用它区分两版,如「编译期白名单」)。
    fn mechanism_name(&self) -> &'static str;
 
    /// 机制的说明(一句话讲清它「扩展时要动什么」)。
    fn mechanism_note(&self) -> &'static str;
 
    /// 本机制「**必须手动保持同步的清单份数**」。
    ///
    /// ## 这个数是本工程要量化的那个东西
    ///
    /// 简单工厂的代价不是「改一行」,而是「改两处并保持同步」。
    /// 那「两处」就是本方法的返回值:
    ///
    /// | 机制 | 份数 | 是哪几份 |
    /// |---|---|---|
    /// | 编译期白名单 | **2** | 白名单(接受哪些编码)+ 构造器表(怎么造) |
    /// | 运行期登记表 | **1** | 登记表本身就是白名单,两份合一 |
    ///
    /// ## 为什么让它由机制自己回答,而不是报表去猜
    ///
    /// 报表初版按 `mechanism_name()` 的字符串匹配来决定印 1 还是 2。
    /// 那是个坏做法:字符串一旦改动,报表会静默地落进兜底分支。
    /// 更重要的是,**「清单有几份」是机制的固有属性**,
    /// 与「机制叫什么名字」是同一种信息,理应由同一个地方给出。
    ///
    /// 把它声明成 trait 方法还有一个副作用:新增第三种机制时,
    /// **编译器会强迫实现者回答这个问题**,而不是让它默默沿用某个默认值。
    fn dispatch_list_count(&self) -> usize;
 
    /// 取当前账本的**快照**。
    ///
    /// ## 为什么是快照而不是引用
    ///
    /// 账本存在 `RefCell` 里,借出引用会让调用方在持有期间阻塞所有创建操作。
    /// 更重要的是:报表与对账必须建立在**同一时刻**的数据上。
    /// 若报表一边打印计数、一边继续有创建发生,印出来的分项与合计就可能对不上。
    /// 快照语义把这件事变成结构上的保证。
    fn ledger_snapshot(&self) -> CreationLedger;
}
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : testing_order_factory.rs
//! # 简单工厂(编译期白名单版)—— 本工程要量化其代价的那一版
//!
//! ## 这一版的「简单」体现在哪,代价又体现在哪
//!
//! **简单**:一个结构体、一个方法,调用方给出编码就拿到对象。这是教科书上的
//! 简单工厂,也是它被广泛使用的原因——没有继承层级、没有抽象工厂、没有产品族。
//!
//! **代价**:它需要**两份必须手动同步的清单**:
//!
//! | 清单 | 在哪 | 作用 |
//! |---|---|---|
//! | 白名单 [`BUILTIN_SUPPORTED_ORDER_CODES`] | 本文件(`factory` 层) | 决定「接受哪些编码」 |
//! | 构造器表 | [`crate::product::builtin_product_builders`](`product` 层) | 决定「怎么造」 |
//!
//! 这是本工程要证明的核心命题:
//! **简单工厂的扩展成本不是「改一行」,而是「改两处并保持同步」**。
//!
//! 两份清单会以**两个方向**失配,而两个方向的危险程度并不对称。
//! 本工程的初稿把它写成「一个显性、一个隐性(完全没有报错)」,
//! 那个说法**不准确**——实测下来两个方向都会在运行期表现为「未知编码」。
//! 准确的说法是:
//!
//! | 失配方向 | `supports` 的回答 | 创建的结果 | 判定 |
//! |---|---|---|---|
//! | 白名单有、构造器表没有 | `true`(承诺支持) | 失败 | **自相矛盾**:承诺了却做不到 |
//! | 构造器表有、白名单没有 | `false`(不承诺) | 失败 | **完全自洽**:与「功能没做」不可区分 |
//!
//! 第二个方向才是真正难发现的:**它的报错看起来完全合理**。
//! 从对外行为看,你无法区分「这个功能没上线」与
//! 「上线了、但忘了往白名单加一行」。要发现它,只能去比那两份清单——
//! 而「知道有两份清单要比」本身,就是这一版机制转嫁给维护者的成本。
//!
//! 本工程把这件事做成**可测量的**:见 `analysis::dispatch_table_mismatch`
//! 的双向差集,以及 `analysis::audit_support_claims` 的
//! 「声明与行为是否矛盾」审计。前者抓两个方向,后者只抓得到第一个方向——
//! **这个「抓不到」本身就是证据**。
//!
//! ## 与运行期登记表版本的对照
//!
//! 登记表版本([`crate::factory::TestingOrderRegistry`])只有**一份**清单——
//! 表本身就是白名单,因此**结构上不可能失配**。
//! 这个差异不是「写法风格」,而是两种机制的本质区别,也正是本工程
//! 第七幕要量化出来的东西。
 
use std::cell::RefCell;
 
use crate::domain::TestingOrderCode;
use crate::factory::creation_error::CreationError;
use crate::factory::creation_ledger::CreationLedger;
use crate::factory::order_creator::OrderCreator;
use crate::product::{
    builtin_product_builders, SampleSpecification, TestingOrder,
};
use crate::product::testing_order::ProductBuilder;
 
/// 编译期白名单:本工厂接受的全部编码。
///
/// ## ★ 这一行就是「简单工厂代价」的物证
///
/// 数组长度 `5` 是**写死的**。新增一种检测类型时,这里必须
/// ① 加一个元素、② 把长度从 5 改成 6。同时还要去
/// `product` 层往构造器表里加一行。也就是说,**一次扩展要动两个层、三处**。
///
/// 对照运行期登记表:那里只需要在调用方写一行 `register(..)`,
/// 分层目录一个文件都不用碰。
///
/// ## 为什么不做成「从构造器表自动推导」
///
/// 因为那样它就不再是白名单了——它退化成登记表,
/// 本工程就失去了要对照的那一版。**保留这份冗余是刻意的**:
/// 冗余正是这一版机制的特征,抹掉它等于把被考察的对象抹掉了。
pub const BUILTIN_SUPPORTED_ORDER_CODES: [TestingOrderCode; 5] = [
    crate::domain::ORDER_CODE_PRECIOUS_METAL_PURITY,
    crate::domain::ORDER_CODE_SILVER_PURITY,
    crate::domain::ORDER_CODE_DIAMOND_GRADING,
    crate::domain::ORDER_CODE_GEMSTONE_IDENTIFICATION,
    crate::domain::ORDER_CODE_JADE_AUTHENTICATION,
];
 
/// 简单工厂(编译期白名单版)。
pub struct TestingOrderFactory {
    /// 白名单:接受哪些编码。
    ///
    /// 存成字段而不是直接引用常量,是为了让本工程能构造出
    /// **失配的反例工厂**(第七幕用它演示「白名单与构造器表不同步」会被体检抓到)。
    supported_codes: Vec<TestingOrderCode>,
    /// 构造器表:怎么造。
    builders: Vec<(TestingOrderCode, ProductBuilder)>,
    /// 创建账本。用 `RefCell` 包起来,使 [`OrderCreator`] 的方法全部可以取 `&self`。
    ledger: RefCell<CreationLedger>,
}
 
impl TestingOrderFactory {
    /// 用给定的白名单与构造器表构造一个工厂。
    ///
    /// 参数 `supported_codes`:白名单;`builders`:构造器表。
    /// 返回:工厂。
    ///
    /// ## 为什么构造入口是公开的,而这**不**削弱「扩展要改分层文件」的结论
    ///
    /// 调用方当然可以自己拼一份白名单再 new 一个工厂。但那不构成「扩展」:
    /// 它只是**调用方自己又写了一遍分派逻辑**,而工程资产
    /// [`TestingOrderFactory::builtin`] 返回的那个工厂仍然不认识新产品——
    /// 其他任何拿到标准工厂的代码都得不到新能力。
    ///
    /// 本工程要说清的是:**扩展的判定标准是「标准工厂的能力是否变化」**,
    /// 不是「有没有办法绕过它」。绕过之后你自己维护清单,那正是简单工厂
    /// 把成本转嫁给使用者的表现,而不是它的优点。
    pub fn new(
        supported_codes: Vec<TestingOrderCode>,
        builders: Vec<(TestingOrderCode, ProductBuilder)>,
    ) -> TestingOrderFactory {
        TestingOrderFactory {
            supported_codes,
            builders,
            ledger: RefCell::new(CreationLedger::new()),
        }
    }
 
    /// 构造工程标准工厂:编译期白名单 + 内置构造器表。
    pub fn builtin() -> TestingOrderFactory {
        TestingOrderFactory::new(
            BUILTIN_SUPPORTED_ORDER_CODES.to_vec(),
            builtin_product_builders(),
        )
    }
 
    /// 在构造器表里查一个编码对应的构造器。
    ///
    /// 参数 `order_code`:检测类型编码。
    /// 返回:找到时返回构造器。
    fn find_builder(&self, order_code: &TestingOrderCode) -> Option<ProductBuilder> {
        // 线性查找:清单长度是个位数,查找成本可忽略,且不引入哈希顺序问题。
        self.builders
            .iter()
            .find(|entry| entry.0 == *order_code)
            .map(|entry| entry.1)
    }
 
    /// ★ 构造器表里的**全部编码**(按表内顺序,稳定)。
    ///
    /// ## 这个出口是给「两份清单比对」用的
    ///
    /// 本工厂自己有白名单与构造器表两份清单,因此它是唯一能一次拿到
    /// 两份清单的地方。但**比对判据不住在这里,而是住在 `analysis` 层**
    /// (`analysis::dispatch_table_mismatch`)。分工是:
    ///
    /// | 谁 | 负责 |
    /// |---|---|
    /// | 本工厂 | **给出数据**:白名单(经 `OrderCreator::supported_codes`)与构造器表(本方法) |
    /// | `analysis` | **做出判断**:双向差集、是否同步、哪一侧更危险 |
    ///
    /// ## 为什么不把差集写成工厂自己的方法(初稿曾经如此)
    ///
    /// 初稿写过 `whitelist_without_builder()` / `builders_without_whitelist()` /
    /// `lists_are_in_sync()` 三个方法。删掉它们的原因是:
    /// **一个没人调用的检查等于不存在**。挂在工厂上,它们只是「一个可选能力」;
    /// 搬进分析层之后,它们成了每次体检都必然执行的一条。
    ///
    /// 而且搬走之后还多了一个好处:判据变成了纯数据函数,
    /// 于是它也能套在**工程外构造的反例工厂**上(见 `main.rs` 第九幕)——
    /// 那是本工程演示「失配会被抓到」的地方。
    pub fn builder_codes(&self) -> Vec<TestingOrderCode> {
        self.builders.iter().map(|entry| entry.0).collect()
    }
}
 
impl OrderCreator for TestingOrderFactory {
    fn create(
        &self,
        order_code: &TestingOrderCode,
        specification: &SampleSpecification,
    ) -> Result<Box<dyn TestingOrder>, CreationError> {
        // 第一步:白名单校验。**不在这里查构造器表**,因为白名单才是
        // 「本工厂承诺支持什么」的权威。先查白名单的好处是错误信息更准确:
        // 「未知编码」而不是「白名单有但表里没有」。
        if !self.supported_codes.contains(order_code) {
            self.ledger
                .borrow_mut()
                .record_unknown_code(order_code.code());
            return Err(CreationError::UnknownOrderCode {
                requested_code: order_code.code().to_string(),
                known_codes: self
                    .supported_codes
                    .iter()
                    .map(|code| code.code().to_string())
                    .collect(),
            });
        }
 
        // 第二步:取构造器。走到这里说明白名单通过,但构造器表仍可能缺失
        // (两份清单失配的第一种方向)。此时按「未知编码」处理并记账,
        // 让这种内部不一致在账本上留下痕迹,而不是悄悄返回一个错误的对象。
        let Some(builder) = self.find_builder(order_code) else {
            self.ledger
                .borrow_mut()
                .record_unknown_code(order_code.code());
            return Err(CreationError::UnknownOrderCode {
                requested_code: order_code.code().to_string(),
                known_codes: self
                    .supported_codes
                    .iter()
                    .map(|code| code.code().to_string())
                    .collect(),
            });
        };
 
        // 第三步:交给构造器。产品侧的拒绝在这里被包装成创建错误,
        // 包装逻辑走 CreationError::from_rejection,两版机制共用同一段代码。
        match builder(specification) {
            Ok(order) => {
                self.ledger.borrow_mut().record_created(order_code);
                Ok(order)
            }
            Err(rejection) => {
                self.ledger
                    .borrow_mut()
                    .record_rejected(rejection.rule_code());
                Err(CreationError::from_rejection(order_code, rejection))
            }
        }
    }
 
    fn supports(&self, order_code: &TestingOrderCode) -> bool {
        // ⚠️ 只读,**不触碰账本**。理由见 OrderCreator::supports 的文档:
        // 覆盖率检查会逐一带入编码询问,若这里记账就会污染创建账本。
        self.supported_codes.contains(order_code)
    }
 
    fn supported_codes(&self) -> Vec<TestingOrderCode> {
        self.supported_codes.clone()
    }
 
    fn mechanism_name(&self) -> &'static str {
        "编译期白名单"
    }
 
    fn mechanism_note(&self) -> &'static str {
        "扩展要改两层三处:白名单加一项、数组长度加一、构造器表加一行"
    }
 
    fn dispatch_list_count(&self) -> usize {
        // ★ 2 = 白名单 + 构造器表。这一版机制的**全部代价**就是这个数字:
        //   两份清单必须手动保持同步,而其中一种失配方向(构造器表有、
        //   白名单没有)**不会产生任何报错**。
        2
    }
 
    fn ledger_snapshot(&self) -> CreationLedger {
        self.ledger.borrow().clone()
    }
}
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : testing_order_registry.rs
//! # 简单工厂(运行期登记表版)—— 把分派表从代码里搬到数据里
//!
//! ## 它比白名单版多了什么,又少了什么
//!
//! **多了**:一张可以在运行期追加的登记表 [`TestingOrderRegistry::register`]。
//! 新增一种检测类型时,调用方写一行注册即可——
//! `factory` 层与 `product` 层**一个文件都不用改**。
//!
//! **少了**:白名单那份冗余清单。本版**表本身就是白名单**,
//! 因此不存在「两份清单」这个概念,也就**结构上不可能失配**。
//! 第六幕要证明的正是这一点:同一种机制性缺陷在这一版里**无处可写**。
//!
//! ## 登记表是「只增不减」的
//!
//! 本版刻意**不提供** `unregister`。理由不是偷懒:
//!
//! 1. 产品的创建能力一旦发布,撤销它会让**已经受理的委托单**
//!    在重跑时对不上账(同一批输入,上次能造、这次造不出),
//!    而本工程对可复现性有硬要求;
//! 2. 撤销能力会引入「撤销后又注册」的顺序依赖,
//!    使同一份输入产生不同的输出——这正好破坏两次运行逐字节一致。
//!
//! 若业务上确实需要下线某种检测类型,正确做法是**在业务层拒绝新受理**
//! (产品自己的构造器返回拒绝原因),而不是把创建能力从表里抽走。
//! 「能力下线」与「受理停止」是两件事,混在一起会让审计失去基准。
//!
//! ## 重复注册会被拒绝,而且会被计数
//!
//! 若同一个编码被注册两次,**先注册的胜出**,后一次被拒绝并计数。
//! 这个选择是刻意的:
//!
//! - 「后注册覆盖先注册」会让行为依赖于注册顺序,
//!   而注册顺序在真实系统里往往是不确定的(模块初始化顺序、配置加载顺序);
//! - 「先注册胜出 + 计数」把冲突变成**可观测的事件**,
//!   而不是一个安静的覆盖动作。
//!
//! 这与本工程的一贯取舍一致:**宁可让冲突被看见,也不要让它被自动摆平。**
 
use std::cell::RefCell;
 
use crate::domain::TestingOrderCode;
use crate::factory::creation_error::CreationError;
use crate::factory::creation_ledger::CreationLedger;
use crate::factory::order_creator::OrderCreator;
use crate::product::testing_order::ProductBuilder;
use crate::product::{builtin_product_builders, SampleSpecification, TestingOrder};
 
/// 简单工厂(运行期登记表版)。
pub struct TestingOrderRegistry {
    /// 登记表。`RefCell` 使其在 `&self` 下可追加,
    /// 从而 [`OrderCreator`] 的方法签名不必变成 `&mut self`。
    entries: RefCell<Vec<(TestingOrderCode, ProductBuilder)>>,
    /// 创建账本。
    ledger: RefCell<CreationLedger>,
    /// 被拒绝的重复注册次数。
    ///
    /// 单独用一个 `Cell<u32>` 而不是塞进账本:账本记的是**创建**的账,
    /// 而重复注册是**登记**阶段的冲突,两者性质不同,
    /// 混在一起会让「创建了几张」这个数字的语义变模糊。
    duplicate_registration_count: std::cell::Cell<u32>,
    /// 成功注册的新产品数量(不含初始种子)。
    registered_extension_count: std::cell::Cell<u32>,
}
 
impl TestingOrderRegistry {
    /// 构造一个空登记表。
    pub fn empty() -> TestingOrderRegistry {
        TestingOrderRegistry {
            entries: RefCell::new(Vec::new()),
            ledger: RefCell::new(CreationLedger::new()),
            duplicate_registration_count: std::cell::Cell::new(0),
            registered_extension_count: std::cell::Cell::new(0),
        }
    }
 
    /// 构造工程标准登记表:以内置构造器表为初始内容。
    pub fn builtin() -> TestingOrderRegistry {
        let registry: TestingOrderRegistry = TestingOrderRegistry::empty();
        // 初始种子走 register,因此「重复注册」的保护同样作用于内置项
        // (若构造器表里出现重复编码,会被这里挡下并计数)。
        for (order_code, builder) in builtin_product_builders() {
            registry.register(order_code, builder);
        }
        // 种子不算「扩展」,把计数归零——否则报表会把 5 个内置项
        // 误报成「已扩展 5 种」。
        registry.registered_extension_count.set(0);
        registry
    }
 
    /// 注册一种产品的构造器。
    ///
    /// 参数 `order_code`:检测类型编码;`builder`:构造器。
    /// 返回:注册成功返回 `true`;该编码已存在返回 `false`(先注册者胜出)。
    ///
    /// ## 这个方法的参数在类型上不可能「造出东西」
    ///
    /// 注意签名:它接受的是 [`ProductBuilder`](**怎么造**),
    /// 而不是 `Box<dyn TestingOrder>`(**造好的东西**)。
    /// 因此调用方无法通过「先造一个再塞进来」来绕过工厂——
    /// 表里存的永远是方法,不是对象。这是本工程「创建只能经过工厂」
    /// 这条红线的另一半。
    pub fn register(&self, order_code: TestingOrderCode, builder: ProductBuilder) -> bool {
        let mut entries = self.entries.borrow_mut();
        // 查重:线性查找(清单长度个位数)。
        if entries.iter().any(|entry| entry.0 == order_code) {
            self.duplicate_registration_count
                .set(self.duplicate_registration_count.get() + 1);
            return false;
        }
        entries.push((order_code, builder));
        self.registered_extension_count
            .set(self.registered_extension_count.get() + 1);
        true
    }
 
    /// 当前登记的产品数量。
    pub fn entry_count(&self) -> usize {
        self.entries.borrow().len()
    }
 
    /// 被拒绝的重复注册次数。
    pub fn duplicate_registration_count(&self) -> u32 {
        self.duplicate_registration_count.get()
    }
 
    /// 相对「内置种子」新增的注册数量。
    ///
    /// 这个数是本工程「扩展零改动」的**度量**:
    /// 第七幕跑完扩展之后,它应当恰好等于扩展方注册的产品个数。
    pub fn registered_extension_count(&self) -> u32 {
        self.registered_extension_count.get()
    }
 
    /// 取登记表的快照(编码清单,顺序为登记顺序)。
    pub fn entry_codes_snapshot(&self) -> Vec<TestingOrderCode> {
        self.entries
            .borrow()
            .iter()
            .map(|entry| entry.0)
            .collect()
    }
}
 
impl OrderCreator for TestingOrderRegistry {
    fn create(
        &self,
        order_code: &TestingOrderCode,
        specification: &SampleSpecification,
    ) -> Result<Box<dyn TestingOrder>, CreationError> {
        // 取出构造器(先取出函数指针,再释放借用——与既有工程里
        // 「先释放借用、再执行」的纪律一致:构造器执行期间不应持有表借用,
        // 否则构造器内部若再次访问登记表就会 panic)。
        let builder: Option<ProductBuilder> = {
            let entries = self.entries.borrow();
            entries
                .iter()
                .find(|entry| entry.0 == *order_code)
                .map(|entry| entry.1)
        };
 
        let Some(builder) = builder else {
            self.ledger
                .borrow_mut()
                .record_unknown_code(order_code.code());
            return Err(CreationError::UnknownOrderCode {
                requested_code: order_code.code().to_string(),
                known_codes: self
                    .entry_codes_snapshot()
                    .iter()
                    .map(|code| code.code().to_string())
                    .collect(),
            });
        };
 
        match builder(specification) {
            Ok(order) => {
                self.ledger.borrow_mut().record_created(order_code);
                Ok(order)
            }
            Err(rejection) => {
                self.ledger
                    .borrow_mut()
                    .record_rejected(rejection.rule_code());
                Err(CreationError::from_rejection(order_code, rejection))
            }
        }
    }
 
    fn supports(&self, order_code: &TestingOrderCode) -> bool {
        // ⚠️ 只读,不记账(同白名单版的理由)。
        self.entries
            .borrow()
            .iter()
            .any(|entry| entry.0 == *order_code)
    }
 
    fn supported_codes(&self) -> Vec<TestingOrderCode> {
        self.entry_codes_snapshot()
    }
 
    fn mechanism_name(&self) -> &'static str {
        "运行期登记表"
    }
 
    fn mechanism_note(&self) -> &'static str {
        "扩展只加一行 register(..):分层目录零改动,且只有一份清单、结构上不会失配"
    }
 
    fn dispatch_list_count(&self) -> usize {
        // ★ 1 = 只有登记表本身。表就是白名单,因此不存在「两份清单失配」这个概念——
        //   不是「我们小心地维护好了」,而是**结构上没有第二份清单可以失配**。
        //   这个差别是本工程第七、九幕要演示的核心。
        1
    }
 
    fn ledger_snapshot(&self) -> CreationLedger {
        self.ledger.borrow().clone()
    }
}
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : builtin_product_builders.rs
//! # 内置产品构造器表 —— 工程内置产品的**唯一**出口
//!
//! ## 这个文件是整个工程的关键枢纽
//!
//! 简单工厂的分派表必须来自某一处。本工程把它做成**一张真实的表**
//! (`Vec<(编码, 构造器)>`)而不是散落在代码里的 `match` 分支,理由是:
//!
//! | 用 `match` | 用表 |
//! |---|---|
//! | 「有哪些产品」只能靠读代码数出来 | `builtin_product_builders().len()` 直接可数 |
//! | 新增产品 = 改函数体 | 新增产品 = 表里加一行 |
//! | 报表无法列出产品清单 | 报表直接遍历这张表 |
//! | 覆盖率体检要维护第二份清单 | 只有这一份清单 |
//!
//! 最后一条尤其重要:既有工程里反复踩到的坑是
//! **「同一份清单被写了两遍,然后两遍不同步」**。
//! 本工程从设计上就只允许一份——表就是清单。
//!
//! ## 为什么构造器表必须是**函数指针**而不是「已经造好的对象」
//!
//! 因为产品的构造依赖输入(样品规格)。表里存的是**怎么造**,
//! 不是**造好的东西**。这也解释了为什么本表可以安全地被多个消费者共享:
//! 它不含任何状态。
//!
//! ## 红线在这里闭环
//!
//! 五个具体产品的类型与构造入口都是 `pub(super)`,因此本文件
//! **不能**把它们作为类型名暴露出去,只能把它们转成 [`ProductBuilder`]
//! 函数指针。调用方拿到表之后,能做的只有「按编码取一个构造器并调用」——
//! 它**无法**构造出一个工程内置产品再直接塞进别处。
//!
//! 于是「创建集中在一处」这条主张,在类型系统里成立:
//! 调用方能到达产品的路径只有
//! `工厂/登记表 → 构造器表 → 具体产品`,中间没有旁路。
 
use crate::domain::{
    TestingOrderCode, ORDER_CODE_DIAMOND_GRADING, ORDER_CODE_GEMSTONE_IDENTIFICATION,
    ORDER_CODE_JADE_AUTHENTICATION, ORDER_CODE_PRECIOUS_METAL_PURITY, ORDER_CODE_SILVER_PURITY,
};
use crate::product::{
    diamond_grading_order, gemstone_identification_order, jade_authentication_order,
    precious_metal_purity_order, silver_purity_order,
};
use crate::product::testing_order::ProductBuilder;
 
/// 返回全部内置产品构造器,顺序与业务口径的展示顺序一致。
///
/// ## 为什么返回 `Vec` 而不是 `&'static [..]`
///
/// 静态切片要求内容在编译期就完全确定,而函数指针虽然可以放进 `const`
/// 数组,但那样每新增一种产品都要改数组长度(`[T; N]` 的 N 要跟着变),
/// 反而把「加一行」变成了「加一行 + 改数字」。
///
/// 返回 `Vec` 的代价是每次调用做一次小分配。本工程的调用次数是两位数,
/// 这个代价可以忽略;换来的是**扩展时只加一行**。
/// 这是一个明确的取舍,写在这里以免后来者以为是无意的。
///
/// ## 顺序为什么重要
///
/// 报表按本顺序打印产品清单。若顺序随机(比如来自 `HashMap` 遍历),
/// **两次运行的输出就会不同**,而本工程要求逐字节一致。
/// 用固定的 `Vec` 字面量,顺序由代码位置决定,天然稳定。
pub fn builtin_product_builders() -> Vec<(TestingOrderCode, ProductBuilder)> {
    vec![
        // 两个「按件计价」的贵金属类,先排(工艺相近,报表上相邻便于对照)。
        (
            ORDER_CODE_PRECIOUS_METAL_PURITY,
            precious_metal_purity_order::build as ProductBuilder,
        ),
        (
            ORDER_CODE_SILVER_PURITY,
            silver_purity_order::build as ProductBuilder,
        ),
        // 「按克拉计价」的钻石分级。
        (
            ORDER_CODE_DIAMOND_GRADING,
            diamond_grading_order::build as ProductBuilder,
        ),
        // 两个「按项目数计价」的鉴定类。
        (
            ORDER_CODE_GEMSTONE_IDENTIFICATION,
            gemstone_identification_order::build as ProductBuilder,
        ),
        (
            ORDER_CODE_JADE_AUTHENTICATION,
            jade_authentication_order::build as ProductBuilder,
        ),
    ]
}
 
/// 内置产品的数量。
///
/// ## 为什么不写成一个手填的常量
///
/// 手填常量(`const BUILTIN_PRODUCT_COUNT: usize = 5;`)会与本文件顶端的表
/// **不同步**——加了第六种产品却忘了改数字,是这类代码最典型的失效方式。
/// 这里由 [`builtin_product_builders`] 的实际长度算出来,
/// 因此「产品数量」永远等于「表里有几行」,不可能对不上。
pub fn builtin_product_count() -> usize {
    builtin_product_builders().len()
}
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : diamond_grading_order.rs
//! # 钻石分级委托单(具体产品)
//!
//! ## 这是唯一一种「计价口径依赖连续量」的产品
//!
//! 其余四种的基准费要么是「单价 × 件数」,要么是「各项目单价之和」,
//! 都是整数乘法。钻石分级不一样:它按**克拉重量**计价,而重量是连续量。
//!
//! 把连续量纳入整数体系的唯一正确做法是**先定点、再运算**:
//!
//! ```text
//! 重量用「千分之一克拉」存整数(0.500 ct = 500,1.205 ct = 1205)
//! 基准费 = 每克拉单价 × 计费千分之一克拉数 ÷ 1000
//! ```
//!
//! 全程 `i64`/`i128` 整数乘除,舍入只发生在 [`Money::scale_by_ratio`] 一处。
//!
//! ## 为什么不直接用 `f64`
//!
//! `1.205` 在 `f64` 里实际是 `1.2049999999999998`。若直接用它乘单价,
//! 结果会落在 `.995` 与 `.005` 这种临界位置上,四舍五入偶尔差一分。
//! 而这类偏差**只在某些重量上出现**——测试时随手取一个 1.000 克拉
//! 恰好不会暴露,上线后遇到 1.205 克拉才出错。
//! 定点整数把这个概率降到零。
//!
//! ## 「最小计费重量」是业务规则,必须写在代码里并解释
//!
//! 分级的实际工作量与最低耗材(镶口、夹具、标准比色石校准)
//! 不随重量线性下降,因此行业惯例设一个最小计费重量。
//! 本工程取 **0.500 克拉**。低于此重量仍按 0.500 计——
//! 这不是「抹零」,是**用最小计费量表达固定成本**。
//! 若省略成「直接乘」,小石头客户的账单会低于成本,这个规则迟早被人重新发现一遍。
 
use crate::domain::{
    CertificateKind, Currency, Money, PricingBasis, Rate, SampleKind, TestingItem,
    TestingOrderCode, CERTIFICATE_KIND_GRADING, CURRENCY_CHINESE_YUAN, ORDER_CODE_DIAMOND_GRADING,
    PRICING_BASIS_PER_CARAT, SAMPLE_KIND_DIAMOND, TESTING_ITEM_CARAT_WEIGHT,
    TESTING_ITEM_CLARITY_GRADE, TESTING_ITEM_COLOR_GRADE, TESTING_ITEM_CUT_GRADE,
};
use crate::product::sample_specification::SampleSpecification;
use crate::product::specification_guard::{
    require_piece_count, require_positive_value, require_sample_kind, require_value_in_range,
};
use crate::product::specification_rejection::SpecificationRejection;
use crate::product::testing_order::TestingOrder;
 
/// 每克拉分级费:¥600.00。
///
/// 注意单位:这是「每**克拉**」,而基准费算出来是「每 1000 个千分之一克拉」,
/// 因此计算里要除以 1000。两个数字的**量纲不同**,
/// 写混了会得到 1000 倍的错账——故此处显式注明量纲。
const UNIT_FEE_PER_CARAT: Money = Money::from_minor_units(60_000, CURRENCY_CHINESE_YUAN);
 
/// 最小计费重量:0.500 克拉 = 500 个千分之一克拉。
const MINIMUM_BILLABLE_CARAT_MILLIS: i64 = 500;
 
/// 单颗钻石的重量上限:30.000 克拉。超过这个量级已属博物馆藏品,
/// 需要走特殊流程而非标准委托单。
const MAXIMUM_CARAT_MILLIS: i64 = 30_000;
 
/// 千分之一克拉与克拉的换算基数。
const CARAT_MILLIS_PER_CARAT: i64 = 1_000;
 
/// 标准出证工作日。分级要过比色石、测净度、评切工,周期最长。
const TURNOVER_DAYS: u16 = 5;
 
/// 加急费率 40%。分级是手工工序,加急意味着插队占用鉴定师工时,
/// 因此溢价高于常规检测的 30%。
const URGENCY_RATE: Rate = Rate::from_percent(40);
 
/// 钻石分级委托单。
pub(super) struct DiamondGradingOrder {
    /// 样品编号。
    sample_code: String,
    /// 送检客户。
    applicant: String,
    /// 件数(本标准要求恒为 1,一证一石)。
    piece_count: u16,
    /// 克拉重量(千分之一克拉)。
    carat_millis: i64,
    /// **计费用的**克拉重量(已应用最小计费重量)。
    ///
    /// 单独存一份而不是每次 `max(实际, 500)`:
    /// 「受理那一刻的计费重量」与「基准费」属于同一份价格快照,
    /// 必须一起被固化。若重算,将来最小计费重量改为 0.300 克拉时,
    /// 历史委托单的计费重量会被追溯改写,而基准费不会——
    /// 于是「费用与计费重量对不上」,且只在历史单上看得出。
    billable_carat_millis: i64,
    /// 是否加急。
    urgent: bool,
    /// 基准费(受理时的价格快照)。
    base_fee: Money,
}
 
impl TestingOrder for DiamondGradingOrder {
    fn order_code(&self) -> TestingOrderCode {
        ORDER_CODE_DIAMOND_GRADING
    }
 
    fn sample_code(&self) -> &str {
        &self.sample_code
    }
 
    fn applicant(&self) -> &str {
        &self.applicant
    }
 
    fn handling_department(&self) -> &'static str {
        "钻石室"
    }
 
    fn sample_kind(&self) -> SampleKind {
        SAMPLE_KIND_DIAMOND
    }
 
    fn pricing_basis(&self) -> PricingBasis {
        PRICING_BASIS_PER_CARAT
    }
 
    fn certificate_kind(&self) -> CertificateKind {
        CERTIFICATE_KIND_GRADING
    }
 
    fn currency(&self) -> Currency {
        CURRENCY_CHINESE_YUAN
    }
 
    fn base_fee(&self) -> Money {
        self.base_fee
    }
 
    fn urgency_rate(&self) -> Rate {
        URGENCY_RATE
    }
 
    fn loss_rate(&self) -> Rate {
        // 分级是非破坏性检测:样品原样返还。
        Rate::zero()
    }
 
    fn turnover_days(&self) -> u16 {
        TURNOVER_DAYS
    }
 
    fn is_destructive(&self) -> bool {
        false
    }
 
    fn is_urgent(&self) -> bool {
        self.urgent
    }
 
    fn piece_count(&self) -> u16 {
        self.piece_count
    }
 
    fn carat_millis(&self) -> i64 {
        self.carat_millis
    }
 
    /// 覆盖默认实现:钻石是**唯一**需要区分「实际重量」与「计费重量」的产品。
    ///
    /// 不覆盖它对其它四种产品是正确的(非钻石类两者都是 0),
    /// 但漏掉这里会让「最小计费重量」在报表上完全不可见——
    /// 读者只会看到一个「比 600 × 0.450 多出 30 元」的费用,无从解释。
    fn billable_carat_millis(&self) -> i64 {
        self.billable_carat_millis
    }
 
    fn required_items(&self) -> &[TestingItem] {
        // 4C 中的四项。克拉重量本身也是收费依据,但仍作为项目列出,
        // 因为它确实是一道工序(称重与记录),有其对应的工作量。
        &[
            TESTING_ITEM_CARAT_WEIGHT,
            TESTING_ITEM_COLOR_GRADE,
            TESTING_ITEM_CLARITY_GRADE,
            TESTING_ITEM_CUT_GRADE,
        ]
    }
}
 
/// 由规格构造一张钻石分级委托单。
///
/// 参数 `specification`:样品规格。
/// 返回:合格时返回产品,否则返回拒绝原因。
pub(super) fn build(
    specification: &SampleSpecification,
) -> Result<Box<dyn TestingOrder>, SpecificationRejection> {
    // ① 样品必须是钻石。
    require_sample_kind(specification, SAMPLE_KIND_DIAMOND)?;
    // ② 一件一证:分级证书不合并出具。
    require_piece_count(specification, 1, 1)?;
    // ③ 克拉重量必填(0 视为没填,归入「缺少必填参数」)。
    require_positive_value(
        specification.carat_millis(),
        "carat_millis",
        "克拉重量(千分之一克拉)",
    )?;
    // ④ 克拉重量不得超过单颗上限。
    require_value_in_range(
        specification.carat_millis(),
        "carat_millis",
        "克拉重量(千分之一克拉)",
        1,
        MAXIMUM_CARAT_MILLIS,
    )?;
 
    // ⑤ 最小计费重量:低于 0.500 克拉按 0.500 计(固定成本的表达)。
    let billable_carat_millis: i64 =
        specification.carat_millis().max(MINIMUM_BILLABLE_CARAT_MILLIS);
 
    // ⑥ 计价:每克拉单价 × 计费克拉数。
    //    量纲:单价是「分/克拉」,计费量是「千分之一克拉」,
    //    因此要把千分之一克拉折算回克拉——除以 1000,由 scale_by_ratio 承担。
    let base_fee: Money =
        UNIT_FEE_PER_CARAT.scale_by_ratio(billable_carat_millis, CARAT_MILLIS_PER_CARAT);
 
    Ok(Box::new(DiamondGradingOrder {
        sample_code: specification.sample_code().to_string(),
        applicant: specification.applicant().to_string(),
        piece_count: specification.piece_count(),
        carat_millis: specification.carat_millis(),
        billable_carat_millis,
        urgent: specification.is_urgent(),
        base_fee,
    }))
}
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : gemstone_identification_order.rs
//! # 宝石鉴定委托单(具体产品)
//!
//! ## 这是本工程唯一的「破坏性检测」产品
//!
//! 宝石鉴定在遇到「是否经过充填/扩散处理」这类疑难时,
//! 需要**取样做成分分析**——样品会被破坏,无法原样返还。
//! 这带来三个与其他四种检测不同的业务后果,全部体现在本文件里:
//!
//! 1. **必须向客户明示**([`TestingOrder::is_destructive`] 为 `true`),
//!    报表上会单独列出破坏性委托单,让接收样品的同事先与客户确认;
//! 2. **客户须承担损耗**,因此计一项损耗费([`TestingOrder::loss_rate`] = 5%);
//! 3. **工作量更高**([`TestingOrder::workload_units`] 里的破坏性附加 20 单元)。
//!
//! 这三条都不是「多写几行」,而是**同一个业务事实在三个不同口径上的投影**。
//! 把它们都挂在 `is_destructive()` 这一个判断上(通过默认方法),
//! 使得「新增一种破坏性检测类型」只需把该方法返回 `true`,
//! 三个后果自动全部生效——不会漏掉其中任何一个。
//!
//! ## 计价:按项目数
//!
//! 基准费 = 各检测项目单价之和。求和走 [`super::item_fee_sum::sum_item_fees`],
//! 因此本文件**不需要认识任何一个具体项目的单价**——
//! 项目清单变了、新增了项目、调价了,本文件一行都不用改。
 
use crate::domain::{
    CertificateKind, Currency, Money, PricingBasis, Rate, SampleKind, TestingItem,
    TestingOrderCode, CERTIFICATE_KIND_IDENTIFICATION, CURRENCY_CHINESE_YUAN,
    ORDER_CODE_GEMSTONE_IDENTIFICATION, PRICING_BASIS_PER_ITEM, SAMPLE_KIND_GEMSTONE,
    TESTING_ITEM_MAGNIFICATION, TESTING_ITEM_REFRACTIVE_INDEX, TESTING_ITEM_SPECIFIC_GRAVITY,
    TESTING_ITEM_UV_FLUORESCENCE,
};
use crate::product::item_fee_sum::sum_item_fees;
use crate::product::sample_specification::SampleSpecification;
use crate::product::specification_guard::{require_piece_count, require_sample_kind};
use crate::product::specification_rejection::SpecificationRejection;
use crate::product::testing_order::TestingOrder;
 
/// 本项目包含的四项常规检测。
///
/// 以 `const` 数组而非内联字面量:报表与体检都要拿到这份清单,
/// 内联会让「同一份清单出现两处」。
const REQUIRED_ITEMS: [TestingItem; 4] = [
    TESTING_ITEM_REFRACTIVE_INDEX,
    TESTING_ITEM_SPECIFIC_GRAVITY,
    TESTING_ITEM_UV_FLUORESCENCE,
    TESTING_ITEM_MAGNIFICATION,
];
 
/// 标准出证工作日。
const TURNOVER_DAYS: u16 = 3;
 
/// 加急费率 30%。
const URGENCY_RATE: Rate = Rate::from_percent(30);
 
/// 样品损耗费率 5%(破坏性检测取样不可返还)。
const LOSS_RATE: Rate = Rate::from_percent(5);
 
/// 单张委托单允许的最大件数。
///
/// 破坏性检测的件数上限刻意压低到 20:取样是不可逆的,
/// 大批量送检应当先与客户书面确认,不能一张单子默默毁掉 99 件样品。
/// **同一个字段(件数上限)在两种检测类型上取不同的值,正是「它们该是两个产品」
/// 的直接证据。**
const MAXIMUM_PIECE_COUNT: u16 = 20;
 
/// 宝石鉴定委托单。
pub(super) struct GemstoneIdentificationOrder {
    /// 样品编号。
    sample_code: String,
    /// 送检客户。
    applicant: String,
    /// 件数。
    piece_count: u16,
    /// 是否加急。
    urgent: bool,
    /// 基准费(受理时的价格快照)。
    base_fee: Money,
}
 
impl TestingOrder for GemstoneIdentificationOrder {
    fn order_code(&self) -> TestingOrderCode {
        ORDER_CODE_GEMSTONE_IDENTIFICATION
    }
 
    fn sample_code(&self) -> &str {
        &self.sample_code
    }
 
    fn applicant(&self) -> &str {
        &self.applicant
    }
 
    fn handling_department(&self) -> &'static str {
        "宝石室"
    }
 
    fn sample_kind(&self) -> SampleKind {
        SAMPLE_KIND_GEMSTONE
    }
 
    fn pricing_basis(&self) -> PricingBasis {
        PRICING_BASIS_PER_ITEM
    }
 
    fn certificate_kind(&self) -> CertificateKind {
        CERTIFICATE_KIND_IDENTIFICATION
    }
 
    fn currency(&self) -> Currency {
        CURRENCY_CHINESE_YUAN
    }
 
    fn base_fee(&self) -> Money {
        self.base_fee
    }
 
    fn urgency_rate(&self) -> Rate {
        URGENCY_RATE
    }
 
    fn loss_rate(&self) -> Rate {
        LOSS_RATE
    }
 
    fn turnover_days(&self) -> u16 {
        TURNOVER_DAYS
    }
 
    fn is_destructive(&self) -> bool {
        // ★ 这一个 `true` 同时开启三件事:报表标注、损耗计费、工作量附加。
        //    详见本模块文档;这不是巧合,而是默认方法设计的直接结果。
        true
    }
 
    fn is_urgent(&self) -> bool {
        self.urgent
    }
 
    fn piece_count(&self) -> u16 {
        self.piece_count
    }
 
    fn carat_millis(&self) -> i64 {
        0
    }
 
    fn required_items(&self) -> &[TestingItem] {
        &REQUIRED_ITEMS
    }
}
 
/// 由规格构造一张宝石鉴定委托单。
///
/// 参数 `specification`:样品规格。
/// 返回:合格时返回产品,否则返回拒绝原因。
pub(super) fn build(
    specification: &SampleSpecification,
) -> Result<Box<dyn TestingOrder>, SpecificationRejection> {
    require_sample_kind(specification, SAMPLE_KIND_GEMSTONE)?;
    require_piece_count(specification, 1, MAXIMUM_PIECE_COUNT)?;
 
    // 按项目数计价:各项目单价之和。本文件不认识任何具体项目的单价。
    let base_fee: Money = sum_item_fees(&REQUIRED_ITEMS, CURRENCY_CHINESE_YUAN);
 
    Ok(Box::new(GemstoneIdentificationOrder {
        sample_code: specification.sample_code().to_string(),
        applicant: specification.applicant().to_string(),
        piece_count: specification.piece_count(),
        urgent: specification.is_urgent(),
        base_fee,
    }))
}
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : item_fee_sum.rs
//! # 项目费求和 —— 「按项目数计价」的共用算法
//!
//! ## 为什么这个三行函数值得单独一个文件
//!
//! 宝石鉴定与玉石鉴定都是「按项目数计价」,都要把项目清单的单价加起来。
//! 两处各写一遍循环本身不是问题,问题在于**求和过程中的币种处理**:
//! [`Money::add`] 在同币种时返回 `Some`,跨币种返回 `None`。
//! 若两处各写各的兜底,就可能一处写 `unwrap_or(total)`(跳过这一项,
//! 静默少算),另一处写 `unwrap_or_else(|| Money::zero(..))`(清零,
//! 静默大错)。两种兜底都会让账单出错,而且**报表看起来完全正常**。
//!
//! 把这个循环收进一个函数,兜底策略就只有一份:
//! **保留已累加的值**(不跳过、不清零),并让 debug 构建立刻炸掉。
//! 释放构建下最坏也只是少了某一项,而不会整单归零——
//! 在「宁可少算一项,也不要把整单算成 0」之间,前者更容易被发现。
//!
//! ## 口径:项目费与基准费的关系
//!
//! 对「按项目数计价」的检测类型,二者**相等**;对「按件/按克拉计价」的类型,
//! 项目费只是说明性清单,与账单无关。这个差异由报表并排展示,
//! 不在本函数里做任何区分——本函数只负责如实求和。
 
use crate::domain::{Currency, Money, TestingItem};
 
/// 求一张委托单的项目费合计。
///
/// 参数 `items`:项目清单;`currency`:结算币种。
/// 返回:各项目单价之和。
pub(super) fn sum_item_fees(items: &[TestingItem], currency: Currency) -> Money {
    let mut accumulated: Money = Money::zero(currency);
    for item in items {
        let next: Option<Money> = accumulated.add(&item.item_fee());
        // debug 构建下:币种不一致立刻炸,并在消息里指出是哪个项目。
        debug_assert!(
            next.is_some(),
            "项目「{}」的单价币种与委托单币种不一致,合计会漏掉这一项",
            item.code()
        );
        // 释放构建下:保留已累加的值(不跳过、更不清零)。
        accumulated = next.unwrap_or(accumulated);
    }
    accumulated
}
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : jade_authentication_order.rs
//! # 玉石鉴定委托单(具体产品)
//!
//! ## 与宝石鉴定的差别,恰是本工程「为什么不能合并产品」的样本
//!
//! | 维度 | 宝石鉴定 | 玉石鉴定 |
//! |---|---|---|
//! | 样品类别 | 宝石 | 玉石 |
//! | 检测项目 | 折射率 + 密度 + 紫外荧光 + 放大检查 | 折射率 + 密度 + 放大检查 |
//! | 是否破坏性 | **是**(可能取样) | 否 |
//! | 损耗费 | 5% | 0% |
//! | 件数上限 | 20(取样不可逆,须书面确认) | 60 |
//! | 基准费 | ¥530.00 | ¥440.00 |
//!
//! 六项里有两项(项目清单、件数上限)**用字段区分也无法安全合并**——
//! 一旦合并,件数上限的取值范围就变成一个「取决于另一个字段」的条件约束,
//! 而条件约束是运行时才成立的,编译器帮不上忙。
//!
//! 拆成两个产品之后,「玉石不能取样」与「宝石取样要限件数」这两条业务规则
//! 各自落在自己的文件里,**互不干扰,也互不构成特例**。
//! 这就是本工程反复强调的那句话:**形状相同不等于职责相同**。
//!
//! ## 红线同前
//!
//! 结构体与构造入口都是 `pub(super)`。
 
use crate::domain::{
    CertificateKind, Currency, Money, PricingBasis, Rate, SampleKind, TestingItem,
    TestingOrderCode, CERTIFICATE_KIND_IDENTIFICATION, CURRENCY_CHINESE_YUAN,
    ORDER_CODE_JADE_AUTHENTICATION, PRICING_BASIS_PER_ITEM, SAMPLE_KIND_JADE,
    TESTING_ITEM_MAGNIFICATION, TESTING_ITEM_REFRACTIVE_INDEX, TESTING_ITEM_SPECIFIC_GRAVITY,
};
use crate::product::item_fee_sum::sum_item_fees;
use crate::product::sample_specification::SampleSpecification;
use crate::product::specification_guard::{require_piece_count, require_sample_kind};
use crate::product::specification_rejection::SpecificationRejection;
use crate::product::testing_order::TestingOrder;
 
/// 本项目包含的三项常规检测(不做紫外荧光——玉石的处理鉴别主要靠放大检查)。
const REQUIRED_ITEMS: [TestingItem; 3] = [
    TESTING_ITEM_REFRACTIVE_INDEX,
    TESTING_ITEM_SPECIFIC_GRAVITY,
    TESTING_ITEM_MAGNIFICATION,
];
 
/// 标准出证工作日。
const TURNOVER_DAYS: u16 = 3;
 
/// 加急费率 30%。
const URGENCY_RATE: Rate = Rate::from_percent(30);
 
/// 单张委托单允许的最大件数。非破坏性检测,上限可以放宽。
const MAXIMUM_PIECE_COUNT: u16 = 60;
 
/// 玉石鉴定委托单。
pub(super) struct JadeAuthenticationOrder {
    /// 样品编号。
    sample_code: String,
    /// 送检客户。
    applicant: String,
    /// 件数。
    piece_count: u16,
    /// 是否加急。
    urgent: bool,
    /// 基准费(受理时的价格快照)。
    base_fee: Money,
}
 
impl TestingOrder for JadeAuthenticationOrder {
    fn order_code(&self) -> TestingOrderCode {
        ORDER_CODE_JADE_AUTHENTICATION
    }
 
    fn sample_code(&self) -> &str {
        &self.sample_code
    }
 
    fn applicant(&self) -> &str {
        &self.applicant
    }
 
    fn handling_department(&self) -> &'static str {
        "玉石室"
    }
 
    fn sample_kind(&self) -> SampleKind {
        SAMPLE_KIND_JADE
    }
 
    fn pricing_basis(&self) -> PricingBasis {
        PRICING_BASIS_PER_ITEM
    }
 
    fn certificate_kind(&self) -> CertificateKind {
        CERTIFICATE_KIND_IDENTIFICATION
    }
 
    fn currency(&self) -> Currency {
        CURRENCY_CHINESE_YUAN
    }
 
    fn base_fee(&self) -> Money {
        self.base_fee
    }
 
    fn urgency_rate(&self) -> Rate {
        URGENCY_RATE
    }
 
    fn loss_rate(&self) -> Rate {
        // 非破坏性:样品原样返还,损耗费率恒为 0%。
        Rate::zero()
    }
 
    fn turnover_days(&self) -> u16 {
        TURNOVER_DAYS
    }
 
    fn is_destructive(&self) -> bool {
        false
    }
 
    fn is_urgent(&self) -> bool {
        self.urgent
    }
 
    fn piece_count(&self) -> u16 {
        self.piece_count
    }
 
    fn carat_millis(&self) -> i64 {
        0
    }
 
    fn required_items(&self) -> &[TestingItem] {
        &REQUIRED_ITEMS
    }
}
 
/// 由规格构造一张玉石鉴定委托单。
///
/// 参数 `specification`:样品规格。
/// 返回:合格时返回产品,否则返回拒绝原因。
pub(super) fn build(
    specification: &SampleSpecification,
) -> Result<Box<dyn TestingOrder>, SpecificationRejection> {
    require_sample_kind(specification, SAMPLE_KIND_JADE)?;
    require_piece_count(specification, 1, MAXIMUM_PIECE_COUNT)?;
 
    let base_fee: Money = sum_item_fees(&REQUIRED_ITEMS, CURRENCY_CHINESE_YUAN);
 
    Ok(Box::new(JadeAuthenticationOrder {
        sample_code: specification.sample_code().to_string(),
        applicant: specification.applicant().to_string(),
        piece_count: specification.piece_count(),
        urgent: specification.is_urgent(),
        base_fee,
    }))
}
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : precious_metal_purity_order.rs
//! # 贵金属纯度检测委托单(具体产品)
//!
//! ## 本类型为什么声明为 `pub(super)`
//!
//! 这是本工程**唯一一条结构性红线的落点**,值得讲清楚。
//!
//! 简单工厂的价值是「创建集中在一处」。要证明这一点,
//! 最有力的办法不是写一句「调用方请不要自己 `new`」,
//! 而是**让调用方根本没有地方可以写**:
//!
//! - 本结构体的字段**全部私有**(没有 `pub`);
//! - 唯一的构造入口 [`build`] 声明为 `pub(super)`,即**只对 `product` 层可见**;
//! - 结构体自身也是 `pub(super)`,于是**调用方连这个类型名都写不出来**。
//!
//! 结果:`main.rs` 里写不出 `PreciousMetalPurityOrder::build(...)`,
//! 也写不出 `PreciousMetalPurityOrder { ... }`——两者都会编译失败。
//! 调用方拿到这种委托单的方式**只剩一条**:
//! 通过 `product::builtin_product_builders()` 交给工厂/登记表,
//! 再从 [`crate::factory::OrderCreator::create`] 取回 `Box<dyn TestingOrder>`。
//!
//! 「创建集中在一处」于是从一句约定变成了**类型系统的事实**。
//! 这不是本工程发明的技巧——在不支持模块可见性细分的语言里这件事只能靠纪律,
//! Rust 的 `pub(super)` 让它变成编译期约束,没有理由不用。
 
use crate::domain::{
    CertificateKind, Currency, Money, PricingBasis, Rate, SampleKind, TestingItem,
    TestingOrderCode, CERTIFICATE_KIND_TEST_REPORT, CURRENCY_CHINESE_YUAN,
    ORDER_CODE_PRECIOUS_METAL_PURITY, PRICING_BASIS_PER_PIECE, SAMPLE_KIND_PRECIOUS_METAL,
    TESTING_ITEM_PURITY_ASSAY,
};
use crate::product::sample_specification::SampleSpecification;
use crate::product::specification_guard::{require_piece_count, require_sample_kind};
use crate::product::specification_rejection::SpecificationRejection;
use crate::product::testing_order::TestingOrder;
 
/// 每件样品的固定检测费:¥380.00。
///
/// 写成「整数分」常量而不是散在函数里的字面量:报表里要印单价,
/// 体检要核对「单价 × 件数 = 基准费」,两处必须引用同一个常量。
const UNIT_FEE_PER_PIECE: Money = Money::from_minor_units(38_000, CURRENCY_CHINESE_YUAN);
 
/// 标准出证工作日(不加急)。
const TURNOVER_DAYS: u16 = 2;
 
/// 加急费率为 30%。
const URGENCY_RATE: Rate = Rate::from_percent(30);
 
/// 单张委托单允许的最大件数。
///
/// 设上限不是「怕程序慢」,而是业务约束:纯度检测是**逐一破坏性取样前的
/// 非破坏性筛查**,超过 99 件的批次应当拆成多张委托单走批次折扣,
/// 而不是塞进一张单子里。
const MAXIMUM_PIECE_COUNT: u16 = 99;
 
/// 贵金属纯度检测委托单。
///
/// 声明为 `pub(super)` 的理由见本模块文档(结构性红线)。
pub(super) struct PreciousMetalPurityOrder {
    /// 样品编号(来自规格,原样保存)。
    sample_code: String,
    /// 送检客户。
    applicant: String,
    /// 件数。
    piece_count: u16,
    /// 是否加急。
    urgent: bool,
    /// 基准检测费(构造时一次算定,之后只读)。
    ///
    /// ## 为什么在构造时就把费用算定,而不是每次调用时重算
    ///
    /// 「基准费」是**受理那一刻的价格快照**。若每次 `base_fee()` 都重算,
    /// 那么将来调价之后,已经受理的历史委托单在报表上会显示新价格——
    /// 历史记录被**追溯改写**了。快照语义是这个字段存在的唯一理由,
    /// 因此即使它看起来「只是一个乘法」,也必须存下来。
    base_fee: Money,
}
 
impl TestingOrder for PreciousMetalPurityOrder {
    fn order_code(&self) -> TestingOrderCode {
        ORDER_CODE_PRECIOUS_METAL_PURITY
    }
 
    fn sample_code(&self) -> &str {
        &self.sample_code
    }
 
    fn applicant(&self) -> &str {
        &self.applicant
    }
 
    fn handling_department(&self) -> &'static str {
        "贵金属室"
    }
 
    fn sample_kind(&self) -> SampleKind {
        SAMPLE_KIND_PRECIOUS_METAL
    }
 
    fn pricing_basis(&self) -> PricingBasis {
        PRICING_BASIS_PER_PIECE
    }
 
    fn certificate_kind(&self) -> CertificateKind {
        CERTIFICATE_KIND_TEST_REPORT
    }
 
    fn currency(&self) -> Currency {
        CURRENCY_CHINESE_YUAN
    }
 
    fn base_fee(&self) -> Money {
        self.base_fee
    }
 
    fn urgency_rate(&self) -> Rate {
        // 不加急时费率仍返回 30% 而不是 0%——「是否加急」由 `is_urgent()` 表达,
        // 费率是**价目表上的承诺**,与这一单是否加急无关。
        // 这样报表才能印出「加急加收 30%」这条规则,即使本批没有任何加急单。
        URGENCY_RATE
    }
 
    fn loss_rate(&self) -> Rate {
        // 非破坏性检测:损耗费率恒为 0%,走同一条运算路径(不写 if)。
        Rate::zero()
    }
 
    fn turnover_days(&self) -> u16 {
        TURNOVER_DAYS
    }
 
    fn is_destructive(&self) -> bool {
        false
    }
 
    fn is_urgent(&self) -> bool {
        self.urgent
    }
 
    fn piece_count(&self) -> u16 {
        self.piece_count
    }
 
    fn carat_millis(&self) -> i64 {
        // 素金没有克拉概念,恒为 0。
        0
    }
 
    fn required_items(&self) -> &[TestingItem] {
        // 返回静态数组的切片:项目清单是常量,不需要每次构造 Vec。
        &[TESTING_ITEM_PURITY_ASSAY]
    }
}
 
/// 由规格构造一张贵金属纯度检测委托单。
///
/// 参数 `specification`:样品规格。
/// 返回:合格时返回 `Box<dyn TestingOrder>`,否则返回拒绝原因。
///
/// ## 函数前四行就是一张完整的准入清单
///
/// 每一条 `?` 都对应一条业务规则,读者从上往下读一遍就知道
/// 「这种检测接受什么样的样品」。把校验写成「先全部验完、再构造」的顺序
/// (而不是边构造边验),还带来一个好处:**对象一旦构造出来就一定是合法的**,
/// 下游所有代码都不必再怀疑它的字段。
pub(super) fn build(
    specification: &SampleSpecification,
) -> Result<Box<dyn TestingOrder>, SpecificationRejection> {
    // ① 样品必须是素金——拿钻石来送纯度检测会得到一张毫无意义的报告。
    require_sample_kind(specification, SAMPLE_KIND_PRECIOUS_METAL)?;
    // ② 件数必须在 [1, 99]。
    require_piece_count(specification, 1, MAXIMUM_PIECE_COUNT)?;
 
    // ③ 计价:按件。单价 × 件数(整数乘,无浮点)。
    let base_fee: Money = UNIT_FEE_PER_PIECE.multiply_by_quantity(specification.piece_count() as i64);
 
    Ok(Box::new(PreciousMetalPurityOrder {
        sample_code: specification.sample_code().to_string(),
        applicant: specification.applicant().to_string(),
        piece_count: specification.piece_count(),
        urgent: specification.is_urgent(),
        base_fee,
    }))
}
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : sample_specification.rs
//! # 样品规格 —— 交给工厂的「图纸」
//!
//! ## 为什么需要这个中间类型
//!
//! 工厂的调用签名有两种可能的形态:
//!
//! ```text
//! 形态 A:create(code, sample_code, applicant, kind, pieces, carat, urgent, ...)
//! 形态 B:create(code, specification)      ← 本工程采用
//! ```
//!
//! 形态 A 的问题是:**参数表会随业务增长而不断变长**,而且每多一个参数,
//! 所有调用点都要改一次——包括那些根本不关心新参数的调用点。
//! 更糟的是多参数同类型时容易传错位置(两个 `&str` 谁在前谁在后),
//! 编译器不会报错。
//!
//! 形态 B 把「图纸」收进一个类型:新增一个样品属性时只改这一个类型,
//! 调用点零改动;且每个字段有名字,不存在传错位置的可能。
//!
//! ## 为什么规格里**不含**检测类型编码
//!
//! 编码是**分派键**,它决定「造哪一种」,而规格描述的是「原料」。
//! 把编码也塞进规格里,会让 [`crate::factory`] 的签名退化成
//! `create(specification)`——那时「按什么分派」就藏进参数内部了,
//! 读者要翻进结构体才知道分派依据。把编码留在签名上,
//! **「分派键是显式的」这件事在调用点就看得见**。
//!
//! ## 为什么「样品编号」**不是**构造函数的参数
//!
//! 本类型初版把编号列为必填三项之一,于是调用点全部写成
//! `SampleSpecification::new("", "六福珠宝·中环总店", SAMPLE_KIND_PRECIOUS_METAL)`——
//! **先占一个空位、再被批次覆盖**。这是个坏信号:一个参数若每次都被传占位值,
//! 说明它根本不属于这个签名。
//!
//! 更深一层的理由是**责任归属**:编号不是「调用方知道的信息」,
//! 而是「批次**分配**的信息」——它由检测类型与批次内序号确定性派生
//! (见 [`crate::client::ConsignmentBatch::with_request`]),
//! 目的是让同一条请求无论被加到哪个批次里都得到同一个编号。
//! 把「分配结果」写成构造函数参数,等于要求调用方**先知道后填**,
//! 与分配逻辑的意图正好相反。
//!
//! 因此本类型只保留**两项**必填(送检客户、样品类别),
//! 编号经 [`SampleSpecification::with_sample_code`] 追加。
//!
//! ### 那「忘了填编号」怎么办
//!
//! 不设一条产品侧的拒绝路径,理由是**空编号在工程内不可达**:
//! 本类型唯一的运行期生产者是 `ConsignmentBatch::with_request`,
//! 它在追加之前必定派生并写入编号(并且带 `debug_assert` 兜底)。
//! 多写一条永远不会触发的校验,会让读者误以为它有可能触发,
//! 反而降低了文档可信度——**校验代码的价值在于它抓到过东西**。
 
use crate::domain::SampleKind;
 
/// 一样送检样品的规格(工厂造产品所需的全部原料)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SampleSpecification {
    /// 样品编号(由批次按确定性规则**分配**后写入)。
    ///
    /// 默认空串,正常路径下由 [`SampleSpecification::with_sample_code`] 写入。
    sample_code: String,
    /// 送检客户名称。
    applicant: String,
    /// 样品类别。
    sample_kind: SampleKind,
    /// 件数。
    piece_count: u16,
    /// 克拉重量(千分之一克拉;非钻石类为 0)。
    carat_millis: i64,
    /// 是否加急。
    urgent: bool,
}
 
impl SampleSpecification {
    /// 由「必填两项」构造,其余项取安全默认值。
    ///
    /// 参数 `applicant`:送检客户;`sample_kind`:样品类别。
    /// 返回:规格。默认件数 1、克拉 0(非钻石)、不加急、编号为空串。
    ///
    /// ## 为什么这两项是「必填」
    ///
    /// 它们是**任何检测类型都绕不开、且只有调用方知道**的最小信息:
    /// 没有客户就无法开票(客户不存在于样品里),
    /// 没有样品类别就无法判断「这个样品该不该送这类检测」
    /// (类别是客户申报的事实,产品只能校验不能推断)。
    ///
    /// 把它们放在构造函数上,等于让「忘了填」变成编译错误。
    /// 其余项(件数、克拉、加急)都有合理的默认值,用 `with_*` 追加;
    /// 而**样品编号**虽然也必填,却由批次分配 —— 理由见模块文档。
    pub fn new(applicant: &str, sample_kind: SampleKind) -> SampleSpecification {
        SampleSpecification {
            // 空串是「尚未分配」,不是「编号为空」。工程内唯一的生产者
            // 会在追加到批次之前把它填上,见模块文档。
            sample_code: String::new(),
            applicant: applicant.to_string(),
            sample_kind,
            // 默认 1 件:送检的最常见情形就是单件。
            piece_count: 1,
            // 默认 0:非钻石类样品本就没有克拉数,写 0 而不是「未知」,
            // 让「钻石类却写了 0」能被专门的产品侧校验抓出来。
            carat_millis: 0,
            urgent: false,
        }
    }
 
    /// 写入样品编号(流式写法)。
    ///
    /// 参数 `sample_code`:由批次派生的样品编号。
    /// 返回:写入后的规格。
    ///
    /// ## 为什么这个入口存在,而不是把编号塞进 `new`
    ///
    /// 看调用顺序就明白了:**编号是派生出来的,派生输入包含「批次内序号」**,
    /// 而序号只有在请求被追加进批次的那一刻才知道。
    /// 于是调用方**在写 `new(..)` 的时候根本还不知道编号是多少**——
    /// 一个参数若在调用点无法得知,它就不该出现在那个签名上。
    pub fn with_sample_code(mut self, sample_code: &str) -> SampleSpecification {
        self.sample_code = sample_code.to_string();
        self
    }
 
    /// 设置件数(流式写法)。
    ///
    /// 参数 `piece_count`:件数。
    /// 返回:设置后的规格。
    ///
    /// 取 `self -> Self` 而不是 `&mut self -> &mut Self`:
    /// 规格在演示代码里常被临时拼装后直接传走,
    /// 所有权链式传递比借用再释放更顺手,也不会出现「借用还没结束」的编译错误。
    pub fn with_piece_count(mut self, piece_count: u16) -> SampleSpecification {
        self.piece_count = piece_count;
        self
    }
 
    /// 设置克拉重量(千分之一克拉)。
    ///
    /// 参数 `carat_millis`:千分之一克拉(0.500 克拉即 500)。
    /// 返回:设置后的规格。
    pub fn with_carat_millis(mut self, carat_millis: i64) -> SampleSpecification {
        self.carat_millis = carat_millis;
        self
    }
 
    /// 设置是否加急。
    ///
    /// 参数 `urgent`:是否加急。
    /// 返回:设置后的规格。
    pub fn with_urgency(mut self, urgent: bool) -> SampleSpecification {
        self.urgent = urgent;
        self
    }
 
    /// 样品编号。
    pub fn sample_code(&self) -> &str {
        &self.sample_code
    }
 
    /// 送检客户。
    pub fn applicant(&self) -> &str {
        &self.applicant
    }
 
    /// 样品类别。
    pub fn sample_kind(&self) -> SampleKind {
        self.sample_kind
    }
 
    /// 件数。
    pub fn piece_count(&self) -> u16 {
        self.piece_count
    }
 
    /// 克拉重量(千分之一克拉)。
    pub fn carat_millis(&self) -> i64 {
        self.carat_millis
    }
 
    /// 是否加急。
    pub fn is_urgent(&self) -> bool {
        self.urgent
    }
}
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : silver_purity_order.rs
//! # 银饰纯度检测委托单(具体产品)
//!
//! ## 与贵金属纯度检测的关系:**形状几乎相同,但仍然各写一份**
//!
//! 两者的计价口径(按件)、出证周期(2 个工作日)、证书类型(检测报告)、
//! 承接部门(贵金属室)完全一样,差别只在:样品类别(银饰 vs 素金)、
//! 单价(¥260.00 vs ¥380.00)、检测项目(金属含量测定 vs 成色测定)。
//!
//! 一个自然的念头是「合并成一个类型,用字段区分」。
//! 本工程**不做**这个合并,理由是简单工厂这个模式的论证需要它们分开:
//!
//! - 本工程要演示「**分派表里有几条**」——合并之后分派表只剩 1 条,
//!   「新增一种要改几处」这个度量就失去了分辨率;
//! - 更要紧的是,**银饰与素金是业务上独立的检测类型**(报告模板、资质范围、
//!   收费标准都可能各自调整)。用字段区分的写法会让这两件事重新耦合,
//!   与「一种检测类型一个产品」的分工相反。
//!
//! 一句话:**形状相同不等于职责相同**。合并只在代码行数上有收益,
//! 代价是丢掉一类业务边界。
//!
//! ## 红线同前一文件
//!
//! 结构体与构造入口都是 `pub(super)`,调用方无法自行构造。
 
use crate::domain::{
    CertificateKind, Currency, Money, PricingBasis, Rate, SampleKind, TestingItem,
    TestingOrderCode, CERTIFICATE_KIND_TEST_REPORT, CURRENCY_CHINESE_YUAN,
    ORDER_CODE_SILVER_PURITY, PRICING_BASIS_PER_PIECE, SAMPLE_KIND_SILVER,
    TESTING_ITEM_METAL_CONTENT,
};
use crate::product::sample_specification::SampleSpecification;
use crate::product::specification_guard::{require_piece_count, require_sample_kind};
use crate::product::specification_rejection::SpecificationRejection;
use crate::product::testing_order::TestingOrder;
 
/// 每件样品的固定检测费:¥260.00。银饰检测工艺比素金简单,故单价更低。
const UNIT_FEE_PER_PIECE: Money = Money::from_minor_units(26_000, CURRENCY_CHINESE_YUAN);
 
/// 标准出证工作日。
const TURNOVER_DAYS: u16 = 2;
 
/// 加急费率 30%。
const URGENCY_RATE: Rate = Rate::from_percent(30);
 
/// 单张委托单允许的最大件数。
const MAXIMUM_PIECE_COUNT: u16 = 99;
 
/// 银饰纯度检测委托单。
pub(super) struct SilverPurityOrder {
    /// 样品编号。
    sample_code: String,
    /// 送检客户。
    applicant: String,
    /// 件数。
    piece_count: u16,
    /// 是否加急。
    urgent: bool,
    /// 基准费(受理时的价格快照)。
    base_fee: Money,
}
 
impl TestingOrder for SilverPurityOrder {
    fn order_code(&self) -> TestingOrderCode {
        ORDER_CODE_SILVER_PURITY
    }
 
    fn sample_code(&self) -> &str {
        &self.sample_code
    }
 
    fn applicant(&self) -> &str {
        &self.applicant
    }
 
    fn handling_department(&self) -> &'static str {
        // 银饰与素金同为贵金属检测,共用同一个实验室。
        "贵金属室"
    }
 
    fn sample_kind(&self) -> SampleKind {
        SAMPLE_KIND_SILVER
    }
 
    fn pricing_basis(&self) -> PricingBasis {
        PRICING_BASIS_PER_PIECE
    }
 
    fn certificate_kind(&self) -> CertificateKind {
        CERTIFICATE_KIND_TEST_REPORT
    }
 
    fn currency(&self) -> Currency {
        CURRENCY_CHINESE_YUAN
    }
 
    fn base_fee(&self) -> Money {
        self.base_fee
    }
 
    fn urgency_rate(&self) -> Rate {
        URGENCY_RATE
    }
 
    fn loss_rate(&self) -> Rate {
        Rate::zero()
    }
 
    fn turnover_days(&self) -> u16 {
        TURNOVER_DAYS
    }
 
    fn is_destructive(&self) -> bool {
        false
    }
 
    fn is_urgent(&self) -> bool {
        self.urgent
    }
 
    fn piece_count(&self) -> u16 {
        self.piece_count
    }
 
    fn carat_millis(&self) -> i64 {
        0
    }
 
    fn required_items(&self) -> &[TestingItem] {
        &[TESTING_ITEM_METAL_CONTENT]
    }
}
 
/// 由规格构造一张银饰纯度检测委托单。
///
/// 参数 `specification`:样品规格。
/// 返回:合格时返回产品,否则返回拒绝原因。
pub(super) fn build(
    specification: &SampleSpecification,
) -> Result<Box<dyn TestingOrder>, SpecificationRejection> {
    require_sample_kind(specification, SAMPLE_KIND_SILVER)?;
    require_piece_count(specification, 1, MAXIMUM_PIECE_COUNT)?;
 
    let base_fee: Money =
        UNIT_FEE_PER_PIECE.multiply_by_quantity(specification.piece_count() as i64);
 
    Ok(Box::new(SilverPurityOrder {
        sample_code: specification.sample_code().to_string(),
        applicant: specification.applicant().to_string(),
        piece_count: specification.piece_count(),
        urgent: specification.is_urgent(),
        base_fee,
    }))
}
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : specification_guard.rs
//! # 规格守卫 —— 各具体产品共用的校验口径
//!
//! ## 为什么把这些小检查集中在一个文件里
//!
//! 五个具体产品都要做同样的三件事:核对样品类别、核对件数、核对数值区间。
//! 若每个产品各写一遍,会立刻出现两个后果:
//!
//! 1. **口径漂移**:钻石分级写「件数必须 ≥ 1」,宝石鉴定写成「> 0」——
//!    数值上等价,但**拒绝规则的说明文本会不一样**,
//!    于是报表上同一类错误出现两种措辞,读者以为它们是不同的问题。
//! 2. **漏改**:将来「件数上限」从 99 调到 199,要记得改五处。
//!
//! 集中之后,五个产品调用的是同一段代码,
//! 「同一条规则全工程只有一份实现」这件事就是结构上的事实,而不是纪律。
//!
//! ## 返回 `Result` 而不是「构造一个失败对象」
//!
//! 本模块的函数全是「不满足就返回 `Err(拒绝原因)`」,
//! 调用方用 `?` 直接向上抛。这样产品构造函数的前几行就是一张
//! **可读的准入清单**,读者一眼能看完「这种委托单接受什么样的样品」——
//! 比散落在各处的 `if ... { return Err(...) }` 好得多。
 
use crate::domain::SampleKind;
use crate::product::sample_specification::SampleSpecification;
use crate::product::specification_rejection::SpecificationRejection;
 
/// 校验件数区间。
///
/// 参数 `specification`:样品规格;`minimum` / `maximum`:允许区间(含)。
/// 返回:合格返回 `Ok(())`,否则返回 [`SpecificationRejection::ParameterOutOfRange`]。
///
/// 件数下界通常传 1——「0 件样品」在业务上不是一个合法的送检请求,
/// 但它能通过 `with_piece_count(0)` 被构造出来,因此必须显式拦。
pub fn require_piece_count(
    specification: &SampleSpecification,
    minimum: u16,
    maximum: u16,
) -> Result<(), SpecificationRejection> {
    let actual: u16 = specification.piece_count();
    if actual < minimum || actual > maximum {
        return Err(SpecificationRejection::ParameterOutOfRange {
            parameter: "piece_count",
            parameter_chinese_name: "样品件数",
            value: actual as i64,
            minimum: minimum as i64,
            maximum: maximum as i64,
        });
    }
    Ok(())
}
 
/// 校验样品类别必须等于期望值。
///
/// 参数 `specification`:样品规格;`expected`:该检测类型要求的类别。
/// 返回:相符返回 `Ok(())`,否则返回 [`SpecificationRejection::SampleKindMismatch`]。
pub fn require_sample_kind(
    specification: &SampleSpecification,
    expected: SampleKind,
) -> Result<(), SpecificationRejection> {
    let actual: SampleKind = specification.sample_kind();
    if actual != expected {
        return Err(SpecificationRejection::SampleKindMismatch { expected, actual });
    }
    Ok(())
}
 
/// 校验一个整数值必须为正(`> 0`),否则报「缺少必填参数」。
///
/// 参数 `value`:待校验的值;`parameter`:参数英文名;
/// `parameter_chinese_name`:参数中文名。
/// 返回:合格返回 `Ok(())`。
///
/// ## 为什么「值为 0」被归为「缺少必填参数」而不是「参数越界」
///
/// 这是刻意的分类选择。用户没填某个数值字段时,表单提交上来就是 0
/// (而不是一个空值)——对业务而言「没填」与「填了 0」是同一件事。
/// 若归到「参数越界」,报表上就会把「用户忘了填克拉数」
/// 与「用户填了 999 克拉」混成一类,而这两件事的处理方式完全不同:
/// 前者要联系客户补填,后者要联系客户复核。
///
/// 分类的价值在于**它决定了后续动作**,不在于它是否精确描述了数值。
pub fn require_positive_value(
    value: i64,
    parameter: &'static str,
    parameter_chinese_name: &'static str,
) -> Result<(), SpecificationRejection> {
    if value <= 0 {
        return Err(SpecificationRejection::MissingRequiredParameter {
            parameter,
            parameter_chinese_name,
        });
    }
    Ok(())
}
 
/// 校验一个整数值落在闭区间内。
///
/// 参数 `value`:待校验的值;`parameter`:参数英文名;
/// `parameter_chinese_name`:参数中文名;`minimum` / `maximum`:允许区间(含)。
/// 返回:合格返回 `Ok(())`。
pub fn require_value_in_range(
    value: i64,
    parameter: &'static str,
    parameter_chinese_name: &'static str,
    minimum: i64,
    maximum: i64,
) -> Result<(), SpecificationRejection> {
    if value < minimum || value > maximum {
        return Err(SpecificationRejection::ParameterOutOfRange {
            parameter,
            parameter_chinese_name,
            value,
            minimum,
            maximum,
        });
    }
    Ok(())
}
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : specification_rejection.rs
//! # 规格拒绝 —— 「知道该造什么,但原料不合格」
//!
//! ## 为什么它在 `product` 层,而不是 `factory` 层
//!
//! 本工程把「创建失败」拆成**两类性质完全不同**的失败,并让它们分居两层:
//!
//! | 失败 | 含义 | 归谁 | 类型 |
//! |---|---|---|---|
//! | 不知道**该造什么** | 编码不在分派表里 | `factory` | [`crate::factory::CreationError::UnknownOrderCode`] |
//! | 知道该造什么、但**原料不合格** | 样品类别不符、必填缺失、参数越界 | `product` | 本类型 |
//!
//! 这个拆分不是为了好看。它带来三个实际好处:
//!
//! 1. **责任边界清晰**:工厂本身**永远不会失败**——它的分派动作总是能执行完,
//!    要么找到构造器,要么没找到。而「样品是钻石却要送玉石鉴定」这件事,
//!    只有产品自己知道,工厂不可能替它判断(工厂不该认识业务规则)。
//! 2. **报表可分列统计**:两类失败在报表上是两块数字。
//!    若合并成一个枚举,读者看到「失败 7 笔」时无法判断
//!    到底是「编码写错了」(运维问题)还是「样品送错了」(业务问题)。
//! 3. **扩展方只需实现前一类**:在工程外新增一种检测类型时,
//!    自己定义自己的拒绝理由即可——`factory` 一行都不用改。
//!
//! ## 拒绝原因是「规则编码 + 说明」两条信息
//!
//! 沿用既有工程里已经验证过的做法:每个拒绝变体都配一个
//! **稳定的规则编码**([`SpecificationRejection::rule_code`])与一段
//! **可读的说明**([`SpecificationRejection::description_text`])。
//! 报表按编码归组统计,按说明展示细节——
//! 若只有说明,文字一改统计就断;若只有编码,读者不知道发生了什么。
 
use crate::domain::SampleKind;
 
/// 产品侧拒绝:知道该造哪一种委托单,但样品规格不合格。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpecificationRejection {
    /// 样品类别与该检测类型要求的类别不符。
    ///
    /// 例:拿一件玉石去送「钻石分级」。
    /// 这**必须**被拒绝而不是照单全收——分级证书上会写明钻石的 4C,
    /// 若样品根本不是钻石,出了证书就是伪造。
    SampleKindMismatch {
        /// 该检测类型要求的类别。
        expected: SampleKind,
        /// 实际送检的类别。
        actual: SampleKind,
    },
 
    /// 缺少必填参数。
    ///
    /// 例:钻石分级必须给出克拉重量,而规格里是 0。
    MissingRequiredParameter {
        /// 缺失的参数名(英文标识,便于检索代码)。
        parameter: &'static str,
        /// 参数的中文说明,直接进报表。
        parameter_chinese_name: &'static str,
    },
 
    /// 参数超出允许区间。
    ///
    /// 例:件数为 0、克拉重量超过单颗上限。
    ParameterOutOfRange {
        /// 越界的参数名。
        parameter: &'static str,
        /// 参数的中文说明。
        parameter_chinese_name: &'static str,
        /// 实际取值。
        value: i64,
        /// 允许下界(含)。
        minimum: i64,
        /// 允许上界(含)。
        maximum: i64,
    },
}
 
impl SpecificationRejection {
    /// 稳定的规则编码。
    ///
    /// 报表按它归组统计。编码一经发布**不应再改**——
    /// 改了会让历史统计对不上。若规则含义要变,应当新增一个编码。
    pub fn rule_code(&self) -> &'static str {
        match self {
            SpecificationRejection::SampleKindMismatch { .. } => "SAMPLE_KIND_MISMATCH",
            SpecificationRejection::MissingRequiredParameter { .. } => "MISSING_REQUIRED_PARAMETER",
            SpecificationRejection::ParameterOutOfRange { .. } => "PARAMETER_OUT_OF_RANGE",
        }
    }
 
    /// 可读的中文说明。
    ///
    /// ## 为什么把「原值」保留在说明里
    ///
    /// 「参数越界」这句话本身没有任何诊断价值;读者需要知道
    /// **是哪个参数、原值多少、允许区间多少**,才能去定位送检单填错在哪。
    /// 因此说明文本里必须带上原值——这与既有工程里
    /// 「拒绝说明要保留越界原值,可直接定位」的口径一致。
    pub fn description_text(&self) -> String {
        match self {
            SpecificationRejection::SampleKindMismatch { expected, actual } => format!(
                "样品类别不符:本检测类型要求「{}」,实际送检「{}」",
                expected.chinese_name(),
                actual.chinese_name()
            ),
            SpecificationRejection::MissingRequiredParameter {
                parameter,
                parameter_chinese_name,
            } => format!(
                "缺少必填参数:{}({})",
                parameter_chinese_name, parameter
            ),
            SpecificationRejection::ParameterOutOfRange {
                parameter,
                parameter_chinese_name,
                value,
                minimum,
                maximum,
            } => format!(
                "参数越界:{}({})= {},允许区间 [{}, {}](保留原值,可直接定位送检单)",
                parameter_chinese_name, parameter, value, minimum, maximum
            ),
        }
    }
}
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : testing_order.rs
//! # 抽象产品 —— 客户端唯一直接依赖的类型
//!
//! ## 这个 trait 的设计目标:让「客户端不认识任何具体产品」
//!
//! 简单工厂的价值主张是「客户端只要给出一个类型编码,就拿到一个可用对象」。
//! 要让这句话成立,客户端**必须**只依赖一个抽象类型——
//! 否则它就得 `match` 编码再分别处理,等于把工厂的分派逻辑抄了一遍。
//!
//! 因此本 trait 把「一个检测委托单能被问到的全部问题」都声明出来,
//! 客户端与分析层只用这些方法,**永远不需要知道**具体是哪种委托单。
//!
//! ## 哪些方法是「必须实现」的,哪些是「默认方法」
//!
//! 判断标准只有一条:**这个方法能不能只用 trait 已声明的方法算出来?**
//!
//! - 能 → 写成默认方法。例如 [`TestingOrder::total_fee`] 只是
//!   `基准费 + 加急费 + 损耗费` 三项相加,而三项各自都已声明。
//!   写成默认方法的好处是:**新增一种委托单时不必重写这三行**,
//!   也就不会出现「某一种委托单忘了加损耗费」这种漏算。
//! - 不能 → 必须由具体实现提供。例如 [`TestingOrder::base_fee`],
//!   每种检测类型的计价规则本来就不同,trait 无从推算。
//!
//! 这条标准看似简单,却是「默认方法能不能省样板」的全部依据。
//! 反过来,如果一个默认方法试图访问实现者的私有字段,编译器会直接拒绝——
//! 这正是 Rust 帮我们守住的边界。
 
use crate::domain::{
    zero_money, CertificateKind, Currency, Money, PricingBasis, Rate, SampleKind, TestingItem,
    TestingOrderCode,
};
use crate::product::sample_specification::SampleSpecification;
use crate::product::specification_rejection::SpecificationRejection;
use crate::support::CalendarDate;
 
/// 出证排期。
///
/// ## 为什么把「被跳过的周末」也留下来
///
/// 排期结果是一个日期,但读者要能核实这个日期是**怎么来的**:
/// 「受理日 2026-09-03(周四)+ 5 个工作日 = 09-10(周四),
/// 期间跳过 09-05(周六)、09-06(周日)」。
/// 只印最终日期的话,读者无法判断该不该相信它。
///
/// 把过程一并留在结构体里,报表就能把「跳过了哪两天」原样打印出来——
/// **可核查性优先于结构简洁**。这也是本工程所有分析结构的共同取向:
/// 结论必须带着可复算的依据。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeliverySchedule {
    /// 受理日。
    accepted_on: CalendarDate,
    /// 承诺工作日数(加急时是折半后的值)。
    promised_working_days: u16,
    /// 预计出证日。
    delivery_on: CalendarDate,
    /// 期间被跳过的周末日期(按时间先后排列)。
    skipped_weekends: Vec<CalendarDate>,
}
 
impl DeliverySchedule {
    /// 构造一个排期。
    ///
    /// 参数 `accepted_on` / `promised_working_days` / `delivery_on` / `skipped_weekends`。
    /// 返回:排期值对象。
    ///
    /// 参数较多是刻意的:排期的四个要素都必须由调用方(唯一的生产者
    /// [`TestingOrder::schedule`])一次给全,不给「先造一半再填」的机会。
    pub fn new(
        accepted_on: CalendarDate,
        promised_working_days: u16,
        delivery_on: CalendarDate,
        skipped_weekends: Vec<CalendarDate>,
    ) -> DeliverySchedule {
        DeliverySchedule {
            accepted_on,
            promised_working_days,
            delivery_on,
            skipped_weekends,
        }
    }
 
    /// 受理日。
    pub fn accepted_on(&self) -> CalendarDate {
        self.accepted_on
    }
 
    /// 承诺工作日数。
    pub fn promised_working_days(&self) -> u16 {
        self.promised_working_days
    }
 
    /// 预计出证日。
    pub fn delivery_on(&self) -> CalendarDate {
        self.delivery_on
    }
 
    /// 被跳过的周末日期。
    pub fn skipped_weekends(&self) -> &[CalendarDate] {
        &self.skipped_weekends
    }
 
    /// 被跳过的周末数量。
    pub fn skipped_weekend_count(&self) -> usize {
        self.skipped_weekends.len()
    }
 
    /// 从受理日到出证日的**日历天**跨度(含首尾)。
    ///
    /// 与 [`DeliverySchedule::promised_working_days`] 对照着看能说明一件事:
    /// 「承诺 5 个工作日」实际跨了 8 个日历天——这正是要向客户解释的口径差。
    /// 两个数都印出来,客户才不会以为实验室拖延了。
    pub fn calendar_span_days(&self) -> u32 {
        self.delivery_on.span_days_inclusive(&self.accepted_on)
    }
 
    /// 被跳过的周末的中文列表文本,如 `9 月 5 日(六)、9 月 6 日(日)`。
    ///
    /// 空列表时返回 `"无"`——**不要**返回空字符串。空字符串在表格里看起来
    /// 与「这一格没填」完全一样,读者无法区分「没有跳过周末」与「忘了算」。
    pub fn skipped_days_text(&self) -> String {
        if self.skipped_weekends.is_empty() {
            return "无".to_string();
        }
        self.skipped_weekends
            .iter()
            .map(|date| format!("{}({})", date.month_day_text(), date.weekday_text()))
            .collect::<Vec<String>>()
            .join("、")
    }
}
 
/// 检测委托单(抽象产品角色)。
///
/// 这是客户端与分析层**唯一**直接依赖的产品类型。
/// 具体实现见本层其余文件;它们的类型名在本层之外**不可见**
/// (声明为 `pub(super)`),因此调用方连「写出一个具体产品类型」都做不到,
/// 只能通过 [`crate::factory`] 的分派拿到 `Box<dyn TestingOrder>`。
pub trait TestingOrder {
    // =====================================================================
    // 必须由具体实现提供的方法
    // =====================================================================
 
    /// 检测类型编码。**这是分派的结果,也是报表的归组键。**
    fn order_code(&self) -> TestingOrderCode;
 
    /// 样品编号。由调用方给出(确定性派生),委托单原样保存。
    fn sample_code(&self) -> &str;
 
    /// 送检客户名称。
    fn applicant(&self) -> &str;
 
    /// 承接部门(如「钻石室」)。
    fn handling_department(&self) -> &'static str;
 
    /// 样品类别。
    fn sample_kind(&self) -> SampleKind;
 
    /// 计价口径(一个**可分组**的标签,不是分派键;理由见
    /// [`crate::domain::pricing_basis`] 的模块文档)。
    fn pricing_basis(&self) -> PricingBasis;
 
    /// 证书类型。
    fn certificate_kind(&self) -> CertificateKind;
 
    /// 结算币种。
    ///
    /// 单独声明出来,是为了让默认方法里的 [`zero_money`] 有参数可用——
    /// 默认方法拿不到实现者的私有字段,只能问它要。
    fn currency(&self) -> Currency;
 
    /// 基准检测费(**不含**加急与损耗)。
    fn base_fee(&self) -> Money;
 
    /// **价目表上的**加急费率(与这一单是否加急无关)。
    ///
    /// ⚠️ 不要把「不加急」误实现成返回 0%:报表需要印出
    /// 「加急加收 30%」这条规则,即使本批一张加急单都没有。
    /// 「是否适用」由默认方法 [`TestingOrder::urgency_surcharge`] 处理。
    fn urgency_rate(&self) -> Rate;
 
    /// **检测类型的**损耗费率(非破坏性检测返回 0%)。
    ///
    /// 与 [`TestingOrder::urgency_rate`] 的差别在于适用面:
    /// 损耗是**检测类型的性质**(宝石鉴定永远要取样),
    /// 因此类型自己就能给出最终值,默认方法无需分支。
    ///
    /// 注意:费率**恒有值**,即使检测是非破坏性的也返回 0%
    /// 而不是「没有这个概念」。理由是默认方法 [`TestingOrder::loss_fee`]
    /// 必须能无条件拿到一个比率;返回 `Option<Rate>` 会让每个调用点
    /// 都要处理一个「永远不会是 None」的分支。
    fn loss_rate(&self) -> Rate;
 
    /// 标准出证工作日数(不加急时)。
    fn turnover_days(&self) -> u16;
 
    /// 是否破坏性检测(需要取样,样品不可完整返还)。
    fn is_destructive(&self) -> bool;
 
    /// 是否加急。
    fn is_urgent(&self) -> bool;
 
    /// 样品件数。
    fn piece_count(&self) -> u16;
 
    /// 钻石类样品的克拉重量(**千分之一克拉**为单位的整数;非钻石类为 0)。
    ///
    /// ## 为什么用「千分之一克拉」而不是 `f64`
    ///
    /// 克拉报价通常是「每克拉多少钱」,而钻石重量要精确到小数点后三位
    /// (0.500 ct / 1.205 ct)。若用 `f64` 存储 1.205,它实际是
    /// 1.2049999999999998,乘上单价再舍入就会偶尔差一分。
    /// 存成整数「千分之一克拉」之后,计价变成纯整数乘除,
    /// 舍入口径只在一处([`Money::scale_by_ratio`])。
    fn carat_millis(&self) -> i64;
 
    /// **计费用的**克拉重量(千分之一克拉)。
    ///
    /// ## 默认实现就是实际重量——但有一类产品必须覆盖它
    ///
    /// 钻石分级设了「最小计费重量 0.500 克拉」(用最小计费量表达固定成本,
    /// 见 [`crate::product::diamond_grading_order`] 的模块文档)。
    /// 于是「实际重量」与「计费重量」是两个不同的数:
    /// 一颗 0.450 ct 的钻石,实际重量是 450,计费重量是 500。
    ///
    /// ## 为什么它必须是 trait 上的方法,而不是报表里算出来的
    ///
    /// 报表确实可以写 `max(实际重量, 500)` —— 但那要求报表**知道 500 这个业务常量**。
    /// 业务常量一旦出现在报表层,调价时就会漏改一处,
    /// 而漏改的表现是「账单按新规则算、报表按旧规则解释」。
    ///
    /// 把它做成 trait 方法之后,报表拿到的是**产品自己给出的答案**,
    /// 报表只需要把两个数并排打印,就能让读者自行核实
    /// 「计费重量确实被抬到了 0.500」。**这是「最小计费重量」这条规则
    /// 从一句文档声明变成可核对数字的唯一途径。**
    ///
    /// ## 为什么做成默认方法
    ///
    /// 判据仍然是那条:能不能只用已声明的方法算出来?
    /// 对四种不按克拉计价的产品,`计费重量 == 实际重量`
    /// 完全可以由 [`TestingOrder::carat_millis`] 得出,
    /// 因此它们不需要写一行代码。只有钻石分级需要覆盖。
    fn billable_carat_millis(&self) -> i64 {
        self.carat_millis()
    }
 
    /// 该委托单包含的检测项目清单。
    fn required_items(&self) -> &[TestingItem];
 
    // =====================================================================
    // 默认方法(只依赖上面已声明的方法,因此实现者不必重写)
    // =====================================================================
 
    /// 加急附加费 = 基准费 × **适用**加急费率。
    ///
    /// ## ⚠️ 这里必须有一个分支,而损耗费那边没有——差别不是随意的
    ///
    /// 本方法的初版写成 `self.urgency_rate().apply_to(&self.base_fee())`,
    /// 并且注释断言「不加急时费率为 0%,自然算出零」。**那是错的**:
    /// [`TestingOrder::urgency_rate`] 返回的是**价目表上的承诺费率**(30%),
    /// 它与「这一单是否加急」无关。于是不加急的单子也会被加收 30%——
    /// 一个只在账单上体现、报表看起来却完全正常的缺陷。
    ///
    /// 修正之后,`适用费率` 在这里现算:
    ///
    /// | | 费率从哪来 | 是否需要分支 |
    /// |---|---|---|
    /// | 损耗费 | **检测类型**的属性(宝石鉴定恒为破坏性) | 不需要,类型自己就是 0% 或 5% |
    /// | 加急费 | **这一张委托单**的属性(同一类型既可加急也可不加急) | 需要 |
    ///
    /// 两处看起来不对称,但那个不对称恰好反映了业务事实:
    /// 「是否破坏性」是类型的性质,「是否加急」是实例的性质。
    /// 把这条差异写出来,比强行让两个方法长得一样更有价值。
    ///
    /// ## 零仍然走统一运算路径
    ///
    /// 不加急时取 [`Rate::zero`] 再 `apply_to`,**不是**「跳过这次乘法」。
    /// 这样将来若出现「加急但免费」的活动价,改的仍然只是费率一处。
    fn urgency_surcharge(&self) -> Money {
        let applicable_rate: Rate = if self.is_urgent() {
            self.urgency_rate()
        } else {
            Rate::zero()
        };
        applicable_rate.apply_to(&self.base_fee())
    }
 
    /// 样品损耗费 = 基准费 × 损耗费率(非破坏性检测时为 `¥0.00`)。
    ///
    /// 与 [`TestingOrder::urgency_surcharge`] 相对照:那里需要一个分支
    /// (加急是**实例**属性),这里不需要(破坏性是**类型**属性,
    /// 非破坏性类型的 [`TestingOrder::loss_rate`] 本身就是 0%)。
    /// 差异的理由见 `urgency_surcharge` 的文档。
    ///
    /// 零仍然走统一运算路径——`Rate::zero().apply_to(x)`,而不是跳过乘法。
    fn loss_fee(&self) -> Money {
        self.loss_rate().apply_to(&self.base_fee())
    }
 
    /// 合计检测费 = 基准费 + 加急附加费 + 损耗费。
    ///
    /// ## 为什么这里可以放心用 `expect`
    ///
    /// 三项的币种都来自同一个 [`TestingOrder::currency`],必然相等,
    /// 因此 [`Money::add`] 不可能返回 `None`。若真返回了 `None`,
    /// 那说明有人给同一张委托单混用了币种——这是**编程错误**,
    /// 应当在 debug 构建下立刻炸掉,而不是让报表悄悄少算一笔。
    ///
    /// 用 `debug_assert!` + 兜底而不是直接 `expect`:
    /// 释放构建下不 panic(报表仍能打印),但 debug 构建下会当场指出问题。
    fn total_fee(&self) -> Money {
        let first_step: Money = self
            .base_fee()
            .add(&self.urgency_surcharge())
            .unwrap_or_else(|| self.base_fee());
        debug_assert!(
            self.base_fee().add(&self.urgency_surcharge()).is_some(),
            "委托单 {} 的基准费与加急费币种不一致,合计会少算一笔",
            self.order_code()
        );
        first_step
            .add(&self.loss_fee())
            .unwrap_or(first_step)
    }
 
    /// 项目费合计(各检测项目单价之和)。
    ///
    /// ⚠️ 这个数**不参与**计价([`TestingOrder::base_fee`] 才是账单口径)。
    /// 它存在的意义是**对照**:报表把「项目费合计」与「基准费」并排印出来,
    /// 读者就能看出「按件计价」的检测类型其项目清单只是说明性的,
    /// 而「按项目数计价」的类型两者才会相等。把这两个数并排,
    /// 比在文档里写一句「口径不同」有效得多。
    fn item_fee_total(&self) -> Money {
        let mut accumulated: Money = zero_money(self.currency());
        for item in self.required_items() {
            accumulated = accumulated.add(&item.item_fee()).unwrap_or(accumulated);
        }
        accumulated
    }
 
    /// 检测项目行数。
    ///
    /// 默认方法而非字段:行数**就是**清单的长度,存第二份必然有一天不同步。
    fn line_count(&self) -> usize {
        self.required_items().len()
    }
 
    /// 承诺工作日数(加急时折半,向上取整)。
    ///
    /// ## 为什么是「向上取整」而不是「向下」
    ///
    /// 3 个工作日的加急若向下取整成 1 天,等于承诺了一个做不到的周期;
    /// 向上取整成 2 天则是**可以兑现**的承诺。对客户的承诺宁可保守,
    /// 这是业务口径,不是技术偏好,因此写在这里并说明。
    ///
    /// 实现用 `(days + 1) / 2`(整数)而不是 `(days as f64 / 2.0).ceil()`:
    /// 后者引入浮点,而本工程全程无浮点。
    fn promised_working_days(&self) -> u16 {
        let standard: u16 = self.turnover_days();
        if !self.is_urgent() {
            return standard;
        }
        (standard + 1) / 2
    }
 
    /// 由受理日推算排期。
    ///
    /// 参数 `accepted_on`:受理日。
    /// 返回:排期(含被跳过的周末清单)。
    ///
    /// 排期只依赖 [`TestingOrder::promised_working_days`] 与受理日,
    /// 因此可以做成默认方法——**新增一种委托单不必重写排期**,
    /// 也就不可能出现「某一种委托单的排期忘了跳过周末」。
    fn schedule(&self, accepted_on: &CalendarDate) -> DeliverySchedule {
        let promised: u16 = self.promised_working_days();
        // 工作日推进的算法在 support 层,这里只负责把结果装进排期对象。
        let (delivery_on, skipped_weekends) = accepted_on.add_working_days(promised);
        DeliverySchedule::new(*accepted_on, promised, delivery_on, skipped_weekends)
    }
 
    /// 工作量单元:本张委托单对实验室产能的占用。
    ///
    /// ## 为什么不用金额代替它
    ///
    /// 金额是**收费口径**,而产能是**占用口径**。一张加急的钻石分级
    /// 可能收费高(金额大),但它占用的工时反而比一件需要取样的宝石鉴定少。
    /// 两者不是同一个量,因此必须分别计量。
    ///
    /// 口径定义(写在类型上,避免各处各算一遍):
    /// ```text
    /// 工作量单元 = 项目数 × 10 + 件数 × 2 + 破坏性附加 20 + 加急附加 10
    /// ```
    /// 这三个附加项是**业务的产能经验值**,不是从别的数推出来的,
    /// 所以必须显式写下口径。
    fn workload_units(&self) -> u32 {
        let from_items: u32 = (self.line_count() as u32) * 10;
        let from_pieces: u32 = (self.piece_count() as u32) * 2;
        let destructive_extra: u32 = if self.is_destructive() { 20 } else { 0 };
        let urgent_extra: u32 = if self.is_urgent() { 10 } else { 0 };
        from_items + from_pieces + destructive_extra + urgent_extra
    }
}
 
/// 产品构造器:把一份样品规格变成一张委托单,或给出拒绝原因。
///
/// ## 为什么用函数指针而不是裸闭包
///
/// 函数指针 `fn(..) -> ..` 是 `Copy` 的、可比较的,且**没有捕获环境**。
/// 这带来两个本工程需要的性质:
///
/// 1. **可被登记表反复复制**:登记表要把同一张构造器表分发给多个消费者
///    (编译期白名单工厂、运行期登记表、覆盖率体检),
///    若是 `Box<dyn Fn>` 就要处处 `clone` 且无法比较。
/// 2. **没有隐藏状态**:构造器除了入参之外不依赖任何东西,
///    因此「同一份规格永远得到同一张委托单」这件事在类型上成立,
///    本工程「两次运行逐字节一致」的验收标准因此不需要额外论证。
///
/// 代价是构造器不能捕获上下文(比如把实验室的价目表带进去)。
/// 本工程不需要——价目表是 `const`,编译期内联进构造器即可。
/// 这是**刻意的取舍**,不是没考虑到。
pub type ProductBuilder =
    fn(&SampleSpecification) -> Result<Box<dyn TestingOrder>, SpecificationRejection>;
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : calendar_date.rs
//! # 日历日 —— 受理日、出证排期与工作日推进
//!
//! ## 为什么自己实现
//!
//! 本工程要把「受理日 + N 个工作日 = 预计出证日」算出来,并把这个过程
//! **打印成可复核的证据**(跳过了哪两天、为什么跳过)。
//! 第三方日期库会读真实时间、内部表示是儒略日/时区/纳秒,
//! 两点都不符合本工程要求:**两次运行输出必须逐字节一致**,
//! 且「跳过了哪几天」必须能被逐日核算。
//!
//! ## ⚠️ 星期算法不能用算术近似式
//!
//! Sakamoto 算法的核心是**月份偏移表**
//! `[0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]`。
//! 有人会把它「化简」成 `(26*(m+1))/10`——这两者**在 3 月/9 月等多个月份不相等**,
//! 会导致**整年星期错一位**,进而让「跳过周末」跳错日子。
//! 有公认表格的算法,宁可把表写出来,也不要用「看起来等价」的算式。
//!
//! ## 全部运算都是整数
//!
//! 不经过任何浮点:闰年用整除判断、月天数用查表、日期差用「绝对日序号相减」。
//! 这保证「同样的输入永远得到同样的输出」。
 
/// 每个月的天数(非闰年)。
///
/// 下标 0 占位(月份从 1 开始),便于直接用 `month` 索引而不用减一——
/// 少一次减法就少一个「忘了减一」的机会。
const DAYS_IN_MONTH_NON_LEAP: [u32; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
 
/// Sakamoto 算法的月份偏移表。
///
/// 下标 0 占位(月份从 1 开始)。这张表是**公认表格**,不可改动,
/// 也不可用 `(26*(m+1))/10` 之类的算术式替代——两者在 3 月与 9 月不同。
const SAKAMOTO_MONTH_OFFSET: [i32; 13] = [0, 0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
 
/// 星期名称(0 = 周日)。
const WEEKDAY_TEXT: [&str; 7] = ["日", "一", "二", "三", "四", "五", "六"];
 
/// 一个日历日。
///
/// 字段全部私有,只能经 [`CalendarDate::from_ymd`] 构造,保证
/// 「构造出来的日期是合法的」这一不变量集中在构造函数里维护。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CalendarDate {
    /// 公历年(如 `2026`)。
    year: i32,
    /// 月份(1..=12)。
    month: u32,
    /// 日(1..=该月天数)。
    day: u32,
}
 
impl CalendarDate {
    /// 由年、月、日构造。
    ///
    /// 参数 `year` / `month` / `day`。
    /// 返回:日历日。若入参非法(月份越界、日越界),
    /// **回退到该年 1 月 1 日**而不是 panic——
    /// 业务数据脏值时,报表崩掉比出现一个明显可疑的日期更难排查。
    /// 错误要可见,不要致命。
    pub fn from_ymd(year: i32, month: u32, day: u32) -> CalendarDate {
        if !CalendarDate::is_valid(year, month, day) {
            // 回退:非法输入统一落到该年 1 月 1 日,便于在输出里一眼看出异常。
            return CalendarDate {
                year,
                month: 1,
                day: 1,
            };
        }
        CalendarDate { year, month, day }
    }
 
    /// 判断是否闰年。
    ///
    /// 规则:能被 4 整除,但不能被 100 整除;或者能被 400 整除。
    /// 全部用整除判断,不引入浮点。
    pub const fn is_leap_year(year: i32) -> bool {
        (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
    }
 
    /// 某年某月的天数。
    ///
    /// 参数 `year` / `month`。
    /// 返回:天数;月份越界时返回 0(而不是 panic)——
    /// 调用方拿到 0 就知道入参有问题,且不会让整份报表崩掉。
    pub fn days_in_month(year: i32, month: u32) -> u32 {
        if month < 1 || month > 12 {
            return 0;
        }
        if month == 2 && CalendarDate::is_leap_year(year) {
            // 闰年 2 月 29 天。
            return 29;
        }
        DAYS_IN_MONTH_NON_LEAP[month as usize]
    }
 
    /// 校验年月日是否合法。
    ///
    /// 参数 `year` / `month` / `day`。
    /// 返回:合法为 `true`。
    pub fn is_valid(year: i32, month: u32, day: u32) -> bool {
        if month < 1 || month > 12 || day < 1 {
            return false;
        }
        day <= CalendarDate::days_in_month(year, month)
    }
 
    /// 该日期在**年内**的序号(1 月 1 日为 1)。
    ///
    /// 这是算「两个日期相差多少天」的基础:先各自折算成绝对值再相减。
    /// 把所有日期先折算成同一个线性尺度,是处理日期差的通用做法——
    /// 它避免了对「跨月」「跨年」「闰年 2 月」的逐段特殊处理。
    pub fn day_ordinal(&self) -> i32 {
        let mut ordinal: i32 = 0;
        // 累加前面各整月的天数。
        let mut earlier_month: u32 = 1;
        while earlier_month < self.month {
            ordinal += CalendarDate::days_in_month(self.year, earlier_month) as i32;
            earlier_month += 1;
        }
        ordinal + self.day as i32
    }
 
    /// 把日期折算成「自 0001-01-01 起的天数」。
    ///
    /// 用于跨年相减。这里用**逐年累加**而不是公式,因为本工程的年份范围
    /// 很小(演示区间就在 2026 年附近),逐年循环几圈远比一个容易写错的
    /// 复合公式更可靠——「看得懂的慢」优于「看不懂的快」。
    fn absolute_day_number(&self) -> i64 {
        let mut total_days: i64 = 0;
        let mut year: i32 = 1;
        while year < self.year {
            total_days += if CalendarDate::is_leap_year(year) { 366 } else { 365 };
            year += 1;
        }
        total_days + self.day_ordinal() as i64
    }
 
    /// 与另一日期相差的天数(`self - other`)。
    ///
    /// 参数 `other`:被减日期。
    /// 返回:天数差。`self` 晚于 `other` 时为正。
    pub fn days_from(&self, other: &CalendarDate) -> i64 {
        self.absolute_day_number() - other.absolute_day_number()
    }
 
    /// 计算两个日期之间**包含两端**的天数。
    ///
    /// 参数 `other`:另一端日期。
    /// 返回:闭区间天数。例如 9 月 1 日到 9 月 30 日为 30 天。
    ///
    /// ## ⚠️ 为什么必须提供这个函数,而不是让调用方写 `days_from + 1`
    ///
    /// 「区间天数」在业务上是**闭区间**(含首尾两天),而 `days_from` 返回的是差值,
    /// 两者差 1。本工程里这个 1 恰好是排期口径的一部分:
    /// 「承诺 5 个工作日」实际跨了 8 个日历天——若调用方误用差值(7),
    /// 报表上就会出现「客户以为实验室少算了 1 天」的歧义。
    /// 把定义收进一个函数并写明闭区间语义,比让每个调用点各自 `+1` 安全得多。
    pub fn span_days_inclusive(&self, other: &CalendarDate) -> u32 {
        let difference: i64 = self.days_from(other).abs();
        // 差值加一即为闭区间天数;本工程年份有限,差值不可能溢出 u32。
        (difference + 1) as u32
    }
 
    /// 向后推进若干天(**日历天**,不跳周末)。
    ///
    /// 参数 `days`:天数,可为负(表示向前)。
    /// 返回:推进后的日期。
    pub fn add_days(&self, days: i64) -> CalendarDate {
        let target: i64 = self.absolute_day_number() + days;
        CalendarDate::from_absolute_day_number(target)
    }
 
    /// ★ 向后推进若干**工作日**(跳过周六与周日)。
    ///
    /// 参数 `working_days`:工作日数量(必须非负)。
    /// 返回:推进后的日期。
    ///
    /// ## 为什么是「本工程必须自己实现」的函数
    ///
    /// 检测实验室对客户的承诺是**工作日**口径:「钻石分级 5 个工作日出证」。
    /// 若用日历天推进,周二受理的 5 个工作日会被算成周日,
    /// 客户按这个日期来取件会扑空——这是真实业务的差错,不是演示瑕疵。
    ///
    /// ## 「从受理日的次日起算」——这条口径必须写死在这里
    ///
    /// 受理当天算不算一个工作日?行业口径是**不算**:
    /// 客户上午送样,实验室当天来不及开工,所以第 1 个工作日在次日。
    /// 因此本函数**从 `self + 1 天` 开始数**,而不是从 `self` 开始数。
    /// 这个 `+1` 若漏掉,所有排期都会提前一天——而「提前一天」在报表上
    /// 看起来完全正常,属于最难被发现的错误类型。故此处显式写出来并注释。
    ///
    /// ## 返回值同时给出「跳过了哪几天」
    ///
    /// 返回 `(出证日, 期间被跳过的周末日期列表)`。把跳过的日子**打印出来**
    /// 是刻意的:报表读者要能核实「9 月 5 日与 9 月 6 日确实被跳过了」,
    /// 而不是只能相信一个算出来的日期。可核查性优先于简洁。
    pub fn add_working_days(&self, working_days: u16) -> (CalendarDate, Vec<CalendarDate>) {
        // 已计入的工作日数量。
        let mut counted: u16 = 0;
        // 被跳过的周日历日(用于打印证据)。
        let mut skipped_weekends: Vec<CalendarDate> = Vec::new();
        // ★ 从次日起算(受理当天不计入)。
        let mut cursor: CalendarDate = self.add_days(1);
 
        // 上界保护:工作日最多几百,日历天最多是它的 1.5 倍再放宽,
        // 防止 `working_days` 被传入异常值时死循环。
        let safety_limit: u32 = (working_days as u32) * 3 + 32;
        let mut steps: u32 = 0;
 
        while counted < working_days && steps < safety_limit {
            if cursor.is_weekend() {
                // 周末:不计入工作日,但记账「跳过了哪一天」。
                skipped_weekends.push(cursor);
            } else {
                counted += 1;
            }
            cursor = cursor.add_days(1);
            steps += 1;
        }
 
        // 循环结束时 `cursor` 已经指向「最后一个被计入的工作日的次日」,
        // 因此出证日要退一天。这个「退一天」与上面的「进一天」是一对,
        // 二者必须成对维护:只改一个会让排期整体偏移两天。
        let delivery_date: CalendarDate = cursor.add_days(-1);
        (delivery_date, skipped_weekends)
    }
 
    /// 由「自 0001-01-01 起的天数」反推日期。
    ///
    /// 与 `absolute_day_number` 互为逆运算。两者必须成对维护——
    /// 只改一个就会出现「加 30 天再减 30 天回不到原日期」这种隐蔽错误。
    fn from_absolute_day_number(mut day_number: i64) -> CalendarDate {
        let mut year: i32 = 1;
        loop {
            let year_length: i64 = if CalendarDate::is_leap_year(year) { 366 } else { 365 };
            if day_number <= year_length {
                break;
            }
            day_number -= year_length;
            year += 1;
        }
        // 此时 day_number 落在该年内(1 基)。
        let mut month: u32 = 1;
        loop {
            let month_length: i64 = CalendarDate::days_in_month(year, month) as i64;
            if day_number <= month_length {
                break;
            }
            day_number -= month_length;
            month += 1;
        }
        CalendarDate {
            year,
            month,
            day: day_number as u32,
        }
    }
 
    /// 星期序号(0 = 周日,1 = 周一,……,6 = 周六)。
    ///
    /// 使用 **Sakamoto 算法**(查表版):
    /// ```text
    /// y = 年;  若 月 < 3 { y = y - 1 }        // ★ 这一步最容易漏
    /// 星期 = (y + y/4 - y/100 + y/400 + 月偏移[月] + 日) % 7
    /// ```
    /// 其中 `月偏移[月]` 取自 `SAKAMOTO_MONTH_OFFSET`。
    ///
    /// ## ⚠️ 「月 < 3 时年份减一」不可省略
    ///
    /// 这张偏移表是把 1 月与 2 月**当作上一年的 13 月与 14 月**来排的
    /// (这是 Sakamoto 算法的定义方式),因此计算前必须先把年份退回一年。
    /// 漏掉这一步会让**每年 1 月与 2 月的星期整体错一位**,
    /// 而 3..12 月全部正确——正是这种「大部分正确」的缺陷最难被发现。
    ///
    /// 校验方式:拿 `python -c "import datetime"` 的 `weekday()` 跑一批样例比对,
    /// 样例必须**覆盖 1 月与 2 月**,否则这个 bug 根本不会暴露。
    pub fn weekday_index(&self) -> u32 {
        let mut year: i32 = self.year;
        let month: u32 = self.month;
        let day: i32 = self.day as i32;
        // ★ 1 月与 2 月按「上一年的 13/14 月」处理,故年份退回一年。
        if month < 3 {
            year -= 1;
        }
        // 逐项相加:年份 + 闰年数修正 + 世纪修正 + 月份偏移 + 日。
        let sum: i32 = year
            + year / 4
            - year / 100
            + year / 400
            + SAKAMOTO_MONTH_OFFSET[month as usize]
            + day;
        // 取模 7。Rust 的 `%` 对负数会返回负值,故先加 7 再取模保险。
        ((sum % 7) + 7) as u32 % 7
    }
 
    /// 星期文本(单字,如 `"三"`)。
    pub fn weekday_text(&self) -> &'static str {
        WEEKDAY_TEXT[self.weekday_index() as usize]
    }
 
    /// 是否周末(周六或周日)。
    pub fn is_weekend(&self) -> bool {
        let weekday: u32 = self.weekday_index();
        weekday == 0 || weekday == 6
    }
 
    /// 返回 `2026-09-01` 形式。
    pub fn formatted(&self) -> String {
        format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
    }
 
    /// 返回 `2026 年 9 月 1 日` 形式。
    pub fn chinese_text(&self) -> String {
        format!("{} 年 {} 月 {} 日", self.year, self.month, self.day)
    }
 
    /// 返回 `9 月 1 日` 形式(省略年份,用于表格内节省列宽)。
    pub fn month_day_text(&self) -> String {
        format!("{} 月 {} 日", self.month, self.day)
    }
}
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : deterministic_code.rs
//! # 确定性编码 —— 由输入内容派生稳定标识
//!
//! ## 为什么需要「确定性」编码
//!
//! 本工程有三处需要从内容派生标识:
//! 1. **委托单流水号**:每张委托单要有一个可被报表引用的编号;
//! 2. **样品编号**:同一件样品在不同检测类型下要能指回同一编号;
//! 3. **演示样品参数**:样品的重量(毫克)、件数、克拉数等要有一个
//!    「稳定的事实」,不因生成顺序变化而变。
//!
//! 若用随机数或自增计数器:
//! - 随机数:每次运行数值都变,两次运行的报表无法对比;
//! - 自增:值取决于遍历顺序,一旦生成顺序调整(比如多受理一张委托单),
//!   **所有委托单的编号全变**,历史记录失去可追溯性。
//!
//! 本模块用 **FNV-1a 64 位哈希**从输入内容派生数值:同样的输入永远得到同样的输出,
//! 且与生成顺序无关。这个性质在「演示数据」上的收益尤其明显——
//! 「委托单 A 的样品重量」是一个**稳定的事实**,
//! 不管它第几个被算出来,值都一样。
//!
//! ## 为什么选 FNV-1a 而不是标准库的 `DefaultHasher`
//!
//! `std::collections::hash_map::DefaultHasher` 的输出**在 Rust 版本之间不保证稳定**
//! (官方明确说明「不应作为持久化格式」)。本工程要在报表里打印编码、
//! 并跨运行对比,因此必须用一个**自己写死、永不改变**的算法。
//! FNV-1a 只有三行,且常量是公开标准,最合适。
//!
//! ⚠️ 本模块**不是密码学哈希**,不要用于安全场景(如签名、口令)。
//! 它只保证「同输入同输出」与「广泛分布」,不保证抗碰撞攻击。
 
/// FNV-1a 哈希的 64 位偏移基准量。
///
/// 这个常量来自 FNV 规范(offset basis),改动它会使所有既有编码失效。
const FNV_OFFSET_BASIS_64: u64 = 0xcbf2_9ce4_8422_2325;
 
/// FNV-1a 哈希的 64 位质数。
///
/// 同样来自 FNV 规范(prime),不可改动。
const FNV_PRIME_64: u64 = 0x0000_0100_0000_01b3;
 
/// 对一个字节切片做 FNV-1a 哈希。
///
/// 参数 `bytes`:待哈希的字节。
/// 返回:64 位哈希值。
///
/// 算法(1a 变体,与 1 的区别在于「先异或再乘」):
/// 1. 从偏移基准量开始;
/// 2. 对每个字节:先与哈希值异或,再乘以质数;
/// 3. 全程使用 64 位**环绕**乘法(`wrapping_mul`),溢出即丢弃高位。
///
/// `wrapping_mul` 在这里不是「图省事」,而是算法定义的一部分——
/// 用普通乘法会在调试构建下 panic、释放构建下数值不同,
/// 造成「debug 与 release 输出不一致」这种最难查的问题。
pub const fn fnv1a_64(bytes: &[u8]) -> u64 {
    let mut hash_value: u64 = FNV_OFFSET_BASIS_64;
    let mut index: usize = 0;
    while index < bytes.len() {
        // 第一步:异或当前字节(先异或是 1a 与 1 的唯一区别)。
        hash_value ^= bytes[index] as u64;
        // 第二步:乘以质数,允许溢出环绕。
        hash_value = hash_value.wrapping_mul(FNV_PRIME_64);
        index += 1;
    }
    hash_value
}
 
/// 把多个字符串拼成一个「种子」再做哈希。
///
/// 参数 `segments`:按顺序参与的字符串片段。
/// 返回:哈希值。
///
/// ## 分隔符不可省略
///
/// 若直接把 `["AB", "C"]` 与 `["A", "BC"]` 拼接成 `"ABC"`,
/// 两者哈希相同——这是**真实存在的碰撞**,不是理论担忧:
/// 本工程的样品参数种子正是「检测类型编码 + 样品编号 + 项目编码」这种多段拼接,
/// 编码末尾与编号开头很容易黏连出歧义。
///
/// 因此本函数在每段之间插入 `\x1f`(ASCII Unit Separator)。
/// 这个字符不会出现在本工程的任何编码、日期或名称里
/// (编码是 `A-Z0-9_`,日期是数字与短横),因此能可靠分界。
pub fn build_seed(segments: &[&str]) -> u64 {
    // 分隔符:ASCII Unit Separator,业务数据中不会出现。
    const SEPARATOR: u8 = 0x1f;
    let mut hash_value: u64 = FNV_OFFSET_BASIS_64;
    for (position, segment) in segments.iter().enumerate() {
        // 除第一段外,每段前先吃一个分隔符,保证「段边界」参与哈希。
        if position > 0 {
            hash_value ^= SEPARATOR as u64;
            hash_value = hash_value.wrapping_mul(FNV_PRIME_64);
        }
        // 再把该段的每个字节并入。
        for byte in segment.as_bytes() {
            hash_value ^= *byte as u64;
            hash_value = hash_value.wrapping_mul(FNV_PRIME_64);
        }
    }
    hash_value
}
 
/// 由哈希值派生一个带前缀的 12 位大写十六进制编码。
///
/// 参数 `prefix`:编码前缀(如 `"CONS"`);`hash_value`:哈希值。
/// 返回:形如 `CONS-1A2B3C4D5E6F` 的字符串。
///
/// 取哈希低 48 位(12 个十六进制位):
/// - 48 位在演示规模(数十张委托单)下碰撞概率极低;
/// - 12 位定长便于表格对齐(每行编号显示宽度完全相同)。
///
/// 为何不取全部 64 位?16 位十六进制会让报表一行放不下,
/// 而多出的 16 位对本工程的规模没有实际价值。这是**按用途裁剪**,
/// 不是「随便取几位」——注释里写清理由,后来者才好判断能否改动。
pub fn derive_code(prefix: &str, hash_value: u64) -> String {
    // 取低 48 位:用掩码清掉高位。
    let truncated_value: u64 = hash_value & 0x0000_FFFF_FFFF_FFFF;
    format!("{}-{:012X}", prefix, truncated_value)
}
 
/// 由哈希值派生一个 8 位大写十六进制指纹。
///
/// 参数 `hash_value`:哈希值。
/// 返回:8 位十六进制文本。
///
/// 用于报表表头(「本批委托指纹 A1B2C3D4」):读者可以凭这 8 位
/// 确认「两次运行看到的是同一批委托单」,而不必逐行比对几十条记录。
pub fn short_fingerprint(hash_value: u64) -> String {
    // 取高 32 位(而非低位)——低位已被 derive_code 用于编号,
    // 指纹取高位可让「编号相近的两条记录」在指纹上也有明显差异。
    let upper_bits: u32 = (hash_value >> 32) as u32;
    format!("{:08X}", upper_bits)
}
 
/// 把哈希值映射到给定闭区间内的一个整数。
///
/// 参数 `hash_value`:哈希值;`minimum` / `maximum`:闭区间端点(含)。
/// 返回:落在 `[minimum, maximum]` 内的值。
///
/// ## 为什么不用 `hash % (max - min + 1) + min`
///
/// 那个写法在 `maximum < minimum` 时会产生负数或 panic,
/// 且当区间宽度不是 2 的幂时低位分布不均。本函数用取模前先取高位、
/// 并对退化区间显式兜底,避免调用方各自处理边界。
///
/// ⚠️ 这不是「随机数生成器」——同样的 `hash_value` 永远得到同样的结果。
/// 本工程用它从「检测类型 + 样品编号 + 项目编码」的哈希派生**稳定**的样品参数。
pub fn map_hash_to_range(hash_value: u64, minimum: i64, maximum: i64) -> i64 {
    if maximum <= minimum {
        // 退化区间:直接返回下界,不 panic。
        return minimum;
    }
    let span: i64 = maximum - minimum + 1;
    // 取哈希的高 48 位(低位已被编号占用,且高位的分布更均匀)。
    let high_bits: u64 = hash_value >> 16;
    let offset: i64 = (high_bits % span as u64) as i64;
    minimum + offset
}
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : text_layout.rs
//! # CJK 宽度感知的文本排版
//!
//! ## 为什么这一层不可省略
//!
//! 本工程的报表要打印检测类型名(「贵金属纯度检测」)、样品类别(「素金」)、
//! 证书类型(「分级证书」)、检测项目(「净度」)——全是中文。
//! 而 Rust 的格式化填充 `{:<20}` 按**字符数**(`char` 个数)补空格,
//! 不是按**显示列数**。一个汉字在等宽终端里占 2 列,于是:
//!
//! ```text
//! {:<12} 的效果(错位)              display_width 的效果(对齐)
//! 检测类型      样品类别             检测类型      样品类别
//! 贵金属纯度检测 素金                贵金属纯度检测 素金
//! ^ 前者只补到 12 个「字符」          ^ 前者补到 12 个「列」
//! ```
//!
//! 全工程禁止直接用 `{:<N}` 打印含中文的表格单元,一律走本模块。
//!
//! ## 区段表必须互不重叠
//!
//! 下面 [`is_wide_character`] 里的区间若出现重叠或交叠,
//! `matches!` 会报「不可达模式」(unreachable pattern)告警,
//! 或者更糟——悄悄漏判某一区段。写这类表时要一格一格核对边界。
 
/// 判断一个字符在等宽终端中是否占 2 个显示列。
///
/// 参数 `character`:待判定的字符。
/// 返回:占 2 列返回 `true`,占 1 列返回 `false`。
///
/// 这里采用**主区域判定**而非穷举全表:全量 Unicode 的宽字符表有上千个区段,
/// 但本工程的字符来源受控(预置常量 + 演示数据,都在 CJK 与拉丁范围内),
/// 因此覆盖以下区段即足够,且每一段都可人工核对:
///
/// | 区段 | 含义 | 本工程的实际来源 |
/// |---|---|---|
/// | `U+1100..=U+115F` | 谚文字母 | 未使用(预留) |
/// | `U+2E80..=U+303E` | CJK 部首补充、符号 | 「·」等 |
/// | `U+3041..=U+33FF` | 平假名、片假名、CJK 注音 | 未使用(预留) |
/// | `U+3400..=U+4DBF` | CJK 扩展 A | 生僻字(预留) |
/// | `U+4E00..=U+9FFF` | CJK 基本区 | 检测类型名、样品类别、证书类型 |
/// | `U+A000..=U+A4CF` | 彝文 | 未使用(预留) |
/// | `U+AC00..=U+D7A3` | 谚文音节 | 未使用(预留) |
/// | `U+F900..=U+FAFF` | CJK 兼容表意文字 | 未使用(预留) |
/// | `U+FE30..=U+FE4F` | CJK 兼容形式 | 「:」全角标点(预留) |
/// | `U+FF00..=U+FF60` | 全角 ASCII | 「()」「%」全角形式 |
/// | `U+FFE0..=U+FFE6` | 全角符号 | 「¢」「£」「¥」 |
///
/// 注意 `U+FF61..=U+FFDC`(半角片假名)**不被**判为宽字符——它们确实是 1 列。
pub fn is_wide_character(character: char) -> bool {
    // 用 u32 比较,避免在每个分支里重复做 `as u32` 转换。
    let code_point: u32 = character as u32;
    matches!(
        code_point,
        0x1100..=0x115F
            | 0x2E80..=0x303E
            | 0x3041..=0x33FF
            | 0x3400..=0x4DBF
            | 0x4E00..=0x9FFF
            | 0xA000..=0xA4CF
            | 0xAC00..=0xD7A3
            | 0xF900..=0xFAFF
            | 0xFE30..=0xFE4F
            | 0xFF00..=0xFF60
            | 0xFFE0..=0xFFE6
    )
}
 
/// 计算字符串在等宽终端中的显示列数。
///
/// 参数 `text`:待测量的字符串。
/// 返回:显示列数(汉字计 2,其余计 1)。
///
/// 这是本模块所有填充函数的基础。**不要**用 `text.len()`(那是 UTF-8 字节数,
/// 一个汉字 3 字节)或 `text.chars().count()`(那是字符数,一个汉字 1 个)。
pub fn display_width(text: &str) -> usize {
    // 逐个字符累加宽度:宽字符 2 列,窄字符 1 列。
    text.chars()
        .map(|character| if is_wide_character(character) { 2 } else { 1 })
        .sum()
}
 
/// 在右侧补空格,使结果达到指定**显示列数**。
///
/// 参数 `text`:原文本;`target_width`:目标显示列数。
/// 返回:补齐后的字符串。
///
/// **超出时不截断**:若 `text` 的显示宽度已超过 `target_width`,原样返回。
/// 这一点是刻意的——截断会丢信息,而「列宽估小了」是调用方的 bug,
/// 应该在输出里暴露出来(表格错位一眼可见),而不是被静默吃掉。
pub fn pad_right(text: &str, target_width: usize) -> String {
    let current_width: usize = display_width(text);
    if current_width >= target_width {
        return text.to_string();
    }
    // 差额就是需要补的空格数(空格永远是 1 列宽)。
    let padding_count: usize = target_width - current_width;
    format!("{}{}", text, " ".repeat(padding_count))
}
 
/// 在左侧补空格,使结果达到指定**显示列数**。
///
/// 参数 `text`:原文本;`target_width`:目标显示列数。
/// 返回:补齐后的字符串。
///
/// 用于数字列的右对齐——金额(`¥1,234.50`)、工作日天数、样品件数都靠它对齐。
pub fn pad_left(text: &str, target_width: usize) -> String {
    let current_width: usize = display_width(text);
    if current_width >= target_width {
        return text.to_string();
    }
    let padding_count: usize = target_width - current_width;
    format!("{}{}", " ".repeat(padding_count), text)
}
 
/// 居中对齐到指定显示列数。
///
/// 参数 `text`:原文本;`target_width`:目标显示列数。
/// 返回:两侧补空格后的字符串。
///
/// 差值分配规则:**左少右多**(左侧补 `floor(差值/2)`,右侧补剩余)。
/// 为什么不左右均分?因为当差值为奇数时无法均分,必须选一边多补一格。
/// 选「右多」是因为中文标题通常希望视觉重心略偏左,
/// 左边少一格会让标题看起来更靠中。这个选择要写下来,
/// 否则下一个人会以为是算错了。
pub fn pad_center(text: &str, target_width: usize) -> String {
    let current_width: usize = display_width(text);
    if current_width >= target_width {
        return text.to_string();
    }
    let total_padding: usize = target_width - current_width;
    let left_padding: usize = total_padding / 2;
    let right_padding: usize = total_padding - left_padding;
    format!(
        "{}{}{}",
        " ".repeat(left_padding),
        text,
        " ".repeat(right_padding)
    )
}
 
/// 按显示列数截断字符串。
///
/// 参数 `text`:原文本;`maximum_width`:允许的最大显示列数。
/// 返回:在不超过 `maximum_width` 的前提下能容纳的最长前缀。
///
/// ## 为什么必须自己写这个函数
///
/// Rust 的 `&text[..n]` 按**字节**切片,切在汉字中间会 panic
/// (`byte index is not a char boundary`)。本函数按字符逐个累加宽度,
/// 天然保证切点落在字符边界上。
///
/// ## 放弃半个汉字
///
/// 若下一个字符是宽字符(2 列)而剩余宽度只有 1 列,就直接停下,
/// **不**用空格或半个字符填充。表格单元宁可短一列,
/// 也不能出现「只有左半边」的乱码。
pub fn truncate_to_width(text: &str, maximum_width: usize) -> String {
    let mut accumulated_width: usize = 0;
    // 收集能放下的字符;用 String 而非 &str 切片,天然按字符边界推进。
    let mut result: String = String::new();
    for character in text.chars() {
        let character_width: usize = if is_wide_character(character) { 2 } else { 1 };
        // 加上这个字符会超宽 → 停止(放弃这个字符,包括「放不下的宽字符」)。
        if accumulated_width + character_width > maximum_width {
            break;
        }
        result.push(character);
        accumulated_width += character_width;
    }
    result
}
 
/// 按显示列数把长文本折成多行(折行,不截断)。
///
/// 参数 `text`:原文本;`first_line_width`:首行可用宽度;
/// `continuation_width`:续行可用宽度;`continuation_indent`:续行前缀。
/// 返回:折行后的各行。
///
/// ## 为什么需要「首行宽度 ≠ 续行宽度」
///
/// 报表里常见这种排版:
///
/// ```text
/// · 说明标题 这里是很长很长的正文,需要折到第二行继续写……
///             续行与正文起点对齐,而不是与「·」对齐。
/// ```
///
/// 首行前面已经占了「· 」两个列宽,所以首行可用宽度比续行**少 2 列**。
/// 若两者用同一个宽度,首行会溢出,或者续行会留出一段奇怪的空白。
/// 调用方显式传入两个宽度,这个差异就是可见的、可控的。
///
/// ## 折行点只落在字符边界
///
/// 与 [`truncate_to_width`] 同源:按字符累加宽度,绝不按字节切。
/// 中英混排时不额外做「按词折行」——本工程的说明文字以中文为主,
/// 按字符折行才是中文排版的正解(中文没有词间空格可依靠)。
pub fn wrap_text(
    text: &str,
    first_line_width: usize,
    continuation_width: usize,
    continuation_indent: &str,
) -> Vec<String> {
    let mut lines: Vec<String> = Vec::new();
    let mut current_line: String = String::new();
    let continuation_width: usize = continuation_width;
    let mut current_limit: usize = first_line_width;
 
    for character in text.chars() {
        // 显式换行符:结束当前行,下一行按续行宽度治理。
        if character == '\n' {
            lines.push(current_line);
            current_line = String::new();
            current_limit = continuation_width;
            continue;
        }
        let character_width: usize = if is_wide_character(character) { 2 } else { 1 };
        if display_width(&current_line) + character_width > current_limit {
            // 放不下 → 收束当前行,开新行;新行带续行前缀并切换到续行宽度。
            lines.push(current_line);
            current_line = format!("{}{}", continuation_indent, character);
            current_limit = continuation_width;
        } else {
            current_line.push(character);
        }
    }
    // 收尾:最后一行若为空且已有内容,就不再补一个空行。
    if !current_line.is_empty() || lines.is_empty() {
        lines.push(current_line);
    }
    lines
}
 
/// 生成一条横线,用于表格分隔。
///
/// 参数 `column_width`:横线的显示列数。
/// 返回:由制表符 `─` 拼成的字符串,**其 [`display_width`] 恰等于 `column_width`**。
///
/// ## ⚠️ 这条横线曾经只有一半长——原因是一个凭印象写下的码点区间
///
/// 本函数在另一个工程里的初版注释是这么写的:
///
/// > 用 `─`(U+2500,属于 `U+2E80..=U+303E` 区段,占 2 列)拼接……
///
/// 这句话是**错的**,而且错在最容易被跳过的那一步:U+2500 是十进制 9472,
/// 而 `0x2E80` 是 11904 —— **9472 比 11904 小**,根本不在那个区段里。
/// U+2500 的东亚宽度属性是 `A`(Ambiguous),本工程的 [`is_wide_character`]
/// 按 1 列处理(这也是终端里的常见渲染结果)。
///
/// 于是代码里的 `column_width / 2` 与本工程自己的宽度模型直接冲突:
/// `display_width("─")` 返回 1,而 `horizontal_rule(2)` 只产出 1 个字符。
/// 结果是**每一张表、每一个分节标题下的分隔线都只有标题行的一半长**。
///
/// 这个缺陷为什么难被发现:
///
/// 1. 它**不报错**——没有断言、没有 panic,没有任何东西会因此失败;
/// 2. 它**不影响对齐**——分隔线与数据行本来就不同字符集,短一截看起来
///    只是「这条线有点短」,不像错位那样扎眼;
/// 3. 它**处处都在**,反而让人以为是设计如此。
///
/// 处置办法不是「把这条注释改对」就完事,而是给宽度模型加一条**自检**:
/// 见 `main.rs` 自查区里对 `display_width(horizontal_rule(n)) == n` 的核对。
/// 有公认表格/区间的算法与码点判断,宁可让机器算一遍,也不要凭印象写区间边界。
pub fn horizontal_rule(column_width: usize) -> String {
    // 一条 `─` 占 1 列(与 `is_wide_character` 的口径一致),因此重复 `column_width` 次。
    "─".repeat(column_width)
}
 

  

 

//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : capacity_profile.rs
//! # 排期与产能画像 —— 实验室看到的那一面
//!
//! ## 为什么要单独有一个「产能」视角
//!
//! 本工程已有账本(记「造了几张」)与成本(记「收了多少钱」)。
//! 但实验室排产要回答的是第三个问题:**这批送检单要占用多少工时、什么时候能交**。
//!
//! 三个视角的口径**互不替代**,这一点在既有工程里已经被验证过:
//! 金额是收费口径,产能是占用口径。一张加急的钻石分级可能收费很高,
//! 但它占用的工时反而比一件需要取样的宝石鉴定少
//! (取样是不可逆操作,工时里有不可压缩的部分)。
//! 用金额替代工作量会让排产错得离谱,而且错得没有报警。
//!
//! ## 本文件与 `cost_profile` 的分工
//!
//! 本文件在构造时就把**逐张金额丢掉**了(只留下件数与工作量)。
//! 这是刻意的:聚合之后看不到金额,就不可能写出「拿不到数据却打印通过」
//! 的假检查。金额类的一致性核对
//! (加急费与加急标记是否一致、损耗费与破坏性是否一致、
//! 计费克拉与实际克拉的关系)全部放在
//! [`crate::analysis::cost_profile`] 里执行——那里持有完整的金额字段。
//!
//! **一个拿不到数据却仍然打印「通过」的检查是最坏的一种**:
//! 它让读者以为那里被验证过了。本工程对校验代码的纪律是
//! 「它必须真的抓到过东西」,因此宁可把检查放到能跑的地方去。
 
use crate::analysis::{CheckLine, CheckReport};
use crate::client::WorkbenchRun;
 
/// 把「分子 / 分母」渲染成保留一位小数的天数文本,**远离零方向四舍五入**。
///
/// 参数 `numerator`:分子(如 Σ承诺工作日);`denominator`:分母(如 Σ张数)。
/// 返回:如 `2.9 天`;分母为 0 时返回 `—`。
///
/// ## 为什么要把它抽成一个自由函数
///
/// 本工程有两处需要「平均天数」:分组内部([`CapacityBucket`] 自己)
/// 与分组表的合计格([`CapacityProfile::weighted_average_promised_days_text`])。
/// 两处若各写一遍「乘 10 → 整除 → 拼一位小数」,
/// 将来修取整口径时就会只改一处——而**两处口径不一致是报表最难发现的缺陷**:
/// 分组合计与总计都能自圆其说,只是彼此对不上。
///
/// ## 为什么用 `u64` 中间量
///
/// `numerator * 10` 在 `u32` 上溢出会 wrap(release)或 panic(debug)。
/// 天数与张数都不会大到需要 `u64` 的精度,但用宽类型做中间量是**零成本的防呆**:
/// 它与本工程「金额乘除用 `i128` 中间量」是同一条纪律。
fn average_tenths_text(numerator: u32, denominator: u32) -> String {
    if denominator == 0 {
        return "—".to_string();
    }
    // 十分位口径上的四舍五入:先放大 10 倍,除完加 0.5 个刻度(即 5)再取整。
    // 全程整数运算,不经过浮点——报表上每一个数字都必须能被子算器复算。
    let scaled_numerator: u64 = u64::from(numerator) * 10;
    let scaled: u64 = (scaled_numerator + u64::from(denominator) / 2) / u64::from(denominator);
    format!("{}.{} 天", scaled / 10, scaled % 10)
}
 
/// 一种检测类型的产能分组。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapacityBucket {
    /// 检测类型编码。
    order_code: String,
    /// 检测类型中文名。
    order_chinese_name: String,
    /// 承接部门。
    department: String,
    /// 张数。
    order_count: usize,
    /// 件数合计。
    piece_total: u32,
    /// 工作量单元合计。
    workload_total: u32,
    /// 承诺工作日合计(用于算平均)。
    promised_days_total: u32,
}
 
impl CapacityBucket {
    /// 检测类型编码。
    pub fn order_code(&self) -> &str {
        &self.order_code
    }
 
    /// 检测类型中文名。
    pub fn order_chinese_name(&self) -> &str {
        &self.order_chinese_name
    }
 
    /// 承接部门。
    pub fn department(&self) -> &str {
        &self.department
    }
 
    /// 张数。
    pub fn order_count(&self) -> usize {
        self.order_count
    }
 
    /// 件数合计。
    pub fn piece_total(&self) -> u32 {
        self.piece_total
    }
 
    /// 工作量单元合计。
    pub fn workload_total(&self) -> u32 {
        self.workload_total
    }
 
    /// 承诺工作日合计。
    ///
    /// 单独暴露分子(而不是只给一个平均数)是为了让**加权平均可以复算**:
    /// `Σ承诺工作日 ÷ Σ张数`。若只给平均数,读者无从核对它是不是加权过的——
    /// 而未加权平均在张数不等时是错的。
    pub fn promised_days_total(&self) -> u32 {
        self.promised_days_total
    }
 
    /// 平均承诺工作日。
    ///
    /// ## 为什么用「合计 ÷ 张数」而不是「各自算完再平均」
    ///
    /// 两者在算术上等价,但前者只需要维护「合计」与「张数」两个可独立核对的数:
    /// 报表上这两列印出来之后,读者按计算器一除就能验证平均值。
    /// 若改成「各自算完再平均」,报表上就必须再印一列「逐张承诺天数」,
    /// 否则读者无从复算。
    ///
    /// ## ★ 取整必须「四舍五入」而不是「截断」
    ///
    /// 初版这里是整数除法截断(`total * 10 / count`),于是
    /// 贵金属室 `(1+2+2)×10 / 3 = 16` 印成 `1.6 天`,而真值是 `1.67 天`——
    /// **读者按计算器得到 1.7,报表印的是 1.6,两者对不上**。
    ///
    /// 截断的坏处在报表上比在别处更严重:它**总是朝同一个方向偏**
    /// (永远偏小),于是「差一点点」会在每一行上累加,且永远不会自己暴露。
    /// 本工程在金额上早就定了「远离零方向四舍五入」这条纪律
    /// (见 `Money::scale_by_ratio`),这里是它在天数上的同一口径。
    ///
    /// 除数为 0 时返回 `—` 而不是 panic:分组可能为空,而报表崩掉比
    /// 出现一个 `—` 更难排查。这与 `CalendarDate::from_ymd`
    /// 对非法输入回退而不 panic 是同一口径。
    pub fn average_promised_days_text(&self) -> String {
        average_tenths_text(self.promised_days_total, self.order_count as u32)
    }
}
 
/// 一张委托单的排期明细。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduleLine {
    /// 样品编号。
    sample_code: String,
    /// 检测类型编码。
    order_code: String,
    /// 受理日文本。
    accepted_on_text: String,
    /// 出证日文本。
    delivery_on_text: String,
    /// 承诺工作日。
    promised_working_days: u16,
    /// 日历天跨度(含首尾)。
    calendar_span_days: u32,
    /// 被跳过的周末文本。
    skipped_days_text: String,
}
 
impl ScheduleLine {
    /// 样品编号。
    pub fn sample_code(&self) -> &str {
        &self.sample_code
    }
 
    /// 检测类型编码。
    pub fn order_code(&self) -> &str {
        &self.order_code
    }
 
    // ⚠️ 这里原本还有一个 `accepted_on_text()`。删掉的理由是一条**归属**判断:
    //    受理日是全批次共用的(`CapacityProfile::accepted_on_text`),
    //    把它也在明细行上开一个出口,会让读者以为「每一行可能有不同的受理日」。
    //    一个 DTO 上的访问器不是越多越好——**放错层级的字段会制造不存在的问题**。
    /// 出证日文本。
    pub fn delivery_on_text(&self) -> &str {
        &self.delivery_on_text
    }
 
    /// 承诺工作日。
    pub fn promised_working_days(&self) -> u16 {
        self.promised_working_days
    }
 
    /// 日历天跨度(含首尾)。
    pub fn calendar_span_days(&self) -> u32 {
        self.calendar_span_days
    }
 
    /// 被跳过的周末文本。
    pub fn skipped_days_text(&self) -> &str {
        &self.skipped_days_text
    }
}
 
/// 一批委托单的产能画像。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapacityProfile {
    /// 批次名。
    batch_name: String,
    /// 受理日文本(全批次共用)。
    accepted_on_text: String,
    /// 按检测类型的分组(按工作量降序,同值按编码升序)。
    by_order_code: Vec<CapacityBucket>,
    /// 按部门的分组(同样规则排序)。
    by_department: Vec<CapacityBucket>,
    /// 排期明细(按出证日、然后样品编号排序)。
    schedule_lines: Vec<ScheduleLine>,
    /// 需要客户书面确认的破坏性检测样品编号。
    destructive_sample_codes: Vec<String>,
    /// 加急委托单的样品编号。
    urgent_sample_codes: Vec<String>,
    /// 工作量总计。
    total_workload: u32,
    /// 件数总计。
    total_pieces: u32,
    /// 张数总计。
    total_orders: usize,
}
 
impl CapacityProfile {
    /// 批次名。
    pub fn batch_name(&self) -> &str {
        &self.batch_name
    }
 
    /// 受理日文本。
    pub fn accepted_on_text(&self) -> &str {
        &self.accepted_on_text
    }
 
    /// 按检测类型的分组。
    pub fn by_order_code(&self) -> &[CapacityBucket] {
        &self.by_order_code
    }
 
    /// 按部门的分组。
    pub fn by_department(&self) -> &[CapacityBucket] {
        &self.by_department
    }
 
    /// 排期明细。
    pub fn schedule_lines(&self) -> &[ScheduleLine] {
        &self.schedule_lines
    }
 
    /// 破坏性检测的样品编号。
    pub fn destructive_sample_codes(&self) -> &[String] {
        &self.destructive_sample_codes
    }
 
    /// 加急委托单的样品编号。
    pub fn urgent_sample_codes(&self) -> &[String] {
        &self.urgent_sample_codes
    }
 
    /// 工作量总计。
    pub fn total_workload(&self) -> u32 {
        self.total_workload
    }
 
    /// 件数总计。
    pub fn total_pieces(&self) -> u32 {
        self.total_pieces
    }
 
    /// 张数总计。
    pub fn total_orders(&self) -> usize {
        self.total_orders
    }
 
    /// 全批的**加权平均**承诺工作日文本(分组表的合计格用)。
    ///
    /// ## 为什么这个计算住在 analysis 层,而不是报表层
    ///
    /// 本工程对 `app` 层的定位是「只负责怎么摆,不负责是什么」,
    /// 它的模块文档明确写着「没有计算(除了把整数转成百分比文本这种渲染动作)」。
    /// 而「Σ承诺工作日 ÷ Σ张数」是一次**真实的计算**——
    /// 把它写在报表里,`app` 层那条自我约束当场失效,
    /// 而且它的取整口径会与分组内那一处([`CapacityBucket::average_promised_days_text`])
    /// 各写一遍、各自演化。
    ///
    /// 挪到 analysis 层之后,两处共用同一个私有函数 [`average_tenths_text`],
    /// **口径只有一处**。报表层要做的只是把它印出来。
    ///
    /// ## 为什么用「按类型分组」而不是「按部门分组」来求和
    ///
    /// 两条分组路径的总计必须相同(本工程有一条体检专门核对这件事),
    /// 因此取哪一条都行。这里取 `by_order_code`,理由是它是**报表上先出现的那一张**,
    /// 读者按计算器核对时先看到的就是它。
    pub fn weighted_average_promised_days_text(&self) -> String {
        let total_days: u32 = self
            .by_order_code
            .iter()
            .map(|bucket| bucket.promised_days_total())
            .sum();
        average_tenths_text(total_days, self.total_orders as u32)
    }
 
    /// 最晚出证日文本(空批次时给 `—`)。
    ///
    /// ## 为什么取「排期明细的最后一条」而不是重新遍历一遍求最大
    ///
    /// 因为排期明细**已经按出证日排好序**(见 [`build_capacity_profile`])。
    /// 已经排好序的集合,取极值就该取两端——重新遍历一遍不仅多写代码,
    /// 还多出一个可能与排序口径不一致的地方(比如一处按文本排、
    /// 一处按日期比)。**同一个口径只写一次,是本工程反复强调的纪律。**
    pub fn latest_delivery_on_text(&self) -> String {
        self.schedule_lines
            .last()
            .map(|line| line.delivery_on_text.clone())
            .unwrap_or_else(|| "—".to_string())
    }
 
    /// 转成体检报告。
    ///
    /// ## 这里只查「产能自己能证明的事」
    ///
    /// 本函数刻意**不**去查费用类的一致性(加急费、损耗费、计费克拉)。
    /// 理由不是偷懒,而是那些检查需要每张单的费用字段,
    /// 而 [`CapacityProfile`] 经过聚合之后**已经看不到逐张的金额了**。
    ///
    /// 一个「拿不到数据却仍然打印『通过』」的检查是最坏的一种:
    /// 它让读者以为那里被验证过了。本工程对校验代码的纪律是
    /// **它必须真的抓到过东西**——所以那些检查放在持有金额的
    /// [`crate::analysis::cost_profile::fee_consistency_report`] 里,
    /// 由 `main.rs` 的体检幕一并调用。
    ///
    /// 本函数只保留三件事:分组求和与总计对得上、明细条数与张数对得上、
    /// 以及两张需要人工跟进的清单(破坏性 / 加急)的条数可核对。
    pub fn to_check_report(&self) -> CheckReport {
        let mut report: CheckReport = CheckReport::new("排期与产能核对");
 
        // ---- 分组求和 = 总计(两条独立累加路径必须对上)----
        let workload_from_buckets: u32 = self.by_order_code.iter().map(|b| b.workload_total).sum();
        let workload_from_departments: u32 =
            self.by_department.iter().map(|b| b.workload_total).sum();
        let pieces_from_buckets: u32 = self.by_order_code.iter().map(|b| b.piece_total).sum();
        let orders_from_buckets: usize = self.by_order_code.iter().map(|b| b.order_count).sum();
        let orders_from_departments: usize = self.by_department.iter().map(|b| b.order_count).sum();
 
        report = report.with_line(CheckLine::new(
            "按类型分组工作量 = 总计",
            workload_from_buckets == self.total_workload,
            &format!(
                "分组合计 {},总计 {}",
                workload_from_buckets, self.total_workload
            ),
        ));
        report = report.with_line(CheckLine::new(
            "按部门分组工作量 = 总计",
            workload_from_departments == self.total_workload,
            &format!(
                "分组合计 {},总计 {}",
                workload_from_departments, self.total_workload
            ),
        ));
        report = report.with_line(CheckLine::new(
            "按类型分组件数 = 总计",
            pieces_from_buckets == self.total_pieces,
            &format!(
                "分组合计 {},总计 {}",
                pieces_from_buckets, self.total_pieces
            ),
        ));
        report = report.with_line(CheckLine::new(
            "按类型分组张数 = 总计",
            orders_from_buckets == self.total_orders,
            &format!("分组合计 {},总计 {}", orders_from_buckets, self.total_orders),
        ));
        report = report.with_line(CheckLine::new(
            "按部门分组张数 = 总计",
            orders_from_departments == self.total_orders,
            &format!(
                "分组合计 {},总计 {}",
                orders_from_departments, self.total_orders
            ),
        ));
 
        // ---- 排期明细的条数必须等于张数 ----
        // 这条不能省:排期明细是**另一份**从快照生成的数据,
        // 「分组数对得上」不蕴含「明细条数对得上」。
        report = report.with_line(CheckLine::new(
            "排期明细条数 = 张数",
            self.schedule_lines.len() == self.total_orders,
            &format!(
                "明细 {} 条,张数 {}",
                self.schedule_lines.len(),
                self.total_orders
            ),
        ));
 
        // ---- 排期单调性:出证日不得早于受理日 ----
        // 用字符串比较:日期是 `YYYY-MM-DD` 定宽文本,字典序与时间序一致。
        let early_deliveries: Vec<String> = self
            .schedule_lines
            .iter()
            .filter(|line| line.delivery_on_text < self.accepted_on_text)
            .map(|line| line.sample_code.clone())
            .collect();
        report = report.with_line(CheckLine::new(
            "每张单:出证日不早于受理日",
            early_deliveries.is_empty(),
            &format!(
                "全部 {} 张;早于受理日 {} 张{}",
                self.schedule_lines.len(),
                early_deliveries.len(),
                short_suffix(&early_deliveries)
            ),
        ));
 
        // ---- 两张需要人工跟进的清单:条数与总张数的关系必须可解释 ----
        // 这两条 `passed` 恒为 `true`,因为「有多少张加急」本身不是缺陷。
        // 但**不能因此不打印**:这两张清单是要人工去处理的
        // (破坏性检测需客户书面确认、加急要插队排产),
        // 报表上必须让人看见它们有多少条。
        report = report.with_line(CheckLine::new(
            "待人工跟进:破坏性检测(需客户书面确认)",
            true,
            &format!(
                "{} 张 / 共 {} 张",
                self.destructive_sample_codes.len(),
                self.total_orders
            ),
        ));
        report = report.with_line(CheckLine::new(
            "待人工跟进:加急插队排产",
            true,
            &format!(
                "{} 张 / 共 {} 张",
                self.urgent_sample_codes.len(),
                self.total_orders
            ),
        ));
 
        report
    }
}
 
/// 建立一批委托单的产能画像。
///
/// 参数 `run`:一次运行结果。
/// 返回:产能画像。
///
/// ## 排序口径
///
/// - 分组按「工作量降序,同值按编码升序」:排产时最吃工时的排在前面。
///   同值用编码做二次键是必需的——否则当两个分组工作量相同时,
///   顺序会取决于遍历次序,而遍历次序取决于请求顺序,
///   于是「两次运行逐字节一致」就依赖于数据文件的排列顺序。
/// - 排期明细按「出证日,然后样品编号」:出证日相同时用编号兜底,
///   保证顺序完全由内容决定。
pub fn build_capacity_profile(run: &WorkbenchRun) -> CapacityProfile {
    let snapshots = run.created_snapshots();
 
    let mut buckets: Vec<CapacityBucket> = Vec::new();
    let mut department_buckets: Vec<CapacityBucket> = Vec::new();
    let mut schedule_lines: Vec<ScheduleLine> = Vec::new();
    let mut destructive_sample_codes: Vec<String> = Vec::new();
    let mut urgent_sample_codes: Vec<String> = Vec::new();
 
    for snapshot in &snapshots {
        // ---- 按检测类型累加 ----
        accumulate_bucket(
            &mut buckets,
            &snapshot.order_code,
            &snapshot.order_chinese_name,
            &snapshot.department,
            snapshot.piece_count,
            snapshot.workload_units,
            snapshot.promised_working_days,
        );
        // ---- 按部门累加(复用同一段累加逻辑,只是分组键不同)----
        accumulate_bucket(
            &mut department_buckets,
            &snapshot.department,
            &snapshot.department,
            &snapshot.department,
            snapshot.piece_count,
            snapshot.workload_units,
            snapshot.promised_working_days,
        );
 
        schedule_lines.push(ScheduleLine {
            sample_code: snapshot.sample_code.clone(),
            order_code: snapshot.order_code.clone(),
            accepted_on_text: snapshot.accepted_on_text.clone(),
            delivery_on_text: snapshot.delivery_on_text.clone(),
            promised_working_days: snapshot.promised_working_days,
            calendar_span_days: snapshot.calendar_span_days,
            skipped_days_text: snapshot.skipped_days_text.clone(),
        });
 
        if snapshot.is_destructive {
            destructive_sample_codes.push(snapshot.sample_code.clone());
        }
        if snapshot.is_urgent {
            urgent_sample_codes.push(snapshot.sample_code.clone());
        }
    }
 
    sort_buckets(&mut buckets);
    sort_buckets(&mut department_buckets);
 
    // 排期明细:出证日 → 样品编号。
    // 出证日是 `YYYY-MM-DD` 定宽文本,按文本排序与按日期排序结果相同——
    // 这是本工程把日期格式固定成定宽的一个额外收益。
    schedule_lines.sort_by(|left, right| {
        left.delivery_on_text
            .cmp(&right.delivery_on_text)
            .then_with(|| left.sample_code.cmp(&right.sample_code))
    });
 
    // 需要客户确认的两张清单也排序:它们直接进报表。
    destructive_sample_codes.sort();
    urgent_sample_codes.sort();
 
    CapacityProfile {
        batch_name: run.batch_name().to_string(),
        accepted_on_text: run.accepted_on().formatted(),
        total_workload: run.total_workload_units(),
        total_pieces: snapshots.iter().map(|snapshot| snapshot.piece_count as u32).sum(),
        total_orders: snapshots.len(),
        by_order_code: buckets,
        by_department: department_buckets,
        schedule_lines,
        destructive_sample_codes,
        urgent_sample_codes,
    }
}
 
/// 向分组表里累加一张委托单。
///
/// 参数 `buckets`:分组表(存在则累加,不存在则新增);
/// `bucket_code` / `bucket_chinese_name` / `bucket_department`:分组键与展示名;
/// `piece_count` / `workload_units` / `promised_working_days`:被累加的数值。
/// 返回:无(就地累加)。
///
/// ## 为什么抽成函数而不是在两处各写一遍循环
///
/// 本函数被「按检测类型」与「按部门」两种分组共用。若各写一遍,
/// 两段代码就会开始漂移:某天有人给某个分组加一个「最晚出证日」的统计,
/// 另一个分组就漏了,而**漏掉的那个分组在报表上看起来完全正常**
/// (只是少了一列)。抽成函数之后,加字段只加一处。
#[allow(clippy::too_many_arguments)]
fn accumulate_bucket(
    buckets: &mut Vec<CapacityBucket>,
    bucket_code: &str,
    bucket_chinese_name: &str,
    bucket_department: &str,
    piece_count: u16,
    workload_units: u32,
    promised_working_days: u16,
) {
    // 线性查找:分组数是个位数,成本可忽略,且顺序天然稳定(不引入哈希)。
    for bucket in buckets.iter_mut() {
        if bucket.order_code == bucket_code {
            bucket.order_count += 1;
            bucket.piece_total += piece_count as u32;
            bucket.workload_total += workload_units;
            bucket.promised_days_total += promised_working_days as u32;
            return;
        }
    }
    buckets.push(CapacityBucket {
        order_code: bucket_code.to_string(),
        order_chinese_name: bucket_chinese_name.to_string(),
        department: bucket_department.to_string(),
        order_count: 1,
        piece_total: piece_count as u32,
        workload_total: workload_units,
        promised_days_total: promised_working_days as u32,
    });
}
 
/// 按「工作量降序,同值按编码升序」排序分组。
///
/// 参数 `buckets`:分组表。
/// 返回:无(就地排序)。
fn sort_buckets(buckets: &mut [CapacityBucket]) {
    buckets.sort_by(|left, right| {
        right
            .workload_total
            .cmp(&left.workload_total)
            .then_with(|| left.order_code.cmp(&right.order_code))
    });
}
 
/// 给清单拼一个「(如:A、B)」后缀,最多列三个,空清单返回空串。
///
/// 参数 `items`:标识清单。
/// 返回:可读后缀。
///
/// 与 `creation_ledger_analysis::listing_suffix` 同口径(封顶三个):
/// 这一段文本要进表格单元,**必须有长度上界**,
/// 否则列宽就取决于运行时数据,而定宽表一旦溢出就会挤掉相邻列。
/// 两处各写一个同口径的小函数是可以接受的——
/// 它们服务于不同的数据来源,合并反而会让其中一个文件的依赖变杂。
fn short_suffix(items: &[String]) -> String {
    /// 体检行里最多列出的条目个数。
    const MAXIMUM_LISTED_ITEMS: usize = 3;
 
    if items.is_empty() {
        return String::new();
    }
    let listed: Vec<String> = items.iter().take(MAXIMUM_LISTED_ITEMS).cloned().collect();
    let suffix: String = if items.len() > MAXIMUM_LISTED_ITEMS {
        "…".to_string()
    } else {
        String::new()
    };
    format!("(如:{}{})", listed.join("、"), suffix)
}
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : check_line.rs
//! # 体检行 —— 把「结论」做成一个可以被机器判定的值
//!
//! ## 为什么需要这个类型,而不是直接 `println!("OK")`
//!
//! 本工程的 `analysis` 层要给出大量结论:「两版账本是否一致」、
//! 「白名单与构造器表是否同步」、「每条成功的委托单费用是否自洽」……
//! 若每个结论都写成一句打印,就会有两个后果:
//!
//! 1. **无法汇总**——没人能回答「一共查了多少项、过了多少项」;
//! 2. **无法被检查**——`println!("两版一致")` 这句话是硬编码的字符串,
//!    即使两版真的不一致,代码也不会说什么。
//!
//! 因此把结论收进 [`CheckLine`]:**名称 + 是否通过 + 实测值说明**。
//! 于是「通过」是算出来的,不是写出来的。
//!
//! ## 为什么必须带「实测值说明」
//!
//! 一条只说 `true/false` 的检查,在失败时提供不了任何诊断信息。
//! 例如 `两版账本一致 = false` —— 读者接下来要自己去找哪里不一致。
//! 而 `两版账本一致 = false(左 成功 7/被拒 5/未知 2,右 成功 6/被拒 5/未知 2)`
//! 一眼就能看出差在「成功」那一项。
//!
//! 这条纪律在既有工程里被反复验证过:**可核查性优先于简洁**。
//! 报表里每一个判断旁边都应该能看到它是基于什么数字做出来的。
//!
//! ## `detail` 的长度上界
//!
//! 说明文本最终要进表格单元,因此**必须有长度上界**。
//! 本类型不自己截断(截断是排版决策,属于 `app` 层),
//! 而是在文档里要求调用方把说明控制在「一句话」的量级。
//! 需要贴长清单时,把清单移到表下的注解行——
//! 这是既有工程踩过「长说明撑破列宽」之后定下的口径。
 
/// 一条体检结论。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckLine {
    /// 检查项名称(如「两版账本逐字段一致」)。
    name: String,
    /// 是否通过。
    passed: bool,
    /// 实测值说明(供读者复核判断依据)。
    detail: String,
}
 
impl CheckLine {
    /// 由名称、结论、说明构造。
    ///
    /// 参数 `name`:检查项名称;`passed`:是否通过;`detail`:实测值说明。
    /// 返回:体检行。
    ///
    /// ## 为什么不提供「只传名称」的快捷构造
    ///
    /// 因为那会诱惑调用方省略 `detail`。本类型的全部价值就在于
    /// 「结论必须带着依据」,少一个参数的便利不该凌驾于这条设计目标之上。
    pub fn new(name: &str, passed: bool, detail: &str) -> CheckLine {
        CheckLine {
            name: name.to_string(),
            passed,
            detail: detail.to_string(),
        }
    }
 
    /// 构造一条「通过」的体检行。
    ///
    /// 参数 `name`:检查项名称;`detail`:实测值说明。
    /// 返回:体检行。
    pub fn pass(name: &str, detail: &str) -> CheckLine {
        CheckLine::new(name, true, detail)
    }
 
    // ⚠️ 这里原本还有一个 `fail(name, detail)`。它被删掉的理由值得写下来:
    //
    // 本工程**每一行结论的通过与否都由实测值当场算出来**(`CheckLine::new`)。
    // 一个专门用来造「不通过」的构造函数,唯一的使用场景是
    // 「我先认定它该失败,再补一句说明」——那正是**写死结论**。
    // 与它成对的 `pass()` 有正当用途(「这条只是陈述事实,通过与否不适用」),
    // 所以留下;`fail()` 没有,因此删掉。
    //
    // 这不是不对称,而是「只保留有真实用途的那一个」。
    /// 检查项名称。
    pub fn name(&self) -> &str {
        &self.name
    }
 
    /// 是否通过。
    pub fn passed(&self) -> bool {
        self.passed
    }
 
    /// 实测值说明。
    pub fn detail(&self) -> &str {
        &self.detail
    }
 
    /// 报表里的结论列文本。
    ///
    /// 用 `通过` / `未通过` 两个中文词而不是 `OK` / `NG`:
    /// 本工程的报表面向业务读者,全中文的口径更不容易被误读。
    /// 同时注意**不要用对勾与叉号那两个符号(码点 U+2713 / U+2717)**——
    /// 它们的东亚宽度属性是 `A`(歧义),
    /// 在宽度模型里归 1 列,但很多终端渲染成 2 列,会让整列错位。
    /// 表格里能用中文词表达的东西,就不要用符号。
    pub fn conclusion_text(&self) -> &'static str {
        if self.passed {
            "通过"
        } else {
            "未通过"
        }
    }
}
 
/// 把一段文本压到指定显示宽度以内,超长时**显式**加省略号。
///
/// 参数 `text`:原文本;`maximum_width`:最大显示列数。
/// 返回:压好之后的文本。
///
/// ## 「显式省略」与「静默截断」是两回事
///
/// 既有工程里踩过一个坑:表格单元被 `truncate_to_width` 悄悄切掉,
/// 切完之后各行仍然对齐,**报表看起来完全正常**,18 处截断里 14 处
/// 是肉眼漏掉的。
///
/// 但那个教训的结论不是「任何截断都不许有」——而是
/// **截断必须留下痕迹**。本函数就是那条痕迹:
/// 一旦发生省略,末尾会出现一个 `…`,读者立刻知道这里被压缩过。
///
/// 两者的分工是这样的:
///
/// | 手段 | 用在哪 | 是否可见 |
/// |---|---|---|
/// | [`elide_text`] | 分析层生成说明文本时**主动压到上界内** | **可见**(带 `…`) |
/// | `app::layout::render_cell` 的 `debug_assert` | 排版层兜底,debug 构建下当场 panic | 构建期可见 |
///
/// 也就是说:正常情况下永远不该走到 `render_cell` 的截断分支——
/// 若走到了,说明某一处的说明文本没有主动设上界,那是一个应当被修的缺陷,
/// 而不是应当被容忍的常态。
pub fn elide_text(text: &str, maximum_width: usize) -> String {
    let width: usize = crate::support::display_width(text);
    if width <= maximum_width {
        return text.to_string();
    }
    // 省略号 U+2026 在本工程的宽度模型里占 1 列——`is_wide_character`
    // 的区段表里没有包含它。因此这里的预算就是「上限 − 1」。
    //
    // ⚠️ 这是个耦合点:若某天有人把 U+2026 加进宽字符区段表,
    // 这里的预算就必须改成 2。改动很小,但**必须与宽度模型同步**,
    // 否则省略之后的文本会比上限多出 1 列,在定宽表里表现为相邻列被挤掉。
    // 有公认表格/区间的判断宁可让机器算一遍——这就是为什么
    // `main.rs` 的自查区要核对 `display_width(horizontal_rule(n)) == n`。
    const ELLIPSIS_WIDTH: usize = 1;
    let content_budget: usize = maximum_width.saturating_sub(ELLIPSIS_WIDTH);
    format!("{}…", crate::support::truncate_to_width(text, content_budget))
}
 
/// 一组体检结论。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckReport {
    /// 报告标题(如「创建账本自洽核对」)。
    title: String,
    /// 逐条结论(顺序即报告顺序)。
    lines: Vec<CheckLine>,
}
 
impl CheckReport {
    /// 新建一个空报告。
    ///
    /// 参数 `title`:报告标题。
    /// 返回:空报告。
    pub fn new(title: &str) -> CheckReport {
        CheckReport {
            title: title.to_string(),
            lines: Vec::new(),
        }
    }
 
    /// 追加一条结论(流式)。
    ///
    /// 参数 `line`:体检行。
    /// 返回:追加后的报告。
    pub fn with_line(mut self, line: CheckLine) -> CheckReport {
        self.lines.push(line);
        self
    }
 
    /// 批量追加结论(流式)。
    ///
    /// 参数 `lines`:体检行列表。
    /// 返回:追加后的报告。
    ///
    /// 单独提供批量入口,是因为多数场景下结论是**由一个循环产出的**
    /// (如「逐条请求比对」「逐字段比对」)。有批量入口就不必在循环里
    /// 反复重新绑定 `self`,代码可读性明显更好。
    pub fn with_lines(mut self, lines: Vec<CheckLine>) -> CheckReport {
        self.lines.extend(lines);
        self
    }
 
    /// 报告标题。
    pub fn title(&self) -> &str {
        &self.title
    }
 
    /// 逐条结论。
    pub fn lines(&self) -> &[CheckLine] {
        &self.lines
    }
 
    /// 检查项总数。
    pub fn total_count(&self) -> usize {
        self.lines.len()
    }
 
    /// 通过的项数。
    pub fn passed_count(&self) -> usize {
        self.lines.iter().filter(|line| line.passed).count()
    }
 
    /// 未通过的项数。
    pub fn failed_count(&self) -> usize {
        // 用「总数 − 通过数」还是「数一遍」?
        // 这里**数一遍**。理由与创建账本「不用减法推导失败数」完全相同:
        // 减法推导在有多个计数来源时容易在某一处漏算,
        // 而一旦 `passed_count + failed_count != total_count`,
        // 报表上会出现读者一眼就发现的自相矛盾。
        // 数一遍的代价是一次遍历,收益是「两个计数各自独立可验证」。
        self.lines.iter().filter(|line| !line.passed).count()
    }
 
    /// 是否全部通过。
    pub fn all_passed(&self) -> bool {
        self.failed_count() == 0
    }
 
    /// 汇总结论文本,如 `18/18 通过`。
    ///
    /// ## 为什么把分母也印出来
    ///
    /// 只印「18 通过」无法回答「一共查了几项」。
    /// 分母必须出现——这是本工程对派生指标的通用要求
    /// (「除数要出现在标签或值里」,见既有工程的报表纪律)。
    pub fn summary_text(&self) -> String {
        format!("{}/{} 通过", self.passed_count(), self.total_count())
    }
 
    /// 未通过的结论清单(用于把问题集中展示)。
    pub fn failed_lines(&self) -> Vec<&CheckLine> {
        self.lines.iter().filter(|line| !line.passed).collect()
    }
}
 
/// 把若干份报告合并成一份。
///
/// 参数 `title`:合并后报告的标题;`reports`:待合并的报告。
/// 返回:合并后的报告。
///
/// ## 为什么保留「来自哪一份报告」这个信息
///
/// 合并会把多条结论压平成一列,读者因此失去「这一条属于哪个体检项」的线索。
/// 本函数在每条名称前加一段报告前缀(`报告标题 · 检查项名`),
/// 使压平之后依然可以定位。若不做这件事,合并报告就只能用于看总数,
/// 一旦有未通过项就得回头翻原始报告。
pub fn merge_reports(title: &str, reports: &[CheckReport]) -> CheckReport {
    let mut merged: CheckReport = CheckReport::new(title);
    for report in reports {
        for line in report.lines() {
            merged = merged.with_line(CheckLine::new(
                &format!("{} · {}", report.title(), line.name()),
                line.passed(),
                line.detail(),
            ));
        }
    }
    merged
}
 
 
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Simple Factory Pattern  简单工厂模式 Creational Patterns 创建型模式
//!# Author    : geovindu,Geovin Du 涂聚文.
//!# IDE       : RustRover  2025.1.1 Rust  rustc 1.98.1 
//!# os        : windows 10
//!# database  : mysql 9.0 sql server 2025, postgreSQL 18.0  Oracle 21c Neo4j
//!# Datetime  : 2026/10/8 21:48 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : simplefactorypattern
//!# File      : cost_profile.rs
//! # 成本画像 —— 钱的那一面,以及三条真正会抓到东西的核对
//!
//! ## 本文件的三个产出
//!
//! 1. **按检测类型分组的成本表**([`CostProfile`]):基准费 / 加急费 / 损耗费 /
//!    合计 / 项目费合计(对照列)。
//! 2. **按计价口径分组的成本表**:按件、按克拉、按项目数三档并排,
//!    让读者看清「同为检测费,三种口径的费用结构完全不同」。
//! 3. **费用一致性核对**([`fee_consistency_report`]):本文件真正的价值所在。
//!
//! ## 三条核对的来历——它们不是「顺手加的」
//!
//! ### ① 加急费与「是否加急」必须一致(双向)
//!
//! 这条针对本工程**实际发生过的一个缺陷**:
//!
//! > `urgency_surcharge()` 的初版写成
//! > `self.urgency_rate().apply_to(&self.base_fee())`,
//! > 并在注释里断言「不加急时费率为 0%,自然算出零」。
//! > 但 `urgency_rate()` 返回的是**价目表上的承诺费率**(30%),
//! > 与「这一单是否加急」无关。于是**不加急的单子也被加收了 30%**。
//!
//! 它的可怕之处在于:报表看起来完全正常——每张单都有合计、
//! 合计都为正、三项之和自洽、账本计数正确。唯一能戳破它的办法,
//! 就是把「是否加急」与「加急费是否为零」交叉核对。
//!
//! 而这条核对**必须双向**:
//!
//! | 方向 | 抓到的缺陷 |
//! |---|---|
//! | 加急 → 加急费应 > 0 | 「忘了收加急费」(少收) |
//! | 不加急 → 加急费应 = 0 | **本工程真实踩到的那个**(多收) |
//!
//! 只查第一个方向,那个真实缺陷会完整地活下来。
//! 这与「白名单 / 构造器表」的双向失配是同一个道理——
//! **单向检查只能抓到一类错,反向的那一类会永远沉默。**
//!
//! ### ② 损耗费与「是否破坏性」必须一致(双向)
//!
//! 与 ① 同源,但结构上有一个**关键差别**:
//! 「是否破坏性」是**检测类型的属性**(宝石鉴定永远要取样),
//! 而「是否加急」是**单张委托单的属性**。
//! 因此这条核对在本工程的数据上只会出现两种组合,
//! 但核对代码仍然写成双向——因为将来新增一种破坏性检测类型时,
//! 谁也不能保证它不忘写 `loss_rate`。
//!
//! ### ③ 计费克拉 >= 实际克拉,且非钻石类两者都为 0
//!
//! 这条让「最小计费重量 0.500 克拉」这条业务规则**变成可核对的数字**。
//! 没有它,读者在成本表上会看到一个无法解释的差额:
//! 一颗 0.450 ct 的钻石按每克拉 ¥600 本该收 ¥270.00,实际收了 ¥300.00。
//!
//! ## 为什么这些核对不写在 `capacity_profile` 里
//!
//! 因为 `CapacityProfile` 在构造时就把逐张金额聚合掉了。
//! 一个**拿不到数据**的检查只能打印「通过」,那是装饰性代码。
//! 本文件持有完整的快照(因而持有全部金额字段),
//! 核对放在这里才有可能真的失败。
 
use crate::analysis::{fee_text, CheckLine, CheckReport};
use crate::client::WorkbenchRun;
 
/// 一种检测类型的成本分组。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CostBucket {
    /// 分组键(检测类型编码或计价口径编码)。
    bucket_code: String,
    /// 展示名(检测类型中文名或计价口径中文名)。
    bucket_chinese_name: String,
    /// 张数。
    order_count: usize,
    /// 基准费合计(整数分)。
    base_fee_total: i64,
    /// 加急费合计(整数分)。
    urgency_total: i64,
    /// 损耗费合计(整数分)。
    loss_total: i64,
    /// 合计检测费(整数分)。
    total_fee: i64,
    /// 项目费合计(整数分)。**不参与计价**,仅作口径对照。
    item_fee_total: i64,
}
 
impl CostBucket {
    /// 分组键。
    pub fn bucket_code(&self) -> &str {
        &self.bucket_code
    }
 
    /// 展示名。
    pub fn bucket_chinese_name(&self) -> &str {
        &self.bucket_chinese_name
    }
 
    /// 张数。
    pub fn order_count(&self) -> usize {
        self.order_count
    }
 
    /// 基准费合计。
    pub fn base_fee_total(&self) -> i64 {
        self.base_fee_total
    }
 
    /// 加急费合计。
    pub fn urgency_total(&self) -> i64 {
        self.urgency_total
    }
 
    /// 损耗费合计。
    pub fn loss_total(&self) -> i64 {
        self.loss_total
    }
 
    /// 合计检测费。
    pub fn total_fee(&self) -> i64 {
        self.total_fee
    }
 
    /// 项目费合计(对照列)。
    pub fn item_fee_total(&self) -> i64 {
        self.item_fee_total
    }
 
    /// 基准费合计的文本。
    pub fn base_fee_text(&self) -> String {
        fee_text(self.base_fee_total)
    }
 
    /// 加急费合计的文本。
    ///
    /// ## 为什么零印成 `—` 而不是 `¥0.00`
    ///
    /// 「¥0.00」看起来像一个费用数值,读者会以为「这一类收过加急费,
    /// 只是恰好是零」。而 `—` 表达的是「这一格不适用」——
    /// 本工程里素金、银饰、钻石、玉石的加急费确实可以是 `¥0.00`
    /// (本批没加急),与宝石鉴定的 `¥159.00` 是同一列里的两种事实。
    ///
    /// ⚠️ 但要小心:**「本批没加急」与「这个类型不支持加急」是两件事**。
    /// 本工程所有类型都支持加急,因此「零」的真实含义是「本批没有加急单」。
    /// 若将来出现「不支持加急」的类型,这一列就不能再共用,
    /// 那时必须另开一列或在旁边加标记——**不要用同一个 `—` 表达两种含义**。
    pub fn urgency_text(&self) -> String {
        if self.urgency_total == 0 {
            "—".to_string()
        } else {
            fee_text(self.urgency_total)
        }
    }
 
    /// 损耗费合计的文本(口径同 [`CostBucket::urgency_text`])。
    pub fn loss_text(&self) -> String {
        if self.loss_total == 0 {
            "—".to_string()
        } else {
            fee_text(self.loss_total)
        }
    }
 
    /// 合计检测费的文本。
    pub fn total_fee_text(&self) -> String {
        fee_text(self.total_fee)
    }
 
    /// 项目费合计的文本。
    pub fn item_fee_text(&self) -> String {
        fee_text(self.item_fee_total)
    }
}
 
/// 一批委托单的成本画像。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CostProfile {
    /// 批次名。
    batch_name: String,
    /// 币种代码。
    currency_code: String,
    /// 按检测类型的分组(按合计降序,同值按编码升序)。
    by_order_code: Vec<CostBucket>,
    /// 按计价口径的分组(按口径编码升序,因为「口径」本身有固定的展示次序)。
    by_pricing_basis: Vec<CostBucket>,
    /// 基准费总计。
    base_total: i64,
    /// 加急费总计。
    urgency_total: i64,
    /// 损耗费总计。
    loss_total: i64,
    /// 合计总计。
    grand_total: i64,
    /// 项目费总计(对照)。
    item_fee_total: i64,
}
 
impl CostProfile {
    /// 批次名。
    pub fn batch_name(&self) -> &str {
        &self.batch_name
    }
 
    /// 币种代码。
    pub fn currency_code(&self) -> &str {
        &self.currency_code
    }
 
    /// 按检测类型的分组。
    pub fn by_order_code(&self) -> &[CostBucket] {
        &self.by_order_code
    }
 
    /// 按计价口径的分组。
    pub fn by_pricing_basis(&self) -> &[CostBucket] {
        &self.by_pricing_basis
    }
 
    /// 基准费总计。
    pub fn base_total(&self) -> i64 {
        self.base_total
    }
 
    /// 加急费总计。
    pub fn urgency_total(&self) -> i64 {
        self.urgency_total
    }
 
    /// 损耗费总计。
    pub fn loss_total(&self) -> i64 {
        self.loss_total
    }
 
    /// 合计总计。
    pub fn grand_total(&self) -> i64 {
        self.grand_total
    }
 
    /// 项目费总计。
    pub fn item_fee_total(&self) -> i64 {
        self.item_fee_total
    }
 
    /// 合计金额的文本。
    pub fn grand_total_text(&self) -> String {
        fee_text(self.grand_total)
    }
 
    /// 基准费总计的文本。
    pub fn base_total_text(&self) -> String {
        fee_text(self.base_total)
    }
 
    /// 加急费总计的文本。
    pub fn urgency_total_text(&self) -> String {
        fee_text(self.urgency_total)
    }
 
    /// 损耗费总计的文本。
    pub fn loss_total_text(&self) -> String {
        fee_text(self.loss_total)
    }
 
    /// 项目费总计的文本。
    pub fn item_fee_total_text(&self) -> String {
        fee_text(self.item_fee_total)
    }
 
    /// 三项附加与基准费之和是否等于总计(列求和核对)。
    pub fn columns_sum_to_grand_total(&self) -> bool {
        self.base_total + self.urgency_total + self.loss_total == self.grand_total
    }
 
    /// 转成体检报告。
    ///
    /// ## 主表只能做一件事:对每一列求和
    ///
    /// 这条纪律来自既有工程的教训:若某列印的是「张数」而上面各行印的是
    /// 「各类发生次数」,`1+10+1 ≠ 22`,任何读者按计算器都会认为表算错。
    /// 因此本报告的核对全部是**同口径列求和**,
    /// 不同口径的量(项目费合计)**不进求和行**,只在表下用注解说明。
    pub fn to_check_report(&self) -> CheckReport {
        let mut report: CheckReport = CheckReport::new("成本画像核对");
 
        // ---- 按类型分组:逐列求和 vs 总计 ----
        let base_from_buckets: i64 = self.by_order_code.iter().map(|b| b.base_fee_total).sum();
        let urgency_from_buckets: i64 = self.by_order_code.iter().map(|b| b.urgency_total).sum();
        let loss_from_buckets: i64 = self.by_order_code.iter().map(|b| b.loss_total).sum();
        let total_from_buckets: i64 = self.by_order_code.iter().map(|b| b.total_fee).sum();
        let orders_from_buckets: usize = self.by_order_code.iter().map(|b| b.order_count).sum();
 
        report = report.with_line(CheckLine::new(
            "按类型分组·基准费列求和 = 总计",
            base_from_buckets == self.base_total,
            &format!(
                "列和 {},总计 {}",
                fee_text(base_from_buckets),
                fee_text(self.base_total)
            ),
        ));
        report = report.with_line(CheckLine::new(
            "按类型分组·加急费列求和 = 总计",
            urgency_from_buckets == self.urgency_total,
            &format!(
                "列和 {},总计 {}",
                fee_text(urgency_from_buckets),
                fee_text(self.urgency_total)
            ),
        ));
        report = report.with_line(CheckLine::new(
            "按类型分组·损耗费列求和 = 总计",
            loss_from_buckets == self.loss_total,
            &format!(
                "列和 {},总计 {}",
                fee_text(loss_from_buckets),
                fee_text(self.loss_total)
            ),
        ));
        report = report.with_line(CheckLine::new(
            "按类型分组·合计列求和 = 总计",
            total_from_buckets == self.grand_total,
            &format!(
                "列和 {},总计 {}",
                fee_text(total_from_buckets),
                fee_text(self.grand_total)
            ),
        ));
 
        // ---- 按口径分组:同样逐列求和 ----
        let total_from_basis: i64 = self.by_pricing_basis.iter().map(|b| b.total_fee).sum();
        let orders_from_basis: usize = self.by_pricing_basis.iter().map(|b| b.order_count).sum();
        report = report.with_line(CheckLine::new(
            "按口径分组·合计列求和 = 总计",
            total_from_basis == self.grand_total,
            &format!(
                "列和 {},总计 {}",
                fee_text(total_from_basis),
                fee_text(self.grand_total)
            ),
        ));
 
        // ---- 两条分组路径必须给出同一个张数 ----
        // 这两条是「两条独立累加路径互相印证」,不是重复检查。
        report = report.with_line(CheckLine::new(
            "两条分组路径·张数一致",
            orders_from_buckets == orders_from_basis,
            &format!("按类型 {},按口径 {}", orders_from_buckets, orders_from_basis),
        ));
 
        // ---- 基准费 + 加急费 + 损耗费 = 合计(本表自己的三项之和不变量)----
        report = report.with_line(CheckLine::new(
            "基准费 + 加急费 + 损耗费 = 合计",
            self.columns_sum_to_grand_total(),
            &format!(
                "{} + {} + {} = {}",
                fee_text(self.base_total),
                fee_text(self.urgency_total),
                fee_text(self.loss_total),
                fee_text(self.grand_total)
            ),
        ));
 
        report
    }
}
 
/// 建立一批委托单的成本画像。
///
/// 参数 `run`:一次运行结果。
/// 返回:成本画像。
///
/// ## 为什么「按口径分组」在累加之后就按口径编码排序,而不是按金额降序
///
/// 因为「计价口径」是一个**固定的小集合**(按件 / 按克拉 / 按项目数),
/// 它的展示次序本身就是业务约定(先按件、后按克拉、再按项目数)。
/// 按金额降序会让同一份数据在不同批次下换顺序——
/// 读者对比两份报表时要重新找行。**分类维度用固定顺序,
/// 排序维度用金额**:本文件里按类型分组用金额降序(类型数量会增长),
/// 按口径分组用编码升序(口径数量固定)。
pub fn build_cost_profile(run: &WorkbenchRun) -> CostProfile {
    let snapshots = run.created_snapshots();
 
    let mut buckets: Vec<CostBucket> = Vec::new();
    let mut basis_buckets: Vec<CostBucket> = Vec::new();
 
    for snapshot in &snapshots {
        accumulate_cost_bucket(
            &mut buckets,
            &snapshot.order_code,
            &snapshot.order_chinese_name,
            snapshot.base_fee_minor_units,
            snapshot.urgency_surcharge_minor_units,
            snapshot.loss_fee_minor_units,
            snapshot.total_fee_minor_units,
            snapshot.item_fee_total_minor_units,
        );
        accumulate_cost_bucket(
            &mut basis_buckets,
            &snapshot.pricing_basis,
            &snapshot.pricing_basis,
            snapshot.base_fee_minor_units,
            snapshot.urgency_surcharge_minor_units,
            snapshot.loss_fee_minor_units,
            snapshot.total_fee_minor_units,
            snapshot.item_fee_total_minor_units,
        );
    }
 
    // 按类型:合计降序,同值按编码升序。
    buckets.sort_by(|left, right| {
        right
            .total_fee
            .cmp(&left.total_fee)
            .then_with(|| left.bucket_code.cmp(&right.bucket_code))
    });
    // 按口径:编码升序(固定展示次序)。
    basis_buckets.sort_by(|left, right| left.bucket_code.cmp(&right.bucket_code));
 
    // 总计从**快照直接求和**,分组从同一批快照累加。
    //
    // ⚠️ 为什么总计不取「分组求和的结果」?那样两条路径会变成同一件事,
    //    「分组求和 = 总计」这条核对就成了恒真式,再也抓不到「漏了一行」。
    //    两条路径互相独立,核对才有内容。这是本工程「用独立第二来源核对」
    //    这条纪律在代码里的落点。
    let base_total: i64 = snapshots.iter().map(|s| s.base_fee_minor_units).sum();
    let urgency_total: i64 = snapshots.iter().map(|s| s.urgency_surcharge_minor_units).sum();
    let loss_total: i64 = snapshots.iter().map(|s| s.loss_fee_minor_units).sum();
    let grand_total: i64 = snapshots.iter().map(|s| s.total_fee_minor_units).sum();
    let item_fee_total: i64 = snapshots.iter().map(|s| s.item_fee_total_minor_units).sum();
 
    CostProfile {
        batch_name: run.batch_name().to_string(),
        currency_code: crate::domain::CURRENCY_CHINESE_YUAN.code().to_string(),
        by_order_code: buckets,
        by_pricing_basis: basis_buckets,
        base_total,
        urgency_total,
        loss_total,
        grand_total,
        item_fee_total,
    }
}
 
/// 向成本分组表里累加一张委托单。
///
/// 参数 `buckets`:分组表;`bucket_code` / `bucket_chinese_name`:分组键与展示名;
/// `base` / `urgency` / `loss` / `total` / `item_fee`:五个金额(整数分)。
/// 返回:无(就地累加)。
///
/// ## 为什么五个金额全部单独累加
///
/// 因为报表要**逐列求和**,而逐列求和的前提是每一列都有自己的累加器。
/// 若只累加合计、其余四列在打印时现算(比如用比例反推),
/// 那么「列求和 = 总计」这条核对就退化成恒真式,再也抓不到漏行。
#[allow(clippy::too_many_arguments)]
fn accumulate_cost_bucket(
    buckets: &mut Vec<CostBucket>,
    bucket_code: &str,
    bucket_chinese_name: &str,
    base: i64,
    urgency: i64,
    loss: i64,
    total: i64,
    item_fee: i64,
) {
    for bucket in buckets.iter_mut() {
        if bucket.bucket_code == bucket_code {
            bucket.order_count += 1;
            bucket.base_fee_total += base;
            bucket.urgency_total += urgency;
            bucket.loss_total += loss;
            bucket.total_fee += total;
            bucket.item_fee_total += item_fee;
            return;
        }
    }
    buckets.push(CostBucket {
        bucket_code: bucket_code.to_string(),
        bucket_chinese_name: bucket_chinese_name.to_string(),
        order_count: 1,
        base_fee_total: base,
        urgency_total: urgency,
        loss_total: loss,
        total_fee: total,
        item_fee_total: item_fee,
    });
}
 
/// 三条费用一致性核对(本文件的核心价值)。
///
/// 参数 `run`:一次运行结果。
/// 返回:体检报告。
///
/// ## 为什么必须传 `run` 而不是 [`CostProfile`]
///
/// 因为 `CostProfile` 已经聚合过,**看不到逐张的标记与金额的对应关系**。
/// 要核对「这张单标了加急,那么它的加急费是不是正数」,
/// 就必须同时持有「标记」与「金额」——那只有快照里有。
/// 这是本函数签名接受 `WorkbenchRun` 的唯一理由,也是它不能是
/// `CostProfile` 的方法的原因。
pub fn fee_consistency_report(run: &WorkbenchRun) -> CheckReport {
    let snapshots = run.created_snapshots();
    let mut report: CheckReport = CheckReport::new("费用一致性(逐张核对)");
 
    // ---- ① 加急费与加急标记(双向)----
    // 方向 A:标了加急,加急费必须为正。
    let urgent_but_free: Vec<String> = snapshots
        .iter()
        .filter(|s| s.is_urgent && s.urgency_surcharge_minor_units <= 0)
        .map(|s| s.sample_code.clone())
        .collect();
    // 方向 B:没标加急,加急费必须为零。
    // ★ 这一条正是本工程真实缺陷的回归检查。
    let not_urgent_but_charged: Vec<String> = snapshots
        .iter()
        .filter(|s| !s.is_urgent && s.urgency_surcharge_minor_units != 0)
        .map(|s| s.sample_code.clone())
        .collect();
 
    report = report.with_line(CheckLine::new(
        "加急单:加急费为正",
        urgent_but_free.is_empty(),
        &format!(
            "加急 {} 张;未收 {} 张{}",
            snapshots.iter().filter(|s| s.is_urgent).count(),
            urgent_but_free.len(),
            short_suffix(&urgent_but_free)
        ),
    ));
    report = report.with_line(CheckLine::new(
        "非加急单:加急费为零(回归检查)",
        not_urgent_but_charged.is_empty(),
        &format!(
            "非加急 {} 张;误收 {} 张{}",
            snapshots.iter().filter(|s| !s.is_urgent).count(),
            not_urgent_but_charged.len(),
            short_suffix(&not_urgent_but_charged)
        ),
    ));
 
    // ---- ② 损耗费与破坏性标记(双向)----
    let destructive_but_free: Vec<String> = snapshots
        .iter()
        .filter(|s| s.is_destructive && s.loss_fee_minor_units <= 0)
        .map(|s| s.sample_code.clone())
        .collect();
    let intact_but_charged: Vec<String> = snapshots
        .iter()
        .filter(|s| !s.is_destructive && s.loss_fee_minor_units != 0)
        .map(|s| s.sample_code.clone())
        .collect();
 
    report = report.with_line(CheckLine::new(
        "破坏性检测:损耗费为正",
        destructive_but_free.is_empty(),
        &format!(
            "破坏性 {} 张;未收 {} 张{}",
            snapshots.iter().filter(|s| s.is_destructive).count(),
            destructive_but_free.len(),
            short_suffix(&destructive_but_free)
        ),
    ));
    report = report.with_line(CheckLine::new(
        "非破坏性检测:损耗费为零",
        intact_but_charged.is_empty(),
        &format!(
            "非破坏性 {} 张;误收 {} 张{}",
            snapshots.iter().filter(|s| !s.is_destructive).count(),
            intact_but_charged.len(),
            short_suffix(&intact_but_charged)
        ),
    ));
 
    // ---- ③ 计费克拉与实际克拉的关系 ----
    // 方向 A:计费克拉不得小于实际克拉(小了就是少收钱,且无解释)。
    let under_billed: Vec<String> = snapshots
        .iter()
        .filter(|s| s.billable_carat_millis < s.carat_millis)
        .map(|s| s.sample_code.clone())
        .collect();
    // 方向 B:非钻石类(实际克拉为 0)的计费克拉也必须为 0。
    // 若某个非钻石类产品误把计费克拉填成非零,会凭空多出一笔按克拉的费用。
    let phantom_carat: Vec<String> = snapshots
        .iter()
        .filter(|s| s.carat_millis == 0 && s.billable_carat_millis != 0)
        .map(|s| s.sample_code.clone())
        .collect();
 
    report = report.with_line(CheckLine::new(
        "计费克拉 >= 实际克拉",
        under_billed.is_empty(),
        &format!(
            "全部 {} 张;偏低 {} 张{}",
            snapshots.len(),
            under_billed.len(),
            short_suffix(&under_billed)
        ),
    ));
    report = report.with_line(CheckLine::new(
        "非钻石类:两个克拉字段都为 0",
        phantom_carat.is_empty(),
        &format!(
            "非钻石 {} 张;计费克拉非零 {} 张{}",
            snapshots.iter().filter(|s| s.carat_millis == 0).count(),
            phantom_carat.len(),
            short_suffix(&phantom_carat)
        ),
    ));
 
    // ---- ④ 触发最小计费重量的记录(陈述性,用于把差额解释清楚)----
    // 这一条 `passed` 恒为 true:触发最小计费不是缺陷,而是一条业务规则在生效。
    // 但必须打印出来——否则成本表上会出现「0.450 ct 却收了 0.500 ct 的钱」
    // 这个读者无法解释的差额。把它列出来,差额就变成可核对的规则。
    let minimum_billing_applied: Vec<String> = snapshots
        .iter()
        .filter(|s| s.billable_carat_millis > s.carat_millis)
        .map(|s| {
            // ★ 重量文本走 `client::carat_text`,**不在这里做 `as f64 / 1000.0`**。
            //   本工程全程无浮点:千分之一克拉除以 1000.0 会让 0.450
            //   变成 0.45000000000000001 之类的值,而这条说明文本的全部价值
            //   就在于那个数字能被读者一眼核对。整数除余拼串在 carat_text 里
            //   只有一份,这里复用它。
            format!(
                "{}({} → {})",
                s.sample_code,
                crate::client::carat_text(s.carat_millis),
                crate::client::carat_text(s.billable_carat_millis)
            )
        })
        .collect();
    report = report.with_line(CheckLine::new(
        "触发了最小计费重量的记录",
        true,
        &format!(
            "{} 张{}",
            minimum_billing_applied.len(),
            if minimum_billing_applied.is_empty() {
                String::new()
            } else {
                format!("(如:{})", minimum_billing_applied.join("、"))
            }
        ),
    ));
 
    report
}
 
/// 给清单拼一个「(如:A、B)」后缀,最多列三个,空清单返回空串。
///
/// 参数 `items`:标识清单。
/// 返回:可读后缀。
///
/// 封顶三个的理由与其它体检文件一致:这段文本要进表格单元,
/// **必须有长度上界**,否则列宽就取决于运行时数据。
fn short_suffix(items: &[String]) -> String {
    /// 体检行里最多列出的条目个数。
    const MAXIMUM_LISTED_ITEMS: usize = 3;
 
    if items.is_empty() {
        return String::new();
    }
    let listed: Vec<String> = items.iter().take(MAXIMUM_LISTED_ITEMS).cloned().collect();
    let suffix: String = if items.len() > MAXIMUM_LISTED_ITEMS {
        "…".to_string()
    } else {
        String::new()
    };
    format!("(如:{}{})", listed.join("、"), suffix)
}
 


 

posted @ 2026-10-08 22:31  ®Geovin Du Dream Park™  阅读(2)  评论(0)    收藏  举报