// stats 3 action(issues/103,C23:全纯列/显式错误/空库全 0)
// 数据源:repo 批量查询(get_all_instances/get_all_tasks)+ facade 聚合(rust 同款架构)
///|
/// "yyyy-MM-dd[ HH:mm[:ss]]" → epoch 秒(civil 算法逆运算)
pub fn parse_epoch_secs(s : String) -> Int64? {
let t = s.trim(chars=" \t\r\n").to_string()
if t.length() < 10 {
return None
}
let y = (try? @string.parse_int(t.substring(start=0, end=4).to_string()))
let mo = (try? @string.parse_int(t.substring(start=5, end=7).to_string()))
let d = (try? @string.parse_int(t.substring(start=8, end=10).to_string()))
let (y, mo, d) = match (y, mo, d) {
(Ok(a), Ok(b), Ok(c)) => (a, b, c)
_ => return None
}
var hh = 0
var mi = 0
var ss = 0
if t.length() >= 19 {
match (try? @string.parse_int(t.substring(start=11, end=13).to_string())) {
Ok(v) => hh = v
Err(_) => ()
}
match (try? @string.parse_int(t.substring(start=14, end=16).to_string())) {
Ok(v) => mi = v
Err(_) => ()
}
match (try? @string.parse_int(t.substring(start=17, end=19).to_string())) {
Ok(v) => ss = v
Err(_) => ()
}
}
let days = days_from_civil(y, mo, d)
Some(days.to_int64() * 86400L + (hh * 3600 + mi * 60 + ss).to_int64())
}
///|
/// Howard Hinnant days_from_civil
fn days_from_civil(y : Int, m : Int, d : Int) -> Int {
let y2 = if m <= 2 { y - 1 } else { y }
let era = (if y2 >= 0 { y2 } else { y2 - 399 }) / 400
let yoe = y2 - era * 400
let mp = (m + 9) % 12
let doy = (153 * mp + 2) / 5 + d - 1
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
era * 146097 + doe - 719468
}
///|
/// epoch 秒 → (y, m, d)(civil_from_days)
fn civil_from_days(days : Int) -> (Int, Int, Int) {
let z = days + 719468
let era = (if z >= 0 { z } else { z - 146096 }) / 146097
let doe = z - era * 146097
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
let y = yoe + era * 400
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 { y + 1 } else { y }
(y, m, d)
}
///|
fn pad2(n : Int) -> String {
if n < 10 {
"0" + n.to_string()
} else {
n.to_string()
}
}
///|
/// ISO 周标签 YYYY-Www
fn iso_week_key(epoch_secs : Int64) -> String {
let days = (epoch_secs / 86400L).to_int()
// 1970-01-01 = 周四(周一=0 → 3)
let weekday = (days + 3) % 7
let thursday = days - weekday + 3
let (y, _, _) = civil_from_days(thursday)
let jan1 = days_from_civil(y, 1, 1)
let week = (thursday - jan1) / 7 + 1
y.to_string() + "-W" + pad2(week)
}
///|
/// 时间过滤:start/end 归一后字符串比较(格式自身可字典序比较)
fn in_range(create_time : String?, start : String?, end : String?) -> Bool {
let ct = match create_time {
Some(t) => t
None => return true // 无时间参与聚合(对齐 rust stats_filter 的宽匹配)
}
match start {
Some(s) => if ct < s { return false }
None => ()
}
match end {
Some(e) => if ct > e { return false }
None => ()
}
true
}
///|
pub async fn[R : @spi.ProcessRepository, E : @spi.ProcessExtRepository] Facade::stats_overview(
self : Facade[R, E],
args : Map[String, Json]
) -> Json raise @error.JeeflowError {
let start = parse_surrogate_time(arg_str(args, "start"))
let end = parse_surrogate_time(arg_str(args, "end"))
// stateIn 缺省 [10,20,30,40,45,50]
let state_in : Array[Int] = match args.get("stateIn") {
Some(Json::Array(items)) => {
let arr : Array[Int] = []
for v in items {
match @json.as_i64(v) {
Some(n) => arr.push(n.to_int())
None => ()
}
}
if arr.is_empty() {
[10, 20, 30, 40, 45, 50]
} else {
arr
}
}
_ => [10, 20, 30, 40, 45, 50]
}
let all = (self.repo).get_all_instances()
var total = 0L
var in_progress = 0L
var completed = 0L
var withdrawn = 0L
var rejected = 0L
var suspended = 0L
for inst in all {
if in_range(inst.create_time, start, end) {
total = total + 1L
if state_in.contains(inst.state) {
match inst.state {
10 => in_progress = in_progress + 1L
20 => completed = completed + 1L
30 => withdrawn = withdrawn + 1L
45 => rejected = rejected + 1L
50 => suspended = suspended + 1L
_ => ()
}
}
}
}
// todayNew:恒按当前钟当天
let today_prefix = @model.current_time_str().substring(start=0, end=10).to_string()
var today_new = 0L
for inst in all {
match inst.create_time {
Some(ct) => if ct.substring(start=0, end=10).to_string() == today_prefix { today_new = today_new + 1L }
None => ()
}
}
// 待办/逾期(全量)
let tasks = (self.repo).get_all_tasks()
var pending = 0L
var overdue = 0L
let today_secs = @model.epoch_secs()
for t in tasks {
if t.task_state == 10 {
pending = pending + 1L
match t.expire_time {
Some(et) =>
match parse_epoch_secs(et) {
Some(exp) => if exp < today_secs { overdue = overdue + 1L }
None => ()
}
None => ()
}
}
}
// 已完成任务聚合
var task_total = 0L
var countersign = 0L
var on_time = 0L
var on_time_denom = 0L
for t in tasks {
if t.task_state == 20 {
task_total = task_total + 1L
if t.perform_type == 1 {
countersign = countersign + 1L
}
match (t.finish_time, t.expire_time) {
(Some(f), Some(e)) =>
match (parse_epoch_secs(f), parse_epoch_secs(e)) {
(Some(fs), Some(es)) => {
on_time_denom = on_time_denom + 1L
if fs <= es {
on_time = on_time + 1L
}
}
_ => ()
}
_ => ()
}
}
}
// 平均完成时长(C23:MAX(task.finish_time)-create_time 的**均值**,非最大值)
var dur_total = 0L
var dur_count = 0L
for inst in all {
if inst.state == 20 && in_range(inst.create_time, start, end) {
match stats_instance_duration_secs(inst.create_time, inst.tasks) {
Some(dur) => {
dur_total += dur
dur_count += 1L
}
None => ()
}
}
}
let avg_duration = if dur_count > 0L { (dur_total / dur_count).to_int() } else { 0 }
let o = @json.empty_object()
@json.obj_set(o, "total", @json.number_of(total))
@json.obj_set(o, "inProgress", @json.number_of(in_progress))
@json.obj_set(o, "completed", @json.number_of(completed))
@json.obj_set(o, "rejected", @json.number_of(rejected))
@json.obj_set(o, "withdrawn", @json.number_of(withdrawn))
@json.obj_set(o, "suspended", @json.number_of(suspended))
@json.obj_set(o, "todayNew", @json.number_of(today_new))
@json.obj_set(o, "avgDurationSeconds", @json.number_of(avg_duration.to_int64()))
let denom = if completed + rejected > 0L { (completed + rejected).to_double() } else { 1.0 }
@json.obj_set(o, "rejectRate", @json.number_f_of(round4(rejected.to_double() / denom)))
@json.obj_set(o, "pendingTaskCount", @json.number_of(pending))
@json.obj_set(o, "overdueTaskCount", @json.number_of(overdue))
@json.obj_set(o, "countersignRate", @json.number_f_of(round4(if task_total > 0L {
countersign.to_double() / task_total.to_double()
} else {
0.0
})))
@json.obj_set(o, "onTimeRate", @json.number_f_of(round4(if on_time_denom > 0L {
on_time.to_double() / on_time_denom.to_double()
} else {
0.0
})))
o
}
///|
fn round4(v : Double) -> Double {
(v * 10000.0 + 0.5).floor() / 10000.0
}
///|
pub async fn[R : @spi.ProcessRepository, E : @spi.ProcessExtRepository] Facade::stats_trend(self : Facade[R, E], args : Map[String, Json]) -> Json raise @error.JeeflowError {
let granularity = arg_str_or(args, "granularity", "")
let start_s = parse_surrogate_time(arg_str(args, "start"))
let end_s = parse_surrogate_time(arg_str(args, "end"))
// C23:缺参/非法显式错误
let start_v = match start_s {
Some(s) => s
None => raise @error.Business("trend 缺少必填参数:start/end/granularity")
}
let end_v = match end_s {
Some(s) => s
None => raise @error.Business("trend 缺少必填参数:start/end/granularity")
}
if granularity.is_empty() {
raise @error.Business("trend 缺少必填参数:start/end/granularity")
}
if !(granularity == "hour" || granularity == "day" || granularity == "week" || granularity == "month") {
raise @error.Business("granularity 参数非法,允许值:hour/day/week/month")
}
let buckets = enumerate_buckets(start_v, end_v, granularity)
// 计数
let counts : Map[String, (Int64, Int64)] = Map::new()
for b in buckets {
counts[b] = (0L, 0L)
}
let all = (self.repo).get_all_instances()
for inst in all {
match inst.create_time {
Some(ct) => {
let bk = bucket_key_of(ct, granularity)
match bk {
Some(k) =>
match counts.get(k) {
Some((s, f)) => counts[k] = (s + 1L, f)
None => ()
}
None => ()
}
}
None => ()
}
}
let tasks = (self.repo).get_all_tasks()
for t in tasks {
if t.task_state == 20 {
match t.finish_time {
Some(f) => {
let bk = bucket_key_of(f, granularity)
match bk {
Some(k) =>
match counts.get(k) {
Some((s, fi)) => counts[k] = (s, fi + 1L)
None => ()
}
None => ()
}
}
None => ()
}
}
}
let series = @json.empty_array()
for b in buckets {
match counts.get(b) {
Some((s, f)) => {
let point = @json.empty_object()
@json.obj_set(point, "bucket", @json.string_of(b))
@json.obj_set(point, "started", @json.number_of(s))
@json.obj_set(point, "finished", @json.number_of(f))
@json.arr_push(series, point)
}
None => ()
}
}
series
}
///|
/// 桶枚举(连续桶):hour/day/week/month
fn enumerate_buckets(start_s : String, end_s : String, granularity : String) -> Array[String] {
let buckets : Array[String] = []
let start_secs = parse_epoch_secs(start_s).unwrap_or(0L)
let end_secs = parse_epoch_secs(end_s).unwrap_or(0L)
if granularity == "hour" {
var cursor = start_secs / 3600L * 3600L
while cursor <= end_secs {
buckets.push(hour_key(cursor))
cursor = cursor + 3600L
}
} else if granularity == "day" {
var day = (start_secs / 86400L).to_int()
let end_day = (end_secs / 86400L).to_int()
while day <= end_day {
let (y, m, d) = civil_from_days(day)
buckets.push("\{y}-\{pad2(m)}-\{pad2(d)}")
day = day + 1
}
} else if granularity == "week" {
// 对齐周一
var day = (start_secs / 86400L).to_int()
let weekday = (day + 3) % 7
day = day - weekday
let end_day = (end_secs / 86400L).to_int()
while day <= end_day {
let thursday = day + 3
let (y, _, _) = civil_from_days(thursday)
let jan1 = days_from_civil(y, 1, 1)
let week = (thursday - jan1) / 7 + 1
buckets.push("\{y}-W\{pad2(week)}")
day = day + 7
}
} else {
// month
let (y0, m0, _) = civil_from_days((start_secs / 86400L).to_int())
let (y1, m1, _) = civil_from_days((end_secs / 86400L).to_int())
var y = y0
var m = m0
while y < y1 || (y == y1 && m <= m1) {
buckets.push("\{y}-\{pad2(m)}")
m = m + 1
if m > 12 {
m = 1
y = y + 1
}
}
}
buckets
}
///|
fn hour_key(epoch_secs : Int64) -> String {
let days = (epoch_secs / 86400L).to_int()
let (y, m, d) = civil_from_days(days)
let hh = ((epoch_secs % 86400L) / 3600L).to_int()
"\{y}-\{pad2(m)}-\{pad2(d)} \{pad2(hh)}:00"
}
///|
/// 时间值 → 桶键(不落入枚举桶返回 None)
fn bucket_key_of(time_str : String, granularity : String) -> String? {
let norm = parse_surrogate_time(Some(time_str))
match norm {
Some(n) =>
match parse_epoch_secs(n) {
Some(secs) => {
if granularity == "hour" {
Some(hour_key(secs / 3600L * 3600L))
} else if granularity == "day" {
let (y, m, d) = civil_from_days((secs / 86400L).to_int())
Some("\{y}-\{pad2(m)}-\{pad2(d)}")
} else if granularity == "week" {
Some(iso_week_key(secs))
} else if granularity == "month" {
let (y, m, _) = civil_from_days((secs / 86400L).to_int())
Some("\{y}-\{pad2(m)}")
} else {
None
}
}
None => None
}
None => None
}
}
///|
pub async fn[R : @spi.ProcessRepository, E : @spi.ProcessExtRepository] Facade::stats_group(self : Facade[R, E], args : Map[String, Json]) -> Json raise @error.JeeflowError {
let start = parse_surrogate_time(arg_str(args, "start"))
let end = parse_surrogate_time(arg_str(args, "end"))
let dimension = arg_str_or(args, "dimension", "define")
let limit = arg_i64_or(args, "limit", 10L).to_int()
let valid = [
"state", "define", "category", "approver", "applicant", "node", "stuckNode", "stuckApprover", "durationBucket",
]
if !valid.contains(dimension) {
raise @error.Business("dimension 参数非法,允许值:state/define/category/approver/applicant/node/stuckNode/stuckApprover/durationBucket")
}
let rows : Array[Json] = []
// [key, count, avgDurSeconds?]
let counts : Map[String, Int64] = Map::new()
let durations : Map[String, Int64] = Map::new()
let dur_counts : Map[String, Int64] = Map::new()
// define 维度:key=编码 name,label=display_name(spec 06 §4.2 契约口径)
let labels : Map[String, String] = Map::new()
fn bump(k : String) -> Unit {
match counts.get(k) {
Some(c) => counts[k] = c + 1L
None => counts[k] = 1L
}
}
if dimension == "state" || dimension == "applicant" || dimension == "category" {
for inst in (self.repo).get_all_instances() {
if !in_range(inst.create_time, start, end) {
continue
}
if dimension == "state" {
bump(inst.state.to_string())
} else if dimension == "applicant" {
if !inst.operator.is_empty() {
bump(inst.operator)
}
} else {
// category → define.type
match (self.repo).find_define_by_id(inst.define_id) {
Some(d) => bump(d.define_type)
None => bump("")
}
}
}
} else if dimension == "define" {
for inst in (self.repo).get_all_instances() {
if !in_range(inst.create_time, start, end) {
continue
}
match (self.repo).find_define_by_id(inst.define_id) {
Some(d) => {
bump(d.name)
match labels.get(d.name) {
None => labels[d.name] = d.display_name
Some(_) => ()
}
// 已完成实例时长进 define 平均(C23)
if inst.state == 20 {
match stats_instance_duration_secs(inst.create_time, inst.tasks) {
Some(dur) => {
match durations.get(d.name) {
Some(v) => durations[d.name] = v + dur
None => durations[d.name] = dur
}
match dur_counts.get(d.name) {
Some(c) => dur_counts[d.name] = c + 1L
None => dur_counts[d.name] = 1L
}
}
None => ()
}
}
}
None => ()
}
}
} else if dimension == "approver" || dimension == "node" {
for t in (self.repo).get_all_tasks() {
if t.task_state != 20 {
continue
}
if dimension == "approver" {
match t.actor_id {
Some(op) => if !op.is_empty() { bump(op) }
None => ()
}
} else {
if !t.display_name.is_empty() {
var dur = 0L
match (t.create_time, t.finish_time) {
(Some(c), Some(f)) =>
match (parse_epoch_secs(c), parse_epoch_secs(f)) {
(Some(cs), Some(fs)) => dur = fs - cs
_ => ()
}
_ => ()
}
bump(t.display_name)
match durations.get(t.display_name) {
Some(v) => durations[t.display_name] = v + dur
None => durations[t.display_name] = dur
}
match dur_counts.get(t.display_name) {
Some(c) => dur_counts[t.display_name] = c + 1L
None => dur_counts[t.display_name] = 1L
}
}
}
}
} else if dimension == "stuckNode" {
for t in (self.repo).get_all_tasks() {
if t.task_state == 10 && !t.display_name.is_empty() {
bump(t.display_name)
}
}
} else if dimension == "stuckApprover" {
for t in (self.repo).get_all_tasks() {
if t.task_state == 10 {
match t.actor_id {
Some(op) => if !op.is_empty() { bump(op) }
None => ()
}
}
}
} else {
// durationBucket:4 桶(sameDay/1to3d/3to7d/over7d)
for inst in (self.repo).get_all_instances() {
if inst.state != 20 || !in_range(inst.create_time, start, end) {
continue
}
match (inst.create_time, parse_epoch_secs(inst.create_time.unwrap_or(""))) {
(Some(_), Some(cs)) => {
var latest_finish = cs
for t in inst.tasks {
match t.finish_time {
Some(f) =>
match parse_epoch_secs(f) {
Some(fs) => if fs > latest_finish { latest_finish = fs }
None => ()
}
None => ()
}
}
let dur = latest_finish - cs
let bucket = if dur < 86400L {
"sameDay"
} else if dur < 259200L {
"1to3d"
} else if dur < 604800L {
"3to7d"
} else {
"over7d"
}
bump(bucket)
}
_ => ()
}
}
}
// 排序:count DESC,limit 截断
let entries : Array[(String, Int64)] = []
for k, v in counts {
entries.push((k, v))
}
// count DESC 自写插入排序
for i in 1..= 0 && entries[j].1 < key.1 {
entries[j + 1] = entries[j]
j = j - 1
}
entries[j + 1] = key
}
var i = 0
for entry in entries {
if i >= limit {
break
}
let (k, c) = entry
let row = @json.empty_object()
@json.obj_set(row, "key", @json.string_of(k))
match labels.get(k) {
Some(l) => @json.obj_set(row, "label", @json.string_of(l))
None => @json.obj_set(row, "label", @json.null_json())
}
@json.obj_set(row, "count", @json.number_of(c))
if dimension == "node" || dimension == "define" {
match (durations.get(k), dur_counts.get(k)) {
(Some(total), Some(n)) =>
if n > 0L {
@json.obj_set(row, "avgDurationSeconds", @json.number_of((total / n).to_int().to_int64()))
} else {
@json.obj_set(row, "avgDurationSeconds", @json.null_json())
}
_ => @json.obj_set(row, "avgDurationSeconds", @json.null_json())
}
} else {
@json.obj_set(row, "avgDurationSeconds", @json.null_json())
}
rows.push(row)
i = i + 1
}
let arr = @json.empty_array()
for r in rows {
@json.arr_push(arr, r)
}
arr
}
///|
/// 实例办结时长秒(C23:MAX(task.finish_time) - create_time;时间缺失/不可解析返回 None;
/// 同秒完成返回 Some(0),计入均值样本)
fn stats_instance_duration_secs(create_time : String?, tasks : Array[@model.ProcessTask]) -> Int64? {
let base = match create_time {
Some(c) => parse_epoch_secs(c)
None => None
}
match base {
Some(cs) => {
var latest = cs
for t in tasks {
match t.finish_time {
Some(f) =>
match parse_epoch_secs(f) {
Some(fs) => if fs > latest { latest = fs }
None => ()
}
None => ()
}
}
Some(if latest > cs { latest - cs } else { 0L })
}
None => None
}
}