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 : coverage_audit.rs
//! # 排班合规体检 —— 「一次报全」的检查器
//!
//! ## 设计要点一:全部规则用同一个签名
//!
//! 每条规则都是一个 `(&ShiftPlan, &mut Vec<CoverageAuditEntry>) -> ()` 形态
//! 的检查函数,**从不提前 `return`**,也从不短路。
//!
//! 于是无论发现多少问题,报表都能一次列全。运营改一次配置就能重跑,
//! 不必「修一个、跑一次、再发现一个」。
//!
//! 这条经验在 Builder 工程里已被验证(`check_*` 统一签名 + 不提前返回),
//! 本工程沿用。
//!
//! ## 设计要点二:规则里包含「模式自身是否生效」
//!
//! 大多数规则检查业务(人手够不够、加班超没超)。
//! 但本工程额外加了一条**架构规则**:
//!
//! > `SHARING_EFFECTIVE` —— 平均共享倍数不低于 100。
//!
//! 它把「享元有没有真的在共享」变成一条**可自动检查的合规项**。
//! 若某次改动让键变细(例如有人往键里加了日期),
//! 这条规则会立刻在报表里报「不通过」——
//! **把架构退化变成红灯,而不是等某天内存爆掉才发现。**
//!
//! 这是本工程认为最值得带走的一条实践:
//! **好的架构约束应当能被写成自动断言,而不是留在设计文档里。**
//!
//! ## 设计要点三:严重度只用三档,且与处理动作一一对应
//!
//! | 档次 | 处理动作 |
//! |---|---|
//! | 阻断 | 必须改,否则不允许发布 |
//! | 警告 | 建议改,可人工确认后发布 |
//! | 提示 | 知悉即可 |
//!
//! 不使用更多档次:档次数应由**处理路径数**决定(见 `ComplianceSeverity` 文档)。
use crate::client::{ShiftPlan, ShiftSlot};
use crate::domain::{ComplianceSeverity, SHIFT_CODE_NIGHT_AUDIT};
use crate::support::text_layout::display_width;
/// 单槽位加班的告警阈值(分钟)。
///
/// 90 分钟 = 1.5 小时。这是**本工程选定的管理口径**,
/// 不是法定值(各地法定上限不同)。放在本文件而不是 `domain`,
/// 因为它是「本工程的检查政策」而非普适领域事实。
pub const OVERTIME_WARNING_THRESHOLD_MINUTES: u32 = 90;
/// 平均共享倍数的合规下限(万分点,即 100 倍)。
///
/// 见模块文档「设计要点二」。
pub const MINIMUM_AVERAGE_SHARE_BASIS_POINTS: i64 = 100 * 10_000;
/// 一条体检结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoverageAuditEntry {
/// 规则编码(稳定标识,便于报表比对两次运行)。
pub rule_code: &'static str,
/// 规则描述。
pub rule_description: &'static str,
/// 严重度。
pub severity: ComplianceSeverity,
/// 是否通过。
pub passed: bool,
/// 结论说明(含具体数字,便于定位)。
pub conclusion: String,
}
impl CoverageAuditEntry {
/// 构造一条通过的结果。
fn pass(
rule_code: &'static str,
rule_description: &'static str,
severity: ComplianceSeverity,
conclusion: String,
) -> CoverageAuditEntry {
CoverageAuditEntry {
rule_code,
rule_description,
severity,
passed: true,
conclusion,
}
}
/// 构造一条未通过的结果。
fn fail(
rule_code: &'static str,
rule_description: &'static str,
severity: ComplianceSeverity,
conclusion: String,
) -> CoverageAuditEntry {
CoverageAuditEntry {
rule_code,
rule_description,
severity,
passed: false,
conclusion,
}
}
/// 报表里的结果标记(通过 / 不通过)。
pub const fn result_marker(&self) -> &'static str {
if self.passed {
"通过"
} else {
"未通过"
}
}
/// 未通过时,本项是否阻断发布。
pub const fn blocks_publication_when_failed(&self) -> bool {
// 通过时永不阻断;未通过时看严重度。
!self.passed && self.severity.blocks_publication()
}
/// 结论文本的显示宽度(供报表对齐使用)。
pub fn conclusion_display_width(&self) -> usize {
display_width(&self.conclusion)
}
}
/// 一份体检报告。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoverageAuditReport {
/// 全部规则的结果(按定义顺序)。
pub entries: Vec<CoverageAuditEntry>,
}
impl CoverageAuditReport {
/// 规则总数。
pub fn total_count(&self) -> usize {
self.entries.len()
}
/// 通过数。
pub fn passed_count(&self) -> usize {
self.entries.iter().filter(|entry| entry.passed).count()
}
/// 未通过数。
pub fn failed_count(&self) -> usize {
self.entries.iter().filter(|entry| !entry.passed).count()
}
/// 指定严重度的未通过项数。
pub fn failed_count_of_severity(&self, severity: ComplianceSeverity) -> usize {
self.entries
.iter()
.filter(|entry| !entry.passed && entry.severity == severity)
.count()
}
/// 是否存在阻断项。
pub fn has_blocker(&self) -> bool {
self.entries
.iter()
.any(CoverageAuditEntry::blocks_publication_when_failed)
}
/// 是否全部通过。
pub fn is_fully_compliant(&self) -> bool {
self.failed_count() == 0
}
/// 未通过项的描述文本列表(供结论行使用)。
pub fn failed_descriptions(&self) -> Vec<&'static str> {
self.entries
.iter()
.filter(|entry| !entry.passed)
.map(|entry| entry.rule_description)
.collect()
}
}
/// 对一份排班方案执行全部合规规则。
///
/// 参数 `plan`:排班方案。
/// 返回:体检报告(**含全部规则的结果,无论通过与否**)。
///
/// 每一条规则都在这一个函数体里被调用一次,顺序即报表顺序。
/// 把调用顺序集中在一处,是为了让「新增规则要加在哪」有唯一答案。
pub fn audit_coverage(plan: &ShiftPlan) -> CoverageAuditReport {
let mut entries: Vec<CoverageAuditEntry> = Vec::new();
// 规则 1~3:装配完整性(阻断级)。
check_no_unregistered_shift(plan, &mut entries);
check_no_unregistered_store_grade(plan, &mut entries);
check_no_staffing_gap(plan, &mut entries);
// 规则 4:享元池健康(警告级)。
check_template_pool_not_exhausted(plan, &mut entries);
// 规则 5:模式有效性(警告级)——本工程特有的架构断言。
check_sharing_effective(plan, &mut entries);
// 规则 6~7:排班业务规则(警告 / 提示级)。
check_no_double_booking(plan, &mut entries);
check_overtime_within_threshold(plan, &mut entries);
// 规则 8:盘点夜班已排入(提示级)。
check_night_audit_scheduled(plan, &mut entries);
CoverageAuditReport { entries }
}
/// 规则 1:所有班次模板均已登记(无漏排)。
fn check_no_unregistered_shift(plan: &ShiftPlan, entries: &mut Vec<CoverageAuditEntry>) {
const RULE_CODE: &str = "NO_UNREGISTERED_SHIFT";
const DESCRIPTION: &str = "全部班次均已在模板工厂登记";
if !plan.has_unregistered_shift_gap() {
entries.push(CoverageAuditEntry::pass(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Blocker,
format!("{} 个槽位全部取到模板", plan.slot_count()),
));
return;
}
// 未通过:把每个未登记的班次编码都列出来(而不是只报总数),
// 这样运营能直接照着去补登记,不必反查。
let details: Vec<String> = plan
.unregistered_shift_records()
.iter()
.map(|(shift_code, count)| format!("{}({} 次)", shift_code, count))
.collect();
entries.push(CoverageAuditEntry::fail(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Blocker,
format!("未登记班次:{}", details.join("、")),
));
}
/// 规则 2:所有门店等级均已登记配置。
fn check_no_unregistered_store_grade(plan: &ShiftPlan, entries: &mut Vec<CoverageAuditEntry>) {
const RULE_CODE: &str = "NO_UNREGISTERED_STORE_GRADE";
const DESCRIPTION: &str = "全部门店等级均已登记配置";
if plan.unregistered_store_grade_count() == 0 {
entries.push(CoverageAuditEntry::pass(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Blocker,
format!("{} 家门店均取到等级配置", plan.store_count()),
));
return;
}
entries.push(CoverageAuditEntry::fail(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Blocker,
format!("{} 家门店因等级未登记被整体跳过", plan.unregistered_store_grade_count()),
));
}
/// 规则 3:无人力缺口(每槽位都排到了合格员工)。
fn check_no_staffing_gap(plan: &ShiftPlan, entries: &mut Vec<CoverageAuditEntry>) {
const RULE_CODE: &str = "NO_STAFFING_GAP";
const DESCRIPTION: &str = "每个槽位都排到了具备资质的员工";
if plan.staffing_gap_count() == 0 {
entries.push(CoverageAuditEntry::pass(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Blocker,
format!("{} 个槽位均已排人", plan.slot_count()),
));
return;
}
entries.push(CoverageAuditEntry::fail(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Blocker,
format!("{} 个槽位因本店无对应技能等级员工而空缺", plan.staffing_gap_count()),
));
}
/// 规则 4:享元池未溢出。
fn check_template_pool_not_exhausted(plan: &ShiftPlan, entries: &mut Vec<CoverageAuditEntry>) {
const RULE_CODE: &str = "TEMPLATE_POOL_NOT_EXHAUSTED";
const DESCRIPTION: &str = "班次模板享元池未触及容量上限";
let snapshot = plan.template_pool_snapshot();
if !plan.is_template_pool_exhausted() {
let limit_text: String = match snapshot.entry_limit {
Some(limit) => format!("(上限 {})", limit),
// 不限容量的池也要说清楚,否则读者不知道「未溢出」是因为
// 「真的没溢出」还是「压根没有上限」。
None => "(未设上限)".to_string(),
};
entries.push(CoverageAuditEntry::pass(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Warning,
format!(
"池内 {} 个模板,从未拒绝新建{}",
snapshot.distinct_entry_count, limit_text
),
));
return;
}
entries.push(CoverageAuditEntry::fail(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Warning,
format!(
"池已满(上限 {},实际需要 {} 个不同键)",
snapshot.entry_limit.unwrap_or(0),
snapshot.distinct_entry_count
),
));
}
/// 规则 5:享元共享确实生效(架构断言)。
fn check_sharing_effective(plan: &ShiftPlan, entries: &mut Vec<CoverageAuditEntry>) {
const RULE_CODE: &str = "SHARING_EFFECTIVE";
const DESCRIPTION: &str = "班次模板的平均共享倍数达标(享元生效)";
let snapshot = plan.template_pool_snapshot();
let average_share: i64 = snapshot.average_share_multiplier_basis_points();
if average_share >= MINIMUM_AVERAGE_SHARE_BASIS_POINTS {
entries.push(CoverageAuditEntry::pass(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Warning,
format!(
"{} 个槽位共享 {} 个模板,平均 {}.{:04} 倍",
plan.slot_count(),
snapshot.distinct_entry_count,
average_share / 10_000,
average_share % 10_000
),
));
return;
}
entries.push(CoverageAuditEntry::fail(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Warning,
format!(
"平均共享仅 {}.{:04} 倍(下限 {} 倍),键可能过细",
average_share / 10_000,
average_share % 10_000,
MINIMUM_AVERAGE_SHARE_BASIS_POINTS / 10_000
),
));
}
/// 规则 6:无同一员工同日重复排班。
///
/// ## 实现:用排序 + 相邻比较,而不是哈希集合
///
/// 槽位已经有确定性序号,但序号**不含员工维度**的排序意义。
/// 因此这里构造 `(员工, 日期)` 的排序键,排序后检查相邻是否重复。
///
/// 复杂度 O(n log n),n = 1.2 万,可接受。
/// 用哈希集合是 O(n),但集合的迭代顺序不确定,
/// 会让「哪个员工冲突」这个诊断输出不可复现——
/// 而本工程要求输出可复现(便于核对)。**正确性优先于常数级性能。**
fn check_no_double_booking(plan: &ShiftPlan, entries: &mut Vec<CoverageAuditEntry>) {
const RULE_CODE: &str = "NO_DOUBLE_BOOKING";
const DESCRIPTION: &str = "无员工在同一天被排入两个班次";
// 构造排序键:员工工号文本 + 日期文本。
let mut assignment_keys: Vec<(String, String, String)> = plan
.slots()
.iter()
.map(|slot: &ShiftSlot| {
(
slot.staff_number().text().to_string(),
slot.shift_date().formatted(),
slot.slot_code_text(),
)
})
.collect();
assignment_keys.sort();
// 排序后检查相邻两条是否「同员工 + 同日期」。
// 记录冲突的员工与日期(去重后最多列几个,避免报表爆炸)。
let mut conflicts: Vec<String> = Vec::new();
for window in assignment_keys.windows(2) {
let (left_staff, left_date, _) = &window[0];
let (right_staff, right_date, _) = &window[1];
if left_staff == right_staff && left_date == right_date {
let conflict_text: String = format!("{}@{}", left_staff, left_date);
// 去重(相邻窗口可能对同一冲突重复报告)。
if !conflicts.contains(&conflict_text) {
conflicts.push(conflict_text);
}
}
}
if conflicts.is_empty() {
entries.push(CoverageAuditEntry::pass(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Warning,
format!("全部 {} 个槽位无同日冲突", plan.slot_count()),
));
return;
}
// 报前 3 个冲突即可(其余用「等 N 项」概括),避免报表行过长。
let shown: Vec<String> = conflicts.iter().take(3).cloned().collect();
let suffix: String = if conflicts.len() > shown.len() {
format!(" 等 {} 项", conflicts.len())
} else {
String::new()
};
entries.push(CoverageAuditEntry::fail(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Warning,
format!("{}{}", shown.join("、"), suffix),
));
}
/// 规则 7:单槽位加班未超阈值。
fn check_overtime_within_threshold(plan: &ShiftPlan, entries: &mut Vec<CoverageAuditEntry>) {
const RULE_CODE: &str = "OVERTIME_WITHIN_THRESHOLD";
const DESCRIPTION: &str = "单槽位加班时长未超过管理阈值";
let mut exceeding_count: u32 = 0;
let mut maximum_overtime: u32 = 0;
for slot in plan.slots() {
let overtime: u32 = slot.overtime_minutes();
// 记录最大值用于报表(即使没超阈值也报出来,让读者知道余量)。
if overtime > maximum_overtime {
maximum_overtime = overtime;
}
if overtime > OVERTIME_WARNING_THRESHOLD_MINUTES {
exceeding_count += 1;
}
}
if exceeding_count == 0 {
entries.push(CoverageAuditEntry::pass(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Warning,
format!(
"最长加班 {} 分钟(阈值 {} 分钟),合计加班 {} 分钟",
maximum_overtime,
OVERTIME_WARNING_THRESHOLD_MINUTES,
plan.total_overtime_minutes()
),
));
return;
}
entries.push(CoverageAuditEntry::fail(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Warning,
format!(
"{} 个槽位加班超过 {} 分钟(最长 {} 分钟)",
exceeding_count, OVERTIME_WARNING_THRESHOLD_MINUTES, maximum_overtime
),
));
}
/// 规则 8:要求每日盘点的门店确实排入了盘点夜班。
fn check_night_audit_scheduled(plan: &ShiftPlan, entries: &mut Vec<CoverageAuditEntry>) {
const RULE_CODE: &str = "NIGHT_AUDIT_SCHEDULED";
const DESCRIPTION: &str = "要求盘点的门店已排入盘点夜班";
// 统计盘点夜班槽位数。
let audit_slot_count: usize = plan
.slots()
.iter()
.filter(|slot| slot.template().shift_code() == SHIFT_CODE_NIGHT_AUDIT)
.count();
if audit_slot_count > 0 {
entries.push(CoverageAuditEntry::pass(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Information,
format!("共排入 {} 个盘点夜班槽位", audit_slot_count),
));
return;
}
entries.push(CoverageAuditEntry::fail(
RULE_CODE,
DESCRIPTION,
ComplianceSeverity::Information,
"本区间内无盘点夜班(若区间不含盘点日则属正常)".to_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 : memory_comparison.rs
//! # 成员内存对照 —— 「共享省了多少」的定量回答
//!
//! ## 这是本工程的验收核心
//!
//! 前面的层把享元建起来了,但「建起来了」不等于「有价值」。
//! 本模块负责回答那个唯一重要的问题:
//!
//! > **共享后占多少字节?不共享要占多少?差多少?**
//!
//! ## 对照口径(必须逐项说清,否则数字不可信)
//!
//! 设:槽位数 `N`,模板池实例数 `D_t`,等级池实例数 `D_g`。
//!
//! ```text
//! 共享侧 = 模板池内容(Σ B_i) + 等级池内容(Σ G_j)
//! + 两池的控制块(D_t + D_g) × 16
//! + 全部持有句柄 N × 8
//! + 槽位自身外在状态 N × size_of::<ShiftSlot>()
//!
//! 独立侧 = N × (size_of::<ShiftSlot>() ← 外在状态(两版本相同)
//! + 模板副本字节 ← 每个槽位一份
//! + 门店配置副本字节) ← 每个槽位一份
//! ```
//!
//! 注意「槽位自身外在状态」**两版本相同**,因此在差额中抵消。
//! 报告里仍要把它列出来,因为读者需要知道「总额里有多少是外在状态」——
//! 若不列出,读者会以为差额就是全部内存。
//!
//! ## 一处诚实声明:为什么「独立侧」需要外部传入字节数
//!
//! 「不共享的槽位长什么样」是本工程**必须自己假设**的——
//! 它不是一个真实存在的类型(工程里只有共享版本)。
//! 若在本模块里凭空写一个数字,那这个数字就是编的。
//!
//! 本工程的处理:把「不共享的槽位」**真的实现出来**
//! (见 `main.rs` 末尾的工程外扩展区 `UnsharedShiftDefinition` /
//! `UnsharedStoreGradeConfig`),让它也实现字节统计,
//! 然后把它的实测值传进本模块。
//!
//! 于是对照的两侧都是**可编译、可调用的真实类型**,
//! 差异只来自「有没有共享」这一件事。
//! **这比在两个魔数之间算减法有意义得多。**
use crate::client::ShiftPlan;
use crate::domain::{CurrencyAmount, Ratio};
/// 「不共享」时每个槽位额外承载的副本字节数。
///
/// 由调用方从真实的不共享类型实测得到(见模块文档的诚实声明)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnsharedSlotOverhead {
/// 每个槽位若各自持有一份班次定义,需要多少字节。
pub template_copy_bytes: usize,
/// 每个槽位若各自持有一份门店等级配置副本,需要多少字节。
pub store_grade_copy_bytes: usize,
}
impl UnsharedSlotOverhead {
/// 构造对照开销。
pub const fn new(
template_copy_bytes: usize,
store_grade_copy_bytes: usize,
) -> UnsharedSlotOverhead {
UnsharedSlotOverhead {
template_copy_bytes,
store_grade_copy_bytes,
}
}
/// 每个槽位额外承载的副本总量。
pub const fn total_per_slot(&self) -> usize {
self.template_copy_bytes
.saturating_add(self.store_grade_copy_bytes)
}
}
/// 共享侧与独立侧的内存对照结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MemoryComparison {
/// 槽位总数。
pub slot_count: usize,
/// 单个槽位外在状态自身的字节数(两版本相同,用于报告构成)。
pub slot_intrinsic_bytes: usize,
/// 共享侧:模板池内容字节。
pub shared_template_content_bytes: usize,
/// 共享侧:等级池内容字节。
pub shared_store_grade_content_bytes: usize,
/// 共享侧:句柄与控制块字节(两池合计)。
pub shared_handle_bytes: usize,
/// 共享侧:槽位外在状态总字节。
pub shared_slot_state_bytes: usize,
/// 独立侧:模板副本字节。
pub unshared_template_copy_bytes: usize,
/// 独立侧:门店配置副本字节。
pub unshared_store_grade_copy_bytes: usize,
/// 独立侧:槽位外在状态总字节(与共享侧同值)。
pub unshared_slot_state_bytes: usize,
}
impl MemoryComparison {
/// 由排班方案与对照开销构造。
///
/// 参数 `plan`:排班方案;`overhead`:不共享时的每槽位副本字节。
/// 返回:对照结果。
pub fn from_plan(plan: &ShiftPlan, overhead: UnsharedSlotOverhead) -> MemoryComparison {
let slot_count: usize = plan.slot_count();
// 单个槽位的栈内字节数。`ShiftSlot` 无堆字段(见其文档),
// 因此 `size_of` 即全部。
let slot_intrinsic_bytes: usize = std::mem::size_of::<crate::client::ShiftSlot>();
let template_snapshot = plan.template_pool_snapshot();
let grade_snapshot = plan.grade_pool_snapshot();
MemoryComparison {
slot_count,
slot_intrinsic_bytes,
shared_template_content_bytes: template_snapshot.intrinsic_bytes_total,
shared_store_grade_content_bytes: grade_snapshot.intrinsic_bytes_total,
// 句柄与控制块合计:两个池各自报出的句柄侧字节。
shared_handle_bytes: template_snapshot
.handle_bytes_total
.saturating_add(grade_snapshot.handle_bytes_total),
shared_slot_state_bytes: slot_count.saturating_mul(slot_intrinsic_bytes),
unshared_template_copy_bytes: slot_count
.saturating_mul(overhead.template_copy_bytes),
unshared_store_grade_copy_bytes: slot_count
.saturating_mul(overhead.store_grade_copy_bytes),
unshared_slot_state_bytes: slot_count.saturating_mul(slot_intrinsic_bytes),
}
}
/// 共享侧总字节。
pub const fn shared_total_bytes(&self) -> usize {
self.shared_template_content_bytes
.saturating_add(self.shared_store_grade_content_bytes)
.saturating_add(self.shared_handle_bytes)
.saturating_add(self.shared_slot_state_bytes)
}
/// 独立侧总字节。
pub const fn unshared_total_bytes(&self) -> usize {
self.unshared_template_copy_bytes
.saturating_add(self.unshared_store_grade_copy_bytes)
.saturating_add(self.unshared_slot_state_bytes)
}
/// 节省字节数(独立 - 共享)。可能为负。
///
/// ## 为什么允许负数并如实返回
///
/// 存在共享反而更费内存的情形:实例极小(如一个 `u32`)
/// 或共享倍数极低(每个实例只被引用一次)时,
/// `Rc` 的控制块(16 字节)+ 指针(8 字节)会超过副本本身。
///
/// 若把负数钳到 0,本模块就只会「报喜」——
/// 而那会掩盖「这个场景不该用享元」这个**最有价值的结论**。
/// 第四幕的「键过细」场景会真的产生一个趋近于零、甚至为负的节省率,
/// 本方法的返回值必须如实反映它。
pub const fn saved_bytes(&self) -> i64 {
self.unshared_total_bytes() as i64 - self.shared_total_bytes() as i64
}
/// 节省比例(万分点)。节省为负时返回负值。
pub fn saved_ratio_basis_points(&self) -> i64 {
let unshared_total: usize = self.unshared_total_bytes();
if unshared_total == 0 {
return 0;
}
let numerator: i128 = self.saved_bytes() as i128 * 10_000i128;
(numerator / unshared_total as i128) as i64
}
/// 只考虑「享元共享的那部分」的节省比例(万分点)。
///
/// ## 为什么需要这个「更聚焦」的比率
///
/// 总节省率被**外在状态**稀释了:无论是否共享,
/// 每个槽位都要存门店、日期、员工(约 100 字节)。
/// 1.2 万槽位就是 1.2MB 的「常量开销」,
/// 它让「模板部分的节省」看起来没那么惊人。
///
/// 而本模式真正作用的地方是**模板部分**。两个比率都要报:
/// - **总节省率**回答「这个方案省了多少内存」(运营关心);
/// - **可共享部分节省率**回答「享元这个机制本身的效果有多强」(架构关心)。
///
/// 只报一个都会让读者产生误解。
pub fn saved_ratio_of_shareable_basis_points(&self) -> i64 {
let unshared_shareable: usize = self
.unshared_template_copy_bytes
.saturating_add(self.unshared_store_grade_copy_bytes);
if unshared_shareable == 0 {
return 0;
}
let shared_shareable: usize = self
.shared_template_content_bytes
.saturating_add(self.shared_store_grade_content_bytes)
.saturating_add(self.shared_handle_bytes);
let saved: i64 = unshared_shareable as i64 - shared_shareable as i64;
let numerator: i128 = saved as i128 * 10_000i128;
(numerator / unshared_shareable as i128) as i64
}
/// 每个槽位的平均节省字节数(有符号)。
pub const fn saved_bytes_per_slot(&self) -> i64 {
if self.slot_count == 0 {
return 0;
}
self.saved_bytes() / self.slot_count as i64
}
/// 外在状态在共享侧总内存中的占比(万分点)。
///
/// 用于回答「共享之后,剩下的内存主要花在哪」——
/// 若这个比率很高(如 > 90%),说明**再优化享元已经没有意义**,
/// 该去优化外在状态(例如把门店编码换成索引)。
/// 这是一个能指导下一步工作的数字。
pub fn slot_state_ratio_basis_points(&self) -> i64 {
let shared_total: usize = self.shared_total_bytes();
if shared_total == 0 {
return 0;
}
let numerator: i128 = self.shared_slot_state_bytes as i128 * 10_000i128;
(numerator / shared_total as i128) as i64
}
/// 用万分点表示的两个比率,方便报表直接打印。
pub fn saved_ratio_as_ratio(&self) -> Ratio {
Ratio::from_basis_points(self.saved_ratio_basis_points())
}
/// 共享侧总字节对应的「每槽位平均字节数」。
pub fn shared_bytes_per_slot(&self) -> usize {
if self.slot_count == 0 {
return 0;
}
self.shared_total_bytes() / self.slot_count
}
/// 独立侧总字节对应的「每槽位平均字节数」。
pub fn unshared_bytes_per_slot(&self) -> usize {
if self.slot_count == 0 {
return 0;
}
self.unshared_total_bytes() / self.slot_count
}
/// 供报表使用的「节省金额」类比说明:
/// 返回节省的字节数折算成「多少个槽位自身大小」。
///
/// ## 为什么要有这个换算
///
/// 「省了 6.1 MB」这个数字对读者没有直觉。换成
/// 「相当于省下了 59,000 个槽位自身大小」,
/// 读者立刻能感受到量级。这是**把抽象数字锚定到已知单位**的常用手法,
/// 且换算所用的除数(`slot_intrinsic_bytes` = `size_of::<ShiftSlot>()` = 104)
/// 是实测值,不是估的。
///
/// ## 除数是谁,必须说清
///
/// 除数**不是** `unshared_bytes_per_slot()`(667 字节)——那个值还包含
/// 每个槽位各背一份的模板副本。用 104 还是 667 当除数,结果差 6 倍以上。
/// 报表标签因此必须写明除数,否则读者无法复算(本工程曾在此写错过标签)。
pub fn saved_equivalent_slot_count(&self) -> i64 {
if self.slot_intrinsic_bytes == 0 {
return 0;
}
self.saved_bytes() / self.slot_intrinsic_bytes as i64
}
/// 类型检查辅助:确认金额类型未在本模块被误用。
///
/// 本模块只做字节统计,不涉及金额。这个函数存在的唯一目的是
/// 让「本模块与金额无关」在类型层面有一个锚点——
/// 它返回一个恒为零的同币种金额,供第七幕清点时调用。
pub fn zero_amount_of(currency: crate::domain::Currency) -> CurrencyAmount {
CurrencyAmount::zero(currency)
}
}
//!# 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 : payroll_breakdown.rs
//! # 人力成本分布 —— 按任意维度分组
//!
//! ## 一个函数支持任意分组维度(这是刻意的设计)
//!
//! 本模块只有一个构造函数,它接收一个**标签提取闭包**:
//!
//! ```ignore
//! // 按计薪时段分组
//! let by_period = build_payroll_breakdown(&plan, |slot| {
//! slot.template().pay_period_kind().display_name().to_string()
//! });
//! // 按班次种类分组
//! let by_shift = build_payroll_breakdown(&plan, |slot| {
//! slot.template().shift_code().display_name().to_string()
//! });
//! // 按门店等级分组
//! let by_grade = build_payroll_breakdown(&plan, |slot| {
//! slot.grade_profile().grade().display_name().to_string()
//! });
//! ```
//!
//! **新增一个分析维度 = 新增一个闭包,而不是新增一个函数。**
//! 这是「分析层可无限扩展操作」这条要求的具体落实方式,
//! 也解释了为什么本模块不需要为每个维度各写一份聚合代码
//! (那样每加一个维度就要复制一遍累加逻辑,迟早漂移)。
//!
//! ## 为什么分组结果要按标签排序
//!
//! 因为本工程要求**两次运行的输出完全一致**(数字可核对)。
//! 而遍历槽位的顺序决定了各分组首次出现的顺序,
//! 若直接按插入顺序输出,报表的行序会随装配顺序变化。
//! 排序后输出稳定。
//!
//! 排序键用**标签文本**而不是金额:按金额排序会让报表行序随数据变化,
//! 反而更难对比两次运行。按文本排序则始终一致。
use crate::client::{ShiftPlan, ShiftSlot};
use crate::domain::{Currency, CurrencyAmount, Ratio};
/// 一个分组维度下的一行统计。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PayrollCategoryEntry {
/// 分组标签(由调用方的闭包提供)。
pub label: String,
/// 该组的槽位数。
pub slot_count: usize,
/// 该组的计薪分钟总数。
pub paid_minutes: u32,
/// 该组的正常工时成本(不含加班)。
pub labor_cost: CurrencyAmount,
/// 该组的加班成本。
pub overtime_cost: CurrencyAmount,
/// 该组成本占总额的比例(万分点)。
pub share_basis_points: i64,
}
impl PayrollCategoryEntry {
/// 该组的「每分钟总成本」(最小单位/分钟,向下取整)。
///
/// ## 口径:含加班
///
/// 分子是 `人力成本 + 加班成本`,与 [`PayrollBreakdown::average_cost_per_slot`]
/// 保持同一口径。本工程规定:
///
/// > **凡叫「平均成本」「单位成本」的派生指标,一律用「总成本」作分子。**
///
/// 理由:报表里已经有 `人力成本` 与 `加班成本` 两个**账目**列,
/// 若派生指标各选一个分子,读者就要去分辨「这个均值含不含加班」——
/// 而那是一个读报表时不该出现的负担。
/// 统一口径之后,派生指标只需记住一句话:**除数量,用总额**。
///
/// ## 为什么向下取整而不是四舍五入
///
/// 这是**派生指标**(不是账目),用于横向比较各组的单位成本。
/// 账目类数字必须精确(所以用统一舍入入口),
/// 而派生指标只要口径一致即可。向下取整的好处是
/// 「同一组数据算两次结果必然相同」,不必关心舍入边界。
///
/// 若把它当成账目用(比如拿它乘分钟数去核对总额),
/// 会因取整而有小额偏差——**这条限制必须在注释里讲明**,
/// 否则会有人拿它去对账。
pub fn cost_per_minute_minor_units(&self) -> i64 {
if self.paid_minutes == 0 {
return 0;
}
self.total_minor_units() / self.paid_minutes as i64
}
/// 该组的「每槽位平均总成本」。
///
/// 口径与 [`PayrollCategoryEntry::cost_per_minute_minor_units`] 一致:
/// 分子是 `人力成本 + 加班成本`。
///
/// 用整数除法(截断)而非金额缩放:这是**统计均值**,不是账目。
/// 用统一舍入入口反而会让人误以为它可以参与对账。
/// 「均值用截断、账目用统一舍入」——这个区分要一直保持。
pub fn average_cost_per_slot(&self) -> CurrencyAmount {
if self.slot_count == 0 {
return CurrencyAmount::zero(self.labor_cost.currency());
}
let quotient: i64 = self.total_minor_units() / self.slot_count as i64;
CurrencyAmount::from_minor_units(quotient, self.labor_cost.currency())
}
/// 该组总成本(人力 + 加班)的最小单位数。
///
/// 私有辅助:把「含加班的总额」这个口径**收在一处**。
/// 若让上面两个方法各写一遍 `labor_cost + overtime_cost`,
/// 将来若要改成「加三班津贴」,就会漏改一处——
/// 而漏改的那一处会静默地少算,且只体现在一个派生指标上,极难发现。
fn total_minor_units(&self) -> i64 {
// 同币种相加:两个字段由同一个分组构造过程写入,币种必然一致。
// 用 `saturating_add` 而非 `add()` 是为了让本方法保持 `i64` 返回类型,
// 不必处理 `Option`——这是**私有**方法,不变量由调用方保证。
self.labor_cost
.minor_units()
.saturating_add(self.overtime_cost.minor_units())
}
/// 该组占比(作为 `Ratio`)。
pub fn share_as_ratio(&self) -> Ratio {
Ratio::from_basis_points(self.share_basis_points)
}
}
/// 一份人力成本分布。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PayrollBreakdown {
/// 各分组行(按标签升序)。
pub entries: Vec<PayrollCategoryEntry>,
/// 正常工时成本总额。
pub labor_total: CurrencyAmount,
/// 加班成本总额。
pub overtime_total: CurrencyAmount,
/// 总成本。
pub total: CurrencyAmount,
/// 计薪分钟总数。
pub total_paid_minutes: u32,
/// 槽位总数。
pub slot_count: usize,
/// 分组维度的中文名(供报表标题使用,如「计薪时段」)。
pub dimension_label: &'static str,
}
impl PayrollBreakdown {
/// 分组数量。
pub fn category_count(&self) -> usize {
self.entries.len()
}
/// 平均每槽位成本。
pub fn average_cost_per_slot(&self) -> CurrencyAmount {
if self.slot_count == 0 {
return CurrencyAmount::zero(self.total.currency());
}
let quotient: i64 = self.total.minor_units() / self.slot_count as i64;
CurrencyAmount::from_minor_units(quotient, self.total.currency())
}
/// 平均每小时成本(即平均生效时薪)。
///
/// 实现:`总额 × 60 / 总分钟`,用统一入口折算而非自己写除法,
/// 保证与逐个槽位算出的单价口径一致。
pub fn average_cost_per_hour(&self) -> CurrencyAmount {
if self.total_paid_minutes == 0 {
return CurrencyAmount::zero(self.total.currency());
}
// 「总额 / 总小时数」= 总额 × 60 / 总分钟。
// 这里把 `× 60 / 总分钟` 表达为一次 `scale_by_minute_fraction` 的逆运算——
// 由于没有现成的逆函数,用 i128 显式表达并复用统一舍入入口。
let numerator: i128 = self.total.minor_units() as i128 * 60i128;
let quotient: i128 = crate::domain::divide_rounded_away_from_zero(
numerator,
self.total_paid_minutes as i128,
);
CurrencyAmount::from_minor_units(
crate::domain::clamp_i128_to_i64(quotient),
self.total.currency(),
)
}
/// 加班成本占总成本的比例(万分点)。
pub fn overtime_share_basis_points(&self) -> i64 {
let total: i64 = self.total.minor_units();
if total == 0 {
return 0;
}
let numerator: i128 = self.overtime_total.minor_units() as i128 * 10_000i128;
(numerator / total as i128) as i64
}
/// 币种。
pub const fn currency(&self) -> Currency {
self.total.currency()
}
}
/// 按任意维度聚合人力成本。
///
/// 参数 `plan`:排班方案;`dimension_label`:维度中文名(报表标题用);
/// `extract_label`:把槽位映射到分组标签的闭包。
/// 返回:成本分布。
///
/// ## 实现:一趟遍历,线性聚合
///
/// 用 `Vec` 线性查找而不是 `HashMap`:
/// - 分组数极少(本工程最多 5 类),线性查找比哈希更快;
/// - `HashMap` 的迭代顺序不确定,还要额外排序才能保证输出稳定;
/// - `Vec` 让「按标签排序」这一步的语义更清楚。
///
/// 若将来分组维度可能产生上千个类别(如按门店编码分组且有上万家店),
/// 应换成 `HashMap` + 排序。**这个替换的触发条件写在这里**,
/// 免得有人以为「线性查找」是永远正确的选择。
pub fn build_payroll_breakdown<ExtractLabel>(
plan: &ShiftPlan,
dimension_label: &'static str,
extract_label: ExtractLabel,
) -> PayrollBreakdown
where
ExtractLabel: Fn(&ShiftSlot) -> String,
{
let currency: Currency = plan.currency();
let company_base_hourly_rate: CurrencyAmount = plan.company_base_hourly_rate();
// 聚合缓冲:(标签, 槽位数, 计薪分钟, 正常成本分, 加班成本分)。
let mut aggregated: Vec<(String, usize, u32, i64, i64)> = Vec::new();
for slot in plan.slots() {
let label: String = extract_label(slot);
let labor_minor_units: i64 = slot.labor_cost(&company_base_hourly_rate).minor_units();
let overtime_minor_units: i64 = crate::client::compute_overtime_cost(
&company_base_hourly_rate,
slot.store_pay_index(),
slot.overtime_minutes(),
)
.minor_units();
// 线性查找该标签是否已有行。
match aggregated.iter_mut().find(|(existing, _, _, _, _)| *existing == label) {
Some((_, count, minutes, labor, overtime)) => {
*count += 1;
*minutes = minutes.saturating_add(slot.paid_minutes());
*labor = labor.saturating_add(labor_minor_units);
*overtime = overtime.saturating_add(overtime_minor_units);
}
None => aggregated.push((
label,
1,
slot.paid_minutes(),
labor_minor_units,
overtime_minor_units,
)),
}
}
// 总额要在构造各行占比之前先算出来。
let labor_total_minor_units: i64 =
aggregated.iter().map(|(_, _, _, labor, _)| *labor).sum();
let overtime_total_minor_units: i64 =
aggregated.iter().map(|(_, _, _, _, overtime)| *overtime).sum();
let grand_total_minor_units: i64 = labor_total_minor_units.saturating_add(overtime_total_minor_units);
// 构造各行并按标签升序排列。
let mut entries: Vec<PayrollCategoryEntry> = aggregated
.into_iter()
.map(|(label, count, minutes, labor, overtime)| {
// 占比的分母是「正常 + 加班」的总额,与报表打印的总额口径一致。
let share_basis_points: i64 = if grand_total_minor_units == 0 {
0
} else {
let combined: i128 = (labor + overtime) as i128 * 10_000i128;
(combined / grand_total_minor_units as i128) as i64
};
PayrollCategoryEntry {
label,
slot_count: count,
paid_minutes: minutes,
labor_cost: CurrencyAmount::from_minor_units(labor, currency),
overtime_cost: CurrencyAmount::from_minor_units(overtime, currency),
share_basis_points,
}
})
.collect();
// 按标签文本升序,保证两次运行输出一致。
entries.sort_by(|left, right| left.label.cmp(&right.label));
PayrollBreakdown {
entries,
labor_total: CurrencyAmount::from_minor_units(labor_total_minor_units, currency),
overtime_total: CurrencyAmount::from_minor_units(overtime_total_minor_units, currency),
total: CurrencyAmount::from_minor_units(grand_total_minor_units, currency),
total_paid_minutes: plan.total_paid_minutes(),
slot_count: plan.slot_count(),
dimension_label,
}
}
/// 便捷封装:按计薪时段分组。
pub fn build_payroll_breakdown_by_period(plan: &ShiftPlan) -> PayrollBreakdown {
build_payroll_breakdown(plan, "计薪时段", |slot| {
slot.template().pay_period_kind().display_name().to_string()
})
}
/// 便捷封装:按班次种类分组。
pub fn build_payroll_breakdown_by_shift(plan: &ShiftPlan) -> PayrollBreakdown {
build_payroll_breakdown(plan, "班次种类", |slot| {
slot.template().shift_code().display_name().to_string()
})
}
/// 便捷封装:按门店等级分组。
pub fn build_payroll_breakdown_by_store_grade(plan: &ShiftPlan) -> PayrollBreakdown {
build_payroll_breakdown(plan, "门店等级", |slot| {
slot.grade_profile().grade().display_name().to_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 : sharing_profile.rs
//! # 共享度画像 —— 享元是否真的在共享
//!
//! ## 与「内存对照」的分工
//!
//! - `memory_comparison` 回答「省了多少字节」(**结果**);
//! - 本模块回答「共享是怎么发生的」(**过程**)。
//!
//! 两个都要看。举例:若内存省得很多,但命中率只有 50%,
//! 说明池里有大量「只被引用一两次」的实例——
//! 那是**键设计有问题**的信号(键太细),只是被「总槽位数大」
//! 这个事实掩盖了。只看结果数字看不出这一点。
//!
//! ## 三个必须分开看的指标
//!
//! | 指标 | 含义 | 偏低说明什么 |
//! |---|---|---|
//! | 命中率 | 取用请求中复用既有实例的比例 | 预热期长,或键太细 |
//! | 平均共享倍数 | 每个实例平均被几个槽位持有 | **共享是否真的发生** |
//! | 最大共享倍数 | 最热实例被多少槽位共享 | 共享是否不均衡(少数实例扛大头) |
//!
//! **平均与最大并列**:若平均只有 3 但最大有 1000,
//! 说明共享严重不均衡——少数几个模板被大量复用,
//! 而其余模板几乎是「一次性」的。这种情况下,
//! 整体节省率仍可能不错,但**键设计的改进空间很大**
//! (把那些「一次性」模板的键合并/剔除)。
//!
//! 单看平均或单看最大,都会漏掉这个结论。
use crate::client::ShiftPlan;
use crate::domain::Ratio;
/// 共享度画像。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SharingProfile {
/// 槽位总数。
pub slot_count: usize,
/// 班次模板池的不同实例数。
pub distinct_template_count: usize,
/// 班次模板的取用请求总数。
pub template_request_count: u64,
/// 班次模板的命中次数。
pub template_hit_count: u64,
/// 班次模板的未命中次数(= 实际构造的实例数)。
pub template_miss_count: u64,
/// 门店等级配置池的不同实例数。
pub distinct_store_grade_count: usize,
/// 门店等级配置的取用请求总数。
pub store_grade_request_count: u64,
/// 门店等级配置的命中次数。
pub store_grade_hit_count: u64,
/// 模板池内实例当前被外部持有的句柄总数。
pub template_held_reference_total: u64,
/// 门店等级池内实例当前被外部持有的句柄总数。
pub store_grade_held_reference_total: u64,
/// 单个模板被共享的最大槽位数。
pub maximum_template_share_count: usize,
}
impl SharingProfile {
/// 由排班方案构造画像。
///
/// 参数 `plan`:排班方案。
/// 返回:共享度画像。
pub fn from_plan(plan: &ShiftPlan) -> SharingProfile {
let template_snapshot = plan.template_pool_snapshot();
let grade_snapshot = plan.grade_pool_snapshot();
// 最大共享数从「按模板聚合」的结果里取(该结果已按共享数降序)。
let distribution: Vec<(String, usize)> = plan.template_share_distribution();
let maximum_share: usize = distribution.first().map(|(_, count)| *count).unwrap_or(0);
SharingProfile {
slot_count: plan.slot_count(),
distinct_template_count: template_snapshot.distinct_entry_count,
template_request_count: template_snapshot.counters.request_count,
template_hit_count: template_snapshot.counters.hit_count,
template_miss_count: template_snapshot.counters.miss_count,
distinct_store_grade_count: grade_snapshot.distinct_entry_count,
store_grade_request_count: grade_snapshot.counters.request_count,
store_grade_hit_count: grade_snapshot.counters.hit_count,
template_held_reference_total: template_snapshot.held_reference_total,
store_grade_held_reference_total: grade_snapshot.held_reference_total,
maximum_template_share_count: maximum_share,
}
}
/// 模板命中率(万分点)。
pub fn template_hit_rate_basis_points(&self) -> i64 {
if self.template_request_count == 0 {
return 0;
}
let numerator: i128 = self.template_hit_count as i128 * 10_000i128;
(numerator / self.template_request_count as i128) as i64
}
/// 门店等级命中率(万分点)。
pub fn store_grade_hit_rate_basis_points(&self) -> i64 {
if self.store_grade_request_count == 0 {
return 0;
}
let numerator: i128 = self.store_grade_hit_count as i128 * 10_000i128;
(numerator / self.store_grade_request_count as i128) as i64
}
/// 模板的平均共享倍数(万分点)。
///
/// = 持有的句柄总数 / 不同实例数。
/// 例:12000 个槽位持有 7 个实例 → `1714285` 万分点 → 展示为 `171.4285 倍`。
pub fn average_template_share_basis_points(&self) -> i64 {
if self.distinct_template_count == 0 {
return 0;
}
let numerator: i128 = self.template_held_reference_total as i128 * 10_000i128;
(numerator / self.distinct_template_count as i128) as i64
}
/// 门店等级的平均共享倍数(万分点)。
///
/// ## 这一项通常远大于模板的共享倍数
///
/// 因为等级配置的实例更少(3 个)而持有者更多(每个槽位一份)。
/// 这不是「等级配置比班次模板更适合享元」,
/// 而是「等级维度天然低基数」。
///
/// 读者若把两个数字直接比较会得出错误结论,
/// 因此报表必须**并列打印两者的实例数**,
/// 让「低基数 → 高共享倍数」这个因果关系显式可见。
pub fn average_store_grade_share_basis_points(&self) -> i64 {
if self.distinct_store_grade_count == 0 {
return 0;
}
let numerator: i128 = self.store_grade_held_reference_total as i128 * 10_000i128;
(numerator / self.distinct_store_grade_count as i128) as i64
}
/// 每个槽位平均引用几个模板(总等于 1,用于说明「一槽一模板」)。
///
/// 这个值恒为 10000 万分点(1 倍),看起来是废话,
/// 但它是一个**结构断言**:若某天它不等于 1,
/// 说明有槽位引用了多个模板或零个模板,即装配逻辑出错。
/// 报表里打印它,等于每次都做一次廉价的自检。
pub fn template_references_per_slot_basis_points(&self) -> i64 {
if self.slot_count == 0 {
return 0;
}
let numerator: i128 = self.template_held_reference_total as i128 * 10_000i128;
(numerator / self.slot_count as i128) as i64
}
/// 共享是否不均衡。
///
/// 判定:最大共享数 > 平均共享数的 4 倍。
///
/// ## 为什么阈值取 4 倍
///
/// 这是一个**启发式阈值**,不是推导出来的。
/// 取 4 的依据:完全均匀分布时最大/平均 = 1;
/// 而「7 个模板 + 负载集中在 2 个」这类常见不均衡约为 3~5 倍。
/// 4 倍是这条经验曲线的中点。
///
/// **必须把这个阈值是启发式的这件事写出来**——
/// 否则读者会以为它有理论依据,进而在别的场景盲目沿用。
pub fn is_sharing_uneven(&self) -> bool {
let average: i64 = self.average_template_share_basis_points();
if average <= 0 {
return false;
}
// 用万分点比较,避免浮点:最大值 × 10000 与 平均值 × 4 比较。
let maximum_scaled: i128 = self.maximum_template_share_count as i128 * 10_000i128;
let threshold: i128 = average as i128 * 4;
maximum_scaled > threshold
}
/// 平均共享倍数(作为 `Ratio` 供报表格式化)。
pub fn average_template_share_as_ratio(&self) -> Ratio {
Ratio::from_basis_points(self.average_template_share_basis_points())
}
/// 命中率(作为 `Ratio`)。
pub fn template_hit_rate_as_ratio(&self) -> Ratio {
Ratio::from_basis_points(self.template_hit_rate_basis_points())
}
/// 两个池的总实例数。
pub const fn total_distinct_instance_count(&self) -> usize {
self.distinct_template_count + self.distinct_store_grade_count
}
/// 两个池的取用请求总数。
pub const fn total_request_count(&self) -> u64 {
self.template_request_count + self.store_grade_request_count
}
}
//!# 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 : audit_report.rs
//! # 合规体检报表 —— 红绿灯总表
//!
//! ## 一屏之内要能回答「能不能发布」
//!
//! 体检报表的第一行不是细节,而是**结论**:
//! `可否发布:是 / 否`。运营打开报表先看这一行,
//! 只有在「否」或想确认细节时才继续往下读。这就是把结论放最上的理由。
//!
//! ## 通过的规则也要逐条打印
//!
//! 若只打印失败的规则,报表看起来更短更「干净」,
//! 但读者无法区分「这条规则通过了」和「这条规则根本没跑」。
//!
//! 在一个**把架构约束写成自动断言**的工程里,这个区别至关重要:
//! `SHARING_EFFECTIVE` 这条规则存在的意义就是「让共享失效变成红灯」。
//! 若它通过了却不显示,读者不会知道有这道防线,
//! 也就不会在改动键设计时预期它会报警。
//!
//! **绿灯要亮着,才有红灯的意义。**
//!
//! ## 严重度用「标记 + 文字」双写
//!
//! `marker()` 给的是 `●` / `▲` / `·` 这类单字符标记,
//! `label()` 给的是「阻断 / 警告 / 提示」。
//! 两者都打印:标记让眼睛能快速扫读,文字让输出在纯文本环境
//! (日志、邮件、CI 输出)里仍然可读——标记在那里可能显示不出来。
use crate::analysis::CoverageAuditReport;
use crate::domain::ComplianceSeverity;
use super::layout::{key_value_line, TableColumn, TextTable};
/// 体检报表里「标签:值」行的标签宽度。
const AUDIT_LABEL_WIDTH: usize = 16;
/// 渲染合规体检报表。
///
/// 参数 `report`:`analysis` 层的体检结果。
/// 返回:若干行文本。
pub fn render_coverage_audit(report: &CoverageAuditReport) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
// ---------- 第一屏:结论 ----------
lines.push(" ── 体检结论 ──".to_string());
lines.push(key_value_line(
"可否发布",
AUDIT_LABEL_WIDTH,
if report.has_blocker() {
"否 —— 存在阻断项,必须整改后重跑"
} else if report.is_fully_compliant() {
"是 —— 全部规则通过"
} else {
"是(有附条件)—— 无阻断项,但存在警告/提示"
},
));
lines.push(key_value_line(
"规则总数",
AUDIT_LABEL_WIDTH,
&format!("{} 条", report.total_count()),
));
lines.push(key_value_line(
"通过 / 不通过",
AUDIT_LABEL_WIDTH,
&format!("{} / {}", report.passed_count(), report.failed_count()),
));
// 三档严重度全部显示,包括为 0 的档位——
// 「阻断项 0 条」与「报表里没有阻断这一栏」传达的安全感完全不同。
lines.push(key_value_line(
"阻断 / 警告 / 提示",
AUDIT_LABEL_WIDTH,
&format!(
"{} / {} / {}",
report.failed_count_of_severity(ComplianceSeverity::Blocker),
report.failed_count_of_severity(ComplianceSeverity::Warning),
report.failed_count_of_severity(ComplianceSeverity::Information),
),
));
// ---------- 第二屏:逐条明细 ----------
lines.push(String::new());
lines.push(" ── 逐条明细 ──".to_string());
// 结论列宽度按**本报表实际内容**算出来,而不是拍一个魔数。
// 理由:规则结论文本长度差异很大(「通过」2 列 vs 一句 50+ 列的说明),
// 拍一个过小的宽度会截断(丢失排查信息),拍过大则浪费版面。
// 用最大实际宽度,是唯一不会截断又不过度留白的做法。
//
// ⚠️ 这里曾经写成 `.min(50)`,结果是**静默截断**:
// 最长的一条结论(规则 7 的「最长加班 … 分钟(阈值 … 分钟),合计加班 … 分钟」)
// 需要 53 列,被砍掉 3 列后各行仍然对齐,肉眼根本看不出来。
// 教训:**上限只能用来防呆,不能用来省版面**——一旦它成为实际约束,
// 就会把「内容太长」这个信号悄悄吞掉。
//
// 因此现在改为:先算真实最大宽度,再用 debug_assert 守住防呆上限。
// 若将来有人加了一条超长结论,`cargo run` 会立刻 panic 并指出该调大哪个常量,
// 而不是产出一份少了几个字的报表。
const MAX_CONCLUSION_COLUMN_WIDTH: usize = 56;
const MIN_CONCLUSION_COLUMN_WIDTH: usize = 10;
let longest_conclusion_width: usize = report
.entries
.iter()
.map(|entry| entry.conclusion_display_width())
.max()
.unwrap_or(0);
debug_assert!(
longest_conclusion_width <= MAX_CONCLUSION_COLUMN_WIDTH,
"体检结论列宽上限 {} 列不足,最长结论需要 {} 列;\
请调大 MAX_CONCLUSION_COLUMN_WIDTH(不要改回静默截断)。",
MAX_CONCLUSION_COLUMN_WIDTH,
longest_conclusion_width
);
let conclusion_width: usize = longest_conclusion_width
.min(MAX_CONCLUSION_COLUMN_WIDTH)
.max(MIN_CONCLUSION_COLUMN_WIDTH);
let table: TextTable = TextTable::new(vec![
TableColumn::left("严重度", 12),
// 规则编码列要容得下最长的编码
// (`NO_UNREGISTERED_STORE_GRADE` 共 26 字符),否则会被截断,
// 而截断后的编码无法被拿去代码里检索——那正是这一列的全部用途。
TableColumn::left("规则编码", 28),
TableColumn::left("结果", 6),
TableColumn::left("结论", conclusion_width),
]);
lines.push(format!(" {}", table.header_line()));
lines.push(format!(" {}", table.separator_line()));
for entry in &report.entries {
// 严重度写「标记 + 文字」:标记便于扫读,文字保证纯文本环境可读。
let severity_text: String = format!("{} {}", entry.severity.marker(), entry.severity.label());
lines.push(format!(
" {}",
table.row_line(&[
severity_text,
entry.rule_code.to_string(),
entry.result_marker().to_string(),
entry.conclusion.clone(),
])
));
}
lines.push(format!(" {}", table.separator_line()));
// ---------- 第三屏:不通过项的规则说明 ----------
//
// 明细表里只放了规则编码,因为规则说明(`rule_description`)通常是
// 一整句话,放进去会把表撑爆。
// 但编码对人没有意义,因此把「不通过项的说明」单独列出来——
// 这样读者不需要回头翻代码去查这个编码是什么意思。
//
// 只列不通过的项:通过项的说明没有行动价值(你不会去修一个通过的东西)。
let mut failed_notes: Vec<&crate::analysis::CoverageAuditEntry> =
report.entries.iter().filter(|entry| !entry.passed).collect();
// 按严重度优先级排序:先看该改的,再看可选的。
// 用稳定排序保持同严重度内的原始顺序(即规则定义顺序),便于对照代码。
failed_notes.sort_by_key(|entry| entry.severity.priority());
lines.push(String::new());
lines.push(" ── 不通过项说明 ──".to_string());
if failed_notes.is_empty() {
lines.push(" · (无 —— 全部规则通过)".to_string());
} else {
for entry in &failed_notes {
lines.push(format!(
" {} [{}] {}",
entry.severity.marker(),
entry.rule_code,
entry.rule_description
));
lines.push(format!(" └─ {}", entry.conclusion));
}
}
// ---------- 第四屏:阻断项的行动清单 ----------
//
// 「一次报全」的设计目的是让运营改一轮就能过。
// 因此最后要明确列出「必须先处理哪些」,
// 而不是让运营自己在 8 条规则里判断哪条挡着发布。
let blockers: Vec<&str> = report
.entries
.iter()
.filter(|entry| !entry.passed && entry.blocks_publication_when_failed())
.map(|entry| entry.rule_code)
.collect();
lines.push(String::new());
lines.push(" ── 发布前必须处理 ──".to_string());
if blockers.is_empty() {
lines.push(" · (无 —— 无阻断项,警告与提示可人工确认后发布)".to_string());
} else {
for (index, rule_code) in blockers.iter().enumerate() {
lines.push(format!(" {}. {}", index + 1, rule_code));
}
}
lines
}
//!# 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 : flyweight_report.rs
//! # 享元专项报表 —— 共享省了多少、共享是怎么发生的
//!
//! ## 为什么享元专项要独立成一份报表
//!
//! 排班报表回答的是「业务做得怎么样」,享元专项回答的是
//! **「这套共享机制本身工作得怎么样」**。
//!
//! 这两类问题的读者不同、发布节奏也不同:
//! 前者给运营每周看,后者给架构/性能负责人每次改键设计时看。
//! 合成一份报表的后果是:看业务的人被一堆字节数干扰,
//! 看机制的人要在几千行业务明细里翻那十几行关键指标。
//!
//! **报表拆分的依据是「读者与发布节奏」,不是「数据来源」。**
//! 本模块与 `schedule_report` 都读同一个 `ShiftPlan`,
//! 但拆开是对的。
//!
//! ## 三屏的顺序是有讲究的
//!
//! 1. **池快照**——先给事实(请求多少次、命中多少次、几个实例);
//! 2. **内存对照**——再给结果(省了多少字节);
//! 3. **共享度画像**——最后给过程(共享是怎么发生的、均不均匀)。
//!
//! 顺序不能颠倒。若先给「省了 96%」,读者会立刻接受这个结论;
//! 之后再看「命中率 3%」时,就难以推翻已经形成的印象。
//! **先事实、再结果、最后过程**,让每一步判断都有据可依。
use crate::analysis::{MemoryComparison, SharingProfile};
use crate::client::ShiftPlan;
use crate::domain::Ratio;
use crate::flyweight::PoolSnapshot;
use super::layout::{
bullet_line, byte_count_text, key_value_line, with_thousands_separator, TableColumn, TextTable,
};
/// 享元专项报表里「标签:值」行的标签宽度。
const SPECIAL_LABEL_WIDTH: usize = 18;
/// 渲染两个享元池的统计快照。
///
/// 参数 `plan`:排班方案(内含两个池的快照)。
/// 返回:若干行文本。
///
/// ## 为什么把「请求 / 命中 / 未命中 / 拒绝」四个数都打印
///
/// 只打印命中率是不够的——**命中率的含义依赖于未命中的构成**。
/// 举例:若拒绝 100 次、未命中 0 次,命中率看起来很高,
/// 但真相是「有 100 次请求根本没进池」(键空间失控)。
/// 四个数并列时,这种情形一眼可见。
///
/// 这也解释了为什么 `PoolCounters` 把三者**分开算**而不是用减法:
/// 用「请求 - 命中 = 未命中」会把拒绝次数算成未命中,
/// 于是「键失控」会被伪装成「预热期长」。
pub fn render_pool_snapshots(plan: &ShiftPlan) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
lines.push(" ── 班次模板池快照 ──".to_string());
lines.extend(render_one_pool_snapshot(&plan.template_pool_snapshot(), "模板"));
lines.push(String::new());
lines.push(" ── 门店等级配置池快照 ──".to_string());
lines.extend(render_one_pool_snapshot(&plan.grade_pool_snapshot(), "配置"));
lines
}
/// 渲染单个池的快照(供 [`render_pool_snapshots`] 复用两份)。
///
/// 参数 `snapshot`:池快照;`noun`:该池里享元的中文称谓(「模板」/「配置」)。
/// 返回:若干行文本。
///
/// 用一个函数渲染两个池而不是写两遍:**两个池的指标口径必须完全一致**,
/// 若各写一遍,迟早会出现「模板池打印拒绝次数、配置池忘了打印」这类不一致,
/// 而读者会误以为配置池从未拒绝过。
fn render_one_pool_snapshot(snapshot: &PoolSnapshot, noun: &str) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
let counters = snapshot.counters;
lines.push(key_value_line(
&format!("取用{}次数", noun),
SPECIAL_LABEL_WIDTH,
&with_thousands_separator(counters.request_count as i64),
));
lines.push(key_value_line(
"命中(复用既有)",
SPECIAL_LABEL_WIDTH,
&format!(
"{}({})",
with_thousands_separator(counters.hit_count as i64),
Ratio::from_basis_points(counters.hit_rate_basis_points()).as_percent_text()
),
));
lines.push(key_value_line(
"未命中(新建)",
SPECIAL_LABEL_WIDTH,
&format!(
"{}({})",
with_thousands_separator(counters.miss_count as i64),
Ratio::from_basis_points(counters.miss_rate_basis_points()).as_percent_text()
),
));
// 拒绝次数只在真的发生过时才展开显示;但它**一定会被显示**,
// 而不是被省略掉——见下方「口径」说明。
lines.push(key_value_line(
"拒绝(容量溢出)",
SPECIAL_LABEL_WIDTH,
&format!(
"{}({})",
with_thousands_separator(counters.rejected_count as i64),
Ratio::from_basis_points(counters.rejection_rate_basis_points()).as_percent_text()
),
));
lines.push(key_value_line(
"池内不同实例数",
SPECIAL_LABEL_WIDTH,
&format!("{} 个", snapshot.distinct_entry_count),
));
lines.push(key_value_line(
"容量上限",
SPECIAL_LABEL_WIDTH,
&match snapshot.entry_limit {
Some(limit) => format!("{} 个", limit),
// 「未设上限」与「上限很大」是两件事,必须显示成两种文本。
None => "未设上限".to_string(),
},
));
lines.push(key_value_line(
"被持有句柄总数",
SPECIAL_LABEL_WIDTH,
&with_thousands_separator(snapshot.held_reference_total as i64),
));
lines.push(key_value_line(
"实例内容字节",
SPECIAL_LABEL_WIDTH,
&byte_count_text(snapshot.intrinsic_bytes_total),
));
lines.push(key_value_line(
"句柄与控制块字节",
SPECIAL_LABEL_WIDTH,
&byte_count_text(snapshot.handle_bytes_total),
));
lines.push(key_value_line(
"共享侧合计",
SPECIAL_LABEL_WIDTH,
&byte_count_text(snapshot.shared_total_bytes()),
));
lines.push(key_value_line(
&format!("平均每个{}字节", noun),
SPECIAL_LABEL_WIDTH,
&byte_count_text(snapshot.average_intrinsic_bytes()),
));
lines.push(key_value_line(
"平均共享倍数",
SPECIAL_LABEL_WIDTH,
&Ratio::from_basis_points(snapshot.average_share_multiplier_basis_points())
.as_multiplier_text(),
));
lines.push(key_value_line(
"是否曾溢出",
SPECIAL_LABEL_WIDTH,
if snapshot.is_capacity_exceeded() { "是" } else { "否" },
));
lines
}
/// 渲染内存对照表。
///
/// 参数 `comparison`:`analysis` 层算好的对照结果。
/// 返回:若干行文本。
///
/// ## 打印顺序:共享侧 → 独立侧 → 差额 → 比率
///
/// 这个顺序本身就是论证结构:
/// 先摆出两个数(各自多少),再摆出差,最后才给比率。
/// **比率放在最后,是因为比率是最容易被误读的量**——
/// 一个「省了 97%」的数字,若不先看到「独立侧 8.8 MB」这个绝对量,
/// 读者无法判断这到底省得多还是本来就无所谓。
///
/// ## 为什么不换算成 KB / MB
///
/// 因为字节数要人工核对。换算会引入小数并在展示时丢掉精度,
/// 当「报表显示 8.6 MB」而实测是 9,012,345 字节时,
/// 无法判断是四舍五入还是算错了。**保留原值 + 千分位**是唯一稳妥的做法。
pub fn render_memory_comparison(comparison: &MemoryComparison) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
lines.push(format!(
" ── 内存对照(槽位 {} 个)──",
with_thousands_separator(comparison.slot_count as i64)
));
// ---------- 共享侧明细 ----------
lines.push(" 【共享侧:享元 + 句柄 + 外在状态】".to_string());
lines.push(bullet_line(
"班次模板内容(含键)",
&with_thousands_separator(comparison.shared_template_content_bytes as i64),
14,
));
lines.push(bullet_line(
"门店等级配置内容",
&with_thousands_separator(comparison.shared_store_grade_content_bytes as i64),
14,
));
lines.push(bullet_line(
"句柄与控制块",
&with_thousands_separator(comparison.shared_handle_bytes as i64),
14,
));
lines.push(bullet_line(
"槽位自身外在状态",
&with_thousands_separator(comparison.shared_slot_state_bytes as i64),
14,
));
lines.push(bullet_line(
"共享侧合计",
&with_thousands_separator(comparison.shared_total_bytes() as i64),
14,
));
// ---------- 独立侧明细 ----------
lines.push(String::new());
lines.push(" 【独立侧:每个槽位各持一份副本】".to_string());
lines.push(bullet_line(
"班次模板副本(全部槽位)",
&with_thousands_separator(comparison.unshared_template_copy_bytes as i64),
14,
));
lines.push(bullet_line(
"门店等级配置副本(全部槽位)",
&with_thousands_separator(comparison.unshared_store_grade_copy_bytes as i64),
14,
));
lines.push(bullet_line(
"槽位自身外在状态(同上)",
&with_thousands_separator(comparison.unshared_slot_state_bytes as i64),
14,
));
lines.push(bullet_line(
"独立侧合计",
&with_thousands_separator(comparison.unshared_total_bytes() as i64),
14,
));
// ---------- 差额 ----------
//
// 差额可能是负数(例如槽位数极少时,池的固定开销反而更大)。
// 因此这一行不做「省了多少」的措辞,只说「差额」,
// 由下面两行的比率来定性。若强行写成「省了 -1234 字节」,
// 读者会以为算错了。
lines.push(String::new());
lines.push(bullet_line(
"差额(独立侧 − 共享侧)",
&with_thousands_separator(comparison.saved_bytes()),
14,
));
if comparison.saved_bytes() < 0 {
lines.push(" · 注意:差额为负,说明该规模下池的固定开销超过收益".to_string());
}
// ---------- 比率 ----------
lines.push(String::new());
lines.push(key_value_line(
"总内存节省率",
SPECIAL_LABEL_WIDTH,
&format!(
"{}(含外在状态,被稀释)",
comparison.saved_ratio_as_ratio().as_percent_text()
),
));
lines.push(key_value_line(
"可共享部分节省率",
SPECIAL_LABEL_WIDTH,
&Ratio::from_basis_points(comparison.saved_ratio_of_shareable_basis_points())
.as_percent_text(),
));
lines.push(key_value_line(
"槽位外在状态占比",
SPECIAL_LABEL_WIDTH,
&Ratio::from_basis_points(comparison.slot_state_ratio_basis_points()).as_percent_text(),
));
lines.push(key_value_line(
"每槽位共享侧字节",
SPECIAL_LABEL_WIDTH,
&format!("{} 字节", comparison.shared_bytes_per_slot()),
));
lines.push(key_value_line(
"每槽位独立侧字节",
SPECIAL_LABEL_WIDTH,
&format!("{} 字节", comparison.unshared_bytes_per_slot()),
));
// 这一行把「省了多少字节」锚定到一个读者有直觉的单位上。
// 除数是**单个槽位自身**的字节数(`slot_intrinsic_bytes`,即 ShiftSlot 的
// size_of),不是「独立侧每槽位字节」(那个值还含每槽位各背一份的副本)。
// 两者相差一个数量级,标签必须写清楚除数是谁,否则读者无法复算。
lines.push(key_value_line(
"节省折合槽位数",
SPECIAL_LABEL_WIDTH,
&format!(
"≈ {} 个槽位自身大小(除数 = 单个槽位 {} 字节)",
comparison.saved_equivalent_slot_count(),
comparison.slot_intrinsic_bytes
),
));
// ---------- 两个节省率为什么必须同时给出 ----------
//
// 「总节省率」把外在状态(每个槽位都要占的那部分)也算进分母,
// 因此它总是偏小;「可共享部分节省率」只看真正可共享的那部分,
// 因此它反映的是**机制本身的效果**。
//
// 只报总节省率会让读者低估享元的价值(并据此错误地放弃优化);
// 只报可共享部分节省率会让读者高估整体收益。
// 两个一起给,读者才能判断「是机制不够好」还是「可共享的东西本来就少」。
lines.push(String::new());
lines.push(" · 说明:总节省率的分母含每个槽位都要占的外在状态,故必然偏小;".to_string());
lines.push(" 可共享部分节省率只衡量「可共享内容」的缩减,反映机制本身的效果。".to_string());
lines.push(" 两个一起看,才能区分「机制无效」与「可共享的东西本来就少」。".to_string());
lines
}
/// 渲染共享度画像。
///
/// 参数 `profile`:`analysis` 层算好的画像。
/// 返回:若干行文本。
pub fn render_sharing_profile(profile: &SharingProfile) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
lines.push(" ── 共享度画像 ──".to_string());
let table: TextTable = TextTable::new(vec![
TableColumn::left("享元族", 16),
TableColumn::right("实例数", 8),
TableColumn::right("取用次数", 12),
TableColumn::right("命中率", 10),
TableColumn::right("被持有句柄", 12),
TableColumn::right("平均共享倍数", 14),
]);
lines.push(format!(" {}", table.header_line()));
lines.push(format!(" {}", table.separator_line()));
lines.push(format!(
" {}",
table.row_line(&[
"班次模板".to_string(),
profile.distinct_template_count.to_string(),
with_thousands_separator(profile.template_request_count as i64),
Ratio::from_basis_points(profile.template_hit_rate_basis_points()).as_percent_text(),
with_thousands_separator(profile.template_held_reference_total as i64),
Ratio::from_basis_points(profile.average_template_share_basis_points())
.as_multiplier_text(),
])
));
lines.push(format!(
" {}",
table.row_line(&[
"门店等级配置".to_string(),
profile.distinct_store_grade_count.to_string(),
with_thousands_separator(profile.store_grade_request_count as i64),
Ratio::from_basis_points(profile.store_grade_hit_rate_basis_points()).as_percent_text(),
with_thousands_separator(profile.store_grade_held_reference_total as i64),
Ratio::from_basis_points(profile.average_store_grade_share_basis_points())
.as_multiplier_text(),
])
));
lines.push(format!(" {}", table.separator_line()));
lines.push(String::new());
lines.push(key_value_line(
"两族实例总数",
SPECIAL_LABEL_WIDTH,
&format!("{} 个", profile.total_distinct_instance_count()),
));
lines.push(key_value_line(
"两族取用总次数",
SPECIAL_LABEL_WIDTH,
&with_thousands_separator(profile.total_request_count() as i64),
));
lines.push(key_value_line(
"最热模板共享槽位",
SPECIAL_LABEL_WIDTH,
&format!(
"{} 个(分布{})",
with_thousands_separator(profile.maximum_template_share_count as i64),
// 「均匀」与否由 `is_sharing_uneven`(阈值 4 倍)判断。
// 这里只输出一个词,不输出倍数——倍数已在「平均共享倍数」一栏给出,
// 重复输出只会让读者去比较两个本应相同的数。
if profile.is_sharing_uneven() { "不均" } else { "较均匀" }
),
));
lines.push(key_value_line(
"每槽位平均引用模板数",
SPECIAL_LABEL_WIDTH,
&Ratio::from_basis_points(profile.template_references_per_slot_basis_points())
.as_multiplier_text(),
));
// ---------- 平均与最大必须并列的说明 ----------
lines.push(String::new());
lines.push(" · 平均共享倍数与最热共享槽位必须并列看:".to_string());
lines.push(" 平均低而最大高,说明共享严重不均衡——少数实例被大量复用,".to_string());
lines.push(" 其余近乎一次性。此时整体节省率可能仍不错,但键设计的改进空间很大。".to_string());
lines
}
//!# 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 : layout.rs
//! # 排版原语 —— 表格与分节
//!
//! ## 为什么把「排版」单独抽出来
//!
//! 本工程有四份报表(方案总览 / 成本分布 / 享元专项 / 合规体检)。
//! 若每份报表各自实现「怎么对齐一列数字」,迟早会出现
//! 「A 报表右对齐、B 报表左对齐」这种不一致,
//! 而且每修一次列宽要改四处。
//!
//! 因此把「一列多宽、往哪边对齐、怎么拼一行」这件唯一的事抽到本模块,
//! 四份报表只提供**内容**,不碰**宽度**。这就是 app 层内部的职责单一。
//!
//! ## 两个关键约束
//!
//! ### 约束一:宽度按**显示列数**算,不按字节也不按字符数
//!
//! 中文报表里 `"旗舰店"` 是 3 个字符 / 9 个字节 / **6 个显示列**。
//! Rust 的 `{:<10}` 按**字符数**填充,于是中文列会比英文列窄一半,
//! 整个表错位。本模块全部宽度都走 [`crate::support::text_layout`],
//! 与终端真实渲染口径一致。
//!
//! ### 约束二:单元格**截断**,与 `pad_right` 的「不截断」策略相反
//!
//! `text_layout::pad_right` 刻意不截断,理由是「列宽估小了应当暴露」。
//! 但那是给**单行文本**用的。表格不同:
//!
//! > 表格是一个**固定网格**。某一格溢出会把该行后面所有列推走,
//! > 读者看到的是「整张表乱了」,却**无法定位是哪一格太长**。
//!
//! 所以在表格里,溢出必须被限制在一个格子内——宁可截断,
//! 也不要让错误扩散到整行。这是两种场景对「诚实」的不同答案,
//! 不是自相矛盾:一个要暴露错误,一个要定位错误。
//!
//! 截断后不加省略号:本工程的列宽都留有充分余量,
//! 若真的发生截断,说明内容长度超出预期,此时**不加修饰**反而更容易发现
//! (`…` 会被误认为是内容本身的一部分)。
use crate::support::text_layout::{display_width, horizontal_rule, pad_left, pad_right, truncate_to_width};
/// 列内容的对齐方向。
///
/// 只用两种,不用「居中」:数字右对齐、文字左对齐已经覆盖报表全部需求,
/// 而居中对齐在中文环境下会遇到「奇数差值往哪边补」的问题
/// (见 `pad_center` 文档),多一种对齐就多一类对齐事故。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnAlignment {
/// 左对齐:用于名称、描述等文字列。
Left,
/// 右对齐:用于金额、件数、字节数等数字列。
Right,
}
/// 一列的定义。
#[derive(Debug, Clone, Copy)]
pub struct TableColumn {
/// 表头文字。
pub header: &'static str,
/// 该列的显示宽度(中文字符按 2 计)。
pub width: usize,
/// 该列的对齐方向。
pub alignment: ColumnAlignment,
}
impl TableColumn {
/// 构造一个**左对齐**列。
pub const fn left(header: &'static str, width: usize) -> TableColumn {
TableColumn { header, width, alignment: ColumnAlignment::Left }
}
/// 构造一个**右对齐**列。
pub const fn right(header: &'static str, width: usize) -> TableColumn {
TableColumn { header, width, alignment: ColumnAlignment::Right }
}
/// 把一个单元格内容按本列的口径整理成恰好该列宽的字符串。
///
/// 参数 `cell`:原始单元格内容。
/// 返回:截断并填充到列宽后的字符串。
fn render_cell(&self, cell: &str) -> String {
// 先截断到列宽以内(防止溢出推走后续列)。
//
// ## 为什么截断必须是「响亮」的
//
// 单元格溢出会推乱整行,所以截断本身是对的。但**静默**截断是危险的:
// 截断之后各行仍然对齐,报表看起来完全正常,只是内容少了一截
// (本工程曾把 `79247 小时 30 分` 悄悄截成 `79247 小时 30`,
// 肉眼几乎发现不了)。因此这里用 `debug_assert!` 把它变成
// 开发期就会立刻炸出来的错误——`cargo run`(debug 构建)必然触发,
// 而 release 构建下断言被消除,仍保留「宁可截断也不推乱整行」的行为。
//
// 若将来某个表格**确实**需要截断,把该列的宽度调够,或在调用处显式
// 截断文本后再传入——不要把这里改回静默状态。
let clipped: String = truncate_to_width(cell, self.width);
debug_assert!(
clipped == cell,
"表格单元格被截断:列宽 {} 列,但内容需要 {} 列,原文=[{}]。\
请调大该列宽度,或缩短传入的文本。",
self.width,
display_width(cell),
cell
);
// 再按对齐方向补齐到列宽。
match self.alignment {
ColumnAlignment::Left => pad_right(&clipped, self.width),
ColumnAlignment::Right => pad_left(&clipped, self.width),
}
}
}
/// 列与列之间的间隔空格数。
///
/// 选 2 而不是 1:中文全角字符本身视觉密度高,1 个半角空格会让相邻列
/// 看起来「粘在一起」,尤其当左列是中文、右列是数字时。
const COLUMN_GAP: usize = 2;
/// 一张定宽纯文本表。
///
/// ## 用法
///
/// ```ignore
/// let table = TextTable::new(vec![
/// TableColumn::left("规则", 22),
/// TableColumn::right("结果", 6),
/// ]);
/// println!("{}", table.header_line());
/// println!("{}", table.separator_line());
/// println!("{}", table.row_line(&["班次登记完整".to_string(), "通过".to_string()]));
/// ```
#[derive(Debug, Clone)]
pub struct TextTable {
/// 全部列定义,顺序即输出顺序。
columns: Vec<TableColumn>,
}
impl TextTable {
/// 用列定义构造一张表。
pub const fn new(columns: Vec<TableColumn>) -> TextTable {
TextTable { columns }
}
/// 表格总显示宽度(各列宽之和 + 列间隔)。
///
/// 用于绘制与之等宽的分隔线。若只在表头下方画一条线、
/// 而不是每行都画,表格既清晰又不啰嗦。
pub fn total_width(&self) -> usize {
let columns_width: usize = self.columns.iter().map(|column| column.width).sum();
let gaps_width: usize = self.columns.len().saturating_sub(1) * COLUMN_GAP;
columns_width + gaps_width
}
/// 渲染表头行。
pub fn header_line(&self) -> String {
let cells: Vec<String> = self
.columns
.iter()
.map(|column| {
// 表头一律左对齐:表头是标签,不是数据。
// 若表头也右对齐,读者会把表头与数字糊在一起。
pad_right(column.header, column.width)
})
.collect();
cells.join(&" ".repeat(COLUMN_GAP))
}
/// 渲染分隔线(与表格等宽)。
pub fn separator_line(&self) -> String {
horizontal_rule(self.total_width())
}
/// 渲染一行数据。
///
/// 参数 `cells`:按列顺序给出的单元格内容。
///
/// ## 单元格数量与列数不一致时怎么办
///
/// - 少于列数:缺的位置按空串渲染(**不 panic**);
/// - 多于列数:多余的内容**被忽略**。
///
/// 这两种情况都是调用方的 bug,但本工程选择「渲染但不错乱」,
/// 配合 `debug_assert` 让它在开发期就炸出来。
/// 理由是:报表渲染失败会让整场演示中断,而其代价远大于
/// 「多一格空列」这个后果——把错误降级为可观察的异常,而不是崩溃。
pub fn row_line(&self, cells: &[String]) -> String {
debug_assert_eq!(
cells.len(),
self.columns.len(),
"表格行列数不一致:表定义 {} 列,传入 {} 个单元格",
self.columns.len(),
cells.len()
);
let rendered: Vec<String> = self
.columns
.iter()
.enumerate()
.map(|(index, column)| {
let cell: &str = cells.get(index).map(String::as_str).unwrap_or("");
column.render_cell(cell)
})
.collect();
rendered.join(&" ".repeat(COLUMN_GAP))
}
/// 渲染一行「空行占位」,保持与数据行等宽(用于分组之间留白)。
pub fn blank_line(&self) -> String {
" ".repeat(self.total_width())
}
}
/// 分节标题的装饰线宽度。
///
/// ## 为什么是私有常量而不是公开配置
///
/// 它只影响「节的标题线画多长」,属于本模块的排版实现细节。
/// 若把它公开,调用方就会开始依赖这个数值——
/// 于是将来调整线宽会变成一次跨层的接口变更。
/// **实现细节公开出去,就会变成接口**,这条代价值得警惕。
///
/// ## 84 这个数是怎么来的
///
/// 它同时被两处使用:
/// 1. `section_header` 画分隔线;
/// 2. `note_line` 用 `84 - indent` 作为说明文字的最大宽度。
///
/// 因此它不是随手取的:**它必须大于本工程最长的一句说明文字**,
/// 否则说明会被静默截断(而截断的说明恰恰是最需要读全的那类文字)。
/// 本工程最长的一行说明约 80 列,故取 84 留出 4 列余量。
/// 若将来加入了更长的说明,这个数要跟着调——这是**耦合**,所以写在这里说明。
const SECTION_TITLE_WIDTH: usize = 84;
/// 生成一节的开头:空行 + 标题 + 分隔线。
///
/// 参数 `title`:节标题文本。
/// 返回:三行文本,按顺序放入输出即可。
///
/// 每节以空行开始,是为了在长输出里让节与节之间有呼吸感。
/// 分隔线用全角 `─`(见 `horizontal_rule`),与表格分隔线同一风格。
pub fn section_header(title: &str) -> Vec<String> {
vec![
String::new(),
format!("【{}】", title),
horizontal_rule(SECTION_TITLE_WIDTH),
]
}
/// 生成一行「标签:值」。
///
/// 参数 `label`:标签文本(右补空格到 `label_width` 列);
/// `value`:值文本。
/// 返回:一行文本。
///
/// 标签宽度由调用方给定而不是自算,是为了**同一份报表里的多行能对齐**——
/// 每行各自算宽度只会得到各自的对齐,反而参差。
pub fn key_value_line(label: &str, label_width: usize, value: &str) -> String {
format!(" {} {}", pad_right(label, label_width), value)
}
/// 生成一行「项目符号 + 文本(右对齐数值)」。
///
/// 参数 `text`:左侧文字;`numeric_text`:右侧数值文本;
/// `numeric_width`:右侧数值占的显示宽度。
/// 返回:一行文本。
///
/// 用于「结论 + 数字」并列的场景,例如内存对照里
/// 「共享侧合计 …… 12,345 字节」这种行。
pub fn bullet_line(text: &str, numeric_text: &str, numeric_width: usize) -> String {
format!(" · {} {}", text, pad_left(numeric_text, numeric_width))
}
/// 把一串字节数渲染成「12,345 字节」。
///
/// 参数 `byte_count`:字节数。
/// 返回:带千分位的文本。
///
/// ## 为什么本函数只管排版、不做除法
///
/// 一个「转成 KB/MB」的版本很诱人,但它会让「共享省了多少」在
/// 小数位上失真——而本工程的字节数是要**人工核对**的。
/// 因此坚持原样输出**字节数**,只加千分位方便阅读。
/// 万一将来真要显示 KB,那也应当是**另一个**函数,而不是把
/// 单位换算偷偷混进排版里(那会让「报表数字与实测值不一致」变得难查)。
pub fn byte_count_text(byte_count: usize) -> String {
format!("{} 字节", with_thousands_separator(byte_count as i64))
}
/// 把整数渲染成带千分位的文本(支持负数)。
///
/// 参数 `value`:整数。
/// 返回:如 `12,345` 或 `-1,200`。
///
/// 本函数与 `domain::currency_amount` 内部的千分位函数**刻意重复**:
/// 领域层那个是金额格式化的一部分(要处理小数位与币种符号),
/// 属于领域知识;本函数只处理裸整数,属于排版知识。
/// 强行复用一个会让领域层的私有实现泄漏成公开 API——
/// 为了消除 20 行重复而牺牲模块边界,不划算。
pub fn with_thousands_separator(value: i64) -> String {
let negative: bool = value < 0;
// 用 i128 取绝对值,避免 i64::MIN 取反溢出。
let magnitude: i128 = (value as i128).abs();
let digits: String = magnitude.to_string();
let mut grouped: String = String::new();
for (index, character) in digits.chars().enumerate() {
// 从右往左每 3 位插一个逗号:当「剩余位数」是 3 的倍数且不是首位时插入。
let remaining: usize = digits.len() - index;
if index > 0 && remaining % 3 == 0 {
grouped.push(',');
}
grouped.push(character);
}
if negative {
format!("-{}", grouped)
} else {
grouped
}
}
/// 生成一行「缩进的补充说明」,并在需要时按宽度截断。
///
/// 参数 `text`:说明文字;`indent`:缩进空格数。
/// 返回:一行文本。
pub fn note_line(text: &str, indent: usize) -> String {
format!("{}· {}", " ".repeat(indent), truncate_to_width(text, SECTION_TITLE_WIDTH - indent))
}
/// 把多行文本按「最长行」对齐后的宽度补齐(用于给整块输出加边框)。
///
/// 参数 `lines`:文本行列表。
/// 返回:这些行中最大的显示宽度。
pub fn max_display_width(lines: &[String]) -> usize {
lines.iter().map(|line| display_width(line)).max().unwrap_or(0)
}
//!# 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 : schedule_report.rs
//! # 排班报表 —— 方案总览、成本分布、池内容清单
//!
//! ## 本模块只做一件事:把已有的数字摆到一张表里
//!
//! 所有被打印的数字都来自 `ShiftPlan` / `PayrollBreakdown` / 两个工厂,
//! 本模块**一次加、减、乘、除都不做**。
//!
//! 唯一看起来像「计算」的地方是「计薪工时」列:把 `u32` 分钟交给
//! [`WorkDuration::from_minutes`] 再调 `formatted_hours_and_minutes()`。
//! 这不是算术,而是**换一种表示法**(分钟 → 「7 小时 0 分」)。
//! 单位换算放在领域层做,是因为换算规则属于领域知识,
//! 而且换算本身也要遵守整数纪律——若在表示层自己 `minutes / 60`,
//! 就会在报表层引入一个不受领域层约束的除法。
//!
//! ## 池内容清单为什么必须存在
//!
//! 「池里有 7 个模板」是一个**结论**。若不能把这 7 个模板逐条列出来,
//! 读者只能选择相信或怀疑,无法判断。
//!
//! 而键设计是否正确,恰恰只能通过看这份清单来判断:
//! 若清单里出现「同一班次、同一技能、同一时段」的两行,
//! 说明键里混进了不该有的维度,共享已经失效。
//! 这项检查在 `coverage_audit` 里有自动版本(`SHARING_EFFECTIVE`),
//! 但**人眼看得见**是无可替代的——自动检查只能报「红灯」,
//! 无法告诉运营「是哪两个模板重复了」。
use crate::analysis::PayrollBreakdown;
use crate::client::ShiftPlan;
use crate::domain::{Ratio, WorkDuration};
use crate::flyweight::{ShiftTemplateFactory, StoreGradeFactory};
use super::layout::{key_value_line, with_thousands_separator, TableColumn, TextTable};
/// 「标签:值」行里标签的固定宽度(显示列数)。
///
/// 12 列够放下「实际出勤合计」这类 6 个汉字(12 列)的标签。
/// 定死宽度而不是每行自算,是为了同一节内的多行能对齐。
const OVERVIEW_LABEL_WIDTH: usize = 14;
/// 渲染「排班方案总览」。
///
/// 参数 `plan`:装配器产出的排班方案。
/// 返回:若干行文本。
///
/// ## 为什么把「区间 / 规模 / 金额」拆成三组
///
/// 因为读者读报表是**带着问题**来的:
/// 「排的是哪段时间」→ 看区间;「规模多大」→ 看规模;
/// 「花了多少」→ 看金额。
/// 三组之间插空行,让眼睛能按问题跳读;
/// 全部混在一起「信息密度更高」,但读者要逐行找到自己要的那一项。
pub fn render_plan_overview(plan: &ShiftPlan) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
// ---------- 第一组:区间与规模 ----------
lines.push(" ── 区间与规模 ──".to_string());
lines.push(key_value_line(
"排班区间",
OVERVIEW_LABEL_WIDTH,
&format!(
"{} ~ {}",
plan.date_range_start().formatted(),
plan.date_range_end().formatted()
),
));
lines.push(key_value_line(
"区间天数",
OVERVIEW_LABEL_WIDTH,
&format!("{} 天", plan.day_count()),
));
lines.push(key_value_line(
"参与门店",
OVERVIEW_LABEL_WIDTH,
&format!("{} 家", plan.store_count()),
));
lines.push(key_value_line(
"排班槽位",
OVERVIEW_LABEL_WIDTH,
&format!("{} 个", with_thousands_separator(plan.slot_count() as i64)),
));
// ---------- 第二组:工时 ----------
lines.push(String::new());
lines.push(" ── 工时 ──".to_string());
lines.push(key_value_line(
"计薪工时合计",
OVERVIEW_LABEL_WIDTH,
&WorkDuration::from_minutes(plan.total_paid_minutes()).formatted_hours_and_minutes(),
));
lines.push(key_value_line(
"实际出勤合计",
OVERVIEW_LABEL_WIDTH,
&WorkDuration::from_minutes(plan.total_actual_worked_minutes())
.formatted_hours_and_minutes(),
));
lines.push(key_value_line(
"加班合计",
OVERVIEW_LABEL_WIDTH,
&WorkDuration::from_minutes(plan.total_overtime_minutes()).formatted_hours_and_minutes(),
));
// ---------- 第三组:金额 ----------
lines.push(String::new());
lines.push(" ── 金额 ──".to_string());
lines.push(key_value_line(
"结算币种",
OVERVIEW_LABEL_WIDTH,
&format!(
"{}({})",
plan.currency().display_name(),
plan.currency().code()
),
));
lines.push(key_value_line(
"公司基准时薪",
OVERVIEW_LABEL_WIDTH,
&plan.company_base_hourly_rate().formatted_with_currency_code(),
));
lines.push(key_value_line(
"正常工时成本",
OVERVIEW_LABEL_WIDTH,
&plan.total_labor_cost().formatted(),
));
lines.push(key_value_line(
"加班成本",
OVERVIEW_LABEL_WIDTH,
&plan.total_overtime_cost().formatted(),
));
lines.push(key_value_line(
"成本合计",
OVERVIEW_LABEL_WIDTH,
&plan.total_cost().formatted(),
));
// ---------- 第四组:完整性 ----------
//
// 漏排信息即便为零也要打印。理由是:报表读者看到「漏排 0 条」
// 与「报表里没有漏排这一栏」是两种完全不同的安全感——
// 后者无法区分「确实没有」和「忘了检查」。
lines.push(String::new());
lines.push(" ── 完整性 ──".to_string());
lines.push(key_value_line(
"未登记班次漏排",
OVERVIEW_LABEL_WIDTH,
&format!("{} 个槽位", plan.unregistered_gap_count()),
));
lines.push(key_value_line(
"人手不足漏排",
OVERVIEW_LABEL_WIDTH,
&format!("{} 个槽位", plan.staffing_gap_count()),
));
lines.push(key_value_line(
"等级未登记门店",
OVERVIEW_LABEL_WIDTH,
&format!("{} 家(整体跳过)", plan.unregistered_store_grade_count()),
));
lines.push(key_value_line(
"模板池溢出",
OVERVIEW_LABEL_WIDTH,
if plan.is_template_pool_exhausted() { "是(新键一律被拒)" } else { "否" },
));
lines.push(key_value_line(
"方案是否完整",
OVERVIEW_LABEL_WIDTH,
if plan.has_any_gap() { "否 —— 存在漏排" } else { "是 —— 无漏排" },
));
// ---------- 第五组:未登记班次明细 ----------
//
// 只在这一组非空时才打印:空表打印一个表头只会占版面而零信息。
// 「为零也要打印」与「为空就省略」这两条规则的差别在于:
// 前者是**一个已知字段的取值**(有/无是两种可能的取值);
// 后者是**一张明细清单**(无明细时表头本身不构成信息)。
let records = plan.unregistered_shift_records();
if !records.is_empty() {
lines.push(String::new());
lines.push(" ── 未登记班次明细 ──".to_string());
let table = TextTable::new(vec![
TableColumn::left("班次编码", 20),
TableColumn::right("漏排槽位数", 12),
]);
lines.push(format!(" {}", table.header_line()));
lines.push(format!(" {}", table.separator_line()));
for (shift_code_text, count) in records {
lines.push(format!(
" {}",
table.row_line(&[shift_code_text.to_string(), count.to_string()])
));
}
}
lines
}
/// 渲染一份人力成本分布表。
///
/// 参数 `breakdown`:`analysis` 层产出的任一维度分组结果。
/// 返回:若干行文本。
///
/// ## 一个函数服务三个维度
///
/// 报表不关心分组是按时段、按班次还是按门店等级——
/// 它只需要「一组 (标签, 统计)」。因此本函数接收 `PayrollBreakdown`
/// 而不接收任何具体维度,新增维度(例如按城市)时本函数零改动。
/// 这与 `build_payroll_breakdown` 接收闭包的设计是同一条原则的两端:
/// **分析层不关心维度怎么来的,表示层不关心维度是什么。**
pub fn render_payroll_breakdown(breakdown: &PayrollBreakdown) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
lines.push(format!(" ── 按{}分布 ──", breakdown.dimension_label));
// 列宽一律按**该列可能出现的最宽值**定,而不是按「大部分值有多宽」。
// 「计薪工时」列最宽值形如 `79247 小时 30 分`(16 显示列)——
// 若按常见的 `7 小时 0 分`(10 列)定宽,合计行就会被悄悄截成
// `79247 小时 30`,而**截断后的表格看起来仍然整齐**,极难被发现。
let table = TextTable::new(vec![
TableColumn::left("分组", 13),
TableColumn::right("槽位数", 7),
TableColumn::right("计薪工时", 16),
TableColumn::right("人力成本", 14),
TableColumn::right("加班成本", 11),
TableColumn::right("成本占比", 8),
TableColumn::right("槽位均成本", 12),
]);
lines.push(format!(" {}", table.header_line()));
lines.push(format!(" {}", table.separator_line()));
for entry in &breakdown.entries {
lines.push(format!(
" {}",
table.row_line(&[
entry.label.clone(),
entry.slot_count.to_string(),
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.average_cost_per_slot().formatted(),
])
));
}
lines.push(format!(" {}", table.separator_line()));
// 合计行与明细行同一张表渲染,因此列宽天然一致——
// 若合计行单独用一套格式,最容易出现「合计行与明细行差一列」的经典缺陷。
lines.push(format!(
" {}",
table.row_line(&[
format!("合计 {} 组", breakdown.category_count()),
breakdown.slot_count.to_string(),
WorkDuration::from_minutes(breakdown.total_paid_minutes).formatted_hours_and_minutes(),
breakdown.labor_total.formatted(),
breakdown.overtime_total.formatted(),
"100.00%".to_string(),
breakdown.average_cost_per_slot().formatted(),
])
));
lines.push(String::new());
lines.push(format!(
" · 结算币种:{}({})",
breakdown.currency().display_name(),
breakdown.currency().code()
));
// 口径必须在报表里写明,而不是只写在代码注释里——
// 否则读者无法判断「槽位均成本」到底含不含加班。
lines.push(
" · 口径:本表的「槽位均成本」「每小时平均成本」均为**含加班的总成本**除以数量;"
.to_string(),
);
lines.push(
" 「人力成本」「加班成本」两列则是账目本身,不含对方。两类数字不要混着加减。"
.to_string(),
);
lines.push(format!(
" · 每次排班平均总成本:{}(共 {} 个槽位)",
breakdown.average_cost_per_slot().formatted(),
breakdown.slot_count
));
lines.push(format!(
" · 每计薪小时平均总成本:{}",
breakdown.average_cost_per_hour().formatted()
));
lines.push(format!(
" · 加班成本占总成本:{}",
// 这里把 `i64` 万分比交给 `Ratio` 再取百分比文本——
// 不是在做除法,而是**换一种表示法**(万分比 → 百分比字符串)。
// 除以 100 这件事由 `Ratio::as_percent_text` 内部完成,
// 它同时处理了负号与小数位,比在报表里手写 `x / 100` 稳。
Ratio::from_basis_points(breakdown.overtime_share_basis_points()).as_percent_text()
));
lines
}
/// 渲染班次模板池的内容清单。
///
/// 参数 `factory`:班次模板工厂。
/// 返回:若干行文本。
///
/// ## 这一屏是享元模式的「证据」
///
/// 遍历池里的每个实例并打印其内容。
/// 读者会看到:无论排了 1.2 万个槽位,这一屏**行数永远不变**——
/// 因为池里只有 7 个实例。这份「行数恒定」的视觉印象,
/// 比任何解释性的文字都更能说明享元在做什么。
pub fn render_shift_template_inventory(factory: &ShiftTemplateFactory) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
lines.push(" ── 班次模板池内容清单 ──".to_string());
let table = TextTable::new(vec![
TableColumn::left("班次", 10),
TableColumn::left("技能门槛", 8),
TableColumn::left("计薪时段", 10),
// 时刻列要容得下「22:30-次日 06:30」这种跨零点写法(16 显示列)。
TableColumn::left("时刻", 18),
TableColumn::right("计薪时长", 12),
TableColumn::right("综合系数", 10),
TableColumn::left("安保/双人", 12),
]);
lines.push(format!(" {}", table.header_line()));
lines.push(format!(" {}", table.separator_line()));
// 用回调遍历而不是先收集成 `Vec`——本工程的 `SharedHandle::strong_count`
// 参与统计,多克隆一次句柄就会让「共享倍数」多 1。
// 因此「只读遍历」必须用回调形式(见 `SharedPool::for_each_entry` 文档)。
//
// 但回调内不能直接 `push` 到 `lines`(闭包借用冲突要靠 `RefCell`,
// 而这里没必要引入内部可变性),因此先把行收集到局部 `Vec`,
// 回调结束后再并入输出。`Vec<String>` 的克隆代价与正确性相比不值一提。
let mut rows: Vec<Vec<String>> = Vec::new();
factory.pool().for_each_entry(|key, template| {
rows.push(vec![
template.shift_code().display_name().to_string(),
template.minimum_skill_grade().display_name().to_string(),
template.pay_period_kind().display_name().to_string(),
template.time_range_text(),
template.paid_duration().formatted_hours_and_minutes(),
template.combined_pay_multiplier().as_multiplier_text(),
format!(
"{}/{}",
yes_no(template.requires_security_presence()),
yes_no(template.requires_dual_presence())
),
]);
// 键的紧凑文本不进主表(太长会挤掉内容列),
// 但在调试时它是定位「两个实例为什么没合并」的第一手线索。
// 因此把它作为一个 `debug` 断言的载体保留在闭包内。
debug_assert!(!key.compact_text().is_empty(), "键的紧凑文本不应为空");
});
// 按班次显示顺序排序,保证两次运行输出一致。
// 池的遍历顺序取决于 `HashMap` 的迭代顺序(Rust 默认使用了随机种子),
// **不排序就不能复现**——这一点在「数字可核对」的要求下是硬约束。
rows.sort_by(|left, right| left[0].cmp(&right[0]).then(left[2].cmp(&right[2])));
for row in &rows {
lines.push(format!(" {}", table.row_line(row)));
}
lines.push(format!(" {}", table.separator_line()));
lines.push(format!(
" · 池内实例数:{} 个(登记班次时刻表 {} 种,计薪时段差异会各占一个实例)",
factory.pool().entry_count(),
factory.registered_shift_count()
));
lines
}
/// 渲染门店等级配置池的内容清单。
///
/// 参数 `factory`:门店等级配置工厂。
/// 返回:若干行文本。
///
/// 与班次模板清单同理:**行数恒定**是共享生效的视觉证据。
/// 差别在于本族的享元含两个 `Vec`(标准班次列表、合规备注),
/// 因此池内字节数不为零常量——这一屏也顺带让读者看到
/// 「享元不必是纯 POD,含堆内容的类型同样可以共享」。
pub fn render_store_grade_inventory(factory: &StoreGradeFactory) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
lines.push(" ── 门店等级配置池内容清单 ──".to_string());
let table = TextTable::new(vec![
TableColumn::left("门店等级", 12),
TableColumn::right("每班人数", 10),
TableColumn::left("营业时段", 14),
TableColumn::right("标准班次", 10),
TableColumn::left("专属安保", 10),
TableColumn::left("每日盘点", 10),
TableColumn::right("备注条数", 10),
]);
lines.push(format!(" {}", table.header_line()));
lines.push(format!(" {}", table.separator_line()));
let mut rows: Vec<Vec<String>> = Vec::new();
factory.pool().for_each_entry(|_grade, profile| {
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.standard_shift_count().to_string(),
if profile.requires_dedicated_security() { "需要" } else { "不需要" }.to_string(),
if profile.daily_audit_required() { "需要" } else { "不需要" }.to_string(),
profile.compliance_notes().len().to_string(),
]);
});
rows.sort_by(|left, right| left[0].cmp(&right[0]));
for row in &rows {
lines.push(format!(" {}", table.row_line(row)));
}
lines.push(format!(" {}", table.separator_line()));
lines.push(format!(
" · 池内实例数:{} 个(登记门店等级 {} 种,键即等级标签故二者相等)",
factory.pool().entry_count(),
factory.registered_grade_count()
));
// ---------- 合规备注明细 ----------
//
// 备注是「为什么这个等级要这么配」的书面依据,放在清单之后单独列出。
// 放进主表会把内容列撑爆(每条备注都超过 20 列),
// 这属于**选择正确的呈现形态**,而不是「为了好看牺牲信息」。
lines.push(String::new());
lines.push(" ── 各等级合规备注 ──".to_string());
let mut note_rows: Vec<Vec<String>> = Vec::new();
factory.pool().for_each_entry(|_grade, profile| {
for note in profile.compliance_notes() {
note_rows.push(vec![
profile.grade().display_name().to_string(),
(*note).to_string(),
]);
}
});
note_rows.sort_by(|left, right| left[0].cmp(&right[0]).then(left[1].cmp(&right[1])));
if note_rows.is_empty() {
lines.push(" · (无)".to_string());
} else {
for row in ¬e_rows {
lines.push(format!(" · {}:{}", row[0], row[1]));
}
}
lines
}
/// 把布尔值渲染成「是 / 否」。
///
/// 参数 `value`:布尔值。
/// 返回:`"是"` 或 `"否"`。
///
/// 不用 ✅/❌ 之类的符号:本工程的输出要被人工核对,
/// 而符号在不同终端字体下宽度可能不同(`is_wide_character` 未必能覆盖),
/// 会破坏已经精心对齐的列。汉字「是/否」宽度稳定可测。
fn yes_no(value: bool) -> &'static str {
if value {
"是"
} else {
"否"
}
}
//!# 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 : labor_cost.rs
//! # 人力成本公式 —— 一个可独立核对的纯函数
//!
//! ## 为什么公式要单独成文件、做成纯函数
//!
//! 本工程的报表要打印金额,且要求**人工可核对**。
//! 若把公式写在 `ShiftSlot::labor_cost` 里(作为方法),
//! 核对时就必须先构造一个合法槽位、再调用它——
//! 而构造槽位又需要享元句柄、门店、员工……核对成本极高。
//!
//! 抽成纯函数后,核对变成一次直接的函数调用:
//!
//! ```ignore
//! // 手算:¥28.00/时 × 1.00(技能)× 1.00(班次)× 1.00(时段)
//! // = ¥28.00/时;× 420 分钟 / 60 = ¥196.00
//! assert_eq!(
//! compute_labor_cost(&rate_28_00, Ratio::one(), Ratio::one(), 420, 0).formatted(),
//! "¥196.00".to_string()
//! );
//! ```
//!
//! ## 舍入链:四次算术,四处明确口径
//!
//! ```text
//! ① store_rate = base_rate × 门店指数 (basis_points,一次舍入)
//! ② effective_rate = store_rate × 模板综合系数 (basis_points,一次舍入)
//! ③ 工时成本 = effective_rate × 分钟 / 60 (minute_fraction,一次舍入)
//! ④ 槽位成本 = 工时成本 + 工龄津贴 (整数加法,不舍入)
//! ```
//!
//! **每一步的舍入口径都由 `domain` 层提供**(`scale_by_basis_points` /
//! `scale_by_minute_fraction`),本文件不自己写任何除法。
//! 这样「舍入方向」(远离零)在全工程只有一处定义。
//!
//! ## 一处刻意的口径选择:不合并乘法
//!
//! 数学上 `base × index × multiplier / 10000²` 可以一次算完(只舍入一次),
//! 比上面两步各舍入一次更精确。本工程仍选两步,理由是
//! **报表要打印中间值**(门店时薪、生效时薪),而这些中间值必须与
//! 最终金额口径一致。若一次算完,报表里的「生效时薪」就得另算一遍,
//! 于是出现两个可能不一致的口径——审计时无法解释。
//!
//! 「可解释性优先于最后一位精度」——这个取舍要写下来,
//! 否则后来者会把它「优化」掉。
use crate::domain::{CurrencyAmount, Ratio};
/// 加班时薪倍数(1.5 倍,即 15000 万分点)。
///
/// 放在本文件而不是 `domain`:加班倍数由**本工程的记账政策**决定,
/// 不是普适的领域事实(不同地区法定倍数不同)。
/// 领域层应只承载「不随本工程业务选择而变」的概念。
pub const OVERTIME_MULTIPLIER_BASIS_POINTS: i64 = 15_000;
/// 计算一个槽位的单班人力成本(不含加班)。
///
/// 参数 `company_base_hourly_rate`:公司基准时薪;
/// `store_pay_index`:门店时薪指数;
/// `combined_pay_multiplier`:班次模板的综合系数(技能 × 班次 × 时段);
/// `paid_minutes`:计薪分钟数(**来自享元模板**);
/// `seniority_allowance_minor_units`:该员工的每班工龄津贴(最小单位数)。
/// 返回:该槽位的成本。
///
/// ## 手算示例(用于人工核对)
///
/// 取公司基准 ¥28.00/时(2800 分)、旗舰店指数 11500(1.15)、
/// 早班模板系数 10000(1.00)、计薪 420 分钟、津贴 0:
///
/// ```text
/// ① 2800 × 11500 / 10000 = 3220.0 → ¥32.20/时(门店生效时薪)
/// ② 3220 × 10000 / 10000 = 3220.0 → ¥32.20/时(含班次系数)
/// ③ 3220 × 420 / 60 = 22540.0 → ¥225.40
/// ④ 22540 + 0 = 22540 → ¥225.40
/// ```
///
/// 交叉验证:¥32.20/时 × 7 小时 = ¥225.40 ✓
pub fn compute_labor_cost(
company_base_hourly_rate: &CurrencyAmount,
store_pay_index: Ratio,
combined_pay_multiplier: Ratio,
paid_minutes: u32,
seniority_allowance_minor_units: i64,
) -> CurrencyAmount {
// ① 公司基准时薪 → 门店生效时薪。
let store_rate: CurrencyAmount =
company_base_hourly_rate.scale_by_basis_points(store_pay_index.basis_points());
// ② 门店生效时薪 → 含班次/技能/时段系数的生效时薪。
let effective_rate: CurrencyAmount =
store_rate.scale_by_basis_points(combined_pay_multiplier.basis_points());
// ③ 时薪 × 计薪分钟 / 60 → 工时成本。
// 用统一入口而非 `× minutes / 60`:见 `scale_by_minute_fraction` 的说明。
let worked_cost: CurrencyAmount = effective_rate.scale_by_minute_fraction(paid_minutes);
// ④ 加上工龄津贴。津贴与基准时薪同币种,因此用同一个币种构造。
let allowance: CurrencyAmount =
CurrencyAmount::from_minor_units(seniority_allowance_minor_units, company_base_hourly_rate.currency());
// `add` 在币种不一致时返回 `None`。此处币种必然一致(由构造保证),
// 因此用 `unwrap_or(worked_cost)` 而不是 `expect`:
// 万一不变量被破坏,退化为「不计津贴」并让金额偏小 —— 偏小会在
// 与手算对照时被发现,而 panic 会让整份报表拿不到。
worked_cost.add(&allowance).unwrap_or(worked_cost)
}
/// 计算加班部分的人力成本(在正常工时的 **顶部** 叠加)。
///
/// 参数 `company_base_hourly_rate` / `store_pay_index` /
/// `overtime_minutes`。
/// 返回:加班成本。
///
/// ## 为什么加班不用班次模板的综合系数
///
/// 加班费按**法定倍数**计(本工程取 1.5 倍),与「当时上的是什么班」无关。
/// 若沿用班次系数,夜班加班就会得到 1.25 × 1.5 = 1.875 倍的叠加结果——
/// 这在某些地区确实成立,但本工程的记账政策明确「加班统一 1.5 倍」。
///
/// **把政策差异显式写出来**(而不是默默沿用班次系数),
/// 是为了让「本工程选了哪种口径」可被读者检查——
/// 一个没写出口径的加班公式,读者无法判断它是漏了班次系数还是刻意不加。
pub fn compute_overtime_cost(
company_base_hourly_rate: &CurrencyAmount,
store_pay_index: Ratio,
overtime_minutes: u32,
) -> CurrencyAmount {
// ① 公司基准时薪 → 门店生效时薪(与正常工时同一口径)。
let store_rate: CurrencyAmount =
company_base_hourly_rate.scale_by_basis_points(store_pay_index.basis_points());
// ② 应用加班倍数。
let overtime_rate: CurrencyAmount =
store_rate.scale_by_basis_points(OVERTIME_MULTIPLIER_BASIS_POINTS);
// ③ 按分钟折算。
overtime_rate.scale_by_minute_fraction(overtime_minutes)
}
/// 供报表展示:把门店时薪指数与模板系数合成为一个「生效时薪」。
///
/// 参数 `company_base_hourly_rate` / `store_pay_index` / `combined_pay_multiplier`。
/// 返回:生效时薪。
///
/// ## 为什么单独提供一个函数(而不让报表自己算)
///
/// 报表要打印「生效时薪」这一中间值。若报表自己写两步 `scale_by_basis_points`,
/// 就出现了第二个执行相同计算的地方——将来若调整口径(如加入地区补贴),
/// 必须记得改两处。
///
/// 本函数与 [`compute_labor_cost`] 的前两步**共用同一实现路径**
/// (都是依次调用两次 `scale_by_basis_points`),
/// 因此两者打印出的数字天然一致。
pub fn compute_effective_hourly_rate(
company_base_hourly_rate: &CurrencyAmount,
store_pay_index: Ratio,
combined_pay_multiplier: Ratio,
) -> CurrencyAmount {
let store_rate: CurrencyAmount =
company_base_hourly_rate.scale_by_basis_points(store_pay_index.basis_points());
store_rate.scale_by_basis_points(combined_pay_multiplier.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 : schedule_assembler.rs
//! # 排班装配器 —— 模式的「客户端」
//!
//! ## 它在模式里的位置
//!
//! GoF 的 Client 角色。它做三件事:
//! 1. **驱动享元工厂**:为每个「门店 × 日期 × 班次」组合请求共享模板;
//! 2. **填充外在状态**:把门店、日期、员工、实际工时放进新建的槽位;
//! 3. **持有共享句柄**:槽位里存的是 `Rc<ShiftTemplate>`,不是模板副本。
//!
//! ## 关键:客户端只持有引用,从不复制享元
//!
//! 装配器拿到的是 `SharedHandle<ShiftTemplate>`。它在整个循环里
//! **从未调用任何「克隆模板内容」的操作**——`SharedHandle::clone` 是
//! 引用计数加一,不是深拷贝。
//!
//! ⚠️ 这个区别是 Rust 里最容易被忽略的一处:`Rc<T>` 实现了 `Clone`,
//! 而 `clone()` **看起来**像复制。若有人把 `slot.template().clone()`
//! 误认为「复制一份模板」,可能会写出「先 clone 再改」的代码——
//! 但 `Rc<T>` 的 `Deref` 只给 `&T`,改不了,因此**这类错误在 Rust 里
//! 根本写不出来**。这正是类型系统替我们守住享元不变性的地方。
//!
//! 对照 Java/C#:那里 `flyweight.clone()` 会真的复制,
//! 而「不要修改共享对象」只能靠注释与纪律维持。
//!
//! ## 装配的嵌套层次
//!
//! ```text
//! for 门店 (120)
//! └ 取门店等级配置(享元,共享)
//! for 日期 (31)
//! └ 判定计薪时段
//! for 该等级的默认班次 (2~4)
//! └ 取班次模板(享元,共享)→ 生成槽位
//! if 是盘点日 → 追加盘点夜班(另取一个模板)
//! ```
//!
//! 三层循环共约 1.1 万次迭代,其中**取模板的调用约 1.1 万次,
//! 但只有 20 个不同的键真正构造过实例**
//! (5 个班次 × 4 个计薪时段)——其余全命中。这就是享元省内存的机制。
//! ## 轮转序号的算法(一处刻意的选择)
//!
//! 选人时要给 [`super::store_profile::StoreProfile::pick_staff_for`]
//! 一个「轮转序号」,它对合格人数取模决定选谁。
//!
//! 轮转序号 = `日偏移 × 每日最多班次数 + 班次序号`。
//!
//! ⚠️ **不要改回「每个班次各一个计数器」**。那样每天的第一个班次
//! 都会取到「合格名单里的第 0 个人」,于是同一天的所有班次
//! 排给**同一个员工**——这会被合规体检的第 6 条规则
//! (`NO_DOUBLE_BOOKING`)抓到。本工程直接把序号乘以日偏移,
//! 让同一天内的班次序号在取模后自然错开。
//!
//! 这里不引入随机数:序号完全由「第几天、第几个班次」决定,
//! 因此两次运行的人员分配完全一致,报表数字可核对。
use crate::domain::{
SkillGrade, SHIFT_CODE_NIGHT_AUDIT, SKILL_GRADE_SENIOR, SKILL_GRADE_TECHNICIAN,
};
use crate::flyweight::{
SharedHandle, ShiftTemplate, ShiftTemplateFactory, ShiftTemplateKey, StoreGradeFactory,
StoreGradeProfile,
};
use crate::support::calendar_date::CalendarDate;
use crate::support::deterministic_code::build_seed;
use super::schedule_request::ScheduleRequest;
use super::shift_plan::ShiftPlan;
use super::shift_slot::ShiftSlot;
use super::staff_member::StaffMember;
/// 每日最多排入的班次数(含盘点夜班)。
///
/// 旗舰店有 4 个标准班次(早 / 中 / 晚 / 周末加强),
/// 加上盘点夜班共 5 个,因此取 8 作为「长度足够且是 2、4 的公倍数」的跨度。
/// 取 8 而不是 5 的原因:轮转序号要 `× 每日班次数` 后再对合格人数取模,
/// 而合格人数在本工程里是 2 / 3 / 4。8 与 2、4 都能整除,
/// 保证了「同一天内相邻班次的序号在取模后仍互不相同」。
/// 若取 5,则「合格人数为 4」时相邻班次的序号会隔 5,
/// `(day×5+0) % 4` 与 `(day×5+2) % 4` 会落到同一人。
pub const ROTATION_SHIFTS_PER_DAY: usize = 8;
/// 排班装配器。
///
/// 持有两个工厂的**引用**(`&'a`)而不是所有权:
/// 工厂的生命周期长于装配器(一次装配用完即弃,工厂可以服务多次装配)。
/// 这避免在装配器里 clone 工厂(那会复制整个享元池引用表)。
pub struct ScheduleAssembler<'context> {
template_factory: &'context ShiftTemplateFactory,
grade_factory: &'context StoreGradeFactory,
}
impl<'context> ScheduleAssembler<'context> {
/// 构造一个装配器。
///
/// 参数 `template_factory` / `grade_factory`。
/// 返回:装配器。
pub fn new(
template_factory: &'context ShiftTemplateFactory,
grade_factory: &'context StoreGradeFactory,
) -> ScheduleAssembler<'context> {
ScheduleAssembler {
template_factory,
grade_factory,
}
}
/// 按请求装配出一份排班方案。
///
/// 参数 `request`:排班请求。
/// 返回:排班方案(含全部槽位、两个池快照、以及全部漏排记录)。
pub fn assemble(&self, request: &ScheduleRequest) -> ShiftPlan {
// 先把日期区间展开成列表(一处展开,后面复用,避免重复算日期)。
let dates: Vec<CalendarDate> = request.enumerate_dates();
// 空请求的保护:没有任何日期时直接返回空方案。
// 不做这个判断会让下面的 `first()/last()` 需要 `unwrap`。
let (range_start, range_end): (CalendarDate, CalendarDate) = match (dates.first(), dates.last())
{
(Some(first_date), Some(last_date)) => (*first_date, *last_date),
_ => (request.start_date(), request.start_date()),
};
// 先生成一个空方案骨架,随后逐步填充。
// 这样「漏排记录」可以在循环中随时写入,不必先攒在临时变量里。
let mut plan: ShiftPlan = ShiftPlan::empty_skeleton(
request.company_base_hourly_rate(),
range_start,
range_end,
request.store_count(),
);
// 轮转序号已在调用点算好(见模块文档「轮转序号的算法」),
// 本方法不再维护计数器——计数器的键若按「门店 + 班次」划分,
// 会让同一天的不同班次取到同一个人(同日重复排班)。
// ── 第一层:遍历门店 ──
for store in request.stores() {
// 取该门店等级的共享配置。未登记则整店跳过(记一笔,不中断)。
let grade_profile: SharedHandle<StoreGradeProfile> =
match self.grade_factory.obtain(store.store_grade()) {
Some(profile) => profile,
None => {
plan.record_unregistered_store_grade();
continue;
}
};
// ── 第二层:遍历日期 ──
for shift_date in &dates {
// 判定当天的计薪时段(决定模板系数与键)。
let period_kind = request.period_kind_of(*shift_date);
// 日偏移:本日期距区间起始日的天数(0 起)。
// 用 `days_until` 而不是在循环里自增一个计数变量:
// 自增变量一旦与 `dates` 的构造方式脱钩(例如将来改成跳日排班),
// 就会静默错位;`days_until` 永远从事实出发。
let day_offset: usize =
request.start_date().days_until(shift_date).max(0) as usize;
// 该日轮转序号的基数。
let rotation_base: usize = day_offset * ROTATION_SHIFTS_PER_DAY;
// ── 第三层:遍历该等级的默认班次 ──
for (shift_ordinal, shift_code) in
grade_profile.standard_shift_codes().iter().enumerate()
{
self.assemble_one_slot(
&mut plan,
store,
&grade_profile,
*shift_code,
*shift_date,
period_kind,
rotation_base + shift_ordinal,
request,
);
}
// 盘点日:追加一个盘点夜班(技能要求更高,是另一个模板)。
if request.is_audit_date(*shift_date, grade_profile.daily_audit_required()) {
self.assemble_one_slot(
&mut plan,
store,
&grade_profile,
SHIFT_CODE_NIGHT_AUDIT,
*shift_date,
period_kind,
// 盘点夜班占用当日最后一个轮转槽位。
// 它的技能要求是技师,与白班的高级员工不共用名单,
// 因此即使序号撞上也不会造成「同一人同一天两个班」。
rotation_base + ROTATION_SHIFTS_PER_DAY - 1,
request,
);
}
}
}
// 装配结束:把两个池的最终快照写入方案。
// **必须在全部槽位生成之后取快照**——否则会漏掉最后几个模板,
// 导致「池里实例数」小于实际使用的模板数。
plan.set_pool_snapshots(
self.template_factory.snapshot(),
self.grade_factory.snapshot(),
);
plan
}
/// 装配单个槽位(内部方法,把三层循环的循环体抽出来)。
///
/// 参数过多是嵌套循环的固有代价;抽成方法后循环体只剩一次调用,
/// 可读性显著提升(对照:若不抽,三层循环里会嵌 30 行)。
#[allow(clippy::too_many_arguments)]
fn assemble_one_slot(
&self,
plan: &mut ShiftPlan,
store: &super::store_profile::StoreProfile,
grade_profile: &SharedHandle<StoreGradeProfile>,
shift_code: crate::domain::ShiftCode,
shift_date: CalendarDate,
period_kind: crate::domain::PayPeriodKind,
rotation_index: usize,
request: &ScheduleRequest,
) {
// 该班次需要的最低技能等级。
// 盘点夜班要求技师(涉及鉴定与保险柜操作),其余班次按「高级」配置。
// ## 为什么这个映射写在这里而不进享元键
// 它表达的是**本工程选择「哪个等级的人来上这个班」**这条排班政策,
// 属于装配侧的知识。若要把它做成可配置,应当放进门店等级配置规格。
let required_grade: SkillGrade = if shift_code == SHIFT_CODE_NIGHT_AUDIT {
SKILL_GRADE_TECHNICIAN
} else {
SKILL_GRADE_SENIOR
};
// 构造享元键:班次 × 技能等级 × 计薪时段。
// 注意 `store` **没有**进键——这正是跨店共享的前提。
let template_key = ShiftTemplateKey::new(shift_code, required_grade, period_kind);
// 向工厂取共享模板。
let template: SharedHandle<ShiftTemplate> = match self.template_factory.obtain(template_key)
{
Ok(shared_template) => shared_template,
Err(lookup_error) => {
// 取不到模板:记录失败并**跳过本槽位**,继续处理后面的。
// 这是「一次报全」的实现方式——绝不中断整批装配。
plan.record_lookup_failure(&lookup_error);
return;
}
};
// 挑人:从本店名单里找符合该班次技能要求的员工。
// 轮转序号由调用点给出(见模块文档),取模后保证同一天内
// 不同班次落到不同员工。
let selected_staff: StaffMember = match store.pick_staff_for(required_grade, rotation_index)
{
Some(member) => member,
None => {
// 本店没有该等级员工:记一笔「人力缺口」并跳过。
// 不 fallback 到更低等级——那会掩盖真实的人手不足问题。
plan.record_staffing_gap();
return;
}
};
// 派生槽位序号:由「门店 + 日期 + 班次 + 时段 + 员工」确定性生成。
// 五段全部参与,保证「换任何一项都会得到不同的槽位号」。
let shift_date_text: String = shift_date.formatted();
let slot_serial: u64 = build_seed(&[
store.store_code().code(),
&shift_date_text,
shift_code.code(),
period_kind.code(),
selected_staff.staff_number().text(),
]);
// ── 外在状态:全部在这里填充 ──
// 实际出勤分钟:按序号确定性派生一个 0~10 分钟的早退量。
// 用取模而不是随机数,保证两次运行输出完全一致(数字可核对)。
let paid_minutes: u32 = template.paid_duration().total_minutes();
let early_leave_minutes: u32 = (slot_serial % 11) as u32;
let actual_worked_minutes: u32 = paid_minutes.saturating_sub(early_leave_minutes);
// 加班:约 1/7 的槽位有加班,时长按序号派生 30/45/60/75 分钟。
let overtime_minutes: u32 = if slot_serial % 7 == 0 {
30 + ((slot_serial % 4) as u32) * 15
} else {
0
};
let slot = ShiftSlot::new(
slot_serial,
template,
SharedHandle::clone(grade_profile),
store.store_code(),
store.pay_index(),
shift_date,
selected_staff.staff_number(),
selected_staff
.per_shift_seniority_allowance()
.minor_units(),
actual_worked_minutes,
overtime_minutes,
);
plan.push_slot(slot);
// `request` 在本次调用里只用于「理论上可读取基准时薪」,
// 实际成本计算发生在 `ShiftPlan::total_labor_cost`。
// 这里显式引用一次,避免「参数未被使用」的告警,
// 也表明「装配阶段与计费阶段是分开的」这条设计意图。
let _ = request.company_base_hourly_rate();
}
}
//!# 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 : schedule_request.rs
//! # 排班请求 —— 装配器的输入
//!
//! ## 为什么把输入打包成一个类型
//!
//! 装配相关的输入有六七个维度(门店表、起止日期、节假日、盘点日、
//! 基准时薪、币种)。若作为参数逐个传给 `assemble`:
//! - 签名会很长,且 `&[StoreProfile]`/`&[CalendarDate]` 这类参数
//! 都是引用类型,**顺序写错编译器不报错**(同型参数);
//! - 将来加一个维度(如「地区补贴表」)要改签名与所有调用点。
//!
//! 打包成类型后,字段有名字,加字段不影响既有调用点。
//! 这是「参数对象」模式的应用,与 `ShiftScheduleSpec` 同样的理由。
//!
//! ## 「哪个日期属于哪个计薪时段」是本类型的核心知识
//!
//! 判定顺序(**顺序本身是业务规则,不能调换**):
//! 1. 先判法定节假日——节假日的上浮最高,且可能落在周末;
//! 2. 再判大促日——大促通常是公司指定日期(常落在周末);
//! 3. 再判周末;
//! 4. 最后是平日。
//!
//! 若把「周末」放在「节假日」之前,那么「落在周末的国庆节」会被
//! 按周末(1.10 倍)而不是节假日(2.00 倍)计薪——**少付一倍工资**。
//! 本工程把这个顺序写在一处并加注释,避免有人重排。
//! 第四幕会用「国庆节恰逢周六」这个具体日期把顺序错误的代价算出来。
use crate::domain::{CurrencyAmount, PayPeriodKind, PAY_PERIOD_HOLIDAY, PAY_PERIOD_NORMAL,
PAY_PERIOD_PROMOTION, PAY_PERIOD_WEEKEND};
use crate::support::calendar_date::CalendarDate;
use super::store_profile::StoreProfile;
/// 一次排班装配请求。
#[derive(Debug, Clone)]
pub struct ScheduleRequest {
/// 参与排班的门店表。
stores: Vec<StoreProfile>,
/// 排班起始日期。
start_date: CalendarDate,
/// 连续排班天数。
day_count: u32,
/// 法定节假日日期表。
holiday_dates: Vec<CalendarDate>,
/// 公司大促日日期表。
promotion_dates: Vec<CalendarDate>,
/// 盘点夜班固定落在星期几(0 = 周一 … 6 = 周日)。
///
/// 本工程取周三(客流较低的一天适合闭店盘点)。
audit_weekday: u32,
/// 公司基准时薪(未含门店指数与班次系数)。
company_base_hourly_rate: CurrencyAmount,
}
impl ScheduleRequest {
/// 构造一个排班请求。
///
/// 参数见各字段说明。
/// 返回:排班请求。
#[allow(clippy::too_many_arguments)]
pub fn new(
stores: Vec<StoreProfile>,
start_date: CalendarDate,
day_count: u32,
holiday_dates: Vec<CalendarDate>,
promotion_dates: Vec<CalendarDate>,
audit_weekday: u32,
company_base_hourly_rate: CurrencyAmount,
) -> ScheduleRequest {
ScheduleRequest {
stores,
start_date,
day_count,
holiday_dates,
promotion_dates,
audit_weekday,
company_base_hourly_rate,
}
}
/// 门店表。
pub fn stores(&self) -> &[StoreProfile] {
&self.stores
}
/// 门店数量。
pub fn store_count(&self) -> usize {
self.stores.len()
}
/// 排班起始日期。
pub const fn start_date(&self) -> CalendarDate {
self.start_date
}
/// 排班天数。
pub const fn day_count(&self) -> u32 {
self.day_count
}
/// 盘点夜班落在星期几。
pub const fn audit_weekday(&self) -> u32 {
self.audit_weekday
}
/// 公司基准时薪。
pub const fn company_base_hourly_rate(&self) -> CurrencyAmount {
self.company_base_hourly_rate
}
/// 币种(由基准时薪携带)。
pub const fn currency(&self) -> crate::domain::Currency {
self.company_base_hourly_rate.currency()
}
/// 枚举排班区间内的所有日期。
///
/// 返回:按日期升序的日期列表。
///
/// 用逐日推进而不是「日期 + 序号」的两层循环:本工程的所有
/// 日期计算都走 [`CalendarDate::add_days`],
/// 这样闰年、月末等边界只有一处实现(见该函数的注释)。
pub fn enumerate_dates(&self) -> Vec<CalendarDate> {
let mut dates: Vec<CalendarDate> = Vec::with_capacity(self.day_count as usize);
for day_offset in 0..self.day_count {
dates.push(self.start_date.add_days(day_offset as i32));
}
dates
}
/// 判定某日期属于哪个计薪时段。
///
/// 参数 `date`:日期。
/// 返回:计薪时段。
///
/// ## 判定顺序不可调换(见模块文档)
///
/// 节假日 → 大促 → 周末 → 平日。
/// 这里把顺序写成一个**自顶向下的 if 链**而不是
/// 打分/优先级表:因为顺序本身是业务规则,用 if 链能让
/// 读者一眼看出「谁先谁后」,而优先级表需要读者去比较数值大小。
pub fn period_kind_of(&self, date: CalendarDate) -> PayPeriodKind {
// 1. 法定节假日优先——可能落在周末,必须优先匹配。
if self.holiday_dates.contains(&date) {
return PAY_PERIOD_HOLIDAY;
}
// 2. 公司大促日次之。
if self.promotion_dates.contains(&date) {
return PAY_PERIOD_PROMOTION;
}
// 3. 周末(周六、周日)。
if date.is_weekend() {
return PAY_PERIOD_WEEKEND;
}
// 4. 平日。
PAY_PERIOD_NORMAL
}
/// 某日期是否为盘点日(该店的等级配置要求盘点,且日期落在盘点星期)。
///
/// 参数 `date`:日期;`daily_audit_required`:该店等级是否要求盘点。
/// 返回:应安排盘点夜班时返回 `true`。
///
/// ## 为什么「是否要求盘点」由调用方传入,而不是本方法读等级配置
///
/// 因为等级配置是**享元实例**,要通过工厂获取;
/// 若本方法去取享元,请求对象就依赖了享元工厂,
/// 而请求对象本应是一个纯粹的「输入数据」。
/// 把「谁是享元」留给装配器处理,请求对象只做纯数据判定。
pub fn is_audit_date(&self, date: CalendarDate, daily_audit_required: bool) -> bool {
daily_audit_required && date.weekday_index() == self.audit_weekday
}
/// 排班区间内有多少个节假日(用于报表说明规模)。
pub fn holiday_count_in_range(&self) -> usize {
let end_date: CalendarDate = self.start_date.add_days(self.day_count as i32 - 1);
self.holiday_dates
.iter()
.filter(|date| {
// 闭区间判定:起始日 ≤ 日期 ≤ 结束日。
**date >= self.start_date && **date <= end_date
})
.count()
}
/// 排班区间内有多少个大促日。
pub fn promotion_count_in_range(&self) -> usize {
let end_date: CalendarDate = self.start_date.add_days(self.day_count as i32 - 1);
self.promotion_dates
.iter()
.filter(|date| **date >= self.start_date && **date <= end_date)
.count()
}
}
//!# 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_plan.rs
//! # 排班方案 —— 装配器的输出
//!
//! ## 本类型同时承担三件事
//!
//! 1. **承载结果**:全部排班槽位;
//! 2. **承载统计**:两个享元池的快照(共享度与内存的证据都在这里);
//! 3. **承载失败信息**:未登记班次、池容量溢出——
//! 这些**不让装配失败**,而是随方案一起返回(「一次报全」)。
//!
//! ## 关于第 3 点:为什么不直接返回 `Result`
//!
//! 若某个班次未登记就返回 `Err`,整批 1.2 万个槽位一个都拿不到,
//! 报表只能打印一行错误——而实际上**绝大多数槽位是好的**。
//!
//! 正确的做法是:能排的照排,排不了的在方案里记一笔,
//! 让报表同时给出「排了 11,988 个槽位」与「有 12 个槽位因班次未登记被跳过」。
//! 运营改一次配置就能重跑,不必反复试。
//!
//! 这与同系列工程「一次报全,从不提前 return」的做法一致。
//! 区别在于:Facade 工程把问题收集成事件列表,本工程收集成两个专门的字段
//! ——因为本工程的问题种类少且各自需要不同的处理动作。
use crate::domain::{Currency, CurrencyAmount};
use crate::flyweight::{PoolSnapshot, ShiftTemplateLookupError};
use crate::support::calendar_date::CalendarDate;
use super::labor_cost::compute_overtime_cost;
use super::shift_slot::ShiftSlot;
/// 一份完整的排班方案。
#[derive(Debug, Clone)]
pub struct ShiftPlan {
/// 全部排班槽位。
slots: Vec<ShiftSlot>,
/// 班次模板池的统计快照。
template_pool_snapshot: PoolSnapshot,
/// 门店等级配置池的统计快照。
grade_pool_snapshot: PoolSnapshot,
/// 因「班次未登记」而未能排班的记录(班次编码 + 次数)。
unregistered_shift_records: Vec<(&'static str, u32)>,
/// 因「该店无合格技能等级员工」而未能排班的槽位数。
staffing_gap_count: u32,
/// 因「门店等级未登记」而被整体跳过的门店数。
unregistered_store_grade_count: u32,
/// 模板池是否发生过容量溢出。
template_pool_capacity_exceeded: bool,
/// 本次排班使用的公司基准时薪。
company_base_hourly_rate: CurrencyAmount,
/// 排班区间起始日。
date_range_start: CalendarDate,
/// 排班区间结束日。
date_range_end: CalendarDate,
/// 参与排班的门店数。
store_count: usize,
}
/// ## 一处刻意的不对称:只有「空骨架」构造器,没有「一次性构造」构造器
///
/// 本类型有一个 `pub(crate) fn empty_skeleton(...)`,却**没有**一个
/// 接收全部字段的 `new(...)`。这不是遗漏:
///
/// - 方案是「逐步填出来」的(槽位一个个 push、失败记录一条条记、
/// 池快照在最后才取),因此真正可用的入口只有「先建骨架」这一个;
/// - 若再提供一个接收 11 个参数的 `new(...)`,那么任何一次字段增加
/// 都要改两处构造代码,且两处会各自漂移(比如 `new` 忘了校验不变量)。
///
/// **只保留一条构造路径**,是让「不变量只有一处维护点」的最简单办法。
/// 若将来真的需要「由外部数据整批构造方案」(例如从数据库读回一份历史方案),
/// 那时再加一个**专门的** `from_persisted(...)`,
/// 并在其中显式处理「历史数据可能缺字段」的差异——
/// 而不是把它硬塞进一个与 `empty_skeleton` 同形的 `new` 里。
impl ShiftPlan {
/// 排班槽位列表。
pub fn slots(&self) -> &[ShiftSlot] {
&self.slots
}
/// 槽位数量。
pub fn slot_count(&self) -> usize {
self.slots.len()
}
/// 参与排班的门店数。
pub const fn store_count(&self) -> usize {
self.store_count
}
/// 排班区间起始日。
pub const fn date_range_start(&self) -> CalendarDate {
self.date_range_start
}
/// 排班区间结束日。
pub const fn date_range_end(&self) -> CalendarDate {
self.date_range_end
}
/// 排班区间天数(含首尾)。
pub fn day_count(&self) -> i32 {
self.date_range_start.days_until(&self.date_range_end) + 1
}
/// 币种。
pub const fn currency(&self) -> Currency {
self.company_base_hourly_rate.currency()
}
/// 公司基准时薪。
pub const fn company_base_hourly_rate(&self) -> CurrencyAmount {
self.company_base_hourly_rate
}
/// 班次模板池快照。
pub const fn template_pool_snapshot(&self) -> PoolSnapshot {
self.template_pool_snapshot
}
/// 门店等级配置池快照。
pub const fn grade_pool_snapshot(&self) -> PoolSnapshot {
self.grade_pool_snapshot
}
/// 因班次未登记而未排班的记录。
pub fn unregistered_shift_records(&self) -> &[(&'static str, u32)] {
&self.unregistered_shift_records
}
/// 是否存在未登记班次导致漏排。
pub fn has_unregistered_shift_gap(&self) -> bool {
!self.unregistered_shift_records.is_empty()
}
/// 未登记班次导致的漏排总数。
pub fn unregistered_gap_count(&self) -> u32 {
self.unregistered_shift_records
.iter()
.map(|(_, count)| *count)
.sum()
}
/// 因无合格员工导致的漏排槽位数。
pub const fn staffing_gap_count(&self) -> u32 {
self.staffing_gap_count
}
/// 因门店等级未登记而被整体跳过的门店数。
pub const fn unregistered_store_grade_count(&self) -> u32 {
self.unregistered_store_grade_count
}
/// 是否存在任何形式的漏排。
///
/// 把四类失败合成一个布尔值供报表判断「方案是否完整」。
/// 但**各类计数仍分别保留**——合起来只是为了快速判断,
/// 不能因为「有个总开关」就把明细丢掉。
pub fn has_any_gap(&self) -> bool {
self.has_unregistered_shift_gap()
|| self.staffing_gap_count > 0
|| self.unregistered_store_grade_count > 0
}
/// 记录一次「无合格员工」的漏排。
pub(crate) fn record_staffing_gap(&mut self) {
self.staffing_gap_count += 1;
}
/// 记录一次「门店等级未登记」的门店跳过。
pub(crate) fn record_unregistered_store_grade(&mut self) {
self.unregistered_store_grade_count += 1;
}
/// 模板池是否溢出过。
pub const fn is_template_pool_exhausted(&self) -> bool {
self.template_pool_capacity_exceeded
}
/// 全部槽位的计薪工时总和(分钟)。
///
/// ## 为什么遍历槽位而不是「槽位数 × 每班时长」
///
/// 因为不同班次的计薪时长不同(早班 420 分钟、盘点夜班 390 分钟、
/// 周末加强班 465 分钟)。用乘法只有在「所有班次时长一致」时才成立,
/// 而那是一个会随业务变化而失效的假设。
/// 遍历求和的代价是 1.2 万次加法(微秒级),换来的是不会静默算错。
pub fn total_paid_minutes(&self) -> u32 {
let mut total: u32 = 0;
for slot in &self.slots {
total = total.saturating_add(slot.paid_minutes());
}
total
}
/// 全部槽位的实际出勤分钟总和。
pub fn total_actual_worked_minutes(&self) -> u32 {
let mut total: u32 = 0;
for slot in &self.slots {
total = total.saturating_add(slot.actual_worked_minutes());
}
total
}
/// 全部槽位的加班分钟总和。
pub fn total_overtime_minutes(&self) -> u32 {
let mut total: u32 = 0;
for slot in &self.slots {
total = total.saturating_add(slot.overtime_minutes());
}
total
}
/// 全部槽位的人力成本总和(含工龄津贴,不含加班)。
///
/// ## 累加方式:先全加、最后一次性给出
///
/// 每笔槽位成本已在 `compute_labor_cost` 里被舍入到「分」。
/// 这里的累加是**整数相加,不再舍入**——因此总额恰好等于
/// 各槽位成本之和,**没有二次舍入误差**。
///
/// 这一点让本工程的总额可被逐行核对:把报表里打印的
/// 前 N 行成本相加,应当精确等于它打印的小计。
pub fn total_labor_cost(&self) -> CurrencyAmount {
let currency: Currency = self.currency();
let mut total_minor_units: i64 = 0;
for slot in &self.slots {
let slot_cost: CurrencyAmount = slot.labor_cost(&self.company_base_hourly_rate);
total_minor_units = total_minor_units.saturating_add(slot_cost.minor_units());
}
CurrencyAmount::from_minor_units(total_minor_units, currency)
}
/// 全部槽位的加班成本总和。
///
/// 加班成本**不在** `total_labor_cost` 里:本工程的记账口径是
/// 「正常工时」与「加班」两本账,报表分列显示。
/// 若合成一个数字,运营无法看出「加班花了多少」这个可管控的量。
pub fn total_overtime_cost(&self) -> CurrencyAmount {
let currency: Currency = self.currency();
let mut total_minor_units: i64 = 0;
for slot in &self.slots {
let overtime_cost: CurrencyAmount = compute_overtime_cost(
&self.company_base_hourly_rate,
slot.store_pay_index(),
slot.overtime_minutes(),
);
total_minor_units = total_minor_units.saturating_add(overtime_cost.minor_units());
}
CurrencyAmount::from_minor_units(total_minor_units, currency)
}
/// 总成本 = 正常工时成本 + 加班成本。
pub fn total_cost(&self) -> CurrencyAmount {
let labor: CurrencyAmount = self.total_labor_cost();
let overtime: CurrencyAmount = self.total_overtime_cost();
// 同币种相加必然成功;用 `unwrap_or(labor)` 兜底而非 `expect`,
// 理由与 `compute_labor_cost` 里一致:偏小的数字能被核对发现。
labor.add(&overtime).unwrap_or(labor)
}
/// 遍历并统计「每个模板被多少个槽位共享」。
///
/// 返回:`(模板显示名, 共享槽位数)` 列表,按共享数降序。
///
/// ## 实现要点:按模板身份聚合,而不是按内容
///
/// 用 `SharedHandle::as_ptr()` 取指针地址作为身份键——
/// **这正是「共享」在运行期的定义:同一个地址即同一个对象。**
/// 若改用「内容比较」(如按班次编码聚合),会把
/// 「两份内容相同但地址不同的实例」误算成一份,
/// 从而**掩盖共享失效的 bug**(那正是本工程要检测的东西)。
///
/// 因此这里刻意用地址。地址是不可复现的(不同运行可能不同),
/// 但本函数只用来做**聚合**,不把地址打印出去,
/// 所以不影响输出的可复现性。
pub fn template_share_distribution(&self) -> Vec<(String, usize)> {
// 地址 → (显示名, 计数)。
// 用 `std::collections::HashMap` 需要 `usize` 键,地址用 `*const T` 转来。
let mut aggregated: Vec<(usize, String, usize)> = Vec::new();
for slot in &self.slots {
let address: usize = std::rc::Rc::as_ptr(slot.template()) as usize;
let display_name: String = format!(
"{}·{}·{}",
slot.template().shift_code().display_name(),
slot.template().minimum_skill_grade().display_name(),
slot.template().pay_period_kind().display_name()
);
// 线性查找(模板只有个位数,代价可忽略);
// 用它而不是 `HashMap` 是为了让「按地址聚合」这一步在代码里显式可见。
match aggregated.iter_mut().find(|(existing, _, _)| *existing == address) {
Some((_, _, count)) => *count += 1,
None => aggregated.push((address, display_name, 1)),
}
}
// 转成对外形态并按共享数降序排列。
let mut distribution: Vec<(String, usize)> = aggregated
.into_iter()
.map(|(_, display_name, count)| (display_name, count))
.collect();
distribution.sort_by(|left, right| right.1.cmp(&left.1));
distribution
}
/// 把「装配过程中的一次取模板失败」记录进方案。
///
/// 参数 `error`:取模板时的失败原因。
///
/// 供装配器调用:装配器在循环里遇到失败时调用本方法,
/// 然后继续处理下一个槽位(**不中断**)。
/// 这正是「一次报全」的实现方式。
pub(crate) fn record_lookup_failure(&mut self, error: &ShiftTemplateLookupError) {
match error {
ShiftTemplateLookupError::UnregisteredShift { shift_code_text } => {
// 已有记录则计数 +1,否则新增一条。
match self
.unregistered_shift_records
.iter_mut()
.find(|(code, _)| *code == *shift_code_text)
{
Some((_, count)) => *count += 1,
None => self
.unregistered_shift_records
.push((shift_code_text, 1)),
}
}
ShiftTemplateLookupError::PoolCapacityExceeded { .. } => {
// 池溢出只需标记「发生过」,因为它是全局性问题
// (一次溢出意味着后续所有新键都会溢出),
// 逐次计数只会让报表出现一个巨大的数字而不增加信息量。
self.template_pool_capacity_exceeded = true;
}
}
}
/// 追加一个槽位。
pub(crate) fn push_slot(&mut self, slot: ShiftSlot) {
self.slots.push(slot);
}
/// 替换两个池快照(装配结束后由装配器写入最终值)。
pub(crate) fn set_pool_snapshots(
&mut self,
template_pool_snapshot: PoolSnapshot,
grade_pool_snapshot: PoolSnapshot,
) {
self.template_pool_snapshot = template_pool_snapshot;
self.grade_pool_snapshot = grade_pool_snapshot;
}
/// 构造一个空的方案骨架(供装配器逐步填充)。
pub(crate) fn empty_skeleton(
company_base_hourly_rate: CurrencyAmount,
date_range_start: CalendarDate,
date_range_end: CalendarDate,
store_count: usize,
) -> ShiftPlan {
ShiftPlan {
slots: Vec::new(),
// 占位快照:装配结束后会被 `set_pool_snapshots` 覆盖。
// 用 `default()` 而非真实快照,是为了让「忘记写入快照」
// 表现为「报表显示全 0」而非「显示上一次的旧值」——
// 前者一眼可见,后者会误导。
template_pool_snapshot: PoolSnapshot::default(),
grade_pool_snapshot: PoolSnapshot::default(),
unregistered_shift_records: Vec::new(),
staffing_gap_count: 0,
unregistered_store_grade_count: 0,
template_pool_capacity_exceeded: false,
company_base_hourly_rate,
date_range_start,
date_range_end,
store_count,
}
}
}
//!# 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_slot.rs
//! # 排班槽位 —— 外在状态(Context)
//!
//! ## 这是「不被共享的那一半」
//!
//! 每个槽位代表「某门店、某天、某个班次、某人」的一次具体排班。
//! 它的外在状态**每个都不同**,因此不能进享元,只能各自持有。
//!
//! ## 字段划分表(这张表就是本模式的核心)
//!
//! | 字段 | 字节 | 共享? | 理由 |
//! |---|---|---|---|
//! | `slot_serial` | 8 | 否 | 每个槽位唯一 |
//! | `template` | 8 | **指针→享元** | 内在状态,7 份共享 |
//! | `grade_profile` | 8 | **指针→享元** | 第二族,3 份共享 |
//! | `store_code` | 32 | 否 | 每槽位所属门店不同 |
//! | `store_pay_index` | 8 | 否 | 各店指数不同 |
//! | `shift_date` | 6 | 否 | 每天不同 |
//! | `staff_number` | 16 | 否 | 每人不同 |
//! | `seniority_allowance_minor_units` | 8 | 否 | 工龄津贴(按人) |
//! | `actual_worked_minutes` | 4 | 否 | 实际出勤 |
//! | `overtime_minutes` | 4 | 否 | 加班时长 |
//!
//! 共享的两项各占 8 字节(一个胖指针),而它们指向的内容
//! 在「不共享」版本里要各占数百字节。这个对比就是本工程的全部论证。
//!
//! ## 两个刻意的「不奢靡」决定
//!
//! ### 1. 槽位号用 `u64` 而不是 `String`
//!
//! 直观写法是 `slot_code: String`(形如 `"SLOT-1A2B3C4D5E6F"`),
//! 但那会给**每个槽位**带来 24 字节 `String` 头 + 一次堆分配(约 20 字节内容
//! + 分配器开销)。1.2 万个槽位就是约 500KB 的额外堆内存与 1.2 万次分配。
//!
//! 改存 `u64`(由键内容确定性派生)后只占 8 字节、零分配,
//! 需要文本时用 [`ShiftSlot::slot_code_text`] 现场格式化。
//! **在一个以「省内存」为主题的工程里,连外在状态也不该挥霍**——
//! 否则「省下来的」会被其他地方悄悄吃掉。
//! (对比:不共享版本同样用 `u64`,因此这一项在两者间抵消,不影响结论。)
//!
//! ### 2. 工龄津贴存 `i64` 分,不存 `CurrencyAmount`
//!
//! `CurrencyAmount` 内含一个 [`crate::domain::Currency`](4 个字段
//! 共约 56 字节,含三个 `&'static str`)。让它出现在**高基数**对象
//! (每个槽位一个)里,等于为「这个金额是人民币」这条信息
//! 复制 1.2 万份——而整份排班表只有一个币种。
//!
//! 因此槽位只存最小单位数(8 字节),币种由 `ShiftPlan` 单一持有。
//!
//! ## 这背后是一条可迁移的规则
//!
//! > **「共享」不只是把大对象做成享元;凡是「在高基数对象里重复承载
//! > 低基数信息」的地方,都是共享的着力点。**
//!
//! 币种、货币符号、门店等级、班次时刻……都属于这一类。
//! 享元模式真正的适用面比「GoF 的字形例子」宽得多。
use crate::domain::{Ratio, StaffNumber, StoreCode};
use crate::flyweight::{SharedHandle, ShiftTemplate, StoreGradeProfile};
use crate::support::calendar_date::CalendarDate;
use crate::support::deterministic_code::derive_code;
use super::labor_cost::compute_labor_cost;
/// 一个排班槽位(外在状态)。
#[derive(Debug, Clone)]
pub struct ShiftSlot {
/// 槽位序号(由「门店 + 日期 + 班次键」确定性派生)。
///
/// 确定性保证:同样的输入永远得到同样的序号,
/// 因此两次运行的报表可以逐行对比(见 `support::deterministic_code`)。
slot_serial: u64,
/// 该槽位使用的班次模板(**共享句柄**)。
template: SharedHandle<ShiftTemplate>,
/// 该槽位所属门店的等级配置(**共享句柄**,第二族享元)。
grade_profile: SharedHandle<StoreGradeProfile>,
/// 门店编码。
store_code: StoreCode,
/// 门店时薪指数(外在状态)。
store_pay_index: Ratio,
/// 排班日期。
shift_date: CalendarDate,
/// 当班员工工号(外在状态)。
staff_number: StaffNumber,
/// 该员工的每班工龄津贴(最小单位数,币种见 `ShiftPlan`)。
seniority_allowance_minor_units: i64,
/// 实际出勤分钟数(外在状态,可与计薪时长不同)。
actual_worked_minutes: u32,
/// 加班分钟数(超出计薪时长的部分)。
overtime_minutes: u32,
}
impl ShiftSlot {
/// 构造一个排班槽位。
///
/// 参数较多,且全部是外在状态字段。**不对外开放**(`pub(crate)`):
/// 槽位应当由装配器统一构造,以保证:
/// 1. `slot_serial` 的派生口径统一(外部自己算可能用了不同种子);
/// 2. `overtime_minutes` 与 `actual_worked_minutes` 的关系一致。
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
slot_serial: u64,
template: SharedHandle<ShiftTemplate>,
grade_profile: SharedHandle<StoreGradeProfile>,
store_code: StoreCode,
store_pay_index: Ratio,
shift_date: CalendarDate,
staff_number: StaffNumber,
seniority_allowance_minor_units: i64,
actual_worked_minutes: u32,
overtime_minutes: u32,
) -> ShiftSlot {
ShiftSlot {
slot_serial,
template,
grade_profile,
store_code,
store_pay_index,
shift_date,
staff_number,
seniority_allowance_minor_units,
actual_worked_minutes,
overtime_minutes,
}
}
/// 槽位序号(原始 `u64`)。
pub const fn slot_serial(&self) -> u64 {
self.slot_serial
}
/// 槽位编号文本(形如 `SLOT-1A2B3C4D5E6F`)。
///
/// 现场格式化而非预存字符串——见类型文档的「不奢靡」说明。
pub fn slot_code_text(&self) -> String {
derive_code("SLOT", self.slot_serial)
}
/// 班次模板句柄。
pub fn template(&self) -> &SharedHandle<ShiftTemplate> {
&self.template
}
/// 门店等级配置句柄。
pub fn grade_profile(&self) -> &SharedHandle<StoreGradeProfile> {
&self.grade_profile
}
/// 门店编码。
pub const fn store_code(&self) -> StoreCode {
self.store_code
}
/// 门店时薪指数。
pub const fn store_pay_index(&self) -> Ratio {
self.store_pay_index
}
/// 排班日期。
pub const fn shift_date(&self) -> CalendarDate {
self.shift_date
}
/// 当班员工工号。
pub const fn staff_number(&self) -> StaffNumber {
self.staff_number
}
/// 工龄津贴(最小单位数)。
pub const fn seniority_allowance_minor_units(&self) -> i64 {
self.seniority_allowance_minor_units
}
/// 实际出勤分钟数。
pub const fn actual_worked_minutes(&self) -> u32 {
self.actual_worked_minutes
}
/// 加班分钟数。
pub const fn overtime_minutes(&self) -> u32 {
self.overtime_minutes
}
/// 该槽位的排班计薪分钟数(**来自享元**,不在槽位里重复存)。
pub fn paid_minutes(&self) -> u32 {
self.template.paid_duration().total_minutes()
}
/// 该槽位的人力成本。
///
/// 参数 `company_base_hourly_rate`:公司基准时薪(如 ¥28.00/时)。
/// 返回:该槽位的成本金额。
///
/// 计算链见 [`super::labor_cost::compute_labor_cost`] 的文档。
/// 本方法只是一个转发,把「槽位的数据」喂给那个纯函数——
/// **把公式留在纯函数里,是为了它能被单独核对**;
/// 若把公式写进本方法,核对时就要先构造一个槽位。
pub fn labor_cost(
&self,
company_base_hourly_rate: &crate::domain::CurrencyAmount,
) -> crate::domain::CurrencyAmount {
compute_labor_cost(
company_base_hourly_rate,
self.store_pay_index,
self.template.combined_pay_multiplier(),
self.paid_minutes(),
self.seniority_allowance_minor_units,
)
}
/// 该槽位当前是否与其他槽位共享同一份模板。
///
/// 返回:模板句柄的强引用计数 > 2 时返回 `true`。
///
/// ## 为什么阈值是 2
///
/// 强引用计数包含两处「固有」持有:
/// 1. 享元池自己在 `entries` 里持有一份;
/// 2. 本槽位自己持有一份。
///
/// 因此「本槽位之外还有别的持有者」⇔ 计数 ≥ 3 ⇔ 计数 > 2。
/// 写 `> 2` 而不是 `>= 3` 是为了让上面的推导在代码里可见
/// (读代码的人会立刻问「为什么是 2」)。
pub fn is_sharing_template_with_others(&self) -> bool {
SharedHandle::strong_count(&self.template) > 2
}
/// 该槽位模板当前被多少个持有点共享(含本槽位)。
///
/// 返回值 = `strong_count - 1`(减去池自己那一份)。
///
/// 这比 [`Self::is_sharing_template_with_others`] 给出的布尔值
/// 信息量更大:报表可以打印「本槽位的模板被 1714 个槽位共享」,
/// 这是一个**可核对的具体数字**,比「是/否」有说服力。
pub fn template_share_count(&self) -> usize {
SharedHandle::strong_count(&self.template) - 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 : store_profile.rs
//! # 门店档案 —— 外在状态的一部分
//!
//! ## 门店的两个部分:共享配置 + 各自差异
//!
//! | 内容 | 归属 | 理由 |
//! |---|---|---|
//! | 每班最低人数、营业时段、安保配置 | **享元**(`StoreGradeProfile`) | 同等级完全一致 |
//! | 门店编码、城市、时薪指数、员工名单 | **本类型**(外在状态) | 各店不同 |
//!
//! 把这两部分分开,是本工程第二个「内在 / 外在」切分点。
//! 旗舰店与旗舰店之间的**配置**完全相同(共享),
//! 但它们的**时薪指数**不同(一线城市生活成本高)。
//!
//! ## 时薪指数为什么是外在状态而不是进键
//!
//! 因为它影响的是**成本金额**,而不是**模板的内容**:
//! 同一份「早班模板」,在一线城市门店用 ¥32.20/时,
//! 在四线城市门店用 ¥26.60/时——**模板是同一个**,
//! 变化发生在「模板 × 门店指数」这一步。
//!
//! 若把时薪指数放进键,就会变成「每店每班一份模板」,
//! 共享收益坍塌(第四幕会量化这个坍塌的代价)。
//!
//! 判定标准再强调一次:**影响享元内容 → 进键;只影响使用结果 → 留在外层。**
use crate::domain::{Ratio, StoreCode, StoreGrade};
use super::staff_member::StaffMember;
/// 一家门店的档案。
///
/// 用 `Clone` 而非 `Copy`:内含 `Vec<StaffMember>`(员工名单),
/// 无法 `Copy`。装配器持有 `&[StoreProfile]` 的引用,不需要复制门店。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreProfile {
/// 门店编码。
store_code: StoreCode,
/// 门店等级(决定取哪一份共享配置)。
store_grade: StoreGrade,
/// 门店时薪指数(万分点)。10000 表示公司基准,11500 表示上浮 15%。
pay_index: Ratio,
/// 该店可排班的员工名单。
///
/// ## 为什么名单是 `Vec<StaffMember>` 而不是 `Vec<StaffNumber>`
///
/// 装配器选人时需要员工的**技能等级**(用于生成模板键)
/// 与**工龄津贴**(用于算成本),因此必须持有完整档案。
/// 若只存工号,装配器就得再反查一次员工表——
/// 那等于把「数据结构的选择」问题转嫁给调用方。
///
/// 代价是门店档案变大(每店约 12 名员工 × 每人约 100 字节)。
/// 但门店只有 120 家(而排班槽位有 1.2 万个),
/// **在这个量级上复制门店是廉价而槽位是昂贵的**——
/// 资源该省在哪里,由「谁的数量大」决定,不是一律都省。
roster: Vec<StaffMember>,
}
impl StoreProfile {
/// 构造一家门店。
///
/// 参数 `store_code` / `store_grade` / `pay_index` / `roster`。
/// 返回:门店档案。
pub fn new(
store_code: StoreCode,
store_grade: StoreGrade,
pay_index: Ratio,
roster: Vec<StaffMember>,
) -> StoreProfile {
StoreProfile {
store_code,
store_grade,
pay_index,
roster,
}
}
/// 门店编码。
pub const fn store_code(&self) -> StoreCode {
self.store_code
}
/// 门店等级。
pub const fn store_grade(&self) -> StoreGrade {
self.store_grade
}
/// 门店时薪指数。
pub const fn pay_index(&self) -> Ratio {
self.pay_index
}
/// 员工名单。
pub fn roster(&self) -> &[StaffMember] {
&self.roster
}
/// 员工人数。
pub fn staff_count(&self) -> usize {
self.roster.len()
}
/// 按指定技能等级挑选员工(返回第一个匹配者)。
///
/// 参数 `required_grade`:班次要求的最低技能等级;
/// `rotation_index`:轮转序号(用于「同一班次多次出现时轮换人员」)。
/// 返回:匹配的员工;无人匹配时返回 `None`。
///
/// ## 轮转序号的作用与「非确定性」的避免
///
/// 若每次都返回第一个人,同一天的三个班次会排给同一名员工——
/// 这在业务上不成立(一人不能在三个班次同时在岗)。
/// 用 `rotation_index` 对匹配人数取模来做轮转。
///
/// 用**取模轮转**而不是随机数:本工程要求数值可复现
/// (两次运行输出必须完全一致,才能核对数字)。
/// 随机数会破坏这个性质,因此这里绝不引入随机。
pub fn pick_staff_for(
&self,
required_grade: crate::domain::SkillGrade,
rotation_index: usize,
) -> Option<StaffMember> {
// 先收集所有符合条件的员工(本工程规模下每店数人,代价可忽略)。
let qualified: Vec<StaffMember> = self
.roster
.iter()
.filter(|member| member.is_qualified_for(required_grade))
.copied()
.collect();
if qualified.is_empty() {
// 该店无此等级员工:返回 None,由装配器记一条「无人可排」的提示。
// 不 fallback 到其他等级——那会掩盖「人手不足」这个真实问题。
return None;
}
// 取模轮转,保证可复现。
Some(qualified[rotation_index % qualified.len()])
}
}
哲学管理(学)人生, 文学艺术生活, 自动(计算机学)物理(学)工作, 生物(学)化学逆境, 历史(学)测绘(学)时间, 经济(学)数学金钱(理财), 心理(学)医学情绪, 诗词美容情感, 美学建筑(学)家园, 解构建构(分析)整合学习, 智商情商(IQ、EQ)运筹(学)生存.---Geovin Du(涂聚文)
浙公网安备 33010602011771号