项目结构:

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(),
),
]
}
输出:
