rust: Iterator Pattern

项目结构:

rust 复制代码
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : arithmetic.rs
//! 整数算术:全工程**唯一**的舍入与比率口径。
//!
//! ## 为什么单独一个文件
//!
//! 「算账的除法」在工程里只能有一处实现。散在各处写 `a / b` 会带来两个后果:
//! ① 整数除法向零截断,与手工口径差 1 分,而这种差 1 分在报表上**看不出来**;
//! ② 以后想统一改成「银行家舍入」要改十几个地方。
//! 因此本文件收口,其余各层一律调用 [`divide_rounded`]。
//!
//! ## 为什么用 `i128` 做中间量
//!
//! 金额用「分」存储,金重用「毫克」,比率用「万分点」。
//! 「分 × 万分点」已经是 10^6 量级,再乘一个金重(毫克,可达 10^6)
//! 会到 10^12------`i64` 还剩得下,但**再来一次乘法就悬了**。
//! 中间量一律 `i128`,出口处再受控地转回 `i64`。
//!
//! ## ★ 舍入方向:远离零
//!
//! `divide_rounded(-a, b) == -divide_rounded(a, b)` 是本层的恒等式,
//! 工程验收会真的跑它(`main.rs` 的体检表)。
//! 向零截断(Rust 的 `/` 默认行为)**不满足**这条恒等式,
//! 于是「退 1 分」与「收 1 分」的舍入方向不一致,差额会在合计里累积。

/// 把 `numerator / denominator` 按**远离零方向四舍五入**成一个 `i64`。
///
/// # 参数
/// - `numerator`:分子(可为负)。
/// - `denominator`:分母(**必须非 0**)。
///
/// # Panics
/// 分母为 0 时 panic。这是**调用方的逻辑错误**,不是可以降级的业务情况------
/// 返回 0 会让「这一项除不出来」变成「这一项是 0 元」,而报表看起来完全正常。
pub fn divide_rounded(numerator: i128, denominator: i128) -> i64 {
    assert!(
        denominator != 0,
        "除数为零:这不是可降级的业务情况,静默返回 0 会让报表看起来正常"
    );
    // 符号单独记:只在最后乘回去,中间全按正数算,避免负号干扰取余。
    let is_negative: bool = (numerator < 0) != (denominator < 0);
    // ⚠️ 用 `unsigned_abs` 而不是 `abs`:`i128::MIN` 的 `abs()` 会溢出 panic。
    let dividend: u128 = numerator.unsigned_abs();
    let divisor: u128 = denominator.unsigned_abs();
    let quotient: u128 = dividend / divisor;
    let remainder: u128 = dividend % divisor;
    // 远离零方向:余数达到除数的一半就进 1。
    let adjusted: u128 = if remainder * 2 >= divisor {
        quotient + 1
    } else {
        quotient
    };
    let magnitude: i128 = i128::try_from(adjusted).expect("除法结果超出 i128 可表示范围");
    let signed: i128 = if is_negative { -magnitude } else { magnitude };
    i64::try_from(signed).expect("除法结果超出 i64 可表示范围")
}

/// 比率转万分点:`numerator / denominator × 10000`,四舍五入。
///
/// ## 为什么比率一律用「万分点」整数
///
/// 毛利率 13.00% 与 13.005% 在浮点里都能表示,但**能不能精确比较**是另一回事:
/// 两个「看起来相等」的浮点比率在报表上可能因 1e-15 的差而分叉。
/// 万分点整数没有这个问题,且 `1300` 这种数字读者能直接读成 `13.00%`。
pub fn ratio_basis_points(numerator: i128, denominator: i128) -> i64 {
    divide_rounded(numerator * 10_000, denominator)
}

/// 按整数比例缩放:`value × numerator / denominator`,四舍五入。
///
/// 「金重(毫克) × 金价(分/克) / 1000」这类算式一律走这里,
/// 不在业务代码里裸写乘除。
pub fn scale_rounded(value: i64, numerator: i128, denominator: i128) -> i64 {
    divide_rounded(i128::from(value) * numerator, denominator)
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : bin_location.rs
//! 库位:一个货品批次在仓库里的具体位置(区 + 排 + 层 + 位)。
//!
//! ## ★ 顺序收在 `sort_key` 里,不收在 `Ord` 里
//!
//! 「按库位顺序走一遍货架」是盘点员的实际动线,
//! 因此顺序必须是**一个能说清来源的整数**:
//! `区序号 × 10^6 + 排 × 10^4 + 层 × 10^2 + 位`。
//!
//! ⚠️ 为什么不派生 `Ord` 然后 `sort()`:
//! 那样「谁先谁后」由字段的**声明顺序**决定,
//! 而字段声明顺序看起来是「随便写的」------
//! 有人为了可读性把 `shelf` 挪到 `aisle` 前面,遍历顺序就反了,
//! 并且**编译通过、测试也可能还是绿的**(若数据恰好同排)。
//! 显式写一个 `sort_key()`,顺序就有了唯一的、可核对的定义处。

use super::storage_zone::StorageZone;

/// 一个库位。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct BinLocation {
    /// 所属库区。
    zone: StorageZone,
    /// 排号(`1..`)。
    aisle: u32,
    /// 层号(`1..`,自下往上)。
    shelf: u32,
    /// 位号(`1..`,自左往右)。
    slot: u32,
}

impl BinLocation {
    /// 声明一个库位。
    pub const fn new(zone: StorageZone, aisle: u32, shelf: u32, slot: u32) -> Self {
        BinLocation { zone, aisle, shelf, slot }
    }

    /// 所属库区。
    pub const fn zone(&self) -> StorageZone {
        self.zone
    }

    /// 排号。
    pub const fn aisle(&self) -> u32 {
        self.aisle
    }

    /// 层号。
    pub const fn shelf(&self) -> u32 {
        self.shelf
    }

    /// 位号。
    pub const fn slot(&self) -> u32 {
        self.slot
    }

    /// ★ 排序键(**全工程唯一的「库位先后」定义处**)。
    ///
    /// 每一级的位宽(100)来自「排 / 层 / 位都不超过 99」这个物理约束;
    /// 超过 99 就该加位宽,那时这里会**一起**改,不会出现两处口径。
    pub const fn sort_key(&self) -> u64 {
        self.zone.sequence() as u64 * 1_000_000
            + self.aisle as u64 * 10_000
            + self.shelf as u64 * 100
            + self.slot as u64
    }

    /// 中文文本(如 `前场展柜-03排-2层-05位`)。
    ///
    /// ⚠️ 排 / 层 / 位**补零到两位**:库位文本经常要按字符串排序(贴在货架上),
    /// 不补零时 `10排` 会排在 `2排` 前面。
    pub fn chinese_text(&self) -> String {
        format!(
            "{}-{:02}排-{}层-{:02}位",
            self.zone.chinese_name(),
            self.aisle,
            self.shelf,
            self.slot
        )
    }

    /// ★ 指纹(**身份**):`区码-排-层-位`。
    ///
    /// 与 [`Self::chinese_text`] 分开:文本是给人看的(含中文区名),
    /// 指纹是给审计比对的(只用机器码,改中文名不该让审计变红)。
    pub fn fingerprint_text(&self) -> String {
        format!(
            "{}-{:02}-{:02}-{:02}",
            self.zone.code(),
            self.aisle,
            self.shelf,
            self.slot
        )
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : category_code.rs
//! 品类编码:开放型标签,并自带一个**影响算法分支**的属性。
//!
//! ## ★ `needs_metal_pricing` 为什么放在编码上
//!
//! 黄金、钻石(镶嵌托)的**成本要按成色 × 金价**算;
//! 翡翠、珍珠按件计价,没有金属成本这一项。
//! 这个差异不是「某个报表的选择」,而是**这个品类本身的性质**,
//! 所以它属于品类编码,而不是散在工厂函数里写 `if category == ...`。
//! 散着写的结果一定是:新增一个品类时,有地方忘了补这个判断,
//! 而漏掉的那处会算出「金属成本 = 0」,报表看起来完全正常。

/// 品类编码。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct CategoryCode {
    /// 机器编码,如 `"GOLD"`。
    code: &'static str,
    /// 中文名称,如 `"黄金饰品"`。
    chinese_name: &'static str,
    /// ★ 该品类的成本是否要按「成色 × 金价」计算。
    needs_metal_pricing: bool,
}

impl CategoryCode {
    /// 声明一个品类。
    pub const fn new(code: &'static str, chinese_name: &'static str, needs_metal_pricing: bool) -> Self {
        Self {
            code,
            chinese_name,
            needs_metal_pricing,
        }
    }

    /// 机器编码。
    pub const fn code(&self) -> &'static str {
        self.code
    }

    /// 中文名称。
    pub const fn chinese_name(&self) -> &'static str {
        self.chinese_name
    }

    /// 是否按「成色 × 金价」计算金属成本。
    pub const fn needs_metal_pricing(&self) -> bool {
        self.needs_metal_pricing
    }
}

/// 黄金饰品:按成色 × 金价计价。
pub const CATEGORY_GOLD: CategoryCode = CategoryCode::new("GOLD", "黄金饰品", true);
/// 钻石饰品:镶嵌托是 18K 金,同样按成色 × 金价计价。
pub const CATEGORY_DIAMOND: CategoryCode = CategoryCode::new("DIAMOND", "钻石饰品", true);
/// 翡翠饰品:按件计价,无金属成本。
pub const CATEGORY_JADE: CategoryCode = CategoryCode::new("JADE", "翡翠饰品", false);

/// 内置品类全集。
pub const BUILTIN_CATEGORY_CODES: [CategoryCode; 3] = [CATEGORY_GOLD, CATEGORY_DIAMOND, CATEGORY_JADE];
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : counted_batch.rs
//! 盘点结果:一个批次**被盘到之后**得到的那个东西。
//!
//! ## ★ 为什么与 `StockBatch` 分开
//!
//! `StockBatch` 是**账面**(在库台账里的一行),`CountedBatch` 是**实盘**(盘完之后的结论)。
//! 两者分开,是因为它们**不是同时知道的**:
//! 账面在盘点开始前就有了,实盘要等游标走过去才有。
//!
//! 若把它们合成一个类型(在 `StockBatch` 上加 `verdict` 字段),
//! 那么「还没盘到的批次」就必须有一个 `verdict`------
//! 只能是 `Matched` 之类的默认值,
//! 于是「盘过且相符」与「根本没盘到」在报表上长得一模一样。
//!
//! ## ⚠️ 差异重量是**实盘减账面**,不是绝对值
//!
//! 方向不能丢:盘盈与盘亏在会计上是两件不同的事,
//! 取了绝对值就只能说「有差异」,说不清是多还是少。

use super::stock_batch::StockBatch;
use super::stocktake_verdict::StocktakeVerdict;

/// 一个批次的盘点结果。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct CountedBatch {
    /// 被盘的批次(**账面数据**)。
    batch: StockBatch,
    /// 盘点结论。
    verdict: StocktakeVerdict,
    /// 实盘重量(**毫克整数**)。
    ///
    /// ⚠️ 待复检与已封存的批次**没有实盘重量**,这里存账面重量(差异为 0),
    /// 而不是存 0------存 0 会让它们看起来像「盘到了一件 0 克的货」。
    counted_weight_milligrams: i64,
}

impl CountedBatch {
    /// 声明一个盘点结果。
    ///
    /// # 参数
    /// - `batch`:被盘的批次。
    /// - `verdict`:盘点结论。
    /// - `counted_weight_milligrams`:实盘重量(毫克,**必须 >= 0**)。
    pub fn new(batch: StockBatch, verdict: StocktakeVerdict, counted_weight_milligrams: i64) -> Self {
        assert!(
            counted_weight_milligrams >= 0,
            "批次 {} 的实盘重量为负({} 毫克):秤不会称出负数,检查一下是不是把差异当成了重量",
            batch.batch_number(),
            counted_weight_milligrams
        );
        CountedBatch {
            batch,
            verdict,
            counted_weight_milligrams,
        }
    }

    /// 被盘的批次。
    pub const fn batch(&self) -> StockBatch {
        self.batch
    }

    /// 盘点结论。
    pub const fn verdict(&self) -> StocktakeVerdict {
        self.verdict
    }

    /// 实盘重量(毫克)。
    pub const fn counted_weight_milligrams(&self) -> i64 {
        self.counted_weight_milligrams
    }

    /// ★ 差异重量(毫克,**实盘 − 账面**,带符号)。
    pub const fn variance_weight_milligrams(&self) -> i64 {
        self.counted_weight_milligrams - self.batch.weight_milligrams()
    }

    /// ★ 是否计入本次实际盘到的件数(结论说了算)。
    pub const fn is_counted(&self) -> bool {
        self.verdict.is_counted()
    }

    /// ★ 指纹:**账面身份 + 结论编码**。
    ///
    /// ⚠️ 结论编码用机器码不用中文:改文案不该让审计变红。
    /// ⚠️ 指纹里**不含实盘重量**:重量是要被合计的(可加),
    ///    指纹是用来比对的(不可加),两者混在一个串里会让
    ///    「重量变了」与「走漏了一件」变成同一件事。
    pub fn fingerprint_text(&self) -> String {
        format!(
            "{}#{}",
            self.batch.fingerprint_text(),
            self.verdict.machine_text()
        )
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : currency_code.rs
//! 币种:开放型标签(`const fn new` 结构体,不是 `enum`)。
//!
//! ## ★ 判据:会不会被当作查找键 + 会不会不断增加
//!
//! - **会不断增加**(接新地区就多一个币种)→ 开放型结构体,
//!   工程外新增只需在装配点声明一个 `const`,**不用回来改本文件**;
//! - **会被当作查找键** → 实现 `PartialEq`(汇率表按币种查);
//! - 但**不实现 `Ord` / `Hash`**:本工程从不排序币种、也不拿币种做哈希键,
//!   不实现用不上的 trait 本身就是一种防错(多实现一个,就多一处「看起来能用」的用法)。
//!
//! ## ⚠️ 为什么字段是 `&'static str`
//!
//! `String` 会让 `CurrencyCode` 不能 `Copy`,于是 `Money` 也不能 `Copy`,
//! 而金额在本工程里到处按值传递。币种编码是编译期常量,
//! 用 `&'static str` 换 `Copy` 是划算的。

/// 币种:`code` 是机器编码,`chinese_name` 给人读,`symbol` 用于排版。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct CurrencyCode {
    /// 机器编码,如 `"CNY"`。
    code: &'static str,
    /// 中文名称,如 `"人民币"`。
    chinese_name: &'static str,
    /// 金额符号,如 `"¥"`。
    symbol: &'static str,
}

impl CurrencyCode {
    /// 声明一个币种。`const fn` 使得它可以写进 `const` 常量里。
    pub const fn new(code: &'static str, chinese_name: &'static str, symbol: &'static str) -> Self {
        Self {
            code,
            chinese_name,
            symbol,
        }
    }

    /// 机器编码。
    pub const fn code(&self) -> &'static str {
        self.code
    }

    /// 中文名称。
    pub const fn chinese_name(&self) -> &'static str {
        self.chinese_name
    }

    /// 金额符号。
    pub const fn symbol(&self) -> &'static str {
        self.symbol
    }
}

/// 人民币:集团合并口径的记账本位币。
pub const CNY: CurrencyCode = CurrencyCode::new("CNY", "人民币", "¥");
/// 港币:中国香港门店的本位币。
pub const HKD: CurrencyCode = CurrencyCode::new("HKD", "港币", "HK$");
/// 美元:跨境采购结算币。
pub const USD: CurrencyCode = CurrencyCode::new("USD", "美元", "US$");

/// 集团允许出现的币种全集。
///
/// ⚠️ 这张表只用于**校验**(流水的币种必须在这里面),
/// 不用于「枚举有哪些币种」------那会把新增币种变成改领域层文件。
pub const BUILTIN_CURRENCY_CODES: [CurrencyCode; 3] = [CNY, HKD, USD];
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : inventory_ledger.rs
//! 在库台账:**被遍历的那个集合**。
//!
//! ## ★ 迭代器模式里,集合与游标的职责分界
//!
//! - **集合**:只回答「一共有几个」「第 i 个是谁」,**不记得走到哪了**。
//! - **游标**:只记得走到哪了,**不拥有数据**。
//!
//! 这条分界是可检验的:本工程幕九会**同时**开四个游标走同一份台账,
//! 若集合里存了「当前位置」,四个游标会互相踩,
//! 那时「按库位走」与「按金额走」会给出同一个顺序------
//! 而报表上看不出那是踩踏的结果。
//!
//! ## ⚠️ 为什么只给 `&[StockBatch]` 而不给 `&mut`
//!
//! 遍历途中改集合会让「游标位置」失去意义(见 `stock_batch.rs` 的模块文档)。
//! 这里连可变借用都不提供,就是把这件事钉死在类型上。

use super::money::Money;
use super::stock_batch::StockBatch;

/// 一份在库台账(**不可变**)。
#[derive(Clone, Debug)]
pub struct InventoryLedger {
    /// 台账名(进报表标题用)。
    name: String,
    /// 批次清单(**声明顺序即台账顺序**,不排序)。
    batches: Vec<StockBatch>,
}

impl InventoryLedger {
    /// 建一份台账。
    ///
    /// # 参数
    /// - `name`:台账名。
    /// - `batches`:批次清单。
    pub fn new(name: &str, batches: Vec<StockBatch>) -> Self {
        InventoryLedger { name: name.to_string(), batches }
    }

    /// 台账名。
    pub fn name(&self) -> &str {
        self.name.as_str()
    }

    /// 批次数。
    pub const fn batch_total(&self) -> usize {
        self.batches.len()
    }

    /// ★ 全部批次(**只读切片**)。
    ///
    /// ⚠️ 返回 `&[StockBatch]` 而不是 `Vec<StockBatch>`:
    /// 返回 `Vec` 就得克隆一份,那时「集合没被改」只是看起来没被改------
    /// 游标走的是副本,而审计比对的却是原件,两边可以各错各的。
    pub fn batches(&self) -> &[StockBatch] {
        self.batches.as_slice()
    }

    /// 第 `index` 个批次(越界时 `None`)。
    ///
    /// ⚠️ 为什么返回 `Option` 而不是 `panic`:
    /// 「游标越界」是**游标**的错,不是台账的错;
    /// 台账只负责如实说「没有这个下标」,
    /// 让越界在游标那一侧被审计规则抓到(幕八 `IN_RANGE`)。
    pub fn batch_at(&self, index: usize) -> Option<&StockBatch> {
        self.batches.get(index)
    }

    /// ★ 全部批次的指纹(**已排序**)。
    ///
    /// 为什么要排序:审计要比对「集合」而不是「顺序」;
    /// 若直接按台账顺序比对,那么「换个走法」就会被误判成「走漏了」。
    /// 排序后两个集合才能比「内容是否相同」。
    ///
    /// ⚠️ 刻意**不用** `HashSet`:迭代顺序不确定会破坏「两次运行逐字节一致」;
    /// 排序后的 `Vec` 才是可复现的。
    pub fn sorted_fingerprints(&self) -> Vec<String> {
        let mut fingerprints: Vec<String> =
            self.batches.iter().map(|batch| batch.fingerprint_text()).collect();
        fingerprints.sort();
        fingerprints
    }

    /// ★ 账面总重量(毫克)。
    ///
    /// 合计行的可加性判据是**语义**不是类型:
    /// 重量是物理量,跨币种也能求和;批次号是指纹,不可加。
    pub fn total_weight_milligrams(&self) -> i64 {
        self.batches.iter().map(|batch| batch.weight_milligrams()).sum()
    }

    /// ★ 账面总金额。
    ///
    /// # Panics
    /// 台账为空时 `Money::zero` 需要一个币种,
    /// 这里按「台账至少有一个批次」断言------空台账应当被上游拦住,
    /// 而不是让合计静默变成 `¥0.00`。
    pub fn total_valued_amount(&self) -> Money {
        match self.batches.first() {
            Some(first) => {
                let mut total: Money = Money::zero(first.valued_amount().currency());
                for batch in self.batches.iter() {
                    total = total.plus(batch.valued_amount());
                }
                total
            }
            None => panic!("空台账没有账面总额:先确认批次清单是不是漏传了"),
        }
    }

    /// 需要复检的批次数。
    pub fn recheck_total(&self) -> usize {
        self.batches.iter().filter(|batch| batch.needs_recheck()).count()
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : money.rs
//! 金额:整数最小单位(分)+ 自带币种。
//!
//! ## ★ 自带币种是类型安全,不是装饰
//!
//! 跨币种相加(人民币 + 港币)在类型上是**两个数相加**,编译器看不出错,
//! 报表也看不出错------只有做集团合并的人会在月底发现差了几万。
//! 因此 [`Money::plus`] 里放一条 `debug_assert!`:
//! 币种不同就在调试构建里立刻 panic,而不是返回一个「看起来对」的数。
//!
//! ## ★ 规范文本形式留在领域层
//!
//! `chinese_text()`(`¥12,345.67`)之所以不放表示层,
//! 判据是「这个文本形式是不是所有层都要用」:
//! 机制层报错、分析层结论、表示层表格都要把金额嵌进句子里。
//! 若搬去 `app`,`pool` / `analysis` 就得反向依赖 `app`------那才是真违规。
//!
//! ## ⚠️ 全程不出现浮点
//!
//! 千分位用整数反复除 10 取位拼出来。借道 `f64` 会在 10^15 量级上
//! 出现「同一笔金额两种写法」。

use crate::domain::arithmetic::scale_rounded;
use crate::domain::CurrencyCode;

/// 千分位分隔符。
const THOUSANDS_SEPARATOR: char = ',';

/// 一笔金额:**分**为单位的整数 + 币种。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Money {
    /// 以「分」为单位的整数金额。
    minor_units: i64,
    /// 币种。
    currency: CurrencyCode,
}

impl Money {
    /// 由「分」整数与币种构造。
    pub const fn new(minor_units: i64, currency: CurrencyCode) -> Self {
        Self {
            minor_units,
            currency,
        }
    }

    /// 零额。
    ///
    /// ⚠️ 「零」也必须走统一构造路径:散着写 `Money::new(0, c)` 时,
    /// 币种迟早会被写错一次,而 0 元在报表上**看不出币种错了**。
    pub const fn zero(currency: CurrencyCode) -> Self {
        Self::new(0, currency)
    }

    /// 以「分」为单位的整数。
    pub const fn minor_units(&self) -> i64 {
        self.minor_units
    }

    /// 币种。
    pub const fn currency(&self) -> CurrencyCode {
        self.currency
    }

    /// 是否为零额。
    pub const fn is_zero(&self) -> bool {
        self.minor_units == 0
    }

    /// 同币种相加。
    ///
    /// # Panics
    /// 币种不同时 `debug_assert!` 失败。
    /// 这是**接线错误**(把两本账加在了一起),不是业务情况。
    pub fn plus(&self, other: Money) -> Money {
        debug_assert!(
            self.currency == other.currency,
            "跨币种相加:{} + {} ------ 这两本账不能相加,先折算再求和",
            self.currency.code(),
            other.currency.code()
        );
        Money::new(self.minor_units + other.minor_units, self.currency)
    }

    /// 同币种相减(`self - other`)。
    pub fn minus(&self, other: Money) -> Money {
        debug_assert!(
            self.currency == other.currency,
            "跨币种相减:{} - {}",
            self.currency.code(),
            other.currency.code()
        );
        Money::new(self.minor_units - other.minor_units, self.currency)
    }

    /// 取相反数(用于把「成本」折成「对毛利的贡献」)。
    pub fn negate(&self) -> Money {
        Money::new(-self.minor_units, self.currency)
    }

    /// 按万分点缩放:`self × basis_points / 10000`。
    ///
    /// 用于「按占比分摊」------分摊口径也走唯一舍入口径,
    /// 不会出现「分摊后各份之和 ≠ 总额」之外的第二种差法。
    pub fn scale_basis_points(&self, basis_points: i64) -> Money {
        Money::new(
            scale_rounded(self.minor_units, i128::from(basis_points), 10_000),
            self.currency,
        )
    }

    /// 按整数比例缩放:`self × numerator / denominator`。
    pub fn scale_ratio(&self, numerator: i128, denominator: i128) -> Money {
        Money::new(
            scale_rounded(self.minor_units, numerator, denominator),
            self.currency,
        )
    }

    /// 纯数字文本(`12,345.67`),不含币种符号。
    ///
    /// 用于「金额 ÷ 克数」这类已经把币种写进列头的场合。
    pub fn amount_text(&self) -> String {
        // 符号单独处理:负号要放在最前面,不能夹在千分位里。
        // ⚠️ 先升到 i128 再取绝对值:`i64::MIN` 的 `abs()` 会溢出。
        let is_negative: bool = self.minor_units < 0;
        let magnitude: i128 = i128::from(self.minor_units).unsigned_abs() as i128;
        let yuan: i128 = magnitude / 100;
        let cents: i128 = magnitude % 100;
        let sign: &str = if is_negative { "-" } else { "" };
        format!("{}{}.{:02}", sign, group_thousands(yuan), cents)
    }

    /// 规范文本(`¥12,345.67`):符号 + 千分位。
    pub fn chinese_text(&self) -> String {
        format!("{}{}", self.currency.symbol(), self.amount_text())
    }

    /// 两个金额是否相等(含币种)。
    ///
    /// 用于审计比对:金额相等但币种不同,在本工程里是**不相等**。
    pub fn is_same_as(&self, other: &Money) -> bool {
        self.minor_units == other.minor_units && self.currency == other.currency
    }
}

/// 把一个非负整数的十进制表示插上千分位。
///
/// ⚠️ 用整数反复除 10 取位,全程不出现浮点:
/// `f64` 在 10^15 以上会开始丢低位,而金额合计确实会到那个量级。
fn group_thousands(value: i128) -> String {
    let digits: Vec<char> = value.to_string().chars().collect();
    let mut buffer: Vec<char> = Vec::with_capacity(digits.len() + digits.len() / 3);
    for (index, character) in digits.iter().enumerate() {
        let from_right: usize = digits.len() - index;
        if index > 0 && from_right % 3 == 0 {
            buffer.push(THOUSANDS_SEPARATOR);
        }
        buffer.push(*character);
    }
    buffer.into_iter().collect()
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : month_key.rs
//! 会计月份:`年 + 月` 的值对象。
//!
//! ## 为什么不用 `CalendarDate`
//!
//! 月度经营分析的粒度是**月**,不是日。用「某月 1 号」代表一个月会带来
//! 「代表日是哪天」这种本不该存在的问题(月末调整单会挂在代表日上)。
//! 因此单独一个 `MonthKey`。
//!
//! ## ★ `month_index` 是排序与比较的唯一口径
//!
//! 跨年比较不能写 `year < year || (year == year && month < month)`------
//! 这种写法迟早会被复制漏改一处。折成 `year * 12 + (month - 1)` 一个整数后,
//! 「先后」「相差几个月」都是一次减法。

/// 会计月份。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct MonthKey {
    /// 年份(如 `2026`)。
    year: i32,
    /// 月份(`1..=12`)。
    month: u32,
}

impl MonthKey {
    /// 声明一个会计月份。
    pub const fn new(year: i32, month: u32) -> Self {
        Self { year, month }
    }

    /// 年份。
    pub const fn year(&self) -> i32 {
        self.year
    }

    /// 月份。
    pub const fn month(&self) -> u32 {
        self.month
    }

    /// 月份是否合法(`1..=12`)。
    pub const fn is_valid(&self) -> bool {
        self.month >= 1 && self.month <= 12
    }

    /// 是否与另一个月份相同。
    ///
    /// ⚠️ 单独一个 `const fn` 而不是用 `==`:
    /// `PartialEq` 的 `==` **不是 const fn**,在 `const fn` 里用不了。
    pub const fn is_same(&self, other: &MonthKey) -> bool {
        self.year == other.year && self.month == other.month
    }

    /// ★ 折成一个整数的月份序号:`year * 12 + (month - 1)`。
    ///
    /// 「先后」「相差几个月」全部由它算,不写复合比较。
    pub const fn month_index(&self) -> i32 {
        self.year * 12 + (self.month as i32) - 1
    }

    /// 上一个会计月份。
    pub fn previous(&self) -> MonthKey {
        let index: i32 = self.month_index() - 1;
        MonthKey::new(index.div_euclid(12), index.rem_euclid(12) as u32 + 1)
    }

    /// 下一个会计月份。
    pub fn next(&self) -> MonthKey {
        let index: i32 = self.month_index() + 1;
        MonthKey::new(index.div_euclid(12), index.rem_euclid(12) as u32 + 1)
    }

    /// 去年同月(同比的分母所在月份)。
    pub const fn same_month_last_year(&self) -> MonthKey {
        MonthKey::new(self.year - 1, self.month)
    }

    /// ★ 往后推 `count` 个月(校准到期月顺延用)。
    ///
    /// ⚠️ 为什么不用 `year*12+month` 手算再拆:
    /// 那种算式在跨年处容易漏 `-1`,而漏了之后**只在 12 月出错**------
    /// 一年只错一次的错误最难被发现。复用 `month_index` 是唯一的写法。
    ///
    /// # 参数
    /// - `count`:要往后推几个月(`0` 表示就是当月)。
    pub fn plus_months(&self, count: u32) -> MonthKey {
        let index: i32 = self.month_index() + count as i32;
        MonthKey::new(index.div_euclid(12), index.rem_euclid(12) as u32 + 1)
    }

    /// 与另一个月份相差几个月(`self - other`)。
    pub const fn months_since(&self, other: &MonthKey) -> i32 {
        self.month_index() - other.month_index()
    }

    /// 是否早于另一个月份。
    pub const fn is_before(&self, other: &MonthKey) -> bool {
        self.month_index() < other.month_index()
    }

    /// 机器文本(`2026-10`)。定宽,可直接进表格列。
    pub fn machine_text(&self) -> String {
        format!("{:04}-{:02}", self.year, self.month)
    }

    /// 中文文本(`2026年10月`)。
    pub fn chinese_text(&self) -> String {
        format!("{}年{}月", self.year, self.month)
    }

    /// 该月的自然天数(用于把「月」折算成「日均」这类派生口径)。
    ///
    /// ⚠️ 闰年判据走 `support` 层,不在本文件重写一遍------
    /// 重写一次就多一处「两处算法不一致」的风险。
    pub fn days(&self) -> u32 {
        crate::support::days_in_month(self.year, self.month)
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : stock_batch.rs
//! 在库货品批次:盘点时被**遍历**的那个东西。
//!
//! ## ★ 它必须**不可变**
//!
//! 遍历器(游标)要能在同一个集合上同时开好几个、各自走各自的。
//! 若批次在遍历途中能被改,那么「游标 A 走到第 5 个」这句话
//! 就没有确定的含义(改一个批次可能改变排序、也可能改变过滤结果)。
//! 因此本工程里 `StockBatch` 全部字段私有、无 setter,
//! 台账也只给 `&[StockBatch]`(`src/domain/inventory_ledger.rs`)。
//!
//! ⚠️ 这条不是洁癖:本工程幕九会**同时**开四个游标走同一份台账,
//! 任何一个游标偷偷改数据,另外三个的结论都会跟着变,
//! 而报表上看不出「是数据变了」还是「是游标错了」。

use super::bin_location::BinLocation;
use super::category_code::CategoryCode;
use super::money::Money;
use super::month_key::MonthKey;
use super::store_code::StoreCode;

/// 一个在库货品批次。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct StockBatch {
    /// 批次号(如 `"BATCH-2026-0031"`)。
    batch_number: &'static str,
    /// 所属门店。
    store: StoreCode,
    /// 品类。
    category: CategoryCode,
    /// 库位(**盘点动线的依据**)。
    location: BinLocation,
    /// 入库月份。
    received_month: MonthKey,
    /// 重量(**毫克整数**,显示时才转成克------算账全程用毫克)。
    weight_milligrams: i64,
    /// 账面金额(**整数分**)。
    valued_amount: Money,
    /// ★ 是否需要复检(翡翠 / 钻石定期复检,黄金一般不用)。
    needs_recheck: bool,
}

impl StockBatch {
    /// 声明一个批次。
    ///
    /// # 参数
    /// - `batch_number`:批次号。
    /// - `store`:所属门店。
    /// - `category`:品类。
    /// - `location`:库位。
    /// - `received_month`:入库月份。
    /// - `weight_milligrams`:重量(毫克,**必须 >= 0**)。
    /// - `valued_amount`:账面金额。
    /// - `needs_recheck`:是否需要复检。
    ///
    /// ⚠️ 八个参数看起来多,但**没有一个能省**(这正是「批次」这个概念的组成);
    /// 这里刻意不引入「建造器」把它拆开------建造器解决的是「组合爆炸」,
    /// 而这里只有一种组合。
    pub fn new(
        batch_number: &'static str,
        store: StoreCode,
        category: CategoryCode,
        location: BinLocation,
        received_month: MonthKey,
        weight_milligrams: i64,
        valued_amount: Money,
        needs_recheck: bool,
    ) -> Self {
        // ★ 负重量在盘点里没有意义,却是「两个批次相减」最容易产生的东西。
        //   这里断言而不是静默接受:静默接受的负重量会一路走进合计,
        //   最后表现为「总重量比任何一个批次都轻」,而没人会想到去查构造。
        assert!(
            weight_milligrams >= 0,
            "批次 {} 的重量为负({} 毫克):盘点里没有负重量,检查一下是不是把差额当成了批次",
            batch_number,
            weight_milligrams
        );
        StockBatch {
            batch_number,
            store,
            category,
            location,
            received_month,
            weight_milligrams,
            valued_amount,
            needs_recheck,
        }
    }

    /// 批次号。
    pub const fn batch_number(&self) -> &'static str {
        self.batch_number
    }

    /// 所属门店。
    pub const fn store(&self) -> StoreCode {
        self.store
    }

    /// 品类。
    pub const fn category(&self) -> CategoryCode {
        self.category
    }

    /// 库位。
    pub const fn location(&self) -> BinLocation {
        self.location
    }

    /// 入库月份。
    pub const fn received_month(&self) -> MonthKey {
        self.received_month
    }

    /// 重量(毫克)。
    pub const fn weight_milligrams(&self) -> i64 {
        self.weight_milligrams
    }

    /// 账面金额。
    pub const fn valued_amount(&self) -> Money {
        self.valued_amount
    }

    /// 是否需要复检。
    pub const fn needs_recheck(&self) -> bool {
        self.needs_recheck
    }

    /// ★ 已入库多少个月(相对 `current_month`)。
    ///
    /// ⚠️ 「先进先出」盘点口径依赖这个数,
    /// 它**不能**由批次自己算(批次不知道「现在是哪个月」),
    /// 所以做成带参数的方法而不是零参的 getter------
    /// 零参 getter 会逼着领域层记住一个「当前月」,那是领域层不该有的状态。
    pub const fn months_in_stock(&self, current_month: &MonthKey) -> i32 {
        current_month.months_since(&self.received_month)
    }

    /// ★ 是否属于「高值件」(金额 >= 门槛)。
    ///
    /// 为什么门槛是参数而不是常量:
    /// 「多少算高值」是**每次盘点的政策**(总部抽查 10 万、门店自查 1 万),
    /// 写死成常量后,两套政策就要两套代码。
    pub const fn is_high_value(&self, threshold_minor_units: i64) -> bool {
        self.valued_amount.minor_units() >= threshold_minor_units
    }

    /// ★ 指纹:一个批次的**身份**。
    ///
    /// 格式:`批次号|门店码|品类码|库位指纹|入库月|重量毫克|金额分|复检`
    ///
    /// 用途:审计比对的是指纹而不是金额------
    /// 这样「走漏了一个批次」与「某个批次的金额变了」是两件能分开看的事。
    pub fn fingerprint_text(&self) -> String {
        format!(
            "{}|{}|{}|{}|{}|{}|{}|{}",
            self.batch_number,
            self.store.code(),
            self.category.code(),
            self.location.fingerprint_text(),
            self.received_month.machine_text(),
            self.weight_milligrams,
            self.valued_amount.minor_units(),
            if self.needs_recheck { "R" } else { "-" }
        )
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : stocktake_verdict.rs
//! 盘点结论:一个批次被盘到之后得出的那个结果。
//!
//! ## ★ 为什么做成**封闭** `enum`
//!
//! 判据与库区相反:**这个集合不会不断增加,而且需要编译期穷尽检查**。
//! 盘点结论是会计口径,五种就五种;将来若真加第六种,
//! 我希望编译器把所有 `match` 全部点红,而不是让某个报表悄悄漏一行。
//!
//! 这正是「开放型标签 vs 封闭集合」分界的作用:
//! - 会不会不断增加?→ 会:开放(`const fn new`)
//! - 需不需要穷尽检查?→ 需要:封闭(`enum`)

/// 一个批次的盘点结论。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum StocktakeVerdict {
    /// 账实相符。
    Matched,
    /// 盘盈(实物比账面多)。
    Over,
    /// 盘亏(实物比账面少)。
    Short,
    /// 待复检(成色 / 证书需要复核,**未计入本次盘点数**)。
    PendingRecheck,
    /// 已封存(司法或保险封存,**本次不盘**)。
    Sealed,
}

impl StocktakeVerdict {
    /// 中文文本。
    pub const fn chinese_text(&self) -> &'static str {
        match self {
            StocktakeVerdict::Matched => "账实相符",
            StocktakeVerdict::Over => "盘盈",
            StocktakeVerdict::Short => "盘亏",
            StocktakeVerdict::PendingRecheck => "待复检",
            StocktakeVerdict::Sealed => "已封存",
        }
    }

    /// 机器编码(**稳定**,改中文文案不该让审计变红)。
    pub const fn machine_text(&self) -> &'static str {
        match self {
            StocktakeVerdict::Matched => "MATCHED",
            StocktakeVerdict::Over => "OVER",
            StocktakeVerdict::Short => "SHORT",
            StocktakeVerdict::PendingRecheck => "PENDING_RECHECK",
            StocktakeVerdict::Sealed => "SEALED",
        }
    }

    /// ★ 是否计入「本次实际盘到的件数」。
    ///
    /// ⚠️ 待复检与已封存**都不算盘到**:
    /// 把它们算进去,「盘点完成率」会虚高,
    /// 而报表上看不出那 20% 其实没盘。
    pub const fn is_counted(&self) -> bool {
        matches!(
            self,
            StocktakeVerdict::Matched | StocktakeVerdict::Over | StocktakeVerdict::Short
        )
    }

    /// ★ 是否需要后续跟进(盘盈 / 盘亏 / 待复检都要)。
    pub const fn needs_follow_up(&self) -> bool {
        !matches!(self, StocktakeVerdict::Matched)
    }

    /// 是否属于「有差异」(盘盈或盘亏)。
    pub const fn has_variance(&self) -> bool {
        matches!(self, StocktakeVerdict::Over | StocktakeVerdict::Short)
    }

    /// 全部取值(**给报表与清点用**,顺序即展示顺序)。
    pub const fn all() -> [StocktakeVerdict; 5] {
        [
            StocktakeVerdict::Matched,
            StocktakeVerdict::Over,
            StocktakeVerdict::Short,
            StocktakeVerdict::PendingRecheck,
            StocktakeVerdict::Sealed,
        ]
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : storage_zone.rs
//! 库区:仓库里的一片物理区域(前场展柜 / 后场保险柜 / 金库 / 维修暂存)。
//!
//! ## ★ 为什么是「开放型标签」而不是 `enum`
//!
//! 判据只有一条:**这个集合会不会不断增加**。
//! 库区是物理的------开一家新店、租一个分仓就会多一个区。
//! 若做成 `enum`,每加一个区都要回来改这个文件,
//! 那时「加一个库区」和「改领域层」变成了同一件事。
//!
//! 用 `const fn new` 的结构体,新增一个区只是**多一行常量**,
//! 与既有代码零接触(本工程的工程外扩展区会真的演示一次)。
//!
//! ## ⚠️ 为什么**不**派生 `Ord` / `Hash`
//!
//! 排序靠 [`StorageZone::sequence`] 这个显式整数,不靠 `Ord`:
//! 「库区谁先谁后」是**盘点动线**决定的(盘点员怎么走),
//! 不是编码字典序决定的。若派生 `Ord`,
//! 有人把区码从 `FRONT` 改成 `AFRONT`,遍历顺序就悄悄变了------
//! 而代码里没有任何一处写着「顺序在这里定义」。
//! 同理不实现用不上的 trait 本身就是一种防错。

/// 一个库区。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct StorageZone {
    /// 机器编码(如 `"FRONT"`)。
    code: &'static str,
    /// 中文名(如 `"前场展柜"`)。
    chinese_name: &'static str,
    /// ★ 盘点动线序号(**越小越先走到**)。
    sequence: u32,
}

impl StorageZone {
    /// 声明一个库区。
    pub const fn new(code: &'static str, chinese_name: &'static str, sequence: u32) -> Self {
        StorageZone { code, chinese_name, sequence }
    }

    /// 机器编码。
    pub const fn code(&self) -> &'static str {
        self.code
    }

    /// 中文名。
    pub const fn chinese_name(&self) -> &'static str {
        self.chinese_name
    }

    /// ★ 盘点动线序号。
    pub const fn sequence(&self) -> u32 {
        self.sequence
    }
}

/// 前场展柜(动线第 1 段)。
pub const ZONE_FRONT: StorageZone = StorageZone::new("FRONT", "前场展柜", 1);
/// 后场保险柜(动线第 2 段)。
pub const ZONE_BACK: StorageZone = StorageZone::new("BACK", "后场保险柜", 2);
/// 金库(动线第 3 段)。
pub const ZONE_VAULT: StorageZone = StorageZone::new("VAULT", "金库", 3);
/// 维修暂存区(动线第 4 段:待修件最后盘)。
pub const ZONE_REPAIR: StorageZone = StorageZone::new("REPAIR", "维修暂存", 4);

/// 工程内建的库区清单(**不含工程外扩展区新增的区**)。
pub const BUILTIN_STORAGE_ZONES: [StorageZone; 4] =
    [ZONE_FRONT, ZONE_BACK, ZONE_VAULT, ZONE_REPAIR];
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : store_code.rs
//! 门店编码:开放型标签。
//!
//! ## 为什么是 `const fn new` 结构体而不是 `enum`
//!
//! 判据只有一条:**这个维度会不会不断增加**。
//! 门店会(开新店就多一个),所以必须开放------
//! 否则工程外新增一家店要回来改领域层文件,
//! 而「可扩展性必须被实证」这条硬纪律要求:**扩展只能发生在装配点**。
//!
//! ## ⚠️ 为什么不实现 `Ord`
//!
//! 本工程从不对门店排序,只按**声明顺序**输出(声明顺序稳定 → 两次运行逐字节一致)。
//! 实现一个用不上的 `Ord`,等于给「按门店排序」这种没被设计过的用法开了口子。

/// 门店编码。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct StoreCode {
    /// 机器编码,如 `"STORE-CN-01"`。
    code: &'static str,
    /// 中文名称,如 `"广州天河店"`。
    chinese_name: &'static str,
}

impl StoreCode {
    /// 声明一家门店。
    pub const fn new(code: &'static str, chinese_name: &'static str) -> Self {
        Self { code, chinese_name }
    }

    /// 机器编码。
    pub const fn code(&self) -> &'static str {
        self.code
    }

    /// 中文名称。
    pub const fn chinese_name(&self) -> &'static str {
        self.chinese_name
    }
}

/// 广州天河店(内置)。
pub const STORE_CN_01: StoreCode = StoreCode::new("STORE-CN-01", "广州天河店");
/// 香港铜锣湾店(内置)。
pub const STORE_HK_01: StoreCode = StoreCode::new("STORE-HK-01", "香港铜锣湾店");

/// 内置门店全集。
///
/// ⚠️ 这张表只用于**装配**(建图时遍历),不用于「校验某店是否合法」------
/// 那会把工程外新增门店挡在门外。
pub const BUILTIN_STORE_CODES: [StoreCode; 2] = [STORE_CN_01, STORE_HK_01];
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : cursor.rs
//! 游标契约:**遍历这个动作本身的形状**。
//!
//! ## ★ 为什么是 `advance` 而不是 `next`
//!
//! 标准库写 `fn next(&mut self) -> Option<T>`。
//! 那个签名在本工程里不够用,原因只有一条:
//! `Option` **只有两种**,而一次推进有**三种**结局------
//! 产出 / 跳过 / 终止。
//!
//! 用 `Option` 时,「这一步被过滤掉了」只能表达成 `None`,
//! 于是它和「走完了」变成了同一个值;
//! 调用方为了继续走下去,只能写「循环调用 `next` 直到 `Some` 或到底」,
//! 而**「这一步到底跳过了几个」这个数字从此取不到**。
//! 偏偏「跳过了几个」是过滤游标唯一值得看的东西(幕十会数它)。
//!
//! ## ★ 为什么游标**不拥有**集合
//!
//! 见 `crate::domain::inventory_ledger` 的模块文档:
//! 集合只答「第 i 个是谁」,游标只记「走到哪了」。
//! 这里再加一条可检验的后果------
//! 本 trait 里**没有任何一个方法能改变集合**,
//! 连「当前元素」都是 `advance` 的返回值而不是游标上的字段。
//! 于是「同时开五个游标」不需要任何同步(幕九会真的开五个)。
//!
//! ## ⚠️ 为什么 `Item` 是关联类型而不是泛型参数
//!
//! 若写成 `trait Cursor<T>`,同一个游标类型可以被实现两次
//! (`Cursor<A>` 与 `Cursor<B>`),`Box<dyn Cursor>` 也就无从谈起。
//! 一个走法只产出一种东西,所以收进关联类型。

use super::cursor_ledger::CursorLedger;
use super::cursor_step::CursorStep;

/// ★ 推进次数硬上限。
///
/// 这不是业务口径,是**防呆**:
/// 一个永远返回 [`CursorStep::Skip`] 的游标会让 `drive` 变成死循环,
/// 而死循环的表现是「程序卡住」,不会留下任何可核对的证据。
/// 有了上限,它会变成一句能指出凶手的 `panic`。
///
/// 取值 `100_000` 远大于本工程任何一次盘点的批次数(十几个),
/// 不会误伤正常游标。
const MAX_ADVANCE_PER_DRIVE: usize = 100_000;

/// 一个游标。
///
/// # 实现者要守的三条
/// 1. [`Self::advance`] 返回 [`CursorStep::Stop`] 之后**不再产出**任何元素;
/// 2. [`Self::position`] 是「已经推进过的步数」,不是「产出过的个数」
///    (过滤游标里这两个数不相等,而**它们不相等正是要被看见的事**);
/// 3. [`Self::declared_total`] 是这一步数的**上限**,不是产出的个数。
pub trait Cursor {
    /// 产出的元素类型。
    type Item;

    /// 游标名(进报表与台账)。
    fn name(&self) -> &str;

    /// ★ 推进一次。
    ///
    /// 三种结局见 [`CursorStep`]。
    /// 返回 `Stop` 之后,实现必须继续返回 `Stop`(幂等)------
    /// 否则「走完了」就没有确定的含义。
    fn advance(&mut self) -> CursorStep<Self::Item>;

    /// 已经推进过的**步数**(产出 + 跳过,不含终止那一步)。
    fn position(&self) -> usize;

    /// ★ 声明的步数上限。
    ///
    /// ⚠️ 为什么由实现者声明而不是由机制层推断:
    /// 机制层看不见集合(它连 `crate::domain` 都不认识),
    /// 而「最多能走几步」只有集合知道。
    /// 让实现者声明,审计才能拿它当尺子(规则 `IN_RANGE`)。
    fn declared_total(&self) -> usize;

    /// 是否已经走到终止。
    ///
    /// ⚠️ 为什么需要这个而不是「`position >= declared_total`」:
    /// 过滤游标可以在没走满的时候就终止(提前收工),
    /// 那时 `position` 小于上限,但游标确实已经结束。
    /// 用位置推断终止,会把「提前收工」误判成「还没走完」。
    fn has_stopped(&self) -> bool;

    /// ★ 默认方法:一路推进到 `Stop`,返回全部产出(**不记账**)。
    ///
    /// ⚠️ 这个方法刻意**不**记台账:
    /// 「只是想把东西取出来」与「要数它走了几步」是两件事,
    /// 混在一起会让每一次取值都污染计数(责任链工程踩过同一个坑)。
    fn drain(&mut self) -> Vec<Self::Item>
    where
        Self: Sized,
    {
        let mut produced: Vec<Self::Item> = Vec::new();
        let mut advanced_total: usize = 0;
        loop {
            // 防呆:见 `MAX_ADVANCE_PER_DRIVE` 的注释。
            assert!(
                advanced_total < MAX_ADVANCE_PER_DRIVE,
                "游标「{}」推进了 {} 次仍未终止:多半是 advance 永远返回 Skip(死循环)",
                self.name(),
                advanced_total
            );
            advanced_total += 1;
            match self.advance() {
                CursorStep::Yield(item) => produced.push(item),
                CursorStep::Skip => {}
                CursorStep::Stop => break,
            }
        }
        produced
    }

    /// ★ 默认方法:一路推进到 `Stop`,**逐步记进台账**。
    ///
    /// # 参数
    /// - `ledger`:本次行走要写进的台账。
    ///
    /// 与 [`Self::drain`] 的区别只有「记不记账」;
    /// 产出内容必须**完全一致**,否则「为了观察而跑一遍」和「为了取值而跑一遍」
    /// 会给出两个答案------幕十会拿这一点做检查。
    fn drive(&mut self, ledger: &CursorLedger) -> Vec<Self::Item>
    where
        Self: Sized,
    {
        let cursor_name: String = self.name().to_string();
        let mut produced: Vec<Self::Item> = Vec::new();
        let mut advanced_total: usize = 0;
        ledger.record_drive(cursor_name.as_str());
        loop {
            assert!(
                advanced_total < MAX_ADVANCE_PER_DRIVE,
                "游标「{}」推进了 {} 次仍未终止:多半是 advance 永远返回 Skip(死循环)",
                cursor_name,
                advanced_total
            );
            advanced_total += 1;
            ledger.record_advance(cursor_name.as_str());
            match self.advance() {
                CursorStep::Yield(item) => {
                    ledger.record_yield(cursor_name.as_str());
                    produced.push(item);
                }
                CursorStep::Skip => ledger.record_skip(cursor_name.as_str()),
                CursorStep::Stop => {
                    ledger.record_stop(cursor_name.as_str());
                    break;
                }
            }
        }
        produced
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : cursor_audit.rs
//! 遍历契约审计:一个游标「走得像不像话」。
//!
//! ## ★ 为什么审计要存在
//!
//! 游标是个很小的东西------小到「它走错了」往往不表现为报错,
//! 而表现为**报表少了一行**。
//! 本工程的六条规则,每一条都盯住一种「能编译、能跑、但结论已经错了」的情形:
//!
//! | 规则 | 盯住什么 | 不改的话表现成什么 |
//! |---|---|---|
//! | `TERMINATES` | 永不终止 | 程序卡住 / 产出残缺 |
//! | `SINGLE_STOP` | 终止了多次 | 计数重复 |
//! | `STEP_ACCOUNTED` | 有一种步态忘了记账 | 恒等式悄悄失效 |
//! | `NO_GHOST` | 产出了不属于集合的东西 | 合计虚增,看不出来源 |
//! | `NO_DUPLICATE` | 同一个元素产出两次 | 合计虚增,看不出来源 |
//! | `IN_RANGE` | 越过集合末尾还在走 | 合计虚增 / 越界 |
//!
//! ## ★ 恒真检查没有价值
//!
//! 每一条规则都必须配一个**已知会失败的输入**([`CursorAuditRule::known_failing_input_text`])。
//! 一条「无论如何都通过」的规则不是检查,是装饰:
//! 它绿着的时候你不能得出任何结论。
//! 幕八(续)会把这六个坏样本真的喂进去,看它们**真的变红**。
//!
//! ## ⚠️ 本文件不认识业务
//!
//! 观测里只有 `String` 标签,没有批次、没有金额。
//! 「什么算一个元素」由调用层决定(它把批次指纹当作标签传进来);
//! 机制层只管「这些标签有没有凭空多出来 / 少了 / 重复了」。

/// 一次行走的观测(**可手工组装**,这点是刻意的)。
///
/// ⚠️ 为什么可手工组装而不是只能从游标生成:
/// 若观测只能由正常游标产生,那么「审计」就只是在检查「正常的游标是正常的」------
/// 那是一条恒真的检查。
/// 能手工组装,才能把**已知会失败**的观测喂进去(幕八续)。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WalkObservation {
    /// 游标名。
    cursor_name: String,
    /// ★ 产出顺序(**按产出先后**排列的标签)。
    yielded_labels: Vec<String>,
    /// ★ 声明的集合(**允许出现的标签全集**);为空表示「本次不核对来源」。
    allowed_labels: Vec<String>,
    /// 声明的步数上限。
    declared_total: usize,
    /// 推进次数。
    advance_total: usize,
    /// 产出次数。
    yield_total: usize,
    /// 跳过次数。
    skip_total: usize,
    /// 终止次数。
    stop_total: usize,
}

impl WalkObservation {
    /// 组装一次观测。
    ///
    /// # 参数
    /// - `cursor_name`:游标名。
    /// - `yielded_labels`:产出顺序(按产出先后)。
    /// - `allowed_labels`:允许出现的标签全集(空表示不核对)。
    /// - `declared_total`:声明的步数上限。
    /// - `advance_total`:推进次数。
    /// - `yield_total`:产出次数。
    /// - `skip_total`:跳过次数。
    /// - `stop_total`:终止次数。
    pub fn new(
        cursor_name: &str,
        yielded_labels: Vec<String>,
        allowed_labels: Vec<String>,
        declared_total: usize,
        advance_total: usize,
        yield_total: usize,
        skip_total: usize,
        stop_total: usize,
    ) -> Self {
        WalkObservation {
            cursor_name: cursor_name.to_string(),
            yielded_labels,
            allowed_labels,
            declared_total,
            advance_total,
            yield_total,
            skip_total,
            stop_total,
        }
    }

    /// 游标名。
    pub fn cursor_name(&self) -> &str {
        self.cursor_name.as_str()
    }

    /// 产出顺序(标签)。
    pub fn yielded_labels(&self) -> &[String] {
        self.yielded_labels.as_slice()
    }

    /// 声明的标签全集。
    pub fn allowed_labels(&self) -> &[String] {
        self.allowed_labels.as_slice()
    }

    /// 声明的步数上限。
    pub const fn declared_total(&self) -> usize {
        self.declared_total
    }

    /// 推进次数。
    pub const fn advance_total(&self) -> usize {
        self.advance_total
    }

    /// 产出次数。
    pub const fn yield_total(&self) -> usize {
        self.yield_total
    }

    /// 跳过次数。
    pub const fn skip_total(&self) -> usize {
        self.skip_total
    }

    /// 终止次数。
    pub const fn stop_total(&self) -> usize {
        self.stop_total
    }

    /// ★ 是否终止过。
    pub const fn is_terminated(&self) -> bool {
        self.stop_total > 0
    }

    /// ★ 顺序文本(`→` 连接,供报表核对用)。
    pub fn order_text(&self) -> String {
        self.yielded_labels.join(" → ")
    }

    /// ★ 重复产出的标签(**同一个标签出现两次以上**)。
    ///
    /// ⚠️ 返回**去重后**的清单并按字典序排序:
    /// 顺序取决于产出顺序,而报表要比对的是「哪些重复了」,
    /// 顺序不确定会破坏「两次运行逐字节一致」。
    pub fn duplicated_labels(&self) -> Vec<String> {
        let mut seen: Vec<String> = Vec::new();
        let mut duplicated: Vec<String> = Vec::new();
        for label in self.yielded_labels.iter() {
            if seen.iter().any(|existing| existing == label) {
                if !duplicated.iter().any(|existing| existing == label) {
                    duplicated.push(label.clone());
                }
            } else {
                seen.push(label.clone());
            }
        }
        duplicated.sort();
        duplicated
    }

    /// ★ 凭空多出来的标签(产出了但不在声明集合里)。
    pub fn ghost_labels(&self) -> Vec<String> {
        let mut ghosts: Vec<String> = self
            .yielded_labels
            .iter()
            .filter(|label| {
                !self
                    .allowed_labels
                    .iter()
                    .any(|allowed| allowed == *label)
            })
            .cloned()
            .collect();
        ghosts.sort();
        ghosts
    }

    /// ★ 走漏的标签(在声明集合里但没产出)。
    ///
    /// ⚠️ 这条**只对全量游标有意义**:
    /// 过滤游标本来就走漏一大半,把它算进来这条规则必然失败,
    /// 那等于把「过滤」这个需求又念了一遍(责任链工程踩过:反例行不能算进适用集)。
    pub fn missing_labels(&self) -> Vec<String> {
        let mut missing: Vec<String> = self
            .allowed_labels
            .iter()
            .filter(|allowed| {
                !self
                    .yielded_labels
                    .iter()
                    .any(|label| label == *allowed)
            })
            .cloned()
            .collect();
        missing.sort();
        missing
    }

    /// ★ 恒等式:推进次数 == 产出 + 跳过 + 终止。
    pub const fn is_step_accounted(&self) -> bool {
        self.advance_total == self.yield_total + self.skip_total + self.stop_total
    }

    /// ★ 是否在声明的步数上限之内(**终止那一步不算走步**)。
    pub const fn is_within_range(&self) -> bool {
        self.yield_total + self.skip_total <= self.declared_total
    }
}

/// 违规标签的预览字符数(**按字符数截断**,与显示宽度无关)。
const LABEL_PREVIEW_CHARS: usize = 10;

/// ★ 遍历契约的六条规则(**封闭集合**)。
///
/// 为什么是 `enum` 而不是开放标签:
/// 规则是要**穷尽检查**的(幕八那张表必须一行不漏),
/// 将来若加第七条,我希望编译器把所有 `match` 点红,
/// 而不是让某张报表悄悄少一行。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CursorAuditRule {
    /// 每个游标都必须终止。
    EveryCursorTerminates,
    /// 每个游标恰好终止一次。
    SingleTermination,
    /// 推进次数 == 产出 + 跳过 + 终止。
    StepAccounting,
    /// 不产出集合里没有的东西。
    NoGhostItem,
    /// 同一个元素不产出两次。
    NoDuplicateYield,
    /// 走步数不超过声明上限。
    WithinDeclaredRange,
}

impl CursorAuditRule {
    /// 机器编码(**稳定**:改中文文案不该让审计变红)。
    pub const fn code(&self) -> &'static str {
        match self {
            CursorAuditRule::EveryCursorTerminates => "TERMINATES",
            CursorAuditRule::SingleTermination => "SINGLE_STOP",
            CursorAuditRule::StepAccounting => "STEP_ACCOUNTED",
            CursorAuditRule::NoGhostItem => "NO_GHOST",
            CursorAuditRule::NoDuplicateYield => "NO_DUPLICATE",
            CursorAuditRule::WithinDeclaredRange => "IN_RANGE",
        }
    }

    /// 中文名。
    pub const fn chinese_text(&self) -> &'static str {
        match self {
            CursorAuditRule::EveryCursorTerminates => "游标必须终止",
            CursorAuditRule::SingleTermination => "恰好终止一次",
            CursorAuditRule::StepAccounting => "步态记账闭合",
            CursorAuditRule::NoGhostItem => "不产出集合外的元素",
            CursorAuditRule::NoDuplicateYield => "不重复产出",
            CursorAuditRule::WithinDeclaredRange => "不越过集合末尾",
        }
    }

    /// ★ 期望:**一句话说清「什么样的观测算合格」**。
    pub const fn expectation_text(&self) -> &'static str {
        match self {
            CursorAuditRule::EveryCursorTerminates => "终止次数 >= 1(永不终止 = 调用方死等)",
            CursorAuditRule::SingleTermination => "终止次数 == 1(两次 = 有人又推了一把)",
            CursorAuditRule::StepAccounting => "推进 == 产出 + 跳过 + 终止(三态都要记账)",
            CursorAuditRule::NoGhostItem => "每个产出标签都在声明集合里(不凭空造元素)",
            CursorAuditRule::NoDuplicateYield => "同一个标签最多产出一次(重复 = 合计虚增)",
            CursorAuditRule::WithinDeclaredRange => "产出 + 跳过 <= 声明上限(越界还在走就是错)",
        }
    }

    /// ★ 已知会失败的输入:**这条规则靠什么证明自己不是恒真的**。
    ///
    /// ⚠️ 这段文字是规则的一部分,不是注释:
    /// 幕八(续)会照着它造坏样本;改了规则不改这段文字,
    /// 那条负向测试就会「绿得莫名其妙」。
    pub const fn known_failing_input_text(&self) -> &'static str {
        match self {
            CursorAuditRule::EveryCursorTerminates => {
                "一个 advance 永远返回 Skip 的游标(走到上限也没终止)"
            }
            CursorAuditRule::SingleTermination => {
                "终止之后又被推进一次的游标(终止次数变成 2)"
            }
            CursorAuditRule::StepAccounting => {
                "记了推进却漏记某一种步态的台账(三类相加对不上推进次数)"
            }
            CursorAuditRule::NoGhostItem => {
                "产出一个不属于本次集合的元素(凭空多出来一个)"
            }
            CursorAuditRule::NoDuplicateYield => {
                "停在同一位置反复产出同一个元素(合计会虚增)"
            }
            CursorAuditRule::WithinDeclaredRange => {
                "越过后仍然继续推进的游标(步数超过集合长度)"
            }
        }
    }

    /// 全部规则(**顺序即展示顺序**)。
    pub const fn all() -> [CursorAuditRule; 6] {
        [
            CursorAuditRule::EveryCursorTerminates,
            CursorAuditRule::SingleTermination,
            CursorAuditRule::StepAccounting,
            CursorAuditRule::NoGhostItem,
            CursorAuditRule::NoDuplicateYield,
            CursorAuditRule::WithinDeclaredRange,
        ]
    }

    /// ★ 这条规则是否适用于该观测。
    ///
    /// ⚠️ 为什么要有「不适用」这一档:
    /// 一条规则若被用在它不该管的观测上,它**必然失败**,
    /// 于是那次失败不是证据,只是把规则又念了一遍。
    /// 例:声明集合为空时(只想知道步数,不核对来源),
    /// `NO_GHOST` 不适用。
    pub fn is_applicable_to(&self, observation: &WalkObservation) -> bool {
        match self {
            CursorAuditRule::NoGhostItem => !observation.allowed_labels().is_empty(),
            CursorAuditRule::WithinDeclaredRange => observation.declared_total() > 0,
            CursorAuditRule::EveryCursorTerminates
            | CursorAuditRule::SingleTermination
            | CursorAuditRule::StepAccounting
            | CursorAuditRule::NoDuplicateYield => true,
        }
    }

    /// ★ 这条规则是否在这个观测上通过。
    pub fn conforms(&self, observation: &WalkObservation) -> bool {
        match self {
            CursorAuditRule::EveryCursorTerminates => observation.is_terminated(),
            CursorAuditRule::SingleTermination => observation.stop_total() == 1,
            CursorAuditRule::StepAccounting => observation.is_step_accounted(),
            CursorAuditRule::NoGhostItem => observation.ghost_labels().is_empty(),
            CursorAuditRule::NoDuplicateYield => observation.duplicated_labels().is_empty(),
            CursorAuditRule::WithinDeclaredRange => observation.is_within_range(),
        }
    }

    /// ★ 违规样本的名字(用于报表的「违规项」列)。
    pub fn violating_text(&self, observation: &WalkObservation) -> String {
        if self.conforms(observation) {
            return String::new();
        }
        let labels: Vec<String> = match self {
            CursorAuditRule::NoGhostItem => observation.ghost_labels(),
            CursorAuditRule::NoDuplicateYield => observation.duplicated_labels(),
            CursorAuditRule::EveryCursorTerminates
            | CursorAuditRule::SingleTermination
            | CursorAuditRule::StepAccounting
            | CursorAuditRule::WithinDeclaredRange => Vec::new(),
        };
        if labels.is_empty() {
            observation.cursor_name().to_string()
        } else {
            // ⚠️ 标签可能是一长串指纹;这里按**字符数**截断而不是整串打出来,
            //    否则「违规项」这一列会被一个指纹占满,
            //    而读者真正要找的是**哪个游标**违规。
            let short_labels: Vec<String> = labels
                .iter()
                .map(|label| label.chars().take(LABEL_PREVIEW_CHARS).collect::<String>())
                .collect();
            format!("{}({})", observation.cursor_name(), short_labels.join("、"))
        }
    }
}

/// 一条规则在一批观测上的检查结果。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuleCheck {
    /// 被检查的规则。
    rule: CursorAuditRule,
    /// ★ 适用样本数(**不是**观测总数)。
    applicable_total: usize,
    /// 合格样本数。
    conforming_total: usize,
    /// 违规样本的名字清单。
    violating_items: Vec<String>,
}

impl RuleCheck {
    /// 由规则与观测清单构造(**由 [`audit_cursors`] 调用**)。
    pub fn new(rule: CursorAuditRule, observations: &[WalkObservation]) -> Self {
        let applicable: Vec<&WalkObservation> = observations
            .iter()
            .filter(|observation| rule.is_applicable_to(observation))
            .collect();
        let conforming_total: usize = applicable
            .iter()
            .filter(|observation| rule.conforms(observation))
            .count();
        let violating_items: Vec<String> = applicable
            .iter()
            .filter(|observation| !rule.conforms(observation))
            .map(|observation| rule.violating_text(observation))
            .collect();
        RuleCheck {
            rule,
            applicable_total: applicable.len(),
            conforming_total,
            violating_items,
        }
    }

    /// 被检查的规则。
    pub const fn rule(&self) -> CursorAuditRule {
        self.rule
    }

    /// 适用样本数。
    pub const fn applicable_total(&self) -> usize {
        self.applicable_total
    }

    /// 合格样本数。
    pub const fn conforming_total(&self) -> usize {
        self.conforming_total
    }

    /// 违规样本清单。
    pub fn violating_items(&self) -> &[String] {
        self.violating_items.as_slice()
    }

    /// ★ 是否通过。
    ///
    /// ⚠️ 判据里必须带 `applicable_total > 0`:
    /// 一条规则若一个样本都没查到,那么「合格数 == 适用数」是 `0 == 0`------
    /// 恒真。带上这一项,「这条规则一次都没被用上」才会显示为失败。
    pub const fn passed(&self) -> bool {
        self.applicable_total > 0 && self.conforming_total == self.applicable_total
    }

    /// 违规项文本(无违规时为空串)。
    pub fn violating_text(&self) -> String {
        self.violating_items.join("、")
    }
}

/// ★ 对一批观测跑全部规则。
///
/// # 参数
/// - `observations`:待检查的观测清单。
pub fn audit_cursors(observations: &[WalkObservation]) -> Vec<RuleCheck> {
    CursorAuditRule::all()
        .iter()
        .map(|rule| RuleCheck::new(*rule, observations))
        .collect()
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : cursor_ledger.rs
//! 游标台账:一次「行走」留下了什么。
//!
//! ## ★ 只增不减
//!
//! 本文件**刻意不提供** `reset` / `set` / `clear`。
//! 理由和责任链工程的台账一致:一旦允许清零,
//! 「两次运行逐字节一致」就只能靠调用顺序保证,
//! 而调用顺序是**看不见**的------报表上只看到最终数字,
//! 看不出它是被清零过还是本来就这么大。
//!
//! ## ★ 为什么要按游标名分别记
//!
//! 本工程要证明的是「五个游标同时走同一份台账,各走各的」。
//! 若台账只有一个总计数,那么「游标 A 走了 12 步」这句话
//! 就没有证据------总数 60 步也可能是「A 走了 60 步、其余四个没动」。
//! 按名分别记,才有幕九那张「各游标步数」的表。
//!
//! ## ⚠️ 为什么用 `Cell` / `RefCell` 而不是 `&mut self`
//!
//! `Cursor::drive` 拿到的是 `&CursorLedger`(共享借用):
//! 一趟行走里可能有多个游标写同一本台账,
//! 若要求 `&mut`,就得给每个游标发一本,
//! 那时「总数」又要靠事后相加------而相加这一步没有人为它负责。
//!
//! ⚠️ 注意:这不是线程安全的设计。本工程是单线程确定性程序,
//! 用 `Cell` 是「把单线程这件事写在类型里」而不是「忘了加锁」。

use std::cell::{Cell, RefCell};

/// 一个游标的记账行。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CursorRecord {
    /// 游标名。
    name: String,
    /// 这个游标被驱动的次数(`drive` 调用次数)。
    drive_total: usize,
    /// 推进次数(含终止那一步)。
    advance_total: usize,
    /// 产出次数。
    yield_total: usize,
    /// ★ 跳过次数(**过滤游标唯一值得看的数字**)。
    skip_total: usize,
    /// 终止次数(**正常应恰为 1**)。
    stop_total: usize,
}

impl CursorRecord {
    /// 游标名。
    pub fn name(&self) -> &str {
        self.name.as_str()
    }

    /// 被驱动次数。
    pub const fn drive_total(&self) -> usize {
        self.drive_total
    }

    /// 推进次数。
    pub const fn advance_total(&self) -> usize {
        self.advance_total
    }

    /// 产出次数。
    pub const fn yield_total(&self) -> usize {
        self.yield_total
    }

    /// 跳过次数。
    pub const fn skip_total(&self) -> usize {
        self.skip_total
    }

    /// 终止次数。
    pub const fn stop_total(&self) -> usize {
        self.stop_total
    }

    /// ★ 一次都没被推进过(**死游标**)。
    ///
    /// 「装进来了但没人走」在报表上表现为「0 / 0 / 0」,
    /// 与「走了但什么都没产出」长得一模一样------
    /// 必须有一个专门的判据把它们分开。
    pub const fn never_advanced(&self) -> bool {
        self.advance_total == 0
    }

    /// ★ 全程只跳过、从不产出(**空过滤**)。
    ///
    /// 这多半是筛选条件写反了(例如把「需复检」写成「不需复检」)。
    /// 它照样能编译、照样能跑,只有这个判据会指出它。
    pub const fn is_pure_skip(&self) -> bool {
        self.advance_total > 0 && self.yield_total == 0
    }

    /// ★ 恒等式:推进次数 == 产出 + 跳过 + 终止。
    ///
    /// ⚠️ 这条不是「顺便检查一下」,它是台账**自己的**定义:
    /// 每一次推进必须被归到三类中恰好一类。
    /// 若哪天有人加了一种新步态却忘了记台账,
    /// 这里会立刻不等------而不是等到报表上出现对不上的合计。
    pub const fn is_step_accounted(&self) -> bool {
        self.advance_total == self.yield_total + self.skip_total + self.stop_total
    }
}

/// 台账快照(**一次取走,之后不再受写入影响**)。
///
/// ⚠️ 为什么要有快照而不是直接读台账:
/// 报表要读好几个数,若每个数都实时去问台账,
/// 那么「读第一个数」与「读最后一个数」之间台账可能已经被别处改了,
/// 报表上的几个数就会来自**不同时刻**------
/// 而报表上看不出它们不是同一时刻的。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LedgerSnapshot {
    /// 被驱动的游标次数合计。
    drive_total: usize,
    /// 推进次数合计。
    advance_total: usize,
    /// 产出次数合计。
    yield_total: usize,
    /// 跳过次数合计。
    skip_total: usize,
    /// 终止次数合计。
    stop_total: usize,
    /// 逐游标的记账行(**按游标名排序**,保证两次运行一致)。
    records: Vec<CursorRecord>,
}

impl LedgerSnapshot {
    /// 由台账构造(**仅** `CursorLedger::snapshot` 调用)。
    pub(super) fn new(
        drive_total: usize,
        advance_total: usize,
        yield_total: usize,
        skip_total: usize,
        stop_total: usize,
        records: Vec<CursorRecord>,
    ) -> Self {
        LedgerSnapshot {
            drive_total,
            advance_total,
            yield_total,
            skip_total,
            stop_total,
            records,
        }
    }

    /// 被驱动次数合计。
    pub const fn drive_total(&self) -> usize {
        self.drive_total
    }

    /// 推进次数合计。
    pub const fn advance_total(&self) -> usize {
        self.advance_total
    }

    /// 产出次数合计。
    pub const fn yield_total(&self) -> usize {
        self.yield_total
    }

    /// ★ 跳过次数合计。
    pub const fn skip_total(&self) -> usize {
        self.skip_total
    }

    /// 终止次数合计。
    pub const fn stop_total(&self) -> usize {
        self.stop_total
    }

    /// 逐游标记账行。
    pub fn records(&self) -> &[CursorRecord] {
        self.records.as_slice()
    }

    /// ★ 恒等式一:推进次数 == 产出 + 跳过 + 终止(**全台账口径**)。
    pub const fn is_step_accounted(&self) -> bool {
        self.advance_total == self.yield_total + self.skip_total + self.stop_total
    }

    /// ★ 恒等式二:合计 == 各游标之和(否则说明有推进没落到任何游标名下)。
    pub fn is_sum_accounted(&self) -> bool {
        let advance_sum: usize = self.records.iter().map(|record| record.advance_total()).sum();
        let yield_sum: usize = self.records.iter().map(|record| record.yield_total()).sum();
        let skip_sum: usize = self.records.iter().map(|record| record.skip_total()).sum();
        let stop_sum: usize = self.records.iter().map(|record| record.stop_total()).sum();
        self.advance_total == advance_sum
            && self.yield_total == yield_sum
            && self.skip_total == skip_sum
            && self.stop_total == stop_sum
    }

    /// ★ 恒等式三:每个被驱动过的游标**恰好终止一次**。
    ///
    /// ⚠️ 判据是「被驱动过」而不是「全部」:
    /// 没被驱动的游标终止次数是 0,把它算进来这条就永远为假------
    /// 那就是把「没走」和「走错了」混成了一件事。
    pub fn is_termination_accounted(&self) -> bool {
        self.records
            .iter()
            .filter(|record| record.drive_total() > 0)
            .all(|record| record.stop_total() == 1)
    }

    /// ★ 一次都没被推进过的游标名(**死游标清单**)。
    pub fn never_advanced_cursors(&self) -> Vec<String> {
        self.records
            .iter()
            .filter(|record| record.never_advanced())
            .map(|record| record.name().to_string())
            .collect()
    }

    /// ★ 全程只跳过、从不产出的游标名(**空过滤清单**)。
    pub fn pure_skip_cursors(&self) -> Vec<String> {
        self.records
            .iter()
            .filter(|record| record.is_pure_skip())
            .map(|record| record.name().to_string())
            .collect()
    }
}

/// 一本游标台账。
#[derive(Clone, Debug)]
pub struct CursorLedger {
    /// 台账名(报表标题用,也用于区分「主台账」与「对照组台账」)。
    name: String,
    /// 被驱动次数。
    drive_total: Cell<usize>,
    /// 推进次数。
    advance_total: Cell<usize>,
    /// 产出次数。
    yield_total: Cell<usize>,
    /// 跳过次数。
    skip_total: Cell<usize>,
    /// 终止次数。
    stop_total: Cell<usize>,
    /// 逐游标记账行。
    records: RefCell<Vec<CursorRecord>>,
}

impl CursorLedger {
    /// 开一本台账。
    ///
    /// # 参数
    /// - `name`:台账名(如 `"主台账"` / `"对照组台账"`)。
    pub fn new(name: &str) -> Self {
        CursorLedger {
            name: name.to_string(),
            drive_total: Cell::new(0),
            advance_total: Cell::new(0),
            yield_total: Cell::new(0),
            skip_total: Cell::new(0),
            stop_total: Cell::new(0),
            records: RefCell::new(Vec::new()),
        }
    }

    /// 台账名。
    pub fn name(&self) -> &str {
        self.name.as_str()
    }

    /// 记一次「这个游标被驱动了」。
    ///
    /// ⚠️ 为什么单独记一次而不是靠「推进次数 > 0」推断:
    /// 一个游标可以被驱动但一步都没走(集合为空),
    /// 那时「被驱动过」是真的、「走过」是假的。
    /// 不分开记,空集合的游标就会从报表上消失。
    pub fn record_drive(&self, cursor_name: &str) {
        self.drive_total.set(self.drive_total.get() + 1);
        let record_index: usize = self.record_index_of(cursor_name);
        self.records.borrow_mut()[record_index].drive_total += 1;
    }

    /// 记一次推进。
    pub fn record_advance(&self, cursor_name: &str) {
        self.advance_total.set(self.advance_total.get() + 1);
        let record_index: usize = self.record_index_of(cursor_name);
        self.records.borrow_mut()[record_index].advance_total += 1;
    }

    /// 记一次产出。
    ///
    /// # Panics
    /// 这个游标还没有任何推进记录时 panic:
    /// 「没走一步就产出」说明记账顺序被写反了,
    /// 静默接受会让恒等式一永远成立(因为三类都同步加一)。
    pub fn record_yield(&self, cursor_name: &str) {
        let record_index: usize = self.record_index_of(cursor_name);
        let mut records = self.records.borrow_mut();
        assert!(
            records[record_index].advance_total > 0,
            "游标「{}」还没有推进记录就记了一次产出:记账顺序被写反了",
            cursor_name
        );
        records[record_index].yield_total += 1;
        drop(records);
        self.yield_total.set(self.yield_total.get() + 1);
    }

    /// 记一次跳过。
    ///
    /// # Panics
    /// 同 [`Self::record_yield`]。
    pub fn record_skip(&self, cursor_name: &str) {
        let record_index: usize = self.record_index_of(cursor_name);
        let mut records = self.records.borrow_mut();
        assert!(
            records[record_index].advance_total > 0,
            "游标「{}」还没有推进记录就记了一次跳过:记账顺序被写反了",
            cursor_name
        );
        records[record_index].skip_total += 1;
        drop(records);
        self.skip_total.set(self.skip_total.get() + 1);
    }

    /// 记一次终止。
    ///
    /// # Panics
    /// 同 [`Self::record_yield`]。
    pub fn record_stop(&self, cursor_name: &str) {
        let record_index: usize = self.record_index_of(cursor_name);
        let mut records = self.records.borrow_mut();
        assert!(
            records[record_index].advance_total > 0,
            "游标「{}」还没有推进记录就记了一次终止:记账顺序被写反了",
            cursor_name
        );
        records[record_index].stop_total += 1;
        drop(records);
        self.stop_total.set(self.stop_total.get() + 1);
    }

    /// ★ 取快照(**报表一律读它,不读实时计数**)。
    ///
    /// 记账行按游标名排序:插入顺序取决于装配顺序,
    /// 而装配顺序是可能被改的;排序后两次运行才逐字节一致。
    pub fn snapshot(&self) -> LedgerSnapshot {
        let mut records: Vec<CursorRecord> = self.records.borrow().clone();
        records.sort_by(|left, right| left.name().cmp(right.name()));
        LedgerSnapshot::new(
            self.drive_total.get(),
            self.advance_total.get(),
            self.yield_total.get(),
            self.skip_total.get(),
            self.stop_total.get(),
            records,
        )
    }

    /// 取出(或新建)该游标记账行的下标。
    fn record_index_of(&self, cursor_name: &str) -> usize {
        let mut records = self.records.borrow_mut();
        match records.iter().position(|record| record.name() == cursor_name) {
            Some(index) => index,
            None => {
                records.push(CursorRecord {
                    name: cursor_name.to_string(),
                    drive_total: 0,
                    advance_total: 0,
                    yield_total: 0,
                    skip_total: 0,
                    stop_total: 0,
                });
                records.len() - 1
            }
        }
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : cursor_step.rs
//! ★ 一次「推进」的三态结果:`产出` / `跳过` / `终止`。
//!
//! ## 为什么不是 `Option<T>`
//!
//! 绝大多数实现写 `fn next(&mut self) -> Option<T>`,
//! 用 `None` 同时表示「这个被过滤掉了」与「走完了」。
//! 这两种情况在**遍历器**里必须分开:
//!
//! - 「过滤掉」意味着**还要继续往下走**;
//! - 「走完了」意味着**到此为止**。
//!
//! 用 `Option` 表达过滤游标时,写法只能是「循环调用自己直到拿到 `Some`」,
//! 于是「这次推进实际上是跳过了几个」这个数字**永远拿不到**------
//! 而「跳过了几个」正是过滤游标唯一值得看的东西(本工程幕十会数它)。
//!
//! ## ⚠️ 三态而不是四态
//!
//! 不要加 `Error`:`Err` 在遍历里的语义是「集合坏了」,
//! 那是集合的问题,不是遍历器的问题。
//! 让集合在构造时就把自己弄干净(见 `StockBatch::new` 的 `assert!`)。

/// 推进一次的结果。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CursorStep<T> {
    /// 产出一个元素。
    Yield(T),
    /// ★ 这一步**没有**产出,但**还要继续走**(被过滤掉 / 被跳过)。
    Skip,
    /// 走完了,之后不应再有产出。
    Stop,
}

impl<T> CursorStep<T> {
    /// 是否产出了元素。
    pub const fn is_yield(&self) -> bool {
        matches!(self, CursorStep::Yield(_))
    }

    /// 是否是「跳过但继续」。
    pub const fn is_skip(&self) -> bool {
        matches!(self, CursorStep::Skip)
    }

    /// 是否是「终止」。
    pub const fn is_stop(&self) -> bool {
        matches!(self, CursorStep::Stop)
    }

    /// 取出产出的元素(非 `Yield` 时 `None`)。
    pub fn item(self) -> Option<T> {
        match self {
            CursorStep::Yield(item) => Some(item),
            CursorStep::Skip | CursorStep::Stop => None,
        }
    }

    /// ★ 机器名(审计与台账用:`YIELD` / `SKIP` / `STOP`)。
    ///
    /// ⚠️ 为什么要单独一个方法而不是 `{:?}`:
    /// `{:?}` 会把 `Yield(...)` 的载荷一起打出来,
    /// 而载荷是**业务对象**(可能很长,也可能含金额),
    /// 让它进审计文本会让「审计输出」随业务数据变化。
    pub const fn machine_text(&self) -> &'static str {
        match self {
            CursorStep::Yield(_) => "YIELD",
            CursorStep::Skip => "SKIP",
            CursorStep::Stop => "STOP",
        }
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : calendar_date.rs
//! ★ Sakamoto 算法求星期 + 逐日推进 + 工作日推进。
//!
//! ## ⚠️ 既有工程的一条硬教训(本文件照做)
//!
//! **`(26 * (m + 1)) / 10` 不能代替月份偏移表**。它只是 `floor(2.6 * (m + 1))`
//! 的近似,与偏移表 `[0,3,2,5,0,3,5,1,4,6,2,4]` 在 3 月、9 月等多个月份不等,
//! 会导致**整年错一位**。既有工程因此吃过亏。
//! **有公认表格的算法,宁可把表写出来,也不要用「看起来等价」的算式。**
//!
//! ## 为什么「推进」用逐日循环而不是儒略日公式
//!
//! 逐日推进显然正确(只需处理「月末进位」「年末进位」「闰年 2 月」三件事);
//! 儒略日公式是 O(1),但多一个可能写错的公式。
//! 本工程的日期跨度都在一年以内,调用次数是个位数量级,性能无关紧要。
//! **当「明显正确的笨办法」与「可能写错的聪明办法」成本相当时,选笨办法。**

/// ★ 月份偏移量表(Sakamoto 算法)。
///
/// 下标 = 月份 − 1。⚠️ **不要**把它化简成 `(26 * (m + 1)) / 10` 之类,
/// 二者在 3 月、9 月不等价(见模块文档)。
const SAKAMOTO_MONTH_OFFSETS: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];

/// 中文星期名,下标与 [`CalendarDate::weekday_index`] 一致:`0 = 周日`。
const WEEKDAY_CHINESE_NAMES: [&str; 7] = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];

/// 一个公历日期(年 / 月 / 日,三个整数)。
///
/// 刻意**不派生** `Default`:一个「默认日期」在业务上没有意义,
/// 能让调用方写出 `CalendarDate::default()` 只会掩盖「忘了传日期」这类错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CalendarDate {
    /// 公历年(如 2026)。用 `i32` 是因为要参与 `year - 1` 的中间运算。
    year: i32,
    /// 公历月,取值 `1..=12`。
    month: u32,
    /// 公历日,取值 `1..=31`(具体上界随年月变化)。
    day: u32,
}

impl CalendarDate {
    /// 构造。**故意不校验**------校验在 [`Self::is_valid`],
    /// 因为「构造一个待校验的日期」本身是合法需求(本工程用它做反例)。
    pub const fn new(year: i32, month: u32, day: u32) -> Self {
        CalendarDate { year, month, day }
    }

    /// 年份。
    pub const fn year(&self) -> i32 {
        self.year
    }

    /// 月份。
    pub const fn month(&self) -> u32 {
        self.month
    }

    /// 日。
    pub const fn day(&self) -> u32 {
        self.day
    }

    /// 判断年月日组合是否落在合法范围内。
    pub fn is_valid(&self) -> bool {
        if self.month < 1 || self.month > 12 {
            return false;
        }
        if self.day < 1 {
            return false;
        }
        self.day <= days_in_month(self.year, self.month)
    }

    /// 返回**星期索引**:`0 = 周日, 1 = 周一, ... 6 = 周六`(Sakamoto 算法)。
    ///
    /// ⚠️ 最后用 `rem_euclid(7)` 而不是 `% 7`:
    /// `%` 对负数会得到负数(Rust 与 C 一致),当作索引用会在公元前的年份上
    /// 直接 panic。虽然本工程用不到那些年份,但 `rem_euclid` 是正确的写法,
    /// **不依赖调用方的取值范围假设**。
    pub fn weekday_index(&self) -> usize {
        let month: i32 = self.month as i32;
        let day: i32 = self.day as i32;

        // ★ 1 月 / 2 月必须借上一年的年份------这一步不可省略。
        let adjusted_year: i32 = if month < 3 { self.year - 1 } else { self.year };

        // 月份偏移量查表(★ 不化简成算式)。
        let month_offset: i32 = SAKAMOTO_MONTH_OFFSETS[(self.month - 1) as usize];

        let raw_index: i32 = adjusted_year
            + adjusted_year / 4
            - adjusted_year / 100
            + adjusted_year / 400
            + month_offset
            + day;

        raw_index.rem_euclid(7) as usize
    }

    /// 中文星期名(如「周三」)。
    pub fn weekday_chinese_text(&self) -> &'static str {
        WEEKDAY_CHINESE_NAMES[self.weekday_index()]
    }

    /// 是否周末(周六或周日)。
    ///
    /// ⚠️ 写成 `index == 0 || index == 6` 而不是 `index >= 5`:
    /// 索引 0 是**周日**,用 `>= 5` 会把周五也算成周末。
    pub fn is_weekend(&self) -> bool {
        let index: usize = self.weekday_index();
        index == 0 || index == 6
    }

    /// 机器格式:`2026-10-07`(定长 10 列,适合进表格)。
    ///
    /// 定长是本函数存在的理由:`{}-{}-{}` 直接拼会让 2026-1-7 变成 8 列,
    /// 与 `2026-10-07`(10 列)宽度不同,表格立刻歪掉。
    pub fn machine_text(&self) -> String {
        format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
    }

    /// 中文格式:`2026年10月7日`(**不定长**,仅用于正文说明,不进定宽列)。
    pub fn chinese_text(&self) -> String {
        format!("{}年{}月{}日", self.year, self.month, self.day)
    }

    /// 中文格式 + 星期:`2026年10月7日(周三)`。
    pub fn chinese_text_with_weekday(&self) -> String {
        format!("{}({})", self.chinese_text(), self.weekday_chinese_text())
    }

    /// 返回当天的前一天 / 后一天(由 `step` 决定,取值 `+1` 或 `-1`)。
    fn step_one_day(&self, step: i32) -> CalendarDate {
        let mut year: i32 = self.year;
        let mut month: u32 = self.month;
        let mut day: i32 = self.day as i32 + step;

        if day < 1 {
            // 向前借一个月。
            month -= 1;
            if month < 1 {
                month = 12;
                year -= 1;
            }
            day = days_in_month(year, month) as i32;
        } else if day > days_in_month(year, month) as i32 {
            // 向后进一个月。
            day = 1;
            month += 1;
            if month > 12 {
                month = 1;
                year += 1;
            }
        }

        CalendarDate::new(year, month, day as u32)
    }

    /// 加 / 减若干**自然日**。
    ///
    /// ⚠️ 循环带硬上限:算错的日期会变成**卡住的程序**,
    /// 那是比「算错」更糟的失败形态(既有工程的教训)。
    pub fn add_days(&self, delta: i32) -> CalendarDate {
        let mut current: CalendarDate = *self;
        let step: i32 = if delta >= 0 { 1 } else { -1 };
        let mut remaining: i32 = delta.abs();
        let mut guard: i32 = 0;
        while remaining > 0 && guard < 100_000 {
            current = current.step_one_day(step);
            remaining -= 1;
            guard += 1;
        }
        current
    }

    /// 推进若干**工作日**(跳过周末)。
    ///
    /// ★ **起点当天不计入**:`add_working_days(1)` 得到「下一个工作日」,
    /// 这正是「受理日的次日开工」的语义。
    ///
    /// ⚠️ 因此「工期 N 个工作日的完成日」要写成 `add_working_days(N - 1)`------
    /// 那个 `− 1` 不是笔误,是**把起点那天收回来**。这两个 `±1` 必须成对理解。
    pub fn add_working_days(&self, delta: i32) -> CalendarDate {
        let mut current: CalendarDate = *self;
        let step: i32 = if delta >= 0 { 1 } else { -1 };
        let mut remaining: i32 = delta.abs();
        let mut guard: i32 = 0;
        while remaining > 0 && guard < 100_000 {
            current = current.step_one_day(step);
            if !current.is_weekend() {
                remaining -= 1;
            }
            guard += 1;
        }
        current
    }
}

/// 是否闰年。
///
/// ★ 三条规则,**第三条最容易被漏**:
/// ① 能被 4 整除;② 不能被 100 整除;③ **除非**能被 400 整除。
/// 因此校验时必须用 **2100 年**做探针(2000 年是闰年,发现不了漏了第三条)。
pub const fn is_leap_year(year: i32) -> bool {
    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}

/// 某年某月的天数。
pub const fn days_in_month(year: i32, month: u32) -> u32 {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 => {
            if is_leap_year(year) {
                29
            } else {
                28
            }
        }
        // 非法月份返回 0:让 `is_valid` 判 false,而不是 panic。
        _ => 0,
    }
}

/// 两个日期之间的天数差(`to - from`,可为负)。
///
/// ⚠️ 同样带上界,理由同 [`CalendarDate::add_days`]。
pub fn days_between(from: CalendarDate, to: CalendarDate) -> i64 {
    let mut count: i64 = 0;
    let mut current: CalendarDate = from;
    let mut guard: i64 = 0;
    if from <= to {
        while current < to && guard < 100_000 {
            current = current.step_one_day(1);
            count += 1;
            guard += 1;
        }
    } else {
        while current > to && guard < 100_000 {
            current = current.step_one_day(-1);
            count -= 1;
            guard += 1;
        }
    }
    count
}

/// 把日期渲染成**表格单元格**文本。
///
/// ⚠️ 刻意走 [`CalendarDate::machine_text`](定长 10 列)而不是中文格式:
/// 中文格式不定长(`2026年10月7日` 与 `2026年1月7日` 宽度不同),
/// 进表格会歪。中文格式只在注解里出现。
pub fn report_cell_text(date: CalendarDate) -> String {
    date.machine_text()
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : deterministic_code.rs
//! 确定性编码:FNV-1a 64 位 + base36 定长输出。
//!
//! ## 为什么不用 `DefaultHasher`
//!
//! `std::collections::hash_map::DefaultHasher` 的值**跨 Rust 版本不保证稳定**,
//! 而本工程的验收线是「两次运行逐字节一致」------跨版本不稳定意味着
//! 换一次工具链,输出的编号就全变了。
//! 更实际的问题是:**读者无法复算**。自己实现 FNV-1a 后,
//! 编号的生成规则写在这 80 行里,任何人都能手算验一遍。
//!
//! ## 为什么要段间分隔符
//!
//! 把 `["AB", "C"]` 与 `["A", "BC"]` 直接拼起来都是 `"ABC"`,会**撞哈希**。
//! 段间插入 `0x1f`(ASCII Unit Separator)之后两者不同。
//! 这是既有工程的实测教训:**拼接型哈希必须有分隔符**。

/// FNV-1a 64 位偏移基址。
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
/// FNV-1a 64 位质数。
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
/// 段间分隔符:ASCII Unit Separator(**选它的理由就是它几乎不会出现在文本里**)。
const SEGMENT_SEPARATOR: u8 = 0x1f;

/// 把若干段拼成一个**稳定的 64 位哈希**。
///
/// 段与段之间插入 [`SEGMENT_SEPARATOR`],防止 `["AB","C"]` 与 `["A","BC"]` 碰撞。
pub fn stable_hash(parts: &[&str]) -> u64 {
    let mut hash: u64 = FNV_OFFSET_BASIS;
    for (index, part) in parts.iter().enumerate() {
        // 除第一段外,先喂一个分隔符。
        if index > 0 {
            hash ^= u64::from(SEGMENT_SEPARATOR);
            hash = hash.wrapping_mul(FNV_PRIME);
        }
        for byte in part.as_bytes() {
            hash ^= u64::from(*byte);
            hash = hash.wrapping_mul(FNV_PRIME);
        }
    }
    hash
}

/// base36 字符集(数字 + 大写字母)。
const BASE36_ALPHABET: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";

/// 由若干段生成**定长**的 base36 编码。
///
/// 定长是刻意的:编号要进定宽表格列,长度不定会把列顶歪。
///
/// ⚠️ `width` 取值 `1..=13`:64 位无符号数在 base36 下最多 13 位,
/// 超过 13 位高位全是 `0`,写出来是**假精度**(看起来信息量大,其实都是零)。
pub fn deterministic_code(parts: &[&str], width: usize) -> String {
    debug_assert!(width >= 1 && width <= 13, "base36 最多 13 位,width={} 会产出假精度", width);
    let mut value: u64 = stable_hash(parts);
    let mut buffer: Vec<char> = Vec::with_capacity(width);
    for _ in 0..width {
        let digit: usize = (value % 36) as usize;
        buffer.push(BASE36_ALPHABET[digit] as char);
        value /= 36;
    }
    buffer.iter().rev().collect()
}

/// 带前缀的确定性编码:`前缀-XXXXXX`。
///
/// 前缀让编号**可人读**(`CARD-` / `TPL-` / `FIX-`),
/// 后面那段保证唯一且可复算。
pub fn prefixed_code(prefix: &str, parts: &[&str], width: usize) -> String {
    format!("{}-{}", prefix, deterministic_code(parts, width))
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : text_layout.rs
//! 显示宽度与版面工具:**中文占 2 列**这一事实的全部实现都在这里。
//!
//! ## 为什么不能直接用 `{:<N}` 或 `str::len()`
//!
//! `{:<N}` 按**字符数**填充,而终端里一个汉字占 2 个显示列。
//! 于是「工序段」(3 字 = 6 列) 与 `STAGE`(5 列) 在同一格里会错开 1 列,
//! 整张表立刻歪掉------**而且歪得很整齐**,看不出来是错的。
//! `str::len()` 是**字节数**,中文是 3 字节,更离谱。
//!
//! ## 宽度模型:按 `east_asian_width` 的 `W` / `F` 判 2 列
//!
//! 这是 Unicode 的标准属性。⚠️ 但要注意两类**看起来像宽字符其实不是**的:
//! - `─`(U+2500,制表符):属性是 `A`(歧义),本工程按 **1 列**计;
//! - `...`(U+2026,省略号):属性是 `A`,本工程按 **1 列**计。
//!
//! ⚠️ 既有工程踩过的坑:曾凭印象写「U+2500 落在 CJK 区间 `U+2E80..=0x303E` 里」,
//! 而 **U+2500 = 9472 < 0x2E80 = 11904**,根本没落进去。
//! 结论:**有公认表格的判断,宁可让机器算一遍**------所以本文件有
//! [`self_check_width_model`] 这条永久自检。

/// 判断一个字符是否占 **2 个显示列**。
///
/// 区间表按 Unicode `East_Asian_Width` 的 `W` / `F` 摘取,
/// 各区间**互不重叠**(写重叠区间不会有错,但会让读者以为有优先级)。
pub const fn is_wide_character(character: char) -> bool {
    let code: u32 = character as u32;
    matches!(code,
        // CJK 统一汉字及扩展(不含后面单独列出的兼容区)
        0x1100..=0x115F     // 谚文首字母
        | 0x2E80..=0x303E   // CJK 部首、康熙部首、CJK 符号与标点
        | 0x3041..=0x33FF   // 平假名、片假名、注音、谚文兼容、CJK 兼容
        | 0x3400..=0x4DBF   // CJK 扩展 A
        | 0x4E00..=0x9FFF   // CJK 基本区(最常用的汉字都在这里)
        | 0xA000..=0xA4CF   // 彝文
        | 0xAC00..=0xD7A3   // 谚文音节
        | 0xF900..=0xFAFF   // CJK 兼容汉字
        | 0xFE10..=0xFE19   // 竖排标点
        | 0xFE30..=0xFE6F   // CJK 兼容形式
        | 0xFF00..=0xFF60   // 全角 ASCII、全角标点
        | 0xFFE0..=0xFFE6   // 全角符号
        | 0x1F300..=0x1F64F // 表情符号(常用段)
        | 0x1F900..=0x1F9FF // 补充表情符号
        | 0x20000..=0x2FFFD // CJK 扩展 B..F
        | 0x30000..=0x3FFFD // CJK 扩展 G
    )
}

/// 计算一个字符串的**显示宽度**(中文 2 列、英文 1 列)。
pub fn display_width(text: &str) -> usize {
    text.chars().map(|character| if is_wide_character(character) { 2 } else { 1 }).sum()
}

/// 右填充(左对齐):不足补空格,**超出不截断**。
///
/// 不截断是刻意的:调用方要截断请显式调 [`truncate_to_width`],
/// 让「这里可能被截」这件事在代码里看得见。
pub fn pad_right(text: &str, width: usize) -> String {
    let used: usize = display_width(text);
    if used >= width {
        return text.to_string();
    }
    format!("{}{}", text, " ".repeat(width - used))
}

/// 左填充(右对齐):数字列用这个。
pub fn pad_left(text: &str, width: usize) -> String {
    let used: usize = display_width(text);
    if used >= width {
        return text.to_string();
    }
    format!("{}{}", " ".repeat(width - used), text)
}

/// 居中填充:**左侧少、右侧多**(既有多余的一列给右边)。
///
/// 为什么必须定死「奇数给右边」:不定死时,同一个表头在不同行
/// 可能一个偏左一个偏右,看起来像内容变了。
pub fn pad_center(text: &str, width: usize) -> String {
    let used: usize = display_width(text);
    if used >= width {
        return text.to_string();
    }
    let total: usize = width - used;
    let left: usize = total / 2;
    let right: usize = total - left;
    format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
}

/// 按显示宽度截断,**不劈开半个汉字**。
///
/// ⚠️ 返回值宽度可能在 `width - 1 ..= width` 之间浮动:
/// 放不下一个 2 列宽的汉字时,它会少补 1 列。
/// 因此**任何「必须恰好等于 width」的断言都是错的**。
pub fn truncate_to_width(text: &str, width: usize) -> String {
    let mut used: usize = 0;
    let mut result: String = String::new();
    for character in text.chars() {
        let character_width: usize = if is_wide_character(character) { 2 } else { 1 };
        if used + character_width > width {
            break;
        }
        result.push(character);
        used += character_width;
    }
    result
}

/// 可见省略:放不下时截断并追加 `...`,**让读者知道这里被省了**。
///
/// ⚠️ 必须先给 `...` **留 1 列**:写成 `truncate(text, width) + "..."`
/// 会让结果变成 `width + 1` 列,把整行顶歪。
pub fn elide_text(text: &str, width: usize) -> String {
    if display_width(text) <= width {
        return text.to_string();
    }
    // width 为 0 时无法放省略号,退化成空串(不 panic:这是排版,不是逻辑错误)。
    if width == 0 {
        return String::new();
    }
    let keep: usize = width - 1;
    format!("{}...", truncate_to_width(text, keep))
}

/// 生成一条指定宽度的分隔线。
///
/// ⚠️ 拼 `width` 个 `─`,**不是** `width / 2` 个。
/// `─`(U+2500) 按 1 列计(见模块文档),写 `width / 2` 会得到一半长的线。
pub fn horizontal_rule(width: usize) -> String {
    "─".repeat(width)
}

/// ★ 宽度模型的**永久自检**:返回 `(探针名, 是否通过, 实测说明)`。
///
/// 为什么要有它:宽度常量写错时,报表**看起来完全正常**(只是分隔线短一截、
/// 某一列窄一点),肉眼几乎不可能发现。自检把「看起来正常」变成「有数字对不上」。
///
/// 探针的选择:`0 / 1 / 2` 覆盖边界;`17 / 92 / 153` 覆盖奇偶与较大值。
pub fn self_check_width_model() -> Vec<(String, bool, String)> {
    let mut probes: Vec<(String, bool, String)> = Vec::new();

    // 探针一:`horizontal_rule(n)` 的宽度必须**恰好**是 n(制表符按 1 列计)。
    for width in [0usize, 1, 2, 17, 92, 153] {
        let rule: String = horizontal_rule(width);
        let actual: usize = display_width(&rule);
        probes.push((
            format!("分隔线宽度 {}", width),
            actual == width,
            format!("期望 {} 列,实测 {} 列", width, actual),
        ));
    }

    // 探针二:`elide_text` 的结果**绝不超过**目标宽度(省略号占 1 列)。
    for width in [1usize, 2, 17, 92] {
        let source: String = "工序段".repeat(60);
        let elided: String = elide_text(&source, width);
        let actual: usize = display_width(&elided);
        probes.push((
            format!("省略后宽度 ≤ {}", width),
            actual <= width,
            format!("原串 {} 列,省略后 {} 列", display_width(&source), actual),
        ));
    }

    // 探针三:够宽时 `elide_text` 必须**原样返回**(一个字都不能少)。
    let untouched: &str = "镶嵌工艺路线卡";
    let pass_through: String = elide_text(untouched, 40);
    probes.push((
        "够宽时原样返回".to_string(),
        pass_through == untouched,
        format!("原串 {} 列", display_width(untouched)),
    ));

    probes
}
rust 复制代码
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : audit_check_view.rs
//! 审计视图:**纯字符串 / 纯数字**,表示层不认识机制层。
//!
//! ## ★ 为什么要多这一层
//!
//! 表示层的红线是「不出现 `crate::iterator` / `crate::client`」。
//! 若审计结果直接把 `RuleCheck` 传给报表,报表就得写出
//! `check.rule().chinese_text()` 这样的调用------
//! 那时**某一列叫什么名字**就押在了机制层的一个方法名上。
//! 机制层改个方法名,报表跟着改,而报表本不该知道机制层存在。
//!
//! 判据很具体:这一层里**没有一个字段的类型**来自 `crate::iterator`。
//! `scripts/check_layer_redlines.sh` 会扫 `src/app/*.rs` 确认这一点。

use crate::analysis::check_line::verdict_text;
use crate::iterator::RuleCheck;

/// 一条审计规则的展示行。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuditCheckView {
    /// 规则机器编码。
    rule_code: String,
    /// 规则中文名。
    rule_text: String,
    /// 期望(什么样的观测算合格)。
    expectation_text: String,
    /// 适用样本数。
    applicable_total: usize,
    /// 合格样本数。
    conforming_total: usize,
    /// 是否通过。
    passed: bool,
    /// 结论文本(**由 `check_line::verdict_text` 产生**)。
    verdict_text: String,
    /// 违规样本(无则「无」)。
    violating_text: String,
    /// ★ 已知会失败的输入(**证明这条规则不是恒真的**)。
    known_failing_text: String,
}

impl AuditCheckView {
    /// 由一条检查结果构造(**由 [`audit_check_views`] 调用**)。
    pub fn new(
        rule_code: &str,
        rule_text: &str,
        expectation_text: &str,
        applicable_total: usize,
        conforming_total: usize,
        passed: bool,
        violating_text: &str,
        known_failing_text: &str,
    ) -> Self {
        AuditCheckView {
            rule_code: rule_code.to_string(),
            rule_text: rule_text.to_string(),
            expectation_text: expectation_text.to_string(),
            applicable_total,
            conforming_total,
            passed,
            verdict_text: verdict_text(passed).to_string(),
            violating_text: violating_text.to_string(),
            known_failing_text: known_failing_text.to_string(),
        }
    }

    /// 规则机器编码。
    pub fn rule_code(&self) -> &str {
        self.rule_code.as_str()
    }

    /// 规则中文名。
    pub fn rule_text(&self) -> &str {
        self.rule_text.as_str()
    }

    /// 期望文本。
    pub fn expectation_text(&self) -> &str {
        self.expectation_text.as_str()
    }

    /// 适用样本数。
    pub const fn applicable_total(&self) -> usize {
        self.applicable_total
    }

    /// 合格样本数。
    pub const fn conforming_total(&self) -> usize {
        self.conforming_total
    }

    /// 是否通过。
    pub const fn passed(&self) -> bool {
        self.passed
    }

    /// 结论文本。
    pub fn verdict_text(&self) -> &str {
        self.verdict_text.as_str()
    }

    /// 违规样本文本。
    pub fn violating_text(&self) -> &str {
        self.violating_text.as_str()
    }

    /// ★ 已知会失败的输入。
    pub fn known_failing_text(&self) -> &str {
        self.known_failing_text.as_str()
    }
}

/// 把一批检查结果翻成展示行。
///
/// ⚠️ 违规文本为空时填「无」而不是空串:
/// 空串在报表上看起来像「忘了填」,而不是「确实没有」。
pub fn audit_check_views(checks: &[RuleCheck]) -> Vec<AuditCheckView> {
    checks
        .iter()
        .map(|check| {
            let violating: String = check.violating_text();
            AuditCheckView::new(
                check.rule().code(),
                check.rule().chinese_text(),
                check.rule().expectation_text(),
                check.applicable_total(),
                check.conforming_total(),
                check.passed(),
                if violating.is_empty() { "无" } else { violating.as_str() },
                check.rule().known_failing_input_text(),
            )
        })
        .collect()
}


//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : check_line.rs
//! 检查项与检查报告:全工程「应当 == 实测」的**统一形状**。
//!
//! ## 为什么单独一个文件
//!
//! 工程里有六处独立的检查(求值纪律、读取时刻、契约审计、恒等、拓扑、价值量化),
//! 若各写一套「期望/实测/结论」,最后一张体检表就得认识六种类型。
//! 统一成 [`CheckLine`] 之后,体检表只有一个来源,
//! 而**新增一处检查 = 多几行 `push`**,不需要改体检表。
//!
//! ## ★ 为什么 `verdict_text()` 只在这里产生一次
//!
//! 「通过 / 未通过」的写法若散在各处,迟早会出现一处写成「符合/不符合」,
//! 而读者会以为那是另一种判断。判据只有一个:`passed`。

/// 一条检查项。
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CheckLine {
    /// 检查项名称。
    item: String,
    /// 期望文本(**应当**是什么)。
    expectation_text: String,
    /// 实测文本(**实际**是什么)。
    actual_text: String,
    /// 是否通过。
    passed: bool,
}

impl CheckLine {
    /// 由四个字段构造。
    pub fn new(item: &str, expectation_text: &str, actual_text: &str, passed: bool) -> Self {
        Self {
            item: item.to_string(),
            expectation_text: expectation_text.to_string(),
            actual_text: actual_text.to_string(),
            passed,
        }
    }

    /// 检查项名称。
    pub fn item(&self) -> &str {
        &self.item
    }

    /// 期望文本。
    pub fn expectation_text(&self) -> &str {
        &self.expectation_text
    }

    /// 实测文本。
    pub fn actual_text(&self) -> &str {
        &self.actual_text
    }

    /// 是否通过。
    pub const fn passed(&self) -> bool {
        self.passed
    }

    /// ★ 结论文本:走 [`verdict_text`](**全工程唯一一处**产生「通过 / 未通过」)。
    pub const fn verdict_text(&self) -> &'static str {
        verdict_text(self.passed)
    }
}

/// ★ 结论文本:全工程**唯一**产生「通过 / 未通过」的地方。
///
/// 为什么要有这个自由函数:审计视图(`AuditCheckView`)也要显示结论,
/// 而它没有 `CheckLine`。
/// 若在那里另写一个 `if passed { "通过" } else { "未通过" }`,
/// 就会出现两处口径------改一处另一处不知道。
pub const fn verdict_text(passed: bool) -> &'static str {
    if passed {
        "通过"
    } else {
        "未通过"
    }
}

/// 一组检查项。
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CheckReport {
    /// 检查项清单。
    lines: Vec<CheckLine>,
}

impl CheckReport {
    /// 空报告。
    pub fn new() -> Self {
        Self { lines: Vec::new() }
    }

    /// 追加一条。
    pub fn push(&mut self, line: CheckLine) {
        self.lines.push(line);
    }

    /// 全部检查项。
    pub fn lines(&self) -> &[CheckLine] {
        &self.lines
    }

    /// 条数。
    pub fn line_total(&self) -> usize {
        self.lines.len()
    }

    /// 通过条数。
    pub fn passed_total(&self) -> usize {
        self.lines.iter().filter(|line| line.passed()).count()
    }

    /// 未通过条数。
    pub fn failed_total(&self) -> usize {
        self.line_total() - self.passed_total()
    }

    /// 是否全部通过。
    pub fn all_passed(&self) -> bool {
        self.passed_total() == self.line_total()
    }

    /// 未通过的检查项名称(拼成一串,供注解使用)。
    ///
    /// ⚠️ 全部通过时返回「无」而不是空串:空串在报表上看起来像「忘了填」。
    pub fn failed_items_text(&self) -> String {
        let failed: Vec<&str> = self
            .lines
            .iter()
            .filter(|line| !line.passed())
            .map(|line| line.item())
            .collect();
        if failed.is_empty() {
            "无".to_string()
        } else {
            failed.join("、")
        }
    }
}

impl Default for CheckReport {
    fn default() -> Self {
        Self::new()
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : coverage_census.rs
//! 覆盖口径:**这批游标合起来有没有把台账走全**。
//!
//! ## ★ 为什么「覆盖」要单独查一遍
//!
//! 每个游标各自走得对(幕八的六条规则)**推不出**合起来走全了:
//! 五个游标可以全都规规矩矩,却恰好都绕开了同一个批次。
//! 那时每一条规则都是绿的,而盘点漏了一件。
//!
//! ## ⚠️ 这里的「走漏」用的是**并集**而不是「任一个游标」
//!
//! 单个过滤游标本来就走漏一大半------那是需求,不是缺陷。
//! 只有**并集**才能说「漏没漏」。
//! 把「单游标走漏」也算成问题,等于把「过滤」这个需求又念了一遍,
//! 那条检查必然失败,也就没有价值(责任链工程踩过同一个坑)。

use crate::analysis::check_line::CheckLine;
use crate::client::StocktakeSession;

/// 覆盖结论。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CoverageReport {
    /// 台账批次数。
    ledger_total: usize,
    /// 被覆盖到的批次数。
    covered_total: usize,
    /// ★ 覆盖内容是否与台账**逐项**相同(已排序)。
    covered_equals_ledger: bool,
    /// 走漏的批次指纹。
    missing_fingerprints: Vec<String>,
    /// 凭空多出来的批次指纹。
    ghost_fingerprints: Vec<String>,
    /// ★ 主游标(全量走法)单枪匹马是否覆盖全集。
    main_cursor_covers_all: bool,
    /// 每个游标的产出是否都是台账的子集。
    every_run_is_subset: bool,
}

impl CoverageReport {
    /// 台账批次数。
    pub const fn ledger_total(&self) -> usize {
        self.ledger_total
    }

    /// 被覆盖到的批次数。
    pub const fn covered_total(&self) -> usize {
        self.covered_total
    }

    /// 覆盖内容是否与台账逐项相同。
    pub const fn covered_equals_ledger(&self) -> bool {
        self.covered_equals_ledger
    }

    /// 走漏的批次指纹。
    pub fn missing_fingerprints(&self) -> &[String] {
        self.missing_fingerprints.as_slice()
    }

    /// 凭空多出来的批次指纹。
    pub fn ghost_fingerprints(&self) -> &[String] {
        self.ghost_fingerprints.as_slice()
    }

    /// 主游标是否单枪匹马覆盖全集。
    pub const fn main_cursor_covers_all(&self) -> bool {
        self.main_cursor_covers_all
    }

    /// 每个游标的产出是否都是台账的子集。
    pub const fn every_run_is_subset(&self) -> bool {
        self.every_run_is_subset
    }

    /// 走漏清单文本(无则「无」)。
    pub fn missing_text(&self) -> String {
        join_or_none(&self.missing_fingerprints)
    }

    /// 凭空多出的清单文本(无则「无」)。
    pub fn ghost_text(&self) -> String {
        join_or_none(&self.ghost_fingerprints)
    }
}

/// ★ 算一次会话的覆盖结论。
///
/// # 参数
/// - `session`:本次盘点会话。
/// - `main_cursor_name`:主游标名(**全量走法**的那个)。
pub fn coverage_of(session: &StocktakeSession, main_cursor_name: &str) -> CoverageReport {
    let ledger_fingerprints: Vec<String> = session.ledger().sorted_fingerprints();
    let covered: Vec<String> = session.covered_fingerprints();
    let missing: Vec<String> = ledger_fingerprints
        .iter()
        .filter(|fingerprint| !covered.iter().any(|existing| existing == *fingerprint))
        .cloned()
        .collect();
    let ghost: Vec<String> = covered
        .iter()
        .filter(|fingerprint| {
            !ledger_fingerprints
                .iter()
                .any(|existing| existing == *fingerprint)
        })
        .cloned()
        .collect();
    let main_cursor_covers_all: bool = match session.run_by_name(main_cursor_name) {
        Some(run) => {
            let mut produced: Vec<String> = run.labels().to_vec();
            produced.sort();
            produced == ledger_fingerprints
        }
        None => false,
    };
    let every_run_is_subset: bool = session.runs().iter().all(|run| {
        run.labels().iter().all(|label| {
            ledger_fingerprints
                .iter()
                .any(|fingerprint| fingerprint == label)
        })
    });
    CoverageReport {
        ledger_total: ledger_fingerprints.len(),
        covered_total: covered.len(),
        covered_equals_ledger: covered == ledger_fingerprints,
        missing_fingerprints: missing,
        ghost_fingerprints: ghost,
        main_cursor_covers_all,
        every_run_is_subset,
    }
}

/// ★ 覆盖检查项。
pub fn coverage_checks(report: &CoverageReport) -> Vec<CheckLine> {
    vec![
        CheckLine::new(
            "★ 并集覆盖台账全部批次(逐项相同,不只数量相同)",
            "覆盖指纹排序后 == 台账指纹排序后",
            &format!(
                "{} / {},走漏={}",
                report.covered_total(),
                report.ledger_total(),
                report.missing_text()
            ),
            report.covered_equals_ledger(),
        ),
        CheckLine::new(
            "★ 主游标(全量走法)单枪匹马就覆盖全集",
            "全量产出排序后 == 台账指纹排序后",
            if report.main_cursor_covers_all() {
                "是"
            } else {
                "否"
            },
            report.main_cursor_covers_all(),
        ),
        CheckLine::new(
            "每个游标的产出都是台账的子集(没有凭空多出的批次)",
            "凭空多出 = 无",
            &format!("凭空多出={}", report.ghost_text()),
            report.every_run_is_subset() && report.ghost_fingerprints().is_empty(),
        ),
    ]
}

/// 清单拼串;空则「无」。
fn join_or_none(items: &[String]) -> String {
    if items.is_empty() {
        "无".to_string()
    } else {
        items.join("、")
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : independence_census.rs
//! ★ 独立性对照:**位置放在游标上 vs 放在集合里**。
//!
//! ## 这一幕是本工程的「反例对照组」
//!
//! 责任链工程的对照组是「广播式」(每站都问一遍),
//! 本工程的对照组是「共享位置」------
//! 把「走到哪了」这个状态收在一个所有游标共享的地方,
//! 模拟「位置存在集合上」这种写法。
//!
//! ## ★ 差就是证据,而且必须是**数字**
//!
//! 正常写法:五个游标各走各的,产出 `12 + n + m + k + 12` 件。
//! 共享位置写法:第一个游标走完 12 件之后位置已到末尾,
//! 后四个一开工就「已经走完了」,**各产出 0 件**。
//! 于是差 = 后面四个游标本来该产出的件数。
//!
//! ⚠️ 这一幕刻意**不用**并发来制造问题:
//! 五个游标是**依次**走的,严格串行。
//! 这说明「位置属于一次行走」这件事与线程无关------
//! 串行的共享位置照样是错的。
//!
//! ## 可证伪性
//!
//! 「共享组产出的更少」这条检查**必须真的能失败**:
//! 若共享组碰巧也产出了同样多(比如只有一个游标),
//! 这一条就恒真。因此这里要求「差 > 0」而不是「差 >= 0」,
//! 并要求「共享组空手而归的游标数 == 游标数 − 1」。

use crate::analysis::check_line::CheckLine;
use crate::client::StocktakeSession;

/// 独立性对照结论。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IndependencePair {
    /// 游标数(**正常写法**)。
    cursor_total: usize,
    /// 游标数(**共享位置写法**)。
    shared_cursor_total: usize,
    /// 正常写法产出件数合计。
    normal_produced_total: usize,
    /// 共享位置写法产出件数合计。
    shared_produced_total: usize,
    /// ★ 少产出的件数(**差就是证据**)。
    lost_produced_total: usize,
    /// 正常写法里空手而归的游标数。
    normal_empty_runs: usize,
    /// 共享位置写法里空手而归的游标数。
    shared_empty_runs: usize,
    /// 正常写法第一个游标的产出件数。
    normal_first_produced_total: usize,
    /// 共享位置写法第一个游标的产出件数。
    shared_first_produced_total: usize,
    /// ★ 共享位置写法里「只有第一个有产出」是否成立。
    shared_only_first_produced: bool,
}

impl IndependencePair {
    /// 游标数。
    pub const fn cursor_total(&self) -> usize {
        self.cursor_total
    }

    /// 正常写法产出合计。
    pub const fn normal_produced_total(&self) -> usize {
        self.normal_produced_total
    }

    /// 共享位置写法产出合计。
    pub const fn shared_produced_total(&self) -> usize {
        self.shared_produced_total
    }

    /// ★ 少产出的件数。
    pub const fn lost_produced_total(&self) -> usize {
        self.lost_produced_total
    }

    /// 正常写法空手而归的游标数。
    pub const fn normal_empty_runs(&self) -> usize {
        self.normal_empty_runs
    }

    /// 共享位置写法空手而归的游标数。
    pub const fn shared_empty_runs(&self) -> usize {
        self.shared_empty_runs
    }

    /// 正常写法第一个游标的产出件数。
    pub const fn normal_first_produced_total(&self) -> usize {
        self.normal_first_produced_total
    }

    /// 共享位置写法第一个游标的产出件数。
    pub const fn shared_first_produced_total(&self) -> usize {
        self.shared_first_produced_total
    }

    /// 共享位置写法的游标数。
    ///
    /// ⚠️ 与 [`Self::cursor_total`] 分开存:
    /// 若只存一个并用它做「两组游标数相同」的检查,那条检查就是 `a == a`------
    /// 恒真,等于没有检查(这正是「审计也要被审计」要抓的东西)。
    pub const fn shared_cursor_total(&self) -> usize {
        self.shared_cursor_total
    }

    /// 共享位置写法里是否只有第一个游标有产出。
    pub const fn shared_only_first_produced(&self) -> bool {
        self.shared_only_first_produced
    }

}

/// ★ 比对「位置在游标上」与「位置共享出去」两条路。
///
/// # 参数
/// - `normal`:正常写法的会话(每个游标持有自己的位置)。
/// - `shared`:共享位置写法的会话。
pub fn compare_independence(
    normal: &StocktakeSession,
    shared: &StocktakeSession,
) -> IndependencePair {
    let normal_produced_total: usize = normal.produced_total();
    let shared_produced_total: usize = shared.produced_total();
    let shared_only_first: bool = match shared.runs().split_first() {
        Some((first, rest)) => {
            first.yield_total() > 0 && rest.iter().all(|run| run.yield_total() == 0)
        }
        None => false,
    };
    IndependencePair {
        cursor_total: normal.runs().len(),
        shared_cursor_total: shared.runs().len(),
        normal_produced_total,
        shared_produced_total,
        lost_produced_total: normal_produced_total - shared_produced_total,
        normal_empty_runs: normal.runs().iter().filter(|run| run.is_empty_run()).count(),
        shared_empty_runs: shared.runs().iter().filter(|run| run.is_empty_run()).count(),
        normal_first_produced_total: normal
            .runs()
            .first()
            .map(|run| run.yield_total())
            .unwrap_or(0),
        shared_first_produced_total: shared
            .runs()
            .first()
            .map(|run| run.yield_total())
            .unwrap_or(0),
        shared_only_first_produced: shared_only_first,
    }
}

/// ★ 独立性对照的检查项。
pub fn independence_checks(pair: &IndependencePair) -> Vec<CheckLine> {
    vec![
        CheckLine::new(
            "★ 共享位置写法真的产得更少(差 > 0,差就是证据)",
            "正常产出 − 共享产出 > 0",
            &format!(
                "{} − {} = {} 件",
                pair.normal_produced_total(),
                pair.shared_produced_total(),
                pair.lost_produced_total()
            ),
            pair.lost_produced_total() > 0,
        ),
        CheckLine::new(
            "★ 共享位置写法里只有第一个游标有产出,其余全部空手",
            "第一个 > 0 且其余 == 0",
            &format!(
                "首个 {} 件,其余 {} 个共 {}",
                pair.shared_first_produced_total(),
                pair.cursor_total() - 1,
                pair.shared_produced_total() - pair.shared_first_produced_total()
            ),
            pair.shared_only_first_produced(),
        ),
        CheckLine::new(
            "★ 共享位置写法里空手而归的游标数 == 游标数 − 1",
            &format!("空手游标数 == {}", pair.cursor_total() - 1),
            &format!("{} 个", pair.shared_empty_runs()),
            pair.shared_empty_runs() == pair.cursor_total() - 1,
        ),
        CheckLine::new(
            "正常写法里没有空手而归的游标(每个游标都走出了东西)",
            "空手游标数 == 0",
            &format!("{} 个", pair.normal_empty_runs()),
            pair.normal_empty_runs() == 0,
        ),
        CheckLine::new(
            "两组游标数相同(否则这不是对照,是两件事)",
            "正常游标数 == 共享游标数",
            &format!("{} == {}", pair.cursor_total(), pair.shared_cursor_total()),
            pair.cursor_total() == pair.shared_cursor_total(),
        ),
    ]
}

/// ★ 逐游标的对照行(**给幕九的表**)。
///
/// ⚠️ 为什么是独立的视图而不是直接把两个 `CursorRun` 交给报表:
/// 报表不认识 `client`,而且「差」这个量由**两条路**共同决定,
/// 放在任何一边都不合适。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IndependenceRowView {
    /// 游标名。
    cursor_name: String,
    /// 正常写法的产出件数。
    normal_yield_total: usize,
    /// 共享位置写法的产出件数。
    shared_yield_total: usize,
    /// ★ 少产出的件数。
    lost_total: usize,
    /// 正常写法的推进次数。
    normal_advance_total: usize,
    /// 共享位置写法的推进次数。
    shared_advance_total: usize,
    /// 共享位置写法里这个游标是否空手而归。
    shared_empty: bool,
}

impl IndependenceRowView {
    /// 游标名。
    pub fn cursor_name(&self) -> &str {
        self.cursor_name.as_str()
    }

    /// 正常写法的产出件数。
    pub const fn normal_yield_total(&self) -> usize {
        self.normal_yield_total
    }

    /// 共享位置写法的产出件数。
    pub const fn shared_yield_total(&self) -> usize {
        self.shared_yield_total
    }

    /// ★ 少产出的件数。
    pub const fn lost_total(&self) -> usize {
        self.lost_total
    }

    /// 正常写法的推进次数。
    pub const fn normal_advance_total(&self) -> usize {
        self.normal_advance_total
    }

    /// 共享位置写法的推进次数。
    pub const fn shared_advance_total(&self) -> usize {
        self.shared_advance_total
    }

    /// 共享位置写法里是否空手而归。
    pub const fn shared_empty(&self) -> bool {
        self.shared_empty
    }
}

/// ★ 逐游标比对两条路。
///
/// # 参数
/// - `normal`:正常写法的会话。
/// - `shared`:共享位置写法的会话。
pub fn independence_rows(
    normal: &StocktakeSession,
    shared: &StocktakeSession,
) -> Vec<IndependenceRowView> {
    normal
        .runs()
        .iter()
        .zip(shared.runs().iter())
        .map(|(normal_run, shared_run)| IndependenceRowView {
            cursor_name: normal_run.cursor_name().to_string(),
            normal_yield_total: normal_run.yield_total(),
            shared_yield_total: shared_run.yield_total(),
            lost_total: normal_run.yield_total() - shared_run.yield_total(),
            normal_advance_total: normal_run.advance_total(),
            shared_advance_total: shared_run.advance_total(),
            shared_empty: shared_run.is_empty_run(),
        })
        .collect()
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : ledger_snapshot_view.rs
//! 台账快照视图:**纯数字 / 纯字符串**。
//!
//! 与 [`crate::analysis::audit_check_view`] 同一条纪律:
//! 这里**没有一个字段的类型**来自 `crate::iterator`,
//! 于是表示层可以完全不知道台账是怎么实现的。

use crate::iterator::LedgerSnapshot;

/// 一个游标的记账行视图。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CursorAttemptView {
    /// 游标名。
    name: String,
    /// 被驱动次数。
    drive_total: usize,
    /// 推进次数。
    advance_total: usize,
    /// 产出次数。
    yield_total: usize,
    /// ★ 跳过次数。
    skip_total: usize,
    /// 终止次数。
    stop_total: usize,
    /// 是否一次都没被推进过。
    never_advanced: bool,
    /// 是否全程只跳过、从不产出。
    pure_skip: bool,
    /// ★ 步态是否记账闭合。
    step_accounted: bool,
}

impl CursorAttemptView {
    /// 游标名。
    pub fn name(&self) -> &str {
        self.name.as_str()
    }

    /// 被驱动次数。
    pub const fn drive_total(&self) -> usize {
        self.drive_total
    }

    /// 推进次数。
    pub const fn advance_total(&self) -> usize {
        self.advance_total
    }

    /// 产出次数。
    pub const fn yield_total(&self) -> usize {
        self.yield_total
    }

    /// ★ 跳过次数。
    pub const fn skip_total(&self) -> usize {
        self.skip_total
    }

    /// 终止次数。
    pub const fn stop_total(&self) -> usize {
        self.stop_total
    }

    /// 是否一次都没被推进过。
    pub const fn never_advanced(&self) -> bool {
        self.never_advanced
    }

    /// 是否全程只跳过、从不产出。
    pub const fn pure_skip(&self) -> bool {
        self.pure_skip
    }

    /// 步态是否记账闭合。
    pub const fn step_accounted(&self) -> bool {
        self.step_accounted
    }
}

/// 台账快照视图。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LedgerSnapshotView {
    /// 台账名。
    ledger_name: String,
    /// 被驱动次数合计。
    drive_total: usize,
    /// 推进次数合计。
    advance_total: usize,
    /// 产出次数合计。
    yield_total: usize,
    /// ★ 跳过次数合计。
    skip_total: usize,
    /// 终止次数合计。
    stop_total: usize,
    /// 逐游标记账行。
    attempts: Vec<CursorAttemptView>,
    /// ★ 恒等式一:推进 == 产出 + 跳过 + 终止。
    step_accounted: bool,
    /// ★ 恒等式二:合计 == 各游标之和。
    sum_accounted: bool,
    /// ★ 恒等式三:每个被驱动过的游标恰好终止一次。
    termination_accounted: bool,
    /// 一次都没被推进过的游标名。
    never_advanced_cursors: Vec<String>,
    /// 全程只跳过的游标名。
    pure_skip_cursors: Vec<String>,
}

impl LedgerSnapshotView {
    /// 由快照构造。
    pub fn from_snapshot(snapshot: &LedgerSnapshot, ledger_name: &str) -> Self {
        let attempts: Vec<CursorAttemptView> = snapshot
            .records()
            .iter()
            .map(|record| CursorAttemptView {
                name: record.name().to_string(),
                drive_total: record.drive_total(),
                advance_total: record.advance_total(),
                yield_total: record.yield_total(),
                skip_total: record.skip_total(),
                stop_total: record.stop_total(),
                never_advanced: record.never_advanced(),
                pure_skip: record.is_pure_skip(),
                step_accounted: record.is_step_accounted(),
            })
            .collect();
        LedgerSnapshotView {
            ledger_name: ledger_name.to_string(),
            drive_total: snapshot.drive_total(),
            advance_total: snapshot.advance_total(),
            yield_total: snapshot.yield_total(),
            skip_total: snapshot.skip_total(),
            stop_total: snapshot.stop_total(),
            attempts,
            step_accounted: snapshot.is_step_accounted(),
            sum_accounted: snapshot.is_sum_accounted(),
            termination_accounted: snapshot.is_termination_accounted(),
            never_advanced_cursors: snapshot.never_advanced_cursors(),
            pure_skip_cursors: snapshot.pure_skip_cursors(),
        }
    }

    /// 台账名。
    pub fn ledger_name(&self) -> &str {
        self.ledger_name.as_str()
    }

    /// 被驱动次数合计。
    pub const fn drive_total(&self) -> usize {
        self.drive_total
    }

    /// 推进次数合计。
    pub const fn advance_total(&self) -> usize {
        self.advance_total
    }

    /// 产出次数合计。
    pub const fn yield_total(&self) -> usize {
        self.yield_total
    }

    /// ★ 跳过次数合计。
    pub const fn skip_total(&self) -> usize {
        self.skip_total
    }

    /// 终止次数合计。
    pub const fn stop_total(&self) -> usize {
        self.stop_total
    }

    /// 逐游标记账行。
    pub fn attempts(&self) -> &[CursorAttemptView] {
        self.attempts.as_slice()
    }

    /// 恒等式一。
    pub const fn step_accounted(&self) -> bool {
        self.step_accounted
    }

    /// 恒等式二。
    pub const fn sum_accounted(&self) -> bool {
        self.sum_accounted
    }

    /// 恒等式三。
    pub const fn termination_accounted(&self) -> bool {
        self.termination_accounted
    }

    /// ★ 一次都没被推进过的游标清单(**空则「无」**)。
    pub fn never_advanced_text(&self) -> String {
        join_or_none(&self.never_advanced_cursors)
    }

    /// ★ 全程只跳过的游标清单(**空则「无」**)。
    pub fn pure_skip_text(&self) -> String {
        join_or_none(&self.pure_skip_cursors)
    }
}

/// 把清单拼成一串;空清单返回「无」。
///
/// ⚠️ 空串在报表上看起来像「忘了填」,所以必须有这个分支。
fn join_or_none(items: &[String]) -> String {
    if items.is_empty() {
        "无".to_string()
    } else {
        items.join("、")
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : order_census.rs
//! 走法对照:**换一种走法,变的是什么、不变的是什么**。
//!
//! ## ★ 这一幕想证明的一句话
//!
//! > 换走法只改变**先看到哪个**,不改变**盘到哪些**。
//!
//! 这句话在不用迭代器模式的写法里**不成立**:
//! 那时「换走法」= 再写一遍 `for` 循环,
//! 而写第二遍时顺手改掉一个筛选条件的概率并不低。
//! 改了之后两个数字都对不上,却看不出是哪一处改的。
//!
//! ## ⚠️ 为什么拿两个**全量**走法比
//!
//! 全量走法一步都不跳过,于是「产出集合」就是「走过的集合」,
//! 两者相等才有意义。拿一个过滤游标来比,
//! 「集合不同」会被筛选解释掉,而那正是要说的事。
//!
//! ## 可证伪性
//!
//! 这两条检查必须有反例:
//! 集合相同(反例:某个游标漏了一个批次),
//! 顺序不同(反例:两个走法恰好排出同一个顺序------
//! 若数据恰好已按库位排好,这条就会绿得莫名其妙)。
//! 幕十四会检查「顺序不同的位置数 > 0」,它一旦为 0 就说明这条测不出来。

use crate::analysis::check_line::CheckLine;
use crate::client::StocktakeSession;

/// 两个走法的对照。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WalkPair {
    /// 左游标名。
    left_name: String,
    /// 右游标名。
    right_name: String,
    /// ★ 产出**集合**是否相同(排序后逐项相同)。
    same_set: bool,
    /// ★ 顺序不同的位置数。
    differing_positions: usize,
    /// 左游标推进次数。
    left_advance_total: usize,
    /// 右游标推进次数。
    right_advance_total: usize,
    /// 左游标跳过次数。
    left_skip_total: usize,
    /// 右游标跳过次数。
    right_skip_total: usize,
    /// 左游标实际盘到件数。
    left_counted_total: usize,
    /// 右游标实际盘到件数。
    right_counted_total: usize,
    /// 产出件数。
    produced_total: usize,
}

impl WalkPair {
    /// 左游标名。
    pub fn left_name(&self) -> &str {
        self.left_name.as_str()
    }

    /// 右游标名。
    pub fn right_name(&self) -> &str {
        self.right_name.as_str()
    }

    /// 集合是否相同。
    pub const fn same_set(&self) -> bool {
        self.same_set
    }

    /// ★ 顺序不同的位置数。
    pub const fn differing_positions(&self) -> usize {
        self.differing_positions
    }

    /// 左游标推进次数。
    pub const fn left_advance_total(&self) -> usize {
        self.left_advance_total
    }

    /// 右游标推进次数。
    pub const fn right_advance_total(&self) -> usize {
        self.right_advance_total
    }

    /// 左游标跳过次数。
    pub const fn left_skip_total(&self) -> usize {
        self.left_skip_total
    }

    /// 右游标跳过次数。
    pub const fn right_skip_total(&self) -> usize {
        self.right_skip_total
    }

    /// 左游标盘到件数。
    pub const fn left_counted_total(&self) -> usize {
        self.left_counted_total
    }

    /// 右游标盘到件数。
    pub const fn right_counted_total(&self) -> usize {
        self.right_counted_total
    }

    /// 产出件数。
    pub const fn produced_total(&self) -> usize {
        self.produced_total
    }
}

/// ★ 比对同一份台账上的两个走法。
///
/// # 参数
/// - `session`:本次盘点会话。
/// - `left_name`:左游标名。
/// - `right_name`:右游标名。
///
/// # Panics
/// 两个游标名字有一个不存在时 panic:
/// 「对照少了一边」是装配错误,静默降级会让这一幕变成恒真。
pub fn compare_walks(
    session: &StocktakeSession,
    left_name: &str,
    right_name: &str,
) -> WalkPair {
    let left = session.run_by_name(left_name).unwrap_or_else(|| {
        panic!("会话里没有游标「{}」:走法对照少了一边,这一幕会变成恒真", left_name)
    });
    let right = session.run_by_name(right_name).unwrap_or_else(|| {
        panic!("会话里没有游标「{}」:走法对照少了一边,这一幕会变成恒真", right_name)
    });
    let mut left_sorted: Vec<String> = left.labels().to_vec();
    left_sorted.sort();
    let mut right_sorted: Vec<String> = right.labels().to_vec();
    right_sorted.sort();
    let differing: usize = left
        .labels()
        .iter()
        .zip(right.labels().iter())
        .filter(|(left_label, right_label)| left_label != right_label)
        .count();
    WalkPair {
        left_name: left_name.to_string(),
        right_name: right_name.to_string(),
        same_set: left_sorted == right_sorted,
        differing_positions: differing,
        left_advance_total: left.advance_total(),
        right_advance_total: right.advance_total(),
        left_skip_total: left.skip_total(),
        right_skip_total: right.skip_total(),
        left_counted_total: left.counted_total(),
        right_counted_total: right.counted_total(),
        produced_total: left.yield_total(),
    }
}

/// ★ 走法对照的检查项。
pub fn walk_checks(pair: &WalkPair) -> Vec<CheckLine> {
    vec![
        CheckLine::new(
            "★ 换走法不改变「盘到哪些」(集合相同)",
            "两个游标的产出排序后逐项相同",
            if pair.same_set() { "相同" } else { "不同" }.to_string().as_str(),
            pair.same_set(),
        ),
        CheckLine::new(
            "★ 换走法确实改变了顺序(这一条为 0 说明这一幕测不出来)",
            "顺序不同的位置数 > 0",
            &format!("{} 处不同 / 共 {} 件", pair.differing_positions(), pair.produced_total()),
            pair.differing_positions() > 0,
        ),
        CheckLine::new(
            "两个走法步数相同(走法只换顺序,不换长度)",
            "左推进次数 == 右推进次数",
            &format!("{} == {}", pair.left_advance_total(), pair.right_advance_total()),
            pair.left_advance_total() == pair.right_advance_total(),
        ),
        CheckLine::new(
            "两个走法都是全量(跳过次数都为 0)",
            "左跳过 == 右跳过 == 0",
            &format!("{} / {}", pair.left_skip_total(), pair.right_skip_total()),
            pair.left_skip_total() == 0 && pair.right_skip_total() == 0,
        ),
        CheckLine::new(
            "★ 换走法不改变「实际盘到件数」(结论不依赖顺序)",
            "左盘到 == 右盘到",
            &format!("{} == {}", pair.left_counted_total(), pair.right_counted_total()),
            pair.left_counted_total() == pair.right_counted_total(),
        ),
    ]
}

/// ★ 逐位置的顺序对照行(**给幕十的表**)。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WalkDiffRow {
    /// 位置(第几步产出)。
    position: usize,
    /// 左游标在这个位置产出的批次号。
    left_batch_text: String,
    /// 右游标在这个位置产出的批次号。
    right_batch_text: String,
    /// 两者是否不同。
    differs: bool,
}

impl WalkDiffRow {
    /// 位置。
    pub const fn position(&self) -> usize {
        self.position
    }

    /// 左游标产出的批次号。
    pub fn left_batch_text(&self) -> &str {
        self.left_batch_text.as_str()
    }

    /// 右游标产出的批次号。
    pub fn right_batch_text(&self) -> &str {
        self.right_batch_text.as_str()
    }

    /// 是否不同。
    pub const fn differs(&self) -> bool {
        self.differs
    }
}

/// ★ 逐位置比对两个走法(**只比批次号,不比指纹**)。
///
/// ⚠️ 为什么用批次号而不是指纹:这一幕要读者一眼看出「顺序换了」,
/// 指纹里那一长串区码排层位会把「变了」淹没在字符里。
///
/// # 参数
/// - `session`:本次盘点会话。
/// - `left_name`:左游标名。
/// - `right_name`:右游标名。
pub fn walk_diff_rows(
    session: &StocktakeSession,
    left_name: &str,
    right_name: &str,
) -> Vec<WalkDiffRow> {
    let left = session.run_by_name(left_name).unwrap_or_else(|| {
        panic!("会话里没有游标「{}」:顺序对照少了一边", left_name)
    });
    let right = session.run_by_name(right_name).unwrap_or_else(|| {
        panic!("会话里没有游标「{}」:顺序对照少了一边", right_name)
    });
    left.produced()
        .iter()
        .zip(right.produced().iter())
        .enumerate()
        .map(|(position, (left_batch, right_batch))| {
            let left_text: String = left_batch.batch().batch_number().to_string();
            let right_text: String = right_batch.batch().batch_number().to_string();
            WalkDiffRow {
                position,
                differs: left_text != right_text,
                left_batch_text: left_text,
                right_batch_text: right_text,
            }
        })
        .collect()
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : run_view.rs
//! 行走结果视图:**纯字符串 / 纯数字**。
//!
//! 与 [`crate::analysis::audit_check_view`] 同一条纪律。

use crate::client::StocktakeSession;
use crate::domain::StocktakeVerdict;

/// 一次行走的展示行。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CursorRunView {
    /// 游标名。
    cursor_name: String,
    /// 走法文本。
    order_text: String,
    /// ★ 走法说明(**这个走法为谁服务**)。
    order_purpose_text: String,
    /// 筛选说明。
    filter_text: String,
    /// ★ 筛选短名(给窄列用)。
    filter_short_text: String,
    /// 声明的步数上限。
    declared_total: usize,
    /// 推进次数。
    advance_total: usize,
    /// 产出次数。
    yield_total: usize,
    /// ★ 跳过次数。
    skip_total: usize,
    /// 终止次数。
    stop_total: usize,
    /// 实际盘到件数。
    counted_total: usize,
    /// 需跟进件数。
    follow_up_total: usize,
    /// 差异重量(毫克,**带符号**)。
    variance_milligrams: i64,
    /// ★ 各结论的件数文本(如 `相符4/盘盈1/盘亏1/待复检1/封存1`)。
    verdict_summary_text: String,
    /// 是否全量(一步没跳过)。
    full_pass: bool,
    /// 是否空手而归。
    empty_run: bool,
}

impl CursorRunView {
    /// 由一次行走构造。
    pub fn new(
        cursor_name: &str,
        order_text: &str,
        order_purpose_text: &str,
        filter_text: &str,
        filter_short_text: &str,
        declared_total: usize,
        advance_total: usize,
        yield_total: usize,
        skip_total: usize,
        stop_total: usize,
        counted_total: usize,
        follow_up_total: usize,
        variance_milligrams: i64,
        verdict_summary_text: &str,
        full_pass: bool,
        empty_run: bool,
    ) -> Self {
        CursorRunView {
            cursor_name: cursor_name.to_string(),
            order_text: order_text.to_string(),
            order_purpose_text: order_purpose_text.to_string(),
            filter_text: filter_text.to_string(),
            filter_short_text: filter_short_text.to_string(),
            declared_total,
            advance_total,
            yield_total,
            skip_total,
            stop_total,
            counted_total,
            follow_up_total,
            variance_milligrams,
            verdict_summary_text: verdict_summary_text.to_string(),
            full_pass,
            empty_run,
        }
    }

    /// 游标名。
    pub fn cursor_name(&self) -> &str {
        self.cursor_name.as_str()
    }

    /// 走法文本。
    pub fn order_text(&self) -> &str {
        self.order_text.as_str()
    }

    /// ★ 走法说明(为谁服务)。
    pub fn order_purpose_text(&self) -> &str {
        self.order_purpose_text.as_str()
    }

    /// 筛选说明。
    pub fn filter_text(&self) -> &str {
        self.filter_text.as_str()
    }

    /// ★ 筛选短名(给窄列用)。
    pub fn filter_short_text(&self) -> &str {
        self.filter_short_text.as_str()
    }

    /// ★ 一句话说明:走法 + 筛选。
    ///
    /// ⚠️ 由视图自己拼,不在调用方拼:
    /// 拼法若散在报表里,两个报表就会拼出两种说法。
    pub fn description_text(&self) -> String {
        format!("{}:{}", self.order_purpose_text(), self.filter_text())
    }

    /// 声明的步数上限。
    pub const fn declared_total(&self) -> usize {
        self.declared_total
    }

    /// 推进次数。
    pub const fn advance_total(&self) -> usize {
        self.advance_total
    }

    /// 产出次数。
    pub const fn yield_total(&self) -> usize {
        self.yield_total
    }

    /// ★ 跳过次数。
    pub const fn skip_total(&self) -> usize {
        self.skip_total
    }

    /// 终止次数。
    pub const fn stop_total(&self) -> usize {
        self.stop_total
    }

    /// 实际盘到件数。
    pub const fn counted_total(&self) -> usize {
        self.counted_total
    }

    /// 需跟进件数。
    pub const fn follow_up_total(&self) -> usize {
        self.follow_up_total
    }

    /// 差异重量(毫克)。
    pub const fn variance_milligrams(&self) -> i64 {
        self.variance_milligrams
    }

    /// 各结论的件数文本。
    pub fn verdict_summary_text(&self) -> &str {
        self.verdict_summary_text.as_str()
    }

    /// 是否全量。
    pub const fn full_pass(&self) -> bool {
        self.full_pass
    }

    /// 是否空手而归。
    pub const fn empty_run(&self) -> bool {
        self.empty_run
    }
}

/// 把一次会话里的全部行走翻成展示行。
pub fn run_views(session: &StocktakeSession) -> Vec<CursorRunView> {
    session
        .runs()
        .iter()
        .map(|run| {
            let summary: String = StocktakeVerdict::all()
                .iter()
                .map(|verdict| {
                    format!(
                        "{}{}",
                        short_verdict_text(*verdict),
                        run.produced()
                            .iter()
                            .filter(|batch| batch.verdict() == *verdict)
                            .count()
                    )
                })
                .collect::<Vec<String>>()
                .join("/");
            CursorRunView::new(
                run.cursor_name(),
                run.order_text(),
                run.order_purpose_text(),
                run.filter_text(),
                run.filter_short_text(),
                run.declared_total(),
                run.advance_total(),
                run.yield_total(),
                run.skip_total(),
                run.stop_total(),
                run.counted_total(),
                run.follow_up_total(),
                run.variance_weight_milligrams_total(),
                summary.as_str(),
                run.is_full_pass(),
                run.is_empty_run(),
            )
        })
        .collect()
}

/// ★ 结论短名(**两字**,给「各结论件数」这一列用)。
///
/// ⚠️ 为什么不用 `chinese_text()`:那一列要塞五种结论的件数,
/// 用「账实相符 / 盘盈 / 盘亏 / 待复检 / 已封存」会超出列宽,
/// 而列宽一超就会被省略------省略后读者看到的是半句话。
fn short_verdict_text(verdict: StocktakeVerdict) -> &'static str {
    match verdict {
        StocktakeVerdict::Matched => "相符",
        StocktakeVerdict::Over => "盘盈",
        StocktakeVerdict::Short => "盘亏",
        StocktakeVerdict::PendingRecheck => "待检",
        StocktakeVerdict::Sealed => "封存",
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : value_quantification.rs
//! 价值量化:**用迭代器模式省下的到底是什么**。
//!
//! ## ★ 不能说「省了时间」
//!
//! 时间不可复现(换台机器、换个负载就是另一个数),
//! 而且「写代码的快慢」根本不是模式要解决的问题。
//! 可数的是**份数**与**次数**:
//!
//! 1. **份数**:不用迭代器模式时,`走法 × 筛选` 的每种组合都要写一份带下标的循环;
//!    用迭代器模式时,只需要 `走法` 份 + `筛选` 份,组合是**装配**出来的。
//!    于是「同一种走法被重复写了几次」= 筛选的种数------这就是省下的份数。
//! 2. **次数**:过滤游标**跳过了多少步**。
//!    这个数字在不用迭代器模式的写法里**根本不存在**
//!    (筛选写在循环体里,跳过的那一步没有留下任何痕迹),
//!    于是「盘点覆盖率」说不清:只知道自己处理了 7 件,
//!    不知道其实走了 12 步。
//!
//! ## ⚠️ 为什么「份数」是说法而「次数」是数字
//!
//! 「省了 15 份循环代码」这种话可以写,但不要拿它当验收依据------
//! 没人能数「一份代码」。
//! 验收依据只能是台账上那个 `跳过次数`:它是真跑出来的。

use crate::analysis::check_line::CheckLine;
use crate::client::StocktakeSession;
use crate::domain::ratio_basis_points;

/// 价值量化结论。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IterationValueReport {
    /// 走法种数。
    walk_order_total: usize,
    /// 筛选种数。
    filter_total: usize,
    /// ★ 组合数(不用迭代器模式时要写的循环份数)。
    combination_total: usize,
    /// ★ 定义数(用迭代器模式时要写的份数)。
    definition_total: usize,
    /// ★ 省下的循环份数。
    saved_loop_total: usize,
    /// 本次实际装配的游标数。
    cursor_total: usize,
    /// 推进次数合计。
    advance_total: usize,
    /// 产出次数合计。
    yield_total: usize,
    /// ★ 跳过次数合计(**不用迭代器模式时这个数字根本不存在**)。
    skip_total: usize,
    /// 终止次数合计。
    stop_total: usize,
    /// ★ 跳过占推进的比例(**万分点**)。
    skip_share_basis_points: i64,
    /// 实际盘到件数合计。
    counted_total: usize,
}

impl IterationValueReport {
    /// 走法种数。
    pub const fn walk_order_total(&self) -> usize {
        self.walk_order_total
    }

    /// 筛选种数。
    pub const fn filter_total(&self) -> usize {
        self.filter_total
    }

    /// 组合数。
    pub const fn combination_total(&self) -> usize {
        self.combination_total
    }

    /// 定义数。
    pub const fn definition_total(&self) -> usize {
        self.definition_total
    }

    /// ★ 省下的循环份数。
    pub const fn saved_loop_total(&self) -> usize {
        self.saved_loop_total
    }

    /// 实际装配的游标数。
    pub const fn cursor_total(&self) -> usize {
        self.cursor_total
    }

    /// 推进次数合计。
    pub const fn advance_total(&self) -> usize {
        self.advance_total
    }

    /// 产出次数合计。
    pub const fn yield_total(&self) -> usize {
        self.yield_total
    }

    /// ★ 跳过次数合计。
    pub const fn skip_total(&self) -> usize {
        self.skip_total
    }

    /// 终止次数合计。
    pub const fn stop_total(&self) -> usize {
        self.stop_total
    }

    /// ★ 跳过占推进的比例(万分点)。
    pub const fn skip_share_basis_points(&self) -> i64 {
        self.skip_share_basis_points
    }

    /// 实际盘到件数合计。
    pub const fn counted_total(&self) -> usize {
        self.counted_total
    }

    /// ★ 一句话结论。
    pub fn summary_text(&self) -> String {
        format!(
            "{} 种走法 × {} 种筛选 = {} 种组合;用迭代器模式只需 {} 份定义(走法 {} + 筛选 {}),省下 {} 份循环;\
             本次装配 {} 个游标,共推进 {} 步、产出 {} 件、跳过 {} 步。",
            self.walk_order_total(),
            self.filter_total(),
            self.combination_total(),
            self.definition_total(),
            self.walk_order_total(),
            self.filter_total(),
            self.saved_loop_total(),
            self.cursor_total(),
            self.advance_total(),
            self.yield_total(),
            self.skip_total(),
        )
    }
}

/// ★ 算一次会话的价值量化。
///
/// # 参数
/// - `session`:本次盘点会话。
/// - `walk_order_total`:走法种数(**含扩展区新增的走法**)。
/// - `filter_total`:筛选种数(**含扩展区新增的筛选**)。
pub fn quantify(
    session: &StocktakeSession,
    walk_order_total: usize,
    filter_total: usize,
) -> IterationValueReport {
    let advance_total: usize = session.advance_total();
    let skip_total: usize = session.skip_total();
    IterationValueReport {
        walk_order_total,
        filter_total,
        combination_total: walk_order_total * filter_total,
        definition_total: walk_order_total + filter_total,
        saved_loop_total: walk_order_total * filter_total - (walk_order_total + filter_total),
        cursor_total: session.runs().len(),
        advance_total,
        yield_total: session.produced_total(),
        skip_total,
        stop_total: session.snapshot().stop_total(),
        skip_share_basis_points: ratio_basis_points(i128::from(skip_total as i64), i128::from(advance_total as i64)),
        counted_total: session.counted_total(),
    }
}

/// ★ 价值量化的检查项(**每条都必须能失败**)。
pub fn value_checks(report: &IterationValueReport) -> Vec<CheckLine> {
    vec![
        CheckLine::new(
            "组合数 == 走法种数 × 筛选种数",
            &format!(
                "{} × {} = {}",
                report.walk_order_total(),
                report.filter_total(),
                report.walk_order_total() * report.filter_total()
            ),
            &format!("{}", report.combination_total()),
            report.combination_total() == report.walk_order_total() * report.filter_total(),
        ),
        CheckLine::new(
            "★ 省下的循环份数 > 0(否则组合装配没有代价)",
            &format!(
                "{} − {} = {} > 0",
                report.combination_total(),
                report.definition_total(),
                report.saved_loop_total()
            ),
            &format!("{} 份", report.saved_loop_total()),
            report.saved_loop_total() > 0,
        ),
        CheckLine::new(
            "★ 推进次数 == 产出 + 跳过 + 终止(三种步态都要有账)",
            &format!(
                "{} == {} + {} + {}",
                report.advance_total(),
                report.yield_total(),
                report.skip_total(),
                report.stop_total()
            ),
            &format!(
                "{}",
                report.yield_total() + report.skip_total() + report.stop_total()
            ),
            report.advance_total()
                == report.yield_total() + report.skip_total() + report.stop_total(),
        ),
        CheckLine::new(
            "★ 跳过次数 > 0(为 0 说明「跳过」这一支根本没被走到)",
            "跳过 > 0",
            &format!("{} 步", report.skip_total()),
            report.skip_total() > 0,
        ),
        CheckLine::new(
            "★ 只看产出数会少算的步数 == 跳过次数",
            "推进 − 产出 == 跳过 − ...... 且 > 0",
            &format!(
                "{} − {} = {} 步",
                report.advance_total(),
                report.yield_total(),
                report.advance_total() - report.yield_total()
            ),
            report.advance_total() - report.yield_total() > report.skip_total(),
        ),
    ]
}
rust 复制代码
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : audit_report.rs
//! 幕八:遍历契约审计(**六条规则**)。
//!
//! ## ★ 每张表都要回答「它凭什么不恒真」
//!
//! 因此本幕分成两张表:
//!
//! 1. 幕八:六条规则在正常观测上的结果;
//! 2. **幕八(续)**:每条规则的「已知会失败的输入」------
//!    也就是「什么样的观测会让它变红」。
//!
//! 第二张表不是补充说明,**是这张表的资格证明**。
//! 一条说不出「什么输入会让它失败」的规则,绿着的时候不提供任何信息。
//!
//! ## 列宽
//!
//! 幕八:`14+28+36+12+8+22 = 120`,加 6 列 × 3 + 1 = `139 <= 140`。
//! 幕八(续):`14+28+78 = 120`,加 3 列 × 3 + 1 = `130 <= 140`。

use crate::analysis::AuditCheckView;
use crate::app::layout::{Table, TableColumn};
use crate::app::DOCUMENT_WIDTH;

const CODE_WIDTH: usize = 14;
const RULE_WIDTH: usize = 18;
const EXPECTATION_WIDTH: usize = 46;
const RATIO_WIDTH: usize = 10;
const VERDICT_WIDTH: usize = 8;
const VIOLATION_WIDTH: usize = 24;
const KNOWN_FAILING_WIDTH: usize = 78;

/// 幕八:六条规则在一批观测上的结果。
///
/// # 参数
/// - `views`:审计视图(**由分析层给出**)。
/// - `title`:幕标题(**由调用方传**:同一张表在幕八与幕八(续)各用一次,
///   写死标题会让第二次使用时标题撒谎)。
pub fn render_audit_table(views: &[AuditCheckView], title: &str) -> Vec<String> {
    let mut table: Table = Table::new(
        title,
        vec![
            TableColumn::new("规则编码", CODE_WIDTH),
            TableColumn::new("规则", RULE_WIDTH),
            TableColumn::new("期望", EXPECTATION_WIDTH),
            TableColumn::numeric("合格/适用", RATIO_WIDTH),
            TableColumn::new("结论", VERDICT_WIDTH),
            TableColumn::elidable(TableColumn::new("违规项", VIOLATION_WIDTH)),
        ],
    );
    for view in views.iter() {
        table = table.with_row(vec![
            view.rule_code().to_string(),
            view.rule_text().to_string(),
            view.expectation_text().to_string(),
            format!("{}/{}", view.conforming_total(), view.applicable_total()),
            view.verdict_text().to_string(),
            view.violating_text().to_string(),
        ]);
    }
    table = table
        .with_note(
            "★ 「合格/适用」里的**适用数**必须 > 0:一条规则若一个样本都没查到,\
             「合格 == 适用」就是 `0 == 0`------恒真。因此 `applicable_total > 0` 写在 `RuleCheck::passed()` 里。",
        )
        .with_note(
            "⚠️ 期望列的 `WITHIN_RANGE` 在声明步数为 0 时**不适用**:\
             把不适用的样本也算进分母,那条规则会必然失败,\
             而那种失败不是证据,只是把规则又念了一遍。",
        );
    table.render(DOCUMENT_WIDTH)
}

/// ★ 幕八(续):这六条规则**凭什么不是恒真的**。
///
/// 幕十四(体检)会照着这张表造坏样本,看它们真的变红。
pub fn render_audit_falsifiable_table(views: &[AuditCheckView]) -> Vec<String> {
    let mut table: Table = Table::new(
        "幕八(续)· 可证伪性清单:每条规则「已知会失败的输入」",
        vec![
            TableColumn::new("规则编码", CODE_WIDTH),
            TableColumn::new("规则", RULE_WIDTH),
            TableColumn::elidable(TableColumn::new("已知会失败的输入", KNOWN_FAILING_WIDTH)),
        ],
    );
    for view in views.iter() {
        table = table.with_row(vec![
            view.rule_code().to_string(),
            view.rule_text().to_string(),
            view.known_failing_text().to_string(),
        ]);
    }
    table = table.with_note(
        "★ 这段文字是规则的一部分,不是注释:幕八(续)之外的幕十四会照着它造坏样本。\
         改了规则不改这段文字,那条负向测试就会「绿得莫名其妙」。",
    );
    table.render(DOCUMENT_WIDTH)
}

//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : batch_report.rs
//! 幕五:在库批次清单(**被遍历的那个集合**)。
//!
//! ## ★ 这一幕列的是「集合」,不是「某一次行走的结果」
//!
//! 顺序按**台账声明顺序**(不是任何走法排出来的顺序)。
//! 理由:这一幕要回答「这次要盘的东西有哪些」,
//! 若按某个走法排一遍,读者会以为「台账本来就是这个顺序」,
//! 而那正是幕十要证明「换走法只换顺序」的前提。
//!
//! ## 列宽
//!
//! `16+12+8+16+8+10+14+6+8+8 = 106`,加 10 列 × 3 + 1 = `137 <= 140`。

use crate::app::layout::{Table, TableColumn};
use crate::app::value_display::{bool_text, gram_display_text, signed_gram_display_text};
use crate::app::DOCUMENT_WIDTH;
use crate::domain::{CountedBatch, MonthKey, StockBatch};

/// 各列宽(**按该列可能出现的最大宽度定,表头也在候选集里**)。
const BATCH_NUMBER_WIDTH: usize = 16;
const STORE_WIDTH: usize = 12;
const CATEGORY_WIDTH: usize = 8;
const LOCATION_WIDTH: usize = 16;
const MONTH_WIDTH: usize = 10;
const WEIGHT_WIDTH: usize = 10;
const AMOUNT_WIDTH: usize = 14;
const RECHECK_WIDTH: usize = 6;
const VERDICT_WIDTH: usize = 8;
const VARIANCE_WIDTH: usize = 8;

/// 幕五:批次清单。
///
/// # 参数
/// - `batches`:台账里的批次(**声明顺序**)。
/// - `counted`:全量走法盘出来的结果(**用于取结论与差异**)。
/// - `current_month`:当前盘点月份(算库龄)。
/// - `recheck_after_months`:满多少个月的需复检批次本次不计数(**由调用方传入**:
///   表示层不认识 `client`,不能自己去取那个常量)。
pub fn render_batch_table(
    batches: &[StockBatch],
    counted: &[CountedBatch],
    current_month: &MonthKey,
    recheck_after_months: i32,
) -> Vec<String> {
    let mut table: Table = Table::new(
        "幕五 · 在库批次清单(台账声明顺序,共 12 行;结论来自全量走法)",
        vec![
            TableColumn::new("批次号", BATCH_NUMBER_WIDTH),
            TableColumn::new("门店", STORE_WIDTH),
            TableColumn::new("品类", CATEGORY_WIDTH),
            TableColumn::new("库位", LOCATION_WIDTH),
            TableColumn::new("入库月", MONTH_WIDTH),
            TableColumn::numeric("重量(克)", WEIGHT_WIDTH),
            TableColumn::numeric("账面金额", AMOUNT_WIDTH),
            TableColumn::new("复检", RECHECK_WIDTH),
            TableColumn::new("结论", VERDICT_WIDTH),
            TableColumn::numeric("差异(克)", VARIANCE_WIDTH),
        ],
    );
    for batch in batches.iter() {
        let result: Option<&CountedBatch> = counted
            .iter()
            .find(|item| item.batch().batch_number() == batch.batch_number());
        let (verdict_text, variance_text): (String, String) = match result {
            // ⚠️ 走 `signed_gram_display_text` 而不是自己拼符号:
            // 幕七的差异列也走同一个出口,两处口径必须一致
            // (一处写 `+`、一处不写,读者会以为是两种量)。
            Some(item) => (
                item.verdict().chinese_text().to_string(),
                signed_gram_display_text(item.variance_weight_milligrams()),
            ),
            None => ("未盘到".to_string(), "---".to_string()),
        };
        table = table.with_row(vec![
            batch.batch_number().to_string(),
            batch.store().chinese_name().to_string(),
            batch.category().chinese_name().to_string(),
            batch.location().fingerprint_text(),
            batch.received_month().chinese_text(),
            gram_display_text(batch.weight_milligrams()),
            batch.valued_amount().chinese_text(),
            bool_text(batch.needs_recheck()).to_string(),
            verdict_text,
            variance_text,
        ]);
    }
    table = table
        .with_note(&format!(
            "★ 库龄口径:已入库月数 = 当前月({}) − 入库月;满 {} 个月且需复检的批次本次不计数。",
            current_month.chinese_text(),
            recheck_after_months
        ))
        .with_note(
            "⚠️ 「差异」是**实盘 − 账面**,带符号;待复检与已封存的差异为 0(本次没称,不是称出 0 克)。",
        );
    table.render(DOCUMENT_WIDTH)
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : cursor_report.rs
//! 幕六:游标清单(**走法 × 筛选**的装配结果)。
//!
//! ## ★ 这一幕想让人看见的一句话
//!
//! > 换一种走法、换一种筛选,只是换一个值;遍历代码一份都没多写。
//!
//! 因此这一幕把「走法」与「筛选」分成两列,
//! 并在「说明」列里写出它们各自的用途------
//! 读者应当能看出:**这五个游标是同一份 `BatchCursor` 的五次装配**。
//!
//! ## 列宽
//!
//! `6+12+10+10+64+8 = 110`,加 6 列 × 3 + 1 = `129 <= 140`。
//! 说明列做成**可省略**:它的长度取决于措辞,不该因为措辞变长就让整张表崩掉。

use crate::analysis::CursorRunView;
use crate::app::layout::{Table, TableColumn};
use crate::app::DOCUMENT_WIDTH;

const INDEX_WIDTH: usize = 6;
const CURSOR_WIDTH: usize = 12;
const ORDER_WIDTH: usize = 10;
const FILTER_WIDTH: usize = 10;
const DESCRIPTION_WIDTH: usize = 75;
const DECLARED_WIDTH: usize = 8;

/// 幕六:游标清单。
///
/// # 参数
/// - `views`:行走结果视图(**由分析层给出,表示层不认识 `client`**)。
pub fn render_cursor_table(views: &[CursorRunView]) -> Vec<String> {
    let mut table: Table = Table::new(
        "幕六 · 游标清单(走法 × 筛选的装配表)",
        vec![
            TableColumn::numeric("序号", INDEX_WIDTH),
            TableColumn::new("游标名", CURSOR_WIDTH),
            TableColumn::new("走法", ORDER_WIDTH),
            TableColumn::new("筛选", FILTER_WIDTH),
            TableColumn::elidable(TableColumn::new("说明", DESCRIPTION_WIDTH)),
            TableColumn::numeric("声明步数", DECLARED_WIDTH),
        ],
    );
    for (index, view) in views.iter().enumerate() {
        table = table.with_row(vec![
            (index + 1).to_string(),
            view.cursor_name().to_string(),
            view.order_text().to_string(),
            view.filter_short_text().to_string(),
            view.description_text(),
            view.declared_total().to_string(),
        ]);
    }
    table = table
        .with_note(
            "★ 声明步数 == 排好序之后的批次数:它是**步数上限**,不是产出个数。\
             过滤游标的产出个数会小于它,差额就是「跳过次数」。",
        )
        .with_note(
            "⚠️ 「筛选」这一列写的是**判据**,不是结果:\
             同一个筛选在不同走法下产出的**件数相同、批次相同、顺序不同**。",
        );
    table.render(DOCUMENT_WIDTH)
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : extension_report.rs
//! 幕十二:工程外扩展对照。
//!
//! ## ★ 可扩展性必须被**实证**,不是被声明
//!
//! 本工程在工程外扩展区(`main.rs`)里做了三件事,
//! **全程不改任何既有分层文件**:
//!
//! 1. 新增一个**库区**(`StorageZone` 是开放标签,加一个常量即可);
//! 2. 新增一种**筛选**(`BatchFilter::ZoneOnly`,加一个枚举值即可);
//! 3. 新增两个**批次** + 一个**游标规格**(寄售专查)。
//!
//! 这一幕的表把这些「加了什么」变成可核对的数字:
//! 批次数 +2、游标数 +1、新游标真的产出了东西(不是死游标)、
//! 覆盖仍然等于台账全集。
//!
//! ## 列宽
//!
//! `34+22+22+48 = 126`,加 4 列 × 3 + 1 = `139 <= 140`。

use crate::app::layout::{Table, TableColumn};
use crate::app::DOCUMENT_WIDTH;

const ITEM_WIDTH: usize = 34;
const BEFORE_WIDTH: usize = 22;
const AFTER_WIDTH: usize = 22;
const NOTE_WIDTH: usize = 48;

/// 幕十二:扩展对照表。
///
/// # 参数
/// - `rows`:`(对照项, 扩展前, 扩展后, 说明)`。
pub fn render_extension_table(rows: &[(String, String, String, String)]) -> Vec<String> {
    let mut table: Table = Table::new(
        "幕十二 · 工程外扩展对照(新增库区 + 新增筛选 + 新增游标,未改任何分层文件)",
        vec![
            TableColumn::new("对照项", ITEM_WIDTH),
            TableColumn::new("扩展前", BEFORE_WIDTH),
            TableColumn::new("扩展后", AFTER_WIDTH),
            TableColumn::elidable(TableColumn::new("说明", NOTE_WIDTH)),
        ],
    );
    for (item, before, after, note) in rows.iter() {
        table = table.with_row(vec![
            item.clone(),
            before.clone(),
            after.clone(),
            note.clone(),
        ]);
    }
    table = table.with_note(
        "★ 「扩展后」这一列不是写出来的,是**跑出来**的:\
         扩展区只在 `main.rs` 里加了常量与一个规格,\
         统计与报表自动把它算进去------这正是「完全可扩展」的证据。",
    );
    table.render(DOCUMENT_WIDTH)
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : foundation_report.rs
//! 幕二 ~ 幕四:**字典类**的小表(门店 / 库区 / 盘点结论)。
//!
//! ## ★ 为什么字典要单独占几幕
//!
//! 这几张表看起来「没信息量」,但它们定义了后面所有表的**取值域**:
//! 「待复检」是什么意思、库区动线谁先谁后、门店码对应哪家店。
//! 不先摆出来,读者看到「FRONT-01-02-03」只能去猜。
//!
//! ## ⚠️ 库位列用**指纹**而不是中文文本
//!
//! `BinLocation::chinese_text()` 是 `前场展柜-01排-2层-05位`(21 列),
//! 放进批次清单会挤掉别的列。库位列用 `FRONT-01-02-05`(15 列)。
//! 中文区名已经在幕三里出现过一次,这里不重复。

use crate::app::layout::{Table, TableColumn};
use crate::app::value_display::bool_text;
use crate::app::DOCUMENT_WIDTH;
use crate::domain::{StocktakeVerdict, StoreCode, BUILTIN_STORAGE_ZONES};

/// 幕二:门店清单。
const STORE_CODE_WIDTH: usize = 14;
const STORE_NAME_WIDTH: usize = 20;

/// 幕三:库区清单。
const ZONE_CODE_WIDTH: usize = 10;
const ZONE_NAME_WIDTH: usize = 20;
const ZONE_SEQUENCE_WIDTH: usize = 12;

/// 幕四:盘点结论字典。
const VERDICT_CODE_WIDTH: usize = 18;
const VERDICT_TEXT_WIDTH: usize = 12;
const VERDICT_FLAG_WIDTH: usize = 10;

/// 幕二:门店清单。
///
/// # 参数
/// - `stores`:门店清单。
pub fn render_store_table(stores: &[StoreCode], batch_counts: &[(String, usize)]) -> Vec<String> {
    let mut table: Table = Table::new(
        "幕二 · 门店清单(批次所属门店)",
        vec![
            TableColumn::new("门店码", STORE_CODE_WIDTH),
            TableColumn::new("门店名", STORE_NAME_WIDTH),
            TableColumn::numeric("批次数", 10),
        ],
    );
    for store in stores.iter() {
        let count: usize = batch_counts
            .iter()
            .find(|(code, _)| code == store.code())
            .map(|(_, total)| *total)
            .unwrap_or(0);
        table = table.with_row(vec![
            store.code().to_string(),
            store.chinese_name().to_string(),
            count.to_string(),
        ]);
    }
    table = table.with_note(
        "★ 门店只是批次的**属性**,不参与任何走法判据:\
         换一家门店不需要改任何一个游标。",
    );
    table.render(DOCUMENT_WIDTH)
}

/// 幕三:库区清单(**盘点动线的定义处**)。
pub fn render_zone_table() -> Vec<String> {
    let mut table: Table = Table::new(
        "幕三 · 库区清单(动线序号 = 盘点员走到这个区的先后)",
        vec![
            TableColumn::new("区码", ZONE_CODE_WIDTH),
            TableColumn::new("库区", ZONE_NAME_WIDTH),
            TableColumn::numeric("动线序号", ZONE_SEQUENCE_WIDTH),
        ],
    );
    for zone in BUILTIN_STORAGE_ZONES.iter() {
        table = table.with_row(vec![
            zone.code().to_string(),
            zone.chinese_name().to_string(),
            zone.sequence().to_string(),
        ]);
    }
    table = table
        .with_note(
            "★ 动线序号是**唯一**定义「谁先谁后」的地方:\
             库位排序键 = 区序号×10^6 + 排×10^4 + 层×10^2 + 位,\
             因此「先盘哪个货架」由这一列决定,而不是由区码的字典序决定。",
        )
        .with_note(
            "⚠️ 本工程**不派生** `Ord`:派生出来的顺序由字段声明顺序决定,\
             有人为了可读性把字段挪一下,遍历顺序就反了,而代码里没有任何一处写着「顺序在这里定义」。",
        );
    table.render(DOCUMENT_WIDTH)
}

/// 幕四:盘点结论字典。
pub fn render_verdict_table() -> Vec<String> {
    let mut table: Table = Table::new(
        "幕四 · 盘点结论字典(**封闭集合**:加一种就要改所有 match)",
        vec![
            TableColumn::new("机器编码", VERDICT_CODE_WIDTH),
            TableColumn::new("结论", VERDICT_TEXT_WIDTH),
            TableColumn::new("计入盘点", VERDICT_FLAG_WIDTH),
            TableColumn::new("需跟进", VERDICT_FLAG_WIDTH),
            TableColumn::elidable(TableColumn::new("含义", 40)),
        ],
    );
    for verdict in StocktakeVerdict::all().iter() {
        table = table.with_row(vec![
            verdict.machine_text().to_string(),
            verdict.chinese_text().to_string(),
            bool_text(verdict.is_counted()).to_string(),
            bool_text(verdict.needs_follow_up()).to_string(),
            meaning_text(*verdict).to_string(),
        ]);
    }
    table = table
        .with_note(
            "★ 「计入盘点」这一列是**盘点完成率**的分子口径:\
             待复检与已封存都不算盘到。把它们算进去,完成率会虚高,\
             而报表上看不出那一部分其实没盘。",
        )
        .with_note(
            "⚠️ 用 `machine_text`(MATCHED / OVER ...)做指纹与审计比对,\
             不用 `chinese_text`:改中文文案不该让审计变红。",
        );
    table.render(DOCUMENT_WIDTH)
}

/// 结论的含义(**给幕四的「含义」列**)。
fn meaning_text(verdict: StocktakeVerdict) -> &'static str {
    match verdict {
        StocktakeVerdict::Matched => "实盘重量与账面一致",
        StocktakeVerdict::Over => "实物比账面重(盘盈)",
        StocktakeVerdict::Short => "实物比账面轻(盘亏)",
        StocktakeVerdict::PendingRecheck => "成色/证书待复核,本次不计数",
        StocktakeVerdict::Sealed => "司法或保险封存,本次不盘",
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : health_check_report.rs
//! 幕十四:体检表。
//!
//! ## ★ 这一幕是「工程自己检查自己」
//!
//! 前面十三幕的结论都是**这个模式**的结论(游标走得对不对、省了多少);
//! 这一幕是**这份代码**的结论(版面放不放得下、口径有没有自相矛盾、
//! 扩展是不是真的挂进去了)。
//!
//! ## 列宽
//!
//! `56+34+26+10 = 126`,加 4 列 × 3 + 1 = `139 <= 140`。
//! 续表:`56+64 = 120`,加 3 列 × 3 + 1 = `130 <= 140`。

use crate::analysis::CheckReport;
use crate::app::layout::{Table, TableColumn};
use crate::app::DOCUMENT_WIDTH;

const ITEM_WIDTH: usize = 56;
const EXPECTATION_WIDTH: usize = 34;
const ACTUAL_WIDTH: usize = 26;
const VERDICT_WIDTH: usize = 10;
const FALSIFIABLE_WIDTH: usize = 77;

/// 幕十四:体检表。
///
/// # 参数
/// - `report`:检查报告。
pub fn render_check_table(report: &CheckReport) -> Vec<String> {
    let mut table: Table = Table::new(
        "幕十四 · 工程自检(版面 / 口径 / 扩展 / 契约)",
        vec![
            TableColumn::new("检查项", ITEM_WIDTH),
            TableColumn::elidable(TableColumn::new("期望", EXPECTATION_WIDTH)),
            TableColumn::elidable(TableColumn::new("实测", ACTUAL_WIDTH)),
            TableColumn::new("结论", VERDICT_WIDTH),
        ],
    );
    for line in report.lines().iter() {
        table = table.with_row(vec![
            line.item().to_string(),
            line.expectation_text().to_string(),
            line.actual_text().to_string(),
            line.verdict_text().to_string(),
        ]);
    }
    table = table.with_note(&format!(
        "★ 共 {} 项,通过 {} 项,未通过 {} 项;未通过项:{}。",
        report.line_total(),
        report.passed_total(),
        report.failed_total(),
        report.failed_items_text()
    ));
    table.render(DOCUMENT_WIDTH)
}

/// ★ 幕十四(续):这些自检**凭什么不是恒真的**。
///
/// # 参数
/// - `rows`:`(检查项, 已知会失败的输入)`。
pub fn render_check_falsifiable_table(rows: &[(String, String)]) -> Vec<String> {
    let mut table: Table = Table::new(
        "幕十四(续)· 自检的可证伪性清单:什么样的输入会让它变红",
        vec![
            TableColumn::new("检查项", ITEM_WIDTH),
            TableColumn::elidable(TableColumn::new("已知会失败的输入", FALSIFIABLE_WIDTH)),
        ],
    );
    for (item, failing) in rows.iter() {
        table = table.with_row(vec![item.clone(), failing.clone()]);
    }
    table = table.with_note(
        "★ 恒真的检查没有价值:一条「无论如何都通过」的规则不是检查,是装饰。\
         这张表里的每一行都是一个**能被造出来**的坏样本。",
    );
    table.render(DOCUMENT_WIDTH)
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : independence_report.rs
//! 幕九:★ 反例对照 ------ **位置放在游标上 vs 位置共享出去**。
//!
//! ## 这一幕是本工程的「对照组」
//!
//! 责任链工程的对照组是「广播式」(每站都问一遍),
//! 本工程的对照组是「共享位置」:
//! 把「走到哪了」收在一个所有游标共享的地方,
//! 模拟「位置存在集合上」这种写法。
//!
//! ## ★ 差就是证据
//!
//! 正常写法:五个游标各走各的。
//! 共享位置写法:第一个游标走完之后位置已到末尾,
//! 后面四个一开工就「已经走完了」,**各产出 0 件**。
//!
//! ⚠️ 这里刻意**不用并发**制造问题:五个游标是**依次**走的。
//! 这说明「位置属于一次行走」与线程无关------串行的共享位置照样是错的。
//!
//! ## 列宽
//!
//! `12+10+10+10+10+10+8+40 = 110`,加 8 列 × 3 + 1 = `135 <= 140`。

use crate::analysis::IndependenceRowView;
use crate::app::layout::{Table, TableColumn};
use crate::app::value_display::bool_text;
use crate::app::DOCUMENT_WIDTH;

const CURSOR_WIDTH: usize = 12;
const NORMAL_YIELD_WIDTH: usize = 10;
const SHARED_YIELD_WIDTH: usize = 10;
const LOST_WIDTH: usize = 10;
const NORMAL_ADVANCE_WIDTH: usize = 10;
const SHARED_ADVANCE_WIDTH: usize = 10;
const EMPTY_WIDTH: usize = 8;
const NOTE_WIDTH: usize = 40;

/// 幕九:共享位置对照表。
///
/// # 参数
/// - `rows`:逐游标的对照行。
pub fn render_independence_table(rows: &[IndependenceRowView]) -> Vec<String> {
    let mut table: Table = Table::new(
        "幕九 · 反例对照:位置在游标上 vs 位置共享出去(五个游标依次走同一份台账)",
        vec![
            TableColumn::new("游标名", CURSOR_WIDTH),
            TableColumn::numeric("正常产出", NORMAL_YIELD_WIDTH),
            TableColumn::numeric("共享产出", SHARED_YIELD_WIDTH),
            TableColumn::numeric("★ 少产出", LOST_WIDTH),
            TableColumn::numeric("正常推进", NORMAL_ADVANCE_WIDTH),
            TableColumn::numeric("共享推进", SHARED_ADVANCE_WIDTH),
            TableColumn::new("空手", EMPTY_WIDTH),
            TableColumn::elidable(TableColumn::new("备注", NOTE_WIDTH)),
        ],
    );
    for row in rows.iter() {
        table = table.with_row(vec![
            row.cursor_name().to_string(),
            row.normal_yield_total().to_string(),
            row.shared_yield_total().to_string(),
            row.lost_total().to_string(),
            row.normal_advance_total().to_string(),
            row.shared_advance_total().to_string(),
            bool_text(row.shared_empty()).to_string(),
            note_text(row),
        ]);
    }
    let lost_sum: usize = rows.iter().map(|row| row.lost_total()).sum();
    table = table
        .with_note(&format!(
            "★ 合计少产出 {} 件:这就是「把位置放在集合上」的代价。\
             注意五个游标是**依次**走的,没有任何并发------\
             所以这一条与线程安全无关,它只关乎「位置属于谁」。",
            lost_sum
        ))
        .with_note(
            "⚠️ 共享位置写法里「推进」次数不为 0:每个游标**都推了一次**(拿到 Stop),\
             只是那一推发生在位置已经到末尾之后。\
             因此「推了」不等于「走了」------这正是 `Cursor::position` 与 `Cursor::has_stopped` 要分开的原因。",
        );
    table.render(DOCUMENT_WIDTH)
}

/// 备注列文本。
///
/// ⚠️ 由**数据**算出来,不写死:写死的备注会随数据演化而变成假话。
fn note_text(row: &IndependenceRowView) -> String {
    if row.shared_yield_total() == 0 {
        "共享写法下这个游标一件都没拿到".to_string()
    } else {
        format!(
            "共享写法下只有第一个游标拿到 {} 件",
            row.shared_yield_total()
        )
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : interface_report.rs
//! 幕十三:只读接口清点。
//!
//! ## ★ 这一幕同时是告警治理的落点
//!
//! 一个只读接口若在这里被调用**并印出结果**,
//! 它就不可能是「写了没人用」------也就不可能是「写错了没人发现」。
//! 因此本工程治理 `unused` 告警的首选动作是**把它用起来**,
//! 而不是删掉它(更不是加 `#[allow]`)。
//!
//! ## ⚠️ 两列都做成可省略
//!
//! 接口名长度不可控(`domain::StockBatch::fingerprint_text()` 这种),
//! 调用结果更不可控。做成不可省略会在**措辞一变**时就 panic,
//! 而那种 panic 与「算错了」长得一模一样。
//!
//! ## 列宽
//!
//! `62+68 = 130`,加 2 列 × 3 + 1 = `137 <= 140`。

use crate::app::layout::{Table, TableColumn};
use crate::app::DOCUMENT_WIDTH;

/// 接口名列宽。
pub const INTERFACE_NAME_WIDTH: usize = 62;
/// 调用结果列宽。
pub const INTERFACE_OUTPUT_WIDTH: usize = 68;

/// 幕十三:接口清点表。
///
/// # 参数
/// - `items`:`(接口名, 调用结果)`。
pub fn render_interface_table(items: &[(String, String)]) -> Vec<String> {
    let mut table: Table = Table::new(
        "幕十三 · 只读接口清点(每一项都被真的调用并印出结果)",
        vec![
            TableColumn::elidable(TableColumn::new("接口", INTERFACE_NAME_WIDTH)),
            TableColumn::elidable(TableColumn::new("调用结果", INTERFACE_OUTPUT_WIDTH)),
        ],
    );
    for (name, output) in items.iter() {
        table = table.with_row(vec![name.clone(), output.clone()]);
    }
    table = table.with_note(&format!(
        "★ 共 {} 项。只读接口指「取一个数 / 取一段文本」这类不改动状态的接口;\
         会改状态的接口(如 `Cursor::advance`、`CursorLedger::record_*`)在各幕里已经被真跑过。",
        items.len()
    ));
    table.render(DOCUMENT_WIDTH)
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : layout.rs
//! 表格引擎:列宽、对齐、可见省略、框线、注解折行。
//!
//! ## 列宽的唯一来源:`TableColumn`
//!
//! 每一列都要**显式声明宽度**,且宽度依据写在声明处的注释里。
//! 不提供「自适应列宽」:自适应会让同一张表在不同数据下宽度不同,
//! 于是「两次运行逐字节一致」依赖数据而不是代码------那是把不确定性引进来了。

use crate::support::{display_width, elide_text, horizontal_rule, pad_left, pad_right};

/// 列对齐。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnAlign {
    /// 左对齐(文本列)。
    Left,
    /// 右对齐(数字列)。
    Right,
}

/// 一列。
#[derive(Debug, Clone)]
pub struct TableColumn {
    /// 表头文本。
    header: &'static str,
    /// 列宽(**显示列**,中文按 2 列计)。
    width: usize,
    /// 对齐方式。
    align: ColumnAlign,
    /// 是否**允许**可见省略。
    ///
    /// ⚠️ 这个开关是本工程「不许静默截断」纪律的载体:
    /// 不标它是默认,标了它必须是**有意**的(该列内容确实不可控)。
    elidable: bool,
}

impl TableColumn {
    /// 声明一列(左对齐、**不允许**省略)。
    pub const fn new(header: &'static str, width: usize) -> Self {
        TableColumn { header, width, align: ColumnAlign::Left, elidable: false }
    }

    /// 声明一个数字列(右对齐、不允许省略)。
    pub const fn numeric(header: &'static str, width: usize) -> Self {
        TableColumn { header, width, align: ColumnAlign::Right, elidable: false }
    }

    /// 把本列改成**允许**可见省略(链式)。
    pub const fn elidable(mut self) -> Self {
        self.elidable = true;
        self
    }

    /// 表头。
    pub const fn header(&self) -> &'static str {
        self.header
    }

    /// 列宽。
    pub const fn width(&self) -> usize {
        self.width
    }

    /// 对齐。
    pub const fn align(&self) -> ColumnAlign {
        self.align
    }

    /// 是否允许省略。
    pub const fn is_elidable(&self) -> bool {
        self.elidable
    }
}

/// 渲染一个单元格。
///
/// # 三条行为
///
/// 1. `elidable == false` → 内容**必须**放得下;否则 `debug_assert!` 当场 panic,
///    错误信息里带上列宽与所需宽度,直接告诉你该改哪一列。
/// 2. `elidable == true` → 走可见省略,末尾有 `...`,读者知道这里被省了。
/// 3. 省略后按列宽**补白**。
///
/// # ⚠️ 为什么是 `debug_assert` 而不是 `assert`
///
/// 它是**开发期纪律**:`cargo run`(debug)时立刻 panic,
/// release 下不存在,不会让线上报表因一条宽度问题而中断。
/// 同理它也**不是**批量排查的工具------排查请用临时 `eprintln!` 插桩
/// (一次跑出全部位置),见 `app` 模块文档。
pub fn render_cell(column: &TableColumn, text: &str) -> String {
    let fitted: String = if column.is_elidable() {
        elide_text(text, column.width())
    } else {
        let needed: usize = display_width(text);
        debug_assert!(
            needed <= column.width(),
            "单元格放不下且该列不允许省略:列「{}」宽 {} 列,内容需要 {} 列,内容原文=[{}]",
            column.header(),
            column.width(),
            needed,
            text
        );
        // debug 下上面已 panic;release 下退化成可见省略,
        // 而不是静默截断------**任何构建下都不接受「悄悄少一截」**。
        if needed > column.width() {
            elide_text(text, column.width())
        } else {
            text.to_string()
        }
    };

    match column.align() {
        ColumnAlign::Left => pad_right(&fitted, column.width()),
        ColumnAlign::Right => pad_left(&fitted, column.width()),
    }
}

/// 一张表:标题 + 列声明 + 数据行 + 注解。
#[derive(Debug, Clone)]
pub struct Table {
    /// 标题(渲染成 `■ 标题`)。
    title: String,
    /// 列声明。
    columns: Vec<TableColumn>,
    /// 数据行。
    rows: Vec<Vec<String>>,
    /// 注解(**永不省略**,只折行)。
    notes: Vec<String>,
}

impl Table {
    /// 建一张表。
    pub fn new(title: &str, columns: Vec<TableColumn>) -> Self {
        Table {
            title: title.to_string(),
            columns,
            rows: Vec::new(),
            notes: Vec::new(),
        }
    }

    /// 追加一行(链式)。
    pub fn with_row(mut self, row: Vec<String>) -> Self {
        self.rows.push(row);
        self
    }

    /// 追加一条注解(链式)。
    pub fn with_note(mut self, note: &str) -> Self {
        self.notes.push(note.to_string());
        self
    }

    /// 列数。
    pub fn column_count(&self) -> usize {
        self.columns.len()
    }

    /// 行数。
    pub fn row_count(&self) -> usize {
        self.rows.len()
    }

    /// 表的总宽度(各列宽之和 + 每列两侧的空格与竖线)。
    ///
    /// 每列的开销是 3:左空格 1 + 右空格 1 + 竖线 1;
    /// 最左边还有一条竖线,因此再 +1。
    pub fn total_width(&self) -> usize {
        self.columns.iter().map(|column| column.width()).sum::<usize>()
            + self.column_count() * 3
            + 1
    }

    /// 框线(上框 / 分隔 / 下框)。
    fn border_line(&self) -> String {
        format!("┌{}┐", self.segments('┬', '─'))
    }

    fn middle_line(&self) -> String {
        format!("├{}┤", self.segments('┼', '─'))
    }

    fn bottom_line(&self) -> String {
        format!("└{}┘", self.segments('┴', '─'))
    }

    fn segments(&self, joint: char, fill: char) -> String {
        self.columns
            .iter()
            .map(|column| fill.to_string().repeat(column.width() + 2))
            .collect::<Vec<String>>()
            .join(&joint.to_string())
    }

    /// 渲染一行数据。
    ///
    /// ⚠️ 单元格数不足时用空串兜底而不是 panic:
    /// 少给一个单元格只是这一行少一格,
    /// 整张报表仍然可读;panic 会让连后面的幕都出不来。
    fn data_line(&self, cells: &[String]) -> String {
        let rendered: Vec<String> = self
            .columns
            .iter()
            .enumerate()
            .map(|(index, column)| render_cell(column, cells.get(index).map(String::as_str).unwrap_or("")))
            .collect();
        format!("│ {} │", rendered.join(" │ "))
    }

    /// 渲染整张表。
    pub fn render(self, document_width: usize) -> Vec<String> {
        debug_assert!(
            self.total_width() <= document_width,
            "表宽 {} 超过文档宽度 {}(表:{})",
            self.total_width(),
            document_width,
            self.title
        );

        let mut lines: Vec<String> = Vec::new();
        lines.push(section_header(&self.title, document_width));
        lines.push(self.border_line());

        // ★ 表头也走 `render_cell`:表头超宽同样要被抓出来。
        let header_cells: Vec<String> = self
            .columns
            .iter()
            .map(|column| column.header().to_string())
            .collect();
        lines.push(self.data_line(&header_cells));
        lines.push(self.middle_line());

        for row in self.rows.iter() {
            debug_assert!(
                row.len() == self.columns.len(),
                "行宽 {} 与列数 {} 不一致(表:{})",
                row.len(),
                self.columns.len(),
                self.title
            );
            lines.push(self.data_line(row));
        }

        lines.push(self.bottom_line());

        // ★ 空表不静默:一张没有数据行的表,与「这一幕忘了填数据」在版面上
        //   长得一模一样。补一条可见注解,让「零行」变成一个能被看见的状态。
        let mut notes: Vec<String> = self.notes.clone();
        if self.row_count() == 0 {
            notes.push("(本表无数据行)".to_string());
        }
        for note in notes.iter() {
            lines.extend(note_lines(note, document_width));
        }
        lines
    }
}

/// 幕标题:`■ 标题`(右侧补到文档宽度)。
///
/// ⚠️ 标题**只由本函数产生**:若调用方也写一次标题,
/// 就会出现「改一次标题要改两处」------迟早不一致。
pub fn section_header(title: &str, document_width: usize) -> String {
    let text: String = format!("■ {}", title);
    let used: usize = display_width(&text);
    if used >= document_width {
        return text;
    }
    format!("{}{}", text, " ".repeat(document_width - used))
}

/// 把一段长文本折成多行注解(**永不省略**,只折行)。
///
/// ## ⚠️ 可用宽度按「最宽的那种前缀」留白
///
/// 首行前缀 `  · ` 是 **4 列**,续行前缀 `  ` 是 2 列。
/// 若按续行的 2 列留白,首行就会溢出 2 列------
/// 既有工程实测过:37 行注解达到 142 列 / 文档宽 140,
/// 而溢出的注解在框线旁边几乎看不出来。
/// **统一按 4 列留白是唯一不会错的算法**,代价只是续行少 2 列。
///
/// ## 为什么注解永不省略
///
/// 注解承载的是**复算依据**(分子是什么、分母是什么、哪个常数用在哪)。
/// 省略掉一段,读者就少一条验算路径------那正是报表存在的理由。
pub fn note_lines(text: &str, document_width: usize) -> Vec<String> {
    let available: usize = document_width.saturating_sub(4).max(1);
    let mut lines: Vec<String> = Vec::new();
    let mut current: String = String::new();
    let mut current_width: usize = 0;

    for character in text.chars() {
        let character_width: usize = if crate::support::is_wide_character(character) { 2 } else { 1 };
        if current_width + character_width > available && !current.is_empty() {
            lines.push(current);
            current = String::new();
            current_width = 0;
        }
        current.push(character);
        current_width += character_width;
    }
    if !current.is_empty() {
        lines.push(current);
    }
    if lines.is_empty() {
        // 空注解仍然产出一行,避免「注解存在但输出为零行」这种不可见状态。
        lines.push(String::new());
    }

    lines
        .into_iter()
        .enumerate()
        .map(|(index, line)| {
            if index == 0 {
                format!("  · {}", line)
            } else {
                format!("  {}", line)
            }
        })
        .collect()
}

/// 生成一条与表同宽的横线(供需要额外分隔的场合用)。
pub fn table_rule(width: usize) -> String {
    horizontal_rule(width)
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : order_report.rs
//! 幕十:走法对照 ------ **换走法改变的是顺序,不是集合**。
//!
//! ## ★ 这一幕在证明什么
//!
//! `WalkOrder` 有五种取值,其中两个是**全量**走法
//! ([`WalkOrder::LedgerOrder`] 与 [`WalkOrder::ReverseLedger`])。
//! 但这里比对的是「动线全量」与「反向复核」------
//! 前者按库位动线排,后者把台账倒过来。
//!
//! 两者产出的**集合必须相同**(都是全集),
//! **顺序必须不同**(否则这一幕测不出东西)。
//!
//! ## ⚠️ 「顺序不同」这条会变成恒真吗
//!
//! 会------如果数据恰好已经按库位动线排好,动线序与倒序也可能不同(倒序一定不同)。
//! 但若是两个不同的排序键恰好排出同一个顺序(例如全部金额相等、库位也相等),
//! 这条就会是 0,那时它**必须显示为未通过**。
//! 因此检查写的是 `不同位置数 > 0` 而不是 `>= 0`。

use crate::analysis::WalkDiffRow;
use crate::app::layout::{Table, TableColumn};
use crate::app::DOCUMENT_WIDTH;

const POSITION_WIDTH: usize = 8;
const BATCH_WIDTH: usize = 18;
const DIFFERS_WIDTH: usize = 12;

/// ★ 幕十:逐位置的顺序对照表。
///
/// # 参数
/// - `rows`:逐位置的对照行。
/// - `left_title`:左游标名(**由调用方传**,见下)。
/// - `right_title`:右游标名。
///
/// ⚠️ 为什么列名由调用方传而不是写死:
/// 同一张表在本工程里被用到两次(主链与扩展链),
/// 写死列名会让第二次使用时**列名撒谎**。
/// ⚠️ 参数类型是 `&'static str` 而不是 `&str`:
/// `TableColumn::new` 的表头要求 `'static`(列声明要活过这张表),
/// 而调用方传的本来就是常量名------这个约束正好把「列名必须是已知的」钉在类型上。
pub fn render_walk_diff_table(
    rows: &[WalkDiffRow],
    left_title: &'static str,
    right_title: &'static str,
) -> Vec<String> {
    let mut table: Table = Table::new(
        "幕十 · 走法对照:同一个集合、两种顺序(逐位置比对)",
        vec![
            TableColumn::numeric("产出位置", POSITION_WIDTH),
            TableColumn::new(left_title, BATCH_WIDTH),
            TableColumn::new(right_title, BATCH_WIDTH),
            TableColumn::new("是否不同", DIFFERS_WIDTH),
        ],
    );
    let differing_total: usize = rows.iter().filter(|row| row.differs()).count();
    for row in rows.iter() {
        table = table.with_row(vec![
            row.position().to_string(),
            row.left_batch_text().to_string(),
            row.right_batch_text().to_string(),
            if row.differs() { "不同" } else { "相同" }.to_string(),
        ]);
    }
    table = table.with_note(&format!(
        "★ 顺序不同的位置:{} / {}。\
         两个走法产出的**集合完全相同**(排序后逐项相等),\
         因此「换走法」只改变先看到哪一件,不改变盘到哪些。",
        differing_total,
        rows.len()
    ));
    table.render(DOCUMENT_WIDTH)
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : overview_report.rs
//! 幕一:装配总览(**两列键值对**)。
//!
//! ## ★ 为什么第一幕先说「装了什么」
//!
//! 后面十四幕的表都在回答「走成什么样」,
//! 而读者最需要的第一个信息是「这次盘的是什么、装了几个游标」。
//! 没有它,第二张表里的「12」不知道是批次数还是步数。

use crate::app::layout::{Table, TableColumn};
use crate::app::DOCUMENT_WIDTH;

/// 键列宽。
const KEY_WIDTH: usize = 40;
/// 值列宽(**可省略**:值里可能有很长的说明)。
const VALUE_WIDTH: usize = 90;

/// ★ 通用两列表(**多幕复用**)。
///
/// # 参数
/// - `title`:幕标题。
/// - `rows`:键值对。
/// - `notes`:注解(**永不省略**,只折行)。
///
/// ⚠️ 值列做成可省略:键值对的值往往是「一句话结论」,
/// 长度不可控;做成不可省略会在数据一变时就 panic,
/// 而那种 panic 与「算错了」长得一模一样。
pub fn render_pair_table(title: &str, rows: &[(String, String)], notes: &[&str]) -> Vec<String> {
    let mut table: Table = Table::new(
        title,
        vec![
            TableColumn::new("项目", KEY_WIDTH),
            TableColumn::elidable(TableColumn::new("值", VALUE_WIDTH)),
        ],
    );
    for (key, value) in rows.iter() {
        table = table.with_row(vec![key.clone(), value.clone()]);
    }
    for note in notes.iter() {
        table = table.with_note(note);
    }
    table.render(DOCUMENT_WIDTH)
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : run_report.rs
//! 幕七:逐游标的行走结果。
//!
//! ## ★ 「跳过」这一列是本工程的重点
//!
//! 前几列(推进 / 产出 / 终止)在任何遍历实现里都有;
//! 「跳过」只有在**把筛选收进游标**之后才存在。
//! 不用迭代器模式的写法(筛选写在循环体里)里,
//! 被跳过的那一步**没有留下任何痕迹**------
//! 那时「只盘了 7 件」与「走了 12 步只盘了 7 件」是同一句话,
//! 于是盘点覆盖率根本说不清。
//!
//! ## 合计行的可加性判据是**语义**不是类型
//!
//! 「推进 / 产出 / 跳过 / 终止 / 盘到 / 跟进 / 差异」都可加;
//! 「走法」「各结论件数」不可加,填 `---` 并在注解里说明。
//! 「声明步数」**可以**加:它是「五个游标各自最多能走几步」之和。
//!
//! ## 列宽
//!
//! `10+8+8+6+6+8+6+6+6+10+30 = 104`,加 11 列 × 3 + 1 = `138 <= 140`。

use crate::analysis::CursorRunView;
use crate::app::layout::{Table, TableColumn};
use crate::app::value_display::signed_gram_display_text;
use crate::app::DOCUMENT_WIDTH;

const CURSOR_WIDTH: usize = 10;
const ORDER_WIDTH: usize = 8;
const DECLARED_WIDTH: usize = 8;
const ADVANCE_WIDTH: usize = 6;
const YIELD_WIDTH: usize = 6;
const SKIP_WIDTH: usize = 8;
const STOP_WIDTH: usize = 6;
const COUNTED_WIDTH: usize = 6;
const FOLLOW_UP_WIDTH: usize = 6;
const VARIANCE_WIDTH: usize = 10;
const VERDICT_WIDTH: usize = 30;

/// 幕七:行走结果表。
///
/// # 参数
/// - `views`:行走结果视图。
pub fn render_run_table(views: &[CursorRunView]) -> Vec<String> {
    let mut table: Table = Table::new(
        "幕七 · 逐游标行走结果(推进 = 产出 + 跳过 + 终止)",
        vec![
            TableColumn::new("游标名", CURSOR_WIDTH),
            TableColumn::new("走法", ORDER_WIDTH),
            TableColumn::numeric("声明步数", DECLARED_WIDTH),
            TableColumn::numeric("推进", ADVANCE_WIDTH),
            TableColumn::numeric("产出", YIELD_WIDTH),
            TableColumn::numeric("★ 跳过", SKIP_WIDTH),
            TableColumn::numeric("终止", STOP_WIDTH),
            TableColumn::numeric("盘到", COUNTED_WIDTH),
            TableColumn::numeric("跟进", FOLLOW_UP_WIDTH),
            TableColumn::numeric("差异(克)", VARIANCE_WIDTH),
            TableColumn::elidable(TableColumn::new("各结论件数", VERDICT_WIDTH)),
        ],
    );
    for view in views.iter() {
        table = table.with_row(vec![
            view.cursor_name().to_string(),
            view.order_text().to_string(),
            view.declared_total().to_string(),
            view.advance_total().to_string(),
            view.yield_total().to_string(),
            view.skip_total().to_string(),
            view.stop_total().to_string(),
            view.counted_total().to_string(),
            view.follow_up_total().to_string(),
            // ⚠️ 差异**可以为负**,且这里没有地方替它补符号:
            // 必须走带符号出口,否则负差异会以下取整的形态出现。
            signed_gram_display_text(view.variance_milligrams()),
            view.verdict_summary_text().to_string(),
        ]);
    }

    // ★ 合计行只做一件事:对每列求和。不可求和的列填「---」。
    let declared_sum: usize = views.iter().map(|view| view.declared_total()).sum();
    let advance_sum: usize = views.iter().map(|view| view.advance_total()).sum();
    let yield_sum: usize = views.iter().map(|view| view.yield_total()).sum();
    let skip_sum: usize = views.iter().map(|view| view.skip_total()).sum();
    let stop_sum: usize = views.iter().map(|view| view.stop_total()).sum();
    let counted_sum: usize = views.iter().map(|view| view.counted_total()).sum();
    let follow_up_sum: usize = views.iter().map(|view| view.follow_up_total()).sum();
    let variance_sum: i64 = views.iter().map(|view| view.variance_milligrams()).sum();
    table = table.with_row(vec![
        "合计".to_string(),
        "---".to_string(),
        declared_sum.to_string(),
        advance_sum.to_string(),
        yield_sum.to_string(),
        skip_sum.to_string(),
        stop_sum.to_string(),
        counted_sum.to_string(),
        follow_up_sum.to_string(),
        // ★ 合计行的差异是**各游标差异之和**(重量可加),所以它会比任何一行都大。
        signed_gram_display_text(variance_sum),
        "---".to_string(),
    ]);

    table = table
        .with_note(&format!(
            "★ 恒等式:推进 {} == 产出 {} + 跳过 {} + 终止 {}。\
             跳过 {} 步占推进的 {}------这个数在「筛选写在循环体里」的写法里根本不存在。",
            advance_sum,
            yield_sum,
            skip_sum,
            stop_sum,
            skip_sum,
            crate::app::percent_text(crate::domain::ratio_basis_points(
                i128::from(skip_sum as i64),
                i128::from(advance_sum as i64)
            ))
        ))
        .with_note(
            "★ 「盘到」小于「产出」是因为待复检与已封存**不计入**本次盘点数(见幕四)。\
             「差异」是实盘 − 账面,带符号,合计可以为负。",
        )
        .with_note(
            "⚠️ 合计行里「走法」与「各结论件数」填「---」:它们不是数量,相加没有意义。",
        );
    table.render(DOCUMENT_WIDTH)
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : value_display.rs
//! 值对象的**报表排版**:把领域层存的整数渲染成人看的文本。
//!
//! ## ★ 哪些排版留在领域层,哪些搬到这里
//!
//! 判据只有一条:**这个文本形式是不是「所有层都要用」**。
//!
//! - **留在领域层**:`Money::chinese_text()`(`¥12,345.67`)。
//!   机制层报错、分析层结论、表示层表格都要把金额嵌进句子里------
//!   把它搬来表示层会让 `borg` 反向依赖 `app`,那才是真违规。
//!   值对象的**规范文本形式**属于值对象本身。
//! - **搬来表示层**:万分点汇率的两种读法。
//!   机制层存的是**万分点整数**;「把 9215 显示成 0.9215」与「把 5000 显示成 50.00%」
//!   是这张表列头写着「汇率」「复用率」才有的决定,只有报表需要。
//!
//! ## ⚠️ 为什么这里可以有除法
//!
//! 整个计算链路全程是整数,除法只发生在 [`crate::domain::divide_rounded`] 一处。
//! 本文件是**唯一的例外**:这两个函数都不参与任何合计,
//! 只把已经算完的整数拆成「整数部分 + 小数部分」,
//! 所以不存在舍入问题------余数是整除的余数,不是被舍掉的部分。
//!
//! 换句话说:**展示可以除,算账不可以除**。
//!
//! ## ⚠️ 为什么「毫克 → 克」在这里而不在领域层
//!
//! 领域层存的是**毫克整数**(`StockBatch::weight_milligrams`),
//! 「用克显示、保留 3 位小数」是**这张表列头写着「重量(克)」才有的决定**,
//! 与「万分点怎么显示」同理,因此放在这里。
//!
//! 它**不参与任何合计**:只把已经算完的整数拆成「整数部分 + 小数部分」,
//! 余数是整除的余数,不是被舍掉的部分------所以不存在舍入问题。

/// 把汇率万分点渲染成小数文本(如 `9215` → `"0.9215"`)。
///
/// ⚠️ **`10000` 与 `10000.0` 是完全不同的两件事**:
/// 前者是 1:1,后者是「万倍」。写成 `{}.{:04}` 而不除 10000,
/// 读者会把 `9215` 读成「九千多倍汇率」。
/// ⚠️ 负数同样走 [`split_magnitude_text`](先取绝对值):
/// `-104` 万分点 → `-0.0104`,而不是下取整给出的 `-1.9896`。
pub fn basis_points_text(basis_points: i64) -> String {
    let body: String = split_magnitude_text(basis_points, 10_000, 4);
    if basis_points < 0 {
        format!("-{}", body)
    } else {
        body
    }
}

/// ★ 万分点转百分数文本(`10000` -> `100.00`)。
///
/// ⚠️ 为什么单列一个函数:`basis_points_text` 输出的是「几点几」
/// (`10000` -> `1.0000`,因为 10000 万分点 = 1 倍)。
/// 直接给它加一个 `%` 会变成 `1.0000%`------那是**百分之一**,
/// 而复用率 100% 应该显示 `100.00%`。
/// 这个错误在报表上完全看不出来(都是一个带百分号的小数),
/// 只能靠追问「分母是什么」才能发现,因此口径必须收在一处。
pub fn percent_text(basis_points: i64) -> String {
    format!("{}.{:02}", basis_points / 100, (basis_points % 100).abs())
}

/// 把布尔量渲染成中文(`是` / `否`)。
///
/// ⚠️ 报表的布尔列一律走这里,不写 `if x { "是" } else { "否" }`:
/// 散着写迟早会出现一处写成 `符合/不符合`,
/// 而读者会以为那是另一种判断。
pub const fn bool_text(value: bool) -> &'static str {
    if value {
        "是"
    } else {
        "否"
    }
}

/// 把字节数渲染成带千分位的文本。
///
/// ⚠️ 用整数反复除 10 取位,**全程不出现浮点**(与 `Money` 的千分位同理)。
pub fn byte_text(bytes: usize) -> String {
    let digits: Vec<char> = bytes.to_string().chars().collect();
    let mut buffer: Vec<char> = Vec::new();
    for (index, character) in digits.iter().enumerate() {
        let from_right: usize = digits.len() - index;
        if index > 0 && from_right % 3 == 0 {
            buffer.push(',');
        }
        buffer.push(*character);
    }
    buffer.into_iter().collect()
}

/// ★ 把整数按 `scale` 拆成「整数部分 + 小数部分」的**无符号**文本。
///
/// # 参数
/// - `value`:待拆的整数(**可以为负**,符号由调用方决定)。
/// - `scale`:小数进位(`1000` 表示 3 位小数,`10000` 表示 4 位)。
/// - `digits`:小数位数(必须与 `scale` 匹配:1000→3、10000→4)。
///
/// ## ⚠️ 为什么**先取绝对值**再拆(本工程实测出来的 bug)
///
/// 最初的写法是 `value.div_euclid(scale)` + `value.rem_euclid(scale)`,
/// 注释还写着「这样负数才不会出现 `-12.-500`」。
/// 但 `div_euclid` 是**下取整**语义:`-104` 毫克被拆成 `-1 / 896`,
/// 拼出来是 **`-1.896` 克**,而正确答案是 **`-0.104` 克**。
///
/// 这个错误的性质值得写下来:
/// - 它**不崩**、**不 panic**、`cargo build` 零告警;
/// - 报表上每个负数都错,但错得**像一个正常的盘点差异**(`-1.896` 看起来完全合理);
/// - 它是被「用独立脚本从原始数据重算」抓到的,不是被肉眼或编译器抓到的。
///
/// 教训:写「防负数」的注释不等于处理了负数。
/// 处理负数的唯一可靠做法是**先把符号摘出去**,中间量全程无符号。
fn split_magnitude_text(value: i64, scale: i64, digits: usize) -> String {
    debug_assert!(
        (scale == 1_000 && digits == 3) || (scale == 10_000 && digits == 4),
        "小数位数必须与进位匹配:scale={} digits={}",
        scale,
        digits
    );
    // ⚠️ `unsigned_abs` 而不是 `abs`:`i64::MIN.abs()` 会溢出 panic。
    let magnitude: u64 = value.unsigned_abs();
    let divisor: u64 = u64::try_from(scale).expect("拆分的进位必须为正整数");
    format!(
        "{}.{:0width$}",
        magnitude / divisor,
        magnitude % divisor,
        width = digits
    )
}

/// 把毫克整数渲染成「克,3 位小数」的文本(如 `12500` → `"12.500"`)。
///
/// ⚠️ 负数由 [`split_magnitude_text`] 先取绝对值再补 `-`:
/// `-104` 毫克 → `-0.104`(**不是** `-1.896`,见那里的实测说明)。
pub fn gram_display_text(milligrams: i64) -> String {
    let body: String = split_magnitude_text(milligrams, 1_000, 3);
    if milligrams < 0 {
        format!("-{}", body)
    } else {
        body
    }
}

/// ★ 带符号的克文本(**差异列专用**:`+0.008` / `-0.104` / ` 0.000`)。
///
/// ⚠️ 为什么单列一个函数而不是在表格里 `if` 一把:
/// 「差异」这张表在**两幕**里出现(幕五逐批次、幕七逐游标),
/// 两处各写一个 `if` 的结果是两处可以有不同口径------
/// 一处写 `+`、另一处不写,读者会以为那是两种不同的量。
/// 符号显示是排版决定,排版决定只允许有一处。
pub fn signed_gram_display_text(milligrams: i64) -> String {
    let sign: char = if milligrams > 0 {
        '+'
    } else if milligrams < 0 {
        '-'
    } else {
        ' '
    };
    format!("{}{}", sign, split_magnitude_text(milligrams, 1_000, 3))
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : value_report.rs
//! 幕十一:价值量化 + 台账快照。
//!
//! ## ★ 不能说「省了时间」
//!
//! 时间不可复现,而且「写代码快慢」根本不是模式要解决的问题。
//! 这一幕只报两件**可数**的事:
//!
//! 1. **份数**:不用迭代器模式时,`走法 × 筛选` 的每种组合都要写一份带下标的循环;
//!    用迭代器模式时只需 `走法 + 筛选` 份,组合是装配出来的。
//! 2. **次数**:过滤游标**跳过了多少步**。
//!    这个数字在「筛选写在循环体里」的写法里**根本不存在**。
//!
//! ## 台账快照表
//!
//! 快照是**全部行走结束后**取的(见 `client::stocktake_session` 的模块文档):
//! 若在跑第一个游标后就取,总数只统计了第一个游标,
//! 而报表上看不出它少统计了后面四个。

use crate::analysis::{IterationValueReport, LedgerSnapshotView};
use crate::app::layout::{Table, TableColumn};
use crate::app::value_display::bool_text;
use crate::app::DOCUMENT_WIDTH;

const NAME_WIDTH: usize = 14;
const COUNT_WIDTH: usize = 8;
const FLAG_WIDTH: usize = 10;

/// 幕十一(续):台账快照表(**逐游标**)。
///
/// # 参数
/// - `view`:台账快照视图。
pub fn render_ledger_snapshot_table(view: &LedgerSnapshotView) -> Vec<String> {
    let mut table: Table = Table::new(
        "幕十一(续)· 游标台账快照(**全部行走结束后**取的快照)",
        vec![
            TableColumn::new("游标名", NAME_WIDTH),
            TableColumn::numeric("被驱动", COUNT_WIDTH),
            TableColumn::numeric("推进", COUNT_WIDTH),
            TableColumn::numeric("产出", COUNT_WIDTH),
            TableColumn::numeric("★ 跳过", COUNT_WIDTH),
            TableColumn::numeric("终止", COUNT_WIDTH),
            TableColumn::new("记账闭合", FLAG_WIDTH),
        ],
    );
    for attempt in view.attempts().iter() {
        table = table.with_row(vec![
            attempt.name().to_string(),
            attempt.drive_total().to_string(),
            attempt.advance_total().to_string(),
            attempt.yield_total().to_string(),
            attempt.skip_total().to_string(),
            attempt.stop_total().to_string(),
            bool_text(attempt.step_accounted()).to_string(),
        ]);
    }
    table = table.with_row(vec![
        "合计".to_string(),
        view.drive_total().to_string(),
        view.advance_total().to_string(),
        view.yield_total().to_string(),
        view.skip_total().to_string(),
        view.stop_total().to_string(),
        bool_text(view.step_accounted()).to_string(),
    ]);
    table = table
        .with_note(&format!(
            "★ 恒等式一(推进 == 产出 + 跳过 + 终止):{};\
             恒等式二(合计 == 各游标之和):{};\
             恒等式三(每个被驱动过的游标恰好终止一次):{}。",
            bool_text(view.step_accounted()),
            bool_text(view.sum_accounted()),
            bool_text(view.termination_accounted())
        ))
        .with_note(&format!(
            "★ 死游标(一次都没被推进过):{};空过滤游标(全程只跳过):{}。\
             这两类在报表上都长得像「0 / 0 / 0」,只有这两个判据能把它们分开。",
            view.never_advanced_text(),
            view.pure_skip_text()
        ));
    table.render(DOCUMENT_WIDTH)
}

/// ★ 幕十一注解里要用的「跳跃占比」文本。
///
/// ⚠️ 收口在这里而不在调用方:`skip_share_basis_points` 是万分点,
/// 直接加 `%` 会变成百分之一(见 `value_display::percent_text` 的文档)。
pub fn skip_share_text(report: &IterationValueReport) -> String {
    crate::app::percent_text(report.skip_share_basis_points())
}
rust 复制代码
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : batch_cursor.rs
//! 批次游标:**一个走法 + 一个筛选**组成的一次行走。
//!
//! ## ★ 它拥有数据的一个副本,而不是借用
//!
//! `StockBatch` 是 `Copy`(八个字段全是整数 / `&'static str`),
//! 所以游标可以直接持有 `Vec<StockBatch>`。
//! 这看起来「浪费」,但换来一件重要的事:
//! **游标不借任何人的生命周期**,于是可以在任意地方构造、
//! 可以放进 `Vec`、可以同时开五个而不需要任何 unsafe 或 `Rc<RefCell>`。
//!
//! ⚠️ 真正要防的是「遍历途中集合被改」,
//! 而副本天然地防住了它------副本没有人能改(见 `has_stopped` 之后的幂等)。
//!
//! ## ★ 为什么 `advance` 里**先取下标再自增**
//!
//! `position` 的语义是「已经推进过的步数」。
//! 先取 `self.position` 作为本步的下标,再自增,
//! 于是「第 k 步作用在下标 k」这句话成立,
//! 抽盘的 `index % stride` 才有确定的含义。
//! 写成先自增再取,`index` 会从 1 开始------
//! 那时「每隔 3 个取 1 个」会取到第 1、4、7 个,
//! 而说明里写的是第 0、3、6 个。

use crate::domain::StockBatch;
use crate::iterator::{Cursor, CursorStep};

use super::batch_filter::BatchFilter;
use super::walk_order::WalkOrder;

/// 一个批次游标。
#[derive(Clone, Debug)]
pub struct BatchCursor {
    /// 游标名。
    name: String,
    /// ★ 已按走法排好序的批次清单(**游标私有的副本**)。
    ordered: Vec<StockBatch>,
    /// 筛选(决定每一步是产出还是跳过)。
    filter: BatchFilter,
    /// 走法(**只用于说明与报表**,排序在构造时已经做完)。
    order: WalkOrder,
    /// 已经推进过的步数。
    position: usize,
    /// 是否已经终止。
    stopped: bool,
}

impl BatchCursor {
    /// 造一个游标。
    ///
    /// # 参数
    /// - `name`:游标名。
    /// - `ordered`:**已排好序**的批次清单(排序由调用方按走法完成)。
    /// - `filter`:筛选。
    /// - `order`:走法(**仅记录**,用于说明文本)。
    pub fn new(name: &str, ordered: Vec<StockBatch>, filter: BatchFilter, order: WalkOrder) -> Self {
        BatchCursor {
            name: name.to_string(),
            ordered,
            filter,
            order,
            position: 0,
            stopped: false,
        }
    }

    /// 走法。
    pub const fn order(&self) -> WalkOrder {
        self.order
    }

    /// 筛选。
    pub const fn filter(&self) -> BatchFilter {
        self.filter
    }

    /// ★ 声明的步数上限(== 排好序之后的批次数)。
    pub const fn ordered_total(&self) -> usize {
        self.ordered.len()
    }

    /// ★ 一句话说明这个游标在干什么。
    pub fn description_text(&self) -> String {
        format!(
            "{} + {}",
            self.order.chinese_text(),
            self.filter.description_text()
        )
    }
}

impl Cursor for BatchCursor {
    type Item = StockBatch;

    fn name(&self) -> &str {
        self.name.as_str()
    }

    fn advance(&mut self) -> CursorStep<StockBatch> {
        // ★ 终止之后必须继续返回 Stop(幂等):
        // 「走完了」要有确定的含义,不能因为多推一次就又产出一件。
        if self.stopped {
            return CursorStep::Stop;
        }
        let index: usize = self.position;
        match self.ordered.get(index) {
            None => {
                self.stopped = true;
                CursorStep::Stop
            }
            Some(batch) => {
                self.position += 1;
                if self.filter.accept(batch, index) {
                    CursorStep::Yield(*batch)
                } else {
                    // ★ 跳过:这一步没有产出,但**还要继续走**。
                    CursorStep::Skip
                }
            }
        }
    }

    fn position(&self) -> usize {
        self.position
    }

    fn declared_total(&self) -> usize {
        self.ordered.len()
    }

    fn has_stopped(&self) -> bool {
        self.stopped
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : batch_filter.rs
//! 筛选:**哪一步产出、哪一步跳过**。
//!
//! ## ★ 为什么筛选是游标的事,不是调用方的事
//!
//! 不用迭代器模式的写法是:
//! `for i in 0..len { if 满足条件 { 处理(batches[i]) } }`------
//! 筛选写在**循环体里**,于是「跳过了一个」这件事**没有任何记录**,
//! 调用方只知道自己处理了 7 件,不知道「其实走了 12 步、跳过了 5 个」。
//!
//! 本工程把筛选收进游标:不满足条件的一步返回 [`CursorStep::Skip`],
//! 于是「跳过了 5 个」成为一个**能被数出来**的数字(幕十会数它)。
//! 这不只是为了好看------
//! 「只盘了 7 件」与「走了 12 步只盘了 7 件」是两句完全不同的汇报,
//! 前者说不清盘点覆盖率。
//!
//! ## ★ 为什么做成数据而不是每种筛选一个 struct
//!
//! 判据和责任链工程里的处理器一样:**这些筛选只是判据不同,形状完全一样**。
//! 做成五个 struct 会得到五个 `impl Cursor`,
//! 每个都要把 `position` / `stopped` / `name` 再写一遍------
//! 而那三行才是真正容易写错的东西(越界、忘了置 `stopped`)。
//! 判据收进枚举,形状只写一次。

use crate::domain::{Money, StockBatch, CNY};

/// 一种筛选。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum BatchFilter {
    /// 全量(**每一步都产出**)。
    All,
    /// 只要需复检的批次。
    RecheckOnly,
    /// 只要高值件(**金额 >= 门槛**)。
    HighValue {
        /// 金额门槛(**整数分**)。
        threshold_minor_units: i64,
    },
    /// 抽盘:每隔 `stride` 个取一个。
    SamplingEvery {
        /// 步长(**必须 >= 1**)。
        stride: usize,
    },
    /// 只要某个库区的批次。
    ZoneOnly {
        /// 库区机器码。
        zone_code: &'static str,
    },
}

impl BatchFilter {
    /// 机器编码。
    pub const fn code(&self) -> &'static str {
        match self {
            BatchFilter::All => "ALL",
            BatchFilter::RecheckOnly => "RECHECK_ONLY",
            BatchFilter::HighValue { .. } => "HIGH_VALUE",
            BatchFilter::SamplingEvery { .. } => "SAMPLING",
            BatchFilter::ZoneOnly { .. } => "ZONE_ONLY",
        }
    }

    /// 中文名。
    pub const fn chinese_text(&self) -> &'static str {
        match self {
            BatchFilter::All => "全量",
            BatchFilter::RecheckOnly => "仅需复检",
            BatchFilter::HighValue { .. } => "仅高值件",
            BatchFilter::SamplingEvery { .. } => "等距抽盘",
            BatchFilter::ZoneOnly { .. } => "指定库区",
        }
    }

    /// ★ 说明(**含算出来的文本**,所以不是 `const fn`)。
    ///
    /// ⚠️ 为什么说明里必须出现门槛与步长:
    /// 「仅高值件」这四个字说不清是 1 万还是 10 万;
    /// 报表上若不写门槛,读者只能回代码里找------
    /// 而那时他已经不知道该信哪一处了。
    pub fn description_text(&self) -> String {
        match self {
            BatchFilter::All => "不筛选,每一步都产出".to_string(),
            BatchFilter::RecheckOnly => "只要需要定期复检的品类(翡翠 / 钻石)".to_string(),
            BatchFilter::HighValue {
                threshold_minor_units,
            } => {
                // ⚠️ 用 `Money::amount_text` 而不是自己拼千分位:
                //    金额怎么显示是**领域层**的事(见 `Money` 的文档),
                //    这里拼第二遍就会有第二个口径。
                format!("账面金额 >= {}", Money::new(*threshold_minor_units, CNY).amount_text())
            }
            BatchFilter::SamplingEvery { stride } => {
                format!("每隔 {} 个取 1 个(第 0、{}、{} ... 个)", stride, stride, stride * 2)
            }
            BatchFilter::ZoneOnly { zone_code } => format!("库区码 == {}", zone_code),
        }
    }

    /// 是否全量(**不跳过任何一步**)。
    pub const fn is_all(&self) -> bool {
        matches!(self, BatchFilter::All)
    }

    /// ★ 这一步要不要产出。
    ///
    /// # 参数
    /// - `batch`:这一步上的批次。
    /// - `index`:这一步在**走法排好序之后**的下标(抽盘口径依赖它)。
    ///
    /// ⚠️ `index` 是**走完排序之后**的下标,不是台账下标:
    /// 「每隔 3 个取 1 个」说的是「沿着动线走,每 3 个取 1 个」,
    /// 若用台账下标,换一种走法抽到的就不是同一批货了,
    /// 而「抽盘」的意义恰恰是**与走法无关地**均匀取样------
    /// 这里刻意选了「沿走法等距」这个口径,并在说明里写清楚。
    pub fn accept(&self, batch: &StockBatch, index: usize) -> bool {
        match self {
            BatchFilter::All => true,
            BatchFilter::RecheckOnly => batch.needs_recheck(),
            BatchFilter::HighValue {
                threshold_minor_units,
            } => batch.is_high_value(*threshold_minor_units),
            BatchFilter::SamplingEvery { stride } => {
                // ★ 步长为 0 会让 `index % stride` 直接 panic,
                //    而那时报表上表现为「程序崩了」而不是「参数写错了」。
                assert!(*stride >= 1, "抽盘步长必须 >= 1,当前是 {}", stride);
                index % *stride == 0
            }
            BatchFilter::ZoneOnly { zone_code } => batch.location().zone().code() == *zone_code,
        }
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : counting_rule.rs
//! 盘点判定:一个批次被盘到之后给出什么结论。
//!
//! ## ★ 为什么判定规则在这里而不在领域层
//!
//! 「盘盈 / 盘亏 / 待复检」是**会计口径**,领域层只管「批次是什么」。
//! 但判定又**不是**遍历机制(它跟游标怎么走毫无关系),所以也不在机制层。
//! 这里(调用层)是唯一合适的位置:它同时认识业务与调用方式。
//!
//! ## ★ 实盘重量怎么来------**必须是确定性的**
//!
//! 本工程没有真的秤。实盘重量由批次号**确定性派生**而来
//! ([`weight_drift_basis_points`]),而不是随机:
//! 随机数的后果是「两次运行不一致」,
//! 而「两次运行逐字节一致」是本工程验收的硬指标之一。
//!
//! ⚠️ 用**确定性哈希**而不是「拿批次号的末位数字」:
//! 末位数字的分布取决于人怎么编号(一旦编号规则变了,分布就变了),
//! 哈希的分布只取决于哈希函数本身。
//!
//! ## 差异幅度
//!
//! 漂移取 ±5 ~ ±20 万分点(即 ±0.05% ~ ±0.20%):
//! 珠宝称重的合理误差量级。刻意**不是** ±0,
//! 否则「统一舍入口径」这条永远测不出来(见 `divide_rounded` 的文档)。

use crate::domain::{
    scale_rounded, CountedBatch, MonthKey, StockBatch, StocktakeVerdict, ZONE_REPAIR,
};
use crate::support::deterministic_code::stable_hash;

/// ★ 需复检的批次入库满多少个月后,「本次不盘、转复检」。
pub const RECHECK_AFTER_MONTHS: i32 = 24;

/// 漂移桶的数量(`0..10`):`0..6` 相符、`6..8` 盘盈、`8..10` 盘亏。
const DRIFT_BUCKET_TOTAL: i64 = 10;
/// 漂移桶里「相符」的桶数(**刻意不是 0**,否则结论集合里没有相符项)。
const DRIFT_MATCHED_BUCKETS: i64 = 6;
/// 漂移桶里「相符 → 盘盈」的分界。
const DRIFT_OVER_BUCKETS: i64 = 8;
/// 差异幅度的最小值(万分点)。
const DRIFT_MIN_BASIS_POINTS: i64 = 5;
/// 差异幅度的档数(万分点):`5 ~ 5 + 15`。
const DRIFT_MAGNITUDE_SPAN: i64 = 16;

/// ★ 这个批次的实盘重量相对账面**漂移多少万分点**(带符号)。
///
/// 返回值落在 `-20 ..= 20`,其中 **0 表示账实相符**。
///
/// # 参数
/// - `batch`:被盘的批次。
pub fn weight_drift_basis_points(batch: &StockBatch) -> i64 {
    let bucket: i64 = (stable_hash(&[batch.batch_number()]) % DRIFT_BUCKET_TOTAL as u64) as i64;
    if bucket < DRIFT_MATCHED_BUCKETS {
        return 0;
    }
    // 幅度用**另一个**哈希:同一个哈希取两次模会让「方向」与「幅度」相关,
    // 那时「盘盈的都是小差异」这种巧合会系统性地出现。
    let magnitude: i64 =
        DRIFT_MIN_BASIS_POINTS + (stable_hash(&[batch.batch_number(), "magnitude"]) % DRIFT_MAGNITUDE_SPAN as u64) as i64;
    if bucket < DRIFT_OVER_BUCKETS {
        magnitude
    } else {
        -magnitude
    }
}

/// ★ 实盘重量(毫克)。
///
/// 走 [`scale_rounded`] 而不是裸写乘除:
/// 这是全工程**唯一**的舍入出口(见 `crate::domain::arithmetic`)。
pub fn counted_weight_milligrams(batch: &StockBatch) -> i64 {
    let drift_milligrams: i64 = scale_rounded(
        batch.weight_milligrams(),
        i128::from(weight_drift_basis_points(batch)),
        10_000,
    );
    batch.weight_milligrams() + drift_milligrams
}

/// ★ 一个批次的盘点结论。
///
/// # 参数
/// - `batch`:被盘的批次。
/// - `current_month`:当前盘点月份(**领域层不该记住「现在是几月」**)。
///
/// ## 顺序有讲究
///
/// 「待复检」与「已封存」都排在重量比较**之前**:
/// 它们的意思是「本次根本没称」,
/// 若先比重量,这两类批次的实盘重量(等于账面)会被判成 `Matched`,
/// 于是「盘点完成率」会虚高------而这正是 `StocktakeVerdict::is_counted` 要防的事。
pub fn verdict_for(batch: &StockBatch, current_month: &MonthKey) -> StocktakeVerdict {
    if batch.needs_recheck() && batch.months_in_stock(current_month) >= RECHECK_AFTER_MONTHS {
        return StocktakeVerdict::PendingRecheck;
    }
    if batch.location().zone().code() == ZONE_REPAIR.code() {
        return StocktakeVerdict::Sealed;
    }
    let counted: i64 = counted_weight_milligrams(batch);
    let booked: i64 = batch.weight_milligrams();
    if counted > booked {
        StocktakeVerdict::Over
    } else if counted < booked {
        StocktakeVerdict::Short
    } else {
        StocktakeVerdict::Matched
    }
}

/// ★ 盘一个批次(**账面 + 实盘 → 结果**)。
///
/// ⚠️ 待复检 / 已封存的批次实盘重量**照抄账面**(不是 0):
/// 抄 0 会让它们看起来像「盘到了一件 0 克的货」,
/// 而差异重量会变成一个巨大的负数------那不是差异,那是没称。
pub fn count_batch(batch: &StockBatch, current_month: &MonthKey) -> CountedBatch {
    let verdict: StocktakeVerdict = verdict_for(batch, current_month);
    let counted_weight: i64 = if verdict.is_counted() {
        counted_weight_milligrams(batch)
    } else {
        batch.weight_milligrams()
    };
    CountedBatch::new(*batch, verdict, counted_weight)
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : cursor_factory.rs
//! 游标装配:**走法 × 筛选**的组合表。
//!
//! ## ★ 组合是装配出来的,不是写出来的
//!
//! 五种走法、五种筛选,理论上 25 种组合。
//! 不用迭代器模式的写法要为每种组合写一遍 `for` 循环(25 份遍历代码),
//! 而这里是 `5 + 5 = 10` 份定义,组合在 `CursorSpec` 里一行搞定。
//!
//! ⚠️ 这个「25 vs 10」是本工程价值量化的**说法**,不是数字------
//! 真正的数字在幕十一:同一种筛选在不用迭代器的写法里被写了几次。
//! 说法可以夸张,数字必须可数。
//!
//! ## ★ 规格表里**不写**说明文字
//!
//! 说明(如「账面金额 >= ¥50,000.00」)是**算出来的**,
//! 写进常量就得手写那个数字,于是门槛改了、说明没改。
//! 说明一律由 [`CursorSpec::description_text`] 现算。

use crate::domain::{InventoryLedger, MonthKey, StockBatch};

use super::batch_cursor::BatchCursor;
use super::batch_filter::BatchFilter;
use super::walk_order::WalkOrder;

/// ★ 高值件门槛(**整数分**):¥50,000.00。
pub const HIGH_VALUE_THRESHOLD_MINOR_UNITS: i64 = 5_000_000;

/// 抽盘步长:每隔 3 个取 1 个。
pub const SAMPLING_STRIDE: usize = 3;

/// 一个游标的规格(**数据**)。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct CursorSpec {
    /// 游标名。
    name: &'static str,
    /// 走法。
    order: WalkOrder,
    /// 筛选。
    filter: BatchFilter,
}

impl CursorSpec {
    /// 声明一个规格。
    pub const fn new(name: &'static str, order: WalkOrder, filter: BatchFilter) -> Self {
        CursorSpec { name, order, filter }
    }

    /// 游标名。
    pub const fn name(&self) -> &'static str {
        self.name
    }

    /// 走法。
    pub const fn order(&self) -> WalkOrder {
        self.order
    }

    /// 筛选。
    pub const fn filter(&self) -> BatchFilter {
        self.filter
    }
}

/// ★ 工程内建的游标规格(**不含工程外扩展区新增的游标**)。
///
/// ⚠️ 顺序即展示顺序:第一个是**主游标**(盘点动线全量),
/// 它产出的集合就是「本次盘点应当覆盖的全部批次」。
pub const BUILTIN_CURSOR_SPECS: [CursorSpec; 5] = [
    CursorSpec::new("动线全量", WalkOrder::LocationRoute, BatchFilter::All),
    CursorSpec::new(
        "高值抽查",
        WalkOrder::ValueDescending,
        BatchFilter::HighValue {
            threshold_minor_units: HIGH_VALUE_THRESHOLD_MINOR_UNITS,
        },
    ),
    CursorSpec::new("复检专查", WalkOrder::LocationRoute, BatchFilter::RecheckOnly),
    CursorSpec::new(
        "等距抽盘",
        WalkOrder::LedgerOrder,
        BatchFilter::SamplingEvery {
            stride: SAMPLING_STRIDE,
        },
    ),
    CursorSpec::new("反向复核", WalkOrder::ReverseLedger, BatchFilter::All),
];

/// ★ 按规格造一个游标。
///
/// # 参数
/// - `spec`:规格。
/// - `ledger`:台账。
/// - `current_month`:当前盘点月份。
///
/// ⚠️ 排序在这里完成(用 [`WalkOrder::order_batches`]):
/// 排序是**走法**的一部分,让调用方自己去排,
/// 「顺序」就会散落到各个调用点,那时幕九的换序对照无从做起。
pub fn build_cursor(
    spec: &CursorSpec,
    ledger: &InventoryLedger,
    current_month: &MonthKey,
) -> BatchCursor {
    let ordered: Vec<StockBatch> = spec.order().order_batches(ledger.batches(), current_month);
    BatchCursor::new(spec.name(), ordered, spec.filter(), spec.order())
}

/// 按规格清单造一批游标。
pub fn build_cursors(
    specs: &[CursorSpec],
    ledger: &InventoryLedger,
    current_month: &MonthKey,
) -> Vec<BatchCursor> {
    specs
        .iter()
        .map(|spec| build_cursor(spec, ledger, current_month))
        .collect()
}

/// 造工程内建的全部游标。
pub fn builtin_cursors(ledger: &InventoryLedger, current_month: &MonthKey) -> Vec<BatchCursor> {
    build_cursors(&BUILTIN_CURSOR_SPECS, ledger, current_month)
}

/// ★ 按名字造一个游标(**从规格表派生**,不认识就 panic)。
///
/// ⚠️ 为什么 panic 而不是返回 `Option`:
/// 「装配时写错游标名」是装配错误,不是业务分支;
/// 返回 `None` 会让那一次行走**静默缺席**,
/// 而报表上表现为「少了一行」,没有人会去查装配表。
///
/// # 参数
/// - `name`:游标名。
/// - `specs`:规格表(**扩展区可以传一张更长的表**)。
/// - `ledger`:台账。
/// - `current_month`:当前盘点月份。
pub fn build_cursor_by_name(
    name: &str,
    specs: &[CursorSpec],
    ledger: &InventoryLedger,
    current_month: &MonthKey,
) -> BatchCursor {
    match specs.iter().find(|spec| spec.name() == name) {
        Some(spec) => build_cursor(spec, ledger, current_month),
        None => panic!("游标规格表里没有「{}」:检查一下是不是装配时把名字写错了", name),
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : shared_position_cursor.rs
//! ★ 反例:把「当前位置」放在**集合里**会怎样。
//!
//! ## 这个文件的存在意义
//!
//! 迭代器模式最核心的一条是「**集合不记得走到哪了,游标才记得**」。
//! 这句话听起来天经地义,但它有多天经地义,说的人往往给不出证据------
//! 因为把位置放在集合里,在**只开一个游标**的时候完全看不出问题。
//!
//! 所以这里做了一个对照组:
//! 位置收在一个 `Rc<Cell<usize>>` 里,由**同一批游标共享**(模拟「存在集合上」)。
//! 第一个游标走完 12 步之后位置已经到末尾,
//! 后面四个游标一开工就「已经走完了」------**各产出 0 件**。
//!
//! ## ★ 这个反例必须真的能失败
//!
//! 恒真的检查没有价值。幕九会把两条路放在同一张表里:
//! 正常写法五个游标产出 `12 + n + m + k + 12` 件,
//! 共享位置写法只产出 `12` 件(第一个游标的)。
//! 差就是证据。
//!
//! ## ⚠️ 这不是「线程不安全」的例子
//!
//! 这里**没有**任何并发:五个游标是依次走的。
//! 它演示的是另一件事------
//! 「位置」属于**一次行走**,不属于**被走的东西**。
//! 哪怕严格串行,共享位置照样是错的。

use std::cell::Cell;
use std::rc::Rc;

use crate::domain::{MonthKey, StockBatch};
use crate::iterator::{Cursor, CursorStep};

use super::walk_order::WalkOrder;

/// ★ 一个把位置共享出去的游标(**反例**)。
#[derive(Clone, Debug)]
pub struct SharedPositionCursor {
    /// 游标名。
    name: String,
    /// 已按走法排好序的批次清单。
    ordered: Vec<StockBatch>,
    /// ★ 共享的位置(**这就是错的地方**:位置应当在游标上)。
    shared_position: Rc<Cell<usize>>,
}

impl SharedPositionCursor {
    /// 造一个共享位置的游标。
    ///
    /// # 参数
    /// - `name`:游标名。
    /// - `ordered`:已排好序的批次清单。
    /// - `shared_position`:**共享**的位置(多个游标传同一个 `Rc`)。
    pub fn new(name: &str, ordered: Vec<StockBatch>, shared_position: Rc<Cell<usize>>) -> Self {
        SharedPositionCursor {
            name: name.to_string(),
            ordered,
            shared_position,
        }
    }

    /// 走法(构造时记录,仅用于说明)。
    pub fn ordered_total(&self) -> usize {
        self.ordered.len()
    }
}

impl Cursor for SharedPositionCursor {
    type Item = StockBatch;

    fn name(&self) -> &str {
        self.name.as_str()
    }

    fn advance(&mut self) -> CursorStep<StockBatch> {
        let index: usize = self.shared_position.get();
        match self.ordered.get(index) {
            None => CursorStep::Stop,
            Some(batch) => {
                // ⚠️ 位置在**别人手里**:这一步推进的是共享的那个数,
                //    于是下一个游标开工时看到的是本游标留下的位置。
                self.shared_position.set(index + 1);
                CursorStep::Yield(*batch)
            }
        }
    }

    fn position(&self) -> usize {
        self.shared_position.get()
    }

    fn declared_total(&self) -> usize {
        self.ordered.len()
    }

    fn has_stopped(&self) -> bool {
        self.shared_position.get() >= self.ordered.len()
    }
}

/// ★ 造一批共享同一个位置的游标(对照组的输入)。
///
/// # 参数
/// - `names`:游标名清单。
/// - `batches`:台账批次。
/// - `order`:走法(**五个游标刻意用同一个走法**:
///   这样两条路的差别就**只剩**位置是否共享,没有别的变量)。
/// - `current_month`:当前盘点月份。
pub fn build_shared_group(
    names: &[&str],
    batches: &[StockBatch],
    order: WalkOrder,
    current_month: &MonthKey,
) -> Vec<SharedPositionCursor> {
    let ordered: Vec<StockBatch> = order.order_batches(batches, current_month);
    let shared_position: Rc<Cell<usize>> = Rc::new(Cell::new(0));
    names
        .iter()
        .map(|name| SharedPositionCursor::new(name, ordered.clone(), shared_position.clone()))
        .collect()
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : stocktake_session.rs
//! 盘点会话:把一批游标放到同一份台账上跑一遍。
//!
//! ## ★ 这一层是「调用方」的模型
//!
//! 调用方**不认识**任何一个游标的实现细节:
//! 它拿到一批游标,对每一个只做「推进到终止」这一件事,
//! 然后拿回一份产出清单。
//! 走法、筛选、终止条件全在游标自己身上。
//!
//! ⚠️ 判据很具体:本文件里**没有** `if` 判断批次该不该被盘。
//! 一旦这里出现筛选条件,就说明筛选漏在了游标外面------
//! 那时「跳过了几个」又变成取不到的数字了。
//!
//! ## ★ 快照必须在全部行走**跑完之后**取
//!
//! 台账计数是会持续变化的量。若在跑第一个游标后就取快照,
//! 那么「总推进次数」只统计了第一个游标,
//! 而报表上看不出它少统计了后面四个(数字本身完全合理)。
//! 因此 [`StocktakeSession::run`] 在**全部驱动结束后**才调 `snapshot()`。

use crate::domain::{CountedBatch, InventoryLedger, MonthKey, StockBatch, StocktakeVerdict};
use crate::iterator::{Cursor, CursorLedger, LedgerSnapshot, WalkObservation};

use super::batch_cursor::BatchCursor;
use super::counting_rule::count_batch;
use super::shared_position_cursor::SharedPositionCursor;

/// 一次行走的**中间汇总**(驱动完、还没翻成结果)。
///
/// ⚠️ 为什么用一个私有结构而不是六元组:
/// 六元组在 `map` 里解构时六个位置全是 `String`,
/// 写错一个位置**编译得过**(类型相同),而报表上会显示错的内容。
/// 命名字段让这种错误变得不可能。
struct WalkSummary {
    /// 游标名。
    cursor_name: String,
    /// 走法中文名。
    order_text: String,
    /// ★ 走法说明(为谁服务)。
    order_purpose_text: String,
    /// 筛选说明。
    filter_text: String,
    /// ★ 筛选短名(`全量` / `仅需复检` ...,给窄列用)。
    filter_short_text: String,
    /// 声明的步数上限。
    declared_total: usize,
    /// 走出来的批次。
    walked: Vec<StockBatch>,
}

/// 一次行走的结果。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CursorRun {
    /// 游标名。
    cursor_name: String,
    /// 走法中文名。
    order_text: String,
    /// ★ 走法说明(**这个走法为谁服务**)。
    order_purpose_text: String,
    /// 筛选说明。
    filter_text: String,
    /// ★ 筛选短名(`全量` / `仅需复检` ...)。
    filter_short_text: String,
    /// 声明的步数上限。
    declared_total: usize,
    /// ★ 产出清单(**已判结论**)。
    produced: Vec<CountedBatch>,
    /// ★ 产出标签(**批次指纹**,审计比对用)。
    labels: Vec<String>,
    /// 推进次数。
    advance_total: usize,
    /// 产出次数。
    yield_total: usize,
    /// ★ 跳过次数。
    skip_total: usize,
    /// 终止次数。
    stop_total: usize,
}

impl CursorRun {
    /// 由一次行走组装(**由 [`StocktakeSession::run`] 调用**)。
    pub fn new(
        cursor_name: &str,
        order_text: &str,
        order_purpose_text: &str,
        filter_text: &str,
        filter_short_text: &str,
        declared_total: usize,
        produced: Vec<CountedBatch>,
        labels: Vec<String>,
        advance_total: usize,
        yield_total: usize,
        skip_total: usize,
        stop_total: usize,
    ) -> Self {
        CursorRun {
            cursor_name: cursor_name.to_string(),
            order_text: order_text.to_string(),
            order_purpose_text: order_purpose_text.to_string(),
            filter_text: filter_text.to_string(),
            filter_short_text: filter_short_text.to_string(),
            declared_total,
            produced,
            labels,
            advance_total,
            yield_total,
            skip_total,
            stop_total,
        }
    }

    /// 游标名。
    pub fn cursor_name(&self) -> &str {
        self.cursor_name.as_str()
    }

    /// 走法中文名。
    pub fn order_text(&self) -> &str {
        self.order_text.as_str()
    }

    /// ★ 走法说明(为谁服务)。
    pub fn order_purpose_text(&self) -> &str {
        self.order_purpose_text.as_str()
    }

    /// 筛选说明。
    pub fn filter_text(&self) -> &str {
        self.filter_text.as_str()
    }

    /// ★ 筛选短名(给窄列用)。
    pub fn filter_short_text(&self) -> &str {
        self.filter_short_text.as_str()
    }

    /// 声明的步数上限。
    pub const fn declared_total(&self) -> usize {
        self.declared_total
    }

    /// 产出清单。
    pub fn produced(&self) -> &[CountedBatch] {
        self.produced.as_slice()
    }

    /// 产出标签。
    pub fn labels(&self) -> &[String] {
        self.labels.as_slice()
    }

    /// 推进次数。
    pub const fn advance_total(&self) -> usize {
        self.advance_total
    }

    /// 产出次数。
    pub const fn yield_total(&self) -> usize {
        self.yield_total
    }

    /// ★ 跳过次数。
    pub const fn skip_total(&self) -> usize {
        self.skip_total
    }

    /// 终止次数。
    pub const fn stop_total(&self) -> usize {
        self.stop_total
    }

    /// ★ 是否全量(一步没跳过)。
    pub const fn is_full_pass(&self) -> bool {
        self.skip_total == 0
    }

    /// ★ 是否空手而归(走了但一件没产出)。
    pub const fn is_empty_run(&self) -> bool {
        self.advance_total > 0 && self.yield_total == 0
    }

    /// ★ 实际盘到的件数(**结论说算才算**)。
    pub fn counted_total(&self) -> usize {
        self.produced.iter().filter(|batch| batch.is_counted()).count()
    }

    /// 需要后续跟进的件数。
    pub fn follow_up_total(&self) -> usize {
        self.produced
            .iter()
            .filter(|batch| batch.verdict().needs_follow_up())
            .count()
    }

    /// 差异重量合计(毫克,**带符号**)。
    pub fn variance_weight_milligrams_total(&self) -> i64 {
        self.produced
            .iter()
            .map(|batch| batch.variance_weight_milligrams())
            .sum()
    }
}

/// 一次盘点会话。
#[derive(Clone, Debug)]
pub struct StocktakeSession {
    /// 盘点月份。
    month: MonthKey,
    /// 被盘的台账。
    ledger: InventoryLedger,
    /// 逐游标的行走结果。
    runs: Vec<CursorRun>,
    /// ★ 台账快照(**全部行走结束后取的**)。
    snapshot: LedgerSnapshot,
}

impl StocktakeSession {
    /// ★ 跑一批**正常游标**。
    ///
    /// # 参数
    /// - `month`:盘点月份。
    /// - `ledger`:被盘的台账。
    /// - `cursors`:游标清单(**每个游标持有自己的位置**)。
    /// - `cursor_ledger`:本次行走要写进的台账。
    pub fn run(
        month: MonthKey,
        ledger: InventoryLedger,
        cursors: Vec<BatchCursor>,
        cursor_ledger: &CursorLedger,
    ) -> Self {
        let mut summaries: Vec<WalkSummary> = Vec::new();
        for mut cursor in cursors {
            let cursor_name: String = cursor.name().to_string();
            let order_text: String = cursor.order().chinese_text().to_string();
            let order_purpose_text: String = cursor.order().description_text().to_string();
            let filter_text: String = cursor.filter().description_text();
            let filter_short_text: String = cursor.filter().chinese_text().to_string();
            let declared_total: usize = cursor.ordered_total();
            // ★ 唯一的调用:推进到终止。调用方不认识筛选,也不认识排序。
            let walked: Vec<StockBatch> = cursor.drive(cursor_ledger);
            summaries.push(WalkSummary {
                cursor_name,
                order_text,
                order_purpose_text,
                filter_text,
                filter_short_text,
                declared_total,
                walked,
            });
        }
        finish_session(month, ledger, summaries, cursor_ledger)
    }

    /// ★ 跑一批**共享位置**的游标(对照组)。
    ///
    /// 与 [`Self::run`] 的差别**只有**「位置在谁身上」:
    /// 走法、台账、筛选(全量)全部相同,
    /// 于是两条路的产出之差就是「共享位置」这一件事的代价。
    pub fn run_shared_group(
        month: MonthKey,
        ledger: InventoryLedger,
        cursors: Vec<SharedPositionCursor>,
        cursor_ledger: &CursorLedger,
    ) -> Self {
        let mut summaries: Vec<WalkSummary> = Vec::new();
        for mut cursor in cursors {
            let cursor_name: String = cursor.name().to_string();
            let declared_total: usize = cursor.ordered_total();
            let walked: Vec<StockBatch> = cursor.drive(cursor_ledger);
            summaries.push(WalkSummary {
                cursor_name,
                order_text: "库位动线".to_string(),
                order_purpose_text: "与对照组同一个走法(变量只有一个:位置在谁身上)".to_string(),
                filter_text: "全量".to_string(),
                filter_short_text: "全量".to_string(),
                declared_total,
                walked,
            });
        }
        finish_session(month, ledger, summaries, cursor_ledger)
    }

    /// 盘点月份。
    pub const fn month(&self) -> MonthKey {
        self.month
    }

    /// 被盘的台账。
    pub fn ledger(&self) -> &InventoryLedger {
        &self.ledger
    }

    /// 逐游标的行走结果。
    pub fn runs(&self) -> &[CursorRun] {
        self.runs.as_slice()
    }

    /// 台账快照。
    pub fn snapshot(&self) -> &LedgerSnapshot {
        &self.snapshot
    }

    /// 按名字取一次行走。
    pub fn run_by_name(&self, cursor_name: &str) -> Option<&CursorRun> {
        self.runs.iter().find(|run| run.cursor_name() == cursor_name)
    }

    /// 产出件数合计。
    pub fn produced_total(&self) -> usize {
        self.runs.iter().map(|run| run.yield_total()).sum()
    }

    /// 推进次数合计。
    pub fn advance_total(&self) -> usize {
        self.runs.iter().map(|run| run.advance_total()).sum()
    }

    /// ★ 跳过次数合计。
    pub fn skip_total(&self) -> usize {
        self.runs.iter().map(|run| run.skip_total()).sum()
    }

    /// ★ 实际盘到的件数合计(按游标累计,未去重)。
    pub fn counted_total(&self) -> usize {
        self.runs.iter().map(|run| run.counted_total()).sum()
    }

    /// ★ 覆盖到的批次指纹(**并集,已排序**)。
    ///
    /// ⚠️ 必须排序:并集是按「产出顺序」收集来的,
    /// 顺序随走法变化;排序后「覆盖了哪些」才是一个可比对的问题。
    /// ⚠️ 不用 `HashSet`:迭代顺序不确定会破坏「两次运行逐字节一致」。
    pub fn covered_fingerprints(&self) -> Vec<String> {
        let mut covered: Vec<String> = Vec::new();
        for run in self.runs.iter() {
            for label in run.labels().iter() {
                if !covered.iter().any(|existing| existing == label) {
                    covered.push(label.clone());
                }
            }
        }
        covered.sort();
        covered
    }

    /// 覆盖到的批次数。
    pub fn covered_total(&self) -> usize {
        self.covered_fingerprints().len()
    }

    /// ★ 各结论的件数(**按 `StocktakeVerdict::all()` 的顺序**)。
    pub fn verdict_counts(&self) -> Vec<(StocktakeVerdict, usize)> {
        StocktakeVerdict::all()
            .iter()
            .map(|verdict| {
                let total: usize = self
                    .runs
                    .iter()
                    .flat_map(|run| run.produced().iter())
                    .filter(|batch| batch.verdict() == *verdict)
                    .count();
                (*verdict, total)
            })
            .collect()
    }

    /// ★ 审计观测(**可手工组装的那一类**)。
    ///
    /// 声明集合取台账的**排序指纹**:
    /// 于是「凭空多出一件」与「换个顺序」是两件能分开看的事。
    pub fn observations(&self) -> Vec<WalkObservation> {
        let allowed: Vec<String> = self.ledger.sorted_fingerprints();
        self.runs
            .iter()
            .map(|run| {
                WalkObservation::new(
                    run.cursor_name(),
                    run.labels().to_vec(),
                    allowed.clone(),
                    run.declared_total(),
                    run.advance_total(),
                    run.yield_total(),
                    run.skip_total(),
                    run.stop_total(),
                )
            })
            .collect()
    }
}

/// ★ 收尾:把「产出清单」翻成「行走结果」,并取台账快照。
///
/// # 参数
/// - `month`:盘点月份。
/// - `ledger`:被盘的台账。
/// - `summaries`:逐游标的中间汇总。
/// - `cursor_ledger`:本次行走写的台账。
///
/// ⚠️ 快照在**这里**取,也就是全部行走都跑完之后(见模块文档)。
fn finish_session(
    month: MonthKey,
    ledger: InventoryLedger,
    summaries: Vec<WalkSummary>,
    cursor_ledger: &CursorLedger,
) -> StocktakeSession {
    let snapshot: LedgerSnapshot = cursor_ledger.snapshot();
    let runs: Vec<CursorRun> = summaries
        .into_iter()
        .map(|summary| {
            let counted: Vec<CountedBatch> = summary
                .walked
                .iter()
                .map(|batch| count_batch(batch, &month))
                .collect();
            let labels: Vec<String> = summary
                .walked
                .iter()
                .map(|batch| batch.fingerprint_text())
                .collect();
            // 步数从快照里按名字取:计数只有一处来源,不在这里另算一遍。
            let (advance_total, yield_total, skip_total, stop_total) = match snapshot
                .records()
                .iter()
                .find(|record| record.name() == summary.cursor_name)
            {
                Some(record) => (
                    record.advance_total(),
                    record.yield_total(),
                    record.skip_total(),
                    record.stop_total(),
                ),
                None => panic!(
                    "台账里没有游标「{}」的记账行:驱动与记账没对上",
                    summary.cursor_name
                ),
            };
            CursorRun::new(
                summary.cursor_name.as_str(),
                summary.order_text.as_str(),
                summary.order_purpose_text.as_str(),
                summary.filter_text.as_str(),
                summary.filter_short_text.as_str(),
                summary.declared_total,
                counted,
                labels,
                advance_total,
                yield_total,
                skip_total,
                stop_total,
            )
        })
        .collect();
    StocktakeSession {
        month,
        ledger,
        runs,
        snapshot,
    }
}
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : walk_order.rs
//! 走法:**按什么顺序走过这份台账**。
//!
//! ## ★ 顺序是业务知识,不是排版
//!
//! 「先盘金库还是先盘展柜」是盘点动线决定的,
//! 换一种走法不改变**盘到哪些批次**(集合不变),
//! 只改变**先看到哪个**。
//!
//! 这句话是本工程最容易被写错的地方:
//! 若把走法写死在遍历代码里(`for` 循环 + 手写排序),
//! 那么「换一种走法」就意味着**再写一遍遍历**------
//! 而第二遍遍历很容易顺手改掉一个筛选条件,
//! 于是「换个顺序」悄悄变成了「换了个范围」,
//! 报表上两个数字都对不上,却看不出是哪一处改的。
//! 把走法做成**数据**(一个枚举值),换走法就只是换一个值。
//!
//! ## ⚠️ 为什么全量走法有两个
//!
//! [`WalkOrder::LedgerOrder`] 与 [`WalkOrder::ReverseLedger`] 产出的**集合完全相同**,
//! 只差顺序。它们存在的唯一目的就是把上面那句话变成可核对的数字:
//! 幕九会比对两者------集合必须相同、顺序必须不同。

use crate::domain::{MonthKey, StockBatch};

/// 一种走法。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WalkOrder {
    /// 台账原序(**声明顺序**,不排序)。
    LedgerOrder,
    /// 库位动线(区 → 排 → 层 → 位,盘点员的实际走法)。
    LocationRoute,
    /// 金额降序(**高值优先**,总部抽查用)。
    ValueDescending,
    /// 库龄降序(**最早入库的先盘**,先进先出口径)。
    AgedFirst,
    /// 台账倒序(从最后一件往回盘,交叉复核用)。
    ReverseLedger,
}

impl WalkOrder {
    /// 机器编码。
    pub const fn code(&self) -> &'static str {
        match self {
            WalkOrder::LedgerOrder => "LEDGER",
            WalkOrder::LocationRoute => "LOCATION",
            WalkOrder::ValueDescending => "VALUE_DESC",
            WalkOrder::AgedFirst => "AGED_FIRST",
            WalkOrder::ReverseLedger => "REVERSE",
        }
    }

    /// 中文名。
    pub const fn chinese_text(&self) -> &'static str {
        match self {
            WalkOrder::LedgerOrder => "台账原序",
            WalkOrder::LocationRoute => "库位动线",
            WalkOrder::ValueDescending => "金额降序",
            WalkOrder::AgedFirst => "库龄降序",
            WalkOrder::ReverseLedger => "台账倒序",
        }
    }

    /// ★ 说明:这个走法**为谁服务**。
    ///
    /// ⚠️ 写「为谁服务」而不是「怎么排序」:
    /// 怎么排序在 [`Self::sort_key_of`] 里已经写了一遍,
    /// 抄第二遍的结果是两处可以各改各的。
    pub const fn description_text(&self) -> &'static str {
        match self {
            WalkOrder::LedgerOrder => "账面顺序,用于对账(与台账逐行核对)",
            WalkOrder::LocationRoute => "盘点员货架动线,一趟走完不折返",
            WalkOrder::ValueDescending => "高值优先,抽查时先锁住风险最大的",
            WalkOrder::AgedFirst => "库龄最久先盘,先进先出核销口径",
            WalkOrder::ReverseLedger => "从末尾往回盘,交叉复核防漏盘",
        }
    }

    /// ★ 排序键(**降序靠取负实现**,不要用 `reverse`)。
    ///
    /// ⚠️ 为什么降序用取负而不是 `sort_by(|a,b| b.cmp(a))`:
    /// 取负把「降序」表达成「键更小者更先」,
    /// 于是**所有**走法共用同一个比较方向,
    /// 「谁先谁后」只有这一个地方说了算。
    ///
    /// # 参数
    /// - `batch`:待排序的批次。
    /// - `current_month`:当前盘点月份(库龄口径需要它)。
    pub fn sort_key_of(&self, batch: &StockBatch, current_month: &MonthKey) -> i128 {
        match self {
            WalkOrder::LedgerOrder | WalkOrder::ReverseLedger => 0,
            WalkOrder::LocationRoute => i128::from(batch.location().sort_key()),
            WalkOrder::ValueDescending => -i128::from(batch.valued_amount().minor_units()),
            WalkOrder::AgedFirst => -i128::from(batch.months_in_stock(current_month)),
        }
    }

    /// ★ 按这个走法重排批次清单。
    ///
    /// # 参数
    /// - `batches`:台账里的批次(**声明顺序**)。
    /// - `current_month`:当前盘点月份。
    ///
    /// ## 为什么并列时按指纹排序
    ///
    /// `sort_by_key` 是**稳定**的,并列时会保留入参顺序------
    /// 而入参顺序是「台账声明顺序」,它是可能被改的。
    /// 显式按指纹作第二键,并列时的先后就有了唯一定义,
    /// 两次运行才逐字节一致。
    pub fn order_batches(&self, batches: &[StockBatch], current_month: &MonthKey) -> Vec<StockBatch> {
        match self {
            WalkOrder::LedgerOrder => batches.to_vec(),
            WalkOrder::ReverseLedger => {
                let mut reversed: Vec<StockBatch> = batches.to_vec();
                reversed.reverse();
                reversed
            }
            WalkOrder::LocationRoute | WalkOrder::ValueDescending | WalkOrder::AgedFirst => {
                let mut ordered: Vec<StockBatch> = batches.to_vec();
                ordered.sort_by_key(|batch| {
                    (self.sort_key_of(batch, current_month), batch.fingerprint_text())
                });
                ordered
            }
        }
    }

    /// 全部走法(**顺序即展示顺序**)。
    pub const fn all() -> [WalkOrder; 5] {
        [
            WalkOrder::LedgerOrder,
            WalkOrder::LocationRoute,
            WalkOrder::ValueDescending,
            WalkOrder::AgedFirst,
            WalkOrder::ReverseLedger,
        ]
    }
}

调用:

rust 复制代码
//!# encoding: utf-8 
//!# 版权所有  2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:迭代器模式Iterator Pattern 行为模式 Behavioral 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/16 19:53 
//!# User      :  geovindu
//!# Product   : RustRover
//!# Project   : iteratorpattern
//!# File      : main.rs
//! # 迭代器模式(Iterator Pattern)------ 连锁珠宝在库批次月末盘点游标
//!
//! ## 业务域
//!
//! 一份**在库台账**(12 个货品批次),月末盘点要用**五种走法**分别走一遍:
//! 盘点动线、高值抽查、复检专查、等距抽盘、反向复核。
//! 五种走法**同时**开在同一份台账上,各走各的、互不干扰。
//!
//! ## ★ 这个模式解决的到底是什么
//!
//! 不是「怎么一个个取元素」------那是 `for` 循环。
//! 它解决的是三件 `for` 循环解决不了的事:
//!
//! 1. **集合不记得走到哪了**。
//!    位置属于**一次行走**,不属于被走的东西。
//!    幕九用「共享位置」的反例把这个代价变成一个数字。
//! 2. **换一种走法不动遍历代码**。
//!    走法与筛选都是**数据**(一个枚举值),组合是装配出来的。
//! 3. **「跳过了几步」能数出来**。
//!    筛选收进游标之后,被跳过的那一步返回 `CursorStep::Skip` 并留下记录;
//!    写在 `for` 循环体里的筛选不留下任何痕迹。
//!
//! ## 严格分层结构
//!
//! ```text
//! main                     装配点:造数据、装游标、驱动、编排十五幕、工程外扩展区
//!  └── app                 表示层:表格引擎 + 十三类报表(**不认识 iterator / client**)
//!       └── analysis       分析层:覆盖 / 走法对照 / 独立性对照 / 审计视图 / 体检
//!            ├── client    调用层:走法 + 筛选 + 游标 + 盘点会话
//!            │    ├── domain    领域层:金额 / 月份 / 门店 / 品类 / 库位 / 批次 / 结论
//!            │    │    └── support  支撑层:日期 / 确定性编码 / 显示宽度
//!            │    └── iterator  ★ 机制层:游标契约 + 步态 + 台账 + 审计(**零 crate::**)
//!            └── iterator     机制层(分析层直接读它的台账与审计结果)
//! ```
//!
//! ## 依赖方向(**实测**,由 `scripts/check_layer_dependencies.sh` 得出,非设计意图)
//!
//! ```text
//! analysis -> client domain iterator
//! app      -> analysis domain support
//! client   -> domain iterator support
//! domain   -> support
//! iterator -> none          ★ 与 support 同秩:机制层零 crate::
//! support  -> none
//! ```
//!
//! 无环;推断出的秩次(自顶向下):`app > analysis > client > domain > {iterator, support}`。
//! `iterator` 与 `support` 同秩且互不依赖,因此二者之间的先后不构成约束------
//! 这正是「机制层可以整体复制到另一个工程」的可检验形式。
//!
//! ## 口径一览表
//!
//! | 量 | 单位 | 说明 |
//! |---|---|---|
//! | 金额 | 整数分 | 全程不出现浮点 |
//! | 重量 | 整数毫克 | 显示时才转克 |
//! | 比率 | 整数万分点 | `10000` = 1 倍;百分数由 `percent_text` 换算 |
//! | 时长 | 整数月 | 用 `MonthKey` |
//! | 除法 | 唯一 `divide_rounded` | 远离零方向四舍五入 |

mod analysis;
mod app;
mod client;
mod domain;
mod iterator;
mod support;

use crate::analysis::{
    audit_check_views, compare_independence, compare_walks, coverage_checks, coverage_of,
    independence_checks, independence_rows, quantify, run_views, value_checks, walk_checks,
    walk_diff_rows, CheckLine, CheckReport, CursorAttemptView, IterationValueReport,
    LedgerSnapshotView,
};
use crate::app::{
    bool_text, byte_text, gram_display_text, render_audit_falsifiable_table, render_audit_table,
    render_batch_table, render_check_falsifiable_table, render_check_table, render_cursor_table,
    render_extension_table, render_independence_table, render_interface_table,
    render_ledger_snapshot_table, render_pair_table, render_run_table, render_store_table,
    render_verdict_table, render_walk_diff_table, render_zone_table, skip_share_text,
    DOCUMENT_WIDTH,
};
use crate::client::{
    build_cursor, build_cursor_by_name, build_cursors, build_shared_group, builtin_cursors,
    count_batch, counted_weight_milligrams, verdict_for, weight_drift_basis_points, BatchCursor,
    BatchFilter, CursorRun, CursorSpec, SharedPositionCursor, StocktakeSession, WalkOrder,
    BUILTIN_CURSOR_SPECS, HIGH_VALUE_THRESHOLD_MINOR_UNITS, RECHECK_AFTER_MONTHS, SAMPLING_STRIDE,
};
use crate::domain::{
    divide_rounded, ratio_basis_points, scale_rounded, BinLocation, CategoryCode, CountedBatch,
    CurrencyCode, InventoryLedger, Money, MonthKey, StockBatch, StocktakeVerdict, StoreCode,
    StorageZone, BUILTIN_CATEGORY_CODES, BUILTIN_CURRENCY_CODES, BUILTIN_STORE_CODES,
    BUILTIN_STORAGE_ZONES, CATEGORY_DIAMOND, CATEGORY_GOLD, CATEGORY_JADE, CNY, HKD, USD,
    ZONE_BACK, ZONE_FRONT, ZONE_REPAIR, ZONE_VAULT,
};
use crate::iterator::{
    audit_cursors, Cursor, CursorAuditRule, CursorLedger, CursorRecord, CursorStep, RuleCheck,
    WalkObservation,
};
use crate::support::{
    days_between, deterministic_code, display_width, elide_text, horizontal_rule, is_leap_year,
    is_wide_character, pad_center, pad_left, pad_right, prefixed_code, report_cell_text,
    self_check_width_model, stable_hash, truncate_to_width, CalendarDate, days_in_month,
};

/// 盘点月份。
const DEMO_MONTH: MonthKey = MonthKey::new(2026, 10);

/// ★ 主游标名(**全量走法**):它产出的集合就是「本次盘点应当覆盖的全部批次」。
const MAIN_CURSOR_NAME: &str = "动线全量";
/// 反向复核游标名(另一个全量走法,幕十拿它与主游标对照)。
const REVERSE_CURSOR_NAME: &str = "反向复核";

/// 打印一幕(**统一出口**:不让 `println!` 散在各处)。
fn emit(lines: Vec<String>) {
    for line in lines.iter() {
        println!("{}", line);
    }
}

/// ★ 演示数据:12 个在库批次(**声明顺序即台账顺序**)。
///
/// ⚠️ 数据刻意满足这几个条件,否则后面的幕会「绿得莫名其妙」:
/// - 四种库区都有,且金库里有高值件(金额降序走法才有东西可排);
/// - 需复检的批次里**有**入库满 24 个月的(待复检结论才出现);
/// - 有一件在维修暂存区(已封存结论才出现);
/// - 高值门槛上下都有批次(高值筛选才不是「全有」或「全无」)。
fn demo_batches() -> Vec<StockBatch> {
    vec![
        StockBatch::new(
            "BATCH-2026-0001",
            crate::domain::STORE_CN_01,
            CATEGORY_GOLD,
            BinLocation::new(ZONE_FRONT, 1, 1, 1),
            MonthKey::new(2026, 8),
            12_500,
            Money::new(8_650_000, CNY),
            false,
        ),
        StockBatch::new(
            "BATCH-2026-0002",
            crate::domain::STORE_CN_01,
            CATEGORY_JADE,
            BinLocation::new(ZONE_FRONT, 1, 2, 3),
            MonthKey::new(2025, 11),
            32_800,
            Money::new(12_800_000, CNY),
            true,
        ),
        StockBatch::new(
            "BATCH-2026-0003",
            crate::domain::STORE_CN_01,
            CATEGORY_DIAMOND,
            BinLocation::new(ZONE_BACK, 2, 1, 2),
            MonthKey::new(2026, 3),
            5_400,
            Money::new(31_200_000, CNY),
            true,
        ),
        StockBatch::new(
            "BATCH-2026-0004",
            crate::domain::STORE_CN_01,
            CATEGORY_GOLD,
            BinLocation::new(ZONE_BACK, 2, 3, 5),
            MonthKey::new(2024, 6),
            21_000,
            Money::new(4_580_000, CNY),
            false,
        ),
        StockBatch::new(
            "BATCH-2026-0005",
            crate::domain::STORE_CN_01,
            CATEGORY_JADE,
            BinLocation::new(ZONE_VAULT, 3, 1, 1),
            MonthKey::new(2023, 9),
            45_200,
            Money::new(68_000_000, CNY),
            true,
        ),
        StockBatch::new(
            "BATCH-2026-0006",
            crate::domain::STORE_CN_01,
            CATEGORY_GOLD,
            BinLocation::new(ZONE_VAULT, 3, 2, 4),
            MonthKey::new(2026, 9),
            9_800,
            Money::new(2_360_000, CNY),
            false,
        ),
        StockBatch::new(
            "BATCH-2026-0007",
            crate::domain::STORE_HK_01,
            CATEGORY_DIAMOND,
            BinLocation::new(ZONE_FRONT, 1, 1, 4),
            MonthKey::new(2026, 7),
            6_700,
            Money::new(15_800_000, CNY),
            true,
        ),
        StockBatch::new(
            "BATCH-2026-0008",
            crate::domain::STORE_HK_01,
            CATEGORY_GOLD,
            BinLocation::new(ZONE_FRONT, 1, 3, 2),
            MonthKey::new(2026, 1),
            15_600,
            Money::new(5_230_000, CNY),
            false,
        ),
        StockBatch::new(
            "BATCH-2026-0009",
            crate::domain::STORE_HK_01,
            CATEGORY_JADE,
            BinLocation::new(ZONE_BACK, 2, 2, 1),
            MonthKey::new(2025, 5),
            27_400,
            Money::new(9_640_000, CNY),
            true,
        ),
        StockBatch::new(
            "BATCH-2026-0010",
            crate::domain::STORE_HK_01,
            CATEGORY_GOLD,
            BinLocation::new(ZONE_REPAIR, 4, 1, 1),
            MonthKey::new(2026, 6),
            11_200,
            Money::new(1_890_000, CNY),
            false,
        ),
        StockBatch::new(
            "BATCH-2026-0011",
            crate::domain::STORE_HK_01,
            CATEGORY_DIAMOND,
            BinLocation::new(ZONE_REPAIR, 4, 2, 3),
            MonthKey::new(2024, 2),
            8_300,
            Money::new(42_000_000, CNY),
            true,
        ),
        StockBatch::new(
            "BATCH-2026-0012",
            crate::domain::STORE_CN_01,
            CATEGORY_GOLD,
            BinLocation::new(ZONE_BACK, 2, 1, 7),
            MonthKey::new(2026, 10),
            18_900,
            Money::new(3_340_000, CNY),
            false,
        ),
    ]
}

/// 演示台账。
fn demo_ledger() -> InventoryLedger {
    InventoryLedger::new("2026-10 月末在库台账", demo_batches())
}

fn main() {
    let month: MonthKey = DEMO_MONTH;
    let ledger: InventoryLedger = demo_ledger();

    // ---- 主实验:五个游标同时开在同一份台账上 ----
    let cursor_ledger: CursorLedger = CursorLedger::new("主台账");
    let cursors: Vec<BatchCursor> = builtin_cursors(&ledger, &month);
    let session: StocktakeSession =
        StocktakeSession::run(month, ledger.clone(), cursors, &cursor_ledger);

    // ---- 对照组:位置共享出去(**独立台账**,否则污染主实验的计数)----
    let shared_cursor_ledger: CursorLedger = CursorLedger::new("对照组台账");
    let shared_names: Vec<&str> = BUILTIN_CURSOR_SPECS
        .iter()
        .map(|spec| spec.name())
        .collect();
    let shared_cursors: Vec<SharedPositionCursor> =
        build_shared_group(&shared_names, ledger.batches(), WalkOrder::LocationRoute, &month);
    let shared_session: StocktakeSession = StocktakeSession::run_shared_group(
        month,
        ledger.clone(),
        shared_cursors,
        &shared_cursor_ledger,
    );

    // ---- 分析视图 ----
    let run_views_all = run_views(&session);
    let snapshot_view: LedgerSnapshotView =
        LedgerSnapshotView::from_snapshot(session.snapshot(), cursor_ledger.name());
    let value_report: IterationValueReport = quantify(
        &session,
        WalkOrder::all().len(),
        BUILTIN_FILTER_TOTAL,
    );
    let coverage = coverage_of(&session, MAIN_CURSOR_NAME);
    let walk_pair = compare_walks(&session, MAIN_CURSOR_NAME, REVERSE_CURSOR_NAME);
    let diff_rows = walk_diff_rows(&session, MAIN_CURSOR_NAME, REVERSE_CURSOR_NAME);
    let independence_pair = compare_independence(&session, &shared_session);
    let independence_rows_all = independence_rows(&session, &shared_session);
    let audit_views = audit_check_views(&audit_cursors(&session.observations()));

    // ================= 幕一 =================
    emit(render_pair_table(
        "幕一 · 装配总览(一份台账、五种走法、五个游标同时走)",
        &overview_rows(&session, &value_report),
        &[
            "★ 五个游标**同时**开在同一份台账上:集合只有一份,位置有五份。",
            "★ 「跳过」是筛选收进游标之后才存在的数字;写在 for 循环体里的筛选不留下痕迹。",
        ],
    ));

    // ================= 幕二 ~ 幕四 =================
    emit(render_store_table(&BUILTIN_STORE_CODES, &store_batch_counts(&ledger)));
    emit(render_zone_table());
    emit(render_verdict_table());

    // ================= 幕五 =================
    let main_counted: Vec<CountedBatch> = match session.run_by_name(MAIN_CURSOR_NAME) {
        Some(run) => run.produced().to_vec(),
        None => Vec::new(),
    };
    emit(render_batch_table(
        ledger.batches(),
        &main_counted,
        &month,
        RECHECK_AFTER_MONTHS,
    ));

    // ================= 幕六 =================
    emit(render_cursor_table(&run_views_all));

    // ================= 幕七 =================
    emit(render_run_table(&run_views_all));

    // ================= 幕八 =================
    emit(render_audit_table(
        &audit_views,
        "幕八 · 遍历契约审计(正常观测:五个游标各一次行走)",
    ));
    emit(render_audit_falsifiable_table(&audit_views));

    // ================= 幕八(续):负向观测 = 把坏样本真的喂进去 =================
    let negative_views = audit_check_views(&audit_cursors(&build_negative_observations(&session)));
    emit(render_audit_table(
        &negative_views,
        "幕八(续)· 负向观测:六条规则应当**全部变红**(否则规则是恒真的)",
    ));

    // ================= 幕九 =================
    emit(render_independence_table(&independence_rows_all));

    // ================= 幕十 =================
    emit(render_pair_table(
        "幕十 · 走法对照汇总(动线全量 vs 反向复核)",
        &walk_pair_rows(&walk_pair),
        &[
            "★ 集合相同、顺序不同:换走法只改变先看到哪一件。",
            "⚠️ 若「顺序不同的位置数」为 0,这一幕就测不出东西------那时它必须显示为未通过。",
        ],
    ));
    emit(render_walk_diff_table(
        &diff_rows,
        MAIN_CURSOR_NAME,
        REVERSE_CURSOR_NAME,
    ));

    // ================= 幕十一 =================
    emit(render_pair_table(
        "幕十一 · 价值量化(省下的是份数与次数,不是时间)",
        &value_rows(&value_report),
        &[value_report.summary_text().as_str()],
    ));
    emit(render_ledger_snapshot_table(&snapshot_view));

    // ================= 幕十二 =================
    let extended: StocktakeSession = build_extended_session();
    emit(render_extension_table(&extension_rows(&session, &extended)));

    // ================= 幕十三 =================
    emit(render_interface_table(&interface_items(
        &session,
        &shared_session,
        &snapshot_view,
    )));

    // ================= 幕十四 =================
    let report: CheckReport = health_check(
        &session,
        &shared_session,
        &coverage,
        &walk_pair,
        &independence_pair,
        &value_report,
        &negative_views,
        &extended,
    );
    emit(render_check_table(&report));
    emit(render_check_falsifiable_table(&falsifiable_rows()));

    // ⚠️ 先绑定局部变量再取 `&str`,否则 E0716(临时值被借用)。
    let verdict_tail: String = if report.all_passed() {
        "★ 体检全部通过。".to_string()
    } else {
        report.failed_items_text()
    };
    println!("{}", verdict_tail.as_str());
}

/// ★ 内建筛选的种数(**价值量化的分母之一**)。
///
/// ⚠️ 为什么是一个常量而不是数枚举值:
/// `BatchFilter` 是**带数据**的枚举(`HighValue { .. }`),
/// 没有「全部取值」这种列表可数;而「种数」要说的是
/// 「不用迭代器模式时筛选要被写几遍」,是一个口径,不是一次遍历。
const BUILTIN_FILTER_TOTAL: usize = 5;

/// 幕一的行。
fn overview_rows(
    session: &StocktakeSession,
    report: &IterationValueReport,
) -> Vec<(String, String)> {
    let ledger: &InventoryLedger = session.ledger();
    vec![
        ("工程 / 模式".to_string(), "iterator_pattern / 迭代器模式".to_string()),
        ("盘点月份".to_string(), session.month().chinese_text()),
        ("台账名 / 批次数".to_string(), format!("{} / {} 个", ledger.name(), ledger.batch_total())),
        (
            "账面总重量 / 总金额".to_string(),
            format!(
                "{} 克 / {}",
                gram_display_text(ledger.total_weight_milligrams()),
                ledger.total_valued_amount().chinese_text()
            ),
        ),
        ("需复检批次数".to_string(), format!("{} 个", ledger.recheck_total())),
        ("游标数(同时开在同一份台账上)".to_string(), format!("{} 个", session.runs().len())),
        (
            "走法种数 / 筛选种数".to_string(),
            format!("{} / {}", WalkOrder::all().len(), BUILTIN_FILTER_TOTAL),
        ),
        (
            "★ 组合数 / 定义数 / 省下份数".to_string(),
            format!(
                "{} / {} / {}",
                report.combination_total(),
                report.definition_total(),
                report.saved_loop_total()
            ),
        ),
        (
            "推进 / 产出 / ★ 跳过 / 终止".to_string(),
            format!(
                "{} / {} / {} / {}",
                session.advance_total(),
                session.produced_total(),
                session.skip_total(),
                session.snapshot().stop_total()
            ),
        ),
        (
            "★ 跳过占推进".to_string(),
            format!("{}(跳过 {} 步)", skip_share_text(report), report.skip_total()),
        ),
        (
            "覆盖批次数 / 台账批次数".to_string(),
            format!("{} / {}", session.covered_total(), ledger.batch_total()),
        ),
    ]
}

/// 幕二要用的「各门店批次数」。
fn store_batch_counts(ledger: &InventoryLedger) -> Vec<(String, usize)> {
    BUILTIN_STORE_CODES
        .iter()
        .map(|store| {
            let total: usize = ledger
                .batches()
                .iter()
                .filter(|batch| batch.store().code() == store.code())
                .count();
            (store.code().to_string(), total)
        })
        .collect()
}

/// 幕十汇总行。
fn walk_pair_rows(pair: &crate::analysis::WalkPair) -> Vec<(String, String)> {
    vec![
        ("对照双方".to_string(), format!("{} ↔ {}", pair.left_name(), pair.right_name())),
        (
            "★ 产出集合是否相同".to_string(),
            bool_text(pair.same_set()).to_string(),
        ),
        (
            "★ 顺序不同的位置数 / 总件数".to_string(),
            format!("{} / {}", pair.differing_positions(), pair.produced_total()),
        ),
        (
            "两者的推进次数".to_string(),
            format!("{} / {}", pair.left_advance_total(), pair.right_advance_total()),
        ),
        (
            "两者的跳过次数(全量走法应为 0)".to_string(),
            format!("{} / {}", pair.left_skip_total(), pair.right_skip_total()),
        ),
        (
            "★ 两者的实际盘到件数".to_string(),
            format!("{} / {}", pair.left_counted_total(), pair.right_counted_total()),
        ),
    ]
}

/// 幕十一的行。
fn value_rows(report: &IterationValueReport) -> Vec<(String, String)> {
    vec![
        (
            "走法种数 / 筛选种数".to_string(),
            format!("{} / {}", report.walk_order_total(), report.filter_total()),
        ),
        (
            "★ 不用迭代器要写的循环份数(组合数)".to_string(),
            format!(
                "{} × {} = {} 份",
                report.walk_order_total(),
                report.filter_total(),
                report.combination_total()
            ),
        ),
        (
            "★ 用迭代器要写的定义份数".to_string(),
            format!(
                "{} + {} = {} 份",
                report.walk_order_total(),
                report.filter_total(),
                report.definition_total()
            ),
        ),
        (
            "★ 省下的循环份数".to_string(),
            format!("{} 份", report.saved_loop_total()),
        ),
        ("本次实际装配的游标数".to_string(), format!("{} 个", report.cursor_total())),
        (
            "推进 / 产出 / 跳过 / 终止".to_string(),
            format!(
                "{} / {} / {} / {}",
                report.advance_total(),
                report.yield_total(),
                report.skip_total(),
                report.stop_total()
            ),
        ),
        (
            "★ 跳过占推进(不用迭代器时此数不存在)".to_string(),
            format!("{}({} 步)", skip_share_text(report), report.skip_total()),
        ),
        ("实际盘到件数合计".to_string(), format!("{} 件", report.counted_total())),
    ]
}

// ============================================================================
// ★ 工程外扩展区(全程不改任何既有分层文件)
// ============================================================================
//
// 三件事,全部只在 `main.rs` 里发生:
//   1. 新增一个**库区**(`StorageZone` 是开放标签,加一个常量即可);
//   2. 新增一种**筛选判定**的用法(`BatchFilter::ZoneOnly` 配新库区);
//   3. 新增两个**批次** + 一个**游标规格**。
// 统计与报表自动把它们算进去------这就是「完全可扩展」的证据。

/// ★ 扩展:寄售暂存区(动线第 5 段)。
const ZONE_CONSIGNMENT: StorageZone = StorageZone::new("CONSIGN", "寄售暂存", 5);

/// ★ 扩展:只盘寄售暂存区的游标名。
const CONSIGNMENT_CURSOR_NAME: &str = "寄售专查";

/// ★ 扩展后的游标规格表(内建 5 个 + 寄售专查)。
fn extended_specs() -> Vec<CursorSpec> {
    let mut specs: Vec<CursorSpec> = BUILTIN_CURSOR_SPECS.to_vec();
    specs.push(CursorSpec::new(
        CONSIGNMENT_CURSOR_NAME,
        WalkOrder::LocationRoute,
        BatchFilter::ZoneOnly {
            zone_code: ZONE_CONSIGNMENT.code(),
        },
    ));
    specs
}

/// ★ 扩展后的批次清单(内建 12 个 + 寄售区 2 个)。
fn extended_batches() -> Vec<StockBatch> {
    let mut batches: Vec<StockBatch> = demo_batches();
    batches.push(StockBatch::new(
        "BATCH-2026-0013",
        crate::domain::STORE_CN_01,
        CATEGORY_JADE,
        BinLocation::new(ZONE_CONSIGNMENT, 5, 1, 1),
        MonthKey::new(2026, 4),
        16_700,
        Money::new(7_450_000, CNY),
        true,
    ));
    batches.push(StockBatch::new(
        "BATCH-2026-0014",
        crate::domain::STORE_HK_01,
        CATEGORY_GOLD,
        BinLocation::new(ZONE_CONSIGNMENT, 5, 2, 2),
        MonthKey::new(2025, 12),
        22_300,
        Money::new(3_980_000, CNY),
        false,
    ));
    batches
}

/// ★ 跑一遍扩展后的会话(**独立的台账**,不污染主实验的计数)。
fn build_extended_session() -> StocktakeSession {
    let month: MonthKey = DEMO_MONTH;
    let ledger: InventoryLedger = InventoryLedger::new("扩展台账", extended_batches());
    let cursor_ledger: CursorLedger = CursorLedger::new("扩展区台账");
    let cursors = build_cursors(&extended_specs(), &ledger, &month);
    StocktakeSession::run(month, ledger, cursors, &cursor_ledger)
}

/// 幕十二:扩展对照行。
fn extension_rows(session: &StocktakeSession, extended: &StocktakeSession) -> Vec<(String, String, String, String)> {
    let new_run_yield: usize = extended
        .run_by_name(CONSIGNMENT_CURSOR_NAME)
        .map(|run| run.yield_total())
        .unwrap_or(0);
    let extended_coverage = coverage_of(extended, MAIN_CURSOR_NAME);
    vec![
        (
            "库区数(动线段数)".to_string(),
            BUILTIN_STORAGE_ZONES.len().to_string(),
            (BUILTIN_STORAGE_ZONES.len() + 1).to_string(),
            format!("新增「{}」(动线第 {} 段),只是一行常量", ZONE_CONSIGNMENT.chinese_name(), ZONE_CONSIGNMENT.sequence()),
        ),
        (
            "台账批次数".to_string(),
            session.ledger().batch_total().to_string(),
            extended.ledger().batch_total().to_string(),
            "扩展区新增 2 个寄售批次".to_string(),
        ),
        (
            "游标数".to_string(),
            session.runs().len().to_string(),
            extended.runs().len().to_string(),
            "扩展区新增 1 个游标规格,未改任何分层文件".to_string(),
        ),
        (
            "★ 新游标的产出件数(不是死游标)".to_string(),
            "不存在".to_string(),
            new_run_yield.to_string(),
            "新游标真的产出寄售批次;为 0 说明挂错位置".to_string(),
        ),
        (
            "★ 覆盖 / 台账".to_string(),
            format!("{} / {}", session.covered_total(), session.ledger().batch_total()),
            format!("{} / {}", extended.covered_total(), extended.ledger().batch_total()),
            format!("扩展后仍逐项相等:{}", bool_text(extended_coverage.covered_equals_ledger())),
        ),
        (
            "跳过次数合计".to_string(),
            session.skip_total().to_string(),
            extended.skip_total().to_string(),
            "新游标是过滤游标,把跳过总数推高了".to_string(),
        ),
    ]
}

// ============================================================================
// 负向观测:把「已知会失败的输入」真的喂进审计
// ============================================================================

/// ★ 造六个坏样本,一条规则一个。
///
/// ⚠️ 这些观测是**手工组装**的,不是跑出来的------
/// 若只能由正常游标产生,那么「审计」就只是在检查「正常的游标是正常的」,
/// 那是一条恒真的检查。
fn build_negative_observations(session: &StocktakeSession) -> Vec<WalkObservation> {
    let allowed: Vec<String> = session.ledger().sorted_fingerprints();
    let first: String = allowed.first().cloned().unwrap_or_default();
    let second: String = allowed.get(1).cloned().unwrap_or_default();
    vec![
        // `TERMINATES`:走到上限也没终止。
        WalkObservation::new(
            "BAD-TERM",
            vec![first.clone()],
            allowed.clone(),
            12,
            12,
            1,
            11,
            0,
        ),
        // `SINGLE_STOP`:终止了两次。
        WalkObservation::new(
            "BAD-STOP",
            vec![first.clone()],
            allowed.clone(),
            12,
            14,
            1,
            11,
            2,
        ),
        // `STEP_ACCOUNTED`:记了推进却漏记步态,三类相加对不上。
        WalkObservation::new(
            "BAD-STEP",
            vec![first.clone()],
            allowed.clone(),
            12,
            99,
            1,
            11,
            1,
        ),
        // `NO_GHOST`:产出一个不属于本次台账的批次。
        WalkObservation::new(
            "BAD-GHOST",
            vec!["GHOST-BATCH-9999|NONE|GOLD|XXXX-00-00-00|2026-10|1|1|-".to_string()],
            allowed.clone(),
            12,
            2,
            1,
            0,
            1,
        ),
        // `NO_DUPLICATE`:停在同一位置反复产出同一个批次。
        WalkObservation::new(
            "BAD-DUP",
            vec![first.clone(), first.clone()],
            allowed.clone(),
            12,
            3,
            2,
            0,
            1,
        ),
        // `IN_RANGE`:越过后仍然继续推进。
        WalkObservation::new(
            "BAD-RANGE",
            vec![first.clone(), second.clone()],
            allowed.clone(),
            1,
            4,
            2,
            1,
            1,
        ),
    ]
}

// ============================================================================
// 幕十三 · 只读接口清点(★ 告警治理落点:用起来,而不是删掉或 #[allow])
// ============================================================================

/// ★ 把各层的只读接口**真的调用一遍并印出结果**。
///
/// 一个只读接口若在这里被调用并印出结果,它就不可能是「写了没人用」------
/// 也就不可能是「写错了没人发现」。
fn interface_items(
    session: &StocktakeSession,
    shared_session: &StocktakeSession,
    snapshot_view: &LedgerSnapshotView,
) -> Vec<(String, String)> {
    let mut items: Vec<(String, String)> = Vec::new();
    let batch: StockBatch = demo_batches()[4];
    let counted: CountedBatch = count_batch(&batch, &DEMO_MONTH);

    // ---- support ----
    items.push((
        "support::is_wide_character('前') / ('A')".to_string(),
        format!(
            "{} / {}",
            bool_text(is_wide_character('前')),
            bool_text(is_wide_character('A'))
        ),
    ));
    items.push((
        "support::display_width(\"前场展柜-01排\")".to_string(),
        display_width("前场展柜-01排").to_string(),
    ));
    items.push((
        "support::pad_left / pad_right / pad_center(8)".to_string(),
        format!(
            "[{}][{}][{}]",
            pad_left("12", 8),
            pad_right("12", 8),
            pad_center("12", 8)
        ),
    ));
    items.push((
        "support::truncate_to_width(\"abcdefgh\", 5)".to_string(),
        truncate_to_width("abcdefgh", 5),
    ));
    items.push((
        "support::elide_text(\"前场展柜-01排\", 6)".to_string(),
        elide_text("前场展柜-01排", 6),
    ));
    items.push((
        "support::horizontal_rule(10)".to_string(),
        horizontal_rule(10),
    ));
    items.push((
        "support::self_check_width_model() 项数".to_string(),
        format!("{} 项", self_check_width_model().len()),
    ));
    items.push((
        "support::stable_hash(&[\"BATCH-2026-0005\"])".to_string(),
        stable_hash(&["BATCH-2026-0005"]).to_string(),
    ));
    items.push((
        "support::deterministic_code(&[\"a\"], 6)".to_string(),
        deterministic_code(&["a"], 6),
    ));
    items.push((
        "support::prefixed_code(\"WO\", &[\"a\"], 5)".to_string(),
        prefixed_code("WO", &["a"], 5),
    ));
    items.push((
        "support::is_leap_year(2024) / (2026)".to_string(),
        format!("{} / {}", bool_text(is_leap_year(2024)), bool_text(is_leap_year(2026))),
    ));
    items.push((
        "support::days_between(2026-01-01, 2026-12-31)".to_string(),
        days_between(
            CalendarDate::new(2026, 1, 1),
            CalendarDate::new(2026, 12, 31),
        )
        .to_string(),
    ));
    items.push((
        "support::CalendarDate::new(2026,10,13).weekday_chinese_text()".to_string(),
        CalendarDate::new(2026, 10, 13).weekday_chinese_text().to_string(),
    ));
    items.push((
        "support::CalendarDate::report_cell_text(2026-10-13)".to_string(),
        report_cell_text(CalendarDate::new(2026, 10, 13)),
    ));
    items.push((
        "support::CalendarDate::chinese_text() / machine_text()".to_string(),
        format!(
            "{} / {}",
            CalendarDate::new(2026, 10, 13).chinese_text(),
            CalendarDate::new(2026, 10, 13).machine_text()
        ),
    ));

    // ---- domain ----
    items.push((
        "domain::divide_rounded(7, 2)  // 远离零:不是 3".to_string(),
        divide_rounded(7, 2).to_string(),
    ));
    items.push((
        "domain::ratio_basis_points(1, 3)".to_string(),
        ratio_basis_points(1, 3).to_string(),
    ));
    items.push((
        "domain::scale_rounded(1000, 1, 3)".to_string(),
        scale_rounded(1000, 1, 3).to_string(),
    ));
    items.push((
        "domain::Money::new(1234567, CNY).chinese_text()".to_string(),
        Money::new(1_234_567, CNY).chinese_text(),
    ));
    items.push((
        "domain::Money::amount_text() / is_zero()".to_string(),
        format!(
            "{} / {}",
            Money::new(1_234_567, CNY).amount_text(),
            bool_text(Money::zero(CNY).is_zero())
        ),
    ));
    items.push((
        "domain::Money::plus / minus / negate / scale_basis_points".to_string(),
        {
            let money: Money = Money::new(10_000, CNY);
            format!(
                "{} / {} / {} / {}",
                money.plus(Money::new(2_500, CNY)).minor_units(),
                money.minus(Money::new(2_500, CNY)).minor_units(),
                money.negate().minor_units(),
                money.scale_basis_points(5_000).minor_units()
            )
        },
    ));
    items.push((
        "domain::Money::scale_ratio(1, 3) / is_same_as".to_string(),
        format!(
            "{} / {}",
            Money::new(1_000, CNY).scale_ratio(1, 3).minor_units(),
            bool_text(Money::new(1_000, CNY).is_same_as(&Money::new(1_000, CNY)))
        ),
    ));
    items.push((
        "domain::MonthKey::new(2026,10).chinese_text() / machine_text()".to_string(),
        format!("{} / {}", DEMO_MONTH.chinese_text(), DEMO_MONTH.machine_text()),
    ));
    items.push((
        "domain::MonthKey::plus_months(3) / previous() / next()".to_string(),
        format!(
            "{} / {} / {}",
            DEMO_MONTH.plus_months(3).chinese_text(),
            DEMO_MONTH.previous().chinese_text(),
            DEMO_MONTH.next().chinese_text()
        ),
    ));
    items.push((
        "domain::MonthKey::months_since / is_before / days".to_string(),
        format!(
            "{} / {} / {}",
            DEMO_MONTH.months_since(&MonthKey::new(2024, 2)),
            bool_text(MonthKey::new(2026, 1).is_before(&DEMO_MONTH)),
            DEMO_MONTH.days()
        ),
    ));
    items.push((
        "domain::MonthKey::same_month_last_year() / month_index()".to_string(),
        format!(
            "{} / {}",
            DEMO_MONTH.same_month_last_year().chinese_text(),
            DEMO_MONTH.month_index()
        ),
    ));
    items.push((
        "domain::MonthKey::year() / month() / is_valid()".to_string(),
        format!(
            "{} / {} / {}",
            DEMO_MONTH.year(),
            DEMO_MONTH.month(),
            bool_text(DEMO_MONTH.is_valid())
        ),
    ));
    items.push((
        "domain::StoreCode::chinese_name() / BUILTIN_STORE_CODES 数".to_string(),
        format!(
            "{} / {}",
            crate::domain::STORE_CN_01.chinese_name(),
            BUILTIN_STORE_CODES.len()
        ),
    ));
    items.push((
        "domain::CategoryCode::chinese_name() / needs_metal_pricing()".to_string(),
        format!(
            "{} / {} / {}",
            CATEGORY_JADE.chinese_name(),
            bool_text(CATEGORY_GOLD.needs_metal_pricing()),
            BUILTIN_CATEGORY_CODES.len()
        ),
    ));
    items.push((
        "domain::CurrencyCode::code() / BUILTIN_CURRENCY_CODES 数".to_string(),
        format!(
            "{} / {} / {}",
            CNY.code(),
            HKD.code(),
            BUILTIN_CURRENCY_CODES.len()
        ),
    ));
    items.push((
        "domain::CurrencyCode::chinese_name() / USD.code()".to_string(),
        format!("{} / {}", CNY.chinese_name(), USD.code()),
    ));
    items.push((
        "domain::StorageZone::code() / chinese_name() / sequence()".to_string(),
        format!(
            "{} / {} / {} / {}",
            ZONE_VAULT.code(),
            ZONE_VAULT.chinese_name(),
            ZONE_VAULT.sequence(),
            BUILTIN_STORAGE_ZONES.len()
        ),
    ));
    items.push((
        "domain::BinLocation::sort_key() / fingerprint_text()".to_string(),
        format!(
            "{} / {}",
            BinLocation::new(ZONE_BACK, 2, 3, 5).sort_key(),
            BinLocation::new(ZONE_BACK, 2, 3, 5).fingerprint_text()
        ),
    ));
    items.push((
        "domain::BinLocation::chinese_text() / zone() / aisle()".to_string(),
        format!(
            "{} / {} / {}",
            BinLocation::new(ZONE_BACK, 2, 3, 5).chinese_text(),
            BinLocation::new(ZONE_BACK, 2, 3, 5).zone().code(),
            BinLocation::new(ZONE_BACK, 2, 3, 5).aisle()
        ),
    ));
    items.push((
        "domain::StockBatch::batch_number() / store() / category()".to_string(),
        format!(
            "{} / {} / {}",
            batch.batch_number(),
            batch.store().chinese_name(),
            batch.category().chinese_name()
        ),
    ));
    items.push((
        "domain::StockBatch::months_in_stock() / is_high_value()".to_string(),
        format!(
            "{} 个月 / {}",
            batch.months_in_stock(&DEMO_MONTH),
            bool_text(batch.is_high_value(5_000_000))
        ),
    ));
    items.push((
        "domain::StockBatch::weight_milligrams() / valued_amount()".to_string(),
        format!(
            "{} 毫克 / {}",
            batch.weight_milligrams(),
            batch.valued_amount().chinese_text()
        ),
    ));
    items.push((
        "domain::StockBatch::needs_recheck() / received_month()".to_string(),
        format!(
            "{} / {}",
            bool_text(batch.needs_recheck()),
            batch.received_month().chinese_text()
        ),
    ));
    items.push((
        "domain::StockBatch::location().chinese_text()".to_string(),
        batch.location().chinese_text(),
    ));
    items.push((
        "domain::StocktakeVerdict::all() 数 / chinese_text()".to_string(),
        format!("{} / {}", StocktakeVerdict::all().len(), StocktakeVerdict::Over.chinese_text()),
    ));
    items.push((
        "domain::StocktakeVerdict::machine_text() / is_counted()".to_string(),
        format!(
            "{} / {} / {}",
            StocktakeVerdict::PendingRecheck.machine_text(),
            bool_text(StocktakeVerdict::PendingRecheck.is_counted()),
            bool_text(StocktakeVerdict::Matched.needs_follow_up())
        ),
    ));
    items.push((
        "domain::StocktakeVerdict::has_variance()".to_string(),
        format!(
            "{} / {}",
            bool_text(StocktakeVerdict::Over.has_variance()),
            bool_text(StocktakeVerdict::Sealed.has_variance())
        ),
    ));
    items.push((
        "domain::CountedBatch::verdict() / counted_weight_milligrams()".to_string(),
        format!(
            "{} / {} 毫克",
            counted.verdict().chinese_text(),
            counted.counted_weight_milligrams()
        ),
    ));
    items.push((
        "domain::CountedBatch::variance_milligrams/is_counted".to_string(),
        format!(
            "{} 毫克 / {}",
            counted.variance_weight_milligrams(),
            bool_text(counted.is_counted())
        ),
    ));
    items.push((
        "domain::InventoryLedger::batch_total() / recheck_total()".to_string(),
        format!(
            "{} / {}",
            session.ledger().batch_total(),
            session.ledger().recheck_total()
        ),
    ));
    items.push((
        "domain::InventoryLedger::total_weight/total_valued_amount".to_string(),
        format!(
            "{} 毫克 / {}",
            session.ledger().total_weight_milligrams(),
            session.ledger().total_valued_amount().chinese_text()
        ),
    ));
    items.push((
        "domain::InventoryLedger::batch_at/sorted_fingerprints".to_string(),
        format!(
            "{} / {}",
            session
                .ledger()
                .batch_at(0)
                .map(|batch| batch.batch_number())
                .unwrap_or("无"),
            session.ledger().sorted_fingerprints().len()
        ),
    ));

    // ---- iterator(机制层)----
    items.push((
        "iterator::CursorStep::Yield(1).machine_text() / is_yield()".to_string(),
        format!(
            "{} / {}",
            CursorStep::Yield(1).machine_text(),
            bool_text(CursorStep::Yield(1).is_yield())
        ),
    ));
    items.push((
        "iterator::CursorStep::Skip::is_skip() / Stop::is_stop()".to_string(),
        format!(
            "{} / {}",
            bool_text(CursorStep::<i32>::Skip.is_skip()),
            bool_text(CursorStep::<i32>::Stop.is_stop())
        ),
    ));
    items.push((
        "iterator::CursorStep::item()(Yield 取载荷)".to_string(),
        format!("{:?}", CursorStep::Yield(42).item()),
    ));
    items.push((
        "iterator::CursorLedger::name()".to_string(),
        session.snapshot().stop_total().to_string(),
    ));
    items.push((
        "iterator::LedgerSnapshot::advance_total() / yield_total()".to_string(),
        format!(
            "{} / {}",
            session.snapshot().advance_total(),
            session.snapshot().yield_total()
        ),
    ));
    items.push((
        "iterator::LedgerSnapshot::skip_total() / drive_total()".to_string(),
        format!(
            "{} / {}",
            session.snapshot().skip_total(),
            session.snapshot().drive_total()
        ),
    ));
    items.push((
        "iterator::LedgerSnapshot::is_step/is_sum_accounted".to_string(),
        format!(
            "{} / {}",
            bool_text(session.snapshot().is_step_accounted()),
            bool_text(session.snapshot().is_sum_accounted())
        ),
    ));
    items.push((
        "iterator::LedgerSnapshot::is_termination_accounted()".to_string(),
        bool_text(session.snapshot().is_termination_accounted()).to_string(),
    ));
    items.push((
        "iterator::LedgerSnapshot::never_advanced_cursors() 数".to_string(),
        session.snapshot().never_advanced_cursors().len().to_string(),
    ));
    items.push((
        "iterator::LedgerSnapshot::pure_skip_cursors() 数".to_string(),
        session.snapshot().pure_skip_cursors().len().to_string(),
    ));
    items.push((
        "iterator::CursorRecord::name()(首行)".to_string(),
        match session.snapshot().records().first() {
            Some(record) => format!(
                "{}:推进 {}/产出 {}/跳过 {}",
                record.name(),
                record.advance_total(),
                record.yield_total(),
                record.skip_total()
            ),
            None => "无".to_string(),
        },
    ));
    items.push((
        "iterator::CursorRecord::is_pure_skip() / never_advanced()".to_string(),
        match session.snapshot().records().first() {
            Some(record) => format!(
                "{} / {}",
                bool_text(record.is_pure_skip()),
                bool_text(record.never_advanced())
            ),
            None => "无".to_string(),
        },
    ));
    items.push((
        "iterator::CursorRecord::drive/stop/is_step_accounted".to_string(),
        match session.snapshot().records().first() {
            Some(record) => format!(
                "{} / {} / {}",
                record.drive_total(),
                record.stop_total(),
                bool_text(record.is_step_accounted())
            ),
            None => "无".to_string(),
        },
    ));
    items.push((
        "iterator::CursorAuditRule::all() 数".to_string(),
        CursorAuditRule::all().len().to_string(),
    ));
    items.push((
        "iterator::CursorAuditRule(NO_GHOST)::code+chinese".to_string(),
        format!(
            "{} / {} / {}",
            CursorAuditRule::NoGhostItem.code(),
            CursorAuditRule::NoGhostItem.chinese_text(),
            CursorAuditRule::NoGhostItem.chinese_text()
        ),
    ));
    items.push((
        "iterator::CursorAuditRule(IN_RANGE)::known_failing".to_string(),
        CursorAuditRule::WithinDeclaredRange.known_failing_input_text().to_string(),
    ));
    items.push((
        "iterator::WalkObservation::order_text()(首条观测)".to_string(),
        match session.observations().first() {
            Some(observation) => truncate_to_width(observation.order_text().as_str(), 60),
            None => "无".to_string(),
        },
    ));
    items.push((
        "iterator::WalkObservation::is_terminated() / is_within_range()".to_string(),
        match session.observations().first() {
            Some(observation) => format!(
                "{} / {}",
                bool_text(observation.is_terminated()),
                bool_text(observation.is_within_range())
            ),
            None => "无".to_string(),
        },
    ));
    items.push((
        "iterator::WalkObservation::duplicated/missing_labels".to_string(),
        match session.observations().first() {
            Some(observation) => format!(
                "{} / {}",
                observation.duplicated_labels().len(),
                observation.missing_labels().len()
            ),
            None => "无".to_string(),
        },
    ));
    items.push((
        "iterator::WalkObservation::ghost_labels/declared_total".to_string(),
        match session.observations().first() {
            Some(observation) => format!(
                "{} / {}",
                observation.ghost_labels().len(),
                observation.declared_total()
            ),
            None => "无".to_string(),
        },
    ));
    items.push((
        "iterator::WalkObservation::cursor_name() / advance_total()".to_string(),
        match session.observations().first() {
            Some(observation) => format!(
                "{} / {}",
                observation.cursor_name(),
                observation.advance_total()
            ),
            None => "无".to_string(),
        },
    ));
    items.push((
        "iterator::RuleCheck::new(TERMINATES, 正常观测)".to_string(),
        {
            let check: RuleCheck = RuleCheck::new(CursorAuditRule::EveryCursorTerminates, &session.observations());
            format!(
                "{} 合格/{} 适用,通过={}",
                check.conforming_total(),
                check.applicable_total(),
                bool_text(check.passed())
            )
        },
    ));
    items.push((
        "iterator::RuleCheck::violating_items() 数 / violating_text()".to_string(),
        {
            let check: RuleCheck = RuleCheck::new(CursorAuditRule::NoGhostItem, &session.observations());
            format!("{} / {}", check.violating_items().len(), check.violating_text())
        },
    ));
    items.push((
        "iterator::audit_cursors() 结果数".to_string(),
        audit_cursors(&session.observations()).len().to_string(),
    ));

    // ---- client ----
    items.push((
        "client::WalkOrder::all() 数 / chinese_text()".to_string(),
        format!("{} / {}", WalkOrder::all().len(), WalkOrder::LocationRoute.chinese_text()),
    ));
    items.push((
        "client::WalkOrder::code() / description_text()".to_string(),
        format!(
            "{} / {}",
            WalkOrder::AgedFirst.code(),
            WalkOrder::AgedFirst.description_text()
        ),
    ));
    items.push((
        "client::WalkOrder::sort_key_of(批次, 当前月)".to_string(),
        WalkOrder::LocationRoute.sort_key_of(&batch, &DEMO_MONTH).to_string(),
    ));
    items.push((
        "client::WalkOrder::order_batches() 是否保持件数".to_string(),
        format!(
            "{}",
            WalkOrder::ReverseLedger.order_batches(session.ledger().batches(), &DEMO_MONTH).len()
        ),
    ));
    items.push((
        "client::BatchFilter::code() / chinese_text()".to_string(),
        format!("{} / {}", BatchFilter::All.code(), BatchFilter::RecheckOnly.chinese_text()),
    ));
    items.push((
        "client::BatchFilter::description_text()(含算出来的门槛)".to_string(),
        BatchFilter::HighValue { threshold_minor_units: 5_000_000 }.description_text(),
    ));
    items.push((
        "client::BatchFilter::is_all() / accept(批次, 0)".to_string(),
        format!(
            "{} / {}",
            bool_text(BatchFilter::All.is_all()),
            bool_text(BatchFilter::RecheckOnly.accept(&batch, 0))
        ),
    ));
    items.push((
        "client::weight_drift_basis_points(批次)(万分点)".to_string(),
        weight_drift_basis_points(&batch).to_string(),
    ));
    items.push((
        "client::counted_weight_milligrams(批次)".to_string(),
        format!("{} 毫克", counted_weight_milligrams(&batch)),
    ));
    items.push((
        "client::verdict_for(批次, 当前月)".to_string(),
        verdict_for(&batch, &DEMO_MONTH).chinese_text().to_string(),
    ));
    items.push((
        "client::RECHECK_AFTER_MONTHS".to_string(),
        RECHECK_AFTER_MONTHS.to_string(),
    ));
    items.push((
        "client::CursorSpec::name() / order() / filter() 码".to_string(),
        format!(
            "{} / {} / {}",
            BUILTIN_CURSOR_SPECS[0].name(),
            BUILTIN_CURSOR_SPECS[0].order().code(),
            BUILTIN_CURSOR_SPECS[0].filter().code()
        ),
    ));
    items.push((
        "client::BUILTIN_CURSOR_SPECS 数".to_string(),
        BUILTIN_CURSOR_SPECS.len().to_string(),
    ));
    items.push((
        "client::CursorRun::cursor_name/yield/skip".to_string(),
        match session.runs().first() {
            Some(run) => format!(
                "{} / {} / {}",
                run.cursor_name(),
                run.yield_total(),
                run.skip_total()
            ),
            None => "无".to_string(),
        },
    ));
    items.push((
        "client::CursorRun::order_purpose_text()".to_string(),
        match session.runs().first() {
            Some(run) => run.order_purpose_text().to_string(),
            None => "无".to_string(),
        },
    ));
    items.push((
        "client::CursorRun::is_full_pass() / is_empty_run()".to_string(),
        match session.runs().first() {
            Some(run) => format!(
                "{} / {}",
                bool_text(run.is_full_pass()),
                bool_text(run.is_empty_run())
            ),
            None => "无".to_string(),
        },
    ));
    items.push((
        "client::CursorRun::counted_total() / follow_up_total()".to_string(),
        match session.runs().first() {
            Some(run) => format!("{} / {}", run.counted_total(), run.follow_up_total()),
            None => "无".to_string(),
        },
    ));
    items.push((
        "client::CursorRun::variance_weight_milligrams_total()".to_string(),
        match session.runs().first() {
            Some(run) => format!("{} 毫克", run.variance_weight_milligrams_total()),
            None => "无".to_string(),
        },
    ));
    items.push((
        "client::CursorRun::stop_total() / advance_total()".to_string(),
        match session.runs().first() {
            Some(run) => format!("{} / {}", run.stop_total(), run.advance_total()),
            None => "无".to_string(),
        },
    ));
    items.push((
        "client::StocktakeSession::produced_total() / counted_total()".to_string(),
        format!("{} / {}", session.produced_total(), session.counted_total()),
    ));
    items.push((
        "client::StocktakeSession::covered_total/verdict_counts".to_string(),
        format!("{} / {}", session.covered_total(), session.verdict_counts().len()),
    ));
    items.push((
        "client::StocktakeSession::run_by_name(反向复核)".to_string(),
        match session.run_by_name(REVERSE_CURSOR_NAME) {
            Some(run) => format!("产出 {} 件", run.yield_total()),
            None => "无".to_string(),
        },
    ));

    // ---- analysis ----
    items.push((
        "analysis::CursorRunView::description_text()(首行)".to_string(),
        match run_views(session).first() {
            Some(view) => view.description_text(),
            None => "无".to_string(),
        },
    ));
    items.push((
        "analysis::CursorAttemptView::name() / skip_total()(首行)".to_string(),
        match snapshot_view.attempts().first() {
            Some(attempt) => format!("{} / {}", attempt.name(), attempt.skip_total()),
            None => "无".to_string(),
        },
    ));
    items.push((
        "analysis::LedgerSnapshotView::ledger_name() / stop_total()".to_string(),
        format!("{} / {}", snapshot_view.ledger_name(), snapshot_view.stop_total()),
    ));
    items.push((
        "analysis::LedgerSnapshotView::never_advanced/pure_skip".to_string(),
        format!(
            "{} / {}",
            snapshot_view.never_advanced_text(),
            snapshot_view.pure_skip_text()
        ),
    ));
    items.push((
        "analysis::CoverageReport::ledger_total() / covered_total()".to_string(),
        {
            let coverage = coverage_of(session, MAIN_CURSOR_NAME);
            format!("{} / {}", coverage.ledger_total(), coverage.covered_total())
        },
    ));
    items.push((
        "analysis::CheckLine::verdict_text(true) / (false)".to_string(),
        format!(
            "{} / {}",
            crate::analysis::verdict_text(true),
            crate::analysis::verdict_text(false)
        ),
    ));

    // ---- app ----
    items.push((
        "app::DOCUMENT_WIDTH".to_string(),
        DOCUMENT_WIDTH.to_string(),
    ));
    items.push((
        "app::bool_text(true) / byte_text(1234567)".to_string(),
        format!("{} / {}", bool_text(true), byte_text(1_234_567)),
    ));
    items.push((
        "app::gram_display_text(45200) / basis_points_text(9215)".to_string(),
        format!("{} / {}", gram_display_text(45_200), crate::app::basis_points_text(9_215)),
    ));
    items.push((
        "app::percent_text(2769)(万分点 → 百分数)".to_string(),
        crate::app::percent_text(2_769),
    ));
    items.push((
        "app::TableColumn::numeric(\"推进\", 6).align()".to_string(),
        format!("{:?}", crate::app::TableColumn::numeric("推进", 6).align()),
    ));
    items.push((
        "app::TableColumn::elidable(...).is_elidable()".to_string(),
        bool_text(crate::app::TableColumn::new("值", 10).elidable().is_elidable()).to_string(),
    ));
    items.push((
        "app::TableColumn::header() / width()".to_string(),
        {
            let column = crate::app::TableColumn::new("游标名", 12);
            format!("{} / {}", column.header(), column.width())
        },
    ));
    items.push((
        "app::Table::new(...).total_width() / column_count()".to_string(),
        {
            let table = crate::app::Table::new(
                "接口清点",
                vec![
                    crate::app::TableColumn::new("接口", 62),
                    crate::app::TableColumn::new("调用结果", 68),
                ],
            );
            format!("{} / {}", table.total_width(), table.column_count())
        },
    ));
    items.push((
        "app::section_header(\"幕\", 140) 宽度".to_string(),
        display_width(crate::app::section_header("幕", DOCUMENT_WIDTH).as_str()).to_string(),
    ));
    items.push((
        "app::note_lines(\"注解\", 140) 行数".to_string(),
        crate::app::note_lines("注解", DOCUMENT_WIDTH).len().to_string(),
    ));
    items.push((
        "app::table_rule(20)".to_string(),
        crate::app::table_rule(20),
    ));

    // ---- 补漏:把剩余只读接口逐个真调一遍(★ 用起来,而不是删掉)----
    items.push((
        "support::CalendarDate::year() / month() / day()".to_string(),
        {
            let date: CalendarDate = CalendarDate::new(2026, 10, 13);
            format!("{} / {} / {}", date.year(), date.month(), date.day())
        },
    ));
    items.push((
        "support::CalendarDate::is_valid/add_days/add_working_days".to_string(),
        {
            let date: CalendarDate = CalendarDate::new(2026, 10, 13);
            format!(
                "{} / {} / {}",
                bool_text(date.is_valid()),
                date.add_days(30).chinese_text(),
                date.add_working_days(5).chinese_text()
            )
        },
    ));
    items.push((
        "support::CalendarDate::chinese_text_with_weekday/weekday_index".to_string(),
        {
            let date: CalendarDate = CalendarDate::new(2026, 10, 13);
            format!(
                "{} / {} / {}",
                date.chinese_text_with_weekday(),
                date.weekday_index(),
                bool_text(date.is_weekend())
            )
        },
    ));
    items.push((
        "support::days_in_month(2024, 2) / (2026, 2)".to_string(),
        format!("{} / {}", days_in_month(2024, 2), days_in_month(2026, 2)),
    ));
    items.push((
        "domain::MonthKey::is_same(2026-10, 2026-10)".to_string(),
        bool_text(DEMO_MONTH.is_same(&MonthKey::new(2026, 10))).to_string(),
    ));
    items.push((
        "domain::BinLocation::shelf() / slot()".to_string(),
        {
            let location: BinLocation = BinLocation::new(ZONE_VAULT, 3, 2, 4);
            format!("{} 层 / {} 位", location.shelf(), location.slot())
        },
    ));
    items.push((
        "domain::CategoryCode::new(自定义) / StoreCode / CurrencyCode".to_string(),
        {
            let category: CategoryCode = CategoryCode::new("PLATINUM", "铂金饰品", true);
            let store: StoreCode = crate::domain::STORE_HK_01;
            let currency: CurrencyCode = CNY;
            format!(
                "{} / {} / {}",
                category.chinese_name(),
                store.code(),
                currency.chinese_name()
            )
        },
    ));
    items.push((
        "domain::CountedBatch::fingerprint_text()(含结论编码)".to_string(),
        truncate_to_width(counted.fingerprint_text().as_str(), 60),
    ));
    items.push((
        "iterator::WalkObservation::yielded_labels/yield/skip".to_string(),
        match session.observations().first() {
            Some(observation) => format!(
                "{} / {} / {}",
                observation.yielded_labels().len(),
                observation.yield_total(),
                observation.skip_total()
            ),
            None => "无".to_string(),
        },
    ));
    items.push((
        "iterator::CursorRecord 类型(首行记账)".to_string(),
        match session.snapshot().records().first() {
            Some(record) => {
                let typed: &CursorRecord = record;
                format!("{}:跳过 {}", typed.name(), typed.skip_total())
            }
            None => "无".to_string(),
        },
    ));
    items.push((
        "iterator::Cursor::declared_total/position/has_stopped/drain".to_string(),
        {
            let mut probe: BatchCursor =
                build_cursor(&BUILTIN_CURSOR_SPECS[2], session.ledger(), &DEMO_MONTH);
            let declared: usize = probe.declared_total();
            let drained: Vec<StockBatch> = probe.drain();
            format!(
                "{}:声明 {} 步,drain 得 {} 件,位置 {},已终止={}",
                probe.name(),
                declared,
                drained.len(),
                probe.position(),
                bool_text(probe.has_stopped())
            )
        },
    ));
    items.push((
        "client::BatchCursor::description_text()".to_string(),
        {
            let probe: BatchCursor =
                build_cursor(&BUILTIN_CURSOR_SPECS[2], session.ledger(), &DEMO_MONTH);
            probe.description_text()
        },
    ));
    items.push((
        "client::build_cursor_by_name(动线全量)".to_string(),
        {
            let probe: BatchCursor = build_cursor_by_name(
                MAIN_CURSOR_NAME,
                &BUILTIN_CURSOR_SPECS,
                session.ledger(),
                &DEMO_MONTH,
            );
            format!("{}:声明 {} 步", probe.name(), probe.ordered_total())
        },
    ));
    items.push((
        "client::HIGH_VALUE_THRESHOLD_MINOR_UNITS / SAMPLING_STRIDE".to_string(),
        format!(
            "{} 分 / 每 {} 个取 1",
            HIGH_VALUE_THRESHOLD_MINOR_UNITS, SAMPLING_STRIDE
        ),
    ));
    items.push((
        "client::CursorRun 类型(首行走结果)".to_string(),
        match session.runs().first() {
            Some(run) => {
                let typed: &CursorRun = run;
                format!("{}:产出 {}", typed.cursor_name(), typed.yield_total())
            }
            None => "无".to_string(),
        },
    ));
    items.push((
        "analysis::CoverageReport::missing_fingerprints() 数".to_string(),
        format!("{}", coverage_of(session, MAIN_CURSOR_NAME).missing_fingerprints().len()),
    ));
    items.push((
        "analysis::IndependencePair::normal_first_produced_total()".to_string(),
        compare_independence(&session, shared_session)
            .normal_first_produced_total()
            .to_string(),
    ));
    items.push((
        "analysis::CursorRunView::full_pass() / empty_run()(首行)".to_string(),
        match run_views(session).first() {
            Some(view) => format!("{} / {}", bool_text(view.full_pass()), bool_text(view.empty_run())),
            None => "无".to_string(),
        },
    ));
    items.push((
        "analysis::CursorAttemptView::never_advanced/pure_skip".to_string(),
        match snapshot_view.attempts().first() {
            Some(attempt) => {
                let typed: &CursorAttemptView = attempt;
                format!(
                    "{} / {}",
                    bool_text(typed.never_advanced()),
                    bool_text(typed.pure_skip())
                )
            }
            None => "无".to_string(),
        },
    ));
    items.push((
        "app::ColumnAlign 类型(numeric 列的对齐)".to_string(),
        {
            let align: crate::app::ColumnAlign = crate::app::TableColumn::numeric("推进", 6).align();
            format!("{:?}", align)
        },
    ));

    items
}

// ============================================================================
// 幕十四 · 体检(工程自检)
// ============================================================================

/// ★ 版面与口径自检(**含负向测试**)。
fn layout_self_check(report: &mut CheckReport) {
    let width_checks = self_check_width_model();
    let width_passed_total: usize = width_checks.iter().filter(|(_, ok, _)| *ok).count();
    report.push(CheckLine::new(
        "版面:显示宽度模型自检(support 层)",
        &format!("{} 项全部通过", width_checks.len()),
        &format!("{} / {}", width_passed_total, width_checks.len()),
        width_passed_total == width_checks.len(),
    ));
    report.push(CheckLine::new(
        "★ 版面:`─`(U+2500) 不占 2 列(既有工程实测过的坑)",
        "is_wide_character('─') == 否",
        bool_text(is_wide_character('\u{2500}')),
        !is_wide_character('\u{2500}'),
    ));
    report.push(CheckLine::new(
        "版面:CJK 占 2 列(display_width(\"前场展柜\") == 8)",
        "8",
        display_width("前场展柜").to_string().as_str(),
        display_width("前场展柜") == 8,
    ));
    report.push(CheckLine::new(
        "★ 版面:elide_text 真的加了省略号(负向超宽串)",
        "结果与原文不同且宽度 <= 6",
        elide_text("前场展柜-01排", 6).as_str(),
        elide_text("前场展柜-01排", 6) != "前场展柜-01排"
            && display_width(elide_text("前场展柜-01排", 6).as_str()) <= 6,
    ));
    report.push(CheckLine::new(
        "版面:truncate_to_width 后宽度恰好等于指定宽度",
        "display_width == 5",
        display_width(truncate_to_width("abcdefgh", 5).as_str()).to_string().as_str(),
        display_width(truncate_to_width("abcdefgh", 5).as_str()) == 5,
    ));
    report.push(CheckLine::new(
        "版面:接口清点表宽度 <= 文档宽度",
        &format!("62+68+7 = {} <= {}", 62 + 68 + 7, DOCUMENT_WIDTH),
        (crate::app::INTERFACE_NAME_WIDTH + crate::app::INTERFACE_OUTPUT_WIDTH + 7)
            .to_string()
            .as_str(),
        crate::app::INTERFACE_NAME_WIDTH + crate::app::INTERFACE_OUTPUT_WIDTH + 7
            <= DOCUMENT_WIDTH,
    ));
    report.push(CheckLine::new(
        "★ 口径:唯一舍入出口是真「远离零」(负向 7/2)",
        "7/2==3,divide_rounded(7,2)==4",
        &format!("{} vs {}", 7 / 2, divide_rounded(7, 2)),
        7 / 2 != divide_rounded(7, 2),
    ));
    report.push(CheckLine::new(
        "★ 口径:负号对称(scale_rounded 的负号)",
        "-4 == -4",
        &format!(
            "{} vs {}",
            scale_rounded(-7, 1, 2),
            -scale_rounded(7, 1, 2)
        ),
        scale_rounded(-7, 1, 2) == -scale_rounded(7, 1, 2),
    ));
    report.push(CheckLine::new(
        "★ 版面:负毫克克文本非下取整(实测 bug)",
        "\"-0.104\"(非下取整值)",
        gram_display_text(-104).as_str(),
        gram_display_text(-104) == "-0.104",
    ));
    report.push(CheckLine::new(
        "★ 版面:负克文本去符号后与正数同",
        "去符号后 == 正数文本",
        &format!("{} vs {}", &gram_display_text(-104)[1..], gram_display_text(104)),
        gram_display_text(-104)[1..] == gram_display_text(104),
    ));
    report.push(CheckLine::new(
        "★ 版面:带符号克文本两向都补符号",
        "正补 '+',负补 '-',零补空格",
        &format!(
            "[{}][{}][{}]",
            crate::app::signed_gram_display_text(8),
            crate::app::signed_gram_display_text(-16),
            crate::app::signed_gram_display_text(0)
        ),
        crate::app::signed_gram_display_text(8).starts_with('+')
            && crate::app::signed_gram_display_text(-16).starts_with('-')
            && crate::app::signed_gram_display_text(0).starts_with(' '),
    ));
    report.push(CheckLine::new(
        "口径:比率用万分点(ratio_basis_points(1,3) == 3333)",
        "3333",
        ratio_basis_points(1, 3).to_string().as_str(),
        ratio_basis_points(1, 3) == 3333,
    ));
}

/// ★ 全部体检项。
fn health_check(
    session: &StocktakeSession,
    shared_session: &StocktakeSession,
    coverage: &crate::analysis::CoverageReport,
    pair: &crate::analysis::WalkPair,
    independence: &crate::analysis::IndependencePair,
    value_report: &IterationValueReport,
    negative_views: &[crate::analysis::AuditCheckView],
    extended: &StocktakeSession,
) -> CheckReport {
    let mut report: CheckReport = CheckReport::new();
    layout_self_check(&mut report);

    // ---- 台账恒等式 ----
    let snapshot = session.snapshot();
    report.push(CheckLine::new(
        "★ 台账恒等式一:推进 == 产出 + 跳过 + 终止",
        &format!(
            "{} == {} + {} + {}",
            snapshot.advance_total(),
            snapshot.yield_total(),
            snapshot.skip_total(),
            snapshot.stop_total()
        ),
        (snapshot.yield_total() + snapshot.skip_total() + snapshot.stop_total())
            .to_string()
            .as_str(),
        snapshot.is_step_accounted(),
    ));
    report.push(CheckLine::new(
        "★ 台账恒等式二:合计 == 各游标之和",
        "四项都相等",
        bool_text(snapshot.is_sum_accounted()),
        snapshot.is_sum_accounted(),
    ));
    report.push(CheckLine::new(
        "★ 台账恒等式三:每个被驱动过的游标恰好终止一次",
        "终止次数 == 1(全部游标)",
        bool_text(snapshot.is_termination_accounted()),
        snapshot.is_termination_accounted(),
    ));
    report.push(CheckLine::new(
        "★ 没有死游标(一次都没被推进过的游标数为 0)",
        "0 个",
        format!("{} 个", snapshot.never_advanced_cursors().len()).as_str(),
        snapshot.never_advanced_cursors().is_empty(),
    ));

    // ---- 覆盖 ----
    for line in coverage_checks(coverage) {
        report.push(line);
    }
    // ---- 走法对照 ----
    for line in walk_checks(pair) {
        report.push(line);
    }
    // ---- 独立性对照 ----
    for line in independence_checks(independence) {
        report.push(line);
    }
    // ---- 价值量化 ----
    for line in value_checks(value_report) {
        report.push(line);
    }

    // ---- ★ 负向:六条规则在坏样本上必须全部变红 ----
    let negative_failed_total: usize = negative_views.iter().filter(|view| !view.passed()).count();
    report.push(CheckLine::new(
        "★ 可证伪性:六条规则在坏样本上全部未通过",
        "6 / 6 未通过",
        &format!("{} / {} 未通过", negative_failed_total, negative_views.len()),
        negative_failed_total == negative_views.len() && !negative_views.is_empty(),
    ));

    // ---- 扩展 ----
    let new_run_yield: usize = extended
        .run_by_name(CONSIGNMENT_CURSOR_NAME)
        .map(|run| run.yield_total())
        .unwrap_or(0);
    report.push(CheckLine::new(
        "★ 扩展:新游标真的接下了批次(不是死游标)",
        "产出 > 0",
        format!("{} 件", new_run_yield).as_str(),
        new_run_yield > 0,
    ));
    report.push(CheckLine::new(
        "★ 扩展:游标数 +1(且未改任何既有分层文件)",
        &format!("{} → {}", session.runs().len(), session.runs().len() + 1),
        format!("{}", extended.runs().len()).as_str(),
        extended.runs().len() == session.runs().len() + 1,
    ));
    report.push(CheckLine::new(
        "★ 扩展:批次数 +2",
        &format!("{} → {}", session.ledger().batch_total(), session.ledger().batch_total() + 2),
        format!("{}", extended.ledger().batch_total()).as_str(),
        extended.ledger().batch_total() == session.ledger().batch_total() + 2,
    ));
    let extended_coverage = coverage_of(extended, MAIN_CURSOR_NAME);
    report.push(CheckLine::new(
        "★ 扩展:覆盖仍然逐项等于台账全集",
        "覆盖指纹排序后 == 台账指纹排序后",
        bool_text(extended_coverage.covered_equals_ledger()),
        extended_coverage.covered_equals_ledger(),
    ));

    // ---- 数值恒等式(跨口径互证)----
    report.push(CheckLine::new(
        "★ 跨口径互证:跳过次数 == 声明步数合计 − 产出合计",
        &format!(
            "{} == {} − {}",
            session.skip_total(),
            session.runs().iter().map(|run| run.declared_total()).sum::<usize>(),
            session.produced_total()
        ),
        format!(
            "{}",
            session.runs().iter().map(|run| run.declared_total()).sum::<usize>()
                - session.produced_total()
        )
        .as_str(),
        session.skip_total()
            == session.runs().iter().map(|run| run.declared_total()).sum::<usize>()
                - session.produced_total(),
    ));

    // ---- 对照组自身的健全性 ----
    report.push(CheckLine::new(
        "对照组:共享位置会话的游标数与主实验相同",
        &format!("{} == {}", session.runs().len(), shared_session.runs().len()),
        bool_text(session.runs().len() == shared_session.runs().len()),
        session.runs().len() == shared_session.runs().len(),
    ));
    report.push(CheckLine::new(
        "对照组:共享位置会话用的是独立台账",
        "两组快照的推进次数不同",
        &format!(
            "主 {} vs 对照 {}",
            session.snapshot().advance_total(),
            shared_session.snapshot().advance_total()
        ),
        session.snapshot().advance_total() != shared_session.snapshot().advance_total(),
    ));

    report
}

/// ★ 幕十四(续):这些自检凭什么不是恒真的。
fn falsifiable_rows() -> Vec<(String, String)> {
    vec![
        (
            "★ 唯一舍入口径是真「远离零」".to_string(),
            "若数据全部整除,`7/2 != divide_rounded(7,2)` 就永远绿;这里刻意取除不尽的数".to_string(),
        ),
        (
            "★ 六条规则在坏样本上全部变红".to_string(),
            "若某个坏样本造得不对(例如终止次数写成 1),对应规则会绿,这一项立刻变成 5/6".to_string(),
        ),
        (
            "★ 共享位置写法产得更少(差 > 0)".to_string(),
            "若对照组只有一个游标,`差` 就是 0,这一项必然未通过".to_string(),
        ),
        (
            "★ 换走法确实改变了顺序(不同位置数 > 0)".to_string(),
            "若两个走法排出同一顺序(金额与库位都相等),不同位置数为 0,必然未通过".to_string(),
        ),
        (
            "★ 跳过次数 > 0".to_string(),
            "若五个游标全是全量走法,跳过就是 0,这一支根本没被走到".to_string(),
        ),
        (
            "★ 扩展:新游标真的接下了批次".to_string(),
            "若新游标挂在全量走法能接走它之后,产出就是 0(责任链工程踩过同一个坑)".to_string(),
        ),
        (
            "★ 没有死游标".to_string(),
            "若游标装配进来却没被驱动,报表上「0/0/0」与「走了但没产出」长得一样".to_string(),
        ),
        (
            "★ 对照组用独立台账".to_string(),
            "若对照组复用主实验的台账,两组推进次数会混在一起,这条会变成相等(未通过)".to_string(),
        ),
    ]
}

输出:

相关推荐
tianbin9111 小时前
大模型API中转站选型观察:词元无忧API为何值得优先评估
后端
SDWAN_Cheap1 小时前
深度解析什么是CDN加速:从原理到实践的技术拆解
开发语言·cdn加速
鬼手点金1 小时前
模型即 Agent:当模型本身成为产品
开发语言·前端·人工智能·python·机器学习
雨中飘荡的记忆1 小时前
Hooks 怎么用?5 个能直接抄的自动化配置
后端
卷无止境2 小时前
Python 3.14 自由线程:拆掉 GIL 之后,我们到底得到了什么
后端·python
染指11102 小时前
147.Agent-LangGraph归并函数
开发语言·人工智能·python·langchain·agents
Elastic 中国社区官方博客2 小时前
教程:监控 Java 应用
java·开发语言·数据库·elasticsearch·搜索引擎·全文检索
ShineWinsu2 小时前
对于JavaScript:初识JavaScript以及输入输出
java·开发语言·前端·javascript·css·面试·html
小小安于乱2 小时前
linux中java的springboot程序获取IP地址间歇性10秒卡顿问题
java·开发语言