Files
hms/crates/erp-config/src/service/numbering_service.rs
iven a96b065190
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
test(config): 补全字典+编号服务单元测试 — 51 新增
- dictionary_service: 提取 dict_model_to_resp/item_model_to_resp + 7 个映射测试
- numbering_service: 提取 format_number 纯函数 + 5 个格式化测试
- erp-config 测试总数从 27 增至 78
2026-04-30 11:02:36 +08:00

743 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use chrono::{Datelike, NaiveDate, Utc};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, PaginatorTrait,
QueryFilter, Set, Statement, TransactionTrait,
};
use uuid::Uuid;
use crate::dto::{CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp};
use crate::entity::numbering_rule;
use crate::error::{ConfigError, ConfigResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
/// 格式化编号字符串。
///
/// 拼接规则:
/// 1. 以 `prefix` 开头
/// 2. 若 `prefix` 非空,追加 `separator`
/// 3. 若 `date_part` 为 `Some` 且非空,追加 `date_part` + `separator`
/// 4. 追加零填充的 `seq_current`(填充到 `seq_length` 位,最少 1 位)
pub(crate) fn format_number(
prefix: &str,
separator: &str,
date_part: Option<&str>,
seq_current: i64,
seq_length: i32,
) -> String {
let mut result = String::with_capacity(32);
result.push_str(prefix);
if !prefix.is_empty() {
result.push_str(separator);
}
if let Some(dp) = date_part {
if !dp.is_empty() {
result.push_str(dp);
result.push_str(separator);
}
}
let width = (seq_length.max(1)) as usize;
let seq_padded = format!("{:0>width$}", seq_current, width = width);
result.push_str(&seq_padded);
result
}
/// 编号规则 CRUD 服务 -- 创建、查询、更新、软删除编号规则,
/// 以及线程安全地生成编号序列。
pub struct NumberingService;
impl NumberingService {
/// 分页查询编号规则列表。
pub async fn list(
tenant_id: Uuid,
pagination: &Pagination,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<(Vec<NumberingRuleResp>, u64)> {
let paginator = numbering_rule::Entity::find()
.filter(numbering_rule::Column::TenantId.eq(tenant_id))
.filter(numbering_rule::Column::DeletedAt.is_null())
.paginate(db, pagination.limit());
let total = paginator
.num_items()
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
let models = paginator
.fetch_page(page_index)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let resps: Vec<NumberingRuleResp> = models.iter().map(Self::model_to_resp).collect();
Ok((resps, total))
}
/// 创建编号规则。
///
/// 检查 code 在租户内唯一后插入。
pub async fn create(
tenant_id: Uuid,
operator_id: Uuid,
req: &CreateNumberingRuleReq,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<NumberingRuleResp> {
// 检查 code 唯一性
let existing = numbering_rule::Entity::find()
.filter(numbering_rule::Column::TenantId.eq(tenant_id))
.filter(numbering_rule::Column::Code.eq(&req.code))
.filter(numbering_rule::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(ConfigError::DuplicateKey(format!(
"编号规则编码已存在: {}",
req.code
)));
}
let now = Utc::now();
let id = Uuid::now_v7();
let seq_start = req.seq_start.unwrap_or(1);
let model = numbering_rule::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(req.name.clone()),
code: Set(req.code.clone()),
prefix: Set(req.prefix.clone().unwrap_or_default()),
date_format: Set(req.date_format.clone()),
seq_length: Set(req.seq_length.unwrap_or(4)),
seq_start: Set(seq_start),
seq_current: Set(seq_start as i64),
separator: Set(req.separator.clone().unwrap_or_else(|| "-".to_string())),
reset_cycle: Set(req
.reset_cycle
.clone()
.unwrap_or_else(|| "never".to_string())),
last_reset_date: Set(Some(Utc::now().date_naive())),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
model
.insert(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"numbering_rule.created",
tenant_id,
serde_json::json!({ "rule_id": id, "code": req.code }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"numbering_rule.create",
"numbering_rule",
)
.with_resource_id(id),
db,
)
.await;
Ok(NumberingRuleResp {
id,
name: req.name.clone(),
code: req.code.clone(),
prefix: req.prefix.clone().unwrap_or_default(),
date_format: req.date_format.clone(),
seq_length: req.seq_length.unwrap_or(4),
seq_start,
seq_current: seq_start as i64,
separator: req.separator.clone().unwrap_or_else(|| "-".to_string()),
reset_cycle: req
.reset_cycle
.clone()
.unwrap_or_else(|| "never".to_string()),
last_reset_date: Some(Utc::now().date_naive().to_string()),
version: 1,
})
}
/// 更新编号规则的可编辑字段。使用乐观锁校验版本。
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &crate::dto::UpdateNumberingRuleReq,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<NumberingRuleResp> {
let model = numbering_rule::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?;
let next_version =
check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
let mut active: numbering_rule::ActiveModel = model.into();
if let Some(name) = &req.name {
active.name = Set(name.clone());
}
if let Some(prefix) = &req.prefix {
active.prefix = Set(prefix.clone());
}
if let Some(date_format) = &req.date_format {
active.date_format = Set(Some(date_format.clone()));
}
if let Some(seq_length) = req.seq_length {
active.seq_length = Set(seq_length);
}
if let Some(separator) = &req.separator {
active.separator = Set(separator.clone());
}
if let Some(reset_cycle) = &req.reset_cycle {
active.reset_cycle = Set(reset_cycle.clone());
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_version);
let updated = active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"numbering_rule.update",
"numbering_rule",
)
.with_resource_id(id),
db,
)
.await;
Ok(Self::model_to_resp(&updated))
}
/// 软删除编号规则。使用乐观锁校验版本。
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
version: i32,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<()> {
let model = numbering_rule::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?;
let next_version =
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
let mut active: numbering_rule::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_version);
active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"numbering_rule.deleted",
tenant_id,
serde_json::json!({ "rule_id": id }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"numbering_rule.delete",
"numbering_rule",
)
.with_resource_id(id),
db,
)
.await;
Ok(())
}
/// 线程安全地生成编号。
///
/// 使用 PostgreSQL advisory lock 保证并发安全:
/// 1. 在事务内获取 pg_advisory_xact_lock
/// 2. 在同一事务内读取规则、检查重置周期、递增序列、更新数据库
/// 3. 拼接编号字符串返回
pub async fn generate_number(
rule_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<GenerateNumberResp> {
// 先读取规则获取 code用于 advisory lock
let rule = numbering_rule::Entity::find_by_id(rule_id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {rule_id}")))?;
let rule_code = rule.code.clone();
let tenant_id_str = tenant_id.to_string();
// 在同一个事务内获取 advisory lock 并执行编号生成
// pg_advisory_xact_lock 是事务级别的,锁会在事务结束时自动释放
let number = db
.transaction(|txn| {
let rule_code = rule_code.clone();
let tenant_id_str = tenant_id_str.clone();
Box::pin(async move {
// 在事务内获取 advisory lock
txn.execute(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SELECT pg_advisory_xact_lock(abs(hashtext($1)), abs(hashtext($2))::int)",
[rule_code.into(), tenant_id_str.into()],
))
.await
.map_err(|e| ConfigError::Validation(format!("获取编号锁失败: {e}")))?;
// 在同一个事务内执行编号生成
Self::generate_number_in_txn(rule_id, tenant_id, txn).await
})
})
.await?;
Ok(GenerateNumberResp { number })
}
/// 事务内执行编号生成逻辑。
///
/// 检查重置周期,必要时重置序列,然后递增并拼接编号。
async fn generate_number_in_txn<C>(
rule_id: Uuid,
tenant_id: Uuid,
txn: &C,
) -> ConfigResult<String>
where
C: ConnectionTrait,
{
let rule = numbering_rule::Entity::find_by_id(rule_id)
.one(txn)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {rule_id}")))?;
let today = Utc::now().date_naive();
let mut seq_current = rule.seq_current;
// 检查是否需要重置序列
seq_current = Self::maybe_reset_sequence(
seq_current,
rule.seq_start as i64,
&rule.reset_cycle,
rule.last_reset_date,
today,
);
// 递增序列
let next_seq = seq_current + 1;
// 检查序列是否超出 seq_length 能表示的最大值
let max_val = 10i64.pow(rule.seq_length as u32) - 1;
if next_seq > max_val {
return Err(ConfigError::NumberingExhausted(format!(
"编号序列已耗尽,当前序列号 {next_seq} 超出长度 {} 的最大值",
rule.seq_length
)));
}
// 更新数据库中的 seq_current 和 last_reset_date
let mut active: numbering_rule::ActiveModel = rule.clone().into();
active.seq_current = Set(next_seq);
active.last_reset_date = Set(Some(today));
active.updated_at = Set(Utc::now());
active
.update(txn)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
// 拼接编号字符串: {prefix}{separator}{date_part}{separator}{seq_padded}
let date_part = rule.date_format.as_ref().map(|fmt| Utc::now().format(fmt).to_string());
let number = format_number(
&rule.prefix,
&rule.separator,
date_part.as_deref(),
seq_current,
rule.seq_length,
);
Ok(number)
}
/// 根据重置周期判断是否需要重置序列号。
///
/// 如果需要重置,返回 `seq_start`;否则返回原值。
fn maybe_reset_sequence(
seq_current: i64,
seq_start: i64,
reset_cycle: &str,
last_reset_date: Option<NaiveDate>,
today: NaiveDate,
) -> i64 {
let last_reset = match last_reset_date {
Some(d) => d,
None => return seq_start, // 从未重置过,使用 seq_start
};
match reset_cycle {
"daily" => {
if last_reset != today {
seq_start
} else {
seq_current
}
}
"monthly" => {
if last_reset.month() != today.month() || last_reset.year() != today.year() {
seq_start
} else {
seq_current
}
}
"yearly" => {
if last_reset.year() != today.year() {
seq_start
} else {
seq_current
}
}
_ => seq_current, // "never" 或其他值不重置
}
}
/// 将数据库模型转换为响应 DTO。
fn model_to_resp(m: &numbering_rule::Model) -> NumberingRuleResp {
NumberingRuleResp {
id: m.id,
name: m.name.clone(),
code: m.code.clone(),
prefix: m.prefix.clone(),
date_format: m.date_format.clone(),
seq_length: m.seq_length,
seq_start: m.seq_start,
seq_current: m.seq_current,
separator: m.separator.clone(),
reset_cycle: m.reset_cycle.clone(),
last_reset_date: m.last_reset_date.map(|d| d.to_string()),
version: m.version,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
/// 辅助:构造 NaiveDate
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
// ---- maybe_reset_sequence 测试 ----
#[test]
fn reset_never_keeps_current() {
// "never" 周期:永远不重置,保持 seq_current
let result = NumberingService::maybe_reset_sequence(
100,
1,
"never",
Some(date(2025, 1, 1)),
date(2026, 4, 15),
);
assert_eq!(result, 100);
}
#[test]
fn reset_unknown_cycle_keeps_current() {
// 未知周期值等同于不重置
let result = NumberingService::maybe_reset_sequence(
50,
1,
"weekly",
Some(date(2025, 1, 1)),
date(2026, 4, 15),
);
assert_eq!(result, 50);
}
#[test]
fn reset_daily_same_day_keeps_current() {
// 同一天内不重置
let today = date(2026, 4, 15);
let result = NumberingService::maybe_reset_sequence(42, 1, "daily", Some(today), today);
assert_eq!(result, 42);
}
#[test]
fn reset_daily_different_day_resets() {
// 不同天重置为 seq_start
let result = NumberingService::maybe_reset_sequence(
42,
1,
"daily",
Some(date(2026, 4, 14)),
date(2026, 4, 15),
);
assert_eq!(result, 1);
}
#[test]
fn reset_daily_resets_with_custom_start() {
// 重置时使用自定义 seq_start
let result = NumberingService::maybe_reset_sequence(
99,
10,
"daily",
Some(date(2026, 4, 10)),
date(2026, 4, 15),
);
assert_eq!(result, 10);
}
#[test]
fn reset_monthly_same_month_keeps_current() {
// 同月不重置
let result = NumberingService::maybe_reset_sequence(
30,
1,
"monthly",
Some(date(2026, 4, 1)),
date(2026, 4, 15),
);
assert_eq!(result, 30);
}
#[test]
fn reset_monthly_different_month_resets() {
// 不同月份重置
let result = NumberingService::maybe_reset_sequence(
30,
1,
"monthly",
Some(date(2026, 3, 31)),
date(2026, 4, 1),
);
assert_eq!(result, 1);
}
#[test]
fn reset_monthly_same_month_different_year_resets() {
// 不同年份但相同月份数字,仍然重置
let result = NumberingService::maybe_reset_sequence(
20,
5,
"monthly",
Some(date(2025, 4, 15)),
date(2026, 4, 15),
);
assert_eq!(result, 5);
}
#[test]
fn reset_yearly_same_year_keeps_current() {
// 同年不重置
let result = NumberingService::maybe_reset_sequence(
50,
1,
"yearly",
Some(date(2026, 1, 1)),
date(2026, 12, 31),
);
assert_eq!(result, 50);
}
#[test]
fn reset_yearly_different_year_resets() {
// 不同年份重置
let result = NumberingService::maybe_reset_sequence(
50,
1,
"yearly",
Some(date(2025, 12, 31)),
date(2026, 1, 1),
);
assert_eq!(result, 1);
}
#[test]
fn reset_no_last_reset_date_returns_seq_start() {
// 从未重置过,使用 seq_start
let result = NumberingService::maybe_reset_sequence(999, 1, "daily", None, date(2026, 4, 15));
assert_eq!(result, 1);
}
#[test]
fn reset_no_last_reset_date_uses_custom_start() {
// 从未重置过,使用自定义 seq_start
let result =
NumberingService::maybe_reset_sequence(999, 42, "monthly", None, date(2026, 4, 15));
assert_eq!(result, 42);
}
// ---- model_to_resp 测试 ----
#[test]
fn model_to_resp_maps_fields_correctly() {
let id = Uuid::now_v7();
let tenant_id = Uuid::now_v7();
let now = Utc::now();
let today = now.date_naive();
let model = numbering_rule::Model {
id,
tenant_id,
name: "订单编号".to_string(),
code: "ORDER".to_string(),
prefix: "ORD".to_string(),
date_format: Some("%Y%m%d".to_string()),
seq_length: 6,
seq_start: 1,
seq_current: 42,
separator: "-".to_string(),
reset_cycle: "daily".to_string(),
last_reset_date: Some(today),
created_at: now,
updated_at: now,
created_by: tenant_id,
updated_by: tenant_id,
deleted_at: None,
version: 3,
};
let resp = NumberingService::model_to_resp(&model);
assert_eq!(resp.id, id);
assert_eq!(resp.name, "订单编号");
assert_eq!(resp.code, "ORDER");
assert_eq!(resp.prefix, "ORD");
assert_eq!(resp.date_format, Some("%Y%m%d".to_string()));
assert_eq!(resp.seq_length, 6);
assert_eq!(resp.seq_start, 1);
assert_eq!(resp.seq_current, 42);
assert_eq!(resp.separator, "-");
assert_eq!(resp.reset_cycle, "daily");
assert_eq!(resp.last_reset_date, Some(today.to_string()));
assert_eq!(resp.version, 3);
}
#[test]
fn model_to_resp_none_fields() {
let id = Uuid::now_v7();
let tenant_id = Uuid::now_v7();
let now = Utc::now();
let model = numbering_rule::Model {
id,
tenant_id,
name: "简单编号".to_string(),
code: "SIMPLE".to_string(),
prefix: "".to_string(),
date_format: None,
seq_length: 4,
seq_start: 1,
seq_current: 1,
separator: "-".to_string(),
reset_cycle: "never".to_string(),
last_reset_date: None,
created_at: now,
updated_at: now,
created_by: tenant_id,
updated_by: tenant_id,
deleted_at: None,
version: 1,
};
let resp = NumberingService::model_to_resp(&model);
assert_eq!(resp.date_format, None);
assert_eq!(resp.last_reset_date, None);
assert_eq!(resp.prefix, "");
}
// ---- format_number 测试 ----
#[test]
fn format_basic_prefix_no_date() {
// 基础:前缀 + 序列号
let result = format_number("ORD", "/", None, 1, 5);
assert_eq!(result, "ORD/00001");
}
#[test]
fn format_with_date_part() {
// 前缀 + 日期 + 序列号
let result = format_number("INV", "-", Some("20260430"), 42, 4);
assert_eq!(result, "INV-20260430-0042");
}
#[test]
fn format_no_prefix() {
// 无前缀,直接输出序列号
let result = format_number("", "/", None, 7, 3);
assert_eq!(result, "007");
}
#[test]
fn format_no_prefix_no_date() {
// 无前缀无日期,仅序列号
let result = format_number("", "-", None, 99, 6);
assert_eq!(result, "000099");
}
#[test]
fn format_seq_length_zero_pads_to_one() {
// seq_length=0 时仍至少填充 1 位
let result = format_number("", "", None, 5, 0);
assert_eq!(result, "5");
}
}