rust: Facade Pattern
项目结构:

//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : cargo_category.rs
//! 货物类别(开放型标签)。
//!
//! ## 为什么是开放型
//!
//! 珠宝公司的货物类别会随业务扩张不断新增(海关样品、维修件、展品、
//! 赠品……)。做成枚举的话,每加一类都要改领域层。
//!
//! ## 携带的属性如何被使用
//!
//! - `requires_formal_declaration`:决定单据子系统是否生成报关单;
//! - `allows_letter_of_credit`:该品类能否用信用证结算(展品通常不行)。
//!
//! 两个都是布尔属性,由子系统读取后自行决定行为,门面不做分派。
//! 这正是「开放型标签」的典型用法:**扩展维度靠加常量,不靠改代码分支**。
/// 一种货物类别。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CargoCategory {
/// 类别编码,如 `"JEWELRY"`。
code: &'static str,
/// 中文名称,如 `"珠宝首饰"`。
label: &'static str,
/// 是否必须做正式报关申报。
requires_formal_declaration: bool,
/// 是否允许使用信用证结算。
allows_letter_of_credit: bool,
}
impl CargoCategory {
/// 构造一个货物类别标签。
pub const fn new(
code: &'static str,
label: &'static str,
requires_formal_declaration: bool,
allows_letter_of_credit: bool,
) -> Self {
CargoCategory {
code,
label,
requires_formal_declaration,
allows_letter_of_credit,
}
}
/// 类别编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn label(&self) -> &'static str {
self.label
}
/// 是否必须正式报关。
pub const fn requires_formal_declaration(&self) -> bool {
self.requires_formal_declaration
}
/// 是否允许信用证结算。
pub const fn allows_letter_of_credit(&self) -> bool {
self.allows_letter_of_credit
}
}
/// 珠宝首饰(高值、必须报关、可用信用证)。
pub const CARGO_JEWELRY: CargoCategory = CargoCategory::new("JEWELRY", "珠宝首饰", true, true);
/// 包装物料(低值、无需单独报关、不可用信用证)。
pub const CARGO_PACKAGING: CargoCategory = CargoCategory::new("PACKAGING", "包装物料", false, false);
/// 随货证书(无商业价值、无需报关)。
pub const CARGO_CERTIFICATE: CargoCategory =
CargoCategory::new("CERTIFICATE", "随货证书", false, false);
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : country_code.rs
//! 国家/地区代码(开放型标签)。
//!
//! ## 为什么是结构体而不是枚举
//!
//! 门面演示里会「新增一个国家」来验证扩展性。若这里是
//! `enum CountryCode { CN, DE }`,工程外想加「新加坡」就得改本文件——
//! 一个纯粹的数据扩充却要动领域层,扩展性验证会当场失败。
//!
//! 做成 `const fn new(...)` 的开放型结构体后,新增国家只是在新位置写一行
//! 常量定义,领域层文件一字不改。这是本工程所有「标签类维度」的统一做法。
//!
//! ## 哪些属性值得携带
//!
//! `code`(ISO 3166-1 alpha-2)与 `label`(中文名)是展示与键控必需的。
//! 另外携带 `requires_customs_declaration`——它决定清关环节要不要生成报关单。
//! 这个标志**是数据不是行为分派**(没有 `match`),所以结构体字段足够,
//! 无需枚举。
/// 一个国家或地区的代码与其业务属性。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CountryCode {
/// ISO 3166-1 alpha-2 代码,如 `"CN"`。
code: &'static str,
/// 中文名称,如 `"中国"`。
label: &'static str,
/// 该目的地是否要求做正式报关申报。
requires_customs_declaration: bool,
}
impl CountryCode {
/// 构造一个国家/地区标签。
///
/// 参数 `code` / `label` / `requires_customs_declaration`。
///
/// 为 `const fn`:这样预置常量与工程外新增常量都能在编译期构造,
/// 运行时零开销。
pub const fn new(
code: &'static str,
label: &'static str,
requires_customs_declaration: bool,
) -> Self {
CountryCode {
code,
label,
requires_customs_declaration,
}
}
/// 国家/地区代码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn label(&self) -> &'static str {
self.label
}
/// 是否要求正式报关。
///
/// 注意这是**属性读取**而非行为分派:门面只是把它塞进单据子系统,
/// 由单据子系统决定怎么用。因此不需要枚举。
pub const fn requires_customs_declaration(&self) -> bool {
self.requires_customs_declaration
}
}
/// 中国内地。
pub const COUNTRY_CHINA: CountryCode = CountryCode::new("CN", "中国", true);
/// 德国(本工程的主要出口目的地)。
pub const COUNTRY_GERMANY: CountryCode = CountryCode::new("DE", "德国", true);
/// 中国香港(本工程用于演示「同一国家维度下的另一个地区标签」)。
pub const COUNTRY_HONG_KONG: CountryCode = CountryCode::new("HK", "中国香港", true);
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : currency_amount.rs
//! 货币与金额。
//!
//! ## 为什么金额必须用整数「分」
//!
//! 若用 `f64` 存金额,`0.1 + 0.2 != 0.3`,而在结算场景里这意味着
//! 「三笔费用相加对不上账单」。门面会汇总多个子系统的金额,
//! 每多一次浮点累加就多一分误差,因此全工程统一用 `i64` 存**最小单位**
//! (人民币为分),只在展示时才转成「元」。
//!
//! ## 为什么把 `Currency` 做成开放型结构体
//!
//! 门面以后可能要接美元、港币账单。若 `Currency` 做成枚举,
//! 工程外想加一种币种就得改本文件,扩展性验证当场失败。
//! 因此它是 [`Currency::new`] 构造的开放型标签——
//! 但注意其 `allows_cent_precision` 之类的属性是**数据**而非行为分派,
//! 所以用结构体携带即可,无需枚举的穷尽性检查。
use std::fmt;
/// 金额的缩放比例分母:万分之。
///
/// 所有「按比例缩放」的操作都以此为基础分母,保证全工程只有一种比例口径。
/// 之所以不用百分之一:运费折扣、税率、佣金率常需要万分之一级别的精度
/// (如 0.03% 的保价费率 = 3 万分之 3),用百分比会在中间步骤就被舍入掉。
pub const BASIS_POINTS_DENOMINATOR: i64 = 10_000;
/// 一种货币。
///
/// 开放型标签:`code` 为 ISO 4217 代码,`label` 为中文名,
/// `minor_units_per_major_unit` 为一个主单位等于多少最小单位
/// (人民币为 100 分)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Currency {
/// 货币代码,如 `"CNY"`、`"HKD"`。
code: &'static str,
/// 货币中文名,如 `"人民币"`。
label: &'static str,
/// 一个主单位等于多少个最小单位(人民币 = 100)。
minor_units_per_major_unit: i64,
/// 该货币的展示符号,如 `"¥"`。
symbol: &'static str,
}
impl Currency {
/// 构造一种货币。
///
/// 参数 `code` / `label` / `minor_units_per_major_unit` / `symbol`。
/// 返回:货币值对象。
///
/// 为 `const fn`,便于在常量上下文中预置币种。
pub const fn new(
code: &'static str,
label: &'static str,
minor_units_per_major_unit: i64,
symbol: &'static str,
) -> Self {
Currency {
code,
label,
minor_units_per_major_unit,
symbol,
}
}
/// 货币代码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 货币中文名。
pub const fn label(&self) -> &'static str {
self.label
}
/// 一个主单位包含的最小单位数。
pub const fn minor_units_per_major_unit(&self) -> i64 {
self.minor_units_per_major_unit
}
/// 货币符号。
pub const fn symbol(&self) -> &'static str {
self.symbol
}
}
/// 人民币(本工程的主要记账币种)。
pub const CURRENCY_CHINESE_YUAN: Currency = Currency::new("CNY", "人民币", 100, "¥");
/// 港币(用于演示多币种扩展点;最小单位是分,与人民币一致)。
pub const CURRENCY_HONG_KONG_DOLLAR: Currency = Currency::new("HKD", "港币", 100, "HK$");
/// 一个金额,以最小单位(分)存储。
///
/// 内部只保存 `minor_units`,币种作为独立字段一起携带——
/// 这样「¥100 加 HK$100」这种错误在类型上仍然合法(都是 `CurrencyAmount`),
/// **需要靠调用方保证同币种**。这里不引入编译期币种类型标记,
/// 因为那会让所有算术签名膨胀,收益(防住一种低频错误)不划算。
/// 代价是:跨币种运算前必须显式换算,本工程在门面里做了这一步。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CurrencyAmount {
/// 金额的最小单位数值(人民币即为「分」)。
minor_units: i64,
/// 该金额所属币种。
currency: Currency,
}
impl CurrencyAmount {
/// 以最小单位构造金额。
///
/// 参数 `minor_units`:最小单位数值;`currency`:币种。
pub const fn from_minor_units(minor_units: i64, currency: Currency) -> Self {
CurrencyAmount {
minor_units,
currency,
}
}
/// 以主单位构造金额(如「12.34 元」→ 1234 分)。
///
/// 参数 `major_units`:主单位整数部分;`minor_part`:最小单位部分;
/// `currency`:币种。
/// 返回:金额。
///
/// 拆成两个参数是为了**避免浮点**:调用方写
/// `from_major_and_minor(12, 34, CURRENCY_CHINESE_YUAN)` 表示 12.34 元,
/// 比写 `12.34` 再转换精确得多。
pub const fn from_major_and_minor(
major_units: i64,
minor_part: i64,
currency: Currency,
) -> Self {
CurrencyAmount {
minor_units: major_units * currency.minor_units_per_major_unit() + minor_part,
currency,
}
}
/// 零金额(给定币种)。
///
/// 参数 `currency`:币种。
pub const fn zero(currency: Currency) -> Self {
CurrencyAmount {
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 const fn absolute(&self) -> Self {
CurrencyAmount {
minor_units: if self.minor_units < 0 {
-self.minor_units
} else {
self.minor_units
},
currency: self.currency,
}
}
/// 相加。
///
/// 参数 `other`:另一个同币种金额。
/// 返回:和。
///
/// 用 `saturating_add` 而不是 `+`:金额溢出在业务上绝不该发生,
/// 但一旦发生,`+` 会在 debug 下 panic、release 下静默回绕,
/// 两种行为都不可接受。饱和到 `i64::MAX` 至少是**可观测**的错误值。
pub const fn add(&self, other: &CurrencyAmount) -> Self {
CurrencyAmount {
minor_units: self.minor_units.saturating_add(other.minor_units),
currency: self.currency,
}
}
/// 相减。
///
/// 参数 `other`:另一个同币种金额。
/// 返回:差(可能为负)。
pub const fn subtract(&self, other: &CurrencyAmount) -> Self {
CurrencyAmount {
minor_units: self.minor_units.saturating_sub(other.minor_units),
currency: self.currency,
}
}
/// 乘以一个整数数量(如「单价 × 件数」)。
///
/// 参数 `quantity`:整数倍数。
/// 返回:乘积。
///
/// 用 i128 做中间量再判溢出:i64 相乘极易溢出,
/// 直接判 i128 结果是否越界比预判 `a * b` 是否溢出更可靠。
pub const fn multiply_by_quantity(&self, quantity: i64) -> Self {
let intermediate: i128 = self.minor_units as i128 * quantity as i128;
CurrencyAmount {
minor_units: clamp_i128_to_i64(intermediate),
currency: self.currency,
}
}
/// 按「万分比」缩放(如运费 × 3 万分之 3 的保价费率)。
///
/// 参数 `basis_points`:万分比数值(3 表示 0.03%)。
/// 返回:缩放后的金额。
///
/// **四舍五入方向:远离零**。理由:这是结算金额,
/// 银行家舍入(四舍六入五成双)会让「五」这一档在业务上难以解释;
/// 而「远离零」对正负金额对称,退款场景也能自洽。
/// 实现上刻意不借道 `f64`——浮点在边界值会给出反直觉结果。
pub const fn scale_by_basis_points(&self, basis_points: i64) -> Self {
// 中间量用 i128:金额(i64)× 万分比(i64)可达 2^126,仍是 i64 的两倍余量。
let numerator: i128 = self.minor_units as i128 * basis_points as i128;
let rounded: i128 = divide_rounded_away_from_zero(numerator, BASIS_POINTS_DENOMINATOR as i128);
CurrencyAmount {
minor_units: clamp_i128_to_i64(rounded),
currency: self.currency,
}
}
/// 生成展示文本,如 `"¥1,234.56"`。
///
/// 千分位分隔是本函数自己实现的(见 `insert_thousands_separator`),
/// 不走 `format!` 的 `{:,.2}`——标准库的千分位选项在旧版本不可用,
/// 且无法处理「最小单位不是 100」的币种。
pub fn formatted(&self) -> String {
let per_major: i64 = self.currency.minor_units_per_major_unit();
// 取绝对值来分别处理「整数部分」与「小数部分」,最后再补符号。
let absolute_units: i64 = self.minor_units.abs();
let major_part: i64 = absolute_units / per_major;
let minor_part: i64 = absolute_units % per_major;
// 小数位数由「一个主单位有多少最小单位」决定:
// 100 → 2 位;1000 → 3 位。用 10 的幂反推位数。
let minor_digits: usize = decimal_digits_of(per_major);
// 构造小数部分(补足前导零,如 5 分要显示为 05)。
let minor_text: String = if minor_digits == 0 {
String::new()
} else {
format!("{:0width$}", minor_part, width = minor_digits)
};
// 千分位分隔只加在整数部分。
let major_text: String = insert_thousands_separator(major_part);
// 符号在有小数时放在金额前,无论正负都显式标出(金额为负必须可见)。
let sign_text: &str = if self.minor_units < 0 { "-" } else { "" };
if minor_digits == 0 {
format!("{}{}{}", sign_text, self.currency.symbol(), major_text)
} else {
format!(
"{}{}{}.{}",
sign_text,
self.currency.symbol(),
major_text,
minor_text
)
}
}
/// 生成带币种代码的展示文本,如 `"¥1,234.56 CNY"`。
///
/// 跨币种汇总时必须用这个,否则只看符号无法区分 CNY 与 HKD
/// (两者符号都以 `$`/`¥` 起头,容易误读)。
pub fn formatted_with_currency_code(&self) -> String {
format!("{} {}", self.formatted(), self.currency.code())
}
}
/// 把 i128 钳制到 i64 范围内。
///
/// 参数 `value`:待钳制的 i128。
/// 返回:钳制后的 i64。
///
/// 独立成自由函数(而非方法):它不依赖任何状态,
/// 且金额、重量、比率三处都要用,放在模块级便于共用。
pub const fn clamp_i128_to_i64(value: i128) -> i64 {
if value > i64::MAX as i128 {
i64::MAX
} else if value < i64::MIN as i128 {
i64::MIN
} else {
value as i64
}
}
/// 整数除法并向**远离零**的方向四舍五入。
///
/// 参数 `numerator`:被除数(i128,避免中间溢出);`denominator`:除数。
/// 返回:四舍五入后的商。
///
/// 实现要点:Rust 的 `/` 是「向零截断」。要改成「四舍五入」,
/// 需要先算余数,再比较 `2 * |余数|` 与 `|除数|`。
/// **不能**用 `(numerator + denominator / 2) / denominator` 这种写法——
/// 它在负数上会偏(向零方向而非远离零),导致退款金额与正向金额不对称。
/// 本函数是全工程唯一的舍入入口,所有金额/重量/比率都走它。
pub const fn divide_rounded_away_from_zero(numerator: i128, denominator: i128) -> i128 {
// 除数为 0 属于调用方错误;返回 0 而不是 panic,
// 因为 const fn 里 panic 的可用形式受限,且本工程调用点均传常量分母。
if denominator == 0 {
return 0;
}
let quotient: i128 = numerator / denominator;
let remainder: i128 = numerator % denominator;
// 余数为 0 时无需调整。
if remainder == 0 {
return quotient;
}
// 比较 2*|余数| 与 |除数|,判断是否该进位。
let doubled_remainder: i128 = if remainder < 0 {
-remainder * 2
} else {
remainder * 2
};
let absolute_denominator: i128 = if denominator < 0 {
-denominator
} else {
denominator
};
if doubled_remainder >= absolute_denominator {
// 该进位。方向取决于商的符号:
// 商为正 → 加 1;商为负(或零而分子为负)→ 减 1,从而远离零。
if numerator < 0 {
quotient - 1
} else {
quotient + 1
}
} else {
quotient
}
}
/// 计算一个 10 的幂有多少位小数(100 → 2,1000 → 3,1 → 0)。
///
/// 参数 `value`:非零正整数。
/// 返回:它是 10 的几次幂;不是 10 的幂时返回 0(调用方按「无小数」处理)。
fn decimal_digits_of(value: i64) -> usize {
if value <= 1 {
return 0;
}
let mut remaining: i64 = value;
let mut digits: usize = 0;
// 反复除以 10,直到剩下 1;若中途除不尽,说明不是 10 的幂。
while remaining > 1 {
if remaining % 10 != 0 {
return 0;
}
remaining /= 10;
digits += 1;
}
digits
}
/// 给整数部分插入千分位分隔符。
///
/// 参数 `integer_value`:非负整数。
/// 返回:插入分隔符后的字符串(如 `1234567` → `"1,234,567"`)。
///
/// 实现方式是从右往左每 3 位插一个逗号,且**必须在字符串上操作**——
/// 用整数取模拼串也能做,但对「1234 取中间三位」这类边界更容易写错。
fn insert_thousands_separator(integer_value: i64) -> String {
// 先转成十进制文本,再自右向左分组。
let digits: String = integer_value.to_string();
let digit_count: usize = digits.len();
// 每 3 位一组,向上取整得到组数。
let group_count: usize = digit_count.div_ceil(3);
let mut result: String = String::with_capacity(digit_count + group_count);
// 逐组追加,组间用逗号连接。
for group_index in 0..group_count {
// 计算该组在原始文本中的起止下标。
// 第 0 组(最左)可能不足 3 位。
let group_start: usize = digit_count.saturating_sub((group_index + 1) * 3);
let group_end: usize = digit_count.saturating_sub(group_index * 3);
if group_index > 0 {
result.insert(0, ',');
}
// 用 insert_str(0, ...) 从左侧拼接:等价于倒序构造,最后得到正序结果。
result.insert_str(0, &digits[group_start..group_end]);
}
result
}
impl fmt::Display for CurrencyAmount {
/// 默认展示用带符号形式,便于直接放进 `{}` 占位符。
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.formatted())
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : document_kind.rs
//! 单据类型(开放型标签)。
//!
//! ## 为什么是开放型而不是枚举
//!
//! 单据类型会随合规要求变化(产地证、成分证、濒危物种证明、AEO 认证……)。
//! 门面的单据子系统按类型生成不同单据,扩展时只应加常量。
//!
//! ## 一个重要的设计区分
//!
//! 注意这里的 `code`/`label`/`is_legally_required` 都是**数据**:
//! 单据子系统遍历「本次需要哪些单据」时只做集合运算,不 `match` 类型。
//! 这一点很关键——如果子系统里出现
//! `match document_kind { Invoice => ..., PackingList => ... }`,
//! 那么新增一种单据就必须改子系统代码,扩展性就没了。
//! 本工程的所有扩展维度(国家、品类、材料、单据、等级)都遵守这条纪律。
/// 一种单据类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DocumentKind {
/// 类型编码,如 `"COMMERCIAL_INVOICE"`。
code: &'static str,
/// 中文名称,如 `"商业发票"`。
label: &'static str,
/// 该单据是否为法定必备(缺少会导致清关受阻)。
is_legally_required: bool,
/// 生成该单据所需的模板标识(由单据子系统解释)。
template_identifier: &'static str,
}
impl DocumentKind {
/// 构造一种单据类型。
pub const fn new(
code: &'static str,
label: &'static str,
is_legally_required: bool,
template_identifier: &'static str,
) -> Self {
DocumentKind {
code,
label,
is_legally_required,
template_identifier,
}
}
/// 类型编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn label(&self) -> &'static str {
self.label
}
/// 是否为法定必备单据。
pub const fn is_legally_required(&self) -> bool {
self.is_legally_required
}
/// 模板标识。
pub const fn template_identifier(&self) -> &'static str {
self.template_identifier
}
}
/// 商业发票(法定必备)。
pub const DOCUMENT_COMMERCIAL_INVOICE: DocumentKind =
DocumentKind::new("COMMERCIAL_INVOICE", "商业发票", true, "TPL_INVOICE_V3");
/// 装箱单(法定必备)。
pub const DOCUMENT_PACKING_LIST: DocumentKind =
DocumentKind::new("PACKING_LIST", "装箱单", true, "TPL_PACKING_V2");
/// 原产地证书(法定必备,但可申请后补)。
pub const DOCUMENT_CERTIFICATE_OF_ORIGIN: DocumentKind =
DocumentKind::new("CERTIFICATE_OF_ORIGIN", "原产地证书", true, "TPL_ORIGIN_V1");
/// 温控声明(非强制,但温控货强烈建议)。
pub const DOCUMENT_TEMPERATURE_DECLARATION: DocumentKind =
DocumentKind::new("TEMPERATURE_DECLARATION", "温控声明", false, "TPL_TEMP_V1");
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : event_severity.rs
//! 事件严重等级(**封闭枚举**)。
//!
//! ## 为什么这里用枚举而不是开放型结构体
//!
//! 与 [`crate::domain::country_code`] 等标签相反,本类型的特点是
//! **每个变体都会被 `match` 分派不同行为**:
//!
//! - `Info` → 不阻断,只记录;
//! - `Warning` → 不阻断,但需人工确认;
//! - `Critical` → 阻断出单。
//!
//! 这种「必须穷尽处理」的维度正是枚举的用武之地:将来若新增一个等级,
//! 所有 `match` 点都会编译失败,逼开发者逐一决定新等级的行为。
//! 若做成开放型结构体,新等级会静默落入某个 `_ =>` 兜底分支——
//! 那是 bug 的温床。
//!
//! ## 判据总结
//!
//! **只被存/传/显示 → 开放型结构体;决定行为分派 → 封闭枚举。**
use std::fmt;
/// 一次装配或校验过程中产生的事件的严重等级。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum EventSeverity {
/// 提示级:仅记录,不影响出单。
Info,
/// 警告级:需人工确认,但不阻断。
Warning,
/// 严重级:阻断出单。
Critical,
}
impl EventSeverity {
/// 返回该等级的中文短标签。
///
/// 用 `match` 而非查表:新增变体时本处会编译报错,
/// 这正是我们想要的「强制更新展示名称」。
pub const fn label(&self) -> &'static str {
match self {
EventSeverity::Info => "提示",
EventSeverity::Warning => "警告",
EventSeverity::Critical => "严重",
}
}
/// 该等级是否会阻断出单。
///
/// 这是本枚举的核心行为分派点:门面据此决定汇聚后的结果是
/// 「可出单」还是「被拒绝」。
pub const fn blocks_dispatch(&self) -> bool {
match self {
// 提示与警告都不阻断——警告留给人工判断,避免系统过度拦截。
EventSeverity::Info | EventSeverity::Warning => false,
// 只有严重级阻断。
EventSeverity::Critical => true,
}
}
/// 该等级对应的处理优先级(数值越大越紧急)。
///
/// 门面在汇总多个子系统的事件时按此排序,
/// 保证最严重的问题排在报表最前面,运营不必翻到最后才发现。
pub const fn priority(&self) -> u32 {
match self {
EventSeverity::Info => 0,
EventSeverity::Warning => 1,
EventSeverity::Critical => 2,
}
}
/// 在报表中使用的标记符号。
pub const fn marker(&self) -> &'static str {
match self {
EventSeverity::Info => "·",
EventSeverity::Warning => "!",
EventSeverity::Critical => "×",
}
}
}
impl fmt::Display for EventSeverity {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.label())
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : packaging_material.rs
//! 包装材料(开放型标签)。
//!
//! ## 为什么单独建模一种「材料」
//!
//! 包装子系统要按材料计费与估算体积。材料的属性(单位成本、是否可回收、
//! 是否属于温控耗材)会被门面汇总到成本里,也会被承运子系统用于体积重计算。
//!
//! ## 携带的属性
//!
//! - `unit_cost_minor_units`:每件材料成本(最小单位);
//! - `is_recyclable`:是否可回收(影响环保合规单据的勾选);
//! - `is_temperature_insulating`:是否为保温材料(决定能否用于温控货)。
//!
//! 三个属性都是数据。注意 `is_temperature_insulating` 会被包装子系统
//! **读取后判断**,但判断发生在子系统内部而非门面,所以仍是数据不是分派。
/// 一种包装材料。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PackagingMaterial {
/// 材料编码,如 `"WOODEN_CRATE"`。
code: &'static str,
/// 中文名称,如 `"木质礼盒"`。
label: &'static str,
/// 每件材料成本(最小单位:分)。
unit_cost_minor_units: i64,
/// 是否可回收。
is_recyclable: bool,
/// 是否为保温材料。
is_temperature_insulating: bool,
}
impl PackagingMaterial {
/// 构造一种包装材料。
pub const fn new(
code: &'static str,
label: &'static str,
unit_cost_minor_units: i64,
is_recyclable: bool,
is_temperature_insulating: bool,
) -> Self {
PackagingMaterial {
code,
label,
unit_cost_minor_units,
is_recyclable,
is_temperature_insulating,
}
}
/// 材料编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn label(&self) -> &'static str {
self.label
}
/// 每件材料成本(最小单位)。
pub const fn unit_cost_minor_units(&self) -> i64 {
self.unit_cost_minor_units
}
/// 是否可回收。
pub const fn is_recyclable(&self) -> bool {
self.is_recyclable
}
/// 是否为保温材料。
pub const fn is_temperature_insulating(&self) -> bool {
self.is_temperature_insulating
}
}
/// 木质礼盒:¥18.00/件,可回收,不保温。
pub const PACKAGING_WOODEN_CRATE: PackagingMaterial =
PackagingMaterial::new("WOODEN_CRATE", "木质礼盒", 1_800, true, false);
/// 防震泡沫箱:¥6.50/件,不可回收,保温。
pub const PACKAGING_FOAM_BOX: PackagingMaterial =
PackagingMaterial::new("FOAM_BOX", "防震泡沫箱", 650, false, true);
/// 真空铝箔袋:¥2.20/件,可回收,保温。
pub const PACKAGING_VACUUM_FOIL_BAG: PackagingMaterial =
PackagingMaterial::new("VACUUM_FOIL_BAG", "真空铝箔袋", 220, true, true);
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : ratio.rs
//! 比率(万分比)。
//!
//! ## 为什么单独做一个类型而不是直接用 f64
//!
//! 费率、税率、折扣率在报表里既要参与计算,又要**原样展示**
//! (如「7.50%」「3 万分之 30」)。若用 `f64`:
//!
//! - `0.075` 在内部无法精确表示,累乘几次后展示成 `7.499999999%`;
//! - 无法回答「这个比率是多少个万分点」——而那正是报表要印的数字。
//!
//! 用整数万分比(`basis_points`)后,展示与计算共用同一个整数,
//! 展示时按整数除余拼串,**全程不出现浮点**。
use super::currency_amount::CurrencyAmount;
/// 万分比的分母(与金额缩放共用同一口径)。
pub const BASIS_POINTS_DENOMINATOR: i64 = 10_000;
/// 一个比率,以万分比(basis points)存储。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Ratio {
/// 比率数值:10000 表示 100%,1 表示 0.01%。
basis_points: i64,
}
impl Ratio {
/// 以万分比构造。
///
/// 参数 `basis_points`:万分比数值(300 表示 3%)。
pub const fn from_basis_points(basis_points: i64) -> Self {
Ratio { basis_points }
}
/// 零比率。
///
/// 为什么提供一个显式的 `ZERO` 构造而不是让调用方写
/// `Ratio::from_basis_points(0)`:免税、免手续费这些**政策性的零**
/// 值得有个名字;将来若零值需要附带含义(如「免税」标记),
/// 只需改这一个构造点。
pub const fn zero() -> Self {
Ratio { basis_points: 0 }
}
/// 读取万分比数值。
pub const fn basis_points(&self) -> i64 {
self.basis_points
}
/// 是否为零。
pub const fn is_zero(&self) -> bool {
self.basis_points == 0
}
/// 判断该比率是否严格大于另一个比率。
///
/// 用于「实际费率是否被上浮」这类核查(承运商燃油附加费的封顶比较)。
pub const fn is_greater_than(&self, other: &Ratio) -> bool {
self.basis_points > other.basis_points
}
/// 把一个金额按本比率缩放出新金额。
///
/// 参数 `amount`:待缩放的金额。
/// 返回:缩放后的金额。
///
/// 本方法是**全工程唯一的比例缩放入口**:任何「乘以一个百分比」的运算
/// 都必须走这里,包括零比率。这样将来变更舍入口径(例如改成
/// 银行家舍入)只需改一处,不用去追散落各处的 `* 0.03`。
pub fn apply_to(&self, amount: &CurrencyAmount) -> CurrencyAmount {
amount.scale_by_basis_points(self.basis_points)
}
/// 返回百分比展示文本,如 `"7.50%"`。
///
/// 保留 2 位小数:业务上费率很少需要比万分之一更细的粒度,
/// 而 2 位小数(精确到万分之一)恰好与内部分母一致,不存在信息丢失。
pub fn as_percent_text(&self) -> String {
let absolute: i64 = self.basis_points.abs();
// 万分比转百分比:除以 100 得整数部分,余数即小数部分。
let whole_percent: i64 = absolute / 100;
let fraction: i64 = absolute % 100;
let sign: &str = if self.basis_points < 0 { "-" } else { "" };
format!("{}{}.{:02}%", sign, whole_percent, fraction)
}
/// 返回万分比的原始数值展示,如 `"750 万分点"`。
///
/// 报表里同时印百分比与万分点,是为了让对账同事能直接核对
/// 「系统里存的整数」——只印百分比会让人怀疑中间是否被浮点污染。
pub fn as_basis_points_text(&self) -> String {
format!("{} 万分点", self.basis_points)
}
/// 把该比率转换为「每单位量的金额」,即乘法因子本身。
///
/// 参数 `base_amount`:基数金额。
/// 返回:比率对应金额(与 [`Ratio::apply_to`] 同义,命名更贴近业务读法)。
///
/// 保留两个名字(`apply_to` / `of`)是有意的:
/// 报价单里更自然的读法是「保费的 0.3%」,写成 `ratio.of(&premium)`
/// 比 `ratio.apply_to(&premium)` 更贴近业务语言,能减少阅读时的翻译成本。
pub fn of(&self, base_amount: &CurrencyAmount) -> CurrencyAmount {
self.apply_to(base_amount)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : service_tier.rs
//! 服务等级(开放型标签)。
//!
//! ## 为什么是开放型
//!
//! 承运商的服务等级会不断新增(标准、加急、当日达、经济小包……),
//! 且每个等级携带的是一组**数值参数**(时效倍数、附加费率、是否可承诺时刻)。
//! 承运子系统只读取这些参数参与计算,不做 `match` 分派,
//! 因此按「只被存/传/计算 → 开放型」的判据,它应该是结构体。
//!
//! ## 为什么把「时效倍数」也做成数据
//!
//! 若把「加急 = 0.5 倍时效」写死在承运子系统的 `match` 里,
//! 那么新增一个「次日达 = 0.3 倍」就必须改子系统代码。
//! 把倍数放进标签后,承运子系统只做 `标准时效 × 倍数` 这一件事,
//! 新增等级零代码改动。
/// 一种承运服务等级。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ServiceTier {
/// 等级编码,如 `"EXPRESS"`。
code: &'static str,
/// 中文名称,如 `"加急"`。
label: &'static str,
/// 标准时效的倍数,以万分比表示(5000 = 0.5 倍时效)。
transit_days_multiplier_basis_points: i64,
/// 相对基础运费的附加费率(万分比,1200 = +12%)。
surcharge_basis_points: i64,
/// 是否承诺具体送达时刻(而非仅日期)。
guarantees_exact_time: bool,
}
impl ServiceTier {
/// 构造一个服务等级标签。
pub const fn new(
code: &'static str,
label: &'static str,
transit_days_multiplier_basis_points: i64,
surcharge_basis_points: i64,
guarantees_exact_time: bool,
) -> Self {
ServiceTier {
code,
label,
transit_days_multiplier_basis_points,
surcharge_basis_points,
guarantees_exact_time,
}
}
/// 等级编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn label(&self) -> &'static str {
self.label
}
/// 时效倍数(万分比)。10000 表示不变,5000 表示减半。
pub const fn transit_days_multiplier_basis_points(&self) -> i64 {
self.transit_days_multiplier_basis_points
}
/// 附加费率(万分比)。
pub const fn surcharge_basis_points(&self) -> i64 {
self.surcharge_basis_points
}
/// 是否承诺具体时刻。
pub const fn guarantees_exact_time(&self) -> bool {
self.guarantees_exact_time
}
}
/// 标准服务:时效不变、无附加费、不承诺时刻。
pub const SERVICE_TIER_STANDARD: ServiceTier =
ServiceTier::new("STANDARD", "标准", 10_000, 0, false);
/// 加急服务:时效 × 0.5、附加费 +12%、承诺时刻。
pub const SERVICE_TIER_EXPRESS: ServiceTier =
ServiceTier::new("EXPRESS", "加急", 5_000, 1_200, true);
/// 经济服务:时效 × 1.5、附加费 −8%(负值表示折扣)、不承诺时刻。
pub const SERVICE_TIER_ECONOMY: ServiceTier =
ServiceTier::new("ECONOMY", "经济", 15_000, -800, false);
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : shipping_weight.rs
//! 货物重量。
//!
//! ## 为什么用毫克
//!
//! 珠宝单件常以克表述(320 g),而物流计费以**公斤**为最小计费单位。
//! 若用克存,遇到「0.5 g」的金饰就要上浮点;若用公斤存,
//! 内部会充满 `0.00032` 这类值。
//!
//! 统一用**毫克**(i64)就可以覆盖从「毫克级宝石」到「吨级托盘」的全区间,
//! 且所有换算都是整数乘除。计费时再按「向上取整到公斤」处理,
//! 舍入点集中在一处,便于核算。
use super::currency_amount::divide_rounded_away_from_zero;
/// 一克包含的毫克数。
const MILLIGRAMS_PER_GRAM: i64 = 1_000;
/// 一公斤包含的毫克数。
const MILLIGRAMS_PER_KILOGRAM: i64 = 1_000_000;
/// 一件货物或一整票货物的重量,以毫克存储。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct ShippingWeight {
/// 重量数值,单位毫克。用 i64 是因为它同时要承载「单件 0.12 g」与「整车 32 t」。
milligrams: i64,
}
impl ShippingWeight {
/// 以毫克构造。
pub const fn from_milligrams(milligrams: i64) -> Self {
ShippingWeight { milligrams }
}
/// 以克构造(支持小数克,通过「克 + 毫克尾数」两个参数表达)。
///
/// 参数 `grams`:克整数部分;`extra_milligrams`:不足一克的毫克尾数。
///
/// 为什么要拆两个参数:`from_grams(320.5)` 会迫使调用方写浮点字面量,
/// 而浮点在域层是禁忌。拆开后调用方写 `from_grams_and_milligrams(320, 500)`,
/// 语义精确且无浮点。
pub const fn from_grams_and_milligrams(grams: i64, extra_milligrams: i64) -> Self {
ShippingWeight {
milligrams: grams * MILLIGRAMS_PER_GRAM + extra_milligrams,
}
}
/// 以整数克构造。
pub const fn from_grams(grams: i64) -> Self {
ShippingWeight {
milligrams: grams * MILLIGRAMS_PER_GRAM,
}
}
/// 零重量。
pub const fn zero() -> Self {
ShippingWeight { milligrams: 0 }
}
/// 读取毫克数。
pub const fn milligrams(&self) -> i64 {
self.milligrams
}
/// 是否为零。
pub const fn is_zero(&self) -> bool {
self.milligrams == 0
}
/// 是否严格大于另一个重量。
///
/// 为什么不用 `PartialOrd` 的 `>`:派生出来的 `Ord` 已经能用,
/// 但显式命名方法能让「承重上限校验」这类调用点读起来就是业务语言。
pub const fn is_greater_than(&self, other: &ShippingWeight) -> bool {
self.milligrams > other.milligrams
}
/// 相加。
pub const fn add(&self, other: &ShippingWeight) -> Self {
ShippingWeight {
milligrams: self.milligrams.saturating_add(other.milligrams),
}
}
/// 求和(对一个重量序列)。
///
/// 参数 `weights`:重量切片。
/// 返回:总重量。
///
/// 放在本类型上而不是让调用方写 `iter().fold(...)`:
/// 「整票总重」是各个子系统都要算的高频量(计费、承重校验、报关),
/// 集中一处可避免各处累加口径不一致(如有的漏算了包装重量)。
pub fn sum(weights: &[ShippingWeight]) -> Self {
let mut total: i64 = 0;
for weight in weights {
total = total.saturating_add(weight.milligrams());
}
ShippingWeight {
milligrams: total,
}
}
/// 按「每公斤单价」计费。
///
/// 参数 `rate_per_kilogram`:每公斤的费率(以最小单位表示,如分)。
/// 返回:计费金额的最小单位数值(未套币种)。
///
/// **关键取舍:计费重量是否向上取整到公斤**。
/// 本函数使用「按实际重量等比计费」(不足一公斤按比例算),
/// 因为珠宝样品单件的实际重量差异很大,向上取整会让 320 g 与 999 g
/// 收一样的钱,演示上不直观。真实快递公司的「首重 + 续重」规则
/// 属于**计价策略**,应由调用方传入费率来表达,不写死在本值对象里。
///
/// 返回值刻意是裸 i64 而不是 `CurrencyAmount`:本层不引入币种假设,
/// 由上层(承运子系统)决定这个数配上哪种币种。
pub fn charge_at_rate_per_kilogram(&self, rate_per_kilogram: i64) -> i64 {
// 中间量 i128:毫克 × 分 可达 1e14 量级,仍在 i64 内,
// 但为避免将来换用更大单位时溢出,统一走 i128。
let numerator: i128 = self.milligrams as i128 * rate_per_kilogram as i128;
let rounded: i128 =
divide_rounded_away_from_zero(numerator, MILLIGRAMS_PER_KILOGRAM as i128);
super::currency_amount::clamp_i128_to_i64(rounded)
}
/// 返回以克为单位的展示文本(保留 3 位小数,即克 + 毫克)。
///
/// 举例:`320500` 毫克 → `"320.500 g"`。
/// 固定 3 位小数是为了让报表里的重量列宽度稳定,便于对齐。
pub fn formatted_grams(&self) -> String {
let absolute: i64 = self.milligrams.abs();
let grams: i64 = absolute / MILLIGRAMS_PER_GRAM;
let milligram_remainder: i64 = absolute % MILLIGRAMS_PER_GRAM;
let sign: &str = if self.milligrams < 0 { "-" } else { "" };
format!(
"{}{}.{:03} g",
sign, grams, milligram_remainder
)
}
/// 返回以公斤为单位的展示文本(保留 3 位小数)。
///
/// 举例:`1045000` 毫克 → `"1.045 kg"`。
/// 保留 3 位小数意味着精确到克,这与「珠宝按克报价」的业务口径一致。
pub fn formatted_kilograms(&self) -> String {
let absolute: i64 = self.milligrams.abs();
let kilograms: i64 = absolute / MILLIGRAMS_PER_KILOGRAM;
// 剩余毫克换算成「公斤的小数部分」:克 * 1000 + 毫克,再补足 6 位。
let remaining_milligrams: i64 = absolute % MILLIGRAMS_PER_KILOGRAM;
// 把剩余毫克转成「千分之一公斤」单位:1 千分之一公斤 = 1000 毫克。
let thousandths: i64 = remaining_milligrams / 1_000;
let sign: &str = if self.milligrams < 0 { "-" } else { "" };
format!("{}{}.{:03} kg", sign, kilograms, thousandths)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : builtin_dispatch.rs
//! 内置调度端口:把 `dispatch` 子系统接到门面的 [`super::DispatchPort`] 契约上。
//!
//! # 适配器为什么要独立成文件
//!
//! 这个文件是**唯一**同时 import `dispatch` 与 `facade` 的地方。
//! 把适配职责集中在这里有两个好处:
//!
//! 1. 子系统内部文件完全不需要知道门面的存在,可独立测试与替换;
//! 2. 「门面依赖子系统」这条边的**具体位置**被压缩到少数几个适配器文件里,
//! 依赖脚本的输出因此非常干净:`dispatch` 不引用 `facade`,
//! 只有 `facade::builtin_*` 引用 `dispatch`。
//!
//! 这正是「严格分层」与「可替换」能同时成立的原因。
use std::cell::RefCell;
use crate::dispatch::capacity_ledger::CapacityLedger;
use crate::dispatch::carrier_option::CarrierOption;
use crate::dispatch::route_planner::{plan_route_legs, RoutePlanningRequest};
use crate::facade::shipping_facade::translate_carrier_option;
use crate::facade::subsystem_ports::{
CapacityPort, CarrierOptionView, DispatchInput, DispatchPort,
};
/// 内置调度端口。
///
/// ## 为什么用 `RefCell<CapacityLedger>`
///
/// [`DispatchPort`] 的 `plan_options` 接收 `&self`(门面只需读能力,
/// 不应给实现方强加可变性要求)。但运力台账在规划过程中需要**占用运力**
/// (有状态变更)。用 `RefCell` 在内部实现「内部可变性」,
/// 就能在 `&self` 的方法里更新台账。
///
/// 这是**单线程**场景下的正当用法(本工程不涉及并发)。
/// 若将来需要多线程,应换成 `Mutex`,且 trait 方法签名不变——
/// 这正是把可变性藏在实现内部的好处。
pub struct BuiltinDispatchPort {
/// 运力台账(内部可变)。
ledger: RefCell<CapacityLedger>,
}
impl BuiltinDispatchPort {
/// 用给定的运力台账构造端口。
pub fn new(ledger: CapacityLedger) -> Self {
BuiltinDispatchPort {
ledger: RefCell::new(ledger),
}
}
}
impl DispatchPort for BuiltinDispatchPort {
fn plan_options(&self, input: &DispatchInput) -> Vec<CarrierOptionView> {
// 构造调度子系统的请求结构体。
// 注意这里是「门面类型 → 子系统类型」的方向,
// 属于适配器应有的翻译职责。
let request = RoutePlanningRequest {
origin_city: input.origin_city,
origin_country: input.origin_country,
destination_city: input.destination_city,
destination_country: input.destination_country,
total_weight: input.chargeable_weight,
requires_temperature_control: input.requires_temperature_control,
service_tier: input.service_tier,
};
// 借用内部台账(可变)执行规划。
// 若在同一线程中发生重入借用,这里会 panic——本工程不会出现,
// 因为门面不会在规划过程中再次调用本端口。
let mut ledger_borrow = self.ledger.borrow_mut();
let planning_result = plan_route_legs(&request, &mut ledger_borrow);
// 把子系统的 CarrierOption 翻译成中立视图。
planning_result
.options
.iter()
.map(translate_carrier_option)
.collect()
}
}
/// 内置运力查询端口。
///
/// ## 为什么与 [`BuiltinDispatchPort`] 分开
///
/// 因为它们的**生命周期需求不同**:查询运力是只读的,
/// 而 [`BuiltinDispatchPort`] 持有 `RefCell` 内部可变状态。
/// 拆开后,只读场景不必触碰可变状态。
///
/// 代价是两者必须共享同一份台账。本实现的做法是:
/// 查询端口**不持有台账**,而是由门面在构造时传入同一个台账的共享引用。
/// 但 `CapacityLedger` 不是 `Rc`,为保持简单,
/// 本工程让查询端口也持有一份台账快照式的独立实例——
/// 见下方 `utilization` 字段的说明。
pub struct BuiltinCapacityPort {
/// 台账快照:记录构造时各承运商的占用率(万分比)。
/// 用「快照」而不是共享台账,避免为只读查询引入 `Rc<RefCell<..>>`
/// 这类共享可变状态(那是环状引用的高发地,见技能里的行为型陷阱)。
utilization_snapshot: Vec<(&'static str, i64)>,
}
impl BuiltinCapacityPort {
/// 用给定的占用率快照构造端口。
///
/// 参数 `utilization_snapshot`:(承运商代码, 占用率万分比)列表。
pub fn new(utilization_snapshot: Vec<(&'static str, i64)>) -> Self {
BuiltinCapacityPort {
utilization_snapshot,
}
}
/// 从台账构造端口(读取当前占用率作为快照)。
///
/// 参数 `ledger`:运力台账。
/// 返回:查询端口。
pub fn from_ledger(ledger: &CapacityLedger) -> Self {
let snapshot: Vec<(&'static str, i64)> = ledger
.snapshot()
.iter()
.map(|(carrier_code, _occupied, _limit)| {
let utilization: i64 = ledger
.utilization_basis_points(carrier_code)
.unwrap_or(0);
(*carrier_code, utilization)
})
.collect();
BuiltinCapacityPort::new(snapshot)
}
}
impl CapacityPort for BuiltinCapacityPort {
fn utilization_basis_points(&self, carrier_code: &str) -> i64 {
// 查不到即返回 0:未知承运商视为「无占用信息」,
// 而不是返回一个会触发误报的高值。
self.utilization_snapshot
.iter()
.find(|(code, _)| *code == carrier_code)
.map(|(_, utilization)| *utilization)
.unwrap_or(0)
}
}
/// 一个辅助函数:把 `CarrierOption` 的列表一次性翻译成视图。
///
/// 参数 `options`:承运方案列表。
/// 返回:中立视图列表。
///
/// 提取出来是为了让工程外实现也能复用同一套翻译,
/// 而不必自己重写(那会导致口径漂移)。
pub fn translate_options(options: &[CarrierOption]) -> Vec<CarrierOptionView> {
options.iter().map(translate_carrier_option).collect()
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : builtin_ports.rs
//! 内置包装 / 单据 / 承运 / 结算端口。
//!
//! ## 为什么四个适配器放在同一个文件
//!
//! 它们都极薄:把门面的中立输入翻译成子系统请求、调用、再把结果翻译回中立视图。
//! 每个大约十行。拆成四个文件会让导航成本超过收益。
//!
//! 而 [`super::builtin_dispatch`] 单独成文件,是因为它引入了
//! `RefCell` 内部可变状态——那值得一份独立的文档说明。
//! 文件划分依据是「职责与复杂度」,不是「角色的数量」。
use crate::carrier::timeline_builder::{build_timeline, TimelineRequest};
use crate::documents::document_compiler::{
compile_document_checklist, DocumentRequest,
};
use crate::packaging::packaging_planner::{plan_packaging, PackagingRequest};
use crate::settlement::settlement_ledger::settle_charges;
use crate::facade::shipping_facade::{
translate_document_requirement, translate_packaging_plan, translate_settlement,
translate_timeline,
};
use crate::facade::subsystem_ports::{
DocumentCompilationPort, DocumentInput, DocumentRequirementView, PackagingInput,
PackagingPlanView, PackagingPort, SettlementPort, SettlementView, TimelineInput, TimelinePort,
TimelineView,
};
/// 内置包装端口。
///
/// 无状态(包装规划是纯函数),因此是单元结构体。
/// 单元结构体(`struct X;`)而非「带空字段的结构体」:
/// 前者在类型层面就说明「没有状态」,读者不必去找有没有隐藏字段。
pub struct BuiltinPackagingPort;
impl PackagingPort for BuiltinPackagingPort {
fn plan_packaging(&self, input: &PackagingInput) -> PackagingPlanView {
let request = PackagingRequest {
jewelry_piece_count: input.jewelry_piece_count,
other_piece_count: input.other_piece_count,
requires_temperature_control: input.requires_temperature_control,
net_cargo_weight: input.net_cargo_weight,
};
// 调用子系统并翻译结果。
let plan = plan_packaging(&request);
translate_packaging_plan(&plan)
}
}
/// 内置单据编成端口。
pub struct BuiltinDocumentPort;
impl DocumentCompilationPort for BuiltinDocumentPort {
fn compile_checklist(&self, input: &DocumentInput) -> Vec<DocumentRequirementView> {
let request = DocumentRequest {
is_cross_border: input.is_cross_border,
requires_temperature_control: input.requires_temperature_control,
has_declarable_cargo: input.has_declarable_cargo,
has_high_value_cargo: input.has_high_value_cargo,
destination_country_code: input.destination_country_code,
destination_requires_customs_declaration: input.destination_requires_customs_declaration,
};
// 内置端口不追加额外规则:传空切片。
// 工程外实现可在这里传入自定义规则,这是「扩展不侵入」的口子。
let checklist = compile_document_checklist(&request, &[]);
checklist
.requirements()
.iter()
.map(translate_document_requirement)
.collect()
}
}
/// 内置承运(时间线)端口。
pub struct BuiltinTimelinePort;
impl TimelinePort for BuiltinTimelinePort {
fn build_timeline_view(&self, input: &TimelineInput) -> TimelineView {
let request = TimelineRequest {
dispatch_date: input.dispatch_date,
route_leg_days: input.route_leg_days.clone(),
route_leg_is_cross_border: input.route_leg_is_cross_border.clone(),
destination_city: input.destination_city,
};
let timeline = build_timeline(&request);
translate_timeline(&timeline)
}
}
/// 内置结算端口。
pub struct BuiltinSettlementPort;
impl SettlementPort for BuiltinSettlementPort {
fn settle(&self, items: Vec<super::subsystem_ports::SettlementInputItem>) -> SettlementView {
// 中立输入 → 子系统费用项,需要把来源字符串映射回枚举。
// 这个映射是适配器的职责;门面无需知道枚举存在。
let charge_items: Vec<crate::settlement::charge_item::ChargeItem> = items
.iter()
.map(|item| {
crate::settlement::charge_item::ChargeItem::new(
item.code,
item.label,
source_from_code(item.source_code),
item.amount,
item.basis_text,
)
})
.collect();
let result = settle_charges(charge_items);
translate_settlement(&result)
}
}
/// 把来源编码字符串映射回 [`crate::settlement::ChargeSource`]。
///
/// 参数 `source_code`:来源编码(如 `"FREIGHT"`)。
/// 返回:对应的来源枚举。
///
/// ## 未知编码如何处理(重要取舍)
///
/// **回退到增值服务费**而不是 panic 或 `Option`。理由:
/// - panic 会让门面因一个陌生的费用来源而整体崩溃,不可接受;
/// - `Option` 会迫使调用方写 `unwrap`,反而把处理责任推给了不需要关心的地方;
/// - 回退到「增值服务费」在报表上是**可见的**(会出现在服务费一栏),
/// 运营能立刻看出「这条费用归类可能不对」,
/// 而不会像静默丢弃那样无声无息。
///
/// 这是一次刻意的「宽松但可见」选择。
fn source_from_code(source_code: &str) -> crate::settlement::charge_item::ChargeSource {
match source_code {
"FREIGHT" => crate::settlement::charge_item::ChargeSource::Freight,
"PACKAGING" => crate::settlement::charge_item::ChargeSource::Packaging,
"DOCUMENTATION" => crate::settlement::charge_item::ChargeSource::Documentation,
// 其余(含 VALUE_ADDED_SERVICE 与任何未知编码)归入增值服务费。
_ => crate::settlement::charge_item::ChargeSource::ValueAddedService,
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : shipment_request.rs
//! 门面的输入:一票运输需求。
//!
//! ## 为什么门面要定义自己的输入类型,而不是直接收各子系统的参数
//!
//! 若门面签名是 `plan(origin, destination, weight, pieces, temp, tier, ...)`,
//! 那么「新增一个考虑因素」就要改门面签名,所有调用点跟着改。
//! 收一个请求结构体后,扩展只是给结构体加一个字段。
//!
//! 更重要的是:**这个请求类型属于门面,不属于任何子系统**。
//! 各子系统只看到自己需要的那部分(门面负责翻译),
//! 因此门面的输入结构演化不会传染给子系统。
use crate::domain::{CargoCategory, CountryCode, ServiceTier, ShippingWeight};
/// 货物清单中的一行。
///
/// 注意这里用 `CargoCategory`(开放型标签)而不是枚举:
/// 门面要能接受工程外新增的品类,若用枚举,
/// 「新增品类」就会变成「改门面的输入类型」,扩展性立刻丧失。
#[derive(Debug, Clone, Copy)]
pub struct CargoLine {
/// 货物名称(如「翡翠手镯」)。
pub name: &'static str,
/// 货物类别(开放型标签)。
pub category: CargoCategory,
/// 件数。
pub quantity: i64,
/// 单件重量。
pub unit_weight: ShippingWeight,
/// 单件申报价值(最小单位,分)。
pub unit_declared_value_minor_units: i64,
}
impl CargoLine {
/// 构造一行货物。
pub const fn new(
name: &'static str,
category: CargoCategory,
quantity: i64,
unit_weight: ShippingWeight,
unit_declared_value_minor_units: i64,
) -> Self {
CargoLine {
name,
category,
quantity,
unit_weight,
unit_declared_value_minor_units,
}
}
/// 该行总重量。
pub const fn total_weight(&self) -> ShippingWeight {
ShippingWeight::from_milligrams(self.unit_weight.milligrams())
}
/// 该行总重量(含件数)。
///
/// 与 [`CargoLine::total_weight`] 的区别:本方法乘件数。
/// 两个方法都存在是因为「单件重量」与「行总重」在报表里都要展示,
/// 显式区分比让调用方自己乘更不容易出错。
pub fn line_total_weight(&self) -> ShippingWeight {
// 先算单件毫克 × 件数(i64 乘法),再构造。
let total_milligrams: i64 = self
.unit_weight
.milligrams()
.saturating_mul(self.quantity);
ShippingWeight::from_milligrams(total_milligrams)
}
/// 该行总申报价值(最小单位)。
pub fn line_total_declared_value_minor_units(&self) -> i64 {
self.unit_declared_value_minor_units
.saturating_mul(self.quantity)
}
}
/// 一票运输需求(门面的输入)。
#[derive(Debug, Clone)]
pub struct ShipmentRequest {
/// 运单业务号(用于派生各类单号;由调用方提供以保证可追溯)。
pub shipment_reference: &'static str,
/// 寄件方名称。
pub sender_name: &'static str,
/// 寄件方国家/地区。
pub sender_country: CountryCode,
/// 寄件方城市。
pub sender_city: &'static str,
/// 收件方名称。
pub receiver_name: &'static str,
/// 收件方国家/地区。
pub receiver_country: CountryCode,
/// 收件方城市。
pub receiver_city: &'static str,
/// 计划发货日。
pub planned_dispatch_date: crate::support::calendar_date::CalendarDate,
/// 服务等级。
pub service_tier: ServiceTier,
/// 是否需要温控。
pub requires_temperature_control: bool,
/// 是否要求投保价险。
pub requires_insurance: bool,
/// 是否要求拆箱查验。
pub requires_inspection: bool,
/// 货物清单。
pub cargo_lines: Vec<CargoLine>,
}
impl ShipmentRequest {
/// 货物总件数。
pub fn total_piece_count(&self) -> i64 {
let mut total: i64 = 0;
for line in &self.cargo_lines {
total = total.saturating_add(line.quantity);
}
total
}
/// 货物净重(不含包装)。
pub fn net_cargo_weight(&self) -> ShippingWeight {
let mut total_milligrams: i64 = 0;
for line in &self.cargo_lines {
total_milligrams =
total_milligrams.saturating_add(line.line_total_weight().milligrams());
}
ShippingWeight::from_milligrams(total_milligrams)
}
/// 申报总价值(最小单位)。
pub fn total_declared_value_minor_units(&self) -> i64 {
let mut total: i64 = 0;
for line in &self.cargo_lines {
total = total.saturating_add(line.line_total_declared_value_minor_units());
}
total
}
/// 珠宝类件数(用于包装规划:决定用多少个硬质礼盒)。
pub fn jewelry_piece_count(&self) -> i64 {
let mut total: i64 = 0;
for line in &self.cargo_lines {
// 只统计「需要正式报关」的高值珠宝类。
// 这里用品类编码判断而非 `==` 比较对象,
// 使工程外新增的同类品类也能被正确归类。
if crate::packaging::packaging_planner::requires_rigid_packaging(line.category.code()) {
total = total.saturating_add(line.quantity);
}
}
total
}
/// 其他(非珠宝)件数。
pub fn other_piece_count(&self) -> i64 {
// 总件数减珠宝件数,保证两者之和恒等于总件数(不重不漏)。
self.total_piece_count() - self.jewelry_piece_count()
}
/// 是否包含需要正式报关的品类。
pub fn has_declarable_cargo(&self) -> bool {
self.cargo_lines
.iter()
.any(|line| line.category.requires_formal_declaration())
}
/// 是否包含高值货(申报总价值达到阈值)。
///
/// 阈值取自 [`HIGH_VALUE_THRESHOLD_MINOR_UNITS`],
/// 之所以做成常量而不是散落的字面量,是为了让报表文案与判断逻辑
/// 引用同一个数,不会出现「判断用 5 万、文案写 8 万」的不一致。
pub fn has_high_value_cargo(&self) -> bool {
self.total_declared_value_minor_units() >= HIGH_VALUE_THRESHOLD_MINOR_UNITS
}
/// 是否为跨境运输。
pub fn is_cross_border(&self) -> bool {
self.sender_country.code() != self.receiver_country.code()
}
}
/// 高值货判定阈值(最小单位:分),即 ¥50,000.00。
///
/// 放在模块级而不是塞进方法体:单据子系统与报表都要引用它,
/// 集中一处才能保证「判断」与「文案」永远一致。
pub const HIGH_VALUE_THRESHOLD_MINOR_UNITS: i64 = 5_000_000;
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : shipping_facade.rs
//! # ShippingFacade —— 门面本体
//!
//! ## 它做的唯一一件事
//!
//! 把「一票运输需求」变成「一份可执行的装配结果」,
//! 中间按正确顺序协调五个子系统,并把它们的输出**翻译**成门面自己的视图类型。
//!
//! ## 为什么门面的核心方法很长(一个方法几百行)
//!
//! 门面的「编排顺序」本身就是它的核心知识,拆成多个私有方法反而会
//! 把「先包装、再按包装后实测重量去调度、最后结算」这个**顺序约束**
//! 打散到多个地方,读者要跳来跳去才能拼出全貌。
//!
//! 因此这里刻意保留一条**线性叙事的主流程**,只在真正的独立计算上
//! 拆出私有辅助方法(如事件汇聚、单据翻译)。可读性优先于方法长度。
//!
//! ## 门面持有什么
//!
//! 五个**端口特征对象**(`Box<dyn ...Port>`)+ 一个运力台账端口。
//! 它**不持有**任何具体子系统类型——这是「子系统可整体替换」的结构保证。
//! 具体绑定发生在构造处([`ShippingFacade::with_default_subsystems`]),
//! 而不在这个文件里硬编码。
use crate::carrier::delivery_rules::adjust_dispatch_date_for_weekend;
use crate::dispatch::CarrierOption;
use crate::documents::document_checklist::DocumentRequirement;
use crate::domain::{
CurrencyAmount, EventSeverity, Ratio, CURRENCY_CHINESE_YUAN,
};
use crate::packaging::packaging_plan::PackagingPlan;
use crate::settlement::charge_item::{ChargeItem, ChargeSource};
use crate::support::deterministic_code::{build_seed, derive_code, short_fingerprint};
use super::shipment_request::{ShipmentRequest, HIGH_VALUE_THRESHOLD_MINOR_UNITS};
use super::shipping_result::{
DocumentView, FacadeEvent, PackagingView, RouteLegView, ShipmentOutcome, ShipmentTimelineView,
ShippingResult,
};
use super::subsystem_ports::{
CapacityPort, CarrierOptionView, DispatchInput, DispatchPort, DocumentCompilationPort,
DocumentInput, DocumentRequirementView, PackagingInput, PackagingPort, SettlementInputItem,
SettlementPort, SettlementView, TimelineInput, TimelinePort,
};
/// 运费中「单据工本费」的每张单价(最小单位:分),¥12.00。
///
/// 这是一个**门面层面的商务参数**——它不属于任何一个子系统:
/// 单据子系统知道「要哪些单」,但不该知道「每张收多少钱」(那是定价)。
/// 放在门面里,意味着调价只需改门面这一处。
const DOCUMENT_ISSUANCE_FEE_MINOR_UNITS: i64 = 1_200;
/// 保价费率:万分之 30(即 0.30%)。
///
/// 同样属于门面的商务参数。用 [`Ratio`] 表达而非字面量,
/// 是为了走全工程统一的缩放路径(含统一的舍入口径)。
const INSURANCE_RATE_BASIS_POINTS: i64 = 30;
/// 拆箱查验费:每件 ¥35.00。
const INSPECTION_FEE_PER_PIECE_MINOR_UNITS: i64 = 3_500;
/// 运力占用率超过此值时,门面追加一条「运力紧张」提醒(万分比,8500 = 85%)。
///
/// 注意这条规则**是门面的规则**而不是子系统的:
/// 子系统只负责报出占用率,至于「多少算紧张」是门面对整体风险的判断。
/// 把判断留在门面,各家承运商就可以用同一份子系统实现,
/// 而门面按自己的风控口径决定提醒与否。
const CAPACITY_TIGHTNESS_THRESHOLD_BASIS_POINTS: i64 = 8_500;
/// 门面:协调五个子系统完成一票运单的装配。
pub struct ShippingFacade {
/// 调度端口。
dispatch_port: Box<dyn DispatchPort>,
/// 包装端口。
packaging_port: Box<dyn PackagingPort>,
/// 单据端口。
document_port: Box<dyn DocumentCompilationPort>,
/// 承运(时间线)端口。
timeline_port: Box<dyn TimelinePort>,
/// 结算端口。
settlement_port: Box<dyn SettlementPort>,
/// 运力台账端口。
capacity_port: Box<dyn CapacityPort>,
}
impl ShippingFacade {
/// 用一组端口构造门面。
///
/// 参数为六个端口实现。
///
/// ## 为什么构造参数这么多却不收一个「端口包」结构体
///
/// 收一个结构体看起来更整洁,但那会引入一个「必须与门面同步演化」的类型。
/// 六个参数虽多,却让「门面依赖哪些能力」在签名上一览无余——
/// 这对一个示范工程来说比少写几个参数更有价值。
/// 调用方若嫌麻烦,可自行封装一个工厂(本工程在
/// [`ShippingFacade::with_default_subsystems`] 里做了示范)。
pub fn new(
dispatch_port: Box<dyn DispatchPort>,
packaging_port: Box<dyn PackagingPort>,
document_port: Box<dyn DocumentCompilationPort>,
timeline_port: Box<dyn TimelinePort>,
settlement_port: Box<dyn SettlementPort>,
capacity_port: Box<dyn CapacityPort>,
) -> Self {
ShippingFacade {
dispatch_port,
packaging_port,
document_port,
timeline_port,
settlement_port,
capacity_port,
}
}
/// 用本工程内置的五个子系统构造门面(便捷构造)。
///
/// 返回:接好内置子系统的门面。
///
/// 这个函数是**唯一**知道「内置子系统具体是什么类型」的地方。
/// 若把它删掉,本文件的其余部分对具体子系统一无所知——
/// 这正是我们想要的结构。
pub fn with_default_subsystems(ledger: crate::dispatch::CapacityLedger) -> Self {
// 先用台账构造调度端口(它会占用运力),再据此生成运力快照端口。
let dispatch_port = crate::facade::builtin_dispatch::BuiltinDispatchPort::new(ledger);
ShippingFacade::new(
Box::new(dispatch_port),
Box::new(crate::facade::builtin_ports::BuiltinPackagingPort),
Box::new(crate::facade::builtin_ports::BuiltinDocumentPort),
Box::new(crate::facade::builtin_ports::BuiltinTimelinePort),
Box::new(crate::facade::builtin_ports::BuiltinSettlementPort),
// 运力快照端口:本工程演示中由调用方另行注入以体现「运力紧张」,
// 这里给一个空快照作为默认(查询任何承运商都得到 0 占用率)。
Box::new(crate::facade::builtin_dispatch::BuiltinCapacityPort::new(
Vec::new(),
)),
)
}
/// 用内置子系统构造门面,并显式注入运力快照。
///
/// 参数 `ledger`:运力台账;`capacity_port`:运力查询端口。
/// 返回:门面。
///
/// 与 [`ShippingFacade::with_default_subsystems`] 的区别:
/// 本函数允许调用方传入**自定义的运力快照**,
/// 用于演示「同一套子系统在不同运力状况下给出不同提醒」。
/// 这正是端口化带来的灵活性——换一个端口实现,门面代码零改动。
pub fn with_capacity_snapshot(
ledger: crate::dispatch::CapacityLedger,
capacity_port: Box<dyn CapacityPort>,
) -> Self {
ShippingFacade::new(
Box::new(crate::facade::builtin_dispatch::BuiltinDispatchPort::new(ledger)),
Box::new(crate::facade::builtin_ports::BuiltinPackagingPort),
Box::new(crate::facade::builtin_ports::BuiltinDocumentPort),
Box::new(crate::facade::builtin_ports::BuiltinTimelinePort),
Box::new(crate::facade::builtin_ports::BuiltinSettlementPort),
capacity_port,
)
}
/// 装配一票运单(门面的核心用例方法)。
///
/// 参数 `request`:运输需求。
/// 返回:装配结论(成功或拒绝,均携带完整结果)。
///
/// ## 编排顺序(这是门面的核心知识,顺序不可随意调换)
///
/// 1. **包装先行**:因为包装会增重,而调度必须按「含包装的重量」计费,
/// 否则运费会少算。若先调度再包装,就得重新调度一次——
/// 那就说明顺序设计错了。
/// 2. **调度**:拿到候选方案,选首个可行者。
/// 3. **时间线**:需要调度给出的路由段天数才能推算。
/// 4. **单据**:需要「是否跨境」「是否有可申报品类」等信息,
/// 而这些在调度完成后才是确定的(路由决定了跨境与否)。
/// 5. **结算**:需要前面所有环节的费用项。
/// 6. **事件汇聚**:把各环节发现的问题统一成事件列表。
///
/// 每一步之间没有反向依赖,因此这条链可以线性走完。
pub fn plan_shipment(&self, request: &ShipmentRequest) -> ShipmentOutcome {
// ---------- 步骤 0:基础量(净重、申报价值、件数) ----------
let net_cargo_weight = request.net_cargo_weight();
let total_declared_value_minor_units = request.total_declared_value_minor_units();
let total_declared_value: CurrencyAmount = CurrencyAmount::from_minor_units(
total_declared_value_minor_units,
CURRENCY_CHINESE_YUAN,
);
// ---------- 步骤 1:包装(先于调度,见方法文档) ----------
let packaging_view = self.packaging_port.plan_packaging(&PackagingInput {
jewelry_piece_count: request.jewelry_piece_count(),
other_piece_count: request.other_piece_count(),
requires_temperature_control: request.requires_temperature_control,
net_cargo_weight,
});
// ---------- 步骤 2:调度(按含包装重量计费) ----------
let dispatch_input = DispatchInput {
origin_city: request.sender_city,
origin_country: request.sender_country,
destination_city: request.receiver_city,
destination_country: request.receiver_country,
chargeable_weight: packaging_view.total_weight_with_packaging,
requires_temperature_control: request.requires_temperature_control,
service_tier: request.service_tier,
};
let candidate_options: Vec<CarrierOptionView> =
self.dispatch_port.plan_options(&dispatch_input);
// 选首个可行方案。端口实现约定「可行优先、报价升序」,
// 因此这里取首个可行者即最优可行方案。
let chosen_option: Option<CarrierOptionView> = candidate_options
.iter()
.find(|option| option.is_feasible)
.cloned();
// 若没有任何可行方案,仍要产出一份完整结果(含事件),
// 而不是中途 return——门面的重要契约是「永远给出可解释的结论」。
// 这里用一个「占位方案」承载后续计算所需的形状。
let option: CarrierOptionView = match chosen_option {
Some(found) => found,
None => self.build_placeholder_option(&candidate_options, &dispatch_input),
};
// ---------- 步骤 3:时间线 ----------
let timeline_view = self.timeline_port.build_timeline_view(&TimelineInput {
dispatch_date: request.planned_dispatch_date,
route_leg_days: option.route_leg_days.clone(),
route_leg_is_cross_border: option.route_leg_is_cross_border.clone(),
destination_city: request.receiver_city,
});
// ---------- 步骤 4:单据 ----------
let is_cross_border: bool = option.route_leg_is_cross_border.iter().any(|flag| *flag);
let document_requirement_views: Vec<DocumentRequirementView> =
self.document_port.compile_checklist(&DocumentInput {
is_cross_border,
requires_temperature_control: request.requires_temperature_control,
has_declarable_cargo: request.has_declarable_cargo(),
has_high_value_cargo: request.has_high_value_cargo(),
destination_country_code: request.receiver_country.code(),
destination_requires_customs_declaration: request
.receiver_country
.requires_customs_declaration(),
});
// ---------- 步骤 5:结算(归集各来源费用) ----------
let settlement_input_items: Vec<SettlementInputItem> = self.build_charge_items(
request,
&packaging_view,
&option,
&document_requirement_views,
&total_declared_value,
);
let settlement_view: SettlementView = self.settlement_port.settle(settlement_input_items);
// ---------- 步骤 6:事件汇聚 ----------
let events: Vec<FacadeEvent> = self.collect_events(
request,
&option,
&candidate_options,
&packaging_view,
&document_requirement_views,
&settlement_view,
total_declared_value_minor_units,
);
// ---------- 步骤 7:派生标识 ----------
// 运单号由「业务号 + 收件方 + 承运商」派生:三者任一变化即换号,
// 且同一输入永远得到同一个号(演示可复现)。
let waybill_seed: String = build_seed(&[
request.shipment_reference,
request.receiver_name,
request.receiver_city,
option.carrier_code,
]);
let waybill_number: String = derive_code("WB", &waybill_seed, 12);
// 指纹用于报表比对同一票货的不同阶段快照。
let fingerprint: String = short_fingerprint(&build_seed(&[
request.shipment_reference,
&option.carrier_code,
&total_declared_value_minor_units.to_string(),
]));
// ---------- 步骤 8:组装结果 ----------
let result = ShippingResult {
shipment_reference: request.shipment_reference,
waybill_number,
fingerprint,
sender_text: format!(
"{}({} {} · {})",
request.sender_name,
request.sender_country.code(),
request.sender_country.label(),
request.sender_city
),
receiver_text: format!(
"{}({} {} · {})",
request.receiver_name,
request.receiver_country.code(),
request.receiver_country.label(),
request.receiver_city
),
carrier_code: option.carrier_code,
carrier_label: option.carrier_label,
service_tier_label: request.service_tier.label(),
route_summary: option.route_summary.clone(),
route_legs: self.translate_route_legs(&option),
packaging: PackagingView {
material_summary: self.summarize_material_lines(&packaging_view),
material_kind_count: packaging_view.material_lines.len(),
total_material_cost: packaging_view.total_material_cost,
weight_gain: packaging_view.weight_gain,
total_weight_with_packaging: packaging_view.total_weight_with_packaging,
uses_insulating_material: packaging_view.uses_insulating_material,
},
documents: document_requirement_views
.iter()
.map(|requirement| DocumentView {
kind: requirement.kind,
status_label: requirement.status_label,
is_mandatory: requirement.is_mandatory,
blocks_dispatch: requirement.blocks_dispatch,
trigger_reason: requirement.trigger_reason,
})
.collect(),
timeline: ShipmentTimelineView {
milestones: timeline_view
.milestones
.iter()
.map(|milestone| {
(milestone.code, milestone.label, milestone.date, milestone.note)
})
.collect(),
estimated_delivery_date: timeline_view.estimated_delivery_date,
delayed_by_weekend: timeline_view.delayed_by_weekend,
},
charge_items: settlement_view
.items
.iter()
.map(|item| {
(
item.code,
item.label,
item.source_label,
item.amount,
item.basis_text,
)
})
.collect(),
grand_total: settlement_view.grand_total,
largest_item_code: settlement_view.largest_item_code,
cargo_lines: request
.cargo_lines
.iter()
.map(|line| {
(
line.name,
line.category.label(),
line.quantity,
line.unit_weight,
CurrencyAmount::from_minor_units(
line.unit_declared_value_minor_units,
CURRENCY_CHINESE_YUAN,
),
)
})
.collect(),
events,
net_cargo_weight,
total_declared_value,
};
// 按事件是否阻断决定返回形态。
if result.is_blocked() {
ShipmentOutcome::Rejected {
reason: format!(
"存在 {} 条严重级问题,装配已拒绝",
result.blocker_count()
),
result: Box::new(result),
}
} else {
ShipmentOutcome::Planned(Box::new(result))
}
}
/// 在没有可行方案时构造一个「占位方案」。
///
/// 参数 `candidates`:全部候选(可能为空);`input`:调度输入。
/// 返回:可承载后续计算(时间线、结算)的占位方案。
///
/// 为什么要占位而不是直接返回错误:
/// 门面的契约是「无论成败都给出一份完整、可解释的结果」。
/// 若中途返回,调用方就拿不到「为什么不行」「其他费用是多少」,
/// 运营只能看到一个空白页。占位方案让所有后续步骤仍能执行,
/// 报表上会把「无可行承运方案」作为一条严重级事件明确列出。
fn build_placeholder_option(
&self,
candidates: &[CarrierOptionView],
input: &DispatchInput,
) -> CarrierOptionView {
// 优先用第一个候选(即使不可行)的信息,因为它的路由与报价
// 对用户仍有参考价值(「最接近可行的那一档差多少」)。
if let Some(first) = candidates.first() {
return first.clone();
}
// 连候选都没有(端口实现完全给不出方案):构造一个空壳。
// 此时路由段为空,时间线会退化为「发货日即达(含周末顺延)」。
let _ = self.capacity_port.utilization_basis_points("");
CarrierOptionView {
carrier_code: "NONE",
carrier_label: "无可用承运商",
freight_charge: CurrencyAmount::zero(CURRENCY_CHINESE_YUAN),
route_leg_days: Vec::new(),
route_leg_is_cross_border: Vec::new(),
route_summary: "(无路由)".to_string(),
visited_country_codes: vec![input.origin_country.code()],
is_feasible: false,
infeasibility_reason: "端口未返回任何候选承运方案",
}
}
/// 把调度返回的路由段信息翻译成门面的 [`RouteLegView]` 列表。
///
/// 参数 `option`:承运方案中立视图。
/// 返回:路由段视图列表。
///
/// 注意这里只能重建「天数」与「是否跨境」,因为端口契约刻意只传了这些。
/// **城市名等信息不在契约里**——若报表需要,应向端口契约追加字段,
/// 而不是让门面去猜。这个限制是有意的:它逼着契约承担起表达力责任,
/// 而不是让门面偷偷依赖实现细节。
fn translate_route_legs(&self, option: &CarrierOptionView) -> Vec<RouteLegView> {
let mut views: Vec<RouteLegView> = Vec::with_capacity(option.route_leg_days.len());
// 途经国家列表来自契约,用于给每个段标注起终点国家。
let visited: &[&'static str] = &option.visited_country_codes;
for (index, days) in option.route_leg_days.iter().enumerate() {
let is_cross_border: bool = option
.route_leg_is_cross_border
.get(index)
.copied()
.unwrap_or(false);
// 起点国家取途经列表中的第 index 个(若不足则回退到第一个)。
let origin_country_code: &'static str = visited
.get(index)
.copied()
.or_else(|| visited.first().copied())
.unwrap_or("--");
// 终点国家取下一个(若已到末尾则取最后一个)。
let destination_country_code: &'static str = visited
.get(index + 1)
.copied()
.or_else(|| visited.last().copied())
.unwrap_or("--");
views.push(RouteLegView {
sequence_number: (index as u32) + 1,
// 城市名不在端口契约内,用序号化描述代替,避免编造数据。
origin_city: if index == 0 { "起运地" } else { "中转地" },
origin_country_code,
destination_city: if index + 1 == option.route_leg_days.len() {
"目的地"
} else {
"中转地"
},
destination_country_code,
transport_mode_label: if is_cross_border { "跨境干线" } else { "境内接驳" },
transit_days: *days,
is_cross_border,
});
}
views
}
/// 汇总材料行的展示文本。
fn summarize_material_lines(
&self,
packaging_view: &super::subsystem_ports::PackagingPlanView,
) -> String {
if packaging_view.material_lines.is_empty() {
return "(无需包装材料)".to_string();
}
let mut summary: String = String::new();
for (position, line) in packaging_view.material_lines.iter().enumerate() {
if position > 0 {
summary.push('、');
}
summary.push_str(&format!("{} × {}", line.material_label, line.quantity));
}
summary
}
/// 构造各来源的费用项(门面的翻译职责)。
///
/// 参数 `request` / `packaging_view` / `option` /
/// `document_requests` / `total_declared_value`。
/// 返回:结算端口的中立输入项列表。
///
/// ## 这个函数是门面模式的精华所在
///
/// 五个子系统各自产出自己的量(运费、包装费、单据数、服务项),
/// 只有门面同时看得见它们,因此**只有门面能完成这次翻译**。
/// 若把这段逻辑下放到任何子系统,那个子系统就必须认识其他子系统,
/// 横向耦合随之出现。这就是「门面不是简单转发器」的具体证据。
fn build_charge_items(
&self,
request: &ShipmentRequest,
packaging_view: &super::subsystem_ports::PackagingPlanView,
option: &CarrierOptionView,
document_requests: &[DocumentRequirementView],
total_declared_value: &CurrencyAmount,
) -> Vec<SettlementInputItem> {
let mut items: Vec<SettlementInputItem> = Vec::new();
// ---------- 运费 ----------
items.push(SettlementInputItem {
code: "FREIGHT",
label: "基础运费",
source_code: "FREIGHT",
source_label: ChargeSource::Freight.label(),
amount: option.freight_charge,
basis_text: "含包装后总重 × 承运商费率 × 服务等级调整",
});
// ---------- 包装材料费 ----------
if !packaging_view.total_material_cost.is_zero() {
items.push(SettlementInputItem {
code: "PACKAGING_MATERIAL",
label: "包装材料费",
source_code: "PACKAGING",
source_label: ChargeSource::Packaging.label(),
amount: packaging_view.total_material_cost,
basis_text: "各材料单价 × 用量之和",
});
}
// ---------- 单据工本费 ----------
// 按「实际会出具的单据张数」计费:豁免项不计费。
let billable_document_count: i64 = document_requests
.iter()
// 只有非「豁免」状态的单据才收费。
.filter(|requirement| requirement.status_label != "已豁免")
.count() as i64;
if billable_document_count > 0 {
let document_fee: CurrencyAmount =
CurrencyAmount::from_minor_units(DOCUMENT_ISSUANCE_FEE_MINOR_UNITS, CURRENCY_CHINESE_YUAN)
.multiply_by_quantity(billable_document_count);
items.push(SettlementInputItem {
code: "DOCUMENTATION",
label: "单据工本费",
source_code: "DOCUMENTATION",
source_label: ChargeSource::Documentation.label(),
amount: document_fee,
basis_text: "按出具单据张数 × 每张 ¥12.00 计费",
});
}
// ---------- 增值服务:保价 ----------
if request.requires_insurance {
let insurance_amount: CurrencyAmount =
Ratio::from_basis_points(INSURANCE_RATE_BASIS_POINTS).of(total_declared_value);
items.push(SettlementInputItem {
code: "INSURANCE",
label: "保价服务",
source_code: "VALUE_ADDED_SERVICE",
source_label: ChargeSource::ValueAddedService.label(),
amount: insurance_amount,
basis_text: "申报总价值 × 0.30%",
});
}
// ---------- 增值服务:拆箱查验 ----------
if request.requires_inspection {
let inspection_fee: CurrencyAmount = CurrencyAmount::from_minor_units(
INSPECTION_FEE_PER_PIECE_MINOR_UNITS,
CURRENCY_CHINESE_YUAN,
)
.multiply_by_quantity(request.total_piece_count());
items.push(SettlementInputItem {
code: "INSPECTION",
label: "拆箱查验",
source_code: "VALUE_ADDED_SERVICE",
source_label: ChargeSource::ValueAddedService.label(),
amount: inspection_fee,
basis_text: "按总件数 × 每件 ¥35.00 计费",
});
}
items
}
/// 汇聚各子系统的问题,形成统一的事件列表。
///
/// 参数为各环节的输出。
/// 返回:事件列表。
///
/// ## 「一次报全」在这里体现
///
/// 本函数**线性走完所有检查**,从不提前 return。
/// 因此一票货的所有问题会一次性出现在同一份报表里。
/// 对运营而言,这意味着「改一次就能提交」而不是「提交八轮」。
#[allow(clippy::too_many_arguments)]
fn collect_events(
&self,
request: &ShipmentRequest,
option: &CarrierOptionView,
candidates: &[CarrierOptionView],
packaging_view: &super::subsystem_ports::PackagingPlanView,
document_requests: &[DocumentRequirementView],
settlement_view: &SettlementView,
total_declared_value_minor_units: i64,
) -> Vec<FacadeEvent> {
let mut events: Vec<FacadeEvent> = Vec::new();
// ---------- 调度环节 ----------
if candidates.is_empty() {
events.push(FacadeEvent::new(
"调度",
EventSeverity::Critical,
"没有任何承运方案可供选择".to_string(),
"联系调度部门确认承运商接入状态",
));
} else if !option.is_feasible && !candidates.iter().any(|candidate| candidate.is_feasible) {
// 全部不可行:把每个候选的原因都列出来(一次报全)。
let reasons: String = candidates
.iter()
.map(|candidate| format!("{}:{}", candidate.carrier_label, candidate.infeasibility_reason))
.collect::<Vec<String>>()
.join(";");
events.push(FacadeEvent::new(
"调度",
EventSeverity::Critical,
format!("全部 {} 个候选承运方案均不可行({})", candidates.len(), reasons),
"调整运输方式、拆分货物或改换温控方案",
));
} else if option.is_feasible {
// 运力紧张提示:占用率超过门面设定的阈值。
let utilization: i64 = self
.capacity_port
.utilization_basis_points(option.carrier_code);
if utilization >= CAPACITY_TIGHTNESS_THRESHOLD_BASIS_POINTS {
events.push(FacadeEvent::new(
"调度",
EventSeverity::Warning,
format!(
"承运商「{}」运力占用率已达 {}.{:02}%,舱位可能紧张",
option.carrier_label,
utilization / 100,
utilization % 100
),
"建议提前订舱或准备备选承运商",
));
}
}
// ---------- 包装环节 ----------
if request.requires_temperature_control && !packaging_view.uses_insulating_material {
events.push(FacadeEvent::new(
"包装",
EventSeverity::Critical,
"本票要求温控,但包装方案未使用保温材料".to_string(),
"改用保温包装材料或取消温控要求",
));
}
// 包装增重提示(信息级):让客户知道计费重量的构成。
if packaging_view.weight_gain.milligrams() > 0 {
events.push(FacadeEvent::new(
"包装",
EventSeverity::Info,
format!(
"包装增重 {},已计入计费重量",
packaging_view.weight_gain.formatted_grams()
),
"如需降低运费可减少包装层数",
));
}
// ---------- 单据环节 ----------
for requirement in document_requests {
// 只有「会阻断」的项才产生严重级事件;
// 其余(已具备)不必逐条报,否则报表会被无信息量的行填满。
if requirement.blocks_dispatch {
events.push(FacadeEvent::new(
"单据",
EventSeverity::Critical,
format!(
"必备单据「{}」缺失:{}",
requirement.kind.label(),
requirement.trigger_reason
),
"补齐该单据或申请豁免",
));
}
}
// ---------- 结算环节 ----------
// 高值货未投保:警告级(不阻断,但风控上应确认)。
let insurance_present: bool = settlement_view
.items
.iter()
.any(|item| item.code == "INSURANCE");
if total_declared_value_minor_units >= HIGH_VALUE_THRESHOLD_MINOR_UNITS && !insurance_present
{
events.push(FacadeEvent::new(
"结算",
EventSeverity::Warning,
format!(
"申报总价值已达高价值线 ¥{},但未投保价服务",
format_minor_units_as_yuan(HIGH_VALUE_THRESHOLD_MINOR_UNITS)
),
"如为贵重货品,建议追加保价服务",
));
}
// 应付总额为负(折扣超过费用):属数据异常,需人工核查。
if settlement_view.grand_total.is_negative() {
events.push(FacadeEvent::new(
"结算",
EventSeverity::Warning,
"应付总额为负值,可能存在折扣配置错误".to_string(),
"核查折扣与费率配置",
));
}
events
}
}
/// 把最小单位金额格式化为「元」的整数字符串(用于文案中的阈值展示)。
///
/// 参数 `minor_units`:最小单位数值。
/// 返回:如 `"50,000.00"` 的文本(不含货币符号)。
///
/// 这是一个**自由函数**而非方法:它不需要门面的任何状态,
/// 且被事件文案与报表共用,放在模块级便于两边引用同一个实现
/// (避免文案里的数字与判断用的阈值不一致)。
pub fn format_minor_units_as_yuan(minor_units: i64) -> String {
// 复用 domain 的格式化能力,确保与报表中的金额格式完全一致。
CurrencyAmount::from_minor_units(minor_units, CURRENCY_CHINESE_YUAN).formatted()
}
/// 一个便捷的自由函数:生成标准运单号(供工程外扩展区复用)。
///
/// 参数 `shipment_reference` / `carrier_code`。
/// 返回:运单号。
///
/// 之所以把它暴露成自由函数而不是让工程外自己写派生逻辑:
/// 保证外部生成的号与门面内部的号**同一套算法**,
/// 否则两边会产出不同的号,报表比对就失真了。
pub fn derive_waybill_number(shipment_reference: &str, carrier_code: &str) -> String {
derive_code(
"WB",
&build_seed(&[shipment_reference, carrier_code]),
12,
)
}
/// 一个便捷的自由函数:把内部包装方案转成门面的包装视图。
///
/// 参数 `plan`:包装子系统的方案。
/// 返回:门面的包装视图。
///
/// 存在的理由:工程外扩展区需要构造与内置实现**形状一致**的端口实现,
/// 而它们往往想复用内置包装方案的翻译逻辑。暴露这个函数后,
/// 外部实现可以「用内置方案 + 自定义端口」组合,而不必重写翻译。
pub fn translate_packaging_plan(plan: &PackagingPlan) -> super::subsystem_ports::PackagingPlanView {
super::subsystem_ports::PackagingPlanView {
material_lines: plan
.lines()
.iter()
.map(|line| super::subsystem_ports::PackagingMaterialLineView {
material_label: line.material().label(),
material_code: line.material().code(),
quantity: line.quantity(),
line_cost: line.line_cost(),
})
.collect(),
total_material_cost: plan.total_material_cost(),
weight_gain: plan.packaging_weight_gain(),
total_weight_with_packaging: plan.total_weight_with_packaging(),
uses_insulating_material: plan.uses_insulating_material(),
}
}
/// 一个便捷的自由函数:把内部承运方案转成中立视图。
///
/// 参数 `option`:调度子系统的候选方案。
/// 返回:中立视图。
///
/// 与 [`translate_packaging_plan`] 同理:内置端口实现与工程外实现
/// 都可以复用它,保证「翻译口径」只有一处。
pub fn translate_carrier_option(option: &CarrierOption) -> CarrierOptionView {
CarrierOptionView {
carrier_code: option.carrier_code(),
carrier_label: option.carrier_label(),
freight_charge: option.base_freight_charge(),
route_leg_days: option
.route_legs()
.iter()
.map(|leg| leg.transit_days())
.collect(),
route_leg_is_cross_border: option
.route_legs()
.iter()
.map(|leg| leg.is_cross_border())
.collect(),
route_summary: option.route_summary_text(),
visited_country_codes: option.visited_country_codes(),
is_feasible: option.is_feasible(),
infeasibility_reason: option.infeasibility_reason(),
}
}
/// 一个便捷的自由函数:把内部单据要求转成中立视图。
///
/// 参数 `requirement`:单据子系统的清单项。
/// 返回:中立视图。
pub fn translate_document_requirement(
requirement: &DocumentRequirement,
) -> DocumentRequirementView {
DocumentRequirementView {
kind: requirement.kind(),
status_label: requirement.status().label(),
is_mandatory: requirement.is_mandatory(),
blocks_dispatch: requirement.blocks_dispatch(),
trigger_reason: requirement.trigger_reason(),
}
}
/// 一个便捷的自由函数:把内部费用项转成结算端口的中立输入。
///
/// 参数 `item`:结算子系统的费用项。
/// 返回:中立输入项。
///
/// 注意 [`ChargeItem`] 与 [`ChargeSource`] 在此被「翻译」掉,
/// 使工程外实现无需依赖 `settlement` 模块即可接受门面的输入。
pub fn translate_charge_item(item: &ChargeItem) -> SettlementInputItem {
SettlementInputItem {
code: item.code(),
label: item.label(),
source_code: source_code_of(item.source()),
source_label: item.source().label(),
amount: item.amount(),
basis_text: item.basis_text(),
}
}
/// 把费用来源枚举转成稳定的字符串编码。
///
/// 参数 `source`:费用来源。
/// 返回:大写编码(如 `"FREIGHT"`)。
///
/// 用 `match` 而不是 `Debug` 格式化:`Debug` 输出(如 `Freight`)
/// 是内部表示,一旦枚举改名就会变化;显式的字符串映射才是稳定契约。
pub fn source_code_of(source: ChargeSource) -> &'static str {
match source {
ChargeSource::Freight => "FREIGHT",
ChargeSource::Packaging => "PACKAGING",
ChargeSource::Documentation => "DOCUMENTATION",
ChargeSource::ValueAddedService => "VALUE_ADDED_SERVICE",
}
}
/// 一个便捷的自由函数:把内部时间线转成中立视图。
///
/// 参数 `timeline`:承运子系统的时间线。
/// 返回:中立视图。
pub fn translate_timeline(
timeline: &crate::carrier::timeline_builder::CarrierTimeline,
) -> super::subsystem_ports::TimelineView {
super::subsystem_ports::TimelineView {
milestones: timeline
.milestones()
.iter()
.map(|milestone| super::subsystem_ports::TimelineMilestoneView {
code: milestone.code(),
label: milestone.label(),
date: milestone.date(),
note: milestone.note(),
})
.collect(),
estimated_delivery_date: timeline.estimated_delivery_date(),
delayed_by_weekend: timeline.delayed_by_weekend(),
}
}
/// 一个便捷的自由函数:把内部结算结果转成中立视图。
///
/// 参数 `result`:结算子系统的结果。
/// 返回:中立视图。
pub fn translate_settlement(
result: &crate::settlement::settlement_ledger::SettlementResult,
) -> SettlementView {
SettlementView {
items: result
.items()
.iter()
.map(translate_charge_item)
.collect(),
grand_total: result.grand_total(),
largest_item_code: result.largest_item().map(|item| item.code()),
source_subtotals: result
.by_source()
.iter()
.map(|subtotal| (source_code_of(subtotal.source()), subtotal.subtotal()))
.collect(),
}
}
/// 判断某工作日调整是否发生(供报表提示用)。
///
/// 参数 `planned` / `adjusted`。
/// 返回:发生变化返回 `true`。
///
/// 这类小工具放在门面模块而非 `support`:它只被门面与报表使用,
/// 提升到支持层反而会让支持层承载业务语义。
pub fn dispatch_date_was_adjusted(planned: &crate::support::calendar_date::CalendarDate) -> bool {
let adjusted = adjust_dispatch_date_for_weekend(planned);
adjusted != *planned
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : shipping_result.rs
//! 门面的输出:装配结果与其视图类型。
//!
//! # 为什么门面必须定义自己的输出类型
//!
//! 这是门面模式最容易被忽略、却最影响可维护性的一条:
//!
//! **若门面把子系统的类型直接返回给调用方,门面就白做了。**
//! 调用方仍会写出 `use crate::documents::DocumentChecklist;`,
//! 于是子系统一改,调用方跟着改——封装只是形式上的。
//!
//! 因此本文件的每个 `*View` 都是门面**自己的**类型:
//! 字段全部是 `domain` 层的值对象或用例无关的原始类型。
//! 调用方(`app` 报表 / `analysis` 分析)只 import 本模块,
//! **编译期就保证了它们看不到任何子系统类型**。
//!
//! 这个性质是可以被验证的:若有人不小心在 `app` 里写了
//! `use crate::dispatch::...`,依赖方向脚本会立刻把它暴露出来。
use crate::domain::{CurrencyAmount, DocumentKind, EventSeverity, ShippingWeight};
use crate::support::calendar_date::CalendarDate;
/// 路由段视图。
#[derive(Debug, Clone)]
pub struct RouteLegView {
/// 段序号。
pub sequence_number: u32,
/// 起点城市。
pub origin_city: &'static str,
/// 起点国家/地区代码。
pub origin_country_code: &'static str,
/// 终点城市。
pub destination_city: &'static str,
/// 终点国家/地区代码。
pub destination_country_code: &'static str,
/// 运输方式描述。
pub transport_mode_label: &'static str,
/// 该段天数。
pub transit_days: u32,
/// 是否为跨境段。
pub is_cross_border: bool,
}
impl RouteLegView {
/// 返回一行展示文本。
pub fn formatted(&self) -> String {
format!(
"第 {} 段|{} {} → {} {}|{}|{} 天",
self.sequence_number,
self.origin_city,
self.origin_country_code,
self.destination_city,
self.destination_country_code,
self.transport_mode_label,
self.transit_days
)
}
}
/// 包装视图。
#[derive(Debug, Clone)]
pub struct PackagingView {
/// 材料清单文本(如「木质礼盒 × 1、防震泡沫箱 × 2」)。
pub material_summary: String,
/// 材料种类数。
pub material_kind_count: usize,
/// 材料总成本。
pub total_material_cost: CurrencyAmount,
/// 包装增重。
pub weight_gain: ShippingWeight,
/// 包装后总重。
pub total_weight_with_packaging: ShippingWeight,
/// 是否使用保温材料。
pub uses_insulating_material: bool,
}
/// 单据视图。
#[derive(Debug, Clone)]
pub struct DocumentView {
/// 单据类型。
pub kind: DocumentKind,
/// 状态标签。
pub status_label: &'static str,
/// 是否强制。
pub is_mandatory: bool,
/// 是否阻断出单。
pub blocks_dispatch: bool,
/// 触发原因。
pub trigger_reason: &'static str,
}
/// 时间线视图。
#[derive(Debug, Clone)]
pub struct ShipmentTimelineView {
/// 里程碑列表(编码, 名称, 日期, 说明)。
pub milestones: Vec<(&'static str, &'static str, CalendarDate, &'static str)>,
/// 预计送达日。
pub estimated_delivery_date: CalendarDate,
/// 是否因周末顺延。
pub delayed_by_weekend: bool,
}
/// 一条汇总事件(门面在汇聚各子系统后产出的统一问题表述)。
#[derive(Debug, Clone)]
pub struct FacadeEvent {
/// 事件来源环节(如「包装」「单据」)。
pub stage_label: &'static str,
/// 严重等级。
pub severity: EventSeverity,
/// 问题描述。
pub description: String,
/// 处理建议。
pub suggestion: &'static str,
}
impl FacadeEvent {
/// 构造一条事件。
pub fn new(
stage_label: &'static str,
severity: EventSeverity,
description: String,
suggestion: &'static str,
) -> Self {
FacadeEvent {
stage_label,
severity,
description,
suggestion,
}
}
}
/// 一次装配的最终结果。
///
/// ## 为什么把「是否可出单」做成方法而不是字段
///
/// 它是由事件列表推导出来的:只要存在 `Critical` 事件即不可出单。
/// 做成派生方法可以杜绝「字段值与事件列表不一致」这类 bug
/// (那在报表场景下会非常难查)。
#[derive(Debug, Clone)]
pub struct ShippingResult {
/// 运单业务号。
pub shipment_reference: &'static str,
/// 运单号(由门面派生)。
pub waybill_number: String,
/// 结果指纹(用于报表比对同一票货的不同阶段)。
pub fingerprint: String,
/// 寄件方描述。
pub sender_text: String,
/// 收件方描述。
pub receiver_text: String,
/// 承运商代码。
pub carrier_code: &'static str,
/// 承运商中文名。
pub carrier_label: &'static str,
/// 服务等级名称。
pub service_tier_label: &'static str,
/// 路由摘要文本。
pub route_summary: String,
/// 路由段视图列表。
pub route_legs: Vec<RouteLegView>,
/// 包装视图。
pub packaging: PackagingView,
/// 单据视图列表。
pub documents: Vec<DocumentView>,
/// 时间线视图。
pub timeline: ShipmentTimelineView,
/// 费用项(编码, 名称, 来源名, 金额, 计价依据)。
pub charge_items: Vec<(&'static str, &'static str, &'static str, CurrencyAmount, &'static str)>,
/// 应付总额。
pub grand_total: CurrencyAmount,
/// 最大费用项编码。
pub largest_item_code: Option<&'static str>,
/// 货物明细(名称, 品类名, 件数, 单件重量, 单件申报价值)。
pub cargo_lines: Vec<(&'static str, &'static str, i64, ShippingWeight, CurrencyAmount)>,
/// 汇聚后的事件列表。
pub events: Vec<FacadeEvent>,
/// 货物净重。
pub net_cargo_weight: ShippingWeight,
/// 申报总价值。
pub total_declared_value: CurrencyAmount,
}
impl ShippingResult {
/// 是否存在阻断级事件。
pub fn is_blocked(&self) -> bool {
self.events
.iter()
.any(|event| event.severity.blocks_dispatch())
}
/// 阻断级事件数量。
pub fn blocker_count(&self) -> usize {
self.events
.iter()
.filter(|event| event.severity.blocks_dispatch())
.count()
}
/// 警告级事件数量。
pub fn warning_count(&self) -> usize {
self.events
.iter()
.filter(|event| event.severity == EventSeverity::Warning)
.count()
}
/// 提示级事件数量。
pub fn info_count(&self) -> usize {
self.events
.iter()
.filter(|event| event.severity == EventSeverity::Info)
.count()
}
/// 事件总数。
pub fn event_count(&self) -> usize {
self.events.len()
}
/// 路由段数。
pub fn route_leg_count(&self) -> usize {
self.route_legs.len()
}
/// 单据数量。
pub fn document_count(&self) -> usize {
self.documents.len()
}
/// 单据编码列表。
pub fn document_codes(&self) -> Vec<&'static str> {
self.documents
.iter()
.map(|document| document.kind.code())
.collect()
}
/// 返回单一业务结论文本(供报表首行展示)。
pub fn outcome_text(&self) -> &'static str {
if self.is_blocked() {
"装配被拒绝:存在严重级问题"
} else if self.warning_count() > 0 {
"装配通过,另有提醒供人工确认"
} else {
"装配通过,未发现需要人工确认的事项"
}
}
}
/// 门面的装配结论(比 [`ShippingResult`] 更轻量的返回形式)。
///
/// 存在的理由:门面偶尔需要返回「成功/失败 + 原因」而无需完整结果,
/// 例如工程外扩展区里只想知道「这票货能不能走」。
/// 用独立的枚举比复用 `ShippingResult` 更贴合这类调用点,
/// 且使得 `match` 的穷尽性检查能覆盖两种情形。
#[derive(Debug, Clone)]
pub enum ShipmentOutcome {
/// 装配成功,携带完整结果。
Planned(Box<ShippingResult>),
/// 装配被拒绝,携带原因与完整结果(便于定位)。
Rejected {
/// 拒绝原因。
reason: String,
/// 仍然返回的完整结果(含全部事件)。
result: Box<ShippingResult>,
},
}
impl ShipmentOutcome {
/// 是否成功。
pub fn is_planned(&self) -> bool {
match self {
ShipmentOutcome::Planned(_) => true,
ShipmentOutcome::Rejected { .. } => false,
}
}
/// 取出结果引用(无论成功与否都有结果)。
pub fn result(&self) -> &ShippingResult {
match self {
ShipmentOutcome::Planned(result) => result,
ShipmentOutcome::Rejected { result, .. } => result,
}
}
/// 拒绝原因(成功时为 None)。
pub fn rejection_reason(&self) -> Option<&str> {
match self {
ShipmentOutcome::Planned(_) => None,
ShipmentOutcome::Rejected { reason, .. } => Some(reason.as_str()),
}
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : subsystem_ports.rs
//! 子系统「端口」特征 —— 门面与子系统之间的**方法形状契约**。
//!
//! # 这是本工程可扩展性的关键设计,务必先读这段
//!
//! ## 问题的由来
//!
//! 「门面封装子系统」听起来很美好,但有个陷阱:
//! 如果门面直接持有 `dispatch::RoutePlanner` 这类**具体类型**,
//! 那么「换一套子系统实现」就必须改门面——门面反而成了新的耦合中心。
//!
//! ## 本工程的对策:门面对子系统只依赖「方法形状」
//!
//! 下面每个 trait 都**只声明方法签名**,且方法签名里**不出现任何子系统类型**
//! (输入输出都是 `domain` 层的值对象或门面自己的类型)。
//! 于是:
//!
//! - 门面持有的是 `Box<dyn DispatchPort>` 之类的**特征对象**;
//! - 工程外只要写一个结构体,实现同名方法(乃至不同类型的同名方法集),
//! 就能替换掉整套子系统,**门面代码零改动**;
//! - 这比「再写一个门面实现」有力得多,因为它证明可替换的是**子系统**
//! (门面模式的真正价值点),而不是门面本身。
//!
//! ## 为什么用特征而不是「结构体 + 泛型」
//!
//! 泛型(`ShippingFacade<D: DispatchPort, P: PackagingPort, ...>`)
//! 也能做到同样的解耦,且是零成本抽象的。这里选特征对象的原因:
//! **门面需要在本工程里被以「运行期组装」的方式演示**
//! (不同幕次使用不同子系统组合),而泛型会把这种灵活性推到类型层面,
//! 让调用方必须写出长长的类型参数。对示范工程而言,可读性优先。
//!
//! 若这条路径将来成为性能瓶颈,把特征对象换成泛型是一个**机械重构**
//! (签名一一对应),不会触及业务逻辑——这正是先定契约的价值。
use crate::domain::{
CountryCode, CurrencyAmount, DocumentKind, ServiceTier, ShippingWeight,
};
use crate::support::calendar_date::CalendarDate;
/// 调度端口:给定运输需求,返回候选承运方案的**中立描述**。
///
/// 注意返回类型不是 `dispatch::CarrierOption`,而是本文件定义的
/// [`CarrierOptionView`]——这样 `dispatch` 内部改字段也不会波及门面,
/// 且工程外的替代实现无需依赖 `dispatch` 模块。
pub trait DispatchPort {
/// 返回候选方案列表(含不可行项及其原因)。
fn plan_options(&self, input: &DispatchInput) -> Vec<CarrierOptionView>;
}
/// 调度端口的输入(中立描述)。
#[derive(Debug, Clone)]
pub struct DispatchInput {
/// 起始城市。
pub origin_city: &'static str,
/// 起始国家/地区。
pub origin_country: CountryCode,
/// 目的城市。
pub destination_city: &'static str,
/// 目的国家/地区。
pub destination_country: CountryCode,
/// 计费重量。
pub chargeable_weight: ShippingWeight,
/// 是否需要温控。
pub requires_temperature_control: bool,
/// 服务等级。
pub service_tier: ServiceTier,
}
/// 承运方案的中立视图。
///
/// 字段全部是 `domain` 层的值对象或原始类型,
/// 因此任何子系统实现都能轻松构造它,门面也无从依赖具体实现。
#[derive(Debug, Clone)]
pub struct CarrierOptionView {
/// 承运商代码。
pub carrier_code: &'static str,
/// 承运商中文名。
pub carrier_label: &'static str,
/// 基础运费(含服务等级调整)。
pub freight_charge: CurrencyAmount,
/// 路由各段的天数(按序)。
pub route_leg_days: Vec<u32>,
/// 路由各段是否为跨境段(与 `route_leg_days` 等长)。
pub route_leg_is_cross_border: Vec<bool>,
/// 路由的一行式摘要(如「深圳 → 广州 → 法兰克福 → 柏林」)。
pub route_summary: String,
/// 途经国家/地区代码(有序)。
pub visited_country_codes: Vec<&'static str>,
/// 是否可行。
pub is_feasible: bool,
/// 不可行原因(可行时为空串)。
pub infeasibility_reason: &'static str,
}
/// 包装端口:给定包装需求,返回包装方案的中立描述。
pub trait PackagingPort {
/// 返回包装方案。
fn plan_packaging(&self, input: &PackagingInput) -> PackagingPlanView;
}
/// 包装端口的输入。
#[derive(Debug, Clone)]
pub struct PackagingInput {
/// 珠宝类件数。
pub jewelry_piece_count: i64,
/// 其他件数。
pub other_piece_count: i64,
/// 是否需要温控。
pub requires_temperature_control: bool,
/// 未包装净重。
pub net_cargo_weight: ShippingWeight,
}
/// 包装方案的中立视图。
#[derive(Debug, Clone)]
pub struct PackagingPlanView {
/// 各材料行(材料名, 数量, 行成本)。
///
/// 用元组而不是定义一个结构体,是因为门面只做转发展示,
/// 不参与计算;加一层命名结构体属于过度设计。
/// 若将来门面要按材料做运算,再引入结构体不迟。
pub material_lines: Vec<PackagingMaterialLineView>,
/// 包装材料总成本。
pub total_material_cost: CurrencyAmount,
/// 包装增重。
pub weight_gain: ShippingWeight,
/// 包装后总重。
pub total_weight_with_packaging: ShippingWeight,
/// 是否使用保温材料。
pub uses_insulating_material: bool,
}
/// 包装方案中的一行材料(中立视图)。
#[derive(Debug, Clone)]
pub struct PackagingMaterialLineView {
/// 材料名称。
pub material_label: &'static str,
/// 材料编码。
pub material_code: &'static str,
/// 用量。
pub quantity: i64,
/// 该行成本。
pub line_cost: CurrencyAmount,
}
/// 单据端口:给定编成条件,返回单据清单的中立描述。
pub trait DocumentCompilationPort {
/// 返回单据清单项列表。
fn compile_checklist(&self, input: &DocumentInput) -> Vec<DocumentRequirementView>;
}
/// 单据端口的输入。
#[derive(Debug, Clone)]
pub struct DocumentInput {
/// 是否跨境。
pub is_cross_border: bool,
/// 是否需要温控。
pub requires_temperature_control: bool,
/// 是否有可申报品类。
pub has_declarable_cargo: bool,
/// 是否有高值货。
pub has_high_value_cargo: bool,
/// 目的国家/地区代码。
pub destination_country_code: &'static str,
/// 目的地是否要求正式报关。
pub destination_requires_customs_declaration: bool,
}
/// 单据需求的中立视图。
#[derive(Debug, Clone)]
pub struct DocumentRequirementView {
/// 单据类型。
pub kind: DocumentKind,
/// 状态中文标签(如「已具备」)。
///
/// 这里用 `&'static str` 而不是系统内部的状态枚举,
/// 是为了让工程外实现无需依赖 `documents` 模块即可产出清单。
/// 代价是失去了状态的可比较性——门面只做展示与「是否阻断」判断,
/// 不需要比较状态,因此这个取舍是划算的。
pub status_label: &'static str,
/// 是否为强制项。
pub is_mandatory: bool,
/// 是否会阻断出单。
pub blocks_dispatch: bool,
/// 触发原因。
pub trigger_reason: &'static str,
}
/// 承运端口:给定时间线需求,返回时间线中立描述。
pub trait TimelinePort {
/// 返回时间线视图。
fn build_timeline_view(&self, input: &TimelineInput) -> TimelineView;
}
/// 承运端口的输入。
#[derive(Debug, Clone)]
pub struct TimelineInput {
/// 计划发货日。
pub dispatch_date: CalendarDate,
/// 各段天数。
pub route_leg_days: Vec<u32>,
/// 各段是否跨境。
pub route_leg_is_cross_border: Vec<bool>,
/// 目的城市名。
pub destination_city: &'static str,
}
/// 时间线的中立视图。
#[derive(Debug, Clone)]
pub struct TimelineView {
/// 各里程碑(编码, 名称, 日期, 说明)。
pub milestones: Vec<TimelineMilestoneView>,
/// 预计送达日。
pub estimated_delivery_date: CalendarDate,
/// 是否因周末顺延。
pub delayed_by_weekend: bool,
}
/// 时间线里程碑的中立视图。
#[derive(Debug, Clone)]
pub struct TimelineMilestoneView {
/// 里程碑编码。
pub code: &'static str,
/// 里程碑名称。
pub label: &'static str,
/// 日期。
pub date: CalendarDate,
/// 说明。
pub note: &'static str,
}
/// 结算端口:给定费用项,返回结算结果的中立描述。
pub trait SettlementPort {
/// 返回结算视图。
fn settle(&self, items: Vec<SettlementInputItem>) -> SettlementView;
}
/// 结算端口的输入项(中立描述)。
///
/// 注意它**不依赖** `settlement::ChargeItem`,
/// 也不依赖 `settlement::ChargeSource`——来源用字符串表达,
/// 使替代实现无需 import 任何 `settlement` 中的类型。
/// 这正是「端口只依赖方法形状」的具体体现。
#[derive(Debug, Clone)]
pub struct SettlementInputItem {
/// 费用项编码。
pub code: &'static str,
/// 费用项名称。
pub label: &'static str,
/// 来源编码(如 `"FREIGHT"`)。
pub source_code: &'static str,
/// 来源名称(如「基础运费」)。
pub source_label: &'static str,
/// 金额。
pub amount: CurrencyAmount,
/// 计价依据。
pub basis_text: &'static str,
}
/// 结算结果的中立视图。
#[derive(Debug, Clone)]
pub struct SettlementView {
/// 全部费用项(已按来源排序)。
pub items: Vec<SettlementInputItem>,
/// 应付总额。
pub grand_total: CurrencyAmount,
/// 最大费用项的编码(无项时为 None)。
pub largest_item_code: Option<&'static str>,
/// 某一来源的合计金额查询用表:(来源编码, 合计金额)。
pub source_subtotals: Vec<(&'static str, CurrencyAmount)>,
}
impl SettlementView {
/// 查询某一来源的合计金额(不存在返回零)。
pub fn subtotal_of_source(&self, source_code: &str) -> Option<CurrencyAmount> {
self.source_subtotals
.iter()
.find(|(code, _)| *code == source_code)
.map(|(_, amount)| *amount)
}
}
/// 运力台账端口:门面用它查询运力紧张度(用于追加警告)。
///
/// 单独成一个端口而不是并入 [`DispatchPort`]:
/// 「规划路线」与「查询运力」是两个不同的关注点,
/// 将来可能有实现只关心其中一个。接口细粒度化便于按需替换。
pub trait CapacityPort {
/// 返回某承运商的占用率(万分比)。
fn utilization_basis_points(&self, carrier_code: &str) -> i64;
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : packaging_plan.rs
//! 包装方案值对象(包装子系统的输出结构)。
//!
//! ## 为什么把「一行材料」单独建模
//!
//! 包装方案常需要**逐件材料列出**(报关与客户对账都要看明细)。
//! 若只在方案里存一个总成本,明细就丢失了。
//! 因此方案由若干 [`PackagingPlanLine`] 组成,汇总值由明细推导
//! (而不是另外存一遍,避免两处不一致)。
use crate::domain::{CurrencyAmount, PackagingMaterial, ShippingWeight, CURRENCY_CHINESE_YUAN};
/// 包装方案中的一行材料用量。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PackagingPlanLine {
/// 使用的材料。
material: PackagingMaterial,
/// 用量(件数)。
quantity: i64,
}
impl PackagingPlanLine {
/// 构造一行材料用量。
pub const fn new(material: PackagingMaterial, quantity: i64) -> Self {
PackagingPlanLine { material, quantity }
}
/// 材料。
pub const fn material(&self) -> PackagingMaterial {
self.material
}
/// 用量。
pub const fn quantity(&self) -> i64 {
self.quantity
}
/// 该行成本(材料单价 × 用量)。
pub fn line_cost(&self) -> CurrencyAmount {
CurrencyAmount::from_minor_units(self.material.unit_cost_minor_units(), CURRENCY_CHINESE_YUAN)
.multiply_by_quantity(self.quantity)
}
/// 返回一行展示文本,如 `"木质礼盒 × 1 = ¥18.00"`。
pub fn formatted(&self) -> String {
format!(
"{} × {} = {}",
self.material.label(),
self.quantity,
self.line_cost().formatted()
)
}
}
/// 一份完整的包装方案。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackagingPlan {
/// 材料明细行。
lines: Vec<PackagingPlanLine>,
/// 包装后的总重量(原货重 + 包装增重)。
///
/// 存总重而不是只存增重,是因为下游(承运、单据)几乎总是需要总重;
/// 同时保留 [`PackagingPlan::packaging_weight_gain`] 让需要增重的地方也能拿到。
total_weight_with_packaging: ShippingWeight,
/// 包装产生的增重(用于门面在报表里单独展示「包装增重」一栏)。
packaging_weight_gain: ShippingWeight,
/// 是否使用了保温材料(温控货必须为 true,由门面核查)。
uses_insulating_material: bool,
}
impl PackagingPlan {
/// 构造一份包装方案。
pub fn new(
lines: Vec<PackagingPlanLine>,
total_weight_with_packaging: ShippingWeight,
packaging_weight_gain: ShippingWeight,
uses_insulating_material: bool,
) -> Self {
PackagingPlan {
lines,
total_weight_with_packaging,
packaging_weight_gain,
uses_insulating_material,
}
}
/// 材料明细行。
pub fn lines(&self) -> &[PackagingPlanLine] {
&self.lines
}
/// 包装后总重。
pub const fn total_weight_with_packaging(&self) -> ShippingWeight {
self.total_weight_with_packaging
}
/// 包装增重。
pub const fn packaging_weight_gain(&self) -> ShippingWeight {
self.packaging_weight_gain
}
/// 是否使用了保温材料。
pub const fn uses_insulating_material(&self) -> bool {
self.uses_insulating_material
}
/// 包装材料总成本(对明细求和)。
///
/// 从明细推导而非另存字段:明细是唯一事实来源,
/// 派生值随明细变化,不存在「改了明细忘改汇总」的可能。
pub fn total_material_cost(&self) -> CurrencyAmount {
let mut total: CurrencyAmount =
CurrencyAmount::zero(CURRENCY_CHINESE_YUAN);
for line in &self.lines {
total = total.add(&line.line_cost());
}
total
}
/// 材料种类数。
pub fn material_kind_count(&self) -> usize {
self.lines.len()
}
/// 材料总件数(各行用量之和)。
pub fn total_material_quantity(&self) -> i64 {
let mut total: i64 = 0;
for line in &self.lines {
total = total.saturating_add(line.quantity());
}
total
}
/// 返回材料清单的一行式文本,如 `"木质礼盒 × 1、防震泡沫箱 × 2"`。
pub fn material_summary_text(&self) -> String {
if self.lines.is_empty() {
return "(无需包装材料)".to_string();
}
let mut summary: String = String::new();
for (position, line) in self.lines.iter().enumerate() {
if position > 0 {
summary.push('、');
}
summary.push_str(&format!("{} × {}", line.material().label(), line.quantity()));
}
summary
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : packaging_planner.rs
//! 包装方案规划(包装子系统的纯计算部分)。
//!
//! ## 输入为什么是一个「请求结构体」
//!
//! 包装需要知道:货物品类构成、是否温控、件数。
//! 这些信息里有些来自门面(温控要求),有些来自货物清单。
//! 用一个请求结构体承载,将来若要加入「是否易碎」「是否需防潮」,
//! 只加字段不动签名。
//!
//! ## 包装增重的计算口径
//!
//! 每种材料有**单位增重**(在下方常量表中定义),
//! 总增重 = Σ(材料单位增重 × 用量)。这与 [`super::packaging_plan`] 的成本口径
//! 完全一致,两处都是「单位值 × 用量」,便于对账同事按同一套逻辑核对。
use crate::domain::{
PackagingMaterial, ShippingWeight, CARGO_JEWELRY, PACKAGING_FOAM_BOX, PACKAGING_VACUUM_FOIL_BAG,
PACKAGING_WOODEN_CRATE,
};
use super::packaging_plan::{PackagingPlan, PackagingPlanLine};
/// 单件木质礼盒的增重(毫克):450 g。
///
/// 为什么把增重放在这里而不是 `PackagingMaterial` 里:
/// 材料定义在 `domain` 层,是「跨子系统共享的标签」;
/// 而「一件木盒重多少克」是**包装子系统的专业参数**,
/// 把它塞进 domain 会让领域层承载子系统知识,破坏职责边界。
/// 代价是:若将来多个子系统都需要这个值,需要提升到 domain——
/// 那是重构点,而不是现在就过度设计。
const WOODEN_CRATE_WEIGHT_MILLIGRAMS: i64 = 450_000;
/// 单件防震泡沫箱的增重(毫克):180 g。
const FOAM_BOX_WEIGHT_MILLIGRAMS: i64 = 180_000;
/// 单件真空铝箔袋的增重(毫克):30 g。
const VACUUM_FOIL_BAG_WEIGHT_MILLIGRAMS: i64 = 30_000;
/// 包装规划的输入。
#[derive(Debug, Clone)]
pub struct PackagingRequest {
/// 珠宝类货物的件数(决定需要多少个硬质礼盒)。
pub jewelry_piece_count: i64,
/// 其他货物件数(包装物料、证书等,用泡沫箱承载)。
pub other_piece_count: i64,
/// 是否需要温控(决定是否追加真空铝箔袋)。
pub requires_temperature_control: bool,
/// 未包装前的货物净重。
pub net_cargo_weight: ShippingWeight,
}
/// 根据输入规划包装方案。
///
/// 参数 `request`:包装请求。
/// 返回:包装方案。
///
/// ## 规则(刻意简单,便于手算复核)
///
/// 1. 每件珠宝 → 1 个木质礼盒(硬质外观件);
/// 2. 每件其他货物 → 1 个防震泡沫箱;
/// 3. 若需温控 → 每种已有材料之外再套 1 个真空铝箔袋
/// (数量 = 1,因为整票货作为一个温控单元托运);
/// 4. 总重 = 净重 + 各材料单位增重 × 用量。
///
/// 注意规则 3 的数量固定为 1 而非「按件数」:
/// 温控是**整票层面**的属性(一个冷藏单元),不是逐件属性。
/// 这个区分容易写错,本函数用注释明确固定下来。
pub fn plan_packaging(request: &PackagingRequest) -> PackagingPlan {
// 用 Vec 收集行,仅在数量大于 0 时推入,避免报表里出现「× 0」的噪音行。
let mut lines: Vec<PackagingPlanLine> = Vec::new();
// 累计增重,单位毫克。
let mut total_gain_milligrams: i64 = 0;
if request.jewelry_piece_count > 0 {
lines.push(PackagingPlanLine::new(
PACKAGING_WOODEN_CRATE,
request.jewelry_piece_count,
));
total_gain_milligrams = total_gain_milligrams
.saturating_add(WOODEN_CRATE_WEIGHT_MILLIGRAMS * request.jewelry_piece_count);
}
if request.other_piece_count > 0 {
lines.push(PackagingPlanLine::new(
PACKAGING_FOAM_BOX,
request.other_piece_count,
));
total_gain_milligrams = total_gain_milligrams
.saturating_add(FOAM_BOX_WEIGHT_MILLIGRAMS * request.other_piece_count);
}
let mut uses_insulating_material: bool = false;
if request.requires_temperature_control {
// 温控为整票属性:数量恒为 1(见函数文档规则 3)。
lines.push(PackagingPlanLine::new(PACKAGING_VACUUM_FOIL_BAG, 1));
total_gain_milligrams =
total_gain_milligrams.saturating_add(VACUUM_FOIL_BAG_WEIGHT_MILLIGRAMS);
uses_insulating_material = true;
}
let packaging_weight_gain: ShippingWeight =
ShippingWeight::from_milligrams(total_gain_milligrams);
let total_weight_with_packaging: ShippingWeight =
request.net_cargo_weight.add(&packaging_weight_gain);
PackagingPlan::new(
lines,
total_weight_with_packaging,
packaging_weight_gain,
uses_insulating_material,
)
}
/// 返回一种材料对应的单位增重(毫克)。
///
/// 参数 `material`:包装材料。
/// 返回:单位增重(毫克)。
///
/// ## 这里为什么必须是 `if` 链而不是 `match`
///
/// `PackagingMaterial` 是**开放型结构体**,没有变体可以 `match`。
/// 因此这里用「材料编码字符串比较」的方式分派。
/// 代价是「新增材料时忘登记」不会编译报错——所以返回 0 单位增重,
/// 并在 [`unknown_material_warning`] 里给出可被门面汇总的提示。
///
/// 这正是开放型标签的另一面:**扩展自由,但需要配套一个运行期兜底**。
/// 本工程把这个兜底显式做出来,而不是假装问题不存在。
pub fn unit_gain_milligrams(material: &PackagingMaterial) -> i64 {
if material.code() == PACKAGING_WOODEN_CRATE.code() {
WOODEN_CRATE_WEIGHT_MILLIGRAMS
} else if material.code() == PACKAGING_FOAM_BOX.code() {
FOAM_BOX_WEIGHT_MILLIGRAMS
} else if material.code() == PACKAGING_VACUUM_FOIL_BAG.code() {
VACUUM_FOIL_BAG_WEIGHT_MILLIGRAMS
} else {
// 未登记材料:返回 0 而不是 panic,并由调用方决定如何提示。
0
}
}
/// 判断一种材料是否已被本子系统登记增重参数。
///
/// 参数 `material`:待检查材料。
/// 返回:已登记返回 `true`。
///
/// 用于工程外新增材料时的运行期兜底提示(见 [`unit_gain_milligrams`] 文档)。
pub fn is_material_registered(material: &PackagingMaterial) -> bool {
material.code() == PACKAGING_WOODEN_CRATE.code()
|| material.code() == PACKAGING_FOAM_BOX.code()
|| material.code() == PACKAGING_VACUUM_FOIL_BAG.code()
}
/// 判断某个品类是否需要硬质外观包装(木质礼盒)。
///
/// 参数 `category_code`:品类编码。
/// 返回:需要返回 `true`。
///
/// 用品类编码而非品类对象:本函数只关心「是不是珠宝」这一个事实,
/// 不需要整个 CargoCategory 的其它属性,接口越窄越好替换。
pub fn requires_rigid_packaging(category_code: &str) -> bool {
category_code == CARGO_JEWELRY.code()
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : charge_item.rs
//! 费用项(结算子系统的中立输入结构)。
//!
//! ## 为什么需要 `ChargeSource`
//!
//! 报表要按来源分组展示(运费 / 包装 / 单据 / 服务),
//! 也要能回答「增值服务费占总额多少」。
//! 若费用项不带来源,门面就得自己记「我刚刚传进去的那条是运费」——
//! 那是把汇总逻辑散在调用点,一旦顺序调整就会串味。
//!
//! `ChargeSource` 是**封闭枚举**:它决定报表的分组行为
//! (不同来源在报表里进入不同小节),属于「行为分派」,
//! 因此用枚举而非开放型标签。工程外新增费用来源时,
//! 应映射到既有来源(如自定义服务费归入 `ValueAddedService`),
//! 而不是扩充本枚举——这条纪律写在枚举文档里。
use crate::domain::CurrencyAmount;
/// 费用来源。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ChargeSource {
/// 基础运费。
Freight,
/// 包装材料费。
Packaging,
/// 单据工本费。
Documentation,
/// 增值服务费。
ValueAddedService,
}
impl ChargeSource {
/// 中文标签。
pub const fn label(&self) -> &'static str {
match self {
ChargeSource::Freight => "基础运费",
ChargeSource::Packaging => "包装材料费",
ChargeSource::Documentation => "单据工本费",
ChargeSource::ValueAddedService => "增值服务费",
}
}
/// 排序权重:决定报表中的展示顺序。
///
/// 顺序刻意是「运费 → 包装 → 单据 → 服务」,
/// 与业务人员的阅读习惯一致(先看主要成本,再看附加项)。
pub const fn display_order(&self) -> u32 {
match self {
ChargeSource::Freight => 0,
ChargeSource::Packaging => 1,
ChargeSource::Documentation => 2,
ChargeSource::ValueAddedService => 3,
}
}
/// 该来源是否计入「应付总额」。
///
/// 全部来源都计入——本工程没有「仅展示不计入」的费用。
/// 保留这个方法是为了给将来留出扩展点(如「预估费用」不计入),
/// 且它使「哪些计入」这件事成为显式可审计的代码,而非隐含假设。
pub const fn counts_toward_total(&self) -> bool {
match self {
ChargeSource::Freight
| ChargeSource::Packaging
| ChargeSource::Documentation
| ChargeSource::ValueAddedService => true,
}
}
}
/// 一个费用项。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChargeItem {
/// 费用项编码(如 `"FREIGHT"`、`"PACKAGING_WOODEN_CRATE"`)。
code: &'static str,
/// 费用项中文名。
label: &'static str,
/// 来源分类。
source: ChargeSource,
/// 金额。
amount: CurrencyAmount,
/// 计价依据说明(报表展示,让客户明白钱花在哪)。
basis_text: &'static str,
}
impl ChargeItem {
/// 构造一个费用项。
pub const fn new(
code: &'static str,
label: &'static str,
source: ChargeSource,
amount: CurrencyAmount,
basis_text: &'static str,
) -> Self {
ChargeItem {
code,
label,
source,
amount,
basis_text,
}
}
/// 费用项编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 费用项中文名。
pub const fn label(&self) -> &'static str {
self.label
}
/// 来源分类。
pub const fn source(&self) -> ChargeSource {
self.source
}
/// 金额。
pub const fn amount(&self) -> CurrencyAmount {
self.amount
}
/// 计价依据说明。
pub const fn basis_text(&self) -> &'static str {
self.basis_text
}
/// 返回一行展示文本。
///
/// 这是「子系统自带的显示口径」,门面刻意不采用它——
/// 报表的列宽、货币符号、缩进都属于**门面/表示层**的职责,子系统一旦
/// 自行决定排版,换一个终端(PDF、Web)就要改子系统。
/// 保留并开放它,是为了在第七幕与门面生成的表格**并列打印**,
/// 让「子系统越界做排版」的后果肉眼可见。
pub fn formatted(&self) -> String {
format!("{}:{}({})", self.label, self.amount.formatted(), self.basis_text)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : settlement_ledger.rs
//! 费用归集(结算子系统的计算部分)。
//!
//! ## 归集做三件事
//!
//! 1. 按来源分组求和(`by_source`);
//! 2. 计算应付总额(只累加 `counts_toward_total` 为真的项);
//! 3. 找出最大费用项(报表要突出「钱主要花在哪」)。
//!
//! ## 为什么不在这里做「折扣计算」
//!
//! 折扣是**商务政策**,不是结算机制。若把折扣规则塞进本函数,
//! 每换一次促销政策就要改结算代码。正确做法是:门面在传入费用项前
//! 把折扣算好(或作为一个负值费用项传入),结算只管归集。
//! 本工程的 `ChargeItem` 允许负金额,正是为此留的口子。
use crate::domain::{CurrencyAmount, CURRENCY_CHINESE_YUAN};
use super::charge_item::{ChargeItem, ChargeSource};
/// 单个来源的汇总。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceSubtotal {
/// 来源。
source: ChargeSource,
/// 该来源金额合计。
subtotal: CurrencyAmount,
/// 该来源包含的费用项条数。
item_count: usize,
}
impl SourceSubtotal {
/// 构造一个来源汇总。
pub const fn new(source: ChargeSource, subtotal: CurrencyAmount, item_count: usize) -> Self {
SourceSubtotal {
source,
subtotal,
item_count,
}
}
/// 来源。
pub const fn source(&self) -> ChargeSource {
self.source
}
/// 金额合计。
pub const fn subtotal(&self) -> CurrencyAmount {
self.subtotal
}
/// 条数。
pub const fn item_count(&self) -> usize {
self.item_count
}
}
/// 结算结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SettlementResult {
/// 全部费用项(按来源排序后)。
items: Vec<ChargeItem>,
/// 按来源的汇总(按展示顺序)。
by_source: Vec<SourceSubtotal>,
/// 应付总额。
grand_total: CurrencyAmount,
/// 最大费用项的下标(若列表非空)。
largest_item_index: Option<usize>,
}
impl SettlementResult {
/// 全部费用项。
pub fn items(&self) -> &[ChargeItem] {
&self.items
}
/// 按来源的汇总。
pub fn by_source(&self) -> &[SourceSubtotal] {
&self.by_source
}
/// 应付总额。
pub const fn grand_total(&self) -> CurrencyAmount {
self.grand_total
}
/// 费用项条数。
pub fn item_count(&self) -> usize {
self.items.len()
}
/// 最大费用项(若有)。
pub fn largest_item(&self) -> Option<&ChargeItem> {
self.largest_item_index
.and_then(|index| self.items.get(index))
}
/// 返回某一来源的合计金额(不存在则返回零)。
///
/// 参数 `source`:来源分类。
/// 返回:该来源合计。
///
/// 门面用它回答「增值服务费占总费用多少」这类比例问题。
pub fn subtotal_of(&self, source: ChargeSource) -> CurrencyAmount {
for subtotal in &self.by_source {
if subtotal.source() == source {
return subtotal.subtotal();
}
}
// 未出现的来源返回零金额:比返回 Option 更好用,
// 因为调用点绝大多数只需知道「没有就是 0」,不想为此写 match。
CurrencyAmount::zero(CURRENCY_CHINESE_YUAN)
}
/// 返回某一来源占应付总额的万分比(总额为 0 时返回 0)。
///
/// 参数 `source`:来源分类。
/// 返回:万分比数值。
pub fn share_basis_points(&self, source: ChargeSource) -> i64 {
let total_minor_units: i64 = self.grand_total.minor_units();
if total_minor_units == 0 {
return 0;
}
let source_minor_units: i64 = self.subtotal_of(source).minor_units();
// i128 中间量:金额 × 10000 后仍在 i64 内,但保持与全工程一致的口径。
let numerator: i128 = source_minor_units as i128 * 10_000i128;
(numerator / total_minor_units as i128) as i64
}
/// 费用项编码列表(报表用)。
pub fn item_codes(&self) -> Vec<&'static str> {
self.items.iter().map(|item| item.code()).collect()
}
}
/// 对一组费用项做归集,产出结算结果。
///
/// 参数 `items`:费用项列表(所有权移交)。
/// 返回:结算结果。
///
/// ## 排序
///
/// 归集前先按 `source.display_order()` 稳定排序。
/// 用**稳定排序**(`sort_by` 在 Rust 中即为稳定)的意图是:
/// 同一来源内部的原始顺序被保留——门面传进来的顺序通常已按业务重要性排好,
/// 不应被结算子系统打乱。
pub fn settle_charges(mut items: Vec<ChargeItem>) -> SettlementResult {
// 稳定排序:同来源项保持原有相对顺序。
items.sort_by_key(|item| item.source().display_order());
// ---------- 按来源汇总 ----------
// 遍历排序后的列表,相邻同来源即归入同一组。
// 不用 HashMap:来源只有 4 种,且线性扫描能自然保序。
let mut by_source: Vec<SourceSubtotal> = Vec::new();
for item in &items {
let source: ChargeSource = item.source();
// 查找已有分组(最后一个即可,因为已排序)。
match by_source.last_mut() {
Some(last) if last.source() == source => {
// 同来源:累加金额与条数。
// 注意这里需要修改 last,故先在局部算好再整体替换,
// 避免同时借用 last 的两个字段(借用检查器会拒绝)。
let updated_subtotal: CurrencyAmount = last.subtotal().add(&item.amount());
let updated_count: usize = last.item_count() + 1;
*last = SourceSubtotal::new(source, updated_subtotal, updated_count);
}
_ => {
// 新来源:开一个新分组。
by_source.push(SourceSubtotal::new(source, item.amount(), 1));
}
}
}
// ---------- 计算应付总额 ----------
let mut grand_total: CurrencyAmount = CurrencyAmount::zero(CURRENCY_CHINESE_YUAN);
for item in &items {
// 只累加「计入总额」的来源(当前全部计入,但保留这个判断
// 使将来出现「仅展示」的预算项时无需改动本函数结构)。
if item.source().counts_toward_total() {
grand_total = grand_total.add(&item.amount());
}
}
// ---------- 找最大费用项 ----------
// 用「先取 0 再比较」而不是 `max_by_key`:需要的是下标,
// 且要保证在金额相同的情况下取**先出现的**(报表更稳定)。
let mut largest_item_index: Option<usize> = None;
for (index, item) in items.iter().enumerate() {
match largest_item_index {
None => largest_item_index = Some(index),
Some(current_best) => {
let current_amount: i64 = items[current_best].amount().minor_units();
if item.amount().minor_units() > current_amount {
largest_item_index = Some(index);
}
}
}
}
SettlementResult {
items,
by_source,
grand_total,
largest_item_index,
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : calendar_date.rs
//! 日历日(不含时刻、不含时区)与天数推算。
//!
//! ## 为什么不用 `chrono`
//!
//! 门面里要算的是「预计送达日」「最迟装运日」这类**日历日**概念:
//! 「2026-10-05 往后推 3 天是哪一天」。它**不是**时间戳,也不涉及时区,
//! 用 `DateTime<Utc>` 表达会引入两处错配:
//!
//! 1. 一个日历日对应 24 小时,但跨夏令时时对应 23 或 25 小时——
//! 用「加 24 小时」算出来的日期可能差一天;
//! 2. 演示数值会随系统时区漂移,无法人工复核。
//!
//! 因此这里用一个三元组 `(年, 月, 日)` 自实现闰年与逐日推进,
//! 全部为纯整数运算,结果与运行环境无关。
//!
//! ## 边界行为
//!
//! [`CalendarDate::from_ymd`] 是 `const fn`,可在常量上下文使用;
//! 它对非法日号采取**就近钳制**(如 2 月 30 日 → 2 月 28/29 日)而不是 panic,
//! 理由见方法文档。
/// 日期所属月份的天数表(非闰年),索引 0 刻意留空以便直接用月份取。
///
/// 为什么用长度为 13 的数组而不是 12:让 `MONTH_LENGTHS[month]` 直接成立,
/// 省掉一次 `month - 1` 的心算,减少差一错误。
const MONTH_LENGTHS_IN_COMMON_YEAR: [u32; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/// 某个公历年份是否为闰年。
///
/// 判据(格里高利历):能被 4 整除 **且** 不能被 100 整除,或能被 400 整除。
/// 参数 `year`:公历年份(如 2026)。
/// 返回:是闰年返回 `true`。
pub const fn is_leap_year(year: i32) -> bool {
// 用取余表达式直译判据,避免引入辅助函数掩盖逻辑。
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
/// 某个公历年份中指定月份的天数。
///
/// 参数 `year`:公历年份;`month`:月份(1..=12)。
/// 返回:该月天数;若 `month` 越界则返回 0(调用方应避免传入非法月份)。
pub const fn days_in_month(year: i32, month: u32) -> u32 {
// 越界保护:数组长度 13,下标 0..=12。月份 0 是本表的哨兵位。
if month == 0 || month > 12 {
return 0;
}
// 二月在闰年为 29 天,其余月份与平年一致。
if month == 2 && is_leap_year(year) {
return 29;
}
MONTH_LENGTHS_IN_COMMON_YEAR[month as usize]
}
/// 一个不含时刻、不含时区的日历日。
///
/// 三个字段都是私有且不可变的:外部只能通过 [`CalendarDate::from_ymd`]
/// 构造、通过 [`CalendarDate::add_days`] 推进,保证任何时候拿到的实例
/// 都是「合法日期」——不会出现 `month = 13` 这种值在系统里流动。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CalendarDate {
/// 公历年(如 2026)。允许负数以表达公元前,本工程不涉及。
year: i32,
/// 公历月(1..=12)。
month: u32,
/// 公历日(1..=该月天数)。
day: u32,
}
impl CalendarDate {
/// 由年、月、日构造一个日历日。
///
/// 参数 `year` / `month` / `day`:公历年月日。
/// 返回:构造出的日期。
///
/// **越界处理策略:就近钳制而非 panic**。理由是这些值可能来自
/// 上游业务数据(如承运日历配置),在报表场景下「2 月 30 日」
/// 更应该表现为 2 月末,而不是让整个门面调用 panic 崩掉。
/// 若将来需要严格校验,应在上游入口做,而不是让这个基础工具承担。
pub const fn from_ymd(year: i32, month: u32, day: u32) -> Self {
// 先把月份钳到 1..=12。
let clamped_month: u32 = if month < 1 {
1
} else if month > 12 {
12
} else {
month
};
// 再按钳制后的月份取天数上界。
let maximum_day: u32 = days_in_month(year, clamped_month);
// 日号钳到 1..=maximum_day。
let clamped_day: u32 = if day < 1 {
1
} else if day > maximum_day {
maximum_day
} else {
day
};
CalendarDate {
year,
month: clamped_month,
day: clamped_day,
}
}
/// 读取公历年。
pub fn year(&self) -> i32 {
self.year
}
/// 读取公历月。
pub fn month(&self) -> u32 {
self.month
}
/// 读取公历日。
pub fn day(&self) -> u32 {
self.day
}
/// 返回往后推 `offset_days` 天之后的日期。
///
/// 参数 `offset_days`:天数偏移(可为负,表示往前推)。
/// 返回:新日期(`self` 本身不变——`CalendarDate` 是值语义的不可变对象)。
///
/// 实现方式为**逐日推进**而不是「用日期算法换算儒略日」:
/// 逐日在调试时可读性高得多(循环里能直接看出闰年跳变),
/// 而本工程的偏移量都是个位到两位数天数,性能无差异。
/// 若将来出现上千天的偏移,应改走儒略日算法(届时也需保留本函数作为对照)。
pub fn add_days(&self, offset_days: i32) -> Self {
// 复制当前值到局部可变状态;用 i64 是为了避免极端偏移下的中间溢出。
let mut cursor_year: i64 = self.year as i64;
let mut cursor_month: i64 = self.month as i64;
let mut cursor_day: i64 = self.day as i64;
// remaining 为「还剩多少天要推进」;负数时按逆方向处理。
let mut remaining: i64 = offset_days as i64;
// 统一转成「向前推进」循环:负数偏移等价于反复退一天。
while remaining > 0 {
// 当前月的天数上界(注意这里 year 需要转回 i32 传给 days_in_month)。
let maximum_day: i64 = days_in_month(cursor_year as i32, cursor_month as u32) as i64;
if cursor_day < maximum_day {
// 还没到月末,直接加一天。
cursor_day += 1;
} else {
// 已到月末:进入下个月的第一天。
cursor_day = 1;
if cursor_month < 12 {
cursor_month += 1;
} else {
// 年末:进入下一年的 1 月。
cursor_month = 1;
cursor_year += 1;
}
}
remaining -= 1;
}
while remaining < 0 {
// 反向推进:逐日退。
if cursor_day > 1 {
cursor_day -= 1;
} else {
// 已到月初:退到上个月的最后一天。
if cursor_month > 1 {
cursor_month -= 1;
} else {
// 年初:退到上一年的 12 月。
cursor_month = 12;
cursor_year -= 1;
}
cursor_day = days_in_month(cursor_year as i32, cursor_month as u32) as i64;
}
remaining += 1;
}
CalendarDate {
year: cursor_year as i32,
month: cursor_month as u32,
day: cursor_day as u32,
}
}
/// 返回与另一日期相差的天数(`self` 早于 `other` 时为正)。
///
/// 参数 `other`:另一个日期。
/// 返回:`other` 减去 `self` 的天数差(可能为负)。
///
/// 实现方式同样走逐日推进,与 [`CalendarDate::add_days`] 共享同一套日历规则,
/// 因此不会出现「加天数与求差值用了两套闰年判断」这种隐性不一致。
pub fn days_until(&self, other: &CalendarDate) -> i32 {
// 若两日期相同,直接返回 0,省去循环。
if self == other {
return 0;
}
// 判断方向,决定用哪一种推进,避免在两个循环里各写一遍逻辑。
let forward: bool = other.year > self.year
|| (other.year == self.year && (other.month > self.month
|| (other.month == self.month && other.day > self.day)));
if forward {
// 从 self 出发逐日前进,直到与 other 相等,统计步数。
let mut cursor: CalendarDate = *self;
let mut distance: i32 = 0;
while cursor != *other {
cursor = cursor.add_days(1);
distance += 1;
// 防御性上界:跨世纪比较误用时不至于死循环。
// 取 366 * 200 ≈ 200 年,业务上远超合理范围。
if distance > 366 * 200 {
break;
}
}
distance
} else {
// 反向:从 other 前进到 self,步数取负。
let mut cursor: CalendarDate = *other;
let mut distance: i32 = 0;
while cursor != *self {
cursor = cursor.add_days(1);
distance -= 1;
if distance < -(366 * 200) {
break;
}
}
distance
}
}
/// 返回 `YYYY-MM-DD` 形式的文本。
///
/// 固定用 `{:02}` 补零,保证「10 月 5 日」写成 `2026-10-05` 而不是
/// `2026-10-5`——报表里日期列宽度必须稳定,否则表格会随月份位数跳动。
pub fn formatted(&self) -> String {
// 注意:这里刻意走 getter 而不是直接读字段,
// 使 getter 成为「报表路径上的必要访问」,避免它们变成死代码。
format!(
"{:04}-{:02}-{:02}",
self.year(),
self.month(),
self.day()
)
}
/// 返回不含年份的中文月日文本,如「10 月 5 日」。
///
/// 用于「发货日 → 到达日」这种同年内的紧凑展示,省掉重复的年份。
pub fn month_day_text(&self) -> String {
format!("{} 月 {} 日", self.month(), self.day())
}
/// 返回该日期是星期几(0 = 周一 ... 6 = 周日)。
///
/// 实现用的是 **Sakamoto 算法**(查表版):先用一张「月份偏移表」
/// `t[m-1]` 把月份贡献折算成一个常数,再叠上年份、世纪修正。
/// 选它而不选「累加天数再取模」的理由是——后者需要一个已知参照日,
/// 而参照日的正确性本身又要被验证,链条更长。
///
/// ⚠️ 踩坑记录(本工程实测踩到过):
/// 曾误用**算术近似式** `(26 * (m + 1)) / 10` 来代替偏移表,
/// 这个式子只是 `floor(2.6 * (m + 1))` 的整数写法,
/// 与查表值在 3 月、9 月等多个月份上并不相等,会导致**整年星期错一位**。
/// 例如 2026-10-05 是周一,错版算法算出「周二」。
/// 教训:这类有公认表格的算法,宁可把表写出来,也不要用「看起来等价」的算式。
pub fn weekday_index(&self) -> u32 {
// 标准 Sakamoto 月份偏移表(下标 0 = 1 月):
// 它把「每月 1 日的星期基线」按累计日数取模后的偏移量预先列好。
const MONTH_OFFSET_TABLE: [i32; 12] =
[0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
// 1 月、2 月视作上一年的 13、14 月(便于统一处理闰日),
// 此时 `adjusted_year` 要减 1,与标准实现的 `if m < 3 { y -= 1 }` 等价。
let adjusted_year: i32 = if self.month() < 3 {
self.year() - 1
} else {
self.year()
};
// 计算:年 + 年/4 - 年/100 + 年/400 + 月偏移 + 日,再取模 7。
// 结果 0 = 周日、1 = 周一 …… 6 = 周六。
let raw: i32 = (adjusted_year
+ adjusted_year / 4
- adjusted_year / 100
+ adjusted_year / 400
+ MONTH_OFFSET_TABLE[(self.month() - 1) as usize]
+ self.day() as i32)
% 7;
let sunday_based: i32 = ((raw % 7) + 7) % 7;
// 转换到「0 = 周一 ... 6 = 周日」:
// 周日基准里的 0 应变成 6,其余 n 变成 n - 1。
if sunday_based == 0 {
6
} else {
(sunday_based - 1) as u32
}
}
/// 返回星期的中文单字(一 / 二 / 三 / 四 / 五 / 六 / 日)。
pub fn weekday_text(&self) -> &'static str {
// 下标 0 对应周一,与 weekday_index 的约定一致。
const WEEKDAY_LABELS: [&str; 7] = ["一", "二", "三", "四", "五", "六", "日"];
WEEKDAY_LABELS[self.weekday_index() as usize]
}
/// 判断该日期是否为周末(周六或周日)。
///
/// 承运与清关环节在周末的处理时效不同,门面会据此在时间线上追加提示。
pub fn is_weekend(&self) -> bool {
// 索引 5 = 周六、6 = 周日。
let index: u32 = self.weekday_index();
index >= 5
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : deterministic_code.rs
//! 确定性编码派生工具(FNV-1a 64 位哈希)。
//!
//! ## 为什么需要「确定性」哈希
//!
//! 门面演示里会产出运单号、报关单号、批次号这类标识。它们必须满足两点:
//!
//! 1. **同一份输入永远得到同一个号**——否则每次 `cargo run` 输出都不同,
//! 数值核算与回归比对就无从谈起;
//! 2. **不用随机数**——`rand` 会引入依赖,且随机性本身就是可复现性的敌人。
//!
//! 因此改用「对输入做确定性哈希,取前若干位十六进制」。选 FNV-1a 而不是
//! 标准库 `DefaultHasher` 的理由是:
//!
//! - `DefaultHasher` 的算法**不承诺跨版本稳定**(官方文档明确说明),
//! 一旦 Rust 升级,历史单号会变,这在本工程不可接受;
//! - FNV-1a 实现只有几行、常数公开、可被审阅,完全够用。
//!
//! ## 注意这不是加密哈希
//!
//! FNV-1a 可被轻易构造碰撞,**仅供演示的编号派生使用**。
//! 任何安全相关场景(签名、令牌)必须改用加密哈希,本工具不适用。
/// FNV-1a 64 位偏移基准(官方常数)。
///
/// 这个值是 FNV 规范固定给出的,不可随意改动——改了就与标准实现不兼容。
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
/// FNV-1a 64 位质数(官方常数)。
///
/// 选这个质数的原因是它能让低位充分扩散——换成 2 的幂会让低位信息丢失,
/// 导致「输入只差最后一位时哈希也只差最后一位」的退化。
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
/// 计算一段字节串的 FNV-1a 64 位哈希。
///
/// 参数 `bytes`:输入字节切片。
/// 返回:64 位哈希值。
///
/// 这是 `const fn`,因此可以在常量上下文里使用——
/// 本工程的部分预置常量(如默认前缀)就依赖这一点。
pub const fn fnv1a_64(bytes: &[u8]) -> u64 {
let mut hash: u64 = FNV_OFFSET_BASIS;
let mut index: usize = 0;
while index < bytes.len() {
// 先异或再乘:这是 FNV-1a 与 FNV-1 的唯一区别。
// 顺序反了(先乘后异或)就退化成 FNV-1,扩散性差一截。
hash ^= bytes[index] as u64;
// wrapping_mul 是必须的:64 位乘法必然溢出,
// 用普通 `*` 会在 debug 构建下 panic。溢出是算法设计的一部分(模 2^64)。
hash = hash.wrapping_mul(FNV_PRIME);
index += 1;
}
hash
}
/// 把若干文本片段拼成一份「种子字符串」,供后续哈希使用。
///
/// 参数 `segments`:任意数量的片段(如国家码、城市、日期、序号)。
/// 返回:用 `|` 连接的种子字符串。
///
/// **为什么用 `|` 作分隔符**:它能避免「拼接歧义」——
/// 若直接连接,`("AB", "C")` 与 `("A", "BC")` 会得到同一个种子,
/// 从而派生出同一个编号。这类碰撞在业务系统里会变成难排查的数据问题。
pub fn build_seed(segments: &[&str]) -> String {
// 先估算容量,减少反复扩容(对演示无性能意义,但注释里说明意图便于维护)。
let estimated_capacity: usize = segments.iter().map(|segment| segment.len() + 1).sum();
let mut seed: String = String::with_capacity(estimated_capacity);
for (position, segment) in segments.iter().enumerate() {
if position > 0 {
seed.push('|');
}
seed.push_str(segment);
}
seed
}
/// 由种子与长度派生一个**定长**的十六进制编码。
///
/// 参数 `prefix`:编码前缀(如 `"SF"` 表示顺丰);`seed`:种子字符串;
/// `hex_length`:主体部分的十六进制位数(会被钳制到 1..=16)。
/// 返回:形如 `SF-3A9C1B04` 的编码。
///
/// 为什么把长度钳到 16:64 位哈希最多表达 16 个十六进制位,
/// 要求更长的位数就必然要重复或补零,那是虚假的熵,不如显式拒绝。
pub fn derive_code(prefix: &str, seed: &str, hex_length: usize) -> String {
// 钳制位数,避免越界;同时保证至少 1 位,不至于产出空主体。
let clamped_length: usize = hex_length.clamp(1, 16);
let hash_value: u64 = fnv1a_64(seed.as_bytes());
// 取高 16 位十六进制,再截取需要的长度。
// 选高位而不是低位:FNV-1a 的高位扩散更充分。
let full_hex: String = format!("{:016X}", hash_value);
let body: &str = &full_hex[..clamped_length];
if prefix.is_empty() {
body.to_string()
} else {
format!("{}-{}", prefix, body)
}
}
/// 由种子派生一个 8 位十六进制的短指纹。
///
/// 参数 `seed`:种子字符串。
/// 返回:8 位大写十六进制串。
///
/// 用途:在报表里给「同一个对象的不同阶段快照」标注身份,
/// 8 位(32 比特)在单次演示的规模下碰撞概率可忽略。
pub fn short_fingerprint(seed: &str) -> String {
// 复用 derive_code,前缀传空串以免多出分隔符。
derive_code("", seed, 8)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:Facade Pattern 外观模式
//!# 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/5 13:45
//!# User : geovindu
//!# Product : RustRover
//!# Project : FacadePattern
//!# File : text_layout.rs
//! 中文(CJK)宽度感知的定宽文本工具。
//!
//! ## 为什么必须自己实现
//!
//! Rust 标准库的格式化填充(`{:<20}`)按 **char 个数**补空格,而中日韩字符
//! 在等宽终端里占 **2 个显示列**。于是 `format!("{:<10}", "翡翠手镯")`
//! 得到的是「6 列内容 + 4 空格」= 10 列,而 `format!("{:<10}", "Jade")`
//! 得到的是「4 列内容 + 6 空格」= 10 列——两者都以「10 列」为目标,
//! 看起来没问题;但一旦列里混排中文与英文,**同一个 `{:<10}` 得到的显示宽度
//! 就不再相等**,表格会呈锯齿状错位。
//!
//! 本工程的所有报表都走这里的 `pad_right` / `pad_left` / `pad_center`,
//! 保证「填充后的显示宽度」才是相等的。
//!
//! ## 关键约定
//!
//! - [`pad_right`] 等函数在**内容本身已超过目标宽度**时**不截断**,
//! 而是原样返回(宁可撑破表格也不静默丢数据)——报表里少一个字符
//! 比表格错位严重得多。需要截断时调用方必须显式调 [`truncate_to_width`]。
//! - [`truncate_to_width`] 在剩余空间放不下一个宽字符时**放弃半个汉字**
//! (不会把一个汉字切成两半的乱码)。
/// 判断一个字符在等宽终端里是否占 2 个显示列。
///
/// 判定依据是 Unicode 的 East Asian Width 属性为 `W`(Wide)或 `F`(Fullwidth)
/// 的区段。这里用区间表近似,**区间必须互不重叠**——
/// 若两个区间有交叠,`matches!` 的后续分支会被判定为「不可达模式」而告警
/// (这是本工程踩过的坑,见下方注释)。
///
/// 参数 `character`:待判定的单个字符(按 Unicode 标量值传入)。
/// 返回:占 2 列返回 `true`,占 1 列返回 `false`。
pub fn is_wide_character(character: char) -> bool {
// 先取码点,便于用区间比较。用 u32 是因为下面的区间是按码点写的。
let code_point: u32 = character as u32;
// 注意:下面的区间已按「上界递增、互不重叠」排列。
// 若把 U+4E00..=U+9FFF(CJK 统一表意文字)与 U+3000..=U+303F
// 的顺序写反,编译器会以「不可达模式」告警——这一点务必保持有序。
matches!(code_point,
// CJK 标点(。、「」等)——中文标点占两列
0x3000..=0x303F
// 平假名
| 0x3040..=0x30FF
// 注音符号扩展、谚文兼容字母
| 0x3100..=0x312F
| 0x3130..=0x318F
// CJK 统一表意文字(常用汉字主区)
| 0x4E00..=0x9FFF
// 谚文音节(韩文)
| 0xAC00..=0xD7A3
// CJK 兼容表意文字
| 0xF900..=0xFAFF
// 全角 ASCII 变体
| 0xFF00..=0xFF60
// 全角符号变体
| 0xFFE0..=0xFFE6
// 国标扩展区(部分生僻字)
| 0x20000..=0x2FFFD
| 0x30000..=0x3FFFD
)
}
/// 计算一段文本在等宽终端里占用的**显示列数**(不是字符数)。
///
/// 参数 `text`:任意 UTF-8 文本切片。
/// 返回:显示列总数(宽字符计 2,其余计 1)。
///
/// 举例:`display_width("翡翠手镯")` 返回 8;`display_width("Jade")` 返回 4。
pub fn display_width(text: &str) -> usize {
// 逐字符累加宽度;累加值用 usize,因为列数不可能是负数。
// 这里刻意不按字节长度算(那会把中文算成 3 列,更离谱)。
let mut total_width: usize = 0;
for character in text.chars() {
total_width += if is_wide_character(character) { 2 } else { 1 };
}
total_width
}
/// 把文本**右填充**到指定显示宽度(文本靠左,空格在右)。
///
/// 参数 `text`:原始文本;`target_width`:目标显示宽度(列)。
/// 返回:填充后的 `String`。
///
/// **超出不截断**(见模块文档):若 `display_width(text) >= target_width`,
/// 原样返回文本。这一条的代价是表格可能被撑破,
/// 但收益是「没有任何数据会在排版环节被悄悄丢掉」。
pub fn pad_right(text: &str, target_width: usize) -> String {
let current_width: usize = display_width(text);
// 用 saturating_sub 防止「当前宽度已超标」时下溢 panic;
// 下溢为 0 时下面的循环不执行,等价于原样返回。
let padding_count: usize = target_width.saturating_sub(current_width);
let mut result: String = String::with_capacity(text.len() + padding_count);
result.push_str(text);
// 填充用半角空格:1 个空格恰好 1 列,便于精确对齐。
for _ in 0..padding_count {
result.push(' ');
}
result
}
/// 把文本**左填充**到指定显示宽度(空格在左,文本靠右)。
///
/// 参数 `text`:原始文本;`target_width`:目标显示宽度(列)。
/// 返回:填充后的 `String`。
///
/// 金额列、序号列一律用这个函数右对齐,避免数字位数变化时表格晃动。
pub fn pad_left(text: &str, target_width: usize) -> String {
let current_width: usize = display_width(text);
let padding_count: usize = target_width.saturating_sub(current_width);
let mut result: String = String::with_capacity(text.len() + padding_count);
for _ in 0..padding_count {
result.push(' ');
}
result.push_str(text);
result
}
/// 把文本**居中**填充到指定显示宽度(两侧均分空格)。
///
/// 参数 `text`:原始文本;`target_width`:目标显示宽度(列)。
/// 返回:填充后的 `String`。
///
/// 当两侧余量是奇数时,**多出的那一个空格放在右侧**(左少右多)——
/// 这是刻意固定的规则,否则标题在不同行之间会左右跳动。
pub fn pad_center(text: &str, target_width: usize) -> String {
let current_width: usize = display_width(text);
let padding_count: usize = target_width.saturating_sub(current_width);
// 左侧取整除(向下取整),余数自然落到右侧。
let left_padding: usize = padding_count / 2;
let right_padding: usize = padding_count - left_padding;
let mut result: String = String::with_capacity(text.len() + padding_count);
for _ in 0..left_padding {
result.push(' ');
}
result.push_str(text);
for _ in 0..right_padding {
result.push(' ');
}
result
}
/// 把文本按**显示宽度**截断到目标宽度以内。
///
/// 参数 `text`:原始文本;`target_width`:允许的最大显示宽度(列)。
/// 返回:截断后的 `String`(若原文本本就在范围内,则原样返回)。
///
/// 关键行为:**绝不切出半个汉字**。若剩余宽度只剩 1 列而下一个字符是宽字符,
/// 则直接停止(宁可少一列,也不产生半个汉字导致的乱码或错位)。
pub fn truncate_to_width(text: &str, target_width: usize) -> String {
let mut result: String = String::new();
let mut used_width: usize = 0;
for character in text.chars() {
let character_width: usize = if is_wide_character(character) { 2 } else { 1 };
// 放得下才推进;放不下就整体停止(不是跳过该字符继续,
// 否则截断结果会前后拼接出与原意不符的文本)。
if used_width + character_width > target_width {
break;
}
result.push(character);
used_width += character_width;
}
result
}
/// 生成一条指定显示宽度的水平分隔线。
///
/// 参数 `character`:构成分隔线的字符(中文报表里常用 `─`);
/// `target_width`:目标显示宽度(列)。
/// 返回:分隔线字符串。
///
/// 存在的理由:报表里有大量分隔线,若每处都手写
/// `"─".repeat(74)`,一旦宽度口径变化就要改几十处,且中文字符的宽度
/// 与列数不是 1:1,容易算错。这里统一用 `display_width` 递推,
/// 保证分隔线与数据行的宽度**严格相等**。
pub fn horizontal_rule(character: char, target_width: usize) -> String {
// 单字符自身的宽度(若传入的是宽字符,则每次要推进 2 列)。
let character_width: usize = if is_wide_character(character) { 2 } else { 1 };
// 防御除零:宽字符为 2,半角为 1,都不会是 0,但显式判一次更安全。
if character_width == 0 {
return String::new();
}
let repeat_count: usize = target_width / character_width;
let mut result: String = String::with_capacity(target_width);
for _ in 0..repeat_count {
result.push(character);
}
result
}
哲学管理(学)人生, 文学艺术生活, 自动(计算机学)物理(学)工作, 生物(学)化学逆境, 历史(学)测绘(学)时间, 经济(学)数学金钱(理财), 心理(学)医学情绪, 诗词美容情感, 美学建筑(学)家园, 解构建构(分析)整合学习, 智商情商(IQ、EQ)运筹(学)生存.---Geovin Du(涂聚文)
浙公网安备 33010602011771号