rust: Flyweight Pattern
项目结构:

//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : compliance_severity.rs
//! # 合规问题严重度 —— 封闭枚举
//!
//! ## 为什么这一个用枚举而不用开放型结构体
//!
//! 判定标准是:**该维度的取值会决定代码分支吗?**
//!
//! 会。严重度直接决定三件事:
//! 1. 是否阻断排班发布(`blocks_publication`);
//! 2. 报表用什么标记(`marker`);
//! 3. 排序优先级(`priority`)。
//!
//! 这类「取值即分支」的维度用枚举,好处是 `match` 的**穷尽性检查**:
//! 若将来真加了第 4 个档次,编译器会强制我们更新每一处 `match`,
//! 一个都不会漏。若用开放型结构体 + `if/else`,新档次会静默落入
//! 某个 `else` 分支——这正是最难查的 bug。
//!
//! 对照地看:`SkillGrade`、`StoreGrade`、`ShiftCode` 这些**不决定分支**
//! (只用于展示、分组、参与键),所以用开放型结构体。
//!
//! 这个判定标准要一直用下去,不要「凭感觉选」。
//!
//! ## 为什么只有三档
//!
//! 合规问题的处理路径只有三条:阻断(必须改)、警告(建议改)、提示(知悉即可)。
//! 多加档次会迫使报表与审批流增加分支,而业务上并无第 4 种处理方式。
//! 「档次数量由处理路径数量决定」——这是比「凭直觉分级」更可靠的依据。
/// 合规问题的严重度。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ComplianceSeverity {
/// 提示:知悉即可,不影响发布。
Information,
/// 警告:建议调整,可人工确认后发布。
Warning,
/// 阻断:必须调整,否则不允许发布排班。
Blocker,
}
impl ComplianceSeverity {
/// 中文名称。
pub const fn label(&self) -> &'static str {
match self {
ComplianceSeverity::Information => "提示",
ComplianceSeverity::Warning => "警告",
ComplianceSeverity::Blocker => "阻断",
}
}
/// 报表里使用的前缀标记(单字符,便于对齐)。
///
/// 用 ASCII 字符而非中文:中文标记占 2 列,
/// 会让这一列比其他列宽,破坏表格对齐。
pub const fn marker(&self) -> &'static str {
match self {
ComplianceSeverity::Information => "·",
ComplianceSeverity::Warning => "!",
ComplianceSeverity::Blocker => "x",
}
}
/// 排序优先级(数值越大越严重)。
///
/// 用于报表按严重度降序排列,让最需要处理的问题排在最上面。
pub const fn priority(&self) -> u8 {
match self {
ComplianceSeverity::Information => 0,
ComplianceSeverity::Warning => 1,
ComplianceSeverity::Blocker => 2,
}
}
/// 是否阻断排班发布。
///
/// 只有 `Blocker` 阻断。`Warning` 虽然需要人工确认,
/// 但确认后即可发布——「需要人工介入」与「禁止发布」是两件事,
/// 混为一谈会让运营被迫为每条警告走一次审批,实际结果是「全都点通过」,
/// 反而失去警示作用。
pub const fn blocks_publication(&self) -> bool {
match self {
ComplianceSeverity::Blocker => true,
// Information 与 Warning 都不阻断。
ComplianceSeverity::Warning | ComplianceSeverity::Information => false,
}
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : currency_amount.rs
//! # 货币与金额 —— 整数「分」的存储
//!
//! ## 为什么金额必须是整数
//!
//! 本工程要计算「排班总人力成本」并把它打印出来。若用 `f64` 存金额:
//! 1. **累加会漂移**:`0.1 + 0.2 != 0.3`,几万条排班累加后误差肉眼可见;
//! 2. **相等不可靠**:享元池的键里若含金额(本工程不含,但将来可能),
//! 浮点相等在 `NaN` 与 `-0.0` 上有陷阱;
//! 3. **内存度量失真**:`f64` 恒定 8 字节,但金额的语义精度是「分」,
//! 用 8 字节存一个只需几十分位的量,是明确的浪费。
//!
//! 因此本类型用 `i64` 存**最小单位**(人民币的「分」),
//! 并用 `currency` 字段记录币种。深圳总部用人民币、中国香港门店用港币,
//! 两种币种不能直接相加——`add` 会在币种不一致时给出 `None`。
//!
//! ## 「分」而不是「元」的乘法保护
//!
//! 时薪 × 时长是核心运算。若用「元」为单位,
//! 一个 ¥28.50 的时薪乘 480 分钟会先得到浮点中间值,再四舍五入——
//! 每一步都可能丢分。用「分」后:`2850 分/时` 与 `480 分` 相乘,
//! 中间量用 `i128` 承载,最后一次性四舍五入到「分」,
//! **全程只有一次舍入**。这是本工程「金额可核对」的前提。
//!
//! ## 关于与 `ratio` 的同层互相引用
//!
//! 本文件需要 `BASIS_POINTS_DENOMINATOR`(万分比的分母),
//! 而 `ratio.rs` 需要 `CurrencyAmount`(用于 `Ratio::apply_to`)。
//! 两个同层模块互相引用在 Rust 里完全合法,也**不违反分层约束**——
//! 依赖方向检查关心的是「层与层之间」,同层内的模块互引不构成环。
//!
//! 但互相引用仍要克制:若同层模块之间形成密集网,说明这一层
//! 应该再切成两个层。本层只有这一处双向引用,且是两个紧邻概念
//! (金额与比率)之间的天然耦合,可以接受。
// 同层引用:万分比分母常量。
use super::ratio::BASIS_POINTS_DENOMINATOR;
/// 币种标签(开放型)。
///
/// ## 为什么不是枚举
///
/// 连锁珠宝零售商会在新地区开店(新加坡、马来西亚、日本……),
/// 币种数量会增长。枚举每加一种都要改本文件,违反「工程外可扩展」。
/// 用 `const fn new` 结构体后,扩展者在自己代码里就能定义新币种:
///
/// ```ignore
/// // 工程外(main.rs 的扩展区),不需要改 domain 层
/// const CURRENCY_SINGAPORE_DOLLAR: Currency =
/// Currency::new("SGD", "新加坡元", 100, "S$");
/// ```
///
/// 字段私有 + `const fn new` 的代价是「无法在编译期阻止拼错币种编码」,
/// 但收益是「新增币种零侵入」。对本工程而言,后者更重要——
/// 这正是开放型标签与封闭枚举的取舍标准。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Currency {
/// 币种编码(如 `"CNY"`、`"HKD"`)。参与相等比较,作为币种身份。
code: &'static str,
/// 中文名称(如 `"人民币"`),仅用于展示。
display_name: &'static str,
/// 一个主单位包含多少个最小单位(人民币为 100,即 100 分)。
minor_units_per_major_unit: i64,
/// 货币符号(如 `"¥"`),仅用于展示。
symbol: &'static str,
}
impl Currency {
/// 构造一个币种。
///
/// 参数 `code` / `display_name` / `minor_units_per_major_unit` / `symbol`。
/// 返回:币种标签。
///
/// `const fn` 使本函数可用于定义 `const` 常量——
/// 这是「工程外零侵入扩展」能成立的技术前提。
pub const fn new(
code: &'static str,
display_name: &'static str,
minor_units_per_major_unit: i64,
symbol: &'static str,
) -> Currency {
Currency {
code,
display_name,
minor_units_per_major_unit,
symbol,
}
}
/// 币种编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn display_name(&self) -> &'static str {
self.display_name
}
/// 一个主单位包含多少个最小单位。
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` 是最小单位数(人民币即「分」)。
/// 允许为负:退款、成本冲销等场景需要负金额。
#[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) -> CurrencyAmount {
CurrencyAmount {
minor_units,
currency,
}
}
/// 由「主单位 + 最小单位」构造(如 `(12, 34)` → `¥12.34`)。
///
/// 参数 `major_units` / `minor_unit_part` / `currency`。
/// 返回:金额。
///
/// 中间量用 `i128`:`major_units` 若接近 `i64::MAX`,
/// 乘以 100 会溢出。虽然实际业务不会出现这种值,
/// 但用一个更宽的中间类型只是写起来多几个字符,却能免除一整类溢出风险。
pub const fn from_major_and_minor(
major_units: i64,
minor_unit_part: i64,
currency: Currency,
) -> CurrencyAmount {
let total_minor_units: i128 =
major_units as i128 * currency.minor_units_per_major_unit as i128
+ minor_unit_part as i128;
CurrencyAmount {
minor_units: clamp_i128_to_i64(total_minor_units),
currency,
}
}
/// 零金额。
pub const fn zero(currency: Currency) -> CurrencyAmount {
CurrencyAmount::from_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
}
/// 取绝对值。
///
/// 注意 `i64::MIN` 的绝对值仍是 `i64::MIN`(溢出环绕),
/// 因此这里用 `saturating_abs`:极端值下钳到 `i64::MAX` 而非回绕成负数。
/// 业务上不会出现 `i64::MIN` 分(约 9.2e16 元),但用饱和运算只是代价极小的保险。
pub const fn absolute(&self) -> CurrencyAmount {
CurrencyAmount {
minor_units: self.minor_units.saturating_abs(),
currency: self.currency,
}
}
/// 相加。币种不一致时返回 `None`。
///
/// 参数 `other`:另一笔金额。
/// 返回:同币种时返回和,否则 `None`。
///
/// ## 为什么返回 `Option` 而不是 panic 或静默按本方币种处理
///
/// 把人民币和港币相加在业务上是**错误**,不是「需要兜底」的情况。
/// panic 会让整个报表拿不到(一个门店币种配错,全表都看不见);
/// 静默混算会产出看似合理的错误数字(最危险)。
/// 返回 `None` 让错误**在调用点显式暴露**,由调用方决定是跳过还是报错。
pub fn add(&self, other: &CurrencyAmount) -> Option<CurrencyAmount> {
if self.currency != other.currency {
return None;
}
// 用 i128 中间量,避免两个接近 i64::MAX 的金额相加溢出。
let sum: i128 = self.minor_units as i128 + other.minor_units as i128;
Some(CurrencyAmount {
minor_units: clamp_i128_to_i64(sum),
currency: self.currency,
})
}
/// 相减。币种不一致时返回 `None`。
pub fn subtract(&self, other: &CurrencyAmount) -> Option<CurrencyAmount> {
if self.currency != other.currency {
return None;
}
let difference: i128 = self.minor_units as i128 - other.minor_units as i128;
Some(CurrencyAmount {
minor_units: clamp_i128_to_i64(difference),
currency: self.currency,
})
}
/// 乘以一个整数倍数。
///
/// 参数 `quantity`:倍数(如「几件」「几人」)。
/// 返回:乘积金额。
pub fn multiply_by_quantity(&self, quantity: i64) -> CurrencyAmount {
let product: i128 = self.minor_units as i128 * quantity as i128;
CurrencyAmount {
minor_units: clamp_i128_to_i64(product),
currency: self.currency,
}
}
/// 按万分比缩放(如「上浮 12.5%」写作 `1250` 万分点)。
///
/// 参数 `basis_points`:万分点。
/// 返回:缩放后的金额。
///
/// 这是**本工程的时薪计算入口**:`基础时薪 × 班次系数`。
/// 例如基础时薪 ¥28.50(2850 分)乘以夜班系数 12500 万分点:
/// `2850 * 12500 / 10000 = 3562.5` → 远离零方向四舍五入 → `3563` 分 = ¥35.63。
pub fn scale_by_basis_points(&self, basis_points: i64) -> CurrencyAmount {
// 三步:乘 → 除 → 舍入。中间量用 i128 承载乘积。
let scaled: i128 = self.minor_units as i128 * basis_points as i128;
let rounded: i128 = divide_rounded_away_from_zero(scaled, BASIS_POINTS_DENOMINATOR as i128);
CurrencyAmount {
minor_units: clamp_i128_to_i64(rounded),
currency: self.currency,
}
}
/// 按「一小时 = 60 分钟」的比例折算:`self × minutes / 60`。
///
/// 参数 `minutes`:分钟数。
/// 返回:折算后的金额。
///
/// ## 为什么需要这个入口,而不是在外面写 `× minutes / 60`
///
/// 本工程的人力成本公式是「时薪 × 计薪分钟数 / 60」。
/// 若各处自己写这个式子,会出现两种不一致的口径:
/// - 有人先算 `minutes / 60`(整数除法 → 丢掉不足一小时的分钟);
/// - 有人先乘后除(正确)。
///
/// 前者会在 420 分钟(7 小时整)时恰好正确,
/// 而在 390 分钟(6.5 小时)时把结果算成 6 小时的钱——
/// **且这个错误只在「非整小时」的班次上出现**,
/// 而本工程的盘点夜班恰好是 390 分钟。这类「只在部分数据上出错」
/// 的 bug 最难发现,因此必须把公式收进一个统一入口。
///
/// 内部用 `i128` 承载乘积,全程只舍入一次。
pub fn scale_by_minute_fraction(&self, minutes: u32) -> CurrencyAmount {
let numerator: i128 = self.minor_units as i128 * minutes as i128;
// 分母固定为 60(一小时 60 分钟),这是时间单位换算的定义,不是业务参数。
let rounded: i128 = divide_rounded_away_from_zero(numerator, 60i128);
CurrencyAmount {
minor_units: clamp_i128_to_i64(rounded),
currency: self.currency,
}
}
/// 返回 `¥1,234.56` 形式的展示文本。
///
/// 实现要点:
/// 1. 负数先分离符号,绝对值参与分拆(否则 `-1234 % 100` 会得到 `-34`);
/// 2. 按币种的 `minor_units_per_major_unit` 拆分主单位与最小单位
/// ——**不要硬编码 100**。日元没有最小单位(`minor_units_per_major_unit = 1`),
/// 硬编码 100 会把 ¥1000 显示成 ¥10.00;
/// 3. 千分位由自由函数插入。
pub fn formatted(&self) -> String {
let sign: &str = if self.minor_units < 0 { "-" } else { "" };
let absolute_value: i64 = self.minor_units.saturating_abs();
let unit_scale: i64 = self.currency.minor_units_per_major_unit();
// 主单位部分与最小单位部分。
let major_part: i64 = absolute_value / unit_scale;
let minor_part: i64 = absolute_value % unit_scale;
// 最小单位的补零宽度由 `unit_scale` 决定:100 → 2 位,1 → 0 位,1000 → 3 位。
let minor_digits: usize = decimal_digits_of(unit_scale);
if minor_digits == 0 {
// 无最小单位(如日元):不输出小数点。
format!(
"{}{}{}",
sign,
self.currency.symbol(),
insert_thousands_separator(major_part)
)
} else {
format!(
"{}{}{}.{:0width$}",
sign,
self.currency.symbol(),
insert_thousands_separator(major_part),
minor_part,
width = minor_digits
)
}
}
/// 返回 `1234.56 CNY` 形式的展示文本(金额 + 币种编码)。
///
/// 用于「多币种并列」的场景:符号 `¥` 与 `HK$` 并排时容易看混,
/// 加上币种编码可以消歧。本工程的深圳/中国香港双币种报表会用到。
pub fn formatted_with_currency_code(&self) -> String {
let sign: &str = if self.minor_units < 0 { "-" } else { "" };
let absolute_value: i64 = self.minor_units.saturating_abs();
let unit_scale: i64 = self.currency.minor_units_per_major_unit();
let major_part: i64 = absolute_value / unit_scale;
let minor_part: i64 = absolute_value % unit_scale;
let minor_digits: usize = decimal_digits_of(unit_scale);
if minor_digits == 0 {
format!(
"{}{} {}",
sign,
insert_thousands_separator(major_part),
self.currency.code()
)
} else {
format!(
"{}{}.{:0width$} {}",
sign,
insert_thousands_separator(major_part),
minor_part,
self.currency.code(),
width = minor_digits
)
}
}
}
/// 把 `i128` 安全钳制到 `i64` 范围。
///
/// 参数 `value`:待钳制的值。
/// 返回:落在 `i64::MIN..=i64::MAX` 内的值。
///
/// 这是本工程唯一的「溢出兜底」函数,所有 `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` / `denominator`。
/// 返回:舍入后的商。
///
/// ## 为什么不用 `(a + b/2) / b`
///
/// 那个写法有四个问题,本工程的金额核算依赖此函数,因此必须写对:
/// 1. **只对正数成立**:`-7/2` 用该式得到 `-3`(向零),而「远离零」应为 `-4`;
/// 2. **中途相加可能溢出**:`a + b/2` 在 `a` 接近上界时会溢出;
/// 3. **b 为负时不成立**;
/// 4. **它不是显式的**:读者要从表达式反推舍入方向。
///
/// 本实现用「取商 + 取余 + 按余数修正」的显式步骤,
/// 每一步的舍入方向都写在注释里,可被逐行核对。
pub const fn divide_rounded_away_from_zero(numerator: i128, denominator: i128) -> i128 {
if denominator == 0 {
// 除零:返回 0 而非 panic。业务上不会有零分母,
// 但报表崩掉比出现一个可疑的 0 更难排查——错误要可见。
return 0;
}
// 第一步:取商的整数部分(Rust 的整数除法天然向零截断)。
let quotient: i128 = numerator / denominator;
// 第二步:取余数,判断是否需要进位/借位。
let remainder: i128 = numerator % denominator;
if remainder == 0 {
// 整除,无舍入。
return quotient;
}
// 第三步:符号一致时「远离零」,即商的绝对值加 1。
// `numerator` 与 `denominator` 同号 ⇒ 结果为正 ⇒ 加 1;
// 异号 ⇒ 结果为负 ⇒ 减 1。两者都是「远离零」。
let same_sign: bool = (numerator > 0) == (denominator > 0);
if same_sign {
quotient + 1
} else {
quotient - 1
}
}
/// 返回一个整数对应的十进制位数(用于最小单位的补零宽度)。
///
/// 参数 `value`:正整数(传入 `1` / `100` / `1000`)。
/// 返回:位数(`1` → 0,`100` → 2,`1000` → 3)。
///
/// 定义:`value = 10^n` 时返回 `n`;其余情况返回 `1` 作为兜底。
/// 用 `match` 精确列出而非 `log10` 取对数——`log10` 会引入浮点,
/// 而本工程「全程无浮点」是硬性纪律。
pub const fn decimal_digits_of(value: i64) -> usize {
match value {
1 => 0,
10 => 1,
100 => 2,
1_000 => 3,
10_000 => 4,
// 非常见取值范围:按 1 位处理,保证不 panic。
_ => 1,
}
}
/// 给整数的十进制文本插入千分位分隔符。
///
/// 参数 `value`:非负整数。
/// 返回:形如 `1,234,567` 的文本。
///
/// 实现思路(**从右往左**每三位一插):
/// 先把数字转字符串,再从个位方向每 3 位打一个逗号。
/// 用「临时字符数组 + 反向读取」而不是「按位置切片拼接」,
/// 后者在字符串长度不是 3 的倍数时要处理前导不足,分支更多。
fn insert_thousands_separator(value: i64) -> String {
let digits: String = value.to_string();
let digit_bytes: &[u8] = digits.as_bytes();
let mut buffer: Vec<char> = Vec::with_capacity(digit_bytes.len() + digit_bytes.len() / 3);
// 反向遍历:`reversed_index` 是「从右数第几位」(0 基)。
let mut reversed_index: usize = 0;
while reversed_index < digit_bytes.len() {
// 不是最右一位、且已满 3 的倍数 → 先放一个逗号。
if reversed_index > 0 && reversed_index % 3 == 0 {
buffer.push(',');
}
// 取「从右数第 reversed_index 位」的字符(ASCII 数字,直接按字节取即可)。
let byte_position: usize = digit_bytes.len() - 1 - reversed_index;
buffer.push(digit_bytes[byte_position] as char);
reversed_index += 1;
}
// buffer 是反的,倒回来。
buffer.reverse();
buffer.into_iter().collect()
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : pay_period_kind.rs
//! # 计薪时段类型 —— 开放型标签
//!
//! ## 为什么时段类型要进「班次模板的键」
//!
//! 同一个「早班」,在平日与法定节假日的**时薪系数不同**:
//! - 平日:系数 1.00;
//! - 周末:系数 1.10(客流高,公司主动上浮留人);
//! - 大促:系数 1.50;
//! - 法定节假日:系数 2.00(法定要求)。
//!
//! 这四种情形的「班次时刻、时长、休息」完全一样,只有系数不同。
//! 若把时段类型漏出键,早班模板就只能取一个系数——
//! 其余三种时段的排班成本会**算错**。
//!
//! 第四幕会把这个错误算成具体金额(若漏进键,全年人力成本会偏差数万元)。
//!
//! ## 与 `ShiftCode::requires_security_presence` 的对照
//!
//! 那个方法用「编码硬比较」判断是否需要安保,属于**行为判断**;
//! 本类型把「时段 → 系数」的映射**放在标签自身的字段里**,
//! 属于**数据携带**。两种做法在本工程里并存,是因为:
//! - 安保需求是「少数班次才有的特例」,写成集中的一处判断更易读;
//! - 时段系数是「每种时段都有值」,放在字段里可免除任何 `match`。
//!
//! 判断标准:**特例用集中判断,普适值用字段携带。**
// 同层引用:系数类型。
use super::ratio::Ratio;
/// 一个计薪时段类型标签。
///
/// 身份即 `code`,手写相等与哈希(理由见 `ShiftCode`)。
#[derive(Debug, Clone, Copy)]
pub struct PayPeriodKind {
/// 时段编码(如 `"HOLIDAY"`)。**唯一参与相等与哈希的字段**。
code: &'static str,
/// 中文名称(如 `"法定节假日"`)。
display_name: &'static str,
/// 该时段的时薪上浮系数(万分点)。`Ratio::one()` 表示不上浮。
surcharge_multiplier: Ratio,
}
impl PartialEq for PayPeriodKind {
/// 只用编码比较身份。
fn eq(&self, other: &PayPeriodKind) -> bool {
self.code == other.code
}
}
impl Eq for PayPeriodKind {}
impl std::hash::Hash for PayPeriodKind {
/// 只把编码喂给 hasher,与 `PartialEq` 口径一致。
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.code.hash(state);
}
}
impl PayPeriodKind {
/// 构造一个时段类型。
///
/// 参数 `code` / `display_name` / `surcharge_multiplier`。
/// 返回:时段类型标签。
pub const fn new(
code: &'static str,
display_name: &'static str,
surcharge_multiplier: Ratio,
) -> PayPeriodKind {
PayPeriodKind {
code,
display_name,
surcharge_multiplier,
}
}
/// 时段编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn display_name(&self) -> &'static str {
self.display_name
}
/// 时薪上浮系数。
pub const fn surcharge_multiplier(&self) -> Ratio {
self.surcharge_multiplier
}
/// 是否为「上浮时段」(系数大于 1 倍)。
///
/// 报表用它把排班表里需要额外计薪的时段标出来。
pub const fn is_surcharged(&self) -> bool {
self.surcharge_multiplier.is_above_one()
}
}
/// 平日:不上浮。
pub const PAY_PERIOD_NORMAL: PayPeriodKind =
PayPeriodKind::new("NORMAL", "平日", Ratio::one());
/// 周末:上浮 10%。
pub const PAY_PERIOD_WEEKEND: PayPeriodKind =
PayPeriodKind::new("WEEKEND", "周末", Ratio::from_basis_points(11_000));
/// 大促:上浮 50%。
pub const PAY_PERIOD_PROMOTION: PayPeriodKind =
PayPeriodKind::new("PROMOTION", "大促", Ratio::from_basis_points(15_000));
/// 法定节假日:上浮 100%(法定要求)。
pub const PAY_PERIOD_HOLIDAY: PayPeriodKind =
PayPeriodKind::new("HOLIDAY", "法定节假日", Ratio::from_basis_points(20_000));
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : ratio.rs
//! # 比率 —— 整数万分比
//!
//! ## 为什么是「万分比」而不是「百分比」
//!
//! 排班场景里出现的比率精度差异很大:
//! - 时薪系数:夜班 1.25 倍 → 需要 2 位小数(12500 万分点);
//! - 加班附加率:1.5 倍 → 同上;
//! - 起征/阈值:85.5% → 需要 1 位小数(8550 万分点);
//! - 法定倍率:2 倍 → 整数倍。
//!
//! 若用百分比(分母 100),`1.25` 倍就只能写成 `125%` 再加一个「倍」的单位,
//! 一旦出现 `1.125` 倍(如节假日加班)就没法表达了。
//! 万分比的精度足以覆盖所有常见业务倍率,且**分母固定为 10000**,
//! 所有比率的乘除都走同一个常量,将来改精度只改一处。
//!
//! ## 「零比率」也要走统一运算路径
//!
//! 免税、免加班费等场景需要「零倍率」。**不要**在调用点写成
//! `CurrencyAmount::zero(...)` 绕过本类型——那样将来「零」的口径变化
//! (比如某地区规定最低倍率 1.0)就要翻遍所有调用点。
//! 正确做法是写 `Ratio::zero().apply_to(amount)`,
//! 让「零」也经过统一路径。这条经验来自同系列 Abstract Factory 工程。
// 同层引用:`apply_to` 需要金额类型。同层模块互引不违反分层约束
// (依赖方向检查只看层与层),但不要形成密集的双向网。
use super::currency_amount::CurrencyAmount;
/// 万分比表示的一个比率。
///
/// `basis_points` 为 10000 时表示 1 倍(100%)。
/// 可为负:经济型班次或淡季下调系数时为负值(如 `-800` 表示 -8%)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Ratio {
/// 万分点。10000 = 1 倍 = 100%。
basis_points: i64,
}
/// 万分比的分母。
///
/// 本工程所有比率运算共用此常量。它被导出的唯一理由是:
/// 「分析层」在把万分点转成展示文本时需要它,
/// 而「硬编码 10000」在分析层出现两遍以上就会成为漂移源。
pub const BASIS_POINTS_DENOMINATOR: i64 = 10_000;
impl Ratio {
/// 由万分点构造。
///
/// 参数 `basis_points`:万分点。
/// 返回:比率。
pub const fn from_basis_points(basis_points: i64) -> Ratio {
Ratio { basis_points }
}
/// 零比率(0%)。
pub const fn zero() -> Ratio {
Ratio { basis_points: 0 }
}
/// 一倍(100%)。
pub const fn one() -> Ratio {
Ratio {
basis_points: BASIS_POINTS_DENOMINATOR,
}
}
/// 万分点值。
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
}
/// 是否大于 1 倍(100%)。
///
/// 排班报表用这个判定标出「上浮」项(加班、夜班、节假日),
/// 与「下调」项(淡季减班)区分开。
pub const fn is_above_one(&self) -> bool {
self.basis_points > BASIS_POINTS_DENOMINATOR
}
/// 把一个金额按本比率缩放。
///
/// 参数 `amount`:待缩放的金额。
/// 返回:缩放后的金额。
///
/// 这是**唯一**的比率→金额运算路径。
/// 调用方不要自己写 `amount.minor_units() * bp / 10000`——
/// 那样的表达式会绕过舍入口径(`divide_rounded_away_from_zero`),
/// 造成「同一笔钱在不同代码路径下差 1 分」的问题。
pub fn apply_to(&self, amount: &CurrencyAmount) -> CurrencyAmount {
amount.scale_by_basis_points(self.basis_points)
}
/// 返回百分比文本,如 `125.00%`。
///
/// 拆法:整数部分 = `基点数 / 100`,小数部分 = `基点数 % 100`。
/// 也就是「基点数 / 10000」的分数形式换算成百分比后的两位小数。
pub fn as_percent_text(&self) -> String {
// 取绝对值参与分拆,符号单独处理(否则 -12500 % 100 得 -0,展示成 "-125.-0%")。
let absolute_value: i64 = self.basis_points.abs();
let whole_part: i64 = absolute_value / 100;
let fractional_part: i64 = absolute_value % 100;
let sign: &str = if self.basis_points < 0 { "-" } else { "" };
format!("{}{}.{:02}%", sign, whole_part, fractional_part)
}
/// 返回倍数文本,如 `1.2500 倍`。
///
/// 小数部分直接取万分点的后四位,得到 4 位小数。
/// 与 `as_percent_text` 的区别在于:百分比把 10000 当整体(100%),
/// 倍数把 10000 当 1。报表里两个口径都会用到,
/// 「时薪系数」用倍数更直观,「上浮比例」用百分比更直观。
pub fn as_multiplier_text(&self) -> String {
let absolute_value: i64 = self.basis_points.abs();
let whole_part: i64 = absolute_value / BASIS_POINTS_DENOMINATOR;
let fractional_part: i64 = absolute_value % BASIS_POINTS_DENOMINATOR;
let sign: &str = if self.basis_points < 0 { "-" } else { "" };
// 小数部分补零到 4 位(万分位)。
format!("{}{}.{:04} 倍", sign, whole_part, fractional_part)
}
/// 返回原始万分点文本,如 `12500 万分点`。
///
/// 调试与核对时用:看到 `12500` 就能立刻确认是 1.25 倍,
/// 不会被百分比与倍数两套口径绕晕。
pub fn as_basis_points_text(&self) -> String {
format!("{} 万分点", self.basis_points)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : shift_code.rs
//! # 班次编码 —— 开放型标签
//!
//! ## 为什么班次编码是开放型而不是枚举
//!
//! 珠宝零售商的班次种类会随经营节奏增加:
//! 常规的早/中/晚班、盘点夜班、节庆加强班、VIP 预约专场、培训专场……
//! 每新增一种就改一次 `domain` 层,等于「扩展要动核心」。
//! 用 `const fn new` 结构体后,扩展者在自己代码里定义即可:
//!
//! ```ignore
//! // 工程外(main.rs 的扩展区),domain 层一行未改
//! const SHIFT_CODE_VIP_APPOINTMENT: ShiftCode =
//! ShiftCode::new("VIP_APP", "VIP 预约班", 45);
//! ```
//!
//! ## 但这个标签不只是「名字」
//!
//! `display_order` 字段让排序成为标签自身的能力,而不是在外层写
//! 一个 `match` 去映射编码→顺序。若用外层 `match`,新增班次时那段
//! `match` 会被漏改(编译器**不会**报错,因为 `_ => ...` 兜底分支吞掉了一切),
//! 从而产生「新班次排在最后」这类静默错误。
//! 把顺序放进标签,新增班次时**必须**给出顺序,否则构造不出来。
//!
//! 这条经验来自同系列 Abstract Factory 工程:「登记表把选择从编译期
//! `match` 提升为运行期数据」,此处是同一思路的局部应用。
/// 一个班次的编码标签。
///
/// 本类型只描述「这是哪种班次」,**不含**任何时长、时刻、系数等排班参数
/// ——那些是 `flyweight::ShiftTemplate` 的内容。
/// 编码标签是**键的一部分**(决定共享粒度),模板是**被共享的享元本体**。
/// 分开的理由:键要尽量窄(键越小,池的索引越省内存),
/// 而享元本体则要包含全部展示与计算所需的信息。
///
/// ## 相等与哈希是手写的,不派生(这个选择很关键)
///
/// 本类型有 `code` / `display_name` / `display_order` 三个字段。
/// 若直接 `#[derive(PartialEq, Eq, Hash)]`,三个字段全部参与比较与哈希——
/// 于是「编码相同但显示名拼法不同」的两个标签会被判为**不相等**,
/// 享元池里就会为同一个班次建两个模板,共享率凭空下降。
///
/// **身份即编码**。因此手写实现,只让 `code` 参与:
/// - `PartialEq`:`code` 相同即相等;
/// - `Hash`:只把 `code` 喂给 hasher。
///
/// 这条纪律对**所有会作为池键的类型**都适用(见 `SkillGrade`、
/// `PayPeriodKind`、`StoreGrade`)。派生看起来很省事,
/// 但它把「展示字段」误当成「身份字段」,是享元池最常见的静默缺陷来源。
#[derive(Debug, Clone, Copy)]
pub struct ShiftCode {
/// 编码(如 `"EARLY"`)。**唯一参与相等与哈希的字段**。
code: &'static str,
/// 中文名称(如 `"早班"`)。仅展示,不参与相等与哈希。
display_name: &'static str,
/// 展示顺序(升序排列)。仅展示,不参与相等与哈希。
display_order: u16,
}
impl PartialEq for ShiftCode {
/// 只用编码比较身份。
fn eq(&self, other: &ShiftCode) -> bool {
self.code == other.code
}
}
// 手写 `PartialEq` 后必须显式实现 `Eq`:
// 这是对「相等满足自反/对称/传递」的承诺。本实现满足(底层是 `str` 比较)。
impl Eq for ShiftCode {}
impl std::hash::Hash for ShiftCode {
/// 只把编码喂给 hasher,与 `PartialEq` 的口径保持一致。
///
/// ⚠️ 若 `PartialEq` 与 `Hash` 口径不一致(一个比三个字段、
/// 一个只哈希一个字段),`HashMap` 的行为是**未定义**的——
/// 表现为「明明存在的键查不到」,且难以复现。
/// 因此这两个实现必须成对修改,永远一起看。
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.code.hash(state);
}
}
impl ShiftCode {
/// 构造一个班次编码。
///
/// 参数 `code` / `display_name` / `display_order`。
/// 返回:班次编码标签。
pub const fn new(
code: &'static str,
display_name: &'static str,
display_order: u16,
) -> ShiftCode {
ShiftCode {
code,
display_name,
display_order,
}
}
/// 编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn display_name(&self) -> &'static str {
self.display_name
}
/// 展示顺序。
pub const fn display_order(&self) -> u16 {
self.display_order
}
/// 是否为需要「双人在场」的班次。
///
/// 珠宝门店的硬性规定:**任何班次都必须至少两人**(防内盗)。
/// 但「是否需要额外加一名安保」只对特定班次成立——
/// 夜班盘点涉及开保险柜,需要安保在场。
///
/// ## 为什么用编码判断而不是加一个字段
///
/// 加字段(`requires_security: bool`)会让「哪些班次需要安保」这个事实
/// 散落到每个常量定义处。而用编码判断,这个知识集中在**一处**,
/// 且新增班次时若漏了这里,编译器不会报错但**行为可见**——
/// 报表里新班次会显示「无需安保」,一眼能看出问题。
///
/// 两种做法各有风险,本工程选「集中判断」,并在注释里说明取舍。
/// 更好的做法是把它做成可配置的登记表,但那会增加本工程与
/// Abstract Factory 工程的重叠度,因此这里刻意保持在简单形态。
pub fn requires_security_presence(&self) -> bool {
// 盘点夜班涉及开柜,需要安保。
self.code == SHIFT_CODE_NIGHT_AUDIT.code
}
}
/// 早班:09:00 - 17:00。
pub const SHIFT_CODE_EARLY: ShiftCode = ShiftCode::new("EARLY", "早班", 10);
/// 中班:13:00 - 21:00。
pub const SHIFT_CODE_MIDDLE: ShiftCode = ShiftCode::new("MIDDLE", "中班", 20);
/// 晚班:15:00 - 23:00。
pub const SHIFT_CODE_LATE: ShiftCode = ShiftCode::new("LATE", "晚班", 30);
/// 盘点夜班:22:30 - 次日 06:30(开保险柜,需安保在场)。
pub const SHIFT_CODE_NIGHT_AUDIT: ShiftCode = ShiftCode::new("NIGHT_AUDIT", "盘点夜班", 40);
/// 周末加强班:10:00 - 19:00(客流高峰,工时更长)。
pub const SHIFT_CODE_WEEKEND_BOOST: ShiftCode = ShiftCode::new("WEEKEND_BOOST", "周末加强班", 50);
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : skill_grade.rs
//! # 技能等级 —— 开放型标签
//!
//! ## 为什么技能等级是开放型
//!
//! 珠宝零售的技能分级会随品类扩张而细化:
//! 见习 → 初级 → 中级 → 高级 → 技师 → (将来可能有)珠宝鉴定师、镶嵌师……
//! 每加一级都要改 `domain`,就违反了「工程外可扩展」。
//!
//! ## 与班次模板的关系
//!
//! 「技能等级」是**班次模板的键的一部分**(见 `flyweight::shift_template_key`):
//! 同一个「晚班」,对「初级」与「高级」员工是两个不同的模板——
//! 因为两人的时薪基数与法定持证要求不同。
//! 这就是享元键必须包含的维度:**凡影响享元内容的因素都要进键**。
//!
//! 反过来说:如果把技能等级漏出键,晚班模板就只能取一个等级,
//! 另一个等级的员工会被套用错误时薪。第五幕会把这个错误算成具体金额。
// 同层内引用:`Ratio` 与本文件同属 `domain` 层,用 `super` 相对路径。
// 不用 `crate::domain::ratio::Ratio` 这种从根写起的长路径——
// 同层引用写长路径会让「这是同层依赖」这个事实在代码里看不出来。
use super::ratio::Ratio;
/// 一个技能等级标签。
///
/// 与 [`super::shift_code::ShiftCode`] 同样手写相等与哈希:
/// **身份即 `code`**,`display_name` / `hourly_base_multiplier` /
/// `requires_certification` 都是随编码决定的派生属性,不参与身份比较。
/// 理由见 `ShiftCode` 的文档——派生会把展示字段误当身份字段。
#[derive(Debug, Clone, Copy)]
pub struct SkillGrade {
/// 等级编码(如 `"SENIOR"`)。**唯一参与相等与哈希的字段**。
code: &'static str,
/// 中文名称(如 `"高级"`)。
display_name: &'static str,
/// 相对时薪基数(万分点)。10000 表示基数本身,12000 表示基数上浮 20%。
///
/// ## 为什么时薪基数放在等级上而不是员工上
///
/// 若放在员工上(每人一个时薪),则「同等级员工时薪相同」这一
/// 公司政策就无法在类型上体现,且排班成本核算要遍历每个员工。
/// 放在等级上后,时薪 = `等级基数 × 班次系数 × 门店系数`,
/// 三个因子都是**可共享的内在状态**——这正是享元模式的着力点。
///
/// 员工的个体差异(如工龄津贴)通过「外在状态」表达(见 `client` 层),
/// 不进享元。
hourly_base_multiplier: Ratio,
/// 该等级是否需要持证上岗(如贵金属鉴定证)。
requires_certification: bool,
}
impl PartialEq for SkillGrade {
/// 只用编码比较身份。
fn eq(&self, other: &SkillGrade) -> bool {
self.code == other.code
}
}
impl Eq for SkillGrade {}
impl std::hash::Hash for SkillGrade {
/// 只把编码喂给 hasher,与 `PartialEq` 口径一致。
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.code.hash(state);
}
}
impl SkillGrade {
/// 构造一个技能等级。
///
/// 参数 `code` / `display_name` / `hourly_base_multiplier` / `requires_certification`。
/// 返回:技能等级标签。
pub const fn new(
code: &'static str,
display_name: &'static str,
hourly_base_multiplier: Ratio,
requires_certification: bool,
) -> SkillGrade {
SkillGrade {
code,
display_name,
hourly_base_multiplier,
requires_certification,
}
}
/// 等级编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn display_name(&self) -> &'static str {
self.display_name
}
/// 相对时薪基数(万分点)。
pub const fn hourly_base_multiplier(&self) -> Ratio {
self.hourly_base_multiplier
}
/// 是否需要持证上岗。
pub const fn requires_certification(&self) -> bool {
self.requires_certification
}
}
/// 见习:基数 80%,无需持证。
pub const SKILL_GRADE_PROBATION: SkillGrade =
SkillGrade::new("PROBATION", "见习", Ratio::from_basis_points(8_000), false);
/// 初级:基数 100%,无需持证。
pub const SKILL_GRADE_JUNIOR: SkillGrade =
SkillGrade::new("JUNIOR", "初级", Ratio::from_basis_points(10_000), false);
/// 高级:基数 120%,需持证。
pub const SKILL_GRADE_SENIOR: SkillGrade =
SkillGrade::new("SENIOR", "高级", Ratio::from_basis_points(12_000), true);
/// 技师:基数 145%,需持证(可负责鉴定与镶嵌)。
pub const SKILL_GRADE_TECHNICIAN: SkillGrade =
SkillGrade::new("TECHNICIAN", "技师", Ratio::from_basis_points(14_500), true);
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : staff_number.rs
//! # 员工工号 —— 带校验的标识
//!
//! ## 与其他两个标签的区别:它有校验逻辑
//!
//! `StoreCode` 与 `ShiftCode` 只是「包装过的字符串」,
//! 而工号在真实系统里有格式约束(本工程约定为 `字母 + 6 位数字`,
//! 如 `E100234`)。把这个约束放进类型,好处是**排班槽位在构造时就拦截脏数据**。
//!
//! ## 为什么校验失败不 panic
//!
//! 与同工程其他「fail visible」选择一致:本工程的工号可能来自
//! 工程外扩展区的演示数据。若构造非法工号直接 panic,
//! 整个演示崩掉、什么都看不到。因此 [`StaffNumber::from_text`]
//! 返回 `Option`,由调用方决定如何处理——
//! 在 `main.rs` 的演示数据构造处,调用方会 `expect` 并以明确信息报错,
//! 因为这属于**工程自身的数据错误**,应当立即暴露。
/// 一个员工工号。
///
/// 内部存的是**原始文本**而不是解析后的数字:
/// 工号可能带前缀字母(`E100234`),且报表要原样打印。
/// 若只存数字部分,展示时还要把字母拼回去——反而多一份状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct StaffNumber {
/// 工号文本(已校验格式)。
text: &'static str,
}
impl StaffNumber {
/// 校验并构造一个工号。
///
/// 参数 `text`:工号文本。
/// 返回:格式合法时返回 `Some`,否则 `None`。
///
/// 格式约定:**恰好 1 个大写字母 + 恰好 6 位数字**,共 7 个字符。
/// 例:`E100234`、`S008877`。
///
/// ## 为什么要求 `&'static str`
///
/// 本工程所有标识都取自预置常量或演示数据(都是 `'static`)。
/// 用 `&'static str` 让 `StaffNumber` 保持 `Copy`(8 字节指针),
/// 若改成 `String` 会变成 24 字节 + 堆分配——
/// 而排班槽位里有工号字段,这个宽度差异会乘以数万倍。
/// 这是享元工程必须计较的地方。
pub fn from_text(text: &'static str) -> Option<StaffNumber> {
let bytes: &[u8] = text.as_bytes();
// 长度必须恰好 7。
if bytes.len() != 7 {
return None;
}
// 首位必须是大写字母 A-Z。
if !bytes[0].is_ascii_uppercase() {
return None;
}
// 后 6 位必须都是数字 0-9。
for position in 1..7 {
if !bytes[position].is_ascii_digit() {
return None;
}
}
Some(StaffNumber { text })
}
/// 工号文本。
pub const fn text(&self) -> &'static str {
self.text
}
/// 工号所属的字母前缀(如 `'E'`)。
///
/// 前缀表示岗位大类(`E` = 营业员,`S` = 安保,`M` = 店长)。
/// 报表按前缀分组时用得到。
///
/// 安全性:`from_text` 已保证首字符是大写 ASCII 字母,
/// 因此 `unwrap` 在类型不变式下不可能 panic。
/// 这里用 `expect` 而非 `unwrap`,是为了在万一不变量被破坏时
/// 给出「哪个不变量」的信息,而不是一个光秃秃的 panic。
pub fn position_prefix(&self) -> char {
self.text
.chars()
.next()
.expect("StaffNumber 的不变式保证首字符存在(from_text 已校验长度 7)")
}
/// 工号尾号(后 4 位),用于报表脱敏展示。
///
/// 报表打印工号时用 `E**0234` 形式,只露首字母与末 4 位。
/// 这是**演示工程里的刻意设计**:真实系统应更谨慎,
/// 但把「脱敏逻辑集中在类型上」这个做法本身是值得示范的——
/// 若散落在各处拼接,迟早有一处忘记脱敏。
pub fn masked_text(&self) -> String {
let characters: Vec<char> = self.text.chars().collect();
// 形如:首字符 + 两个星号 + 末 4 位(丢弃中间 2 位)。
// `from_text` 保证长度恰为 7,因此 `characters[3..7]` 必定合法。
format!(
"{}**{}",
characters[0],
characters[3..7].iter().collect::<String>()
)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : store_code.rs
//! # 门店编码 —— 开放型标签
//!
//! ## 为什么单独建一个类型而不是直接传 `&str`
//!
//! 排班槽位的种子是「门店编码 + 日期 + 班次编码」。
//! 若三者都是 `&str`,调用点写成 `build_seed(&[store, date, shift])` 时,
//! **实参顺序写反编译器不会报错**——`&str`、`&str`、`&str` 三个参数同型。
//! 这种错误一旦发生,排班编号会全线偏移,而报表看起来完全正常。
//!
//! 用 newtype 后,参数类型各不相同,顺序错位立刻是编译错误。
//! 这是 newtype 最经典的价值:**用类型承载「这个字符串是什么」的语义**。
//!
//! ## 为什么 store_grade 与编码分开
//!
//! 同一家门店在长期经营中可能升级(社区店 → 标准店 → 旗舰店)。
//! 等级影响排班配置(最低人数、是否配安保),编码不变。
//! 因此 `StoreCode`(身份)与 `StoreGrade`(等级)必须是两个独立维度——
//! 把它们揉成一个类型,门店升级时就要改所有历史排班的键。
/// 一家门店的编码标签。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct StoreCode {
/// 门店编码(如 `"SZ0001"`),形如「城市缩写 + 4 位序号」。
code: &'static str,
/// 城市名称(如 `"深圳"`),用于报表分组。
city_name: &'static str,
}
impl StoreCode {
/// 构造一个门店编码。
///
/// 参数 `code` / `city_name`。
/// 返回:门店编码标签。
pub const fn new(code: &'static str, city_name: &'static str) -> StoreCode {
StoreCode { code, city_name }
}
/// 门店编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 城市名称。
pub const fn city_name(&self) -> &'static str {
self.city_name
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : store_grade.rs
//! # 门店等级 —— 开放型标签
//!
//! ## 门店等级是本工程的「第二个享元族」
//!
//! 连锁零售商的门店分档经营:旗舰店(重点商圈、面积大)/
//! 标准店 / 社区店。同一档的门店**运营配置完全相同**:
//! 每日最低在岗人数、是否配专职安保、营业时段、是否需要每日盘点。
//!
//! 1000 家门店只对应 3~5 个等级配置。若每家门店各自持有一份配置副本,
//! 就是 1000 份重复数据;而用享元共享后只需 3~5 份。
//!
//! **为什么这比「班次模板」更能说明享元的价值**:
//! 班次模板的共享是「同一模板被多个槽位引用」,
//! 而门店等级的共享是「同一配置被多个**不同实体**(门店)引用」——
//! 后者更接近 GoF 原书里「字符对象的字形数据」的例子
//! (字形被多个字符位置共享)。
//!
//! 本工程同时实现两族享元,并让它们**共用同一个泛型池**
//! (`flyweight::shared_pool`),以此证明「享元机制本身可复用」,
//! 而不只是「某一种对象可以共享」。
//!
//! ## 门店等级会影响排班,但**不进班次模板的键**
//!
//! 门店等级改变的是「需要几个班次」「是否需要安保」,
//! 而不是「某个班的时长与系数」。因此它是 `client` 层的排班配置,
//! 不是班次享元的一部分。**分清「进键的维度」与「在外层的维度」
//! 是享元设计最容易出错的地方**——第三幕专门演示这个错误。
/// 一个门店等级标签。
///
/// 与 [`super::shift_code::ShiftCode`] 同样手写相等与哈希:**身份即 `code`**。
/// 本类型会作为 `flyweight::StoreGradeProfile` 的池键,
/// 因此身份口径必须精确——否则同一等级会被建成两个享元。
#[derive(Debug, Clone, Copy)]
pub struct StoreGrade {
/// 等级编码(如 `"FLAGSHIP"`)。**唯一参与相等与哈希的字段**。
code: &'static str,
/// 中文名称(如 `"旗舰店"`)。
display_name: &'static str,
}
impl PartialEq for StoreGrade {
/// 只用编码比较身份。
fn eq(&self, other: &StoreGrade) -> bool {
self.code == other.code
}
}
impl Eq for StoreGrade {}
impl std::hash::Hash for StoreGrade {
/// 只把编码喂给 hasher,与 `PartialEq` 口径一致。
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.code.hash(state);
}
}
impl StoreGrade {
/// 构造一个门店等级。
pub const fn new(code: &'static str, display_name: &'static str) -> StoreGrade {
StoreGrade { code, display_name }
}
/// 等级编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn display_name(&self) -> &'static str {
self.display_name
}
}
/// 旗舰店:位于核心商圈,客流最大。
pub const STORE_GRADE_FLAGSHIP: StoreGrade = StoreGrade::new("FLAGSHIP", "旗舰店");
/// 标准店:位于成熟商圈。
pub const STORE_GRADE_STANDARD: StoreGrade = StoreGrade::new("STANDARD", "标准店");
/// 社区店:位于住宅区,客流稳定但规模小。
pub const STORE_GRADE_COMMUNITY: StoreGrade = StoreGrade::new("COMMUNITY", "社区店");
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : work_duration.rs
//! # 工时 —— 整数分钟
//!
//! ## 为什么不用 `ClockTime`
//!
//! `ClockTime` 表示「一天内的某个点位」(0..=1439 且跨零点会绕圈)。
//! 而「工时」是**一段长度**:8 小时 = 480 分钟,可以是 0(休息),
//! 也可能超过一天(跨日连班在本工程里不存在,但类型不该禁止它)。
//!
//! 两者语义不同,混用会出问题:把 480 分钟当 `ClockTime` 会得到 08:00,
//! 这不是「8 小时」而是「早上八点」。因此分成两个类型。
//!
//! ## 为什么不用 `std::time::Duration`
//!
//! `Duration` 存「秒 + 纳秒」(16 字节)。工时只需要分钟粒度(业务上
//! 不存在「多上 30 秒」的排班),用 `u32` 分钟(4 字节)即可。
//! 省下的 12 字节乘以数万个排班槽位,是**真实的内存差异**——
//! 而本工程的核心论证正是内存。所以这里必须用最紧的类型。
//!
//! 这个取舍在普通项目里可能不值得(12 字节 × 3 万 = 360KB,可忽略),
//! 但享元工程的价值就在「把每个字段的宽度都算清楚」,
//! 因此本工程刻意选择紧类型,并在注释里说明理由。
/// 一段工时,以整数分钟计。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WorkDuration {
/// 分钟数。允许为 0(无休息)。
total_minutes: u32,
}
impl WorkDuration {
/// 由分钟数构造。
pub const fn from_minutes(total_minutes: u32) -> WorkDuration {
WorkDuration { total_minutes }
}
/// 由「时 + 分」构造。
///
/// 参数 `hours` / `minutes`。
/// 返回:工时。
pub const fn from_hours_and_minutes(hours: u32, minutes: u32) -> WorkDuration {
WorkDuration {
total_minutes: hours * 60 + minutes,
}
}
/// 零工时。
pub const fn zero() -> WorkDuration {
WorkDuration { total_minutes: 0 }
}
/// 分钟数。
pub const fn total_minutes(&self) -> u32 {
self.total_minutes
}
/// 是否为零。
pub const fn is_zero(&self) -> bool {
self.total_minutes == 0
}
/// 相加。
///
/// 用 `saturating_add`:两个「顶格」工时相加会回绕成很小的值,
/// 而饱和加法给出 `u32::MAX`——错误更容易被看出。
pub const fn add(&self, other: &WorkDuration) -> WorkDuration {
WorkDuration {
total_minutes: self.total_minutes.saturating_add(other.total_minutes),
}
}
/// 相减(不小于零)。
///
/// 参数 `other`:被减去的工时。
/// 返回:差值,若 `other` 更大则返回零。
///
/// 「不小于零」是业务约定:加班时长 = 实际工时 - 标准工时,
/// 若实际少于标准(早退),加班时长应为 0 而非负数。
/// 若业务需要负数语义(如「欠班」),应另加一个类型而不是改本函数——
/// 一个函数一种语义,才不会有「同一个减法在两种地方含义不同」的陷阱。
pub const fn subtract_floor_zero(&self, other: &WorkDuration) -> WorkDuration {
WorkDuration {
total_minutes: self.total_minutes.saturating_sub(other.total_minutes),
}
}
/// 求和(对一组工时)。
///
/// 参数 `durations`:工时切片。
/// 返回:总和。
///
/// 这是本工程最频繁执行的运算之一:数万个排班槽位的工时求和。
/// 用 `fold` 而非 `sum`:`sum` 需要实现 `Sum` trait,
/// 而 `saturating_add` 的饱和语义无法通过 `Sum` 表达。
pub fn sum(durations: &[WorkDuration]) -> WorkDuration {
let mut accumulated_minutes: u32 = 0;
for duration in durations {
accumulated_minutes = accumulated_minutes.saturating_add(duration.total_minutes);
}
WorkDuration {
total_minutes: accumulated_minutes,
}
}
/// 返回 `8 小时 0 分` 文本。
///
/// 排班表的「工时」列用这个口径:读「8 小时 30 分」比读「510 分钟」直观。
/// 但**只有展示**用这个口径,所有计算仍用分钟数。
pub fn formatted_hours_and_minutes(&self) -> String {
let hours: u32 = self.total_minutes / 60;
let minutes: u32 = self.total_minutes % 60;
format!("{} 小时 {} 分", hours, minutes)
}
/// 返回 `8.50` 形式的工时小时数文本(两位小数)。
///
/// 用于报表的「工时汇总」列:`8.50` 比 `8 小时 30 分` 更便于纵向对齐与口算。
/// 用整数运算得出两位小数(不借助浮点):
/// 分 = `minutes * 100 / 60`,再按百分位拆成小数。
///
/// ⚠️ 这里的除法**故意用截断而非四舍五入**:
/// `8 小时 20 分` = `500 * 100 / 60` = `833`(截断)→ `8.33`。
/// 若四舍五入会得到 `833`→ 同样是 `8.33`,但 `8 小时 10 分` = `816.67`
/// 截断得 `816`(`8.16`)、四舍得 `817`(`8.17`)。
/// 选截断的理由:`8.16` 与真实值 `8.1667` 之间的差距是「略微低估」,
/// 而报表读者对「工时」的心理预期是「不超过实际值」,低估比高估安全。
/// 这个选择必须写下来,否则会被人当 bug 改掉。
pub fn formatted_decimal_hours(&self) -> String {
// 先算「百分之一小时」的整数部分(截断)。
let hundredths_of_hour: u32 = self.total_minutes.saturating_mul(100) / 60;
let whole_hours: u32 = hundredths_of_hour / 100;
let fractional_hundredths: u32 = hundredths_of_hour % 100;
format!("{}.{:02}", whole_hours, fractional_hundredths)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : footprint.rs
//! # 享元内存足迹 —— 「共享省了多少内存」的度量契约
//!
//! ## 为什么需要这个 trait
//!
//! 享元模式的价值必须能被**量化**,否则「用了模式」与「没用模式」
//! 只是两种写法,说不出哪个更好。本工程要回答的问题是:
//!
//! > 若每个排班槽位各自持有一份完整的班次定义,需要多少字节?
//! > 用享元共享后,实际需要多少字节?差多少?差在哪?
//!
//! 回答它需要一个「这个享元占多少字节」的口径。而 `std::mem::size_of`
//! **只统计栈上部分**,不含 `String` / `Vec` 等堆分配的内容——
//! 而真实对象的内存开销恰恰大量来自堆。因此本 trait 要求类型**自报**足迹:
//! 栈内固定部分(`size_of`)+ 堆上内容(按内容长度计)。
//!
//! ## 口径的诚实性声明(重要)
//!
//! 本工程计算的是「**逻辑字节数**」,不是「进程 RSS 增量」。差异有三处,
//! 必须明说,否则读者会把数字当成精确值:
//!
//! 1. **不含分配器元数据**。`malloc` 每个分配块有 8~16 字节头部,
//! 还有最小分配粒度与对齐填充。本工程不计这些。
//! 2. **按内容长度计,不按容量计**。`String` 的实际容量可能大于长度
//! (`push_str` 的摊还增长策略),本工程按 `len()` 计。
//! 3. **不含 `Rc` 弱引用计数**。`Rc<T>` 的堆头是 `(strong, weak)` 两个
//! `usize`,本工程在池统计里按常量单独加,不重复计入实例本身。
//!
//! 这些口径**全部偏保守**(低估绝对量),但**不改变量级对比**——
//! 「共享后 1.0MB vs 不共享 3.2MB」这种结论不会因漏算分配器头部而翻转。
//! 若将来要写进正式性能报告,必须换成实测(`dhat` 等分配器剖析工具),
//! 不能沿用本口径。这句话要留在代码里,防止被误用。
/// 一个对象的内存足迹(逻辑字节数)。
///
/// 实现者返回「栈内固定部分 + 堆上内容」的估算值。
/// 见模块文档的口径声明。
pub trait FlyweightFootprint {
/// 返回本对象的估算字节数(逻辑口径,非进程 RSS)。
fn estimated_bytes(&self) -> usize;
}
/// `Rc<T>` 的堆上控制块大小。
///
/// `Rc<T>` 的有效载荷前有两个 `usize`:强引用计数与弱引用计数。
/// 在 64 位目标上即 2 × 8 = 16 字节。
///
/// ## 为什么写成函数而不是直接写 16
///
/// 32 位目标上是 8 字节。用 `size_of::<usize>() * 2` 表达,
/// 让本工程在任何目标上都不会算错——而写死 16 会在 32 位平台静默偏差。
/// 这类「平台相关的常量」一律用表达式而非字面量。
pub const fn reference_control_block_bytes() -> usize {
// 强计数 + 弱计数,各占一个 usize。
std::mem::size_of::<usize>() * 2
}
/// `Rc<T>` 引用本身(指针)的大小。
///
/// 即一个 `usize`。排班槽位里存 `Rc<ShiftTemplate>`,
/// 每个槽位为「指向共享实例的指针」付出这些字节。
pub const fn reference_pointer_bytes() -> usize {
std::mem::size_of::<usize>()
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : pool_snapshot.rs
//! # 池统计快照 —— 「共享省了多少」的计算
//!
//! ## 两个口径必须算清楚
//!
//! 设池中有 `D` 个不同键(distinct),累计被取用 `R` 次(request),
//! 每个实例的内容大小为 `B_i`(第 i 个实例)。
//!
//! ### 口径 A:用享元(本工程实际)
//!
//! ```text
//! 共享侧 = Σ B_i (每个不同键只存一份实例内容)
//! + D × 控制块 (每个实例一个 Rc 控制块)
//! + R × 指针 (每个持有点一个 Rc 指针)
//! ```
//!
//! ### 口径 B:不用享元(假设每个持有点各自克隆一份完整对象)
//!
//! ```text
//! 独立侧 = Σ (持有数_i × B_i) (每个持有点一份完整副本)
//! ```
//!
//! 「持有数_i」用 `Rc::strong_count - 1` 实测(见 `SharedPool::held_reference_total`
//! 的文档),**不是估算值**。
//!
//! ## 公式里每一项都必须能指出来源
//!
//! 本工程的每一个字节数都可追溯到具体代码:
//! - `B_i` ← 各享元类型的 `estimated_bytes()`(逐字段累加,见 `shift_template.rs`);
//! - 控制块 ← `reference_control_block_bytes()`(`2 × size_of::<usize>()`);
//! - 指针 ← `reference_pointer_bytes()`(`size_of::<usize>()`);
//! - 持有数 ← `Rc::strong_count`。
//!
//! 没有一个字面量是「拍脑袋估的」。**这是本工程「数值可核对」要求的落实方式**:
//! 不是让读者相信一个数,而是让每个数都能被追到源码行。
use super::footprint::FlyweightFootprint;
/// 池的累计计数器。
///
/// 全部是**单调递增**的累计值,与池内当前状态无关。
/// 用 `u64` 而非 `u32`:即便每秒取用百万次,也要跑 58 万年才溢出,
/// 不必担心长期运行的服务里计数回绕。
///
/// 字段公开:这是一个纯数据记录,没有任何不变式需要在构造时校验。
/// (对比 `CalendarDate` 的字段私有——那里有「月必须是 1..=12」的不变式。)
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PoolCounters {
/// 累计取用请求次数(含命中、未命中、被拒绝)。
pub request_count: u64,
/// 命中既有实例的次数。
pub hit_count: u64,
/// 未命中并新建实例的次数。
pub miss_count: u64,
/// 因容量上限被拒绝的新键次数。
pub rejected_count: u64,
}
impl PoolCounters {
/// 命中率(万分点)。
///
/// 返回:命中数 / 请求数 × 10000。
///
/// 分母为零时返回 0(而不是 panic)——空池查询命中率是合法操作,
/// 结果无意义但不应让报表崩掉。
pub fn hit_rate_basis_points(&self) -> i64 {
if self.request_count == 0 {
return 0;
}
// i128 中间量:两个 u64 相乘可能超出 i64。
let numerator: i128 = self.hit_count as i128 * 10_000i128;
(numerator / self.request_count as i128) as i64
}
/// 未命中率(万分点)。
///
/// 与命中率互补(两者之和为 10000,除非有被拒绝的请求)。
///
/// ## 为什么单独算而不是 `10000 - 命中率`
///
/// 因为被拒绝的请求既不算命中也不算未命中。
/// 若用减法,被拒绝的次数会被误算成「未命中」,
/// 掩盖「池已满」这个需要立刻处理的问题。
/// 报表同时打印三个比率,读者能立刻区分「共享不生效」与「池不够大」——
/// 这两种情况的处理方式完全不同(前者改键设计,后者扩池容量)。
pub fn miss_rate_basis_points(&self) -> i64 {
if self.request_count == 0 {
return 0;
}
let numerator: i128 = self.miss_count as i128 * 10_000i128;
(numerator / self.request_count as i128) as i64
}
/// 拒绝率(万分点)。
pub fn rejection_rate_basis_points(&self) -> i64 {
if self.request_count == 0 {
return 0;
}
let numerator: i128 = self.rejected_count as i128 * 10_000i128;
(numerator / self.request_count as i128) as i64
}
/// 是否有请求被拒绝(池满发生过)。
pub const fn has_rejection(&self) -> bool {
self.rejected_count > 0
}
}
/// 池在某一时刻的统计快照(值类型)。
///
/// 实现 `Default`:装配器先构造一个空骨架(快照全 0),
/// 装配结束后再写入真实快照。用 `Default` 让「忘记写入」表现为
/// 「报表显示全 0」而不是「显示上一次的旧值」——前者一眼可见。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PoolSnapshot {
/// 累计计数器。
pub counters: PoolCounters,
/// 池内不同键的数量(= 实例数)。
pub distinct_entry_count: usize,
/// 池内所有实例的内容字节之和(共享侧)。
pub intrinsic_bytes_total: usize,
/// 句柄侧字节之和:`持有数 × 指针 + 实例数 × 控制块`。
pub handle_bytes_total: usize,
/// 池内实例当前被外部持有的句柄总数。
pub held_reference_total: u64,
/// 该池的容量上限(`None` 表示不限)。
pub entry_limit: Option<usize>,
}
impl PoolSnapshot {
/// 共享侧总字节(用享元的实际开销)。
///
/// = 实例内容 + 句柄侧。
pub const fn shared_total_bytes(&self) -> usize {
// `usize` 相加可能溢出,用饱和加法(溢出时钳到 `usize::MAX`,
// 报表里会出现一个荒谬的大数,一眼可见)。
self.intrinsic_bytes_total.saturating_add(self.handle_bytes_total)
}
/// 独立侧总字节(假设每个持有点各自克隆一份完整实例)。
///
/// 参数 `intrinsic_bytes_per_instance`:单个实例的内容字节数。
///
/// ## 为什么需要参数,而不是用「平均实例大小」
///
/// 池内不同实例的大小可能不等(本工程里「盘点夜班」的模板
/// 与「早班」的模板字段数相同,大小其实相等;但这是一个偶然,
/// 不能依赖)。用**平均大小 × 持有数**在大小不齐时会失真。
///
/// 更精确的做法是逐实例累加 `持有数_i × B_i`。本工程把这一步
/// 放在 `PoolSnapshot::unshared_total_bytes_exact` 里,
/// 由池在构造快照时提供逐实例数据;而本函数提供一个
/// 「已知均匀大小」时的便捷版本。
///
/// 之所以两个都留:便利版本用于快速检查,精确版本用于最终报表。
/// 若只留一个,要么精度不够,要么每次调用都要传一整个数组。
pub const fn unshared_total_bytes_of_uniform(
&self,
intrinsic_bytes_per_instance: usize,
) -> usize {
(self.held_reference_total as usize)
.saturating_mul(intrinsic_bytes_per_instance)
}
/// 共享带来的节省字节数(不共享 - 共享),可能为负。
///
/// 参数 `unshared_total_bytes`:独立侧总字节。
///
/// ## 为什么可能为负
///
/// 当「共享开销」大于「复制开销」时会为负,典型情形:
/// - 池里实例数很多但每个只被引用一两次(共享毫无收益却多付了控制块);
/// - 实例本身极小(如一个 `u32`),而 `Rc` 的控制块有 16 字节。
///
/// **返负值时不能截断为 0**——那会掩盖「这个场景不该用享元」这个结论。
/// 本工程第四幕会刻意构造一个「共享反而更费内存」的场景,
/// 用负数曝露出来。一个只会在好情况下报喜的模式论证没有价值。
pub const fn saved_bytes(&self, unshared_total_bytes: usize) -> i64 {
unshared_total_bytes as i64 - self.shared_total_bytes() as i64
}
/// 节省比例(万分点)。节省为负时返回负值。
///
/// 参数 `unshared_total_bytes`:独立侧总字节。
pub fn saved_ratio_basis_points(&self, unshared_total_bytes: usize) -> i64 {
if unshared_total_bytes == 0 {
return 0;
}
let saved: i64 = self.saved_bytes(unshared_total_bytes);
let numerator: i128 = saved as i128 * 10_000i128;
(numerator / unshared_total_bytes as i128) as i64
}
/// 平均每个实例被引用的次数(万分点,即「平均共享倍数」)。
///
/// 返回:`持有总数 / 实例数`(乘 10000 保留两位小数)。
///
/// ## 为什么这个指标比「命中率」更能说明共享度
///
/// 命中率反映的是**取用过程**,而本指标反映的是**存量结构**:
/// 「平均每个享元被 1714 个槽位共享」这句话,
/// 比「命中率 99.94%」更直接地说明共享的好处有多大。
///
/// 两者都要看:命中率高但共享倍数低,说明实例虽然复用了但复用量小;
/// 共享倍数高但命中率低,说明前几次取用都在建实例(预热期长)。
pub fn average_share_multiplier_basis_points(&self) -> i64 {
if self.distinct_entry_count == 0 {
return 0;
}
let numerator: i128 = self.held_reference_total as i128 * 10_000i128;
(numerator / self.distinct_entry_count as i128) as i64
}
/// 每个实例平均内容字节数。
pub fn average_intrinsic_bytes(&self) -> usize {
if self.distinct_entry_count == 0 {
return 0;
}
self.intrinsic_bytes_total / self.distinct_entry_count
}
/// 是否发生过池满拒绝。
pub const fn is_capacity_exceeded(&self) -> bool {
self.counters.has_rejection()
}
}
/// 一个「假设不共享」的对照模型。
///
/// ## 为什么单独建一个类型
///
/// 本工程要在多个地方做同一件事:「把这批槽位想象成每个都自带一份完整副本,
/// 算算要多少字节」。若各处各写一遍累加,公式会漂移
/// (比如某处忘了算句柄、某处用了不同的实例大小)。
///
/// 把它做成类型后,公式只此一份,且 `FlyweightFootprint` 约束保证
/// 「算字节」的口径与真实享元一致。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnsharedFootprint {
/// 参与统计的槽位(持有点)数量。
pub holder_count: u64,
/// 每个持有点若各自持有一份,其内容字节数。
///
/// 注意:这是**副本**的大小,即「把享元的全部字段深拷贝一份」。
/// 因此它就是享元的 `estimated_bytes()`(深拷贝同型对象,字节数相同),
/// 而不是「句柄 + 实例内容」。
pub bytes_per_holder: usize,
}
impl UnsharedFootprint {
/// 构造一个对照模型。
pub const fn new(holder_count: u64, bytes_per_holder: usize) -> UnsharedFootprint {
UnsharedFootprint {
holder_count,
bytes_per_holder,
}
}
/// 由享元实例构造对照模型。
///
/// 参数 `holder_count`:持有点数量;`flyweight`:任一享元实例
/// (用于读取其内容大小)。
///
/// 用「任一实例」而非「平均」:本工程的两族享元各自内部大小均匀
/// (字段数与类型固定)。若将来出现大小不齐的享元族,
/// 应改用逐实例累加的精确版本——这个限制写在注释里,
/// 而不是默默地算一个偏斜的数字。
pub fn from_flyweight<Flyweight: FlyweightFootprint>(
holder_count: u64,
flyweight: &Flyweight,
) -> UnsharedFootprint {
UnsharedFootprint {
holder_count,
bytes_per_holder: flyweight.estimated_bytes(),
}
}
/// 独立侧总字节。
pub const fn total_bytes(&self) -> usize {
(self.holder_count as usize).saturating_mul(self.bytes_per_holder)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : shared_pool.rs
//! # 共享池 —— 享元工厂的通用形态
//!
//! ## 为什么池是泛型的,而不是「一个班次模板池」
//!
//! 本工程有两族享元:班次模板(`ShiftTemplate`)与门店等级配置
//! (`StoreGradeProfile`)。若为每族各写一个池,会得到两份几乎相同的
//! 「查表 / 未命中则建 / 计数」代码——**两处实现就是两处漂移源**:
//! 将来给池加一个「容量上限」特性,很可能只改了一处。
//!
//! 泛型池把「享元机制」抽成一份代码,两族只是不同的类型参数实例。
//! 这本身也是对本模式的一个说明:**享元可复用的是一个机制,
//! 不是一个具体对象。**
//!
//! ## 为什么用 `Rc` 而不是 `&'a T`
//!
//! 用引用(`&'a ShiftTemplate`)让池借出实例,能省下 `Rc` 的控制块与指针
//! 总共 24 字节/槽位。但代价是**生命周期地狱**:
//! - 池必须比所有槽位活得更久(否则悬垂引用);
//! - 池「未命中则新建并插入」需要 `&mut`,而借出的引用又是 `&`——
//! 在 Rust 里同一容器不能同时存在这两个借用;
//! - 于是要么把池整个包在 `RefCell` 里(仍无法解决引用逃逸),
//! 要么改用「先收集键、再统一构造」的两阶段流程(代码复杂度暴增)。
//!
//! `Rc` 用 8 字节指针换来了「池可以内部可变(`RefCell`)」与
//! 「实例生命周期由引用计数托管」。这是本工程刻意的取舍:
//! **享元带来的节省是「不再复制整份对象」(数百字节/槽位),
//! 用 8 字节指针换它是划算的。**
//!
//! ## 为什么不是 `Arc`
//!
//! `Arc` 的原子计数在本工程场景里纯属浪费:排班装配是单线程的
//! (一个门店的排班不会跨线程并发构造)。原子操作比非原子慢数倍,
//! 且 `Arc` 在同一平台上与 `Rc` 同宽(都是 8 字节指针 + 16 字节控制块),
//! 内存上没有任何优势。
//!
//! **若将来排班装配要跨线程并行**(例如上千家门店并行生成),
//! 把 `Rc` 换成 `Arc` 即可,本文件的类型别名 `SharedHandle` 就是为此留的
//! ——全工程只有这一处定义句柄类型,换实现只需改一行。
use std::cell::RefCell;
use std::collections::HashMap;
use std::hash::Hash;
use std::rc::Rc;
use super::footprint::FlyweightFootprint;
use super::pool_snapshot::{PoolCounters, PoolSnapshot};
/// 共享实例的句柄类型。
///
/// ## 为什么要有这个别名
///
/// 全工程凡是要「持有一个享元」的地方都写 `SharedHandle<ShiftTemplate>`
/// 而不是 `Rc<ShiftTemplate>`。这样将来改用 `Arc`(跨线程)时,
/// **只改这一行**,所有持有点自动跟随。
///
/// 若各处直接写 `Rc<...>`,改 `Arc` 就要 grep 全工程逐一替换,
/// 而这正是最容易漏掉一两处的地方(漏掉的那处会编译失败还好,
/// 若恰好类型兼容则会静默产生两套不互通的引用)。
pub type SharedHandle<Flyweight> = Rc<Flyweight>;
/// 池已达容量上限。
///
/// 只在用 [`SharedPool::with_entry_limit`] 建池时才可能产生。
/// 之所以做成一个具名错误类型(而不是 `Option::None`),
/// 是为了让调用方能从错误里读出「上限是多少」——
/// 报表可以打印「池上限 8,本批数据需要 12 个不同键,请调整设计」,
/// 这比一个光秃秃的 `None` 有用得多。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PoolCapacityExceeded {
/// 该池的键数量上限。
pub entry_limit: usize,
}
/// 通用的享元共享池。
///
/// ## 设计要点
///
/// 1. **池自身不可变借用**(所有方法取 `&self`)。内部用 `RefCell`
/// 做内部可变性。这让「池」可以作为普通字段放在装配器里,
/// 不必把整条调用链都改成 `&mut self`。
/// ⚠️ `RefCell` 的借用检查在**运行期**——若在持有内部借用时
/// 再次调用池的方法,会 panic。因此 [`SharedPool::obtain`] 刻意
/// 把用户提供的构造函数 `create` 调用放在**所有内部借用之外**,
/// 并为此在插入前做了二次查表(防止 `create` 期间池被改动)。
/// 2. **构造与登记分离**:`obtain` 接收一个 `FnOnce(&Key) -> Flyweight`
/// 闭包。池不需要知道如何构造具体享元,因此能做到泛型;
/// 而调用方(具体族)负责给出构造逻辑。
/// 3. **命中/未命中都计数**:命中率是本工程的核心指标之一,
/// 也是「享元真的生效了」的运行时证据。
pub struct SharedPool<Key, Flyweight>
where
Key: Hash + Eq + Clone,
Flyweight: FlyweightFootprint,
{
/// 已登记的共享实例。键 → 实例。
///
/// 放在 `RefCell` 里:`obtain` 取 `&self` 却要插入,只能走内部可变性。
entries: RefCell<HashMap<Key, SharedHandle<Flyweight>>>,
/// 累计计数器(请求/命中/未命中/被拒绝)。
counters: RefCell<PoolCounters>,
/// 键数量上限。`None` 表示不限(演示规模下的默认值)。
entry_limit: Option<usize>,
}
impl<Key, Flyweight> SharedPool<Key, Flyweight>
where
Key: Hash + Eq + Clone,
Flyweight: FlyweightFootprint,
{
/// 建一个不限容量的池。
pub fn new() -> SharedPool<Key, Flyweight> {
SharedPool {
entries: RefCell::new(HashMap::new()),
counters: RefCell::new(PoolCounters::default()),
entry_limit: None,
}
}
/// 建一个键数量有上限的池。
///
/// 参数 `entry_limit`:允许的不同键数量上限。
/// 返回:池。
///
/// ## 为什么享元池需要容量上限
///
/// 享元的经典风险是**键空间失控**:若键里混进了「每次调用都不同」的维度
/// (典型是日期、时间戳、请求 ID),池会为每个请求建一个新实例,
/// 且**永不释放**(`Rc` 强引用一直持有),最终 OOM。
///
/// 这类 bug 的可怕之处在于:功能完全正常,只是内存单向增长,
/// 往往在运行数周后才暴露。加一个上限 + 溢出计数,
/// 能让「键空间失控」在**第一次跑演示时**就被看见。
///
/// 本工程第三幕会刻意构造一个「键里含门店」的失控场景,
/// 用 `with_entry_limit` 把它拦下来。
pub fn with_entry_limit(entry_limit: usize) -> SharedPool<Key, Flyweight> {
SharedPool {
entries: RefCell::new(HashMap::new()),
counters: RefCell::new(PoolCounters::default()),
entry_limit: Some(entry_limit),
}
}
/// 取得一个共享实例;不存在则用 `create` 构造并登记。
///
/// 参数 `key`:享元键;`create`:构造闭包,接收键的引用。
/// 返回:共享实例句柄;若池已满且该键为新键,返回 `Err`。
///
/// ## 为什么构造闭包接收 `&Key` 而不是 `Key`
///
/// 因为键**同时**要作为 `HashMap` 的成员被拥有,又要用于构造实例。
/// 若闭包拿走 `key`,池就没法把它存进表里了(除非 `Clone` 两次)。
/// 传引用让构造方按需读取,所有权仍归池。
///
/// ## 为什么 `create` 在借用之外调用
///
/// 构造函数里可能做很复杂的事(甚至再向本池取另一个享元)。
/// 若这时池的 `entries` 还被 `borrow()` 着,
/// `RefCell` 会在运行期 panic(`already borrowed`)。
/// 因此流程刻意分成「查表(借用)→ 释放借用 → 构造 → 再借用插入」四段,
/// 并在插入前**二次查表**以防构造期间键被别的路径插入。
pub fn obtain<Create>(
&self,
key: Key,
create: Create,
) -> Result<SharedHandle<Flyweight>, PoolCapacityExceeded>
where
Create: FnOnce(&Key) -> Flyweight,
{
// 记录一次请求(无论后续命中与否)。
self.counters.borrow_mut().request_count += 1;
// ── 第一段:查表(借用 ranges 在块结束时自动释放) ──
{
let entries = self.entries.borrow();
if let Some(existing) = entries.get(&key) {
// 命中:克隆句柄(只加引用计数,不复制实例)。
let handle: SharedHandle<Flyweight> = SharedHandle::clone(existing);
// 显式释放借用,避免下面记分时持有两把锁(虽然不冲突,但容易看错)。
drop(entries);
self.counters.borrow_mut().hit_count += 1;
return Ok(handle);
}
}
// ── 第二段:容量检查(此时无任何借用) ──
if let Some(limit) = self.entry_limit {
let current_entry_count: usize = self.entries.borrow().len();
if current_entry_count >= limit {
// 池满:拒绝为新键建实例(既有键仍可命中,上面的分支已处理)。
self.counters.borrow_mut().rejected_count += 1;
return Err(PoolCapacityExceeded {
entry_limit: limit,
});
}
}
// ── 第三段:构造实例(**无借用**,允许构造函数再入本池) ──
let created_flyweight: Flyweight = create(&key);
// ── 第四段:登记(二次查表,防构造期间被插入) ──
let mut entries = self.entries.borrow_mut();
if let Some(existing) = entries.get(&key) {
// 构造期间该键已被别的调用插入 → 丢弃刚构造的实例,复用既有的。
// 单线程下只有「构造函数递归取同键」才会走到这里,
// 但保留此分支成本极低,且能让上面那句「允许再入」的承诺成立。
let handle: SharedHandle<Flyweight> = SharedHandle::clone(existing);
drop(entries);
self.counters.borrow_mut().hit_count += 1;
return Ok(handle);
}
// 真正的新键:以共享句柄插入,并返回同一个句柄。
let shared_handle: SharedHandle<Flyweight> = SharedHandle::new(created_flyweight);
entries.insert(key, SharedHandle::clone(&shared_handle));
drop(entries);
self.counters.borrow_mut().miss_count += 1;
Ok(shared_handle)
}
/// 只读查询:键是否已登记。
///
/// 参数 `key`:待查的键。
/// 返回:已登记返回 `Some(句柄)`,否则 `None`。
///
/// **不计入请求/命中统计**:这是「查看」而不是「取用」。
/// 若把查看也计入命中率,指标会被调试代码污染。
pub fn lookup(&self, key: &Key) -> Option<SharedHandle<Flyweight>> {
self.entries.borrow().get(key).map(SharedHandle::clone)
}
/// 键是否已登记(不取出句柄,最轻量)。
pub fn contains_key(&self, key: &Key) -> bool {
self.entries.borrow().contains_key(key)
}
/// 池内不同键的数量。
pub fn entry_count(&self) -> usize {
self.entries.borrow().len()
}
/// 池的容量上限(`None` 表示不限)。
pub fn entry_limit(&self) -> Option<usize> {
self.entry_limit
}
/// 池内所有实例的估算字节数之和(不含句柄)。
///
/// 这是「共享侧的真实开销」:只需为每个**不同键**存一份实例内容。
pub fn intrinsic_bytes_total(&self) -> usize {
self.entries
.borrow()
.values()
.map(|flyweight| flyweight.estimated_bytes())
.sum()
}
/// 池内所有实例**当前**被外部持有的句柄数之和。
///
/// 用 `Rc::strong_count - 1` 计算:减去池自己持有的那一份。
/// 结果是「当前有多少个槽位/客户端正引用着这些享元」。
///
/// ## 为什么用 `strong_count` 而不是自己维护一个计数表
///
/// 自维护计数表意味着池里再存一个 `HashMap<Key, u64>`——
/// **在一个以省内存为主题的工程里,为统计再加一张哈希表是自相矛盾的**。
/// `Rc` 的控制块里本来就有强引用计数,读它是零成本的。
/// 而且它反映的是**真实**的持有情况(包括调用方自行 `clone` 的那些),
/// 不会因为漏记一处 `clone` 而失真。
pub fn held_reference_total(&self) -> u64 {
self.entries
.borrow()
.values()
// 减 1:池自己在 `entries` 里持有一份。
.map(|flyweight| SharedHandle::strong_count(flyweight) as u64 - 1)
.sum()
}
/// 取一份统计快照。
///
/// 返回:包含累计计数器、条目数、内存估算的快照。
/// 快照是**值类型**,拿到后可自由传递,不会与池的后续变动互相影响。
pub fn snapshot(&self) -> PoolSnapshot {
let entries = self.entries.borrow();
// 池内实例内容总字节(每个不同键一份)。
let intrinsic_bytes_total: usize =
entries.values().map(|flyweight| flyweight.estimated_bytes()).sum();
// 各实例当前被外部持有的句柄数(用于「若各自持有一份」的对照估算)。
let held_reference_total: u64 =
entries.values().map(|flyweight| SharedHandle::strong_count(flyweight) as u64 - 1).sum();
// 句柄侧总字节:每个被持有的句柄一个指针,加上每个实例一个控制块。
let handle_bytes_total: usize = held_reference_total as usize
* super::footprint::reference_pointer_bytes()
+ entries.len() * super::footprint::reference_control_block_bytes();
PoolSnapshot {
counters: *self.counters.borrow(),
distinct_entry_count: entries.len(),
intrinsic_bytes_total,
handle_bytes_total,
held_reference_total,
entry_limit: self.entry_limit,
}
}
/// 遍历池内所有键与实例(只读)。
///
/// 参数 `visit`:对每个 `(键, 实例)` 调用一次。
///
/// ## 为什么用回调解构而不是返回 `Vec<(Key, Handle)>`
///
/// 返回 `Vec` 会为每个键**再克隆一个句柄**——于是遍历这个动作本身
/// 就改变了 `strong_count`,让「共享度」统计失真。
/// 回调形式不克隆,读到的就是真实状态。
/// 在一个统计精度很重要的工程里,这个区别必须在意。
pub fn for_each_entry<Visit>(&self, mut visit: Visit)
where
Visit: FnMut(&Key, &SharedHandle<Flyweight>),
{
let entries = self.entries.borrow();
for (key, flyweight) in entries.iter() {
visit(key, flyweight);
}
}
}
// 默认构造:不加 `#[derive(Default)]` 是因为泛型参数上写 `Default` 约束
// 会限制可用性(`Rc`/`HashMap` 都默认空,与 `Key`/`Flyweight` 是否实现 Default 无关)。
impl<Key, Flyweight> Default for SharedPool<Key, Flyweight>
where
Key: Hash + Eq + Clone,
Flyweight: FlyweightFootprint,
{
/// 等价于 [`SharedPool::new`](不限容量)。
fn default() -> Self {
SharedPool::new()
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : shift_template.rs
//! # 班次模板 —— 享元本体(内在状态)
//!
//! ## 这是「被共享的那一半」
//!
//! 一个班次模板包含「无论哪家门店、哪一天、哪个员工,都完全相同」的信息:
//! 几点上班、几点下班、休息多久、计薪时长、时薪系数、是否需要安保。
//!
//! | 字段类别 | 归属 | 理由 |
//! |---|---|---|
//! | 时刻 / 时长 / 休息 | **内在**(本类型) | 由班次种类唯一决定 |
//! | 时薪系数 | **内在**(本类型) | 由班次 × 时段 × 技能等级决定 |
//! | 门店 / 日期 / 员工 | 外在(`client::ShiftSlot`) | 每次使用都不同 |
//! | 门店时薪指数 | 外在(`client::ShiftSlot`) | 各店不同 |
//! | 实际工时 / 加班 | 外在(`client::ShiftSlot`) | 每个槽位不同 |
//!
//! ## 为什么全部字段私有、只给 `&self` 取值器
//!
//! 享元一旦被多个槽位共享,它就必须**不可变**——
//! 否则「槽位 A 改了模板,槽位 B 的读数跟着变了」,
//! 这是最难排查的一类 bug(改动点与出错点相距很远)。
//!
//! Rust 的所有权系统帮我们把这条约定**变成编译期保证**:
//! - 字段私有 → 外部无法直接赋值;
//! - 只提供 `&self` 方法 → 无法通过方法变异;
//! - 外部只能拿到 `Rc<ShiftTemplate>`,而 `Rc` 只解引用出 `&T`。
//!
//! 于是「享元不可变」不再依赖纪律,而是**写不出变异代码**。
//! 这是 Rust 相比 Java/C# 实现本模式的一个实质优势:
//! 在那些语言里,「不要修改享元」只能写在注释里。
//!
//! ## 本类型的字节足迹:零堆分配
//!
//! 所有字段都是值类型(`ClockTime` 是 `u16`、`WorkDuration` 是 `u32`、
//! 各标签是 `&'static str` + 小整数),因此 `estimated_bytes()`
//! 就是 `size_of::<ShiftTemplate>()`,**没有堆内容**。
//!
//! 这是刻意的设计:若把 `shift_code` 等字段写成 `String`,
//! 每个实例就要多一次堆分配(白付 16 字节控制块 + 分配开销)。
//! 享元实例虽然少(本工程 7 个),但**「用 `&'static str` 而不是 `String`」
//! 这条纪律在整个工程里一致贯彻**,正是它让「不共享侧」的
//! 对照版本(用 `String`)显示出巨大的差异。
use crate::domain::{PayPeriodKind, Ratio, ShiftCode, SkillGrade, WorkDuration};
use crate::support::clock_time::ClockTime;
use super::footprint::FlyweightFootprint;
use super::shift_template_key::ShiftTemplateKey;
/// 一个班次模板(享元本体)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShiftTemplate {
/// 班次编码。
shift_code: ShiftCode,
/// 上班时刻。
starts_at: ClockTime,
/// 下班时刻(可能小于上班时刻,表示跨零点)。
ends_at: ClockTime,
/// 排班时长(含休息)。
scheduled_duration: WorkDuration,
/// 休息时长。
break_duration: WorkDuration,
/// **计薪**时长 = 排班时长 - 休息时长。
///
/// ## 为什么要把计薪时长存成字段而不是每次算
///
/// 「休息不付薪」是本工程的一条业务规则。若每次算成本都现算
/// `scheduled - break`,这条规则就有了多个执行点;
/// 将来规则变化(如「夜班休息也计薪」)就要改多处。
/// 存成字段后,规则只在**构造模板时**执行一次,
/// 之后所有槽位都从同一个值读取——**单一事实来源**。
///
/// 代价是每模板多 4 字节(共 7 个模板 = 28 字节),可忽略。
paid_duration: WorkDuration,
/// 综合时薪系数 = 技能基数 × 班次系数 × 时段上浮系数。
///
/// 三个因子在构造时就乘好,避免每个槽位重复计算
/// (数万槽位 × 三次乘法与三次舍入)。
/// 在享元里,**「把能提前算的都算好」是天然合理的**——
/// 反正只算 7 次。
combined_pay_multiplier: Ratio,
/// 最低技能等级(与键一致,冗余存一份便于报表直接读取)。
minimum_skill_grade: SkillGrade,
/// 计薪时段(与键一致,冗余存一份便于报表直接读取)。
///
/// ## 为什么容忍这份冗余
///
/// 键已经在池里了(`HashMap` 的键),模板里再存一份确实重复。
/// 但两个理由让它值得:
/// 1. 报表渲染时拿到的是 `Rc<ShiftTemplate>`,**拿不到键**;
/// 若要靠键取时段,就得把「模板 + 键」打包传递,处处多一层;
/// 2. 模板是「自描述的」——调试时打印一个模板就能看到它的全部语义,
/// 不必回去查是哪把键生成了它。
///
/// 冗余的代价:7 个模板 × 约 40 字节 = 280 字节。可忽略。
/// **在一个只有 7 个实例的集合里,可读性远比省几百字节重要。**
pay_period_kind: PayPeriodKind,
/// 是否需要安保在场(开保险柜的班次需要)。
requires_security_presence: bool,
/// 是否要求双人在场(珠宝门店一般班次都要求)。
requires_dual_presence: bool,
/// 是否跨越零点(夜班)。
crosses_midnight: bool,
}
impl ShiftTemplate {
/// 构造一个班次模板。
///
/// 参数较多,因此**不对外开放**(`pub(crate)`)——
/// 只有 [`super::shift_template_factory::ShiftTemplateFactory`] 调用它。
///
/// ## 为什么不做成公开的 `new`
///
/// 模板必须经过工厂登记才能被共享。若对外开放 `new`,
/// 调用方可能自己 `ShiftTemplate::new(...)` 造一个**游离实例**,
/// 它不进池、不被共享,却又与池中实例内容相同——
/// 于是「同一份数据存在两个副本」,且没有任何机制能发现。
///
/// 把构造器收进层内(`pub(crate)`),保证**所有模板都出自池**。
/// 这是「用可见性表达不变式」的一个具体应用。
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
key: &ShiftTemplateKey,
starts_at: ClockTime,
ends_at: ClockTime,
scheduled_duration: WorkDuration,
break_duration: WorkDuration,
paid_duration: WorkDuration,
combined_pay_multiplier: Ratio,
requires_security_presence: bool,
requires_dual_presence: bool,
crosses_midnight: bool,
) -> ShiftTemplate {
ShiftTemplate {
shift_code: key.shift_code(),
starts_at,
ends_at,
scheduled_duration,
break_duration,
paid_duration,
combined_pay_multiplier,
minimum_skill_grade: key.minimum_skill_grade(),
pay_period_kind: key.effective_period_kind(),
requires_security_presence,
requires_dual_presence,
crosses_midnight,
}
}
/// 班次编码。
pub const fn shift_code(&self) -> ShiftCode {
self.shift_code
}
/// 上班时刻。
pub const fn starts_at(&self) -> ClockTime {
self.starts_at
}
/// 下班时刻。
pub const fn ends_at(&self) -> ClockTime {
self.ends_at
}
/// 排班时长(含休息)。
pub const fn scheduled_duration(&self) -> WorkDuration {
self.scheduled_duration
}
/// 休息时长。
pub const fn break_duration(&self) -> WorkDuration {
self.break_duration
}
/// 计薪时长。
pub const fn paid_duration(&self) -> WorkDuration {
self.paid_duration
}
/// 综合时薪系数。
pub const fn combined_pay_multiplier(&self) -> Ratio {
self.combined_pay_multiplier
}
/// 最低技能等级。
pub const fn minimum_skill_grade(&self) -> SkillGrade {
self.minimum_skill_grade
}
/// 计薪时段。
pub const fn pay_period_kind(&self) -> PayPeriodKind {
self.pay_period_kind
}
/// 是否需要安保在场。
pub const fn requires_security_presence(&self) -> bool {
self.requires_security_presence
}
/// 是否要求双人在场。
pub const fn requires_dual_presence(&self) -> bool {
self.requires_dual_presence
}
/// 是否跨越零点。
pub const fn crosses_midnight(&self) -> bool {
self.crosses_midnight
}
/// 返回「09:00-17:00」形式的时刻区间文本(跨零点时加「次日」)。
pub fn time_range_text(&self) -> String {
if self.crosses_midnight {
format!("{}-次日 {}", self.starts_at.formatted(), self.ends_at.formatted())
} else {
format!("{}-{}", self.starts_at.formatted(), self.ends_at.formatted())
}
}
/// 返回模板的一行摘要文本。
///
/// ## 注意这里是「子系统/模式层自带排版」
///
/// 与同系列工程的做法一致:本方法是**层内自带的展示口径**,
/// 报表层(`app`)不会用它。保留并在第七幕与报表层排版**并列打印**,
/// 是为了演示「排版职责统一归表示层」这条原则——
/// 若子系统各自排版,改一次列宽就要改多处。
pub fn formatted(&self) -> String {
format!(
"{}({})| {} | 计薪 {} | 系数 {}",
self.shift_code.display_name(),
self.shift_code.code(),
self.time_range_text(),
self.paid_duration.formatted_hours_and_minutes(),
self.combined_pay_multiplier.as_multiplier_text()
)
}
}
impl FlyweightFootprint for ShiftTemplate {
/// 返回模板的估算字节数。
///
/// 本类型**没有任何堆分配字段**(全部是 `u16`/`u32`/`i64`/`bool`
/// 与 `&'static str` 标签),因此 `size_of` 就是全部。
///
/// ⚠️ 这个前提必须随字段变化而复核:若将来给模板加一个
/// `Vec<String>` 字段(比如「适用门店等级列表」),
/// 本函数就**必须**改成「`size_of` + 各 `Vec` 的内容字节」,
/// 否则内存统计会静默低估,进而让「共享节省了多少」被高估。
///
/// 这类「统计函数随结构变化而失效」的风险无法由编译器发现,
/// 因此每个 `FlyweightFootprint` 实现都必须写清
/// 「我这个实现依赖哪些结构前提」。
fn estimated_bytes(&self) -> usize {
std::mem::size_of::<ShiftTemplate>()
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : shift_template_factory.rs
//! # 班次模板工厂 —— 具象享元工厂
//!
//! ## 工厂的两项职责
//!
//! 1. **持有共享池**(`SharedPool<ShiftTemplateKey, ShiftTemplate>`);
//! 2. **持有班次时刻表登记表**——「某个班次几点到几点、休息多久、
//! 基础系数多少」这部分知识。
//!
//! 职责 2 是本文件的关键设计。若把时刻表写成一段 `match shift_code {...}`,
//! 那么**新增班次就必须修改本文件**——而本工程的验收标准包含
//! 「工程外扩展不改任何既有分层文件」。
//!
//! 因此时刻表做成**运行期可登记的**([`ShiftTemplateFactory::register_shift_schedule`]):
//! 内置的 5 个班次在构造时登记,工程外可以再登记新班次,
//! 本文件一行不改。这正是同系列工程反复验证的那条经验——
//! **「登记表把选择从编译期 `match` 提升为运行期数据」。**
//!
//! ## 未登记班次不会被静默忽略
//!
//! 若某个键引用了未登记的班次,[`ShiftTemplateFactory::obtain`] 返回
//! [`ShiftTemplateLookupError::UnregisteredShift`],由调用方决定如何处理。
//!
//! **为什么不给一个「默认时刻表」兜底**:兜底会让「忘记登记」这个错误
//! 变成「排班表里某几个班次的时刻很奇怪」——问题在报表层面才暴露,
//! 且难以定位到「忘了登记」。返回错误让问题在**取模板的那一行**就暴露。
//! 这条与同工程其他地方「fail visible」的选择一致,但此处选择了
//! 「显式错误」而非「钳制后继续」——区别在于兜底值是否**可见**:
//! 日期钳制后报表里会出现明显不对的日期(可见);
//! 而时刻兜底后会得到一个**看起来正常**的时刻(不可见)。
use std::cell::RefCell;
use std::collections::HashMap;
use crate::domain::{
divide_rounded_away_from_zero, Ratio, ShiftCode, SkillGrade, WorkDuration,
PAY_PERIOD_NORMAL, SHIFT_CODE_EARLY, SHIFT_CODE_LATE, SHIFT_CODE_MIDDLE,
SHIFT_CODE_NIGHT_AUDIT, SHIFT_CODE_WEEKEND_BOOST, SKILL_GRADE_JUNIOR,
};
use crate::support::clock_time::ClockTime;
use super::shift_template::ShiftTemplate;
use super::shift_template_key::ShiftTemplateKey;
use super::shared_pool::{SharedHandle, SharedPool};
use super::pool_snapshot::PoolSnapshot;
/// 一个班次的时刻与基础系数规格(可运行期登记)。
///
/// ## 为什么单独成类型而不是把参数塞进 `register_shift_schedule`
///
/// 未来这张表很可能要加字段(如「该班次的最短连续休息要求」)。
/// 若用参数列表,每次加字段都要改方法签名 → 所有调用点跟着改;
/// 用结构体后,加字段只需给一个默认值,既有调用点不受影响。
///
/// 这是「参数对象」模式的一个朴素应用,但对**会被扩展的 API** 很有价值。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShiftScheduleSpec {
/// 班次编码。
shift_code: ShiftCode,
/// 上班时刻。
starts_at: ClockTime,
/// 下班时刻(可小于上班时刻,表示跨零点)。
ends_at: ClockTime,
/// 休息时长。
break_duration: WorkDuration,
/// 班次基础系数(不含技能与时段上浮)。夜班通常高于 1.00。
shift_factor: Ratio,
/// 是否要求双人在场。
requires_dual_presence: bool,
}
impl ShiftScheduleSpec {
/// 构造一个班次时刻规格。
///
/// 参数 `shift_code` / `starts_at` / `ends_at` / `break_duration` /
/// `shift_factor` / `requires_dual_presence`。
/// 返回:规格。
///
/// `const fn`:让工程外能把规格定义成 `const` 常量
/// (见 `main.rs` 的扩展区),而不必在运行期拼装。
pub const fn new(
shift_code: ShiftCode,
starts_at: ClockTime,
ends_at: ClockTime,
break_duration: WorkDuration,
shift_factor: Ratio,
requires_dual_presence: bool,
) -> ShiftScheduleSpec {
ShiftScheduleSpec {
shift_code,
starts_at,
ends_at,
break_duration,
shift_factor,
requires_dual_presence,
}
}
/// 班次编码。
pub const fn shift_code(&self) -> ShiftCode {
self.shift_code
}
/// 上班时刻。
pub const fn starts_at(&self) -> ClockTime {
self.starts_at
}
/// 下班时刻。
pub const fn ends_at(&self) -> ClockTime {
self.ends_at
}
/// 休息时长。
pub const fn break_duration(&self) -> WorkDuration {
self.break_duration
}
/// 班次基础系数。
pub const fn shift_factor(&self) -> Ratio {
self.shift_factor
}
/// 是否要求双人在场。
pub const fn requires_dual_presence(&self) -> bool {
self.requires_dual_presence
}
}
/// 取班次模板时的失败原因。
///
/// 用枚举而非 `Option`:两种失败的处理方式完全不同——
/// 未登记班次要**补登记**,池满要**扩容量或改键设计**。
/// 合成一个 `None` 会让调用方无法给出有用的错误信息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShiftTemplateLookupError {
/// 该班次未在工厂登记。
UnregisteredShift {
/// 未登记的班次编码(供报表指认)。
shift_code_text: &'static str,
},
/// 池已达容量上限,无法为新键建模板。
PoolCapacityExceeded {
/// 该池的键数量上限。
entry_limit: usize,
},
}
impl ShiftTemplateLookupError {
/// 返回一行可读的失败说明。
pub fn description(&self) -> String {
match self {
ShiftTemplateLookupError::UnregisteredShift { shift_code_text } => {
format!("班次「{}」未在工厂登记班次时刻表", shift_code_text)
}
ShiftTemplateLookupError::PoolCapacityExceeded { entry_limit } => {
format!("享元池已达键数量上限 {},无法为新键建模板", entry_limit)
}
}
}
}
/// 班次模板工厂(具象享元工厂)。
pub struct ShiftTemplateFactory {
/// 共享池。
pool: SharedPool<ShiftTemplateKey, ShiftTemplate>,
/// 班次时刻表登记表。键是班次编码。
///
/// 放在 `RefCell` 里以便运行期登记(`register_shift_schedule` 取 `&self`)。
/// 与池一样,本工程不需要把整条调用链改成 `&mut self`。
schedule_registry: RefCell<HashMap<ShiftCode, ShiftScheduleSpec>>,
}
impl ShiftTemplateFactory {
/// 建一个已登记 5 个内置班次、不限池容量的工厂。
pub fn with_builtin_shifts() -> ShiftTemplateFactory {
let factory = ShiftTemplateFactory {
pool: SharedPool::new(),
schedule_registry: RefCell::new(HashMap::new()),
};
factory.register_builtin_shift_schedules();
factory
}
/// 建一个已登记 5 个内置班次、且池有键数量上限的工厂。
///
/// 参数 `entry_limit`:池的键数量上限。
/// 返回:工厂。
///
/// 供第四幕演示「键空间失控被拦下」。
pub fn with_builtin_shifts_and_entry_limit(entry_limit: usize) -> ShiftTemplateFactory {
let factory = ShiftTemplateFactory {
pool: SharedPool::with_entry_limit(entry_limit),
schedule_registry: RefCell::new(HashMap::new()),
};
factory.register_builtin_shift_schedules();
factory
}
/// 登记 5 个内置班次的时刻表。
///
/// ## 为什么是私有方法
///
/// 它是「哪些班次算内置」这个决定的唯一落点。
/// 公开它会让调用方能在任意时刻重置内置班次,
/// 而内置集合应当是**构造时就确定**的。
fn register_builtin_shift_schedules(&self) {
// 早班 09:00-17:00,休息 60 分钟,系数 1.00。
self.register_shift_schedule(ShiftScheduleSpec::new(
SHIFT_CODE_EARLY,
ClockTime::from_hour_and_minute(9, 0),
ClockTime::from_hour_and_minute(17, 0),
WorkDuration::from_minutes(60),
Ratio::one(),
// 珠宝门店:所有班次都要双人在场。
true,
));
// 中班 13:00-21:00,休息 60 分钟,系数 1.00。
self.register_shift_schedule(ShiftScheduleSpec::new(
SHIFT_CODE_MIDDLE,
ClockTime::from_hour_and_minute(13, 0),
ClockTime::from_hour_and_minute(21, 0),
WorkDuration::from_minutes(60),
Ratio::one(),
true,
));
// 晚班 15:00-23:00,休息 60 分钟,系数 1.05(收市结算偏累)。
self.register_shift_schedule(ShiftScheduleSpec::new(
SHIFT_CODE_LATE,
ClockTime::from_hour_and_minute(15, 0),
ClockTime::from_hour_and_minute(23, 0),
WorkDuration::from_minutes(60),
Ratio::from_basis_points(10_500),
true,
));
// 盘点夜班 22:30-次日 06:30,休息 90 分钟,系数 1.25。
self.register_shift_schedule(ShiftScheduleSpec::new(
SHIFT_CODE_NIGHT_AUDIT,
ClockTime::from_hour_and_minute(22, 30),
ClockTime::from_hour_and_minute(6, 30),
WorkDuration::from_minutes(90),
Ratio::from_basis_points(12_500),
// 夜班开保险柜,必须双人在场。
true,
));
// 周末加强班 10:00-19:00,休息 75 分钟,系数 1.10。
self.register_shift_schedule(ShiftScheduleSpec::new(
SHIFT_CODE_WEEKEND_BOOST,
ClockTime::from_hour_and_minute(10, 0),
ClockTime::from_hour_and_minute(19, 0),
WorkDuration::from_minutes(75),
Ratio::from_basis_points(11_000),
true,
));
}
/// 登记一个班次的时刻规格。
///
/// 参数 `spec`:班次时刻规格。
/// 返回:登记成功返回 `true`;若该班次编码已登记(**不覆盖**)返回 `false`。
///
/// ## 为什么不覆盖已有的登记
///
/// 若允许覆盖,则「工程外登记一个新班次」可能意外改掉某个内置班次
/// 的时刻(编码拼错时)。返回 `false` 让调用方知道「这个编码已被占用」,
/// 从而发现拼写错误。这是**用返回值保护数据完整性**——
/// 比静默覆盖安全,比 panic 温和。
///
/// 这也是工程外扩展的正式入口:调用它不影响任何既有分层文件。
pub fn register_shift_schedule(&self, spec: ShiftScheduleSpec) -> bool {
let mut registry = self.schedule_registry.borrow_mut();
if registry.contains_key(&spec.shift_code()) {
return false;
}
registry.insert(spec.shift_code(), spec);
true
}
/// 已登记的班次数量。
pub fn registered_shift_count(&self) -> usize {
self.schedule_registry.borrow().len()
}
/// 某班次是否已登记。
pub fn is_shift_registered(&self, shift_code: &ShiftCode) -> bool {
self.schedule_registry.borrow().contains_key(shift_code)
}
/// 取得(或新建)一个共享班次模板。
///
/// 参数 `key`:享元键。
/// 返回:共享实例;未登记班次或池满时返回 `Err`。
///
/// ## 执行顺序(两步都不可省)
///
/// 1. **先查登记表**:未登记就直接返回错误,**不去碰池**。
/// 若先碰池,未登记的键会先在池里占一个位(`create` 里才发现无法构造),
/// 把容量浪费掉——在有限容量的池里这会误导后面的诊断。
/// 2. **再向池取/建**:命中则共享,未命中则按规格构造并登记。
pub fn obtain(
&self,
key: ShiftTemplateKey,
) -> Result<SharedHandle<ShiftTemplate>, ShiftTemplateLookupError> {
// 第一步:查登记表(只读借用,用完即释放)。
let schedule_spec: ShiftScheduleSpec = {
let registry = self.schedule_registry.borrow();
match registry.get(&key.shift_code()) {
Some(spec) => *spec,
None => {
return Err(ShiftTemplateLookupError::UnregisteredShift {
shift_code_text: key.shift_code().code(),
});
}
}
};
// 第二步:向池取共享实例;未命中时按规格构造。
// 构造闭包在此处调用,且**不持有登记表的借用**,因此安全。
self.pool
.obtain(key, |looked_up_key| {
build_shift_template(looked_up_key, &schedule_spec)
})
.map_err(|capacity_error| ShiftTemplateLookupError::PoolCapacityExceeded {
entry_limit: capacity_error.entry_limit,
})
}
/// 共享池的只读引用。
pub fn pool(&self) -> &SharedPool<ShiftTemplateKey, ShiftTemplate> {
&self.pool
}
/// 池的统计快照。
pub fn snapshot(&self) -> PoolSnapshot {
self.pool.snapshot()
}
}
/// 按规格与键构造一个班次模板。
///
/// 参数 `key`:享元键(提供技能等级与时段);`schedule_spec`:班次时刻规格。
/// 返回:构造好的模板。
///
/// ## 计算步骤与每一步的舍入
///
/// 1. 排班时长 = `starts_at.minutes_until(ends_at)`(跨零点自动绕圈);
/// 2. 计薪时长 = 排班时长 - 休息时长(下限为 0);
/// 3. 综合系数 = 技能基数 × 班次系数 × 时段上浮,**两次乘除各舍入一次**。
fn build_shift_template(
key: &ShiftTemplateKey,
schedule_spec: &ShiftScheduleSpec,
) -> ShiftTemplate {
// 步骤 1:排班时长。`minutes_until` 已处理跨零点(否则夜班会算成负数)。
let scheduled_minutes: u16 = schedule_spec
.starts_at()
.minutes_until(&schedule_spec.ends_at());
let scheduled_duration: WorkDuration = WorkDuration::from_minutes(scheduled_minutes as u32);
// 步骤 2:计薪时长 = 排班 - 休息(下限 0,防止休息配得比排班还长)。
let paid_duration: WorkDuration = scheduled_duration.subtract_floor_zero(
&schedule_spec.break_duration(),
);
// 步骤 3:综合系数。三次因子分两步乘,避免 i128 中间量过大。
let skill_basis_points: i64 = key.minimum_skill_grade().hourly_base_multiplier().basis_points();
let shift_basis_points: i64 = schedule_spec.shift_factor().basis_points();
let period_basis_points: i64 = key.effective_period_kind().surcharge_multiplier().basis_points();
let combined_pay_multiplier: Ratio = Ratio::from_basis_points(
combine_multipliers(skill_basis_points, shift_basis_points, period_basis_points),
);
// 是否跨零点:由时刻本身判断,不依赖调用方传入。
let crosses_midnight: bool = schedule_spec
.starts_at()
.crosses_midnight_to(&schedule_spec.ends_at());
ShiftTemplate::new(
key,
schedule_spec.starts_at(),
schedule_spec.ends_at(),
scheduled_duration,
schedule_spec.break_duration(),
paid_duration,
combined_pay_multiplier,
key.shift_code().requires_security_presence(),
schedule_spec.requires_dual_presence(),
crosses_midnight,
)
}
/// 把三个万分比因子连乘,每步各舍入一次。
///
/// 参数 `first` / `second` / `third`:三个万分比因子。
/// 返回:乘积(万分比)。
///
/// ## 为什么分两步而不是一次乘完再除
///
/// 一次乘完是 `a * b * c / 10000^2`,中间量最大约 `20000^3 = 8 × 10^12`,
/// `i128` 完全放得下,精度也更高(只舍入一次)。**听起来更好。**
///
/// 但本工程选分两步,理由是**口径一致**:其他地方(如
/// `CurrencyAmount::scale_by_basis_points`)都是「每次乘/除各舍入一次」。
/// 若这里用不同口径,会出现「模板里的系数」与「用该系数算钱」两步
/// 舍入方向不一致的错觉——实际上不会不一致(各算各的),
/// 但**审计时很难解释**。为了可解释性牺牲一点精度,在
/// 「系数只算 7 次」的场景下完全值得。
///
/// 这个取舍必须写下来,否则后来者会把它「优化」成一次乘完。
fn combine_multipliers(first: i64, second: i64, third: i64) -> i64 {
// 第一步:技能基数 × 班次系数。
let step_one: i128 = divide_rounded_away_from_zero(
first as i128 * second as i128,
10_000i128,
);
// 第二步:再乘时段上浮。
let step_two: i128 = divide_rounded_away_from_zero(step_one * third as i128, 10_000i128);
// 回落到 i64(系数不会接近 i64 边界,但统一走钳制以避免意外)。
crate::domain::clamp_i128_to_i64(step_two)
}
/// 供其他模块引用的「默认技能等级」便捷常量。
///
/// 放在本文件而非 `domain`:它表达的是**本工厂的默认值选择**,
/// 属于工厂的配置语义,不是领域事实。
pub const DEFAULT_MINIMUM_SKILL_GRADE: SkillGrade = SKILL_GRADE_JUNIOR;
/// 供其他模块引用的「默认计薪时段」便捷常量。
pub const DEFAULT_PAY_PERIOD: crate::domain::PayPeriodKind = PAY_PERIOD_NORMAL;
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : shift_template_key.rs
//! # 享元键 —— 决定「什么算同一个」的那把尺子
//!
//! ## 键的设计就是享元的设计
//!
//! 享元池的一切行为都由键定义:
//! - 两个请求的键相等 → 共享同一个实例;
//! - 键不相等 → 各建一份实例。
//!
//! 因此「键包含哪些维度」直接决定了**共享率**与**正确性**,
//! 而这两个目标经常互相拉扯:
//!
//! | 键的粒度 | 模板数 | 内存 | 正确性 |
//! |---|---|---|---|
//! | 过细(含门店) | 门店数 × 班次数 | 暴涨(退化) | 正确但没省内存 |
//! | 过粗(丢时段) | 只有班次数 | 最省 | **算错钱** |
//! | 恰当 | 班次 × 技能 × 时段 | 省 | 正确 |
//!
//! 本类型用**两个 `Option` 字段**把这个权衡「可拨动」:
//! - [`ShiftTemplateKey::period_scope`]:`None` 表示刻意忽略计薪时段(过粗键);
//! - [`ShiftTemplateKey::store_scope`]:`Some` 表示刻意把门店纳入身份(过细键)。
//!
//! 这不是「为了演示而加的怪字段」——真实系统里这两个维度的取舍
//! 正是设计评审会吵起来的地方:有人主张「按门店建模板更灵活」,
//! 有人主张「时段都一样,不用分」。本工程把两种主张都实现出来,
//! 用真实的模板数与金额误差把它们分出高下。
//!
//! ## 谁不该进键
//!
//! **凡是「每次使用都不同」的维度都不能进键**,否则池退化成缓存,
//! 且永不释放。典型的不该进键的维度:
//! - 日期(同一模板每天都在用,进键就变成 365 份);
//! - 员工工号(每人一份,等于没共享);
//! - 实际工时(每个槽位都不同)。
//!
//! 这些都属于「外在状态」,应该留在 `client::ShiftSlot` 里。
//! 第三幕会把「日期进键」与「门店进键」两种错误都跑一遍,
//! 让它们的模板数在报表里暴露出来。
// 同层引用(模块路径):键由四个领域标签组合而成。
use crate::domain::{PayPeriodKind, ShiftCode, SkillGrade};
/// 一个班次模板的享元键。
///
/// 字段全部私有:键只能经 [`ShiftTemplateKey::new`] 等构造器产生,
/// 保证 `period_scope` / `store_scope` 的取值处于「已设计」的形态,
/// 而不是任意组合。
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ShiftTemplateKey {
/// 班次编码(决定时刻与时长)。
///
/// 用 [`ShiftCode`] 的值而非其 `code` 字符串:
/// 类型化的键让「传错了维度」在编译期就被拒绝
/// (例如把 `SkillGrade` 传进班次参数位)。
shift_code: ShiftCode,
/// 最低技能等级(决定时薪基数与持证要求)。
///
/// ## 为什么技能等级必须进键
///
/// 同一个「晚班」,套在「见习」与「技师」身上,
/// 时薪基数分别是 80% 与 145%——**这是两份不同的模板**。
/// 若把技能等级移出键,两者会共享同一个模板,
/// 其中一方的成本必然算错。
///
/// 这一条的判定标准是:**该维度是否影响享元的内容**。
/// 影响就要进键,不影响(如门店等级)就留在外层。
minimum_skill_grade: SkillGrade,
/// 计薪时段维度。
///
/// - `Some(时段)`:该维度参与共享身份 —— **正确做法**,
/// 平日班与节假日班是两个模板,系数不同;
/// - `None`:刻意忽略该维度 —— 用于演示「键过粗」,
/// 所有时段的班次挤在同一个模板里,系数只能取一个。
period_scope: Option<PayPeriodKind>,
/// 门店维度。
///
/// - `None`:门店不参与共享身份 —— **正确做法**,跨店共享同一模板;
/// - `Some(门店编码)`:该维度参与共享身份 —— 用于演示「键过细」,
/// 模板数会乘上门店数,共享收益坍塌。
///
/// 用 `&'static str` 而不是 `StoreCode`:本字段**只用于演示键的粒度**,
/// 不需要 `StoreCode` 携带的城市名等信息。
/// 让演示专用的字段尽量窄,避免读者误以为它是正式的业务维度。
store_scope: Option<&'static str>,
}
impl ShiftTemplateKey {
/// 构造「正确粒度」的键。
///
/// 参数 `shift_code` / `minimum_skill_grade` / `period_kind`。
/// 返回:`period_scope = Some(period_kind)`、`store_scope = None` 的键。
///
/// 这是**唯一应当出现在业务代码里的构造器**。
/// 另两个构造器([`ShiftTemplateKey::ignoring_pay_period`] 与
/// [`ShiftTemplateKey::scoped_to_store`])只用于第四幕的对照演示。
pub fn new(
shift_code: ShiftCode,
minimum_skill_grade: SkillGrade,
period_kind: PayPeriodKind,
) -> ShiftTemplateKey {
ShiftTemplateKey {
shift_code,
minimum_skill_grade,
period_scope: Some(period_kind),
store_scope: None,
}
}
/// 构造「过粗粒度」的键:刻意不含计薪时段。
///
/// 参数 `shift_code` / `minimum_skill_grade`。
/// 返回:`period_scope = None` 的键。
///
/// ## 这个构造器存在的意义
///
/// 它让「键过粗」这个错误**可以只改一行就复现**,
/// 从而把「过粗键省内存但算错钱」这句话从主张变成可测量的结论。
/// 第四幕用它跑同一批数据,得到的模板数最少(因为不同时段挤在一起),
/// 但人力成本总额与正确口径**不一致**,差额会被算出来。
///
/// 之所以把它放在 `flyweight` 层而不是 `main.rs`:
/// 键的字段是私有的,只有本层能构造。若把它挪到工程外,
/// 就只能把字段改成公开——那会让「键是受控的」这个保证失效。
/// 这体现了「演示性 API 该放在哪一层」的判断:
/// **依赖私有字段的演示 API 必须留在定义域内,并在文档里标明用途。**
pub fn ignoring_pay_period(
shift_code: ShiftCode,
minimum_skill_grade: SkillGrade,
) -> ShiftTemplateKey {
ShiftTemplateKey {
shift_code,
minimum_skill_grade,
period_scope: None,
store_scope: None,
}
}
/// 把门店纳入共享身份(「过细粒度」键)。
///
/// 参数 `store_code`:门店编码。
/// 返回:`store_scope = Some(store_code)` 的新键。
///
/// 用 `self` 取值而不是 `&mut self`:键是值类型且 `Clone` 成本极低,
/// 用「消费式」链式调用可以让「同一个基键派生出多把过细键」写成一行,
/// 且不会意外复用带旧 `store_scope` 的键。
pub fn scoped_to_store(mut self, store_code: &'static str) -> ShiftTemplateKey {
self.store_scope = Some(store_code);
self
}
/// 班次编码。
pub const fn shift_code(&self) -> ShiftCode {
self.shift_code
}
/// 最低技能等级。
pub const fn minimum_skill_grade(&self) -> SkillGrade {
self.minimum_skill_grade
}
/// 计薪时段(若参与共享身份)。
pub const fn period_scope(&self) -> Option<PayPeriodKind> {
self.period_scope
}
/// 门店(若参与共享身份)。
pub const fn store_scope(&self) -> Option<&'static str> {
self.store_scope
}
/// 计薪时段,缺省时返回「平日」。
///
/// 供构造模板时取系数用:键忽略时段时,模板必须挑一个系数兜底,
/// 本工程挑「平日」(系数 1.00)。**这个兜底正是过粗键算错钱的根源**
/// ——节假日排班会被按平日系数计价。
///
/// 把兜底做成一个显式方法(而不是在构造函数里写 `unwrap_or`),
/// 是为了让「这里有一个不确定的默认」在代码里可见。
pub fn effective_period_kind(&self) -> PayPeriodKind {
self.period_scope
.unwrap_or(crate::domain::PAY_PERIOD_NORMAL)
}
/// 返回键的紧凑文本(形如 `EARLY/SENIOR/HOLIDAY`),用于报表。
///
/// 不打印 `store_scope`:它在正确设计下恒为 `None`,
/// 逐行打印一长串 `None` 只是噪音。
/// 若确实需要诊断过细键,用 [`ShiftTemplateKey::diagnostic_text`]。
pub fn compact_text(&self) -> String {
format!(
"{}/{}/{}",
self.shift_code.code(),
self.minimum_skill_grade.code(),
self.effective_period_kind().code()
)
}
/// 返回**诊断用**的完整文本,把每个字段都显式写出来。
///
/// 形如:
/// - 正确键(含时段、无门店范围):`EARLY/SENIOR/HOLIDAY@-`
/// - 过粗键(时段缺失):`EARLY/SENIOR/<时段缺失>@-`
/// - 过细键(并入门店范围):`EARLY/SENIOR/HOLIDAY@LF0001`
///
/// ## 为什么诊断文本必须与紧凑文本不同
///
/// 这对「过粗键」是关键:`compact_text` 对过粗键打印的是
/// **回退值** `.../NORMAL`,与「正确键 + 平日」的文本**完全相同**。
/// 于是只看紧凑文本,无法分辨
///
/// - 「这是按平日正确构造的键」,还是
/// - 「这是丢了时段、被兜底成平日的键」
///
/// 而这两者的业务后果截然不同(后者会让节假日少算一倍工资)。
/// 诊断文本因此把「缺失」显式打印成一个不同的记号。
///
/// **可观测性的核心,是让不同的状态有不同的文本表示。**
/// 若两种不同状态打印出同一段文字,那么日志就失去了区分能力——
/// 而日志正是事故排查的唯一依据。
///
/// 供第三幕的「三种键对照表」使用。与 `compact_text` 分开,
/// 正是为了让「正常报表」与「诊断报表」的口径在类型上就分开,
/// 不会有人顺手在正式报表里打出带门店的键。
pub fn diagnostic_text(&self) -> String {
// 时段:缺失时必须打印成不同的记号,而不是回退值。
let period_text: String = match self.period_scope {
Some(period_kind) => period_kind.code().to_string(),
None => "<时段缺失>".to_string(),
};
// 门店范围:无范围时用一个单字符占位(`-`)而不是省略,
// 这样三个字段的位置在文本里始终固定,便于按列对照。
let store_text: &str = self.store_scope.unwrap_or("-");
format!(
"{}/{}/{}@{}",
self.shift_code.code(),
self.minimum_skill_grade.code(),
period_text,
store_text
)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : store_grade_factory.rs
//!
//! # 门店等级工厂 —— 第二族享元的工厂
//!
//! ## 本文件证明了什么
//!
//! 把它与 `shift_template_factory.rs` 并读,能看到两件事:
//!
//! 1. **两族工厂结构同形**:都是「一个 `SharedPool` + 一张登记表」,
//! 只是类型参数与登记内容不同。这直接说明享元机制是可复用的。
//! 2. **两族的复杂度差异来自业务,不来自模式**:班次模板需要
//! 复合键与时刻计算(业务复杂),门店等级只需要一个标签
//! (业务简单)。**模式本身不增加复杂度**——这是本工程想说明的
//! 一个反直觉结论:用了享元的两族只要业务简单,代码就仍然简单。
//!
//! ## 键是 `StoreGrade` 本身
//!
//! 不再包一层 `StoreGradeKey`。理由:`StoreGrade` 的 `PartialEq`/`Hash`
//! 已被手工实现为「身份即 `code`」(见 `domain::store_grade`),
//! 它本身就是一个合格的键。**再包一层只会多一个无信息的类型。**
//!
//! 这与班次模板形成对照:那里必须包一层,因为键是
//! 「班次 × 技能 × 时段」的复合体,而不是某个既有类型。
//! 判断标准:**键恰好是某个既有类型时就直接用;是多维组合时才新建键类型。**
use std::cell::RefCell;
use std::collections::HashMap;
use crate::domain::{
ShiftCode, StoreGrade, SHIFT_CODE_EARLY, SHIFT_CODE_LATE, SHIFT_CODE_MIDDLE,
SHIFT_CODE_NIGHT_AUDIT, SHIFT_CODE_WEEKEND_BOOST, STORE_GRADE_COMMUNITY,
STORE_GRADE_FLAGSHIP, STORE_GRADE_STANDARD,
};
use crate::support::clock_time::ClockTime;
use super::pool_snapshot::PoolSnapshot;
use super::shared_pool::{SharedHandle, SharedPool};
use super::store_grade_profile::StoreGradeProfile;
/// 门店等级工厂。
pub struct StoreGradeFactory {
/// 共享池。键就是门店等级本身。
pool: SharedPool<StoreGrade, StoreGradeProfile>,
/// 等级 → 配置规格的登记表。
///
/// ## 为什么还要一张登记表,而不是把配置直接写进 `build` 函数
///
/// 与班次工厂同理:写进 `match` 就无法工程外扩展。
/// 登记表让「新增一个门店等级配置」变成一次运行期调用,
/// 而不是一次源码修改。
registry: RefCell<HashMap<StoreGrade, StoreGradeProfileSpec>>,
}
/// 一个门店等级的配置规格(可运行期登记)。
///
/// 与 [`super::shift_template_factory::ShiftScheduleSpec`] 同构:
/// 把「构造实例所需的全部输入」打包成一个值类型,
/// 使登记接口的签名稳定(将来加字段不必改方法签名)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreGradeProfileSpec {
/// 门店等级。
grade: StoreGrade,
/// 每班次最低在岗人数。
minimum_staff_per_shift: u32,
/// 是否配备专职安保。
requires_dedicated_security: bool,
/// 营业开始时刻。
opening_time: ClockTime,
/// 营业结束时刻。
closing_time: ClockTime,
/// 是否要求每日盘点。
daily_audit_required: bool,
/// 默认开设的班次序列。
standard_shift_codes: Vec<ShiftCode>,
/// 合规提示。
compliance_notes: Vec<&'static str>,
}
impl StoreGradeProfileSpec {
/// 构造一个门店等级配置规格。
///
/// 参数较多且都是配置项,因此接收两个 `Vec`。
/// 不做成 `const fn`:`Vec` 无法在 const 上下文里构造,
/// 而数组转 `Vec` 需要 `to_vec()`(运行期)。
/// 工程外的扩展区因此需要在运行期登记(一行 `register_...` 调用),
/// 这比班次时刻表(可 `const`)稍繁琐,但完全可接受。
#[allow(clippy::too_many_arguments)]
pub fn new(
grade: StoreGrade,
minimum_staff_per_shift: u32,
requires_dedicated_security: bool,
opening_time: ClockTime,
closing_time: ClockTime,
daily_audit_required: bool,
standard_shift_codes: Vec<ShiftCode>,
compliance_notes: Vec<&'static str>,
) -> StoreGradeProfileSpec {
StoreGradeProfileSpec {
grade,
minimum_staff_per_shift,
requires_dedicated_security,
opening_time,
closing_time,
daily_audit_required,
standard_shift_codes,
compliance_notes,
}
}
/// 门店等级。
pub const fn grade(&self) -> StoreGrade {
self.grade
}
/// 每班次最低在岗人数。
pub const fn minimum_staff_per_shift(&self) -> u32 {
self.minimum_staff_per_shift
}
/// 是否配备专职安保。
pub const fn requires_dedicated_security(&self) -> bool {
self.requires_dedicated_security
}
/// 营业开始时刻。
pub const fn opening_time(&self) -> ClockTime {
self.opening_time
}
/// 营业结束时刻。
pub const fn closing_time(&self) -> ClockTime {
self.closing_time
}
/// 是否要求每日盘点。
pub const fn daily_audit_required(&self) -> bool {
self.daily_audit_required
}
/// 默认开设的班次序列。
pub fn standard_shift_codes(&self) -> &[ShiftCode] {
&self.standard_shift_codes
}
/// 合规提示。
pub fn compliance_notes(&self) -> &[&'static str] {
&self.compliance_notes
}
}
impl StoreGradeFactory {
/// 建一个已登记 3 个内置门店等级的工厂。
pub fn with_builtin_grades() -> StoreGradeFactory {
let factory = StoreGradeFactory {
pool: SharedPool::new(),
registry: RefCell::new(HashMap::new()),
};
factory.register_builtin_grade_specs();
factory
}
/// 登记 3 个内置门店等级的配置。
fn register_builtin_grade_specs(&self) {
// 旗舰店:核心商圈,4 个班次,配专职安保,每日盘点。
self.register_grade_spec(StoreGradeProfileSpec::new(
STORE_GRADE_FLAGSHIP,
6,
true,
ClockTime::from_hour_and_minute(9, 0),
ClockTime::from_hour_and_minute(22, 30),
true,
vec![
SHIFT_CODE_EARLY,
SHIFT_CODE_MIDDLE,
SHIFT_CODE_LATE,
SHIFT_CODE_WEEKEND_BOOST,
],
vec![
"旗舰店须每日闭店盘点,双人 + 安保在场",
"节假日须提前 7 日提交加强班排班",
],
));
// 标准店:3 个班次,配专职安保,每日盘点。
self.register_grade_spec(StoreGradeProfileSpec::new(
STORE_GRADE_STANDARD,
4,
true,
ClockTime::from_hour_and_minute(9, 30),
ClockTime::from_hour_and_minute(21, 30),
true,
vec![SHIFT_CODE_EARLY, SHIFT_CODE_MIDDLE, SHIFT_CODE_LATE],
vec!["标准店须每日闭店盘点,双人在场"],
));
// 社区店:2 个班次,不配专职安保(由总部巡检替代),隔日盘点。
self.register_grade_spec(StoreGradeProfileSpec::new(
STORE_GRADE_COMMUNITY,
2,
false,
ClockTime::from_hour_and_minute(10, 0),
ClockTime::from_hour_and_minute(20, 30),
// 社区店隔日盘点,由总部安保巡检覆盖。
false,
vec![SHIFT_CODE_EARLY, SHIFT_CODE_LATE],
vec!["社区店隔日盘点,由总部安保巡检覆盖", "盘点夜班须提前报备总部"],
));
}
/// 登记一个门店等级配置。
///
/// 参数 `spec`:配置规格。
/// 返回:成功返回 `true`;该等级已登记(不覆盖)返回 `false`。
///
/// 与班次工厂的登记接口同样的语义:不覆盖,用返回值暴露「编码已被占用」。
pub fn register_grade_spec(&self, spec: StoreGradeProfileSpec) -> bool {
let mut registry = self.registry.borrow_mut();
if registry.contains_key(&spec.grade()) {
return false;
}
registry.insert(spec.grade(), spec);
true
}
/// 已登记的门店等级数量。
pub fn registered_grade_count(&self) -> usize {
self.registry.borrow().len()
}
/// 门店等级是否已登记。
pub fn is_grade_registered(&self, grade: &StoreGrade) -> bool {
self.registry.borrow().contains_key(grade)
}
/// 取得(或新建)一个共享门店等级配置。
///
/// 参数 `grade`:门店等级。
/// 返回:共享实例;未登记时返回 `None`。
///
/// ## 为什么返回 `Option` 而不是像班次工厂那样返回具名错误
///
/// 班次工厂有**两种**失败(未登记 / 池满),需要枚举来区分;
/// 本工厂的池不限容量(门店等级只有 3~5 个,不会失控),
/// 因此只剩「未登记」一种失败——`Option` 已经表达了全部信息,
/// 再造一个只有一个变体的枚举只是噪音。
///
/// **错误类型的复杂度应当与失败模式的复杂度匹配。**
pub fn obtain(&self, grade: StoreGrade) -> Option<SharedHandle<StoreGradeProfile>> {
// 先查登记表(只读借用,随后释放)。
let registered_spec: StoreGradeProfileSpec = {
let registry = self.registry.borrow();
registry.get(&grade)?.clone()
};
// 再向池取/建。池不限容量,因此 `obtain` 不会失败。
self.pool
.obtain(grade, |looked_up_grade| {
StoreGradeProfile::new(
*looked_up_grade,
registered_spec.minimum_staff_per_shift(),
registered_spec.requires_dedicated_security(),
registered_spec.opening_time(),
registered_spec.closing_time(),
registered_spec.daily_audit_required(),
registered_spec.standard_shift_codes().to_vec(),
registered_spec.compliance_notes().to_vec(),
)
})
.ok()
}
/// 共享池的只读引用。
pub fn pool(&self) -> &SharedPool<StoreGrade, StoreGradeProfile> {
&self.pool
}
/// 池的统计快照。
pub fn snapshot(&self) -> PoolSnapshot {
self.pool.snapshot()
}
}
/// 供扩展区参考的「夜班盘点班次」便捷常量。
///
/// 放在本文件是因为它属于「工厂内置登记时的选择」,
/// 不是领域事实。
pub const BUILTIN_AUDIT_SHIFT_CODE: ShiftCode = SHIFT_CODE_NIGHT_AUDIT;
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : store_grade_profile.rs
//! # 门店等级配置 —— 第二族享元(内在状态)
//!
//! ## 为什么要有第二族
//!
//! 只做一族享元,能证明的只是「某种对象可以共享」。
//! 加第二族后,本工程能证明一件更有价值的事:
//! **享元机制本身是可复用的**——两族共用同一个泛型池
//! (`SharedPool`),只有键类型与实例类型不同。
//!
//! 这一点与 GoF 原书的例子吻合:原书里 `Glyph`(字形)的内在状态
//! 被多个字符位置共享。本工程的 `StoreGradeProfile` 正是「同一份配置
//! 被多家门店共享」——比「同一模板被多个槽位共享」更接近原书的形态。
//!
//! ## 与 `ShiftTemplate` 的三点不同(正是这三点让它有对照价值)
//!
//! | 维度 | `ShiftTemplate` | `StoreGradeProfile` |
//! |---|---|---|
//! | 含堆分配字段 | 否(零堆) | **是**(两个 `Vec`) |
//! | 键的类型 | 复合键(班次×技能×时段) | 单一标签(门店等级) |
//! | 实例数 | 7 个(随排班组合增长) | 3 个(固定) |
//!
//! 第二点尤其重要:**键可以很简单**。并非所有享元都需要复合键——
//! 当「配置由单一维度唯一决定」时,一个标签就够了。
//! 若硬要套用复合键,反而会不必要地增加池的条目数。
//!
//! 第一点让 [`FlyweightFootprint`] 的实现有了对照:
//! `ShiftTemplate` 的实现只写 `size_of`,而本类型必须额外累加 `Vec` 内容。
//! 两个实现并排放着,读者能直接看出「什么时候需要多算堆内容」。
use crate::domain::ShiftCode;
use crate::support::clock_time::ClockTime;
use super::footprint::FlyweightFootprint;
/// 一个门店等级的运营配置(享元本体)。
///
/// 字段全部私有:与 `ShiftTemplate` 同样的理由——
/// **共享实例必须不可变**,而私有字段 + 只读方法是 Rust 里
/// 表达这条约定最直接的方式。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreGradeProfile {
/// 该配置适用的门店等级。冗余存一份,便于从实例回溯身份。
grade: crate::domain::StoreGrade,
/// 每班次最低在岗人数。
minimum_staff_per_shift: u32,
/// 是否配备专职安保。
requires_dedicated_security: bool,
/// 营业开始时刻。
opening_time: ClockTime,
/// 营业结束时刻。
closing_time: ClockTime,
/// 是否要求每日盘点(涉及开保险柜,需双人 + 安保)。
daily_audit_required: bool,
/// 该等级默认开设的班次序列。
///
/// ## 为什么用 `Vec` 而不是固定长度数组
///
/// 不同等级的班次数量不同(社区店 2 个、旗舰店 4 个)。
/// 用定长数组要为所有等级按最大值分配空间(浪费),
/// 且「实际几个」还要另存一个长度——等于手写 `Vec`。
///
/// 在享元里用 `Vec` 是**安全**的:实例只有 3 个,
/// 每个 `Vec` 一次分配后就再不变动,不会被反复读写。
/// 若换成数万槽位各自持有一个 `Vec`,那才是灾难——
/// 而**那正是「不用享元」的对照版本要做的事**(见 `main.rs` 扩展区)。
standard_shift_codes: Vec<ShiftCode>,
/// 合规提示(该等级特有的注意事项)。
///
/// 元素类型是 `&'static str`:字符串内容位于二进制的只读段,
/// **不随实例分配堆内存**。因此本字段的堆开销只有
/// `Vec` 本身的槽位(每元素 16 字节),不含字符串内容。
/// 这一点在 [`FlyweightFootprint::estimated_bytes`] 里有对应处理,
/// 两处必须一致——否则内存统计会静默偏差。
compliance_notes: Vec<&'static str>,
}
impl StoreGradeProfile {
/// 构造一个门店等级配置。
///
/// 不对外开放(`pub(crate)`):理由与 `ShiftTemplate::new` 完全相同——
/// 保证所有实例都出自工厂,不产生「游离副本」。
pub(crate) fn new(
grade: crate::domain::StoreGrade,
minimum_staff_per_shift: u32,
requires_dedicated_security: bool,
opening_time: ClockTime,
closing_time: ClockTime,
daily_audit_required: bool,
standard_shift_codes: Vec<ShiftCode>,
compliance_notes: Vec<&'static str>,
) -> StoreGradeProfile {
StoreGradeProfile {
grade,
minimum_staff_per_shift,
requires_dedicated_security,
opening_time,
closing_time,
daily_audit_required,
standard_shift_codes,
compliance_notes,
}
}
/// 门店等级。
pub const fn grade(&self) -> crate::domain::StoreGrade {
self.grade
}
/// 每班次最低在岗人数。
pub const fn minimum_staff_per_shift(&self) -> u32 {
self.minimum_staff_per_shift
}
/// 是否配备专职安保。
pub const fn requires_dedicated_security(&self) -> bool {
self.requires_dedicated_security
}
/// 营业开始时刻。
pub const fn opening_time(&self) -> ClockTime {
self.opening_time
}
/// 营业结束时刻。
pub const fn closing_time(&self) -> ClockTime {
self.closing_time
}
/// 是否要求每日盘点。
pub const fn daily_audit_required(&self) -> bool {
self.daily_audit_required
}
/// 默认开设的班次序列。
pub fn standard_shift_codes(&self) -> &[ShiftCode] {
&self.standard_shift_codes
}
/// 默认班次数量。
pub fn standard_shift_count(&self) -> usize {
self.standard_shift_codes.len()
}
/// 合规提示。
pub fn compliance_notes(&self) -> &[&'static str] {
&self.compliance_notes
}
/// 营业时长(分钟)。
pub fn opening_duration(&self) -> crate::domain::WorkDuration {
let minutes: u16 = self.opening_time.minutes_until(&self.closing_time);
crate::domain::WorkDuration::from_minutes(minutes as u32)
}
/// 返回「营业 09:00-22:00 | 每班最低 6 人」形式的一行摘要。
///
/// 与 `ShiftTemplate::formatted` 同样属于「层内自带排版」,
/// 报表层不会采用它——第七幕会并列打印以演示排版职责的归属。
pub fn formatted(&self) -> String {
format!(
"{} | 营业 {}-{} | 每班最低 {} 人 | 安保 {}",
self.grade.display_name(),
self.opening_time.formatted(),
self.closing_time.formatted(),
self.minimum_staff_per_shift,
if self.requires_dedicated_security {
"配"
} else {
"不配"
}
)
}
}
impl FlyweightFootprint for StoreGradeProfile {
/// 返回配置的估算字节数。
///
/// ## 与 `ShiftTemplate` 的实现对照
///
/// 本类型**有**堆分配字段(两个 `Vec`),因此必须
/// 「`size_of` + 各 `Vec` 的槽位字节」。而 `ShiftTemplate`
/// 只需要 `size_of`。两个实现放在一起,就是
/// 「何时需要额外算堆」的判定示范。
///
/// ## 三个必须写明的口径
///
/// 1. **按 `len()` 不按 `capacity()`**:分配器实际给的容量可能更大,
/// 本工程统一按内容长度计(见 `footprint` 模块的口径声明)。
/// 2. **`&'static str` 的内容不计**:那些字符串在二进制只读段,
/// 不随实例分配。只计 `Vec` 里每个元素的 16 字节槽位。
/// 3. **`ShiftCode` 是按值存在 `Vec` 里的**:因此要按
/// `size_of::<ShiftCode>()` 而非指针大小计。
///
/// 第 3 点尤其容易写错——`Vec<T>` 的堆开销是
/// `len × size_of::<T>()`,而 `T` 是 `ShiftCode`(值类型,约 40 字节),
/// 不是 `&ShiftCode`(8 字节)。写成后者会把统计低估 5 倍。
fn estimated_bytes(&self) -> usize {
// 栈内固定部分。
let inline_bytes: usize = std::mem::size_of::<StoreGradeProfile>();
// `standard_shift_codes` 的堆槽位:按值存储,每个 `size_of::<ShiftCode>()`。
let shift_code_bytes: usize =
self.standard_shift_codes.len() * std::mem::size_of::<ShiftCode>();
// `compliance_notes` 的堆槽位:每元素一个 `&'static str`(16 字节),
// 字符串内容在只读段,不重复计算。
let note_bytes: usize = self.compliance_notes.len() * std::mem::size_of::<&'static str>();
inline_bytes + shift_code_bytes + note_bytes
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : calendar_date.rs
//! # 日历日 —— 只到「日」的精度
//!
//! ## 为什么不直接用时间戳
//!
//! 排班的业务单位是「日」:某门店某天需要哪几个班次。
//! 用时间戳(自epoch秒数)表示日期会引入两个麻烦:
//! 1. 时区问题——同一时刻在不同时区是不同的「日」,排班表会随服务器时区漂移;
//! 2. 可读性——调试输出全是 `1790985600`,人眼无法核对。
//!
//! 因此本类型只存 `(年, 月, 日)` 三个 `u16`,共 6 字节。
//! 这个宽度对本工程很关键:**享元的键里会用到日期**,
//! 键的每个字节都乘以「排班槽位总数」(本工程演示规模下是三万量级),
//! 所以「日期占几字节」直接影响共享后的总内存。
//!
//! ## 星期推算用的是 Sakamoto 查表版
//!
//! ⚠️ **踩坑记录(来自同系列 Facade 工程的实测事故)**:
//! 曾误用算术近似式 `(26 * (m + 1)) / 10` 来代替月份偏移表。
//! 这个式子只是 `floor(2.6 * (m + 1))` 的整数写法,
//! 与标准偏移表在 **3 月、9 月**等多个月份上并不相等,
//! 会导致**整年星期错一位**(2026-10-05 是周一,错版算法算出周二)。
//! 一旦星期错位,「周末班次时薪上浮」这类规则就会全线算错。
//!
//! 教训:**这类有公认表格的算法,宁可把表写出来,也不要用「看起来等价」的算式。**
//! 本文件末尾的 `weekday_index` 用的是查表版,且已用 Python `datetime` 交叉校验。
/// 一个不含时间的日历日。
///
/// 字段全部为 `u16`:年份到 65535 足够,月份 1..=12,日 1..=31。
/// 三者合计 6 字节,无填充(对齐要求为 2)。
///
/// ## 为什么字段是私有的
///
/// 本类型会作为**享元键的一部分**(见 `flyweight::shift_template_key`)。
/// 若字段公开,任何人都能构造出 `month = 13` 的非法值,
/// 进而污染享元池——错误的数据会被当成合法的键去共享。
/// 私有字段 + 构造时钳制,把非法值挡在类型之外。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CalendarDate {
/// 年(如 2026)。
year: u16,
/// 月(1..=12)。
month: u8,
/// 日(1..=31,且不超过该月实际天数)。
day: u8,
}
impl CalendarDate {
/// 按年月日构造一个日期,非法值**就近钳制**而非 panic。
///
/// 参数 `year` / `month` / `day`:年月日。
/// 返回:钳制后的合法日期。
///
/// ## 为什么选「钳制」而不是 `panic!`
///
/// 本工程的数据来源是**演示数据构造**与**工程外扩展**。
/// 扩展者写错月份(如 `13`)时,若直接 panic,整个演示崩掉、
/// 什么信息都得不到;若钳制到 12 月并继续,报表里会出现
/// 「2026-12-31」这个明显不对的日期,错误**可见且可追踪**。
///
/// 这叫「fail visible, not fail fast」——对演示工程而言,
/// 让错误出现在输出里比让程序崩溃更有价值。
/// 生产代码应当相反(宁可快速失败),这一点必须在注释里讲清楚,
/// 免得读者把这个选择当成通用建议抄走。
pub const fn from_ymd(year: u16, month: u8, day: u8) -> CalendarDate {
// 第一步:把月份钳到 1..=12。
let clamped_month: u8 = if month < 1 {
1
} else if month > 12 {
12
} else {
month
};
// 第二步:取该月实际天数,再把日钳到 1..=该天数。
let maximum_day: u8 = days_in_month(year, clamped_month);
let clamped_day: u8 = if day < 1 {
1
} else if day > maximum_day {
maximum_day
} else {
day
};
CalendarDate {
year,
month: clamped_month,
day: clamped_day,
}
}
/// 年。
pub const fn year(&self) -> u16 {
self.year
}
/// 月(1..=12)。
pub const fn month(&self) -> u8 {
self.month
}
/// 日(1..=31)。
pub const fn day(&self) -> u8 {
self.day
}
/// 返回 `YYYY-MM-DD` 文本。
///
/// 用于报表与调试输出。补零宽度固定为 2/2/4,
/// 因此同一年份区间内所有日期的文本宽度完全一致,可直接对齐。
pub fn formatted(&self) -> String {
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
/// 返回「M 月 D 日」文本(省略年份)。
///
/// 排班表通常是「同一个月内的展开」,逐行带年份纯属噪音。
pub fn month_day_text(&self) -> String {
format!("{} 月 {} 日", self.month, self.day)
}
/// 按自然日推进(可为负数,即回退)。
///
/// 参数 `day_count`:要推进的天数。
/// 返回:推进后的日期。
///
/// 实现采用「逐日循环」而非「查表跳月」:
/// 排班表的日期区间通常不超过一个季度(< 100 天),
/// 逐日循环最多 100 次,代价可忽略;而跳月逻辑要处理
/// 闰年、月末、月首跨年等多个分支,出错概率高得多。
/// 这里用性能换正确性,是划算的取舍。
pub fn add_days(&self, day_count: i32) -> CalendarDate {
// 先转成可变状态,逐日推进后再统一校验。
let mut current_year: i32 = self.year as i32;
let mut current_month: u8 = self.month;
let mut current_day: u8 = self.day;
// 正负两个方向用同一套循环:每轮把 day 加减 1,
// 越界则进位/退位到相邻月,直到走完 day_count 次。
let remaining_steps: i32 = day_count.abs();
// 方向:+1 表示向后,-1 表示向前。
let direction: i32 = if day_count >= 0 { 1 } else { -1 };
for _ in 0..remaining_steps {
let tentative_day: i32 = current_day as i32 + direction;
if tentative_day < 1 {
// 退到上个月的最后一天。
let (previous_year, previous_month): (i32, u8) = previous_month_year(
current_year,
current_month,
);
current_year = previous_year;
current_month = previous_month;
current_day = days_in_month(current_year as u16, current_month);
} else if tentative_day as u8 > days_in_month(current_year as u16, current_month) {
// 进到下个月的第一天。
let (next_year, next_month): (i32, u8) = next_month_year(
current_year,
current_month,
);
current_year = next_year;
current_month = next_month;
current_day = 1;
} else {
// 月内正常推进。
current_day = tentative_day as u8;
}
}
// 年份在 i32 域内运算,回落到 u16 时钳制,避免负数溢出。
let bounded_year: u16 = if current_year < 0 {
0
} else if current_year > u16::MAX as i32 {
u16::MAX
} else {
current_year as u16
};
CalendarDate::from_ymd(bounded_year, current_month, current_day)
}
/// 计算 `self` 到 `other` 之间相差的自然日数(`other - self`)。
///
/// 参数 `other`:目标日期。
/// 返回:天数差,可为负。
///
/// ## 为什么不用「转儒略日相减」
///
/// 儒略日公式(如 `367*Y - ...`)是另一处容易写错的算术——
/// 同类的「Sakamoto 算术近似式」已经在本工程里翻过车。
/// 本工程改用「逐日推进 + 计数」,代价是 O(天数),
/// 但天数区间受控(演示数据 ≤ 40 天),且**逻辑可肉眼验证**:
/// 每次只能前进或后退一天,不存在「公式差一点」的可能。
///
/// 这个选择本身值得记录:**当正确性无法通过读代码确认时,
/// 宁可换成慢但显然正确的算法。**
pub fn days_until(&self, other: &CalendarDate) -> i32 {
// 两日期相等,直接返回 0(避免下面的循环无意义地跑)。
if self == other {
return 0;
}
// 向后推进直到追上 other。
if *other > *self {
let mut cursor: CalendarDate = *self;
let mut advanced_days: i32 = 0;
// 上限保护:年份跨度过大时终止,避免极端输入造成长循环。
while cursor != *other && advanced_days < 100_000 {
cursor = cursor.add_days(1);
advanced_days += 1;
}
return advanced_days;
}
// 向前回退直到追上 other。
let mut cursor: CalendarDate = *self;
let mut retreated_days: i32 = 0;
while cursor != *other && retreated_days > -100_000 {
cursor = cursor.add_days(-1);
retreated_days -= 1;
}
retreated_days
}
/// 返回星期索引(0 = 周一 … 6 = 周日)。
///
/// ## 实现:Sakamoto 算法(查表版)
///
/// 算法分三步:
/// 1. 1 月、2 月视为上一年的 13、14 月(这样闰年规则可统一处理);
/// 2. 用年份、世纪修正项与**月份偏移表**累加出一个整数;
/// 3. 对 7 取模,得到以周日为 0 的索引,再映射到「周一为 0」。
///
/// 第 2 步的月份偏移表是 `[0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]`
/// (下标 0 对应 1 月)。它是对「各月 1 日与年初的星期偏移」预先算好的表。
///
/// ⚠️ 再次强调:**不要**把这个表换成 `(26 * (month + 1)) / 10`
/// 之类的算术式,两者在 3 月、9 月等月份不等,会整年错位。
/// 有公认表格就直接写表。
pub fn weekday_index(&self) -> u32 {
// 标准 Sakamoto 月份偏移表(下标 0 = 1 月)。
const MONTH_OFFSET_TABLE: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
// 1 月、2 月归属上一年,便于统一闰日处理。
let adjusted_year: i32 = if self.month < 3 {
self.year as i32 - 1
} else {
self.year as i32
};
// 核心累加:年 + 年/4 - 年/100 + 年/400 + 月偏移 + 日。
// 其中 `年/4 - 年/100 + 年/400` 是完整的闰年修正(含百年例外与四百年回归)。
let raw_sum: i32 = adjusted_year
+ adjusted_year / 4
- adjusted_year / 100
+ adjusted_year / 400
+ MONTH_OFFSET_TABLE[(self.month - 1) as usize]
+ self.day as i32;
// 取模得到「0 = 周日」的索引;`+7` 再取模用于兜住负数(年份为 0 附近时)。
let sunday_based_index: i32 = ((raw_sum % 7) + 7) % 7;
// 映射为「0 = 周一」:周日(0) 变成 6,周一(1) 变成 0,依此类推。
if sunday_based_index == 0 {
6
} else {
(sunday_based_index - 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 = 周日。
self.weekday_index() >= 5
}
}
/// 判断某年是否闰年。
///
/// 参数 `year`:公元年。
/// 返回:闰年返回 `true`。
///
/// 规则:能被 4 整除,但不能被 100 整除;除非能被 400 整除。
/// 写成三行条件而不是一个布尔表达式,是为了让「百年例外」与「四百年回归」
/// 两条规则在代码里各自可见——这是最容易漏掉的两条。
pub const fn is_leap_year(year: u16) -> bool {
// 四百年回归优先:2000 是闰年。
if year % 400 == 0 {
return true;
}
// 百年例外:1900 不是闰年。
if year % 100 == 0 {
return false;
}
// 常规规则:能被 4 整除即可。
year % 4 == 0
}
/// 返回某年某月的天数。
///
/// 参数 `year` / `month`:年月(`month` 假定已合法,1..=12)。
/// 返回:该月天数(28..=31)。
///
/// 用 `match` 精确列出每月天数,而不是「30 天 + 特例判断」——
/// 后者需要记住哪几个月是 31 天,容易在 8 月/9 月交界处写错。
pub const fn days_in_month(year: u16, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
// 二月天数取决于闰年。
if is_leap_year(year) {
29
} else {
28
}
}
// 非法月份(调用方已钳制,这里只是兜底)按 31 处理。
_ => 31,
}
}
/// 返回某月的上一个月(跨年时自动退一年)。
///
/// 参数 `year` / `month`。
/// 返回:`(上一年的年, 上一月)`。
///
/// 抽成自由函数而不是 `CalendarDate` 的方法:它操作的是「年月」这一对值,
/// 与「日」无关,放在类型上反而要多做一次解构与重组。
const fn previous_month_year(year: i32, month: u8) -> (i32, u8) {
if month == 1 {
// 1 月的上一月是上一年 12 月。
(year - 1, 12)
} else {
(year, month - 1)
}
}
/// 返回某月的下一个月(跨年时自动进一年)。
///
/// 参数 `year` / `month`。
/// 返回:`(下一年的年, 下一月)`。
const fn next_month_year(year: i32, month: u8) -> (i32, u8) {
if month == 12 {
// 12 月的下一月是下一年 1 月。
(year + 1, 1)
} else {
(year, month + 1)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : clock_time.rs
//! # 一天内的时刻 —— 以「零点起的分钟数」为单位
//!
//! ## 为什么不用 `(时, 分)` 两个字段
//!
//! 班次有「开始时刻」与「时长」两个维度,报表要算「结束时刻」。
//! 若用 `(hour, minute)` 两个字段,每次算时刻加法都要处理进位
//! (`minute + 45 > 59` 时进位到 `hour`),分支多、易错。
//!
//! 用「零点起的分钟数」(0..=1439)单一整数表示后:
//! - 时刻加法 = 整数加法 + 取模 1440(一行搞定);
//! - 比较大小 = 整数比较(天然正确);
//! - 存储 = 2 字节(`u16` 足够,最大 1439)。
//!
//! ## 跨零点的夜班是必须处理的情况
//!
//! 珠宝门店的「夜班盘点」是 22:30 上班、次日 06:30 下班。
//! 若把结束时刻也算成「零点起分钟数」,则 6:30 = 390 < 22:30 = 1350,
//! 直接相减会得到负数。本模块用 [`ClockTime::minutes_until`] 显式处理
//! 「结束值小于开始值 ⇒ 跨零点」这一约定,并要求调用方通过它来算时长,
//! 而不是自己相减。
/// 一天内的时刻,以「零点起的分钟数」表示。
///
/// 取值恒在 `0..=1439`。字段私有,只能经 [`ClockTime::from_hour_and_minute`]
/// 或 [`ClockTime::from_minute_of_day`] 构造,保证不出现 `1500` 这类非法值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ClockTime {
/// 零点起的分钟数(0..=1439)。
minute_of_day: u16,
}
impl ClockTime {
/// 一天的总分钟数,用于跨零点时的取模。
pub const MINUTES_PER_DAY: u16 = 24 * 60;
/// 按「时 + 分」构造一个时刻。
///
/// 参数 `hour`:时(0..=23,超出则对其取模);`minute`:分(0..=59,超出则对其取模)。
/// 返回:对应的时刻。
///
/// 取模而非 panic:与 [`crate::support::calendar_date::CalendarDate::from_ymd`]
/// 同样的「fail visible」策略——`ClockTime::from_hour_and_minute(25, 0)`
/// 得到 `01:00`,报表里一眼能看出不对,而程序不会崩。
pub const fn from_hour_and_minute(hour: u16, minute: u16) -> ClockTime {
// 先各自归一:小时按 24 取模,分钟按 60 取模。
let normalized_hour: u16 = hour % 24;
let normalized_minute: u16 = minute % 60;
ClockTime {
minute_of_day: normalized_hour * 60 + normalized_minute,
}
}
/// 按「零点起的分钟数」构造一个时刻。
///
/// 参数 `minute_of_day`:分钟数,超出一天则对其取模。
/// 返回:对应的时刻。
///
/// 取模使本函数成为「时刻加法」的天然载体:
/// 22:30 加 8 小时 = 1350 + 480 = 1830,取模 1440 得 390 = 06:30,
/// 正好是次日早晨——跨零点自动完成,无需调用方判断。
pub const fn from_minute_of_day(minute_of_day: u16) -> ClockTime {
ClockTime {
minute_of_day: minute_of_day % ClockTime::MINUTES_PER_DAY,
}
}
/// 零点起的分钟数。
pub const fn minute_of_day(&self) -> u16 {
self.minute_of_day
}
/// 时(0..=23)。
pub const fn hour(&self) -> u16 {
self.minute_of_day / 60
}
/// 分(0..=59)。
pub const fn minute(&self) -> u16 {
self.minute_of_day % 60
}
/// 返回 `HH:MM` 文本。
///
/// 固定补零到两位,因此一天内所有时刻的文本宽度一致(5 列),
/// 报表里可以直接按显示宽度对齐,无需特殊处理。
pub fn formatted(&self) -> String {
format!("{:02}:{:02}", self.hour(), self.minute())
}
/// 计算从 `self` 到 `other` 经过的分钟数,**跨零点时自动绕圈**。
///
/// 参数 `other`:结束时刻。
/// 返回:经过的分钟数(0..=1439)。
///
/// ## 关键约定
///
/// 若 `other` 的时刻值**小于或等于** `self`,则视为「跨过了零点」,
/// 结果为 `other + 1440 - self`。
///
/// 这个约定意味着 `minutes_until` **不能**用来算「往回退多久」——
/// 它会永远返回正数。这正合班次语义:班次时长总是向前的。
/// 若将来需要「往前找最近的班次」,要另写一个函数,
/// 不要在这个函数里加参数去兼容两种语义(那会让两种调用都难读)。
pub fn minutes_until(&self, other: &ClockTime) -> u16 {
if other.minute_of_day >= self.minute_of_day {
// 同一天内:直接相减。
other.minute_of_day - self.minute_of_day
} else {
// 跨零点:终点补一天后再相减。
other.minute_of_day + ClockTime::MINUTES_PER_DAY - self.minute_of_day
}
}
/// 判断 `self` 到 `other` 是否跨越了零点。
///
/// 参数 `other`:结束时刻。
/// 返回:跨零点返回 `true`。
///
/// 夜班盘点(22:30 → 06:30)会返回 `true`。
/// 这个判定在报表里要显式标注「次日」,否则读者会以为 06:30 是当天早上。
pub fn crosses_midnight_to(&self, other: &ClockTime) -> bool {
// 终点时刻值不大于起点 ⇒ 必然跨过了零点。
other.minute_of_day <= self.minute_of_day
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : deterministic_code.rs
//! # 确定性编码 —— 由输入内容派生稳定标识
//!
//! ## 为什么需要「确定性」编码
//!
//! 排班系统里要给「排班槽位」生成编号(便于报表引用、便于核对)。
//! 若用随机数或自增计数器:
//! - 随机数:每次运行编号都变,两次运行的报表无法对比;
//! - 自增:编号取决于遍历顺序,一旦生成顺序调整(比如多跑一家门店),
//! 所有编号全变,历史报表失去可比性。
//!
//! 本模块用 **FNV-1a 64 位哈希**从输入内容派生编码:同样的输入永远得到同样的输出,
//! 且与生成顺序无关。这个性质在本工程里还有一个额外用途——
//! **用派生编码验证「两处独立算出的槽位」确实是同一个槽位**,
//! 即「同一门店 + 同一日期 + 同一班次 → 同一编号」。
//!
//! ## 为什么选 FNV-1a 而不是标准库的 `DefaultHasher`
//!
//! `std::collections::hash_map::DefaultHasher` 的输出**在 Rust 版本之间不保证稳定**
//! (官方明确说明「不应作为持久化格式」)。本工程要在报表里打印编码、
//! 并跨运行对比,因此必须用一个**自己写死、永不改变**的算法。
//! FNV-1a 只有三行,且常量是公开标准,最合适。
//!
//! ⚠️ 本模块**不是密码学哈希**,不要用于安全场景(如签名、口令)。
//! 它只保证「同输入同输出」与「广泛分布」,不保证抗碰撞攻击。
/// FNV-1a 哈希的 64 位偏移基准量。
///
/// 这个常量来自 FNV 规范(offset basis),改动它会使所有既有编码失效。
const FNV_OFFSET_BASIS_64: u64 = 0xcbf2_9ce4_8422_2325;
/// FNV-1a 哈希的 64 位质数。
///
/// 同样来自 FNV 规范(prime),不可改动。
const FNV_PRIME_64: u64 = 0x0000_0100_0000_01b3;
/// 对一个字节切片做 FNV-1a 哈希。
///
/// 参数 `bytes`:待哈希的字节。
/// 返回:64 位哈希值。
///
/// 算法(1a 变体,与 1 的区别在于「先异或再乘」):
/// 1. 从偏移基准量开始;
/// 2. 对每个字节:先与哈希值异或,再乘以质数;
/// 3. 全程使用 64 位**环绕**乘法(`wrapping_mul`),溢出即丢弃高位。
///
/// `wrapping_mul` 在这里不是「图省事」,而是算法定义的一部分——
/// 用普通乘法会在调试构建下 panic,释放构建下数值不同,
/// 造成「debug 与 release 输出不一致」这种最难查的问题。
pub const fn fnv1a_64(bytes: &[u8]) -> u64 {
let mut hash_value: u64 = FNV_OFFSET_BASIS_64;
let mut index: usize = 0;
while index < bytes.len() {
// 第一步:异或当前字节(先异或是 1a 与 1 的唯一区别)。
hash_value ^= bytes[index] as u64;
// 第二步:乘以质数,允许溢出环绕。
hash_value = hash_value.wrapping_mul(FNV_PRIME_64);
index += 1;
}
hash_value
}
/// 把多个字符串拼成一个「种子」再做哈希。
///
/// 参数 `segments`:按顺序参与的字符串片段。
/// 返回:哈希值。
///
/// ## 分隔符不可省略
///
/// 若直接把 `["AB", "C"]` 与 `["A", "BC"]` 拼接成 `"ABC"`,
/// 两者哈希相同——这是**真实存在的碰撞**,不是理论担忧:
/// 排班槽位的种子正是「门店编码 + 日期 + 班次编码」这种多段拼接,
/// 门店编码末尾与日期开头很容易黏连出歧义。
///
/// 因此本函数在每段之间插入 `\x1f`(ASCII Unit Separator)。
/// 这个字符不会出现在本工程的任何编码、日期或名称里
/// (编码是 `A-Z0-9_`,日期是数字与短横),因此能可靠分界。
pub fn build_seed(segments: &[&str]) -> u64 {
// 分隔符:ASCII Unit Separator,业务数据中不会出现。
const SEPARATOR: u8 = 0x1f;
let mut hash_value: u64 = FNV_OFFSET_BASIS_64;
for (position, segment) in segments.iter().enumerate() {
// 除第一段外,每段前先吃一个分隔符,保证「段边界」参与哈希。
if position > 0 {
hash_value ^= SEPARATOR as u64;
hash_value = hash_value.wrapping_mul(FNV_PRIME_64);
}
// 再把该段的每个字节并入。
for byte in segment.as_bytes() {
hash_value ^= *byte as u64;
hash_value = hash_value.wrapping_mul(FNV_PRIME_64);
}
}
hash_value
}
/// 由哈希值派生一个带前缀的 12 位大写十六进制编码。
///
/// 参数 `prefix`:编码前缀(如 `"SLOT"`);`hash_value`:哈希值。
/// 返回:形如 `SLOT-1A2B3C4D5E6F` 的字符串。
///
/// 取哈希低 48 位(12 个十六进制位):
/// - 48 位在演示规模(数万槽位)下碰撞概率极低;
/// - 12 位定长便于表格对齐(每行编号显示宽度完全相同)。
///
/// 为何不取全部 64 位?16 位十六进制会让报表一行放不下,
/// 而多出的 16 位对本工程的规模没有实际价值。这是**按用途裁剪**,
/// 不是「随便取几位」——注释里写清理由,后来者才好判断能否改动。
pub fn derive_code(prefix: &str, hash_value: u64) -> String {
// 取低 48 位:用掩码清掉高位。
let truncated_value: u64 = hash_value & 0x0000_FFFF_FFFF_FFFF;
format!("{}-{:012X}", prefix, truncated_value)
}
/// 由哈希值派生一个 8 位大写十六进制指纹。
///
/// 参数 `hash_value`:哈希值。
/// 返回:8 位十六进制文本。
///
/// 用于报表表头(「本次排班指纹 A1B2C3D4」):读者可以凭这 8 位
/// 确认「两次运行看到的是同一批数据」,而不必逐行比对数万条排班。
pub fn short_fingerprint(hash_value: u64) -> String {
// 取高 32 位(而非低位)——低位已被 derive_code 用于编号,
// 指纹取高位可让「编号相近的两个槽位」在指纹上也有明显差异。
let upper_bits: u32 = (hash_value >> 32) as u32;
format!("{:08X}", upper_bits)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:享元模式 Flyweight Pattern 结构型模式 Structural Patterns
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/7 8:11
//!# User : geovindu
//!# Product : RustRover
//!# Project : flyweightpattern
//!# File : text_layout.rs
//! # CJK 宽度感知的文本排版
//!
//! ## 为什么这一层不可省略
//!
//! 排班表要打印门店名、班次名、员工姓名,全是中文。而 Rust 的格式化填充
//! `{:<20}` 按**字符数**(`char` 个数)补空格,不是按**显示列数**。
//! 一个汉字在等宽终端里占 2 列,于是:
//!
//! ```text
//! {:<12} 的效果(错位) display_width 的效果(对齐)
//! 门店 样式 门店 样式
//! 深圳旗舰店 样式 深圳旗舰店 样式
//! ^ 前者只补到 12 个「字符」 ^ 前者补到 12 个「列」
//! ```
//!
//! 全工程禁止直接用 `{:<N}` 打印含中文的表格单元,一律走本模块。
//!
//! ## 区段表必须互不重叠
//!
//! 下面 `is_wide_character` 里的区间若出现重叠或交叠,
//! `matches!` 会报「不可达模式」(unreachable pattern)告警,
//! 或者更糟——悄悄漏判某一区段。写这类表时要一格一格核对边界。
/// 判断一个字符在等宽终端中是否占 2 个显示列。
///
/// 参数 `character`:待判定的字符。
/// 返回:占 2 列返回 `true`,占 1 列返回 `false`。
///
/// 这里采用**主区域判定**而非穷举全表:全量 Unicode 的宽字符表有上千个区段,
/// 但本工程的字符来源受控(预置常量 + 演示数据,都在 CJK 与拉丁范围内),
/// 因此覆盖以下区段即足够,且每一段都可人工核对:
///
/// | 区段 | 含义 | 本工程的实际来源 |
/// |---|---|---|
/// | `U+1100..=U+115F` | 谚文字母 | 未使用(预留) |
/// | `U+2E80..=U+303E` | CJK 部首补充、符号 | 「·」「~」等 |
/// | `U+3041..=U+33FF` | 平假名、片假名、CJK 注音 | 未使用(预留) |
/// | `U+3400..=U+4DBF` | CJK 扩展 A | 生僻字(预留) |
/// | `U+4E00..=U+9FFF` | CJK 基本区 | 门店名、班次名、员工姓名 |
/// | `U+A000..=U+A4CF` | 彝文 | 未使用(预留) |
/// | `U+AC00..=U+D7A3` | 谚文音节 | 未使用(预留) |
/// | `U+F900..=U+FAFF` | CJK 兼容表意文字 | 未使用(预留) |
/// | `U+FE30..=U+FE4F` | CJK 兼容形式 | 「:」全角标点(预留) |
/// | `U+FF00..=U+FF60` | 全角 ASCII | 「()」「%」全角形式 |
/// | `U+FFE0..=U+FFE6` | 全角符号 | 「¢」「£」「¥」 |
///
/// 注意 `U+FF61..=U+FFDC`(半角片假名)**不被**判为宽字符——它们确实是 1 列。
pub fn is_wide_character(character: char) -> bool {
// 用 u32 比较,避免在每个分支里重复做 `as u32` 转换。
let code_point: u32 = character as u32;
matches!(
code_point,
0x1100..=0x115F
| 0x2E80..=0x303E
| 0x3041..=0x33FF
| 0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xA000..=0xA4CF
| 0xAC00..=0xD7A3
| 0xF900..=0xFAFF
| 0xFE30..=0xFE4F
| 0xFF00..=0xFF60
| 0xFFE0..=0xFFE6
)
}
/// 计算字符串在等宽终端中的显示列数。
///
/// 参数 `text`:待测量的字符串。
/// 返回:显示列数(汉字计 2,其余计 1)。
///
/// 这是本模块所有填充函数的基础。**不要**用 `text.len()`(那是 UTF-8 字节数,
/// 一个汉字 3 字节)或 `text.chars().count()`(那是字符数,一个汉字 1 个)。
pub fn display_width(text: &str) -> usize {
// 逐个字符累加宽度:宽字符 2 列,窄字符 1 列。
text.chars()
.map(|character| if is_wide_character(character) { 2 } else { 1 })
.sum()
}
/// 在右侧补空格,使结果达到指定**显示列数**。
///
/// 参数 `text`:原文本;`target_width`:目标显示列数。
/// 返回:补齐后的字符串。
///
/// **超出时不截断**:若 `text` 的显示宽度已超过 `target_width`,原样返回。
/// 这一点是刻意的——截断会丢信息,而「列宽估小了」是调用方的 bug,
/// 应该在输出里暴露出来(表格错位一眼可见),而不是被静默吃掉。
pub fn pad_right(text: &str, target_width: usize) -> String {
let current_width: usize = display_width(text);
if current_width >= target_width {
return text.to_string();
}
// 差额就是需要补的空格数(空格永远是 1 列宽)。
let padding_count: usize = target_width - current_width;
format!("{}{}", text, " ".repeat(padding_count))
}
/// 在左侧补空格,使结果达到指定**显示列数**。
///
/// 参数 `text`:原文本;`target_width`:目标显示列数。
/// 返回:补齐后的字符串。
///
/// 用于数字列的右对齐——金额、件数、时长都靠它对齐。
pub fn pad_left(text: &str, target_width: usize) -> String {
let current_width: usize = display_width(text);
if current_width >= target_width {
return text.to_string();
}
let padding_count: usize = target_width - current_width;
format!("{}{}", " ".repeat(padding_count), text)
}
/// 居中对齐到指定显示列数。
///
/// 参数 `text`:原文本;`target_width`:目标显示列数。
/// 返回:两侧补空格后的字符串。
///
/// 差值分配规则:**左少右多**(左侧补 `floor(差值/2)`,右侧补剩余)。
/// 为什么不左右均分?因为当差值为奇数时无法均分,必须选一边多补一格。
/// 选「右多」是因为中文标题通常希望视觉重心略偏左,
/// 左边少一格会让标题看起来更靠中。这个选择要写下来,
/// 否则下一个人会以为是算错了。
pub fn pad_center(text: &str, target_width: usize) -> String {
let current_width: usize = display_width(text);
if current_width >= target_width {
return text.to_string();
}
let total_padding: usize = target_width - current_width;
let left_padding: usize = total_padding / 2;
let right_padding: usize = total_padding - left_padding;
format!(
"{}{}{}",
" ".repeat(left_padding),
text,
" ".repeat(right_padding)
)
}
/// 按显示列数截断字符串。
///
/// 参数 `text`:原文本;`maximum_width`:允许的最大显示列数。
/// 返回:在不超过 `maximum_width` 的前提下能容纳的最长前缀。
///
/// ## 为什么必须自己写这个函数
///
/// Rust 的 `&text[..n]` 按**字节**切片,切在汉字中间会 panic
/// (`byte index is not a char boundary`)。本函数按字符逐个累加宽度,
/// 天然保证切点落在字符边界上。
///
/// ## 放弃半个汉字
///
/// 若下一个字符是宽字符(2 列)而剩余宽度只有 1 列,就直接停下,
/// **不**用空格或半个字符填充。表格单元宁可短一列,
/// 也不能出现「只有左半边」的乱码。
pub fn truncate_to_width(text: &str, maximum_width: usize) -> String {
let mut accumulated_width: usize = 0;
// 收集能放下的字符;用 String 而非 &str 切片,天然按字符边界推进。
let mut result: String = String::new();
for character in text.chars() {
let character_width: usize = if is_wide_character(character) { 2 } else { 1 };
// 加上这个字符会超宽 → 停止(放弃这个字符,包括「放不下的宽字符」)。
if accumulated_width + character_width > maximum_width {
break;
}
result.push(character);
accumulated_width += character_width;
}
result
}
/// 生成一条横线,用于表格分隔。
///
/// 参数 `column_width`:横线的显示列数。
/// 返回:由全角制表符 `─` 拼成的字符串。
///
/// 用 `─`(U+2500,属于 `U+2E80..=U+303E` 区段,占 2 列)拼接,
/// 因此**每条字符占 2 列**,`column_width` 需要是偶数才能精确对齐。
/// 若传入奇数宽度,本函数向下取偶数——这比输出错位一格更容易被发现。
pub fn horizontal_rule(column_width: usize) -> String {
// 向下取偶数,保证每条 `─` 恰好铺满 2 列而不留半条。
let segment_count: usize = column_width / 2;
"─".repeat(segment_count)
}
哲学管理(学)人生, 文学艺术生活, 自动(计算机学)物理(学)工作, 生物(学)化学逆境, 历史(学)测绘(学)时间, 经济(学)数学金钱(理财), 心理(学)医学情绪, 诗词美容情感, 美学建筑(学)家园, 解构建构(分析)整合学习, 智商情商(IQ、EQ)运筹(学)生存.---Geovin Du(涂聚文)
浙公网安备 33010602011771号