// issues/126 案 A · 任务行 expire_time 的**求值器** + 建单写点共用的赋值口。
//
// 本栈修前形状(启动词 §1 / §1.5 普查):全仓**一句**到期表达式求值都没有——建单五处一律不赋
// 这一列,只有回退那支 `expire_time: history.expire_time` 有值,而那也不是"按节点表达式算",
// 是"抄上一行"(上一行本身又从来没算过 ⇒ 恒空)。于是 wf_process_task.expire_time 几乎恒
// NULL ⇒ 逾期统计恒 0。所以本栈="先移植求值器,再接五处写点",不是改四行。
//
// 基准=boot2 内置版(owner 2026-09-28 拍板 A),语义逐字对齐:
// - 求值器:boot2 mldong-wf-core engine/util/FlowUtil.java:92 = jeeflow-java
// jeeflow-core util/FlowUtil.java:64(commit d9e9397)
// - 赋值口:jeeflow-java `ProcessInstance.applyExpireTime`(d9e9397)+ 公开入口
// `applyNodeExpireTime`(cb541d4,给"绕过建单 helper 直建任务行"的第五处用)
// - 回退新建取**当前节点**表达式 + 随行变量:boot2 ProcessTaskServiceImpl.java:384-387
// (`((TaskModel)current).getExpireTime()` + `hisVariable`)
//
// 求值器落在 model 包(不是启动词 §1.5 写的 `core/util/`——本栈没有那个目录):本栈的钟与
// "yyyy-MM-dd HH:mm:ss" 格式化都在 model/clock.mbt(current_time_str / format_unix_utc),
// 这一列的落库形态就是那一串;与它同包才不必把私有的历法换算再抄一遍。
///|
/// 任务节点上配的到期表达式属性键
/// (对齐 boot2 `TaskParser.EXPIRE_TIME_KEY` ← 设计器 JSON 的 `properties.expireTime`)
pub let expire_time_prop : String = "expireTime"
///|
/// 变量档的三种落点。刻意做成三态,因为 §1.5 的两个易错点方向相反:
/// ①「值类型不认识」必须**落穿**到相对/绝对档;②「值是字符串但解析不出」必须**终局 NULL**
/// (Java 的 catch → return null),不许落穿。合并成一个 Option 就会把这两档写成一个行为。
priv enum VarTier {
VtTime(String)
VtUnparsable
VtFallthrough
}
///|
/// 节点到期表达式求值。三档顺序**不可变**:
/// ⓪ expr 为 None / 去空白后为空 ⇒ None(这一列保持 NULL:不写 now()、不写 ''、不写 0)
/// ① args 里存在**键名 == expr 原串**的项 ⇒ 取该项的值:日期串 → 该时刻(解析失败 ⇒ **None,
/// 且是终局**);毫秒整数 → epoch + 该毫秒(秒级 floor);其它类型 ⇒ **落穿**
/// ② 以 s|m|h|d 结尾且前缀是**非负**整数 ⇒ 当前时刻 + N 秒/分/时/天(**d 走日历加天**;
/// 负前缀算「解析不出来」⇒ 落穿,issues/137 D)
/// ③ 否则把 expr 本身按 "yyyy-MM-dd HH:mm:ss" 严格解析 ⇒ 该时刻;失败 ⇒ None
///
/// **任何一档都不许返回"当前时间"**:本案病灶恰是"非空但错"的占位 now()(建单即逾期、
/// expire − create ≈ 0),一旦让 now() 当兜底,L2-27 那格就永远抓不到东西。
///
/// 与 Java 的两处形状差(都不改变"空/非空"判点;其中第 1 条**已不再是差异**):
/// 1. 坏前缀(节点误配成 `xh`/`2.5h`/`3hh`)**落穿 → 绝对档 → None**=**八栈一致**口径
/// (issues/137 C · 启动词 §1.9-3:配置写错不该把流程卡死,也不许退化成取当前时间)。
/// ⚠️ 旧文那句"Java 的 `Integer.parseInt` 会抛异常打断建单、本栈是故意差异"**已过期**:
/// java 参考实现已改成同款落穿(jeeflow-java commit `6bdf41b`,随 1.8.36 发出),本栈不再是差异方。
/// 2. Java SimpleDateFormat 是宽松档(`2031-02-30` 会滚到 3 月、尾部多余字符被忽略);本栈与
/// go/C# 一致按**严格**日历校验 ⇒ NULL。
pub fn process_time(expr : String?, args : @json.FlowData) -> String? {
let e = match expr {
None => ""
Some(s) => s
}
// ⓪ 没配 ⇒ NULL(Java 的 isNotBlank 门;owner 2026-09-28:不造默认值)
if e.trim(chars=" \t\r\n").to_owned() == "" {
return None
}
// ① 变量档(**优先于**相对档:args 里真有个键叫 "2h" 时取的是变量值,不是 now+2h)。
// 键名用 expr **原串**,与 Java `args.containsKey(expireTime)` 同档。
match variable_tier(e, args) {
VtTime(t) => return Some(t)
VtUnparsable => return None
VtFallthrough => ()
}
// ② 相对档
match relative_secs(e) {
Some(secs) => return Some(format_unix_utc(secs))
None => ()
}
// ③ 绝对档
match parse_time_secs(e) {
Some(secs) => Some(format_unix_utc(secs))
None => None
}
}
///|
/// 建单写点共用的赋值口(对齐 java `ProcessInstance.applyExpireTime`):
/// 表达式 None/空白 ⇒ **直接 return**,这一列保持 NULL;配了但算不出 ⇒ 求值给 None,同样留空。
///
/// `args` 变量源两档**不许搞混**(搞混会让"表达式是个变量名"这一档跨栈给出不同答案):
/// - 建单三处(普通 / 串行会签首位 / 并行会签全员)+ 串行推进位=**实例变量**
/// = java `this.variables` = boot2 `execution.getArgs()`
/// - 回退/跳转新建=**随行拷贝那份变量** = boot2 的 `hisVariable`
pub fn apply_expire_time(task : ProcessTask, expr : String?, args : @json.FlowData) -> Unit {
let e = match expr {
None => ""
Some(s) => s
}
if e.trim(chars=" \t\r\n").to_owned() == "" {
return
}
task.expire_time = process_time(expr, args)
}
///|
/// "yyyy-MM-dd HH:mm:ss" **严格**解析 → epoch 秒:长度必须 19、五个分隔位逐位对、六段全数字、
/// 月/日/时/分/秒做日历合法性校验(含闰年 2 月)。与 clock.mbt `format_unix_utc`(UTC 口径)互逆。
/// 任一条不满足 ⇒ None(**不是** now)。
pub fn parse_time_secs(s : String) -> Int64? {
let cs = s.to_array()
if cs.length() != 19 {
return None
}
if !(cs[4] == '-' && cs[7] == '-' && cs[10] == ' ' && cs[13] == ':' && cs[16] == ':') {
return None
}
let y = match digits(cs, 0, 4) {
Some(v) => v
None => return None
}
let mo = match digits(cs, 5, 2) {
Some(v) => v
None => return None
}
let d = match digits(cs, 8, 2) {
Some(v) => v
None => return None
}
let hh = match digits(cs, 11, 2) {
Some(v) => v
None => return None
}
let mi = match digits(cs, 14, 2) {
Some(v) => v
None => return None
}
let ss = match digits(cs, 17, 2) {
Some(v) => v
None => return None
}
if mo < 1 || mo > 12 {
return None
}
if d < 1 || d > days_in_month(y, mo) {
return None
}
if hh > 23 || mi > 59 || ss > 59 {
return None
}
Some(days_from_civil(y, mo, d) * 86400L + (hh * 3600 + mi * 60 + ss).to_int64())
}
///|
/// **日历**加 N 天(对齐 Java `Calendar.add(DAY_OF_MONTH, n)` / go `AddDate(0,0,n)`):
/// 拆出年月日、日字段 += n、再合成 epoch 天。刻意不写 `secs + n*86400`——量纲要与基准同档
/// (启动词 §1:`d` 走日历加天,不乘 86400 秒)。
pub fn add_days(secs : Int64, n : Int) -> Int64 {
let mut days = secs / 86400L
let mut tod = secs % 86400L
if tod < 0L {
tod = tod + 86400L
days = days - 1L
}
let (y, m, d) = civil_from_days(days)
days_from_civil(y, m, d + n) * 86400L + tod
}
///|
/// 变量档取值:三态见 `VarTier`。
/// 没有这个键=Java 的 `!containsKey` ⇒ 直接落穿到相对/绝对档。
fn variable_tier(expr : String, args : @json.FlowData) -> VarTier {
match args.get(expr) {
None => VtFallthrough
Some(v) =>
match v {
Json::String(s) =>
// 字符串档:解析失败即**终局** None(Java `catch (ParseException) return null`)
match normalize_time_str(s) {
Some(t) => VtTime(t)
None => VtUnparsable
}
Json::Number(n, repr~) =>
match millis_of(n, repr) {
Some(secs) => VtTime(format_unix_utc(secs))
None => VtFallthrough
}
// bool / object / array / JSON null ⇒ Java 三个 instanceof 全不命中 ⇒ **落穿**
_ => VtFallthrough
}
}
}
///|
/// 毫秒时间戳档 → epoch 秒(对齐 Java `toLocalDateTime(new Date(l))`;本栈这一列只到秒,
/// 秒级 floor 与 Java 落秒同一量纲,启动词 §1 已把它写进断言带宽)。
/// 原文(repr)优先按整数精确解析(D-M5-3 同款口径,防 Double 取整);
/// 非整数值(Java 侧 Double 不匹配 `instanceof Long`)⇒ None ⇒ 交调用方**落穿**。
fn millis_of(n : Double, repr : String?) -> Int64? {
match repr {
Some(s) =>
match parse_int64_strict(s) {
Some(ms) => Some(div_floor(ms, 1000L))
None => None
}
None =>
if n.is_nan() || n.is_inf() {
None
} else if n != n.trunc() {
None
} else {
Some(div_floor(n.to_int64(), 1000L))
}
}
}
///|
/// 相对档 Ns/Nm/Nh/Nd。后缀不认识、或前缀不是**非负**整数 ⇒ None(交回调用方走绝对档)。
/// 前缀**判整数前先裁两端空白**(issues/137 E · owner 2026-10-01 拍「统一 trim」,基准=jeeflow-java
/// `bf1f401` 的 `FlowUtil.parseIntOrNull`):到期表达式是设计器手填/JSON 搬运的串,夹一个空格是常态,
/// 而各栈整数解析对空白的容忍度天然不同(go `strconv.Atoi` 前显式 `TrimSpace`、rust `.trim()`、
/// .NET `TryParse` 与 python `int()` 默认就收前后空白)⇒ 不裁就是「同一份流程定义这一家没到期时间、
/// 别家有」。裁的边界**只到前缀**,三条分界逐栈一致,落点是下面那处 `.trim(chars=...)`:
/// ① `" 2h"`、`"2 h"`(空白落在前缀区内、末位仍是单位符)⇒ 照样算得出 now+2h;
/// ② `"2h "`(单位符后面还带空白)⇒ 末位不是 `s/m/h/d`、认不出单位 ⇒ 落穿绝对档 ⇒ None;
/// **单位符判定用未 trim 的原串末位**(上面那句 `cs[len - 1]`),别顺手改成整串先去空白
/// 再判末位——那是「整体去空白」,契约里没立过法;
/// ③ `" 2.5h"` ⇒ trim 之后仍是误配(小数)⇒ 仍落穿,裁空白 ≠ 裁容错。
/// 判负(issues/137 D)在 trim **之后**照旧生效:`" -5h"` ⇒ None。变量档的键名与绝对档的时间串
/// 本身**都不做 trim**——那是键名/时间串,裁它会改的是另一件事。
///
/// issues/137 D(owner 2026-10-01 拍「判非负」,基准=jeeflow-java `1649955` 的
/// `FlowUtil.parseIntOrNull`):**负前缀同样算解析不出来** ⇒ 落穿绝对档 ⇒ 仍失败就 NULL。
/// 放行 `-5h` 会算出一个**过去**的时刻 ⇒ 新建行当场即逾期,比「没配到期时间」更难发现,也与
/// 上面那条「不允许退化成取当前时间」(issues/126 病灶)同向。四档共用这一处判负:本栈的
/// `d` 档唯一入口就是下面那个 `add_days`(日历加天,负数=历日倒退,不是乘 86400),
/// 无旁路,故一处裁完 s/m/h/d 四档。只裁负、**不裁加号**。
fn relative_secs(expr : String) -> Int64? {
let cs = expr.to_array()
let len = cs.length()
if len < 2 {
return None
}
let unit = cs[len - 1]
if !(unit == 's' || unit == 'm' || unit == 'h' || unit == 'd') {
return None
}
// issues/137 E:裁空白发生在**判整数之前**,且只裁**前缀**这一段子串;上面那个 `unit` 判定用的
// 是未 trim 的原串末位(分界②靠它)。判负/判形仍归 parse_int_non_negative 那枚单点,这里不掺和。
let n = match parse_int_non_negative(expr[0:len - 1].trim(chars=" \t\r\n").to_owned()) {
Some(v) => v
None => return None
}
let base = expire_now_secs()
if unit == 's' {
Some(base + n.to_int64())
} else if unit == 'm' {
Some(base + (n * 60).to_int64())
} else if unit == 'h' {
Some(base + (n * 3600).to_int64())
} else {
Some(add_days(base, n))
}
}
///|
/// 到期计算的"当前时刻"基准:**必须**走 clock.mbt 那把可注入的钟(`current_time_str()`),
/// 而不是裸读 `@env.now()`——否则测试/宿主注入固定钟时,行上的 create_time 与算出的
/// expire_time 会来自两把钟,"同一行 expire − create ≈ 表达式偏移"那格就无从判起
/// (issues/120 附证在本栈定的口径就是"只有一把钟",见 facade/stats.mbt:182)。
/// 注入串不合 "yyyy-MM-dd HH:mm:ss" 时回退真实钟(宁缺判据不整块报错)。
fn expire_now_secs() -> Int64 {
match parse_time_secs(current_time_str()) {
Some(s) => s
None => epoch_secs()
}
}
///|
/// 日期串档:严格解析后**回格式化**(顺带把 "2030-5-6 ..." 这类非零填充串拒掉,与 go/C# 同档)
fn normalize_time_str(s : String) -> String? {
match parse_time_secs(s) {
Some(secs) => Some(format_unix_utc(secs))
None => None
}
}
///|
/// 定长数字段(任一字符非数字 ⇒ None)
fn digits(cs : Array[Char], from : Int, len : Int) -> Int? {
let mut v = 0
for i in 0.. 57 {
return None
}
v = v * 10 + (code - 48)
}
Some(v)
}
///|
/// 严格整数解析(可带 +/- 号,其余一律非数字 ⇒ None)。
/// 本栈刻意不引 `@string.parse_int`:一是 model 包没导 @string,二是它的容忍面比
/// Java parseInt 宽(前导空白),口径以这里为准。
fn parse_int_strict(s : String) -> Int? {
parse_int_sign(s, false)
}
///|
/// 相对档前缀取整数(逐字对齐 java `FlowUtil.parseIntOrNull`,issues/137 D · owner 2026-10-01
/// 拍「判非负」):非整数(`x`/`2.5`/`3h`)或**负数** ⇒ None,交调用方**落穿**,不抛。
///
/// 为什么不是给 `parse_int_sign` 传 `neg=true`(实读结论,别按字面猜这个参数):`neg` 是
/// 「串首没有显式 +/- 号时**结果的默认符号**」,不是「是否允许负号」——串里一旦出现 `-`/`+`,
/// 它就覆盖你传的值(`negative = cs[0] == '-'`)。本文件两处调用都传 false,却依旧解析得出
/// `-5`,证明负号从来不被这个开关门控。改传参既裁不住负档,还会把无符号串整体变负。
///
/// 只裁负**不裁加号**:各栈整数解析(python `[+-]?`、node `[-+]?\d+`、php `[+-]?\d{1,18}`)都
/// 收 '+',裁掉加号等于新造一处跨栈分叉。`-0` 同 java(`Integer.parseInt("-0") == 0`,不算负、
/// 放行)。
fn parse_int_non_negative(s : String) -> Int? {
match parse_int_strict(s) {
Some(v) => if v < 0 {
None
} else {
Some(v)
}
None => None
}
}
///|
/// 严格 Int64 解析(毫秒档用)
fn parse_int64_strict(s : String) -> Int64? {
parse_int_sign64(s, false)
}
fn parse_int_sign(s : String, neg : Bool) -> Int? {
let cs = s.to_array()
let len = cs.length()
let mut i = 0
let mut negative = neg
if len > 0 && (cs[0] == '-' || cs[0] == '+') {
negative = cs[0] == '-'
i = 1
}
if i >= len {
return None
}
let mut v = 0
while i < len {
let code = cs[i].to_int()
if code < 48 || code > 57 {
return None
}
v = v * 10 + (code - 48)
i = i + 1
}
if negative { Some(-v) } else { Some(v) }
}
fn parse_int_sign64(s : String, neg : Bool) -> Int64? {
let cs = s.to_array()
let len = cs.length()
let mut i = 0
let mut negative = neg
if len > 0 && (cs[0] == '-' || cs[0] == '+') {
negative = cs[0] == '-'
i = 1
}
if i >= len {
return None
}
let mut v = 0L
while i < len {
let code = cs[i].to_int()
if code < 48 || code > 57 {
return None
}
v = v * 10L + (code - 48).to_int64()
i = i + 1
}
if negative { Some(-v) } else { Some(v) }
}
///|
/// 向下取整除(毫秒 → 秒在负 epoch 上也要落对;`/` 是向零截断)
fn div_floor(a : Int64, b : Int64) -> Int64 {
let q = a / b
let r = a % b
if r != 0L && ((r < 0L) != (b < 0L)) { q - 1L } else { q }
}
fn leap_year(y : Int) -> Bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
fn days_in_month(y : Int, m : Int) -> Int {
match m {
1 => 31
2 => if leap_year(y) { 29 } else { 28 }
3 => 31
4 => 30
5 => 31
6 => 30
7 => 31
8 => 31
9 => 30
10 => 31
11 => 30
12 => 31
_ => 0
}
}
///|
/// 历日 → epoch 天:clock.mbt `format_unix_utc` 里那段 civil_from_days 的逆运算
/// (Howard Hinnant)。**d 允许越界**(如 1/32、2/0)——该式对 1..12 的月与越界日天然连续,
/// `add_days` 的"日历加天"就靠这一点,不必自己判月末。
fn days_from_civil(y0 : Int, m : Int, d : Int) -> Int64 {
let y = if m <= 2 { y0 - 1 } else { y0 }
let era = (if y >= 0 { y } else { y - 399 }) / 400
let yoe = y - era * 400
let mp = if m > 2 { m - 3 } else { m + 9 }
let doy = (153 * mp + 2) / 5 + d - 1
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
(era * 146097 + doe).to_int64() - 719468L
}
///|
/// epoch 天 → 历日:与 format_unix_utc 同一算法(只把"格式化"换成"返回三元组")
fn civil_from_days(days : Int64) -> (Int, Int, Int) {
let z = days + 719468L
let era = (if z >= 0L { z } else { z - 146096L }) / 146097L
let doe = (z - era * 146097L).to_int()
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
let yy = yoe + (era * 400L).to_int()
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
let mp = (5 * doy + 2) / 153
let d = doy - (153 * mp + 2) / 5 + 1
let m = if mp < 10 { mp + 3 } else { mp - 9 }
let y = if m <= 2 { yy + 1 } else { yy }
(y, m, d)
}