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 &note_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()])
    }
}
 

  

posted @ 2026-10-07 09:00  ®Geovin Du Dream Park™  阅读(1)  评论(0)    收藏  举报