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 : main.rs
//! # Flyweight Pattern —— 连锁珠宝门店排班系统的共享池
//!
//! ## 严格分层结构
//!
//! ```text
//! ┌──────────────────────────────────────────┐
//! main.rs ─────────► │ (工程外扩展):新班次 / 新门店等级 │
//! └──────────────────────────────────────────┘
//! │ 依赖
//! ▼
//! ┌──────────────────────────────────────────┐
//! │ app 表示层:把结论排版成报表 │
//! │ 零算术,只拼接与对齐 │
//! └──────────────────────────────────────────┘
//! │
//! ▼
//! ┌──────────────────────────────────────────┐
//! │ analysis 分析层:只读度量 │
//! │ 成本分布 / 内存对照 / 共享画像 │
//! │ / 合规体检 │
//! └──────────────────────────────────────────┘
//! │
//! ▼
//! ┌──────────────────────────────────────────┐
//! │ client 客户端层:外在状态 + 装配驱动 │
//! │ ShiftSlot / ShiftPlan │
//! │ ScheduleAssembler │
//! └──────────────────────────────────────────┘
//! │
//! ▼
//! ┌──────────────────────────────────────────┐
//! │ flyweight 模式层:共享机制 │
//! │ SharedPool<Key, Flyweight> │
//! │ SharedHandle = Rc<T> │
//! │ ShiftTemplateFactory │
//! │ StoreGradeFactory │
//! └──────────────────────────────────────────┘
//! │
//! ▼
//! ┌──────────────────────────────────────────┐
//! │ domain 领域层:零依赖值对象 │
//! │ 金额(分) / 比率(万分比) / 工时(分) │
//! └──────────────────────────────────────────┘
//! │
//! (无依赖边:两条并列的叶子层)
//! │
//! ┌──────────────────────────────────────────┐
//! │ support 支持层:叶子,零依赖 │
//! │ 日历 / 时刻 / 确定性编码 / 排版 │
//! └──────────────────────────────────────────┘
//! ```
//!
//! **依赖严格单向**:每一层只引用它下面的层,反向依赖为零。
//!
//! 上面的图是**设计意图**,下面这张是**实测结果**(用
//! `bash scripts/check_layer_dependencies.sh src` 跑出来的,
//! 脚本已剥离 `//` 行注释,避免把文档里引用的反例当成真依赖):
//!
//! ```text
//! app -> analysis client domain flyweight support
//! analysis -> client domain support
//! client -> domain flyweight support
//! flyweight -> domain support
//! domain -> none
//! support -> none
//! ```
//!
//! 两处容易被误读的地方,特此说明:
//! - `domain -> none`:领域层**不是**「必须依赖 support」,而是**故意零依赖**——
//! 一个「金额」或「比率」自己就能完成运算,不需要日历、时刻或排版。
//! 因此 `domain` 与 `support` 之间**没有依赖边**,图中画的是并列关系。
//! - 每一层都**可以跳过**更下面的层直接引用更底层(例如 `app` 直接引用 `support`),
//! 「严格分层」约束的是**方向**,不是**相邻性**。
//!
//! 有一条静态可查的红线:`flyweight` 层**不引用 `client`**——
//! 这正是「享元可被任意多个客户端复用」的结构表达。
//! 若哪天有人在 `flyweight` 里 `use crate::client::...`,
//! 那个模式就退化成了「只服务一个客户端的缓存」。
//!
//! ## 模式角色落点
//!
//! | GoF 角色 | 本工程的落点 | 说明 |
//! |---|---|---|
//! | Flyweight(享元) | `ShiftTemplate` / `StoreGradeProfile` | **两族**享元,都是不可变、可共享的内在状态 |
//! | FlyweightFactory | `ShiftTemplateFactory` / `StoreGradeFactory` | 各持一个 `SharedPool` + 一张登记表 |
//! | 内在状态 | 班次时刻、系数;每班人数、营业时段、合规备注 | 与「用在哪儿」无关,可以共享 |
//! | 外在状态 | 门店编码、日期、员工、实际工时、门店指数 | 每次使用都不同,必须留在客户端 |
//! | Client | `ScheduleAssembler` / `ShiftSlot` | 持有句柄,从不复制享元 |
//!
//! ## 本工程要证明的一件事
//!
//! > 一个排班表有 1.1 万个槽位,但**真正不同的班次定义只有 20 个**、
//! > **真正不同的门店等级配置只有 3 个**。
//!
//! 「享元可复用的是**机制**,不是一个具体对象」——这句话在本工程里
//! 被两族享元**共用同一个泛型池**这个事实实证:
//! `SharedPool<ShiftTemplateKey, ShiftTemplate>` 与
//! `SharedPool<StoreGrade, StoreGradeProfile>` 是**同一个类型的两份实例化**,
//! 两个工厂的代码结构同形。
//!
//! ## 八幕导览
//!
//! | 幕 | 内容 | 要回答的问题 |
//! |---|---|---|
//! | 一 | 规模与装配 | 排了多少、花了多少、池里存了什么 |
//! | 二 | 剧痛:若每个槽位各持一份 | 不共享要多少字节?共享省了多少? |
//! | 三 | 键的三段实验 | 键过粗 / 过细各自付出什么代价? |
//! | 四 | 共享度画像 | 共享是怎么发生的?均不均匀? |
//! | 五 | 成本分布 | 钱花在哪个维度上? |
//! | 六 | 合规体检 | 有没有不合规?架构规则是否达标? |
//! | 七 | 破损输入 | 四类漏排能否被一次报全? |
//! | 八 | 工程外扩展 | 新增班次/等级是否**不改任何既有分层文件**? |
//!
//! ## 数值可核对性(本工程的一条硬纪律)
//!
//! 全部输出必须**两次运行完全一致**,且每个数字都能被人工手算核对。
//! 为此:
//! - 不引入任何随机数(人员轮转用确定性序号取模);
//! - 分组报表按标签文本排序输出(不按 `HashMap` 的迭代顺序);
//! - 金额用整数「分」、比率用整数万分比、工时用整数分钟,全程无浮点;
//! - 池的遍历结果排序后再打印。
mod analysis;
mod app;
mod client;
mod domain;
mod flyweight;
mod support;
use std::collections::HashSet;
use analysis::{
audit_coverage, build_payroll_breakdown, build_payroll_breakdown_by_period,
build_payroll_breakdown_by_shift, build_payroll_breakdown_by_store_grade, MemoryComparison,
PayrollBreakdown, PayrollCategoryEntry, SharingProfile, UnsharedSlotOverhead,
MINIMUM_AVERAGE_SHARE_BASIS_POINTS, OVERTIME_WARNING_THRESHOLD_MINUTES,
};
use app::{
bullet_line, byte_count_text, key_value_line, max_display_width, note_line,
render_coverage_audit, render_memory_comparison, render_payroll_breakdown, render_plan_overview,
render_pool_snapshots, render_sharing_profile, render_shift_template_inventory,
render_store_grade_inventory, section_header, with_thousands_separator, TableColumn, TextTable,
};
use client::{
compute_effective_hourly_rate, compute_labor_cost, ScheduleAssembler, ScheduleRequest,
ShiftPlan, StaffMember, StoreProfile, OVERTIME_MULTIPLIER_BASIS_POINTS,
};
use domain::{
CurrencyAmount, PayPeriodKind, Ratio, ShiftCode, SkillGrade, StaffNumber, StoreCode, StoreGrade,
WorkDuration, BASIS_POINTS_DENOMINATOR, CURRENCY_CHINESE_YUAN, CURRENCY_HONG_KONG_DOLLAR,
PAY_PERIOD_HOLIDAY, PAY_PERIOD_PROMOTION, SHIFT_CODE_EARLY, SHIFT_CODE_LATE, SHIFT_CODE_MIDDLE,
SHIFT_CODE_NIGHT_AUDIT, SHIFT_CODE_WEEKEND_BOOST, SKILL_GRADE_JUNIOR, SKILL_GRADE_PROBATION,
SKILL_GRADE_SENIOR, SKILL_GRADE_TECHNICIAN, STORE_GRADE_COMMUNITY, STORE_GRADE_FLAGSHIP,
STORE_GRADE_STANDARD,
};
use flyweight::{
reference_control_block_bytes, reference_pointer_bytes, FlyweightFootprint, PoolCounters,
SharedHandle, SharedPool, ShiftScheduleSpec, ShiftTemplate, ShiftTemplateFactory,
ShiftTemplateKey, ShiftTemplateLookupError, StoreGradeFactory, StoreGradeProfile,
StoreGradeProfileSpec, UnsharedFootprint, BUILTIN_AUDIT_SHIFT_CODE,
DEFAULT_MINIMUM_SKILL_GRADE, DEFAULT_PAY_PERIOD,
};
use support::calendar_date::CalendarDate;
use support::clock_time::ClockTime;
use support::deterministic_code::{fnv1a_64, short_fingerprint};
use support::text_layout::{horizontal_rule, pad_center, truncate_to_width};
// ===========================================================================
// 场景常量
// ===========================================================================
/// 排班区间起始日:2025 年 10 月 1 日(国庆节,且恰逢周三 = 盘点日)。
///
/// ## 为什么刻意让起始日落在「节假日 ∩ 盘点日」
///
/// 这两件事的判定顺序在 ScheduleRequest::period_kind_of 里是业务规则:
/// 节假日优先于周末。而 2025 年 10 月 1 日既是法定节假日、又是一周中的
/// 盘点日,因此这一天的槽位同时检验两条独立规则。
/// 若有人把「周末」的判断提到「节假日」之前,2025 年 10 月 4 日
/// (周六,且在国庆假期内)的成本会从 2.0 倍掉到 1.1 倍——
/// 本幕的金额核对会发现这个落差。
const SCHEDULE_START_YEAR: u16 = 2025;
const SCHEDULE_START_MONTH: u8 = 10;
const SCHEDULE_START_DAY: u8 = 1;
/// 排班天数(整个 10 月)。
const SCHEDULE_DAY_COUNT: u32 = 31;
/// 盘点夜班落在星期几(0 = 周一 … 6 = 周日)。2 = 周三。
const AUDIT_WEEKDAY: u32 = 2;
/// 公司基准时薪:¥28.00/时(整数「分」)。
///
/// 取 2800 而不是整百,是为了让「乘以门店指数再乘以班次系数」的
/// 中间值出现非零小数位(分),从而暴露舍入口径问题。
/// 若取 ¥30.00 而各系数也都是整百,所有中间值都会是整数,
/// 舍入方向的正确性就永远测不出来。
const COMPANY_BASE_HOURLY_RATE_MINOR_UNITS: i64 = 2800;
/// 门店总数。
const STORE_COUNT: usize = 120;
/// 品牌编码前缀(用于生成门店编码与工号)。
const BRAND_CODE: &str = "LF";
/// 演示用城市表。
///
/// 24 个城市循环取用:本工程的报表要人工核对,
/// 城市名只是展示字段(不进享元键、不参与任何计算),
/// 因此无需 120 个互不相同的名字——那只会让输入构造代码变长。
const CITY_NAMES: [&str; 24] = [
"中国香港", "深圳", "广州", "东莞", "佛山", "珠海", "中山", "惠州", "江门", "肇庆", "汕头",
"湛江", "北京", "上海", "杭州", "南京", "苏州", "成都", "重庆", "武汉", "西安", "长沙",
"厦门", "青岛",
];
// ===========================================================================
// 输入构造(把「业务事实」集中到一处,保证可复现)
// ===========================================================================
/// 把一段文本提升成 &'static str。
///
/// ## 为什么需要它(以及为什么这不是「随便泄漏内存」)
///
/// 本工程的标签类字段(StoreCode.code、StaffNumber.text、
/// ShiftCode.code…)一律是 &'static str。这不是随意的类型选择,
/// 而是享元设计的一部分:
///
/// > 若门店编码是 String,那么 每个持有它的槽位 都要为
/// > 「一份指向堆字符串的指针」付出 24 字节;而 &'static str 只付 16 字节,
/// > 且字符串内容在只读段只存一份。
///
/// 单一实例上差别很小,但在 1.1 万个槽位的规模上,这是实打实的差异。
/// 而代价是:运行期生成的编码必须能被「提升」成 'static。
///
/// 真实系统里这些编码来自静态配置表或数据库缓存,天然长存;
/// 演示程序用 Box::leak 模拟这一事实。这些字符串在进程生命周期内
/// 确实需要一直存在,因此它是「有意长存」而非「泄漏」——
/// 差别在于:泄漏是丢失了释放的手段,而这里根本不需要释放。
fn leak_to_static(text: String) -> &'static str {
Box::leak(text.into_boxed_str())
}
/// 生成 120 家门店的门店档案。
///
/// 分布:前 30 家旗舰店、中间 50 家标准店、后 40 家社区店。
/// 这个分布不是随便取的——它决定了两件事:
/// 1. 班次组合的多样性:旗舰店有 4 个标准班次(含周末加强班)、
/// 标准店 3 个、社区店 2 个,因此三种等级都会出现在模板池里;
/// 2. 盘点夜班的分布:旗舰店与标准店要求每日盘点,社区店隔日盘点,
/// 因此盘点夜班模板只在部分门店出现。
fn build_store_profiles() -> Vec<StoreProfile> {
let mut stores: Vec<StoreProfile> = Vec::with_capacity(STORE_COUNT);
for store_index in 0..STORE_COUNT {
let (grade, pay_index_basis_points, roster_plan) = store_plan_of(store_index);
let store_code = StoreCode::new(
leak_to_static(format!("{}{:04}", BRAND_CODE, store_index + 1)),
CITY_NAMES[store_index % CITY_NAMES.len()],
);
let roster: Vec<StaffMember> = build_roster(store_index, store_code, roster_plan, grade);
stores.push(StoreProfile::new(
store_code,
grade,
Ratio::from_basis_points(pay_index_basis_points),
roster,
));
// roster_plan 只用于生成名单,此处显式忽略以避免「未使用变量」告警,
// 同时表明「编制人数只是输入端的构造参数,不进入任何领域类型」。
let _ = roster_plan;
}
stores
}
/// 一家门店的「编制计划」:各技能等级各几人。
type RosterPlan = (u32, u32, u32, u32);
/// 按序号决定门店的等级、时薪指数与编制。
///
/// 参数 store_index:门店序号(0 起)。
/// 返回:(等级, 时薪指数万分点, 编制计划)。
///
/// ## 一处刻意的简化及其代价
///
/// 同一等级的门店使用相同的时薪指数。真实系统里每家店的指数都不同,
/// 但那会让报表金额无法按等级手算核对——本工程宁可牺牲一点真实性,
/// 换取「人工可核对」这条更重要的性质。
/// 而且这个简化不影响享元设计:门店指数本来就属于外在状态
/// (它每次使用都不同),无论它是否按等级相同,都不会进享元键。
fn store_plan_of(store_index: usize) -> (StoreGrade, i64, RosterPlan) {
if store_index < 30 {
// 旗舰店:4 名高级(覆盖 4 个白班)、2 名技师(覆盖盘点夜班)、
// 4 名初级、2 名试用期。
(STORE_GRADE_FLAGSHIP, 11_500, (2, 4, 4, 2))
} else if store_index < 80 {
// 标准店:1 名技师(盘点夜班至少要有一人)、3 名高级、
// 3 名初级、1 名试用期。
(STORE_GRADE_STANDARD, 10_000, (1, 3, 3, 1))
} else {
// 社区店:无技师(该等级不排盘点夜班)、2 名高级、
// 2 名初级、1 名试用期。
(STORE_GRADE_COMMUNITY, 9_000, (0, 2, 2, 1))
}
}
/// 构造一家门店的员工名单。
///
/// 参数 store_index / store_code:门店序号与编码;
/// plan:编制计划(技师 / 高级 / 初级 / 试用期人数);
/// grade:门店等级(只用于让工号前缀与门店等级相关,便于阅读)。
/// 返回:员工名单。
///
/// ## 工号为什么用「门店序号 + 名单序号」而不是全局序号
///
/// 让工号可定位:看到 E0730 就知道大致落在第 7 家店的第 3 人附近。
/// 报表核对时若发现某个员工被排了两次班(NO_DOUBLE_BOOKING),
/// 能立刻知道是哪家店的人。
fn build_roster(
store_index: usize,
store_code: StoreCode,
plan: RosterPlan,
grade: StoreGrade,
) -> Vec<StaffMember> {
// 门店等级只影响工号前缀字符,让不同类型门店的员工在报表里一眼可分。
let prefix_character: char = if grade == STORE_GRADE_FLAGSHIP {
'F'
} else if grade == STORE_GRADE_STANDARD {
'S'
} else {
'C'
};
// 各等级的技能等级、人数、每班工龄津贴(整数「分」)。
let grade_layers: [(SkillGrade, u32, i64); 4] = [
(SKILL_GRADE_TECHNICIAN, plan.0, 800),
(SKILL_GRADE_SENIOR, plan.1, 500),
(SKILL_GRADE_JUNIOR, plan.2, 200),
(SKILL_GRADE_PROBATION, plan.3, 0),
];
let mut roster: Vec<StaffMember> = Vec::new();
let mut member_serial: usize = 0;
for (skill_grade, member_count, allowance_minor_units) in grade_layers {
for _ in 0..member_count {
member_serial += 1;
// 工号格式:1 位大写字母 + 6 位数字(共 7 字符),
// 由 `StaffNumber::from_text` 校验。构造失败说明格式写错了——
// 此时**不静默跳过**,而是立刻 panic 并打印原因,
// 因为一个「少了几名员工」的名单会让后面的漏排统计变得无法解释。
let staff_text: String = format!(
"{}{:03}{:03}",
prefix_character,
store_index + 1,
member_serial
);
let staff_number: StaffNumber = StaffNumber::from_text(leak_to_static(staff_text))
.unwrap_or_else(|| panic!("工号格式非法:门店 {} 第 {} 位", store_index, member_serial));
roster.push(StaffMember::new(
staff_number,
skill_grade,
store_code,
CurrencyAmount::from_minor_units(
allowance_minor_units,
CURRENCY_CHINESE_YUAN,
),
));
}
}
roster
}
/// 构造排班请求。
///
/// 参数 stores:门店表。
/// 返回:排班请求。
///
/// ## 节假日与大促日的取法
///
/// - 节假日:2025 年 10 月 1 日 ~ 10 月 8 日(国庆 + 中秋连休,共 8 天)。
/// 其中 10 月 1 日是周三(盘点日)、10 月 4 日与 5 日是周六周日。
/// - 大促日:2025 年 10 月 18 日、19 日(婚博会大促),恰为周六周日。
///
/// 这两组日期刻意都与其他时段重叠:只有这样,
/// 「判定顺序不可调换」才是一个可被金额验证的结论,而不只是一句注释。
fn build_schedule_request(stores: Vec<StoreProfile>) -> ScheduleRequest {
let holiday_dates: Vec<CalendarDate> = (1..=8u8)
.map(|day| CalendarDate::from_ymd(SCHEDULE_START_YEAR, SCHEDULE_START_MONTH, day))
.collect();
let promotion_dates: Vec<CalendarDate> = vec![
CalendarDate::from_ymd(2025, 10, 18),
CalendarDate::from_ymd(2025, 10, 19),
];
ScheduleRequest::new(
stores,
CalendarDate::from_ymd(SCHEDULE_START_YEAR, SCHEDULE_START_MONTH, SCHEDULE_START_DAY),
SCHEDULE_DAY_COUNT,
holiday_dates,
promotion_dates,
AUDIT_WEEKDAY,
CurrencyAmount::from_minor_units(
COMPANY_BASE_HOURLY_RATE_MINOR_UNITS,
CURRENCY_CHINESE_YUAN,
),
)
}
// ===========================================================================
// 主流程
// ===========================================================================
/// 程序入口:依次跑完八幕。
///
/// ## 为什么工厂在 main 里建、而不是在每一幕里各建一次
///
/// 因为享元池的价值依赖跨调用复用。若每幕各建一个工厂,
/// 池的命中率就永远只有「幕内一次」的水平,享元等于没起作用。
/// 工厂必须活得比单次装配长——这也是本工程把工厂定义为
/// 「长生命周期对象」的原因(见 ScheduleAssembler 的字段说明)。
fn main() {
// 两个工厂:各持一个泛型共享池 + 一张登记表。
// 它们都是 &self 可变(内部用 RefCell),因此在本文件里可以
// 与 ScheduleAssembler 的不可变借用共存——
// 第八幕要一边持有装配器、一边登记新班次,正是靠这条设计。
let template_factory: ShiftTemplateFactory = ShiftTemplateFactory::with_builtin_shifts();
let grade_factory: StoreGradeFactory = StoreGradeFactory::with_builtin_grades();
let request: ScheduleRequest = build_schedule_request(build_store_profiles());
let assembler: ScheduleAssembler<'_> =
ScheduleAssembler::new(&template_factory, &grade_factory);
let plan: ShiftPlan = assembler.assemble(&request);
act_one_scale_and_assembly(&plan, &template_factory, &grade_factory);
act_two_pain_without_sharing(&plan);
act_three_key_granularity(&plan);
act_four_sharing_profile(&plan);
act_five_cost_distribution(&plan);
act_six_compliance_audit(&plan);
act_seven_broken_input(&request);
act_eight_out_of_project_extension(&template_factory, &grade_factory);
// 附幕放在最后:它要把「第八幕登记之后」的完整状态(含工程外新增的两个类型)
// 一并清点,同时验证「登记过的接口」也能被只读视图看到。
act_nine_read_only_inventory(&plan, &template_factory, &grade_factory, &request);
// 收尾自检:把「内置标签全集」显式打印一次。
// 它同时承担两个作用:消除未使用告警(真修,不是压制),
// 以及在运行期留下「哪些标签属于内置」的清单(见 known_label_snapshot)。
print_known_labels();
}
// ===========================================================================
// 输出工具
// ===========================================================================
/// 按顺序打印若干行。
fn print_lines(lines: &[String]) {
for line in lines {
println!("{}", line);
}
}
/// 打印一幕的标题(带分隔线)。
fn print_act_title(ordinal_text: &str, title: &str) {
println!();
println!("{}", horizontal_rule(88));
println!(" {} {}", ordinal_text, title);
println!("{}", horizontal_rule(88));
}
/// 打印一张分节标题。
fn print_section(title: &str) {
print_lines(§ion_header(title));
}
/// 打印一段带上下分隔线的说明文字。
///
/// 参数 title:说明的标题;narration:说明正文(每行一句)。
///
/// 宽度由内容的最大显示宽度算出来(max_display_width),
/// 而不是拍一个魔数:说明文字的长短会随内容变化,
/// 拍死的宽度要么会截断,要么会留一片空白。
/// 这正是「按显示列数算宽度」在本工程的又一次应用。
fn print_banner(title: &str, narration: &[&str]) {
let body: Vec<String> = narration
.iter()
.map(|line| note_line(line, 2))
.collect();
let mut measured: Vec<String> = vec![format!(" {}", title)];
measured.extend(body.iter().cloned());
// 至少 72 列,避免过短的说明画出一条像「分隔线」的短线而失去视觉区分度。
let width: usize = max_display_width(&measured).max(72);
println!();
println!("{}", horizontal_rule(width + 2));
println!(" {}", title);
println!("{}", horizontal_rule(width + 2));
for line in &body {
println!("{}", line);
}
}
/// 计算「加班时薪」(门店生效时薪 × 加班倍数)并渲染成文本。
///
/// 参数 company_base_hourly_rate:公司基准时薪;
/// store_pay_index:门店时薪指数。
/// 返回:加班时薪的展示文本(含币种编码)。
///
/// ## 这里为什么要重算一次,而不是调用既有函数
///
/// client 层公开的是 compute_overtime_cost(算总额),
/// 没有公开「加班单价」——因为业务上只需要总额。
/// 而本幕的手算核对表需要展示单价,于是本函数用领域层的
/// scale_by_basis_points 组合出这一行。
///
/// 注意两个刻意的口径:
/// 1. 第一步用 compute_effective_hourly_rate(..., Ratio::one())
/// —— 传 Ratio::one() 表示「不乘班次系数」,因为加班费按法定倍数计,
/// 与当时上什么班无关(见 compute_overtime_cost 的文档);
/// 2. 第二步只乘加班倍数,不重复乘综合系数。
///
/// 这两点若写错,本表会与报表里的加班金额不一致——而那种不一致
/// 正是本表存在的意义(它是核对用的)。
fn compute_effective_hourly_rate_at_overtime(
company_base_hourly_rate: &CurrencyAmount,
store_pay_index: Ratio,
) -> String {
let store_rate: CurrencyAmount =
compute_effective_hourly_rate(company_base_hourly_rate, store_pay_index, Ratio::one());
let overtime_rate: CurrencyAmount =
store_rate.scale_by_basis_points(OVERTIME_MULTIPLIER_BASIS_POINTS);
overtime_rate.formatted_with_currency_code()
}
/// 构造一张「两列」的键值表(左列标签、右列内容)。
///
/// 参数 left_header / right_header:两列的表头;
/// left_width / right_width:两列的显示宽度。
/// 返回:表格对象。
///
/// 抽成函数是为了让「两列对照表」在多幕里保持完全一致的列宽口径——
/// 若各幕自己 TextTable::new(...),迟早出现列宽不一致的表格,
/// 而读者会以为那是内容差异。
fn two_column_table(
left_header: &'static str,
left_width: usize,
right_header: &'static str,
right_width: usize,
) -> TextTable {
TextTable::new(vec![
TableColumn::left(left_header, left_width),
TableColumn::left(right_header, right_width),
])
}
// ===========================================================================
// 第一幕:规模与装配
// ===========================================================================
/// 第一幕:先把「规模」摆出来,让后面的所有数字都有参照系。
///
/// ## 为什么要先给规模
///
/// 「省了 96%」这个数字,若不知道分母是 800 字节还是 8 MB,
/// 读者无法判断它的意义。规模是所有比率的隐含前提,
/// 因此必须最先给出,且要具体到「1.1 万个槽位 vs 20 个模板」这种量级对比。
fn act_one_scale_and_assembly(
plan: &ShiftPlan,
template_factory: &ShiftTemplateFactory,
grade_factory: &StoreGradeFactory,
) {
print_act_title("第一幕", "规模与装配 —— 先看清分母有多大");
print_banner(
"本幕要回答",
&[
"120 家门店 × 31 天,一共会排出多少个排班槽位?",
"这 1 万多个槽位背后,真正不同的班次定义有几个?",
"享元池里到底存了哪些东西?(这一屏是享元的直接证据)",
],
);
print_section("排班方案总览");
print_lines(&render_plan_overview(plan));
print_section("享元池之一:班次模板");
print_lines(&render_shift_template_inventory(template_factory));
print_section("享元池之二:门店等级配置");
print_lines(&render_store_grade_inventory(grade_factory));
print_banner(
"本幕结论",
&[
"无论排多少天、多少店,「池内容清单」的行数都保持不变——",
"这正是享元在起作用:槽位在增加,而享元实例数不变。",
"每一行模板都带一个『班次 × 技能门槛 × 计薪时段』的组合,",
"没有任何两行是重复的,说明键里没有混进不该有的维度。",
],
);
}
// ===========================================================================
// 第二幕:剧痛 —— 若每个槽位各持一份
// ===========================================================================
/// 第二幕:把「不共享」的世界真的实现出来,再测它要多少字节。
///
/// ## 为什么必须「真的实现」而不是「假设一个数字」
///
/// 若在本幕里凭空写 let unshared_bytes_per_slot = 200;,
/// 那这个数字就是编的——而整幕的结论(省了多少)都建立在它上面。
///
/// 本工程的处理:在文件末尾的工程外扩展区里真实定义两个
/// 不共享版的类型([UnsharedShiftDefinition] /
/// [UnsharedStoreGradeConfig]),让它们也能自报字节数,
/// 然后把实测值传进分析层。
/// 于是对照的两侧都是可编译、可调用的真实类型,
/// 差异只来自「有没有共享」这一件事。
fn act_two_pain_without_sharing(plan: &ShiftPlan) {
print_act_title("第二幕", "剧痛 —— 如果每个槽位各持一份副本");
print_banner(
"本幕要回答",
&[
"不共享时,每个槽位要额外背多少字节?全表合计多少?",
"共享之后实际占用多少?差额与节省率是多少?",
"为什么会有两个节省率?它们分别说明什么?",
],
);
// ---------- 实测「不共享版」的字节数 ----------
//
// 取方案里的第一个槽位,把它的享元内容深拷贝进不共享版的类型。
// 拷贝出来的对象与享元内容完全一致,唯一差别是:它是「这一个槽位自己的」。
let sample_slot = match plan.slots().first() {
Some(slot) => slot,
None => {
println!(" (方案为空,本幕跳过)");
return;
}
};
let unshared_shift: UnsharedShiftDefinition =
UnsharedShiftDefinition::from_template(sample_slot.template());
let unshared_grade: UnsharedStoreGradeConfig =
UnsharedStoreGradeConfig::from_profile(sample_slot.grade_profile());
// 用 UnsharedFootprint 表达对照模型,而不是在本幕里手写乘法。
// 该类型存在的意义正是「把『每个持有点都自带一份』这个换算只写一次」。
let unshared_shift_model: UnsharedFootprint =
UnsharedFootprint::from_flyweight(plan.slot_count() as u64, &unshared_shift);
let unshared_grade_model: UnsharedFootprint =
UnsharedFootprint::from_flyweight(plan.slot_count() as u64, &unshared_grade);
let realistic_overhead: UnsharedSlotOverhead = UnsharedSlotOverhead::new(
unshared_shift_model.bytes_per_holder,
unshared_grade_model.bytes_per_holder,
);
print_section("不共享版的单份字节(实测)");
let measurement_table = TextTable::new(vec![
TableColumn::left("类型", 30),
TableColumn::right("单份字节", 12),
TableColumn::right("槽位数", 10),
TableColumn::right("合计字节", 16),
]);
println!(" {}", measurement_table.header_line());
println!(" {}", measurement_table.separator_line());
println!(
" {}",
measurement_table.row_line(&[
"不共享版班次定义".to_string(),
unshared_shift_model.bytes_per_holder.to_string(),
plan.slot_count().to_string(),
with_thousands_separator(unshared_shift_model.total_bytes() as i64),
])
);
println!(
" {}",
measurement_table.row_line(&[
"不共享版门店等级配置".to_string(),
unshared_grade_model.bytes_per_holder.to_string(),
plan.slot_count().to_string(),
with_thousands_separator(unshared_grade_model.total_bytes() as i64),
])
);
println!(" {}", measurement_table.separator_line());
println!(
" · 每个槽位额外背负的副本合计:{} 字节({} + {})",
realistic_overhead.total_per_slot(),
unshared_shift_model.bytes_per_holder,
unshared_grade_model.bytes_per_holder
);
// ---------- 证明「不共享版是完整深拷贝,不是空壳」 ----------
//
// 这一屏很重要:若对照版本的字节数很好看,但它是靠「只复制了少数字段」
// 得来的,那整个对照就是假的。打印出完整内容,让读者自己核对
// 「这份副本确实和享元内容一一对应」。
print_section("不共享版的内容(证明它是完整深拷贝)");
println!(" · 班次定义副本:");
println!(" {}", unshared_shift.formatted());
println!(" · 门店等级配置副本:");
println!(" {}", unshared_grade.formatted());
// ---------- 单价换算(供人工核对金额用) ----------
//
// 本幕与成本有关的数字,都可以用这张表手算复现:
// 基准时薪 → 门店生效时薪 → 含班次系数的生效时薪 → 该班次的工时成本。
// 四个数依次相除,应能还原出方案里任何一个槽位的正常工时成本。
print_section("单价换算(手算核对用)");
let base_rate: CurrencyAmount = plan.company_base_hourly_rate();
let store_effective_rate: CurrencyAmount = compute_effective_hourly_rate(
&base_rate,
sample_slot.store_pay_index(),
Ratio::one(),
);
let slot_effective_rate: CurrencyAmount = compute_effective_hourly_rate(
&base_rate,
sample_slot.store_pay_index(),
sample_slot.template().combined_pay_multiplier(),
);
let rate_table = TextTable::new(vec![
TableColumn::left("环节", 40),
TableColumn::right("金额/比率", 16),
TableColumn::left("口径说明", 26),
]);
println!(" {}", rate_table.header_line());
println!(" {}", rate_table.separator_line());
println!(
" {}",
rate_table.row_line(&[
"① 公司基准时薪".to_string(),
base_rate.formatted_with_currency_code(),
"所有门店的成本起点".to_string(),
])
);
println!(
" {}",
rate_table.row_line(&[
format!(
"② 门店生效时薪(指数 {})",
sample_slot.store_pay_index().as_multiplier_text()
),
store_effective_rate.formatted_with_currency_code(),
"① × 门店指数,舍入一次".to_string(),
])
);
println!(
" {}",
rate_table.row_line(&[
format!(
"③ 含班次系数的生效时薪(系数 {})",
sample_slot.template().combined_pay_multiplier().as_multiplier_text()
),
slot_effective_rate.formatted_with_currency_code(),
"② × 综合系数,舍入一次".to_string(),
])
);
println!(
" {}",
rate_table.row_line(&[
format!(
"④ 加班时薪(倍数 {})",
Ratio::from_basis_points(OVERTIME_MULTIPLIER_BASIS_POINTS).as_multiplier_text()
),
compute_effective_hourly_rate_at_overtime(&base_rate, sample_slot.store_pay_index()),
"② × 1.5(不乘班次系数)".to_string(),
])
);
println!(" {}", rate_table.separator_line());
// 逐项复现该槽位的成本:③ ÷ 60 × 计薪分钟数 + 工龄津贴。
// 把津贴单独列出来是必要的——否则读者用「③ ÷ 60 × 分钟」算出的数
// 会比报表少一个津贴,从而误以为报表算错了。
let allowance: CurrencyAmount = CurrencyAmount::from_minor_units(
sample_slot.seniority_allowance_minor_units(),
base_rate.currency(),
);
let worked_cost: CurrencyAmount = slot_effective_rate
.scale_by_minute_fraction(sample_slot.template().paid_duration().total_minutes());
let with_allowance: CurrencyAmount = worked_cost.add(&allowance).unwrap_or(worked_cost);
let reported_cost: CurrencyAmount = sample_slot.labor_cost(&base_rate);
println!(
" · 复现该槽位成本:③ {} ÷ 60 × {} 分钟 = {},加工龄津贴 {} = {}",
slot_effective_rate.formatted(),
sample_slot.template().paid_duration().total_minutes(),
worked_cost.formatted(),
allowance.formatted(),
with_allowance.formatted(),
);
println!(
" 与报表值 {} 相比:{}",
reported_cost.formatted(),
if with_allowance.minor_units() == reported_cost.minor_units() {
"一致 ✓(两条路径同源,故必然相等)"
} else {
"不一致 ✗(说明报表口径与本表不同)"
}
);
// ---------- 用实测值构造对照 ----------
let realistic_comparison: MemoryComparison =
MemoryComparison::from_plan(plan, realistic_overhead);
print_section("内存对照(现实模型:不共享版自己持有字符串)");
print_lines(&render_memory_comparison(&realistic_comparison));
// ---------- 保守下界:不共享版也复用静态字符串 ----------
//
// 有人会说:「不共享的实现也可以把字符串做成静态常量,
// 那样每个槽位只多背一个结构体,没有堆分配。」
// 这个反驳是成立的,因此本幕把它也算出来,作为节省率的下界。
// 这样无论读者信哪个模型,都有一个对应的数字可查——
// 而两个模型给出的结论方向一致(都是大幅节省),
// 这本身就是结论稳健的证据。
let conservative_overhead: UnsharedSlotOverhead = UnsharedSlotOverhead::new(
std::mem::size_of::<ShiftTemplate>(),
std::mem::size_of::<StoreGradeProfile>(),
);
let conservative_comparison: MemoryComparison =
MemoryComparison::from_plan(plan, conservative_overhead);
print_section("内存对照(保守下界:不共享版也复用静态字符串)");
let bound_table = two_column_table("口径", 50, "总内存节省率", 16);
println!(" {}", bound_table.header_line());
println!(" {}", bound_table.separator_line());
println!(
" {}",
bound_table.row_line(&[
"下界:不共享版只复制结构体(字符串仍静态复用)".to_string(),
conservative_comparison.saved_ratio_as_ratio().as_percent_text(),
])
);
println!(
" {}",
bound_table.row_line(&[
"现实:不共享版自行持有字符串(每份一次堆分配)".to_string(),
realistic_comparison.saved_ratio_as_ratio().as_percent_text(),
])
);
println!(" {}", bound_table.separator_line());
println!(
" · 两个口径都指向「大幅节省」,说明结论不依赖于对『不共享版怎么实现』的假设。"
);
// ---------- 逐项核对分享侧合计 ----------
//
// 这一屏用 reference_pointer_bytes / reference_control_block_bytes
// 现场重算「句柄 + 控制块」字节,与池快照里那份数值对照。
// 两条独立路径得到同一个数,才说明快照的算法是对的——
// 这是本工程对「可核对」的具体执行:不只看结论,还看它由什么组成。
print_section("逐项核对:共享侧的字节构成");
let template_snapshot = plan.template_pool_snapshot();
let grade_snapshot = plan.grade_pool_snapshot();
let recalculated_handle_bytes: usize = {
let held_total: u64 =
template_snapshot.held_reference_total + grade_snapshot.held_reference_total;
let entry_total: usize =
template_snapshot.distinct_entry_count + grade_snapshot.distinct_entry_count;
(held_total as usize) * reference_pointer_bytes() + entry_total * reference_control_block_bytes()
};
// 列宽按最宽可能内容定。「项」列要容下
// 句柄与控制块(句柄 22500×8B + 实例 21×16B)(45 显示列),
// 故取 46;两个数值列的最宽值是 1,170,000(9 列),12 已足够。
// 若为「看起来整齐」而把「项」列压到 40,单元格会被静默截断——
// 截断后各行仍对齐,所以肉眼几乎发现不了,必须靠插桩或按最宽值定宽来防。
let check_table = TextTable::new(vec![
TableColumn::left("项", 46),
TableColumn::right("现场重算", 12),
TableColumn::right("池快照值", 12),
TableColumn::left("是否一致", 10),
]);
println!(" {}", check_table.header_line());
println!(" {}", check_table.separator_line());
println!(
" {}",
check_table.row_line(&[
format!(
"句柄与控制块(句柄 {}×{}B + 实例 {}×{}B)",
template_snapshot.held_reference_total + grade_snapshot.held_reference_total,
reference_pointer_bytes(),
template_snapshot.distinct_entry_count + grade_snapshot.distinct_entry_count,
reference_control_block_bytes()
),
with_thousands_separator(recalculated_handle_bytes as i64),
with_thousands_separator(realistic_comparison.shared_handle_bytes as i64),
if recalculated_handle_bytes == realistic_comparison.shared_handle_bytes {
"一致"
} else {
"不一致"
}
.to_string(),
])
);
println!(
" {}",
check_table.row_line(&[
format!(
"槽位自身外在状态(槽位数 × 单槽 {}B)",
realistic_comparison.slot_intrinsic_bytes
),
with_thousands_separator(realistic_comparison.shared_slot_state_bytes as i64),
with_thousands_separator(realistic_comparison.unshared_slot_state_bytes as i64),
"两版本相同".to_string(),
])
);
println!(" {}", check_table.separator_line());
println!(
" · 单个槽位自身 {} 字节;它承载的全是外在状态(门店、日期、员工、工时),",
realistic_comparison.slot_intrinsic_bytes
);
println!(
" 因此两种版本都要付——这也解释了为什么『总节省率』会被外在状态稀释。"
);
print_banner(
"本幕结论",
&[
"共享省下的,正是「模板内容 × 槽位数」与「模板内容 × 实例数」之间的差。",
"注意区分两个节省率:总节省率的分母含外在状态,必然偏小;",
"可共享部分节省率只衡量机制本身的效果。两者都要看。",
"另外:任何比对之前,先核对一次「共享侧合计」的构成——",
"结论正确的报表,不能建立在算错的分项上。",
],
);
}
// ===========================================================================
// 第三幕:键的三段实验
// ===========================================================================
/// 第三幕:同一批数据、三种键粒度,看各自付出什么代价。
///
/// ## 为什么这一幕是本工程最重要的论证
///
/// 享元模式的成败几乎全部取决于键的设计:
///
/// | 键 | 后果 |
/// |---|---|
/// | 太粗(漏掉影响内容的维度) | 实例数少、内存省,但算出的业务结果错误 |
/// | 太细(混进每次使用都不同的维度) | 结果正确,但实例数暴涨,共享名存实亡 |
/// | 恰好 | 实例数等于「真正不同的定义数」 |
///
/// 三者都无法只靠「代码看起来对不对」判断——必须把数字摆出来。
/// 本幕把三种键都跑一遍,且不是靠解释而是靠实测说明差别:
/// 过粗键的代价用「少算了多少钱」度量,过细键的代价用「键数暴涨与容量拒绝」度量。
///
/// ## 两个对照工厂为什么是新建的
///
/// 因为取用会污染池——用过粗键取 5 个实例后,
/// 那些实例会留在池里,第四幕的「共享度画像」就再也测不到真实状态。
/// 凡是做对照实验,一律用独立对象:对照实验不能有副作用。
fn act_three_key_granularity(plan: &ShiftPlan) {
print_act_title("第三幕", "键的三段实验 —— 同一个方案,三种键给出三种世界");
print_banner(
"本幕要回答",
&[
"把「计薪时段」从键里去掉,会少算多少钱?",
"把「门店」加进键里,池会膨胀多少倍?",
"为什么「影响享元内容的维度进键、只影响使用结果的维度在外层」是唯一可行的判据?",
],
);
// ---------- 从方案里枚举三种粒度下的不同键 ----------
//
// 用 HashSet 去重:只要集合的大小,不依赖迭代顺序,
// 因此随机化的哈希种子不会影响输出的可复现性(这一点必须想清楚,
// 否则「可复现」会被一个不经意的迭代顺序破坏)。
let mut correct_keys: HashSet<(ShiftCode, SkillGrade, PayPeriodKind)> = HashSet::new();
let mut coarse_keys: HashSet<(ShiftCode, SkillGrade)> = HashSet::new();
let mut fine_keys: HashSet<(ShiftCode, SkillGrade, PayPeriodKind, &'static str)> =
HashSet::new();
for slot in plan.slots() {
let template = slot.template();
let shift_code: ShiftCode = template.shift_code();
let skill_grade: SkillGrade = template.minimum_skill_grade();
let period_kind: PayPeriodKind = template.pay_period_kind();
correct_keys.insert((shift_code, skill_grade, period_kind));
coarse_keys.insert((shift_code, skill_grade));
fine_keys.insert((
shift_code,
skill_grade,
period_kind,
slot.store_code().code(),
));
}
print_section("三种键的规模对照");
let granularity_table = TextTable::new(vec![
TableColumn::left("键的构成", 40),
TableColumn::right("不同键数", 10),
TableColumn::left("业务结果", 26),
]);
println!(" {}", granularity_table.header_line());
println!(" {}", granularity_table.separator_line());
println!(
" {}",
granularity_table.row_line(&[
"班次 × 技能 × 计薪时段(正确)".to_string(),
correct_keys.len().to_string(),
"正确".to_string(),
])
);
println!(
" {}",
granularity_table.row_line(&[
"班次 × 技能(丢掉计薪时段)".to_string(),
coarse_keys.len().to_string(),
"少算钱(时段系数被抹平)".to_string(),
])
);
println!(
" {}",
granularity_table.row_line(&[
format!("班次 × 技能 × 时段 × 门店(并入 {} 店)", plan.store_count()),
fine_keys.len().to_string(),
"结果正确,但共享名存实亡".to_string(),
])
);
println!(" {}", granularity_table.separator_line());
// 把「18 个」这个数解释开:按班次分组统计各自动用了几种计薪时段。
//
// 这一屏是必要的。若只打印一个总数,读者会下意识地用
// 「班次数 × 时段数」去核对,而本工程里那个乘法的结果(5 × 4 = 20)
// 与真实值(18)不等——原因是盘点夜班只在周三出现,
// 而区间内的周三恰好都不是周末、也不是大促日。
// 若不解释,读者会以为键漏了两种组合(其实是业务上从未发生)。
let mut periods_per_shift_code: std::collections::BTreeMap<
&'static str,
std::collections::BTreeSet<&'static str>,
> = std::collections::BTreeMap::new();
for (shift_code, _skill_grade, period_kind) in &correct_keys {
periods_per_shift_code
.entry(shift_code.display_name())
.or_default()
.insert(period_kind.display_name());
}
println!(
" · 正确键的 {} 个为什么不是「班次数 × 时段数」?按班次拆开看:",
correct_keys.len()
);
let explanation_table = TextTable::new(vec![
TableColumn::left("班次", 14),
TableColumn::right("动用时段数", 12),
TableColumn::left("动用的计薪时段", 30),
]);
println!(" {}", explanation_table.header_line());
println!(" {}", explanation_table.separator_line());
for (shift_name, periods) in &periods_per_shift_code {
println!(
" {}",
explanation_table.row_line(&[
(*shift_name).to_string(),
periods.len().to_string(),
periods.iter().copied().collect::<Vec<&str>>().join("+"),
])
);
}
println!(" {}", explanation_table.separator_line());
println!(
" · 盘点夜班只落到 {} 种计薪时段——因为盘点日固定在周三,",
periods_per_shift_code
.get(SHIFT_CODE_NIGHT_AUDIT.display_name())
.map(|periods| periods.len())
.unwrap_or(0)
);
println!(
" 而本区间内的周三恰好都不是周末、也不是大促日。这是业务事实,不是键漏了。"
);
// ---------- 实验一:过粗键少算了多少钱 ----------
//
// 用独立的工厂,避免污染主池。
let coarse_probe_factory: ShiftTemplateFactory = ShiftTemplateFactory::with_builtin_shifts();
let base_rate: CurrencyAmount = plan.company_base_hourly_rate();
let mut correct_total_minor_units: i64 = 0;
let mut coarse_total_minor_units: i64 = 0;
let mut probe_failure_count: u32 = 0;
for slot in plan.slots() {
let template = slot.template();
let coarse_key: ShiftTemplateKey = ShiftTemplateKey::ignoring_pay_period(
template.shift_code(),
template.minimum_skill_grade(),
);
let coarse_template: SharedHandle<ShiftTemplate> =
match coarse_probe_factory.obtain(coarse_key) {
Ok(shared_template) => shared_template,
Err(_) => {
probe_failure_count += 1;
continue;
}
};
// 计薪分钟数两个口径相同(同一个班次),因此差异只来自系数。
let paid_minutes: u32 = template.paid_duration().total_minutes();
let correct_cost: CurrencyAmount = compute_labor_cost(
&base_rate,
slot.store_pay_index(),
template.combined_pay_multiplier(),
paid_minutes,
slot.seniority_allowance_minor_units(),
);
let coarse_cost: CurrencyAmount = compute_labor_cost(
&base_rate,
slot.store_pay_index(),
coarse_template.combined_pay_multiplier(),
paid_minutes,
slot.seniority_allowance_minor_units(),
);
correct_total_minor_units += correct_cost.minor_units();
coarse_total_minor_units += coarse_cost.minor_units();
}
print_section("实验一:把计薪时段从键里去掉,代价是多少");
let coarse_table = two_column_table("口径", 46, "正常工时成本", 18);
println!(" {}", coarse_table.header_line());
println!(" {}", coarse_table.separator_line());
println!(
" {}",
coarse_table.row_line(&[
"正确键(含计薪时段系数)".to_string(),
CurrencyAmount::from_minor_units(correct_total_minor_units, base_rate.currency())
.formatted(),
])
);
println!(
" {}",
coarse_table.row_line(&[
"过粗键(时段系数被抹成平日 1.00)".to_string(),
CurrencyAmount::from_minor_units(coarse_total_minor_units, base_rate.currency())
.formatted(),
])
);
println!(" {}", coarse_table.separator_line());
println!(
" {}",
coarse_table.row_line(&[
"少算的金额(正确 − 过粗)".to_string(),
CurrencyAmount::from_minor_units(
// 用「正确 − 过粗」而不是「过粗 − 正确」:
// 前者为正数,标签「少算」与数值符号一致。
// 若写成后者,报表会出现「少算 −¥881,584.60」这种
// 需要读者在脑中再做一次取反的表述——那是排版没尽责。
correct_total_minor_units - coarse_total_minor_units,
base_rate.currency()
)
.formatted(),
])
);
println!(
" · 交叉核对:本幕重算的正确键总额 {} = 方案总额 {} → {}",
CurrencyAmount::from_minor_units(correct_total_minor_units, base_rate.currency())
.formatted(),
plan.total_labor_cost().formatted(),
if correct_total_minor_units == plan.total_labor_cost().minor_units() {
"一致"
} else {
"不一致(说明重算路径有误)"
}
);
println!(
" · 期间取过粗模板失败 {} 次(未登记班次才会失败,理论上为 0)",
probe_failure_count
);
// ---------- 实验二:过细键让池膨胀并触发容量拒绝 ----------
let fine_probe_entry_limit: usize = 64;
let fine_probe_factory: ShiftTemplateFactory =
ShiftTemplateFactory::with_builtin_shifts_and_entry_limit(fine_probe_entry_limit);
// 两个计数器:一个来自 Err 分支的逐次统计,一个来自池快照。
// 两条独立路径得到同一个数,才能说明快照的计数是可信的。
let mut rejected_by_error_branch: u64 = 0;
let mut unregistered_by_error_branch: u64 = 0;
let mut entry_limit_reported_by_error: usize = 0;
// 只记录第一次被拒的可读说明,避免 2000 多条重复说明把输出淹没。
let mut rejection_description_probe: Option<String> = None;
for (shift_code, skill_grade, period_kind, store_code_text) in &fine_keys {
let fine_key: ShiftTemplateKey =
ShiftTemplateKey::new(*shift_code, *skill_grade, *period_kind)
.scoped_to_store(store_code_text);
match fine_probe_factory.obtain(fine_key) {
Ok(_shared_template) => {}
// 工厂对外暴露的错误类型是 ShiftTemplateLookupError,
// 它把「未登记」与「池满」两种失败分开表达。
// 本实验预期只会出现后者——若出现前者,说明班次登记表有问题,
// 因此单独计数并在报表里显示,避免把两种情况混成一个「失败数」。
Err(lookup_error) => {
if rejection_description_probe.is_none() {
rejection_description_probe = Some(lookup_error.description());
}
match lookup_error {
ShiftTemplateLookupError::UnregisteredShift { .. } => {
unregistered_by_error_branch += 1;
}
ShiftTemplateLookupError::PoolCapacityExceeded { entry_limit } => {
rejected_by_error_branch += 1;
entry_limit_reported_by_error = entry_limit;
}
}
}
}
}
let fine_probe_snapshot = fine_probe_factory.snapshot();
// 「若这些键全部建成实例,池内容要多少字节」——
// 用 UnsharedFootprint 表达,而不是在幕里手写乘法。
let fine_key_footprint: UnsharedFootprint = UnsharedFootprint::from_flyweight(
fine_keys.len() as u64,
// 任取一个槽位的模板作为「单份内容大小」的样本来读。
plan.slots()
.first()
.map(|slot| &**slot.template())
.expect("方案非空时必然可取到样本"),
);
// 过细键相对正确键的放大倍数(用万分比统一口径,避免手写 *100 % 100)。
let correct_distinct_entry_count: usize = plan.template_pool_snapshot().distinct_entry_count;
let fine_key_magnification_basis_points: i64 = if correct_distinct_entry_count == 0 {
0
} else {
(fine_keys.len() as i64 * BASIS_POINTS_DENOMINATOR) / correct_distinct_entry_count as i64
};
print_section("实验二:把门店并进键里,代价是多少");
let fine_table = two_column_table("度量项", 46, "数值", 18);
println!(" {}", fine_table.header_line());
println!(" {}", fine_table.separator_line());
println!(
" {}",
fine_table.row_line(&[
"正确键的实例数".to_string(),
correct_distinct_entry_count.to_string(),
])
);
println!(
" {}",
fine_table.row_line(&[
"过细键需要的实例数".to_string(),
fine_keys.len().to_string(),
])
);
println!(
" {}",
fine_table.row_line(&[
"放大倍数(过细 ÷ 正确)".to_string(),
Ratio::from_basis_points(fine_key_magnification_basis_points).as_multiplier_text(),
])
);
println!(
" {}",
fine_table.row_line(&[
"过细键若全部建成时的池内容字节".to_string(),
with_thousands_separator(fine_key_footprint.total_bytes() as i64),
])
);
println!(
" {}",
fine_table.row_line(&[
"正确键的实际池内容字节".to_string(),
with_thousands_separator(
plan.template_pool_snapshot().intrinsic_bytes_total as i64
),
])
);
println!(" {}", fine_table.separator_line());
println!(
" · 为演示容量失控,本实验给池设了上限 {} 个;实际取用 {} 次,被拒绝 {} 次。",
fine_probe_entry_limit,
fine_probe_snapshot.counters.request_count,
fine_probe_snapshot.counters.rejected_count
);
println!(
" · 池当前条目数 {},上限 {}(由 entry_limit() 读取);拒绝计数两条路径是否一致:{}",
fine_probe_factory.pool().entry_count(),
fine_probe_factory
.pool()
.entry_limit()
.map(|limit| limit.to_string())
.unwrap_or_else(|| "未设上限".to_string()),
if rejected_by_error_branch == fine_probe_snapshot.counters.rejected_count
&& entry_limit_reported_by_error == fine_probe_entry_limit
{
"一致 ✓"
} else {
"不一致 ✗"
}
);
println!(
" · 失败分类:池满 {} 次,未登记班次 {} 次(预计为 0;若不为 0 说明登记表有问题)。",
rejected_by_error_branch, unregistered_by_error_branch
);
// 打印 Err 分支里拿到的可读说明——它是运维定位问题的第一手信息,
// 若不打印出来,这个字段就只是「写了但没人用」的装饰。
if let Some(first_rejection_description) = rejection_description_probe.as_deref() {
println!(" · 首次被拒的可读说明:{}", first_rejection_description);
}
println!(
" · 被拒绝意味着这些槽位根本取不到模板——这不是「省了内存」,而是「排不出班」。"
);
// ---------- 键的自诊断文本(三种粒度对照) ----------
//
// 三种键各自的紧凑文本与诊断文本必须一眼可分:
// 若两种不同粒度的键打印出同样的文本,那么在日志里
// 就再也无法判断「这次取用用的是哪种键」。
// 因此键的展示文本不只是好看,它是可观测性的一部分。
print_section("三种键的自诊断文本(可观测性)");
let diagnostic_table = TextTable::new(vec![
TableColumn::left("键的构造方式", 34),
TableColumn::left("紧凑文本", 26),
TableColumn::left("诊断文本", 40),
]);
println!(" {}", diagnostic_table.header_line());
println!(" {}", diagnostic_table.separator_line());
let diagnostic_keys: [(&str, ShiftTemplateKey); 3] = [
(
"正确键(含计薪时段)",
ShiftTemplateKey::new(SHIFT_CODE_EARLY, SKILL_GRADE_SENIOR, PAY_PERIOD_HOLIDAY),
),
(
"过粗键(丢时段)",
ShiftTemplateKey::ignoring_pay_period(SHIFT_CODE_EARLY, SKILL_GRADE_SENIOR),
),
(
"过细键(并入门店)",
ShiftTemplateKey::new(SHIFT_CODE_EARLY, SKILL_GRADE_SENIOR, PAY_PERIOD_HOLIDAY)
.scoped_to_store("LF0001"),
),
];
for (label, key) in diagnostic_keys {
println!(
" {}",
diagnostic_table.row_line(&[
label.to_string(),
key.compact_text(),
key.diagnostic_text(),
])
);
debug_assert!(
key.store_scope().is_none() || !key.compact_text().is_empty(),
"带门店范围的键也应有紧凑文本"
);
}
println!(" {}", diagnostic_table.separator_line());
let coarse_demo_key: ShiftTemplateKey =
ShiftTemplateKey::ignoring_pay_period(SHIFT_CODE_EARLY, SKILL_GRADE_SENIOR);
println!(
" · 过粗键的 period_scope() = {:?};它的 effective_period_kind() 会回退到缺省值「{}」(常量 DEFAULT_PAY_PERIOD),",
coarse_demo_key.period_scope().map(|period| period.display_name()),
DEFAULT_PAY_PERIOD.display_name()
);
println!(
" 这就是「过粗键少算钱」的机理:节假日槽位拿到了一个平日模板。"
);
println!(
" · 缺省技能等级同样是常量 DEFAULT_MINIMUM_SKILL_GRADE = {},用于「不知道该配哪个等级」时的兜底。",
DEFAULT_MINIMUM_SKILL_GRADE.display_name()
);
print_banner(
"本幕结论",
&[
"判据:影响享元内容的维度必须进键;只影响使用结果的维度必须留在外层。",
"计薪时段影响模板的系数(内容)→ 必须进键;门店影响成本但可在外层乘(结果)→ 不进键。",
"过粗键的代价是钱算错(静默且难以发现),过细键的代价是池膨胀(明显但会撑爆内存)。",
"两种错误的共同点:都不会被编译器发现。因此必须把它们做成可测量的实验。",
],
);
}
// ===========================================================================
// 第四幕:共享度画像
// ===========================================================================
/// 第四幕:看共享「怎么发生」,以及两族享元是否真的共用一个机制。
fn act_four_sharing_profile(plan: &ShiftPlan) {
print_act_title("第四幕", "共享度画像 —— 共享是怎么发生的,两族享元是不是同一个机制");
print_banner(
"本幕要回答",
&[
"命中率、平均共享倍数、最大共享倍数,分别说明什么?",
"两族享元的池,是不是同一个泛型类型的两次实例化?",
],
);
// ---------- 两族享元的结构对照 ----------
//
// 这一屏打印的是编译期类型(std::any::type_name),
// 不是在运行结果上做断言。也就是说:这里展示的不是「我们声称」,
// 而是「编译器实际生成的类型」。同一泛型的两份实例化,
// 无可辩驳地说明「可复用的是机制,不是某个具体对象」。
print_section("两族享元:同一个泛型的两次实例化");
let family_table = TextTable::new(vec![
TableColumn::left("享元族", 16),
TableColumn::left("键类型", 24),
TableColumn::left("享元类型", 22),
TableColumn::right("实例数", 8),
TableColumn::right("请求数", 10),
TableColumn::right("命中率", 10),
]);
let template_snapshot = plan.template_pool_snapshot();
let grade_snapshot = plan.grade_pool_snapshot();
println!(" {}", family_table.header_line());
println!(" {}", family_table.separator_line());
println!(
" {}",
family_table.row_line(&[
"班次模板".to_string(),
"ShiftTemplateKey".to_string(),
"ShiftTemplate".to_string(),
template_snapshot.distinct_entry_count.to_string(),
with_thousands_separator(template_snapshot.counters.request_count as i64),
Ratio::from_basis_points(template_snapshot.counters.hit_rate_basis_points())
.as_percent_text(),
])
);
println!(
" {}",
family_table.row_line(&[
"门店等级配置".to_string(),
"StoreGrade".to_string(),
"StoreGradeProfile".to_string(),
grade_snapshot.distinct_entry_count.to_string(),
with_thousands_separator(grade_snapshot.counters.request_count as i64),
Ratio::from_basis_points(grade_snapshot.counters.hit_rate_basis_points())
.as_percent_text(),
])
);
println!(" {}", family_table.separator_line());
// 编译器眼里的池类型:两族只差两个类型参数。
println!(
" · 编译器实际生成的池类型(第一族):\n {}",
truncate_to_width(
std::any::type_name::<SharedPool<ShiftTemplateKey, ShiftTemplate>>(),
100
)
);
println!(
" · 编译器实际生成的池类型(第二族):\n {}",
truncate_to_width(
std::any::type_name::<SharedPool<StoreGrade, StoreGradeProfile>>(),
100
)
);
println!(" · 两个类型只差两个类型参数,池的实现代码一份也没写两遍。");
// ---------- 共享度画像 ----------
print_section("共享度画像");
print_lines(&render_sharing_profile(&SharingProfile::from_plan(plan)));
// ---------- 两个池的统计快照 ----------
print_section("两个池的统计快照");
print_lines(&render_pool_snapshots(plan));
print_banner(
"本幕结论",
&[
"命中率接近 100% 说明「预热后几乎不再新建实例」,但单看它看不全。",
"真正说明共享是否发生的是「平均共享倍数」和「被持有句柄总数」。",
"平均与最大必须并列看:平均低而最大高意味着共享严重不均衡,",
"此时整体节省率可能仍然不错,但键设计还有很大改进空间。",
],
);
}
// ===========================================================================
// 第五幕:成本分布
// ===========================================================================
/// 第五幕:把同一批槽位按三个不同维度分组,做一次「总额必须相等」的交叉核对。
///
/// ## 为什么会想到做这个核对
///
/// 分组报表最容易出的错不是「算错一项」,而是「某些项被漏进了别的组」——
/// 这类错误在单张表里看不出来(每项都对),只有换一个维度重新分组
/// 才会暴露(总额对不上)。
///
/// 因此本幕不只打印三张表,还强制核对:
/// 三张分组表的合计、以及方案自己的合计,四个数必须完全相等。
/// 若某个维度漏掉了某种槽位,这一屏会立刻显示不一致。
fn act_five_cost_distribution(plan: &ShiftPlan) {
print_act_title("第五幕", "成本分布 —— 同一批数据,三个维度,总额必须相等");
print_banner(
"本幕要回答",
&[
"钱花在哪个计薪时段 / 哪种班次 / 哪类门店上?",
"换三种分组方式算出来的总额,会不会不一致?",
],
);
let by_period = build_payroll_breakdown_by_period(plan);
let by_shift = build_payroll_breakdown_by_shift(plan);
let by_store_grade = build_payroll_breakdown_by_store_grade(plan);
// 第四个维度:按城市。它不是 analysis 层提供的函数,
// 而是本幕现场用泛型 build_payroll_breakdown + 一个闭包拼出来的。
// 这正是「新增维度 = 新增一个闭包」这句话的可执行证据:
// 全程没有修改 analysis 层的任何一行代码。
let by_city: PayrollBreakdown = build_payroll_breakdown(plan, "城市", |slot| {
// 城市名是门店编码携带的展示字段(StoreCode.city_name)。
slot.store_code().city_name().to_string()
});
print_lines(&render_payroll_breakdown(&by_period));
print_lines(&render_payroll_breakdown(&by_shift));
print_lines(&render_payroll_breakdown(&by_store_grade));
print_section("现场新增的第四个维度:城市(未修改分析层一行)");
// 逐条打印分组项,展示 PayrollCategoryEntry 的每个字段。
// 之所以在报表之外再列一次明细,是因为这里要同时展示
// 「分组聚合的结果」与「每一组的份额比率对象」——
// 后者是 PayrollCategoryEntry::share_as_ratio 返回的 Ratio,
// 它让调用方可以用任何比率口径(百分比 / 万分比 / 倍数)复用同一个数字,
// 而不必让分组代码为每种展示口径各存一份。
let entry: &PayrollCategoryEntry = by_city
.entries
.first()
.expect("城市维度至少应有一个分组(门店数大于零)");
println!(
" · 分组示例:{} —— 槽位 {} 个,计薪 {},人力 {},加班 {},占比 {}(万分点 {})",
entry.label,
entry.slot_count,
WorkDuration::from_minutes(entry.paid_minutes).formatted_hours_and_minutes(),
entry.labor_cost.formatted(),
entry.overtime_cost.formatted(),
entry.share_as_ratio().as_percent_text(),
entry.share_as_ratio().as_basis_points_text(),
);
println!(
" · 城市维度共 {} 组;最大一组是「{}」,说明门店按城市均匀分布。",
by_city.category_count(),
by_city
.entries
.iter()
.max_by_key(|candidate| candidate.slot_count)
.map(|candidate| candidate.label.as_str())
.unwrap_or("(无)")
);
// 把城市维度的合计也纳入交叉核对——新增一个维度就应当自动获得核对,
// 若核对需要为每个新维度手工加一行,那说明「可扩展」只做了一半。
let reconcile_rows: [(&str, &PayrollBreakdown); 4] = [
("按计薪时段分组", &by_period),
("按班次种类分组", &by_shift),
("按门店等级分组", &by_store_grade),
("按城市分组(现场新增)", &by_city),
];
// ---------- 交叉核对 ----------
print_section("交叉核对:五条路径的合计必须完全相等");
let reconcile_table = TextTable::new(vec![
TableColumn::left("路径", 26),
TableColumn::right("槽位数", 10),
TableColumn::right("计薪工时", 16),
TableColumn::right("人力成本", 16),
TableColumn::right("加班成本", 14),
]);
println!(" {}", reconcile_table.header_line());
println!(" {}", reconcile_table.separator_line());
for (label, breakdown) in reconcile_rows {
println!(
" {}",
reconcile_table.row_line(&[
label.to_string(),
breakdown.slot_count.to_string(),
WorkDuration::from_minutes(breakdown.total_paid_minutes)
.formatted_hours_and_minutes(),
breakdown.labor_total.formatted(),
breakdown.overtime_total.formatted(),
])
);
}
println!(
" {}",
reconcile_table.row_line(&[
"方案自身合计".to_string(),
plan.slot_count().to_string(),
WorkDuration::from_minutes(plan.total_paid_minutes()).formatted_hours_and_minutes(),
plan.total_labor_cost().formatted(),
plan.total_overtime_cost().formatted(),
])
);
println!(" {}", reconcile_table.separator_line());
// 逐条比对(把「一致」这件事写成代码,而不是靠眼看)
let plan_labor_minor_units: i64 = plan.total_labor_cost().minor_units();
let every_path_agrees: bool = reconcile_rows.iter().all(|(_, breakdown)| {
breakdown.labor_total.minor_units() == plan_labor_minor_units
&& breakdown.slot_count == plan.slot_count()
&& breakdown.total_paid_minutes == plan.total_paid_minutes()
});
println!(
" · 全部 {} 条分组路径与方案自身在三项指标上是否一致:{}",
reconcile_rows.len(),
if every_path_agrees { "一致 ✓" } else { "不一致 ✗" }
);
println!(
" · 说明:本工程的槽位成本在各自计算时已舍入到「分」,累加阶段是整数相加,",
);
println!(" 因此总额恰好等于各槽位之和,不存在二次舍入误差——这是多条路径能完全相等的前提。");
print_banner(
"本幕结论",
&[
"分组维度是可扩展的:新增维度只需新增一个闭包,不必新增函数。",
"「换维度重算、总额必须相等」是分组报表唯一可靠的正确性检验。",
],
);
}
// ===========================================================================
// 第六幕:合规体检
// ===========================================================================
/// 第六幕:把 8 条规则逐条检查,并特别说明那条架构规则。
fn act_six_compliance_audit(plan: &ShiftPlan) {
print_act_title("第六幕", "合规体检 —— 把架构约束写成自动断言");
print_banner(
"本幕要回答",
&[
"除业务规则外,能不能把「享元有没有真的生效」也写进体检?",
"如果哪天有人把键改细了,报表会怎样?",
],
);
print_lines(&render_coverage_audit(&audit_coverage(plan)));
print_banner(
"关于 SHARING_EFFECTIVE 这条规则",
&[
"它是本工程里唯一一条「架构规则」,判定标准是平均共享倍数 ≥ 100。",
"它存在的意义:把架构退化变成红灯,而不是等某天内存爆掉才发现。",
"好的架构约束应当能被写成自动断言,而不是留在设计文档里——",
"因为设计文档不会在代码评审时挡住一次提交。",
],
);
}
// ===========================================================================
// 第七幕:破损输入
// ===========================================================================
/// 第七幕:故意喂入三种破损输入,验证「一次报全」。
///
/// ## 三类破损与它们各自代表的真实事故
///
/// | 破损 | 真实场景 |
/// |---|---|
/// | 门店等级未登记 | 新开了「机场店」,但配置表还没更新 |
/// | 班次未登记 | 门店配置里引用了「凌晨收货班」,但该班次的时刻表没登记 |
/// | 本店无合格员工 | 社区店只有试用期员工,排不出要求「高级」的班次 |
///
/// 三类的处理动作不同(改配置 / 改时刻表 / 派人),
/// 因此不能合并成一个「失败」计数——合并后运营不知道该做什么。
///
/// 这正是「一次报全」的必要性:若按顺序遇到第一个问题就返回,
/// 运营要改一轮跑一次,三类问题要跑三轮。一次全报,一轮改完。
fn act_seven_broken_input(clean_request: &ScheduleRequest) {
print_act_title("第七幕", "破损输入 —— 三类漏排能否被一次报全");
print_banner(
"本幕要回答",
&[
"一次装配里同时出现三类问题,报表能不能全部列出来?",
"四类漏排是否分别计数,而不是合成一个总数?",
],
);
// 独立工厂:本幕会登记一个「含未登记班次」的门店等级,
// 若用主工厂,第八幕的扩展演示会被这个登记影响。
let template_factory: ShiftTemplateFactory = ShiftTemplateFactory::with_builtin_shifts();
let grade_factory: StoreGradeFactory = StoreGradeFactory::with_builtin_grades();
// 登记「港口店」:它的标准班次里包含一个未在时刻表登记的班次。
// 登记这个等级本身是合法的(等级表与时刻表是两张独立的表),
// 于是「等级已登记但班次未登记」这个中间状态被真实地构造出来了。
let harbor_registered: bool = grade_factory.register_grade_spec(StoreGradeProfileSpec::new(
STORE_GRADE_HARBOR,
3,
false,
ClockTime::from_hour_and_minute(8, 0),
ClockTime::from_hour_and_minute(21, 0),
false,
vec![SHIFT_CODE_EARLY, SHIFT_CODE_LATE, SHIFT_CODE_DAWN_DELIVERY],
vec!["港口店须配合船期安排凌晨收货班", "凌晨收货班须双人在场并录像留档"],
));
println!(" · 登记港口店等级(其班次含未登记时刻表):{}", harbor_registered);
let broken_stores: Vec<StoreProfile> = build_broken_stores();
let broken_request: ScheduleRequest = ScheduleRequest::new(
broken_stores,
// 复用干净请求的日期区间与基准时薪,保证两次装配的可比性。
clean_request.start_date(),
7,
Vec::new(),
Vec::new(),
clean_request.audit_weekday(),
clean_request.company_base_hourly_rate(),
);
let assembler: ScheduleAssembler<'_> =
ScheduleAssembler::new(&template_factory, &grade_factory);
let broken_plan: ShiftPlan = assembler.assemble(&broken_request);
print_section("破损方案的装配结果");
let broken_table = TextTable::new(vec![
TableColumn::left("漏排类别", 26),
TableColumn::right("数量", 12),
TableColumn::left("处理动作", 24),
]);
println!(" {}", broken_table.header_line());
println!(" {}", broken_table.separator_line());
println!(
" {}",
broken_table.row_line(&[
"未登记班次漏排".to_string(),
broken_plan.unregistered_gap_count().to_string(),
"补登班次时刻表".to_string(),
])
);
println!(
" {}",
broken_table.row_line(&[
"人手不足漏排".to_string(),
broken_plan.staffing_gap_count().to_string(),
"补派合格员工".to_string(),
])
);
println!(
" {}",
broken_table.row_line(&[
"等级未登记门店(整店跳过)".to_string(),
broken_plan.unregistered_store_grade_count().to_string(),
"补登门店等级配置".to_string(),
])
);
println!(
" {}",
broken_table.row_line(&[
"模板池溢出".to_string(),
if broken_plan.is_template_pool_exhausted() { "是".to_string() } else { "否".to_string() },
"检查键粒度".to_string(),
])
);
println!(" {}", broken_table.separator_line());
println!(
" · 方案是否完整:{};仍成功排出 {} 个槽位(好的照排,坏的不中断)。",
if broken_plan.has_any_gap() { "否" } else { "是" },
broken_plan.slot_count()
);
print_section("破损方案的逐条明细");
// 明细表用比汇总表更宽的类别列:明细里的文字是
// 「未登记班次:DAWN_DELIVERY」这类组合串,比汇总表的固定四类更长。
// 若沿用汇总表的列宽,编码会被截断——而编码正是排查的入口。
let broken_detail_table = TextTable::new(vec![
TableColumn::left("漏排明细", 36),
TableColumn::right("次数", 12),
TableColumn::left("处理动作", 24),
]);
println!(" {}", broken_detail_table.header_line());
println!(" {}", broken_detail_table.separator_line());
for (shift_code_text, count) in broken_plan.unregistered_shift_records() {
println!(
" {}",
broken_detail_table.row_line(&[
format!("未登记班次:{}", shift_code_text),
count.to_string(),
"补登该班次时刻表".to_string(),
])
);
}
println!(" {}", broken_detail_table.separator_line());
print_section("破损方案的体检(同一套规则)");
print_lines(&render_coverage_audit(&audit_coverage(&broken_plan)));
print_banner(
"本幕结论",
&[
"三类漏排分别计数、分别给出处理动作,没有合并成一个「失败」数字。",
"装配从不中断:能排的照排,排不了的在方案里记一笔。",
"体检用的是同一套规则——破损输入不会让检查器失效,只会让它报出更多红灯。",
],
);
}
// ===========================================================================
// 第八幕:工程外扩展
// ===========================================================================
/// 第八幕:在 main.rs 里定义并登记两个全新类型,验证既有分层零改动。
///
/// ## 为什么「登记表」比「枚举 + match」更可扩展
///
/// 若班次种类是枚举、工厂里写着 match shift_code { ... },
/// 那么新增一个班次就必须回到 flyweight 层去加一个分支——
/// 扩展性当场失效。
///
/// 本工程把「有哪些班次」从编译期代码提升为运行期数据(一张登记表)。
/// 于是扩展变成一次函数调用,且可以发生在工程之外。
/// 这是「开闭原则」在结构上真正落地的方式:
/// 不是靠「写好注释提醒后来人」,而是靠让后来人没有需要改的文件。
///
/// ## 本幕的验收方式
///
/// 不是「我说它可扩展」,而是:
/// 1. 登记成功(返回 true);
/// 2. 重复登记被拒绝(返回 false)——返回值保护数据完整性;
/// 3. 重新装配后,新班次出现在池清单里、新等级出现在等级清单里;
/// 4. 新类型的槽位被自动纳入成本统计与合规体检。
///
/// 全程只调用既有公开接口,一行既有分层文件都不改。
fn act_eight_out_of_project_extension(
template_factory: &ShiftTemplateFactory,
grade_factory: &StoreGradeFactory,
) {
print_act_title("第八幕", "工程外扩展 —— 新增班次与门店等级,不改动任何既有文件");
print_banner(
"本幕要证明",
&[
"本幕用的两个新类型(封箱夜班 / 博物馆店)都定义在 main.rs 里——",
"也就是「工程外」。support/domain/flyweight/client/analysis/app 一行都没改。",
"登记之后,它们必须能和内置类型一样参与装配、统计与体检。",
],
);
// ---------- 步骤一、二:登记 ----------
let shift_registered: bool =
template_factory.register_shift_schedule(SHIFT_SCHEDULE_NIGHT_SEAL);
let grade_registered: bool =
grade_factory.register_grade_spec(build_museum_grade_spec());
// ---------- 步骤三:重复登记必须被拒绝 ----------
let duplicate_shift_attempt: bool =
template_factory.register_shift_schedule(SHIFT_SCHEDULE_NIGHT_SEAL);
let duplicate_grade_attempt: bool =
grade_factory.register_grade_spec(build_museum_grade_spec());
print_section("登记结果");
let registration_table = TextTable::new(vec![
TableColumn::left("动作", 40),
TableColumn::left("返回值", 10),
TableColumn::left("含义", 26),
]);
println!(" {}", registration_table.header_line());
println!(" {}", registration_table.separator_line());
let registration_rows: [(&str, bool, &str); 4] = [
(
// 编码与展示名分开打印:编码是机器身份(进池键),
// 展示名是给人看的。两者不可互换——这与「身份即 code」的设计呼应。
"登记封箱夜班时刻表(编码 SEAL_NIGHT)",
shift_registered,
"新增成功",
),
(
"登记博物馆店等级配置(编码 MUSEUM)",
grade_registered,
"新增成功",
),
(
"重复登记封箱夜班时刻表",
duplicate_shift_attempt,
"同编码不覆盖",
),
(
"重复登记博物馆店等级配置",
duplicate_grade_attempt,
"同等级不覆盖",
),
];
for (action, returned, meaning) in registration_rows {
println!(
" {}",
registration_table.row_line(&[
action.to_string(),
returned.to_string(),
meaning.to_string(),
])
);
}
println!(" {}", registration_table.separator_line());
println!(
" · 新登记的等级:编码「{}」/ 展示名「{}」(编码进池键,展示名仅用于报表)",
STORE_GRADE_MUSEUM.code(),
STORE_GRADE_MUSEUM.display_name()
);
println!(
" · 不覆盖是有意的:若编码拼错,覆盖会静默改掉既有配置;",
);
println!(" 返回 false 让调用方立刻知道「这个编码已被占用」。");
// ---------- 步骤四:装配含新等级的门店 ----------
let extension_stores: Vec<StoreProfile> = build_extension_stores();
let extension_request: ScheduleRequest = ScheduleRequest::new(
extension_stores,
// 2025-11-03 是周一;区间内无节假日、无大促,便于手算。
CalendarDate::from_ymd(2025, 11, 3),
7,
Vec::new(),
Vec::new(),
AUDIT_WEEKDAY,
CurrencyAmount::from_minor_units(
COMPANY_BASE_HOURLY_RATE_MINOR_UNITS,
CURRENCY_CHINESE_YUAN,
),
);
let assembler: ScheduleAssembler<'_> =
ScheduleAssembler::new(template_factory, grade_factory);
let extension_plan: ShiftPlan = assembler.assemble(&extension_request);
// ---------- 步骤五:证据 ----------
print_section("证据一:新班次已进入享元池清单");
print_lines(&render_shift_template_inventory(template_factory));
print_section("证据二:新门店等级已进入池清单");
print_lines(&render_store_grade_inventory(grade_factory));
print_section("证据三:新类型的槽位被自动纳入统计");
print_lines(&render_plan_overview(&extension_plan));
print_lines(&render_payroll_breakdown(&build_payroll_breakdown_by_shift(
&extension_plan,
)));
print_banner(
"本幕结论",
&[
"两个新类型定义在 main.rs(工程外),只通过公开接口登记与取用,",
"support / domain / flyweight / client / analysis / app 六个分层零改动。",
"扩展之所以能做到「零改动」,关键是把「有哪些班次/等级」",
"从编译期的 match 分支提升为运行期的一张登记表。",
"可扩展性不是一句声明,而是一次可以被调用、被观察的登记动作。",
],
);
}
// ===========================================================================
// 附幕:只读口径清点
// ===========================================================================
/// 附幕:把各层「实现了但主流程没用到」的只读接口逐条走一遍。
///
/// ## 这一幕为什么必须存在
///
/// 一个分层的工程里,下层会提供一些当前主流程用不到、但语义上完整的接口:
/// 例如 CalendarDate::weekday_text()、ShiftSlot::template_share_count()、
/// MemoryComparison::saved_bytes_per_slot()。
///
/// 它们的存在是合理的(一个「日历日」就应当能说出它是星期几),
/// 但编译器会报「从未使用」。此时有两种做法:
///
/// | 做法 | 后果 |
/// |---|---|
/// | 加 #[allow(dead_code)] | 告警消失,但接口是否真的可用再也没被验证过 |
/// | 删掉 | 接口不再完整(一个日历日说不出星期几,是明显的功能缺失) |
/// | 逐条调用一次并打印 | 告警消失,且接口被实证可用 |
///
/// 本工程选第三种。这不是「为了消警告而写代码」——
/// 因为一个从未被调用过的接口,很可能在第一次被调用时就暴露 bug
/// (参数顺序、边界条件、返回类型)。这一幕把它提前了。
///
/// ## 顺带演示「排版职责归表示层」
///
/// ShiftTemplate::formatted() 与 StoreGradeProfile::formatted() 是
/// 下层「自带的排版」。本幕把它们与 app 层的表格并列打印:
/// 前者紧凑但无法对齐成表,后者能对齐但需要先取字段。
/// 一眼就能看出「为什么排版该统一归表示层」——
/// 若各层各写一套排版,报表就再也无法统一列宽。
fn act_nine_read_only_inventory(
plan: &ShiftPlan,
template_factory: &ShiftTemplateFactory,
grade_factory: &StoreGradeFactory,
request: &ScheduleRequest,
) {
print_act_title("附幕", "只读口径清点 —— 把各层完整的只读接口逐条走一遍");
print_banner(
"本幕要回答",
&[
"那些「当前没用上」的只读接口,真的能用吗?",
"下层自带的排版(formatted)与表示层的表格,差别在哪?",
],
);
// ---------- support 层:日历与时刻 ----------
print_section("support 层:日历与时刻的完整口径");
let range_start: CalendarDate = plan.date_range_start();
let range_end: CalendarDate = plan.date_range_end();
println!(
" · 区间起始日:{} 年 {} 月 {} 日({}),简写 {},周{}",
range_start.year(),
range_start.month(),
range_start.day(),
range_start.formatted(),
range_start.month_day_text(),
range_start.weekday_text(),
);
println!(
" · 区间结束日:{} 年 {} 月 {} 日({}),周{}",
range_end.year(),
range_end.month(),
range_end.day(),
range_end.formatted(),
range_end.weekday_text(),
);
let sample_minute_of_day: u16 = 1_395;
let rebuilt_clock_time = ClockTime::from_minute_of_day(sample_minute_of_day);
println!(
" · 第 {} 分钟 = {}(往返核对:再取回分钟数 = {})",
sample_minute_of_day,
rebuilt_clock_time.formatted(),
rebuilt_clock_time.minute_of_day()
);
// 确定性编码的两种口径:低位派生编号、高位派生指纹。
let fingerprint_seed: u64 = fnv1a_64(b"lukfook-schedule-2025-10");
println!(
" · 排班批量指纹:{}(种子哈希 {:016X});编号用低位、指纹用高位,两者互不干扰。",
short_fingerprint(fingerprint_seed),
fingerprint_seed
);
// ---------- domain 层:金额、比率、工时 ----------
print_section("domain 层:金额 / 比率 / 工时 的完整口径");
let base_rate: CurrencyAmount = plan.company_base_hourly_rate();
let rebuilt_amount = CurrencyAmount::from_major_and_minor(28, 0, base_rate.currency());
println!(
" · 由「主单位 + 最小单位」重建基准时薪:(28, 0) → {};与方案一致:{}",
rebuilt_amount.formatted(),
if rebuilt_amount.minor_units() == base_rate.minor_units() { "是" } else { "否" }
);
let negative_demo = CurrencyAmount::from_minor_units(-12_345, base_rate.currency());
println!(
" · 负金额:{}(is_negative = {},absolute = {});零金额 is_zero = {}",
negative_demo.formatted(),
negative_demo.is_negative(),
negative_demo.absolute().formatted(),
CurrencyAmount::zero(base_rate.currency()).is_zero()
);
let doubled_rate = base_rate.multiply_by_quantity(2);
let difference_rate = doubled_rate
.subtract(&base_rate)
.expect("同币种相减必然成功");
println!(
" · 倍数与差额:基准 × 2 = {},再减基准 = {}(与基准相等:{})",
doubled_rate.formatted(),
difference_rate.formatted(),
if difference_rate.minor_units() == base_rate.minor_units() { "是" } else { "否" }
);
// 币种安全:跨币种相加必须被拒绝。
let cross_currency_sum =
base_rate.add(&CurrencyAmount::from_minor_units(1_000, CURRENCY_HONG_KONG_DOLLAR));
println!(
" · 跨币种相加(人民币 + 中国香港元):{}",
match cross_currency_sum {
Some(_) => "被允许(不应发生 ✗)",
None => "被拒绝 ✓ —— 币种不一致时 add 返回 None,不做静默换算",
}
);
println!(
" · 币种元数据:人民币={}({}),中国香港元={}({})",
CURRENCY_CHINESE_YUAN.display_name(),
CURRENCY_CHINESE_YUAN.code(),
CURRENCY_HONG_KONG_DOLLAR.display_name(),
CURRENCY_HONG_KONG_DOLLAR.code(),
);
// 比率与工时。
let half_ratio = Ratio::from_basis_points(5_000);
println!(
" · 比率 0.5000:is_above_one = {},is_greater_than(零) = {},基础点文本 = {}",
half_ratio.is_above_one(),
half_ratio.is_greater_than(&Ratio::zero()),
half_ratio.as_basis_points_text()
);
println!(
" · 比率作用于金额:基准 × 0.5000 = {}(零比率 is_zero = {})",
half_ratio.apply_to(&base_rate).formatted(),
Ratio::zero().is_zero()
);
let zero_duration = WorkDuration::zero();
let summed_duration = WorkDuration::sum(&[
WorkDuration::from_hours_and_minutes(7, 0),
WorkDuration::from_hours_and_minutes(0, 30),
]);
println!(
" · 工时:零工时 is_zero = {};7 小时 + 30 分 = {}(十进制小时 {})",
zero_duration.is_zero(),
summed_duration.formatted_hours_and_minutes(),
summed_duration.formatted_decimal_hours()
);
println!(
" · 工时相加(复用 add):{} + {} = {}",
zero_duration.formatted_hours_and_minutes(),
summed_duration.formatted_hours_and_minutes(),
zero_duration.add(&summed_duration).formatted_hours_and_minutes()
);
// ---------- domain 层:标签的「附加属性」 ----------
print_section("domain 层:标签携带的附加属性");
// 「附加属性」列宽按最宽值定:position_prefix(末位定位符「F」) 需 34 列、
// city_name(展示字段,不进键) 需 29 列。取 34。
let label_table = TextTable::new(vec![
TableColumn::left("标签", 14),
TableColumn::left("附加属性", 34),
TableColumn::left("值", 16),
]);
println!(" {}", label_table.header_line());
println!(" {}", label_table.separator_line());
println!(
" {}",
label_table.row_line(&[
"班次".to_string(),
"display_order(排序位)".to_string(),
SHIFT_CODE_EARLY.display_order().to_string(),
])
);
println!(
" {}",
label_table.row_line(&[
"技能等级".to_string(),
"requires_certification".to_string(),
if SKILL_GRADE_TECHNICIAN.requires_certification() { "需持证" } else { "无需持证" }
.to_string(),
])
);
println!(
" {}",
label_table.row_line(&[
"计薪时段".to_string(),
"is_surcharged(是否上浮)".to_string(),
if PAY_PERIOD_HOLIDAY.is_surcharged() { "上浮" } else { "不上浮" }.to_string(),
])
);
let first_store = request.stores().first().expect("门店表非空");
println!(
" {}",
label_table.row_line(&[
"门店编码".to_string(),
"city_name(展示字段,不进键)".to_string(),
first_store.store_code().city_name().to_string(),
])
);
let first_staff = first_store.roster().first().expect("名单非空");
println!(
" {}",
label_table.row_line(&[
"工号".to_string(),
format!("position_prefix(末位定位符「{}」)", first_staff.staff_number().position_prefix()),
first_staff.staff_number().masked_text(),
])
);
println!(" {}", label_table.separator_line());
println!(
" · 工号原文 {} 与掩码 {} 并列:掩码用于日志与工单,避免工号全量外泄。",
first_staff.staff_number().text(),
first_staff.staff_number().masked_text()
);
// ---------- flyweight 层:池的只读视图 ----------
//
// ## 本节的阅读顺序是刻意的:先看快照,再看待看就变的实时读数
//
// 池暴露了两种「读法」:
//
// | 读法 | 例子 | 特点 |
// |---|---|---|
// | 快照(值) | plan.template_pool_snapshot() | 装配结束那一刻冻住,永远不变 |
// | 实时视图 | pool.entry_count() / pool.held_reference_total() | 随每次取用变化 |
//
// 报告与账目必须用快照:否则「同一次装配跑两次得到不同的报表」。
// 而诊断「此刻池里有什么」要用实时视图。
// 两者混用会产生「报表数字对不上」这类最让人困惑的 bug——
// 所以本节先给快照、后给实时读数,并明确标注。
print_section("flyweight 层:共享池的只读视图");
let template_pool: &SharedPool<ShiftTemplateKey, ShiftTemplate> = template_factory.pool();
// 【第一段】快照口径(值类型,不随任何后续动作变化)
let template_snapshot = plan.template_pool_snapshot();
let grade_snapshot = plan.grade_pool_snapshot();
let template_counters: PoolCounters = template_snapshot.counters;
println!(" 【快照口径】第一幕装配结束那一刻冻住的计数(报表与对账只能用这一组):");
println!(
" · 请求 {} / 命中 {} / 未命中 {} / 拒绝 {}(四者分开算,不做减法)",
template_counters.request_count,
template_counters.hit_count,
template_counters.miss_count,
template_counters.rejected_count
);
println!(
" · 池内不同实例数 {};实例内容字节 {};被持有句柄 {}",
template_snapshot.distinct_entry_count,
template_snapshot.intrinsic_bytes_total,
template_snapshot.held_reference_total
);
// 【第二段】实时视图(会随每次动作变化)—— 与快照不同是正确行为
let live_entry_count: usize = template_pool.entry_count();
let live_intrinsic_bytes_total: usize = template_pool.intrinsic_bytes_total();
let live_held_reference_total: u64 = template_pool.held_reference_total();
println!(" 【实时视图】此刻池的状态(与快照不同,原因见下):");
println!(
" · entry_count = {}(快照 {});entry_limit = {}",
live_entry_count,
template_snapshot.distinct_entry_count,
template_pool
.entry_limit()
.map(|limit| limit.to_string())
.unwrap_or_else(|| "未设上限".to_string()),
);
println!(
" · intrinsic_bytes_total = {}(快照 {})",
with_thousands_separator(live_intrinsic_bytes_total as i64),
with_thousands_separator(template_snapshot.intrinsic_bytes_total as i64)
);
println!(
" · held_reference_total = {}(快照 {})",
with_thousands_separator(live_held_reference_total as i64),
with_thousands_separator(template_snapshot.held_reference_total as i64)
);
println!(" · 差异原因有两条,都不是 bug —— 它们恰好暴露了享元的生命周期分工:");
println!(
" ① 实例数只增不减:池持有实例的所有权(Rc),登记过的实例永不消失。"
);
println!(
" 第八幕登记「封箱夜班」后池多了 2 个实例(该班次只用到周末与平日两种时段),"
);
println!(
" 故 entry_count 由 {} 升到 {}、内容字节由 {} 升到 {}。",
template_snapshot.distinct_entry_count,
live_entry_count,
with_thousands_separator(template_snapshot.intrinsic_bytes_total as i64),
with_thousands_separator(live_intrinsic_bytes_total as i64),
);
println!(
" ② 句柄数可增可减:槽位只借用句柄,方案对象一旦析构,句柄立刻归还池。"
);
println!(
" 第八幕那 93 个槽位随其方案对象一起释放,因此此刻 held_reference_total 正好回到快照值。"
);
println!(
" 若有谁把第八幕的方案对象存下来(比如塞进一个长命容器),这里就会读到 {}。",
with_thousands_separator(template_snapshot.held_reference_total as i64 + 93)
);
println!(" · 结论:报表与对账必须用快照;实时视图只用于「此刻池里有什么」的诊断。");
// 【第三段】lookup 探测:一次「观测改变被观测对象」的现场演示
let probe_key: ShiftTemplateKey =
ShiftTemplateKey::new(SHIFT_CODE_EARLY, SKILL_GRADE_SENIOR, PAY_PERIOD_HOLIDAY);
{
// 用一个显式作用域把探测句柄的生命周期框出来——
// 这样「句柄在块内持有、块结束后释放」在代码结构上就是可见的,
// 而不是依赖读者去数变量到底活到哪一行。
let probe_lookup: Option<SharedHandle<ShiftTemplate>> = template_pool.lookup(&probe_key);
println!(
" 【探测】lookup 键「{}」:{}(返回 Option,未命中返回 None,不创建实例)",
probe_key.compact_text(),
match &probe_lookup {
Some(_) => "命中",
None => "未命中",
}
);
if let Some(shared_template) = &probe_lookup {
println!(
" · 探测所得模板:{}(ShiftTemplate::formatted() 自带排版)",
shared_template.formatted()
);
println!(
" · 此刻 held_reference_total = {},比上一步多 1 —— 因为这个 lookup 返回的句柄正被本块持有。",
with_thousands_separator(template_pool.held_reference_total() as i64)
);
println!(" 观测行为本身改变了被观测对象。");
// 句柄克隆:引用计数 +1,内容不复制。
let handle_copy: SharedHandle<ShiftTemplate> = SharedHandle::clone(shared_template);
debug_assert!(
SharedHandle::strong_count(&handle_copy) >= 2,
"句柄克隆不会复制内容,但一定让计数增加"
);
}
}
println!(
" · 探测块结束后句柄已释放:现在读到的 held_reference_total = {},回到了上一步的值。",
with_thousands_separator(template_pool.held_reference_total() as i64)
);
println!(
" · contains_key = {}(判定「是否有实例」而不取出句柄,因此不会改变计数)。",
template_pool.contains_key(&probe_key)
);
// ---------- 两条路径重算「独立侧字节」并比对 ----------
let uniform_unshared_bytes: usize = template_snapshot
.unshared_total_bytes_of_uniform(template_snapshot.average_intrinsic_bytes());
let uniform_footprint: UnsharedFootprint = UnsharedFootprint::new(
template_snapshot.held_reference_total,
template_snapshot.average_intrinsic_bytes(),
);
println!(" 【交叉核对】两条独立路径重算「若不共享要多少字节」(都用快照口径):");
println!(
" · 路径一 PoolSnapshot::unshared_total_bytes_of_uniform = {} 字节",
with_thousands_separator(uniform_unshared_bytes as i64)
);
println!(
" · 路径二 UnsharedFootprint::total_bytes = {} 字节 → {}",
with_thousands_separator(uniform_footprint.total_bytes() as i64),
if uniform_footprint.total_bytes() == uniform_unshared_bytes {
"一致 ✓"
} else {
"不一致 ✗"
}
);
println!(
" · 该口径下的节省 {} 字节({} 万分点);门店等级池独立侧 {} 字节",
with_thousands_separator(template_snapshot.saved_bytes(uniform_unshared_bytes)),
template_snapshot.saved_ratio_basis_points(uniform_unshared_bytes),
with_thousands_separator(
grade_snapshot
.unshared_total_bytes_of_uniform(grade_snapshot.average_intrinsic_bytes())
as i64
)
);
println!(
" · 内置盘点夜班常量 BUILTIN_AUDIT_SHIFT_CODE = {},已登记:{}",
BUILTIN_AUDIT_SHIFT_CODE.display_name(),
template_factory.is_shift_registered(&BUILTIN_AUDIT_SHIFT_CODE)
);
println!(
" · 门店等级登记查询:旗舰店={},博物馆店(工程外新增)={}",
grade_factory.is_grade_registered(&STORE_GRADE_FLAGSHIP),
grade_factory.is_grade_registered(&STORE_GRADE_MUSEUM)
);
// ---------- 下层自带排版 vs 表示层表格 ----------
print_section("下层自带排版 vs 表示层表格(排版职责归属的实证)");
println!(" 【下层自带排版:紧凑、但无法对齐成表】");
// 池底层是 HashMap,for_each_entry 的遍历顺序随哈希种子变化。
// 若直接遍一边打印,两次运行的行序就会不同(本幕原先正是如此,实测复现过)。
// 因此这里先在闭包内把每一行渲染成拥有所有权的字符串,再按门店等级排序——
// 且与右侧表格用同一个排序键,这样左右两侧逐行对齐,
// 才谈得上「并列对照」。(闭包给出的引用生命周期不绑定池的借用,
// 不能直接收集 &StoreGradeProfile,故此处走「先渲染成 String」这条路。)
let mut formatted_lines: Vec<(String, Vec<String>)> = Vec::new();
grade_factory.pool().for_each_entry(|_grade, profile| {
formatted_lines.push((
profile.grade().display_name().to_string(),
vec![
format!(" · {}", profile.formatted()),
format!(
" 营业时长 {}(opening_duration)",
profile.opening_duration().formatted_hours_and_minutes()
),
],
));
});
formatted_lines.sort_by(|left, right| left.0.cmp(&right.0));
let formatted_line_count: usize = formatted_lines.len();
for (_grade_name, lines) in &formatted_lines {
for line in lines {
println!("{}", line);
}
}
println!();
println!(" 【表示层表格:同一批数据,列宽统一、可机械核对】");
let aligned_table = TextTable::new(vec![
TableColumn::left("门店等级", 12),
TableColumn::right("每班人数", 10),
TableColumn::left("营业时段", 14),
TableColumn::right("营业时长", 14),
]);
println!(" {}", aligned_table.header_line());
println!(" {}", aligned_table.separator_line());
let mut aligned_rows: Vec<Vec<String>> = Vec::new();
grade_factory.pool().for_each_entry(|_grade, profile| {
aligned_rows.push(vec![
profile.grade().display_name().to_string(),
profile.minimum_staff_per_shift().to_string(),
format!(
"{}~{}",
profile.opening_time().formatted(),
profile.closing_time().formatted()
),
profile.opening_duration().formatted_hours_and_minutes(),
]);
});
aligned_rows.sort_by(|left, right| left[0].cmp(&right[0]));
for row in &aligned_rows {
println!(" {}", aligned_table.row_line(row));
}
println!(" {}", aligned_table.separator_line());
// 空行占位保持等宽 —— 用于分组之间的视觉分隔(这里演示一次)。
println!(" {}", aligned_table.blank_line());
println!(
" {}(表格总宽 {} 列)",
pad_center("以上为表示层对齐后的结果", aligned_table.total_width()),
aligned_table.total_width()
);
debug_assert_eq!(formatted_line_count, aligned_rows.len());
// ---------- client 层:槽位与员工的完整口径 ----------
print_section("client 层:槽位 / 员工 / 门店 的完整口径");
let slot = plan.slots().first().expect("方案非空");
println!(
" · 槽位序号 {}(u64,非 String:1.1 万个槽位若各存一个字符串就是 1.1 万次堆分配)",
slot.slot_serial()
);
println!(
" · 该槽位的模板被 {} 个槽位共享(template_share_count);是否与他人共享:{}",
slot.template_share_count(),
slot.is_sharing_template_with_others()
);
println!(
" · 该槽位员工 {} 的技能等级 {},隶属门店 {}",
slot.staff_number().text(),
first_staff.skill_grade().display_name(),
first_staff.home_store_code().code()
);
println!(
" · 门店 {}({})名单 {} 人,store_count = {}",
first_store.store_code().code(),
first_store.store_code().city_name(),
first_store.staff_count(),
request.store_count()
);
println!(
" · 请求口径:排班 {} 天,币种 {},区间内节假日 {} 天、大促 {} 天",
request.day_count(),
request.currency().code(),
request.holiday_count_in_range(),
request.promotion_count_in_range()
);
// ---------- analysis 层:尚未被报表使用的度量 ----------
print_section("analysis 层:尚未被报表使用的度量");
let audit_report = audit_coverage(plan);
let failed_descriptions: Vec<&'static str> = audit_report.failed_descriptions();
println!(
" · 体检未通过的规则说明(failed_descriptions):{}",
if failed_descriptions.is_empty() {
"(无 —— 全部通过)".to_string()
} else {
failed_descriptions.join(";")
}
);
println!(
" · 体检阈值常量:平均共享倍数下限 {} 倍({} 万分点);单槽位加班上限 {} 分钟",
MINIMUM_AVERAGE_SHARE_BASIS_POINTS / BASIS_POINTS_DENOMINATOR,
MINIMUM_AVERAGE_SHARE_BASIS_POINTS,
OVERTIME_WARNING_THRESHOLD_MINUTES
);
let realistic_overhead = UnsharedSlotOverhead::new(
UnsharedShiftDefinition::from_template(slot.template()).estimated_bytes(),
UnsharedStoreGradeConfig::from_profile(slot.grade_profile()).estimated_bytes(),
);
let comparison = MemoryComparison::from_plan(plan, realistic_overhead);
println!(
" · 每槽位平均节省 {} 字节(saved_bytes_per_slot);总节省折算成 {} 个槽位的外在状态",
with_thousands_separator(comparison.saved_bytes_per_slot()),
with_thousands_separator(comparison.saved_equivalent_slot_count())
);
// zero_amount_of 是一个「类型锚点」:本模块只做字节统计,
// 它返回的零金额证明「本模块与金额无关」这个事实可在类型层面被引用。
let anchor_amount = MemoryComparison::zero_amount_of(plan.currency());
println!(
" · 内存模块的类型锚点:零金额 = {}(该值恒为零,用于声明本模块不涉及金额)",
anchor_amount.formatted()
);
let profile_summary = SharingProfile::from_plan(plan);
println!(
" · 共享度比率对象:平均共享 {}、模板命中率 {}(同一份数据可换成任意比率口径)",
profile_summary.average_template_share_as_ratio().as_multiplier_text(),
profile_summary.template_hit_rate_as_ratio().as_percent_text()
);
let period_breakdown = build_payroll_breakdown_by_period(plan);
let first_category = period_breakdown.entries.first().expect("分组非空");
println!(
" · 分组项级度量:分组「{}」每分钟总成本 {} 分(该组计薪 {},人力 {} + 加班 {})",
first_category.label,
first_category.cost_per_minute_minor_units(),
WorkDuration::from_minutes(first_category.paid_minutes).formatted_hours_and_minutes(),
first_category.labor_cost.formatted(),
first_category.overtime_cost.formatted(),
);
// 交叉核对:每分钟成本 × 分钟数 应当还原出该组的总成本(人力 + 加班),
// 误差只允许来自「整数除法截断」,即最大不超过 1 分钟的成本值。
let first_category_total_minor_units: i64 = first_category
.labor_cost
.minor_units()
.saturating_add(first_category.overtime_cost.minor_units());
let recalculated_labor_minor_units: i64 = first_category
.cost_per_minute_minor_units()
.saturating_mul(first_category.paid_minutes as i64);
let truncation_gap: i64 = (recalculated_labor_minor_units - first_category_total_minor_units).abs();
println!(
" · 交叉核对:每分钟 {} 分 × {} 分钟 = {} 分,与该组总成本 {} 分相差 {} 分(≤ {} 分钟的成本即属正常)",
first_category.cost_per_minute_minor_units(),
first_category.paid_minutes,
with_thousands_separator(recalculated_labor_minor_units),
with_thousands_separator(first_category_total_minor_units),
with_thousands_separator(truncation_gap),
first_category.paid_minutes
);
print_banner(
"本幕结论",
&[
"所有「当前未用上」的只读接口都在这里被真正调用了一次——",
"它们不只是消告警的装饰,而是被验证过可用的接口。",
"下层自带的 formatted() 适合日志与单行摘要;",
"要成表对齐,必须交给表示层——否则同一批数据会输出两种列宽。",
],
);
}
// ===========================================================================
// 工程外扩展区
// ===========================================================================
//
// 本区是「工程外」的地方:这里定义的类型不属于任何分层。
// 它们存在的意义有两个:
//
// 1. 供第二幕做内存对照(UnsharedShiftDefinition / UnsharedStoreGradeConfig);
// 2. 供第七、八幕做扩展演示(几个新的 ShiftCode / StoreGrade / 时刻表)。
//
// ⚠️ 一条纪律:本区只允许引用分层公开的接口。
// 一旦这里出现了对某个分层内部结构的依赖(比如直接构造 ShiftTemplate),
// 就说明那个接口不够公开,「工程外扩展」就变成了一句空话。
// 本区所有代码都能通过编译,正是这条纪律可被验证的证据。
/// 工程外的「不共享版」班次定义。
///
/// ## 与 ShiftTemplate 的唯一结构差异:字符串是 String 而非 &'static str
///
/// 这不是为了「让对照数字好看」而故意加码。理由:
///
/// 一个不共享的实现,通常意味着「每个槽位各自从配置构造自己的定义」。
/// 那条路径上拿到的字符串天然是 String(来自解析、拼接或数据库读取),
/// 而不是指向只读段的 &'static str。要让它变成静态复用,
/// 需要额外的驻留(interning)机制——那已经是另一种形式的共享了。
///
/// 所以 String 是这条对照路径的自然形态,不是被刻意加重的。
/// 为了让这个假设不至于成为结论的全部依据,第二幕同时给出保守下界
/// (假设不共享版也只复制结构体、字符串仍然静态复用)。
#[derive(Debug, Clone)]
struct UnsharedShiftDefinition {
/// 班次编码(拥有所有权)。
shift_code_text: String,
/// 班次名称(拥有所有权)。
shift_display_name: String,
/// 技能门槛名称(拥有所有权)。
minimum_skill_grade_text: String,
/// 计薪时段名称(拥有所有权)。
pay_period_kind_text: String,
/// 开始时刻。
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,
}
impl UnsharedShiftDefinition {
/// 从一个共享模板深拷贝出一份「自己的」定义。
///
/// 参数 template:共享模板。
/// 返回:内容相同、但字符串各自拥有的不共享版本。
///
/// 这个函数就是「不共享」这个假设的可执行形式:
/// 调用一次,就得到一份这个槽位独占的定义副本。
fn from_template(template: &ShiftTemplate) -> UnsharedShiftDefinition {
UnsharedShiftDefinition {
shift_code_text: template.shift_code().code().to_string(),
shift_display_name: template.shift_code().display_name().to_string(),
minimum_skill_grade_text: template.minimum_skill_grade().display_name().to_string(),
pay_period_kind_text: template.pay_period_kind().display_name().to_string(),
starts_at: template.starts_at(),
ends_at: template.ends_at(),
scheduled_duration: template.scheduled_duration(),
break_duration: template.break_duration(),
paid_duration: template.paid_duration(),
combined_pay_multiplier: template.combined_pay_multiplier(),
requires_security_presence: template.requires_security_presence(),
requires_dual_presence: template.requires_dual_presence(),
crosses_midnight: template.crosses_midnight(),
}
}
/// 渲染成一行可读文本(用于证明「这是完整副本,不是空壳」)。
///
/// 返回:形如 早班 / 高级 / 平日 / 09:00-17:00 / 计薪 7 小时 0 分 / 系数 1.0000 / 安保否 / 双人否。
///
/// 这个方法的存在有两个作用:
/// 1. 让「不共享版」的每个字段都被真正读取一次——
/// 否则编译器会报「字段从未被读取」,而那时的直觉反应是加
/// #[allow(dead_code)] 把它压下去。压下去等于承认它是一个空壳,
/// 于是内存对照的可信度就没了;
/// 2. 让读者能逐字段核对两份内容一致,从而相信
/// 「两侧的字节差异只来自『有没有共享』这一件事」。
fn formatted(&self) -> String {
format!(
"{} / {} / {} / {}-{} / 排班 {} / 计薪 {} / 休息 {} / 系数 {} / 安保 {} / 双人 {} / 跨零点 {}",
self.shift_display_name,
self.minimum_skill_grade_text,
self.pay_period_kind_text,
self.starts_at.formatted(),
self.ends_at.formatted(),
self.scheduled_duration.formatted_hours_and_minutes(),
self.paid_duration.formatted_hours_and_minutes(),
self.break_duration.formatted_hours_and_minutes(),
self.combined_pay_multiplier.as_multiplier_text(),
if self.requires_security_presence { "是" } else { "否" },
if self.requires_dual_presence { "是" } else { "否" },
if self.crosses_midnight { "是" } else { "否" },
)
}
}
impl FlyweightFootprint for UnsharedShiftDefinition {
/// 返回本副本的逻辑字节数。
///
/// 口径与 ShiftTemplate::estimated_bytes 完全一致
/// (结构体栈内部分 + 堆内内容按 len() 计),
/// 这样两侧的数字才有可比性——若一边算堆、一边不算,
/// 差额里就会混进「口径差异」这个与共享无关的成分。
fn estimated_bytes(&self) -> usize {
let inline_bytes: usize = std::mem::size_of::<UnsharedShiftDefinition>();
let owned_string_bytes: usize = self.shift_code_text.len()
self.shift_display_name.len()
self.minimum_skill_grade_text.len()
self.pay_period_kind_text.len();
inline_bytes + owned_string_bytes
}
}
/// 工程外的「不共享版」门店等级配置。
///
/// 与 StoreGradeProfile 的结构差异有两处:
/// 1. 字符串字段(备注)从 &'static str 变成 String;
/// 2. 两个 Vec 是每个副本各自的——
/// 不共享的实现里,每个槽位都要为自己的配置克隆这两个列表的内容。
///
/// 第 2 点是这个对照最诚实的地方:即使有人坚持「字符串可以静态复用」,
/// Vec 没法在不共享的前提下复用(除非再用一层 Rc,
/// 而那就已经是共享了)。因此门店配置这一侧的对照几乎无法反驳。
#[derive(Debug, Clone)]
struct UnsharedStoreGradeConfig {
/// 门店等级。
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<String>,
}
impl UnsharedStoreGradeConfig {
/// 从一个共享配置深拷贝出一份「自己的」配置。
///
/// 参数 profile:共享配置。
/// 返回:内容相同、但两个 Vec 各自拥有的不共享版本。
fn from_profile(profile: &StoreGradeProfile) -> UnsharedStoreGradeConfig {
UnsharedStoreGradeConfig {
grade: profile.grade(),
minimum_staff_per_shift: profile.minimum_staff_per_shift(),
requires_dedicated_security: profile.requires_dedicated_security(),
opening_time: profile.opening_time(),
closing_time: profile.closing_time(),
daily_audit_required: profile.daily_audit_required(),
standard_shift_codes: profile.standard_shift_codes().to_vec(),
compliance_notes: profile
.compliance_notes()
.iter()
.map(|note| (*note).to_string())
.collect(),
}
}
/// 渲染成一行可读文本(用于证明「这是完整副本,不是空壳」)。
///
/// 返回:形如 博物馆店 / 每班 3 人 / 营业 09:30-21:00 / 标准班次 3 个 / 安保否 / 每日盘点否 / 备注 2 条。
///
/// 与班次定义的同名方法理由相同:让每个字段都被真正读取一次,
/// 从而既消除告警,又证明对照版本的字节数是「完整内容」的字节数。
fn formatted(&self) -> String {
let standard_shift_names: Vec<&str> = self
.standard_shift_codes
.iter()
.map(|shift_code| shift_code.display_name())
.collect();
format!(
"{} / 每班 {} 人 / 营业 {}-{} / 标准班次 {} 个({}) / 安保 {} / 每日盘点 {} / 备注 {} 条",
self.grade.display_name(),
self.minimum_staff_per_shift,
self.opening_time.formatted(),
self.closing_time.formatted(),
self.standard_shift_codes.len(),
standard_shift_names.join("+"),
if self.requires_dedicated_security { "需要" } else { "不需要" },
if self.daily_audit_required { "需要" } else { "不需要" },
self.compliance_notes.len(),
)
}
}
impl FlyweightFootprint for UnsharedStoreGradeConfig {
/// 返回本副本的逻辑字节数(口径与 StoreGradeProfile 一致)。
fn estimated_bytes(&self) -> usize {
let inline_bytes: usize = std::mem::size_of::<UnsharedStoreGradeConfig>();
// 班次列表:按值存储,每个 size_of::<ShiftCode>()。
let shift_code_bytes: usize =
self.standard_shift_codes.len() * std::mem::size_of::<ShiftCode>();
// 备注列表:每个元素是一个 String(24 字节头)+ 堆内容。
let note_bytes: usize = self
.compliance_notes
.iter()
.map(|note| std::mem::size_of::<String>() + note.len())
.sum();
inline_bytes + shift_code_bytes + note_bytes
}
}
// ---------- 工程外的新标签(开放型结构体,无需修改 domain 层)----------
/// 封箱夜班(工程外新增班次)。
const SHIFT_CODE_NIGHT_SEAL: ShiftCode = ShiftCode::new("SEAL_NIGHT", "封箱夜班", 60);
/// 凌晨收货班(工程外新增班次,刻意不登记时刻表)。
///
/// 它的存在是为了构造「等级已登记、但班次时刻表未登记」这个中间状态,
/// 从而验证第七幕的「未登记班次漏排」能被正确记录。
const SHIFT_CODE_DAWN_DELIVERY: ShiftCode = ShiftCode::new("DAWN_DELIVERY", "凌晨收货班", 55);
/// 博物馆店(工程外新增门店等级)。
const STORE_GRADE_MUSEUM: StoreGrade = StoreGrade::new("MUSEUM", "博物馆店");
/// 港口店(工程外新增门店等级,配置里引用了未登记时刻表的班次)。
const STORE_GRADE_HARBOR: StoreGrade = StoreGrade::new("HARBOR", "港口店");
/// 机场店(工程外新增门店等级,刻意不登记配置)。
const STORE_GRADE_AIRPORT: StoreGrade = StoreGrade::new("AIRPORT", "机场店");
/// 封箱夜班的时刻表规格。
///
/// ## 为什么这个规格能写成 const
///
/// 因为 ShiftScheduleSpec::new 与它用到的全部构造函数
/// (ClockTime::from_hour_and_minute / WorkDuration::from_hours_and_minutes
/// / Ratio::from_basis_points)都是 const fn。
/// const fn 不是语法糖,而是一条扩展性设计:
/// 它让工程外能把规格定义成编译期常量,零运行期成本、零构造代码。
/// 反例是门店等级规格——它含 Vec,无法 const,只能在运行期登记。
const SHIFT_SCHEDULE_NIGHT_SEAL: ShiftScheduleSpec = ShiftScheduleSpec::new(
SHIFT_CODE_NIGHT_SEAL,
// 23:00 到次日 07:00,跨零点。
ClockTime::from_hour_and_minute(23, 0),
ClockTime::from_hour_and_minute(7, 0),
// 休息 60 分钟(夜班含一次轮换休息)。
WorkDuration::from_hours_and_minutes(1, 0),
// 班次系数 1.30(夜班津贴)。
Ratio::from_basis_points(13_000),
// 封箱作业必须双人在场(单人封箱无法互相监督)。
true,
);
/// 构造博物馆店的门店等级配置规格。
///
/// ## 为什么这个规格不能写成 const(一处必须说明的限制)
///
/// 因为它含两个 Vec(标准班次列表、合规备注),
/// 而 Vec 无法在 const 上下文里构造。
/// 因此工程外必须在运行期调用一次 register_grade_spec。
///
/// 这不是设计缺陷,而是「配置里含动态集合」的必然结果。
/// 重要的是这个限制被写出来了——
/// 若一个「可扩展接口」的调用条件没被写清,使用者会以为它坏了。
fn build_museum_grade_spec() -> StoreGradeProfileSpec {
StoreGradeProfileSpec::new(
STORE_GRADE_MUSEUM,
// 每班 3 人(博物馆店以接待与讲解为主,客流低)。
3,
// 不配专职安保(由馆方安保覆盖)。
false,
ClockTime::from_hour_and_minute(9, 30),
ClockTime::from_hour_and_minute(21, 0),
// 不要求每日盘点(贵重展品由馆方单独盘点)。
false,
// 标准班次:早班 + 晚班 + 工程外新增的封箱夜班。
vec![SHIFT_CODE_EARLY, SHIFT_CODE_LATE, SHIFT_CODE_NIGHT_SEAL],
vec![
"博物馆店须配合馆方闭馆时间,不得超时留人",
"封箱夜班须双人在场并录像留档",
],
)
}
/// 构造第八幕的扩展门店表(2 家博物馆店 + 1 家旗舰店 + 1 家标准店)。
///
/// 参数无。返回:门店表。
///
/// 保留两家内置等级的门店,是为了让报表里同时出现
/// 「内置类型」与「工程外类型」的槽位——
/// 若只有新等级,读者无法判断统计是否漏掉了内置类型。
fn build_extension_stores() -> Vec<StoreProfile> {
let mut stores: Vec<StoreProfile> = Vec::new();
// 2 家博物馆店(工程外等级)。
for local_index in 0..2usize {
let store_code = StoreCode::new(
leak_to_static(format!("MS{:04}", local_index + 1)),
"中国香港",
);
// 名单:1 名技师、3 名高级(覆盖早/晚/封箱夜班)、2 名初级、1 名试用期。
let roster: Vec<StaffMember> = build_roster_with_plan(
store_code,
(1, 3, 2, 1),
'M',
local_index + 1,
);
stores.push(StoreProfile::new(
store_code,
STORE_GRADE_MUSEUM,
// 博物馆店指数 1.05(略高于基准,因需多语言接待)。
Ratio::from_basis_points(10_500),
roster,
));
}
// 1 家旗舰店 + 1 家标准店(内置等级),作为对照。
stores.push(build_extension_store_of_grade(
STORE_GRADE_FLAGSHIP,
"LF0001",
"中国香港",
11_500,
(2, 4, 4, 2),
));
stores.push(build_extension_store_of_grade(
STORE_GRADE_STANDARD,
"LF0031",
"深圳",
10_000,
(1, 3, 3, 1),
));
stores
}
/// 构造一家指定等级的门店(扩展幕专用)。
fn build_extension_store_of_grade(
grade: StoreGrade,
store_code_text: &str,
city_name: &'static str,
pay_index_basis_points: i64,
plan: RosterPlan,
) -> StoreProfile {
let store_code: StoreCode = StoreCode::new(leak_to_static(store_code_text.to_string()), city_name);
let prefix_character: char = if grade == STORE_GRADE_FLAGSHIP { 'F' } else { 'S' };
let roster: Vec<StaffMember> =
build_roster_with_plan(store_code, plan, prefix_character, 1);
StoreProfile::new(
store_code,
grade,
Ratio::from_basis_points(pay_index_basis_points),
roster,
)
}
/// 构造第七幕的破损门店表。
///
/// 返回:含四类门店的门店表。
///
/// | 门店 | 破损类型 |
/// |---|---|
/// | 机场店 ×2 | 等级未登记 → 整店跳过 |
/// | 港口店 ×1 | 等级已登记,但配置里引用了未登记时刻表的班次 |
/// | 社区店(只有试用期员工)×1 | 本店无合格员工 |
/// | 旗舰店 ×1 | 正常(对照组,证明「好的照排」) |
fn build_broken_stores() -> Vec<StoreProfile> {
let mut stores: Vec<StoreProfile> = Vec::new();
// 机场店:等级根本没登记(连配置都没有)。
for local_index in 0..2usize {
let store_code = StoreCode::new(
leak_to_static(format!("AP{:04}", local_index + 1)),
"广州",
);
let roster: Vec<StaffMember> =
build_roster_with_plan(store_code, (1, 3, 2, 1), 'A', local_index + 1);
stores.push(StoreProfile::new(
store_code,
STORE_GRADE_AIRPORT,
Ratio::from_basis_points(12_000),
roster,
));
}
// 港口店:等级已登记,但其标准班次里有一个班次未登记时刻表。
let harbor_code = StoreCode::new("HB0001", "青岛");
let harbor_roster: Vec<StaffMember> =
build_roster_with_plan(harbor_code, (1, 3, 2, 1), 'H', 1);
stores.push(StoreProfile::new(
harbor_code,
STORE_GRADE_HARBOR,
Ratio::from_basis_points(10_500),
harbor_roster,
));
// 人力缺口店:社区店,但名单里只有试用期员工,
// 而社区店的班次要求「高级」——因此每个槽位都排不出人。
let thin_code = StoreCode::new("CM0001", "惠州");
let thin_roster: Vec<StaffMember> =
build_roster_with_plan(thin_code, (0, 0, 0, 3), 'T', 1);
stores.push(StoreProfile::new(
thin_code,
STORE_GRADE_COMMUNITY,
// 指数取到下限 0.80,突出「人少且成本低」这个无关变量。
Ratio::from_basis_points(8_000),
thin_roster,
));
// 正常旗舰店:对照组。
stores.push(build_extension_store_of_grade(
STORE_GRADE_FLAGSHIP,
"LF0001",
"中国香港",
11_500,
(2, 4, 4, 2),
));
stores
}
/// 按「编制计划」构造员工名单(供扩展区与破损输入复用)。
///
/// 参数 store_code:门店编码;plan:各技能等级人数;
/// prefix_character:工号前缀字母;store_serial:工号里的门店序号。
/// 返回:员工名单。
///
/// 与 build_roster 的差别只在于「工号前缀与序号怎么来」——
/// 主流程按门店等级与索引取,扩展区按显式参数给。
/// 抽成公共函数而不是复制一份:名单的构造规则只应有一处实现,
/// 否则两处的技能等级顺序、津贴口径会各自漂移。
fn build_roster_with_plan(
store_code: StoreCode,
plan: RosterPlan,
prefix_character: char,
store_serial: usize,
) -> Vec<StaffMember> {
let grade_layers: [(SkillGrade, u32, i64); 4] = [
(SKILL_GRADE_TECHNICIAN, plan.0, 800),
(SKILL_GRADE_SENIOR, plan.1, 500),
(SKILL_GRADE_JUNIOR, plan.2, 200),
(SKILL_GRADE_PROBATION, plan.3, 0),
];
let mut roster: Vec<StaffMember> = Vec::new();
let mut member_serial: usize = 0;
for (skill_grade, member_count, allowance_minor_units) in grade_layers {
for _ in 0..member_count {
member_serial += 1;
let staff_text: String =
format!("{}{:03}{:03}", prefix_character, store_serial, member_serial);
let staff_number: StaffNumber = StaffNumber::from_text(leak_to_static(staff_text))
.unwrap_or_else(|| panic!("工号格式非法:{} 第 {} 位", store_code.code(), member_serial));
roster.push(StaffMember::new(
staff_number,
skill_grade,
store_code,
CurrencyAmount::from_minor_units(allowance_minor_units, CURRENCY_CHINESE_YUAN),
));
}
}
roster
}
/// 本文件里被引用但仅用于「存在性证明」的常量清单。
///
/// ## 这个函数为什么存在
///
/// SHIFT_CODE_MIDDLE、SHIFT_CODE_WEEKEND_BOOST、PAY_PERIOD_HOLIDAY、
/// PAY_PERIOD_PROMOTION、SKILL_GRADE_PROBATION… 这些常量在
/// 主流程里是通过数据(门店等级配置的班次列表、请求的节假日表)
/// 间接使用的,代码里没有直接写它们的名字。
///
/// 但它们不能删(内置登记表要用),也不该靠 #[allow(dead_code)] 掩盖。
/// 本函数把它们显式引用一次,作用是:
///
/// 1. 消除「未使用」告警(真修,而不是压制);
/// 2. 更重要的是——在代码里留下「哪些标签是本工程已知全部取值」的清单。
/// 将来新增班次时,这一屏会提醒扩展者「内置集合在这里」。
///
/// 返回:这些常量的展示名列表(供调用方按需打印)。
fn known_label_snapshot() -> Vec<&'static str> {
vec![
SHIFT_CODE_EARLY.display_name(),
SHIFT_CODE_MIDDLE.display_name(),
SHIFT_CODE_LATE.display_name(),
SHIFT_CODE_NIGHT_AUDIT.display_name(),
SHIFT_CODE_WEEKEND_BOOST.display_name(),
SKILL_GRADE_PROBATION.display_name(),
SKILL_GRADE_JUNIOR.display_name(),
SKILL_GRADE_SENIOR.display_name(),
SKILL_GRADE_TECHNICIAN.display_name(),
STORE_GRADE_FLAGSHIP.display_name(),
STORE_GRADE_STANDARD.display_name(),
STORE_GRADE_COMMUNITY.display_name(),
PAY_PERIOD_HOLIDAY.display_name(),
PAY_PERIOD_PROMOTION.display_name(),
]
}
/// 打印「已知标签清单」并断言其为非空。
///
/// 这是一个自检函数:它的输出不参与业务,但它保证了
/// known_label_snapshot 不被优化掉,也保证了「内置标签集合」
/// 在运行期确实可见。
fn print_known_labels() {
let labels: Vec<&'static str> = known_label_snapshot();
debug_assert!(!labels.is_empty(), "已知标签清单不应为空");
print_section("附:本工程内置标签全集(自检用)");
let mut remaining: Vec<&str> = labels;
remaining.sort_unstable();
for chunk in remaining.chunks(5) {
println!(" · {}", chunk.join(" / "));
}
println!(" · 共 {} 个内置标签;工程外新增的标签不在本清单内,因此本清单可用来快速核对「哪些是内置的」。", remaining.len());
println!(
" · 提示行(用于让 self-check 行与报表宽度一致):{}",
bullet_line("内置标签总数", &known_label_snapshot().len().to_string(), 4)
);
println!(
" · 字节量级参照:{} 与 {}",
byte_count_text(std::mem::size_of::<ShiftTemplate>()),
key_value_line("单槽位外在状态", 18, &byte_count_text(std::mem::size_of::<client::ShiftSlot>()))
);
}
输出:

哲学管理(学)人生, 文学艺术生活, 自动(计算机学)物理(学)工作, 生物(学)化学逆境, 历史(学)测绘(学)时间, 经济(学)数学金钱(理财), 心理(学)医学情绪, 诗词美容情感, 美学建筑(学)家园, 解构建构(分析)整合学习, 智商情商(IQ、EQ)运筹(学)生存.---Geovin Du(涂聚文)
浙公网安备 33010602011771号