@mldong/jeeflow 1.8.22 → 1.8.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ProcessInstance } from './model.js';
2
2
  import type { ProcessRepository, UserProvider, IDGenerator, ExpressionEvaluator } from './spi.js';
3
- import { type EngineExtensions } from './extensions.js';
3
+ import { type EngineExtensions, type ProcessEvent } from './extensions.js';
4
4
  import { HandlerRegistry } from './registry.js';
5
5
  export declare const KeySubmitType = "submitType";
6
6
  export declare const KeyBusinessNo = "BUSINESS_NO";
@@ -23,6 +23,7 @@ export interface Engine {
23
23
  executeAndJumpToFirstTaskNode(taskId: string, operator: string, args?: Record<string, any>): Promise<ProcessInstance>;
24
24
  }
25
25
  export declare class EngineImpl implements Engine {
26
+ #private;
26
27
  private repo;
27
28
  private userProv?;
28
29
  private idGen?;
@@ -43,7 +44,7 @@ export declare class EngineImpl implements Engine {
43
44
  /** 表达式求值(v1.5.0,门面 highLight 决策分支过滤用) */
44
45
  evalExpr(expr: string, vars: Record<string, any>): Promise<any>;
45
46
  private firePost;
46
- private fireEvent;
47
+ fireEvent(evt: ProcessEvent): Promise<void>;
47
48
  startProcessInstanceById(defineId: string, operator: string, args?: Record<string, any>): Promise<ProcessInstance>;
48
49
  executeProcessTask(taskId: string, operator: string, args?: Record<string, any>): Promise<ProcessInstance>;
49
50
  executeAndJumpToEnd(taskId: string, operator: string, args?: Record<string, any>): Promise<ProcessInstance>;
package/dist/engine.js CHANGED
@@ -97,10 +97,23 @@ export class EngineImpl {
97
97
  await ic.postHandle(node, inst);
98
98
  }
99
99
  async fireEvent(evt) {
100
+ // 公开事件发布入口(issues/102 CC_CREATE):facade 层 CC 实例创建后逐抄送人 fire;
101
+ // 无监听器(ext/listeners 为空)时零副作用,与上一版逐字节一致
102
+ await this.#fireEvent(evt);
103
+ }
104
+ async #fireEvent(evt) {
100
105
  if (!this.ext?.listeners)
101
106
  return;
102
- for (const l of this.ext.listeners)
103
- await l(evt);
107
+ // 兜底语义(issues/104 P2 统一口径):单监听器异常只记录不传播——
108
+ // 不得影响引擎主流程,也不得中断后续监听器(对齐 PHP per-listener catch)
109
+ for (const l of this.ext.listeners) {
110
+ try {
111
+ await l(evt);
112
+ }
113
+ catch (e) {
114
+ console.error(`[jeeflow] process event listener error: type=${evt.type} instanceId=${evt.instanceId}`, e);
115
+ }
116
+ }
104
117
  }
105
118
  // ─── Start ─────────────────────────────────────────────────────────────────
106
119
  async startProcessInstanceById(defineId, operator, args = {}) {
@@ -155,6 +168,8 @@ export class EngineImpl {
155
168
  [`operatorList_${curNode.id}`]: actors,
156
169
  };
157
170
  await this.repo.saveTask(nt);
171
+ // TASK_CREATE:顺序会签推进新任务落库后 fire(对齐 Java CreateTaskHandler / Rust)
172
+ await this.fireEvent({ type: EventType.TaskCreate, instanceId: inst.id, taskId: nt.id, nodeId: curNode.id, operator });
158
173
  return (await this.repo.findInstanceById(inst.id));
159
174
  }
160
175
  }
@@ -340,8 +355,11 @@ export class EngineImpl {
340
355
  switch (ct) {
341
356
  case 'PARALLEL':
342
357
  case '':
343
- for (const actor of actors)
344
- await this.repo.saveTask(inst.createTask(this.nextId(), node.id, node.text.value, actor, operator, form, now, 1));
358
+ for (const actor of actors) {
359
+ const nt = inst.createTask(this.nextId(), node.id, node.text.value, actor, operator, form, now, 1);
360
+ await this.repo.saveTask(nt);
361
+ await this.fireEvent({ type: EventType.TaskCreate, instanceId: inst.id, taskId: nt.id, nodeId: node.id, operator });
362
+ }
345
363
  return;
346
364
  case 'SEQUENTIAL': {
347
365
  const nt = inst.createTask(this.nextId(), node.id, node.text.value, actors[0], operator, form, now, 1);
@@ -351,11 +369,15 @@ export class EngineImpl {
351
369
  [`operatorList_${node.id}`]: actors,
352
370
  };
353
371
  await this.repo.saveTask(nt);
372
+ await this.fireEvent({ type: EventType.TaskCreate, instanceId: inst.id, taskId: nt.id, nodeId: node.id, operator });
354
373
  return;
355
374
  }
356
375
  default:
357
- for (const actor of actors)
358
- await this.repo.saveTask(inst.createTask(this.nextId(), node.id, node.text.value, actor, operator, form, now, 1));
376
+ for (const actor of actors) {
377
+ const nt = inst.createTask(this.nextId(), node.id, node.text.value, actor, operator, form, now, 1);
378
+ await this.repo.saveTask(nt);
379
+ await this.fireEvent({ type: EventType.TaskCreate, instanceId: inst.id, taskId: nt.id, nodeId: node.id, operator });
380
+ }
359
381
  return;
360
382
  }
361
383
  }
@@ -363,6 +385,7 @@ export class EngineImpl {
363
385
  if (actors.length > 1)
364
386
  nt.actorIds = actors;
365
387
  await this.repo.saveTask(nt);
388
+ await this.fireEvent({ type: EventType.TaskCreate, instanceId: inst.id, taskId: nt.id, nodeId: node.id, operator });
366
389
  }
367
390
  // ─── Helpers ───────────────────────────────────────────────────────────────
368
391
  async loadAndCheck(taskId, operator) {
@@ -489,8 +512,12 @@ export class EngineImpl {
489
512
  if (isCountersign(node.properties?.performType) && ct) {
490
513
  switch (ct) {
491
514
  case 'PARALLEL':
492
- for (const actor of actors)
493
- await this.repo.saveTask(inst.createTask(this.nextId(), node.id, node.text.value, actor, operator, form, now, 1));
515
+ for (const actor of actors) {
516
+ const nt = inst.createTask(this.nextId(), node.id, node.text.value, actor, operator, form, now, 1);
517
+ await this.repo.saveTask(nt);
518
+ // TASK_CREATE:任务落库后逐个 fire(会签多任务逐个,对齐 Java CreateTaskHandler)
519
+ await this.fireEvent({ type: EventType.TaskCreate, instanceId: inst.id, taskId: nt.id, nodeId: node.id, operator });
520
+ }
494
521
  return;
495
522
  case 'SEQUENTIAL': {
496
523
  // 顺序会签任务也是会签任务(issues/57 E29 修正:仅普通分支默认 0)
@@ -501,11 +528,15 @@ export class EngineImpl {
501
528
  [`operatorList_${node.id}`]: actors,
502
529
  };
503
530
  await this.repo.saveTask(nt);
531
+ await this.fireEvent({ type: EventType.TaskCreate, instanceId: inst.id, taskId: nt.id, nodeId: node.id, operator });
504
532
  return;
505
533
  }
506
534
  default:
507
- for (const actor of actors)
508
- await this.repo.saveTask(inst.createTask(this.nextId(), node.id, node.text.value, actor, operator, form, now, 1));
535
+ for (const actor of actors) {
536
+ const nt = inst.createTask(this.nextId(), node.id, node.text.value, actor, operator, form, now, 1);
537
+ await this.repo.saveTask(nt);
538
+ await this.fireEvent({ type: EventType.TaskCreate, instanceId: inst.id, taskId: nt.id, nodeId: node.id, operator });
539
+ }
509
540
  return;
510
541
  }
511
542
  }
@@ -514,6 +545,7 @@ export class EngineImpl {
514
545
  if (actors.length > 1)
515
546
  nt.actorIds = actors;
516
547
  await this.repo.saveTask(nt);
548
+ await this.fireEvent({ type: EventType.TaskCreate, instanceId: inst.id, taskId: nt.id, nodeId: node.id, operator });
517
549
  }
518
550
  async resolveActors(node, inst, operator, vars) {
519
551
  // 1a. Registry 按名称解析(推荐)
@@ -22,7 +22,8 @@ export declare enum EventType {
22
22
  ProcessFinish = 1,
23
23
  ProcessReject = 2,
24
24
  TaskCreate = 3,
25
- TaskComplete = 4
25
+ TaskComplete = 4,
26
+ CcCreate = 5
26
27
  }
27
28
  export interface ProcessEvent {
28
29
  type: EventType;
@@ -30,6 +31,8 @@ export interface ProcessEvent {
30
31
  taskId?: string;
31
32
  nodeId?: string;
32
33
  operator: string;
34
+ /** 抄送人 id 直传事件体,监听器免反查 cc 表(issues/102;对齐 Java ccActorId / Go CcActorID) */
35
+ ccActorId?: string;
33
36
  }
34
37
  export type ProcessEventListener = (event: ProcessEvent) => void | Promise<void>;
35
38
  export interface EngineExtensions {
@@ -5,4 +5,7 @@ export var EventType;
5
5
  EventType[EventType["ProcessReject"] = 2] = "ProcessReject";
6
6
  EventType[EventType["TaskCreate"] = 3] = "TaskCreate";
7
7
  EventType[EventType["TaskComplete"] = 4] = "TaskComplete";
8
+ // issues/102:抄送知会(对齐 Java CC_CREATE / Go EventCCCreate / Python CC_CREATE / PHP CC_CREATE;
9
+ // 4 号位是活码 TaskComplete,0/1/2/3/4 不重排——码值契约 Go/Node=5)
10
+ EventType[EventType["CcCreate"] = 5] = "CcCreate";
8
11
  })(EventType || (EventType = {}));
package/dist/facade.d.ts CHANGED
@@ -87,4 +87,11 @@ export declare class JeeflowFacade {
87
87
  private doneList;
88
88
  /** 定义 content 解析为 LogicFlow JSON(issues/05 jsonObject) */
89
89
  private parseGraph;
90
+ private static readonly DEFAULT_STATE_IN;
91
+ private static readonly DEFAULT_STATS_LIMIT;
92
+ private static readonly VALID_GRANULARITY;
93
+ private static readonly VALID_DIMENSION;
94
+ private statsOverview;
95
+ private statsTrend;
96
+ private statsGroup;
90
97
  }
package/dist/facade.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // (code=0 成功 / 99999999 失败)。操作人约定:args.operator 显式传入。
6
6
  import { TaskState, } from './model.js';
7
7
  import { KeyNextNodeOperator, KeyProcessStartNextNodeOperator, isCountersign } from './engine.js';
8
+ import { EventType } from './extensions.js';
8
9
  // submitType 枚举(对齐 boot3)
9
10
  const SUBMIT_APPLY = 0;
10
11
  const SUBMIT_AGREE = 1;
@@ -132,6 +133,12 @@ export class JeeflowFacade {
132
133
  return this.taskAddActor(args);
133
134
  case 'processTask/latest':
134
135
  return this.taskLatest(args);
136
+ case 'processInstance/stats/overview':
137
+ return this.statsOverview(args);
138
+ case 'processInstance/stats/trend':
139
+ return this.statsTrend(args);
140
+ case 'processInstance/stats/group':
141
+ return this.statsGroup(args);
135
142
  default:
136
143
  throw new Error(`未知 action: ${action}`);
137
144
  }
@@ -153,6 +160,11 @@ export class JeeflowFacade {
153
160
  ? flowArgs.f_ccActors.split(',').map((x) => x.trim()).filter(Boolean) : [];
154
161
  if (ccList.length > 0) {
155
162
  await this.repo.createCcInstance(inst.id, operator, ...ccList);
163
+ // issues/102:CC 实例落库后逐抄送人 fire CcCreate(ccActorId 直传事件体,
164
+ // 对齐 Go startAndExecute / Python _startAndExecute;监听器据此落抄送知会 NOTICE)
165
+ for (const actor of ccList) {
166
+ await this.engine.fireEvent({ type: EventType.CcCreate, instanceId: inst.id, operator, ccActorId: actor });
167
+ }
156
168
  }
157
169
  // startAndExecute:自动完成申请节点(assignee="applicant" → 发起人)
158
170
  const doing = await this.repo.findDoingTasks(inst.id);
@@ -807,6 +819,10 @@ export class JeeflowFacade {
807
819
  if (actors.length === 0)
808
820
  throw new Error('actorIds 缺失');
809
821
  await this.repo.createCcInstance(instanceId, operator, ...actors);
822
+ // issues/102:手动 CC 与发起路径同语义——逐抄送人 fire CcCreate
823
+ for (const actor of actors) {
824
+ await this.engine.fireEvent({ type: EventType.CcCreate, instanceId, operator, ccActorId: actor });
825
+ }
810
826
  }
811
827
  async updateCCStatus(args) {
812
828
  const instanceId = toId(args.processInstanceId);
@@ -1091,6 +1107,197 @@ export class JeeflowFacade {
1091
1107
  return undefined;
1092
1108
  }
1093
1109
  }
1110
+ // ── 统计(v1.8.25,issues/103) ──────────────────────────────────────────
1111
+ static DEFAULT_STATE_IN = [10, 20, 30, 40, 45, 50];
1112
+ static DEFAULT_STATS_LIMIT = 10;
1113
+ static VALID_GRANULARITY = new Set(['hour', 'day', 'week', 'month']);
1114
+ static VALID_DIMENSION = new Set([
1115
+ 'state', 'define', 'category', 'approver', 'applicant',
1116
+ 'node', 'stuckNode', 'stuckApprover', 'durationBucket',
1117
+ ]);
1118
+ async statsOverview(args) {
1119
+ const start = parseSurrogateTime(args.start);
1120
+ const end = parseSurrogateTime(args.end);
1121
+ const stateIn = args.stateIn?.length ? args.stateIn.map(Number) : JeeflowFacade.DEFAULT_STATE_IN;
1122
+ const insts = await this.repo.queryInstancesForStats(stateIn, start, end);
1123
+ const total = insts.length;
1124
+ const inProgress = insts.filter(r => r.state === 10).length;
1125
+ const completed = insts.filter(r => r.state === 20).length;
1126
+ const withdrawn = insts.filter(r => r.state === 30).length;
1127
+ const rejected = insts.filter(r => r.state === 45).length;
1128
+ const suspended = insts.filter(r => r.state === 50).length;
1129
+ const now = new Date();
1130
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1131
+ const todayEnd = new Date(todayStart.getTime() + 86400000);
1132
+ // E:todayNew 恒按服务器当日、不过滤 state / 不受 stateIn 影响(对齐内置线 countTodayNew)
1133
+ const todayInsts = await this.repo.queryInstancesForStats(null, todayStart, todayEnd);
1134
+ const todayNew = todayInsts.length;
1135
+ const [pending, overdue] = await this.repo.statsPendingAndOverdueCount();
1136
+ const avgDur = await this.repo.statsAvgCompletedDurationSeconds(start, end);
1137
+ const [csTotal, csCount, onTime, onTimeDenom] = await this.repo.statsCompletedTaskAggregate();
1138
+ const countersignRate = csTotal > 0 ? statsRound4(csCount / csTotal) : 0;
1139
+ const onTimeRate = onTimeDenom > 0 ? statsRound4(onTime / onTimeDenom) : 0;
1140
+ const rejectRate = statsRound4(rejected / Math.max(1, completed + rejected));
1141
+ return {
1142
+ total, inProgress, completed, rejected, withdrawn, suspended,
1143
+ todayNew, avgDurationSeconds: avgDur,
1144
+ rejectRate, pendingTaskCount: pending,
1145
+ overdueTaskCount: overdue, countersignRate, onTimeRate,
1146
+ };
1147
+ }
1148
+ async statsTrend(args) {
1149
+ const granularity = String(args.granularity ?? '');
1150
+ if (!JeeflowFacade.VALID_GRANULARITY.has(granularity)) {
1151
+ throw new Error(`不支持的 granularity: ${granularity}`);
1152
+ }
1153
+ const start = parseSurrogateTime(args.start);
1154
+ const end = parseSurrogateTime(args.end);
1155
+ // C:start/end 必填(对齐内置线 20010012 缺参语义),不静默回退不限时间
1156
+ if (!start || !end) {
1157
+ throw new Error('trend 缺少必填参数:start/end/granularity');
1158
+ }
1159
+ // 实例侧无 state 过滤(对齐内置线 countInstanceStartedByBucket)
1160
+ const insts = await this.repo.queryInstancesForStats(null, start, end);
1161
+ const doneTasks = await this.repo.queryTasksForStats(20 /* TaskState.Done */, start, end);
1162
+ const buckets = statsEnumerateBuckets(start, end, granularity);
1163
+ const startedMap = new Map();
1164
+ for (const r of insts) {
1165
+ const ct = parseSurrogateTime(r.createTime);
1166
+ if (ct) {
1167
+ const bk = statsBucketKey(ct, granularity);
1168
+ startedMap.set(bk, (startedMap.get(bk) ?? 0) + 1);
1169
+ }
1170
+ }
1171
+ const finishedMap = new Map();
1172
+ for (const r of doneTasks) {
1173
+ const ft = parseSurrogateTime(r.finishTime);
1174
+ if (ft) {
1175
+ const bk = statsBucketKey(ft, granularity);
1176
+ finishedMap.set(bk, (finishedMap.get(bk) ?? 0) + 1);
1177
+ }
1178
+ }
1179
+ const series = buckets.map(b => ({
1180
+ bucket: b, started: startedMap.get(b) ?? 0, finished: finishedMap.get(b) ?? 0,
1181
+ }));
1182
+ // A:data 本体为裸数组(去掉 {granularity, series} 包装,对齐契约 spec 06 §4.2 / 内置线)
1183
+ return series;
1184
+ }
1185
+ async statsGroup(args) {
1186
+ const dimension = String(args.dimension ?? '');
1187
+ if (!JeeflowFacade.VALID_DIMENSION.has(dimension)) {
1188
+ throw new Error(`不支持的 dimension: ${dimension}`);
1189
+ }
1190
+ const start = parseSurrogateTime(args.start);
1191
+ const end = parseSurrogateTime(args.end);
1192
+ const limit = args.limit ? Number(args.limit) : JeeflowFacade.DEFAULT_STATS_LIMIT;
1193
+ let rows;
1194
+ if (dimension === 'define') {
1195
+ const raw = await this.repo.statsDefineGroup(start, end, limit);
1196
+ rows = raw.map(r => ({ key: r.key, label: r.label ?? null, count: r.count, avgDurationSeconds: r.avgDurationSeconds ?? null }));
1197
+ }
1198
+ else if (dimension === 'state') {
1199
+ // 无 state 过滤(对齐内置线 groupByDimension:仅按时间限定,契约 group 无 stateIn 入参)
1200
+ const insts = await this.repo.queryInstancesForStats(null, start, end);
1201
+ const grouped = new Map();
1202
+ for (const r of insts) {
1203
+ const k = String(r.state);
1204
+ grouped.set(k, (grouped.get(k) ?? 0) + 1);
1205
+ }
1206
+ const entries = [...grouped.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
1207
+ rows = entries.map(([k, c]) => ({ key: k, label: null, count: c, avgDurationSeconds: null }));
1208
+ }
1209
+ else if (dimension === 'category') {
1210
+ const insts = await this.repo.queryInstancesForStats(null, start, end);
1211
+ const defineTypes = new Map();
1212
+ for (const r of insts) {
1213
+ if (!defineTypes.has(r.defineId)) {
1214
+ const def = await this.repo.findDefineById(r.defineId);
1215
+ defineTypes.set(r.defineId, def?.type ?? '');
1216
+ }
1217
+ }
1218
+ const grouped = new Map();
1219
+ for (const r of insts) {
1220
+ const tp = defineTypes.get(r.defineId) ?? '';
1221
+ grouped.set(tp, (grouped.get(tp) ?? 0) + 1);
1222
+ }
1223
+ const entries = [...grouped.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
1224
+ rows = entries.map(([k, c]) => ({ key: k, label: null, count: c, avgDurationSeconds: null }));
1225
+ }
1226
+ else if (dimension === 'approver') {
1227
+ const tasks = await this.repo.queryTasksForStats(20, start, end);
1228
+ const grouped = new Map();
1229
+ for (const r of tasks) {
1230
+ if (!r.operator)
1231
+ continue;
1232
+ grouped.set(r.operator, (grouped.get(r.operator) ?? 0) + 1);
1233
+ }
1234
+ const entries = [...grouped.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
1235
+ rows = entries.map(([k, c]) => ({ key: k, label: null, count: c, avgDurationSeconds: null }));
1236
+ }
1237
+ else if (dimension === 'applicant') {
1238
+ const insts = await this.repo.queryInstancesForStats(null, start, end);
1239
+ const grouped = new Map();
1240
+ for (const r of insts) {
1241
+ if (!r.operator)
1242
+ continue;
1243
+ grouped.set(r.operator, (grouped.get(r.operator) ?? 0) + 1);
1244
+ }
1245
+ const entries = [...grouped.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
1246
+ rows = entries.map(([k, c]) => ({ key: k, label: null, count: c, avgDurationSeconds: null }));
1247
+ }
1248
+ else if (dimension === 'node') {
1249
+ const tasks = await this.repo.queryTasksForStats(20, start, end);
1250
+ const nodeAgg = new Map();
1251
+ for (const r of tasks) {
1252
+ if (!r.displayName)
1253
+ continue;
1254
+ let dur = 0;
1255
+ const ft = parseSurrogateTime(r.finishTime);
1256
+ const ct = parseSurrogateTime(r.createTime);
1257
+ if (ft && ct)
1258
+ dur = Math.floor((ft.getTime() - ct.getTime()) / 1000);
1259
+ const agg = nodeAgg.get(r.displayName) ?? { count: 0, totalDur: 0 };
1260
+ agg.count++;
1261
+ agg.totalDur += dur;
1262
+ nodeAgg.set(r.displayName, agg);
1263
+ }
1264
+ const entries = [...nodeAgg.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, limit);
1265
+ rows = entries.map(([name, agg]) => ({
1266
+ key: name, label: null, count: agg.count,
1267
+ avgDurationSeconds: agg.count > 0 ? Math.round(agg.totalDur / agg.count) : null,
1268
+ }));
1269
+ }
1270
+ else if (dimension === 'stuckNode') {
1271
+ const raw = await this.repo.statsStuckNodeGroup(limit);
1272
+ rows = raw.map(r => ({ key: r.key, label: r.label ?? null, count: r.count, avgDurationSeconds: r.avgDurationSeconds ?? null }));
1273
+ }
1274
+ else if (dimension === 'stuckApprover') {
1275
+ const raw = await this.repo.statsStuckApproverGroup(limit);
1276
+ rows = raw.map(r => ({ key: r.key, label: r.label ?? null, count: r.count, avgDurationSeconds: r.avgDurationSeconds ?? null }));
1277
+ }
1278
+ else if (dimension === 'durationBucket') {
1279
+ const durations = await this.repo.statsCompletedInstanceDurations(start, end);
1280
+ let sameDay = 0, d1to3 = 0, d3to7 = 0, over7d = 0;
1281
+ for (const dur of durations) {
1282
+ if (dur < 86400)
1283
+ sameDay++;
1284
+ else if (dur < 259200)
1285
+ d1to3++;
1286
+ else if (dur < 604800)
1287
+ d3to7++;
1288
+ else
1289
+ over7d++;
1290
+ }
1291
+ const keys = ['sameDay', '1to3d', '3to7d', 'over7d'];
1292
+ const counts = [sameDay, d1to3, d3to7, over7d];
1293
+ rows = keys.map((k, i) => ({ key: k, label: null, count: counts[i], avgDurationSeconds: null }));
1294
+ }
1295
+ else {
1296
+ rows = [];
1297
+ }
1298
+ // A:data 本体为裸数组(去掉 {dimension, rows} 包装,对齐契约 spec 06 §4.2 / 内置线)
1299
+ return rows;
1300
+ }
1094
1301
  }
1095
1302
  // ── 行转 Map(issues/05-2 列表字段契约 + 05-3 时间格式)─────────────────────
1096
1303
  /** issues/15:取 vars 中 prefix 前缀字段,输出「带前缀 + 去前缀副本」(对齐 boot3 getFormData) */
@@ -1304,3 +1511,82 @@ function pageData(pageNum, pageSize, total, rows) {
1304
1511
  totalPage = Math.ceil(total / ps);
1305
1512
  return { pageNum: pn, pageSize: ps, recordCount: total, totalPage, rows };
1306
1513
  }
1514
+ // ═══ 统计辅助(v1.8.25,issues/103) ═══
1515
+ function statsRound4(v) {
1516
+ return Math.round(v * 10000) / 10000;
1517
+ }
1518
+ function statsWeekKey(d) {
1519
+ const iso = isoWeek(d);
1520
+ return `${iso[0]}-W${String(iso[1]).padStart(2, '0')}`;
1521
+ }
1522
+ function isoWeek(d) {
1523
+ const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
1524
+ const dayNum = date.getUTCDay() || 7;
1525
+ date.setUTCDate(date.getUTCDate() + 4 - dayNum);
1526
+ const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
1527
+ const weekNo = Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
1528
+ return [date.getUTCFullYear(), weekNo];
1529
+ }
1530
+ function statsEnumerateBuckets(start, end, granularity) {
1531
+ const now = new Date();
1532
+ const s = start ?? new Date(now.getTime() - 30 * 86400000);
1533
+ const e = end ?? now;
1534
+ const buckets = [];
1535
+ if (granularity === 'hour') {
1536
+ const cursor = new Date(s.getFullYear(), s.getMonth(), s.getDate(), s.getHours(), 0, 0);
1537
+ while (cursor <= e) {
1538
+ buckets.push(formatHour(cursor));
1539
+ cursor.setHours(cursor.getHours() + 1);
1540
+ }
1541
+ }
1542
+ else if (granularity === 'day') {
1543
+ const cursor = new Date(s.getFullYear(), s.getMonth(), s.getDate());
1544
+ const endDay = new Date(e.getFullYear(), e.getMonth(), e.getDate());
1545
+ while (cursor <= endDay) {
1546
+ buckets.push(formatDay(cursor));
1547
+ cursor.setDate(cursor.getDate() + 1);
1548
+ }
1549
+ }
1550
+ else if (granularity === 'week') {
1551
+ const cursor = new Date(s.getFullYear(), s.getMonth(), s.getDate());
1552
+ const weekday = cursor.getDay() || 7;
1553
+ cursor.setDate(cursor.getDate() - (weekday - 1));
1554
+ const endDay = new Date(e.getFullYear(), e.getMonth(), e.getDate());
1555
+ while (cursor <= endDay) {
1556
+ buckets.push(statsWeekKey(cursor));
1557
+ cursor.setDate(cursor.getDate() + 7);
1558
+ }
1559
+ }
1560
+ else if (granularity === 'month') {
1561
+ let year = s.getFullYear();
1562
+ let month = s.getMonth();
1563
+ const endYear = e.getFullYear();
1564
+ const endMonth = e.getMonth();
1565
+ while (year < endYear || (year === endYear && month <= endMonth)) {
1566
+ buckets.push(`${year}-${String(month + 1).padStart(2, '0')}`);
1567
+ month++;
1568
+ if (month > 11) {
1569
+ month = 0;
1570
+ year++;
1571
+ }
1572
+ }
1573
+ }
1574
+ return buckets;
1575
+ }
1576
+ function statsBucketKey(d, granularity) {
1577
+ if (granularity === 'hour')
1578
+ return formatHour(d);
1579
+ if (granularity === 'day')
1580
+ return formatDay(d);
1581
+ if (granularity === 'week')
1582
+ return statsWeekKey(d);
1583
+ if (granularity === 'month')
1584
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
1585
+ return '';
1586
+ }
1587
+ function formatHour(d) {
1588
+ return `${formatDay(d)} ${String(d.getHours()).padStart(2, '0')}:00`;
1589
+ }
1590
+ function formatDay(d) {
1591
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
1592
+ }
@@ -1,5 +1,5 @@
1
1
  import { ProcessInstance, ProcessTask, type ProcessDefine, type CcInstanceRow, type DefineRow, type InstanceRow, type TaskRow } from '../model.js';
2
- import type { IDGenerator, ProcessRepository, QueryCondition } from '../spi.js';
2
+ import type { IDGenerator, ProcessRepository, QueryCondition, InstanceStatsRow, TaskStatsRow } from '../spi.js';
3
3
  /** 默认 ID 生成器:时间戳毫秒 + 同毫秒递增序号(对齐 Java nextId 默认实现)——
4
4
  * issue 38 E9:返回 string(数字值在 2^53 内精确,转字符串统一承载) */
5
5
  export declare class TsIDGenerator implements IDGenerator {
@@ -95,4 +95,13 @@ export declare class JdbcRepository implements ProcessRepository {
95
95
  total: number;
96
96
  }>;
97
97
  private mapCcRow;
98
+ queryInstancesForStats(stateIn?: number[] | null, start?: Date | null, end?: Date | null): Promise<InstanceStatsRow[]>;
99
+ queryTasksForStats(taskState?: number, start?: Date | null, end?: Date | null): Promise<TaskStatsRow[]>;
100
+ statsPendingAndOverdueCount(): Promise<[number, number]>;
101
+ statsCompletedTaskAggregate(): Promise<[number, number, number, number]>;
102
+ statsAvgCompletedDurationSeconds(start?: Date | null, end?: Date | null): Promise<number>;
103
+ statsDefineGroup(start?: Date | null, end?: Date | null, limit?: number): Promise<Record<string, any>[]>;
104
+ statsStuckNodeGroup(limit?: number): Promise<Record<string, any>[]>;
105
+ statsStuckApproverGroup(limit?: number): Promise<Record<string, any>[]>;
106
+ statsCompletedInstanceDurations(start?: Date | null, end?: Date | null): Promise<number[]>;
98
107
  }
@@ -648,4 +648,208 @@ export class JdbcRepository {
648
648
  defineVersion: Number(r.version ?? 0),
649
649
  };
650
650
  }
651
+ // ── Stats(issues/103:统计接口契约) ─────────────────────────────────────
652
+ async queryInstancesForStats(stateIn, start, end) {
653
+ // stateIn 空 = 无 state 过滤(对齐内置线:仅 overview 六计数用 stateIn)
654
+ let sql = 'SELECT process_define_id, state, operator, create_time FROM wf_process_instance WHERE 1=1';
655
+ const args = [];
656
+ if (stateIn && stateIn.length) {
657
+ sql += ` AND state IN (${repeatPh(stateIn.length)})`;
658
+ args.push(...stateIn);
659
+ }
660
+ if (start) {
661
+ sql += ' AND create_time >= ?';
662
+ args.push(start);
663
+ }
664
+ if (end) {
665
+ sql += ' AND create_time < DATE_ADD(?, INTERVAL 1 SECOND)';
666
+ args.push(end);
667
+ }
668
+ sql += ' ORDER BY create_time';
669
+ const conn = await this.c();
670
+ try {
671
+ const rows = await conn.fetchAll(this.sql(sql), args);
672
+ return rows.map((r) => ({
673
+ defineId: rowId(r.process_define_id), state: r.state,
674
+ operator: r.operator ?? '', createTime: r.create_time,
675
+ }));
676
+ }
677
+ finally {
678
+ await this.done(conn);
679
+ }
680
+ }
681
+ async queryTasksForStats(taskState, start, end) {
682
+ let sql = 'SELECT operator, display_name, perform_type, create_time, finish_time, expire_time FROM wf_process_task WHERE 1=1';
683
+ const args = [];
684
+ if (taskState != null) {
685
+ sql += ' AND task_state = ?';
686
+ args.push(taskState);
687
+ }
688
+ if (start) {
689
+ sql += ' AND finish_time >= ?';
690
+ args.push(start);
691
+ }
692
+ if (end) {
693
+ sql += ' AND finish_time < DATE_ADD(?, INTERVAL 1 SECOND)';
694
+ args.push(end);
695
+ }
696
+ const conn = await this.c();
697
+ try {
698
+ const rows = await conn.fetchAll(this.sql(sql), args);
699
+ return rows.map((r) => ({
700
+ operator: r.operator ?? '', displayName: r.display_name ?? '',
701
+ performType: r.perform_type ?? 0, createTime: r.create_time,
702
+ finishTime: r.finish_time, expireTime: r.expire_time,
703
+ }));
704
+ }
705
+ finally {
706
+ await this.done(conn);
707
+ }
708
+ }
709
+ async statsPendingAndOverdueCount() {
710
+ const conn = await this.c();
711
+ try {
712
+ const r1 = await conn.fetchOne(this.sql('SELECT COUNT(*) FROM wf_process_task WHERE task_state = 10'), []);
713
+ const pending = r1 ? Number(Object.values(r1)[0] ?? 0) : 0;
714
+ const now = new Date();
715
+ const r2 = await conn.fetchOne(this.sql('SELECT COUNT(*) FROM wf_process_task WHERE task_state = 10 AND expire_time IS NOT NULL AND expire_time < ?'), [now]);
716
+ const overdue = r2 ? Number(Object.values(r2)[0] ?? 0) : 0;
717
+ return [pending, overdue];
718
+ }
719
+ finally {
720
+ await this.done(conn);
721
+ }
722
+ }
723
+ async statsCompletedTaskAggregate() {
724
+ const conn = await this.c();
725
+ try {
726
+ const r = await conn.fetchOne(this.sql('SELECT COUNT(*) AS total, ' +
727
+ 'SUM(CASE WHEN perform_type = 1 THEN 1 ELSE 0 END) AS countersign, ' +
728
+ 'SUM(CASE WHEN expire_time IS NOT NULL AND finish_time IS NOT NULL AND finish_time <= expire_time THEN 1 ELSE 0 END) AS on_time, ' +
729
+ 'SUM(CASE WHEN expire_time IS NOT NULL THEN 1 ELSE 0 END) AS on_time_denom ' +
730
+ 'FROM wf_process_task WHERE task_state = 20'), []);
731
+ if (!r)
732
+ return [0, 0, 0, 0];
733
+ return [
734
+ Number(r.total ?? 0),
735
+ Number(r.countersign ?? 0),
736
+ Number(r.on_time ?? 0),
737
+ Number(r.on_time_denom ?? 0),
738
+ ];
739
+ }
740
+ finally {
741
+ await this.done(conn);
742
+ }
743
+ }
744
+ async statsAvgCompletedDurationSeconds(start, end) {
745
+ let sql = `SELECT COALESCE(ROUND(AVG(
746
+ TIMESTAMPDIFF(SECOND, i.create_time, (
747
+ SELECT MAX(t.finish_time) FROM wf_process_task t WHERE t.process_instance_id = i.id AND t.finish_time IS NOT NULL
748
+ ))
749
+ )), 0) AS avg_sec FROM wf_process_instance i WHERE i.state = 20`;
750
+ const args = [];
751
+ if (start) {
752
+ sql += ' AND i.create_time >= ?';
753
+ args.push(start);
754
+ }
755
+ if (end) {
756
+ sql += ' AND i.create_time < DATE_ADD(?, INTERVAL 1 SECOND)';
757
+ args.push(end);
758
+ }
759
+ const conn = await this.c();
760
+ try {
761
+ const r = await conn.fetchOne(this.sql(sql), args);
762
+ if (!r)
763
+ return 0;
764
+ return Number(Object.values(r)[0] ?? 0);
765
+ }
766
+ finally {
767
+ await this.done(conn);
768
+ }
769
+ }
770
+ async statsDefineGroup(start, end, limit = 10) {
771
+ // 对齐内置线 mapper:count 全实例(无 state 过滤)、inner join define、
772
+ // avg 仅对 state=20 且有 finish 的实例聚合(MAX(task.finish_time) - create_time)
773
+ let sql = 'SELECT pd.name, pd.display_name, COUNT(*) AS cnt, ' +
774
+ 'ROUND(AVG(CASE WHEN i.state = 20 AND sub.maxft IS NOT NULL ' +
775
+ 'THEN TIMESTAMPDIFF(SECOND, i.create_time, sub.maxft) END)) AS avg_dur ' +
776
+ 'FROM wf_process_instance i ' +
777
+ 'JOIN wf_process_define pd ON i.process_define_id = pd.id ' +
778
+ 'LEFT JOIN (SELECT process_instance_id, MAX(finish_time) AS maxft ' +
779
+ 'FROM wf_process_task GROUP BY process_instance_id) sub ' +
780
+ 'ON sub.process_instance_id = i.id ' +
781
+ 'WHERE 1=1';
782
+ const args = [];
783
+ if (start) {
784
+ sql += ' AND i.create_time >= ?';
785
+ args.push(start);
786
+ }
787
+ if (end) {
788
+ sql += ' AND i.create_time < DATE_ADD(?, INTERVAL 1 SECOND)';
789
+ args.push(end);
790
+ }
791
+ sql += ' GROUP BY pd.id, pd.name, pd.display_name ORDER BY cnt DESC LIMIT ?';
792
+ args.push(limit);
793
+ const conn = await this.c();
794
+ try {
795
+ const rows = await conn.fetchAll(this.sql(sql), args);
796
+ return rows.map((r) => ({
797
+ key: r.name ?? null, label: r.display_name ?? null,
798
+ count: Number(r.cnt ?? 0),
799
+ avgDurationSeconds: r.avg_dur != null ? Math.round(Number(r.avg_dur)) : null,
800
+ }));
801
+ }
802
+ finally {
803
+ await this.done(conn);
804
+ }
805
+ }
806
+ async statsStuckNodeGroup(limit = 10) {
807
+ const conn = await this.c();
808
+ try {
809
+ const rows = await conn.fetchAll(this.sql('SELECT display_name, COUNT(*) AS cnt FROM wf_process_task ' +
810
+ 'WHERE task_state = 10 GROUP BY display_name ORDER BY cnt DESC LIMIT ?'), [limit]);
811
+ return rows.map((r) => ({
812
+ key: r.display_name ?? '', label: null, count: Number(r.cnt ?? 0), avgDurationSeconds: null,
813
+ }));
814
+ }
815
+ finally {
816
+ await this.done(conn);
817
+ }
818
+ }
819
+ async statsStuckApproverGroup(limit = 10) {
820
+ const conn = await this.c();
821
+ try {
822
+ const rows = await conn.fetchAll(this.sql('SELECT ta.actor_id, COUNT(DISTINCT t.id) AS cnt FROM wf_process_task_actor ta ' +
823
+ 'INNER JOIN wf_process_task t ON ta.process_task_id = t.id ' +
824
+ 'WHERE t.task_state = 10 GROUP BY ta.actor_id ORDER BY cnt DESC LIMIT ?'), [limit]);
825
+ return rows.map((r) => ({
826
+ key: rowId(r.actor_id), label: null, count: Number(r.cnt ?? 0), avgDurationSeconds: null,
827
+ }));
828
+ }
829
+ finally {
830
+ await this.done(conn);
831
+ }
832
+ }
833
+ async statsCompletedInstanceDurations(start, end) {
834
+ let sql = `SELECT TIMESTAMPDIFF(SECOND, i.create_time, (
835
+ SELECT MAX(t.finish_time) FROM wf_process_task t WHERE t.process_instance_id = i.id AND t.finish_time IS NOT NULL
836
+ )) AS dur FROM wf_process_instance i WHERE i.state = 20`;
837
+ const args = [];
838
+ if (start) {
839
+ sql += ' AND i.create_time >= ?';
840
+ args.push(start);
841
+ }
842
+ if (end) {
843
+ sql += ' AND i.create_time < DATE_ADD(?, INTERVAL 1 SECOND)';
844
+ args.push(end);
845
+ }
846
+ const conn = await this.c();
847
+ try {
848
+ const rows = await conn.fetchAll(this.sql(sql), args);
849
+ return rows.map((r) => Number(r.dur)).filter((v) => v != null && !isNaN(v));
850
+ }
851
+ finally {
852
+ await this.done(conn);
853
+ }
854
+ }
651
855
  }
package/dist/memory.d.ts CHANGED
@@ -57,4 +57,37 @@ export declare class MemoryRepository implements ProcessRepository {
57
57
  allDefines(): ProcessDefine[];
58
58
  allInstances(): ProcessInstance[];
59
59
  allTasks(): ProcessTask[];
60
+ private toDt;
61
+ queryInstancesForStats(stateIn?: number[] | null, start?: Date | null, end?: Date | null): Promise<{
62
+ defineId: string;
63
+ state: number;
64
+ operator: string;
65
+ createTime: Date | string | null;
66
+ }[]>;
67
+ queryTasksForStats(taskState?: number, start?: Date | null, end?: Date | null): Promise<{
68
+ operator: string;
69
+ displayName: string;
70
+ performType: number;
71
+ createTime: Date | string | null;
72
+ finishTime: Date | string | null;
73
+ expireTime: Date | string | null;
74
+ }[]>;
75
+ statsPendingAndOverdueCount(): Promise<[number, number]>;
76
+ statsCompletedTaskAggregate(): Promise<[number, number, number, number]>;
77
+ statsAvgCompletedDurationSeconds(start?: Date | null, end?: Date | null): Promise<number>;
78
+ statsDefineGroup(start?: Date | null, end?: Date | null, limit?: number): Promise<{
79
+ key: string;
80
+ label: string | null;
81
+ count: number;
82
+ avgDurationSeconds: number | null;
83
+ }[]>;
84
+ statsStuckNodeGroup(limit?: number): Promise<{
85
+ key: string;
86
+ count: number;
87
+ }[]>;
88
+ statsStuckApproverGroup(limit?: number): Promise<{
89
+ key: string;
90
+ count: number;
91
+ }[]>;
92
+ statsCompletedInstanceDurations(start?: Date | null, end?: Date | null): Promise<number[]>;
60
93
  }
package/dist/memory.js CHANGED
@@ -144,13 +144,13 @@ export class MemoryRepository {
144
144
  cp.tasks = [];
145
145
  this.instances.set(inst.id, cp);
146
146
  // v1.0.1:级联保存聚合根内任务状态变更
147
- for (const t of inst.tasks) {
147
+ for (const t of (inst.tasks ?? [])) {
148
148
  if (!t.id)
149
149
  continue;
150
150
  const tc = cloneTask(t);
151
151
  tc.actorIds = [];
152
152
  this.tasks.set(t.id, tc);
153
- if (t.actorIds.length)
153
+ if (t.actorIds?.length)
154
154
  this.actors.set(t.id, [...t.actorIds]);
155
155
  }
156
156
  }
@@ -183,14 +183,14 @@ export class MemoryRepository {
183
183
  const cp = cloneTask(task);
184
184
  cp.actorIds = [];
185
185
  this.tasks.set(task.id, cp);
186
- if (task.actorIds.length)
186
+ if (task.actorIds?.length)
187
187
  this.actors.set(task.id, [...task.actorIds]);
188
188
  }
189
189
  async updateTask(task) {
190
190
  const cp = cloneTask(task);
191
191
  cp.actorIds = [];
192
192
  this.tasks.set(task.id, cp);
193
- if (task.actorIds.length)
193
+ if (task.actorIds?.length)
194
194
  this.actors.set(task.id, [...task.actorIds]);
195
195
  }
196
196
  async findDoingTasks(instanceId, taskNames) {
@@ -375,4 +375,198 @@ export class MemoryRepository {
375
375
  return cp;
376
376
  });
377
377
  }
378
+ // ── 统计(v1.8.25,issues/103) ──────────────────────────────────────────
379
+ toDt(v) {
380
+ if (v == null)
381
+ return undefined;
382
+ if (v instanceof Date)
383
+ return isNaN(v.getTime()) ? undefined : v;
384
+ const d = new Date(String(v).replace(' ', 'T'));
385
+ return isNaN(d.getTime()) ? undefined : d;
386
+ }
387
+ async queryInstancesForStats(stateIn, start, end) {
388
+ const sd = this.toDt(start);
389
+ const ed = this.toDt(end);
390
+ const rows = [];
391
+ for (const inst of this.instances.values()) {
392
+ const sv = Number(inst.state);
393
+ // stateIn 空 = 无 state 过滤(对齐内置线:仅 overview 六计数用 stateIn)
394
+ if (stateIn && stateIn.length && !stateIn.includes(sv))
395
+ continue;
396
+ const ct = this.toDt(inst.createTime);
397
+ if (sd && ct && ct < sd)
398
+ continue;
399
+ if (ed && ct && ct > ed)
400
+ continue;
401
+ rows.push({ defineId: String(inst.defineId), state: sv, operator: inst.operator ?? '', createTime: inst.createTime ?? null });
402
+ }
403
+ return rows;
404
+ }
405
+ async queryTasksForStats(taskState, start, end) {
406
+ const sd = this.toDt(start);
407
+ const ed = this.toDt(end);
408
+ const rows = [];
409
+ for (const t of this.tasks.values()) {
410
+ if (taskState != null && Number(t.taskState) !== taskState)
411
+ continue;
412
+ const ft = this.toDt(t.finishTime);
413
+ if (sd && ft && ft < sd)
414
+ continue;
415
+ if (ed && ft && ft > ed)
416
+ continue;
417
+ rows.push({
418
+ operator: t.actorId ?? '', displayName: t.displayName ?? '',
419
+ performType: t.performType ?? 0,
420
+ createTime: t.createTime ?? null, finishTime: t.finishTime ?? null, expireTime: t.expireTime ?? null,
421
+ });
422
+ }
423
+ return rows;
424
+ }
425
+ async statsPendingAndOverdueCount() {
426
+ const now = new Date();
427
+ let pending = 0, overdue = 0;
428
+ for (const t of this.tasks.values()) {
429
+ if (Number(t.taskState) !== TaskState.Doing)
430
+ continue;
431
+ pending++;
432
+ const exp = this.toDt(t.expireTime);
433
+ if (exp && exp < now)
434
+ overdue++;
435
+ }
436
+ return [pending, overdue];
437
+ }
438
+ async statsCompletedTaskAggregate() {
439
+ let total = 0, countersign = 0, onTime = 0, onTimeDenom = 0;
440
+ for (const t of this.tasks.values()) {
441
+ if (Number(t.taskState) !== TaskState.Done)
442
+ continue;
443
+ total++;
444
+ if (t.performType === 1)
445
+ countersign++;
446
+ const ft = this.toDt(t.finishTime);
447
+ const exp = this.toDt(t.expireTime);
448
+ if (exp != null) {
449
+ onTimeDenom++;
450
+ if (ft && ft <= exp)
451
+ onTime++;
452
+ }
453
+ }
454
+ return [total, countersign, onTime, onTimeDenom];
455
+ }
456
+ async statsAvgCompletedDurationSeconds(start, end) {
457
+ const sd = this.toDt(start);
458
+ const ed = this.toDt(end);
459
+ let totalSec = 0, count = 0;
460
+ for (const inst of this.instances.values()) {
461
+ if (Number(inst.state) !== 20)
462
+ continue;
463
+ const ct = this.toDt(inst.createTime);
464
+ if (sd && ct && ct < sd)
465
+ continue;
466
+ if (ed && ct && ct > ed)
467
+ continue;
468
+ let maxFt;
469
+ for (const t of this.tasks.values()) {
470
+ if (t.processInstanceId !== inst.id)
471
+ continue;
472
+ const ft = this.toDt(t.finishTime);
473
+ if (ft && (!maxFt || ft > maxFt))
474
+ maxFt = ft;
475
+ }
476
+ if (maxFt && ct) {
477
+ totalSec += Math.floor((maxFt.getTime() - ct.getTime()) / 1000);
478
+ count++;
479
+ }
480
+ }
481
+ return count > 0 ? Math.floor(totalSec / count) : 0;
482
+ }
483
+ async statsDefineGroup(start, end, limit = 10) {
484
+ const sd = this.toDt(start);
485
+ const ed = this.toDt(end);
486
+ const grouped = new Map();
487
+ for (const inst of this.instances.values()) {
488
+ const ct = this.toDt(inst.createTime);
489
+ if (sd && ct && ct < sd)
490
+ continue;
491
+ if (ed && ct && ct > ed)
492
+ continue;
493
+ const did = String(inst.defineId);
494
+ if (!grouped.has(did)) {
495
+ const defn = this.defines.get(did);
496
+ grouped.set(did, { key: defn?.name ?? '', label: defn?.displayName ?? null, count: 0, totalDur: 0, durCount: 0 });
497
+ }
498
+ const g = grouped.get(did);
499
+ g.count++;
500
+ if (Number(inst.state) === 20) {
501
+ let maxFt;
502
+ for (const t of this.tasks.values()) {
503
+ if (t.processInstanceId !== inst.id)
504
+ continue;
505
+ const ft = this.toDt(t.finishTime);
506
+ if (ft && (!maxFt || ft > maxFt))
507
+ maxFt = ft;
508
+ }
509
+ if (maxFt && ct) {
510
+ g.totalDur += Math.floor((maxFt.getTime() - ct.getTime()) / 1000);
511
+ g.durCount++;
512
+ }
513
+ }
514
+ }
515
+ const entries = [...grouped.values()].sort((a, b) => b.count - a.count).slice(0, limit);
516
+ return entries.map(e => ({ key: e.key, label: e.label, count: e.count, avgDurationSeconds: e.durCount > 0 ? Math.floor(e.totalDur / e.durCount) : null }));
517
+ }
518
+ async statsStuckNodeGroup(limit = 10) {
519
+ const grouped = new Map();
520
+ for (const t of this.tasks.values()) {
521
+ if (Number(t.taskState) !== TaskState.Doing)
522
+ continue;
523
+ const dn = t.displayName;
524
+ if (!dn)
525
+ continue;
526
+ grouped.set(dn, (grouped.get(dn) ?? 0) + 1);
527
+ }
528
+ const entries = [...grouped.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
529
+ return entries.map(([k, c]) => ({ key: k, count: c }));
530
+ }
531
+ async statsStuckApproverGroup(limit = 10) {
532
+ const grouped = new Map();
533
+ for (const t of this.tasks.values()) {
534
+ if (Number(t.taskState) !== TaskState.Doing)
535
+ continue;
536
+ const actors = this.actors.get(t.id) ?? [];
537
+ for (const aid of actors) {
538
+ if (!aid)
539
+ continue;
540
+ grouped.set(aid, (grouped.get(aid) ?? 0) + 1);
541
+ }
542
+ }
543
+ const entries = [...grouped.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
544
+ return entries.map(([k, c]) => ({ key: k, count: c }));
545
+ }
546
+ async statsCompletedInstanceDurations(start, end) {
547
+ const sd = this.toDt(start);
548
+ const ed = this.toDt(end);
549
+ const durations = [];
550
+ for (const inst of this.instances.values()) {
551
+ if (Number(inst.state) !== 20)
552
+ continue;
553
+ const ct = this.toDt(inst.createTime);
554
+ if (sd && ct && ct < sd)
555
+ continue;
556
+ if (ed && ct && ct > ed)
557
+ continue;
558
+ let maxFt;
559
+ for (const t of this.tasks.values()) {
560
+ if (t.processInstanceId !== inst.id)
561
+ continue;
562
+ const ft = this.toDt(t.finishTime);
563
+ if (ft && (!maxFt || ft > maxFt))
564
+ maxFt = ft;
565
+ }
566
+ if (maxFt && ct) {
567
+ durations.push(Math.floor((maxFt.getTime() - ct.getTime()) / 1000));
568
+ }
569
+ }
570
+ return durations;
571
+ }
378
572
  }
package/dist/model.js CHANGED
@@ -212,7 +212,7 @@ export class ProcessTask {
212
212
  // ─── Clone Helpers(保留 class 原型)────────────────────────────────────────────
213
213
  export function cloneInstance(inst) {
214
214
  return Object.assign(Object.create(ProcessInstance.prototype), inst, {
215
- tasks: inst.tasks.map(cloneTask),
215
+ tasks: (inst.tasks ?? []).map(cloneTask),
216
216
  });
217
217
  }
218
218
  export function cloneTask(task) {
package/dist/spi.d.ts CHANGED
@@ -4,6 +4,20 @@ export interface QueryCondition {
4
4
  operator: string;
5
5
  value: any;
6
6
  }
7
+ export interface InstanceStatsRow {
8
+ defineId: string;
9
+ state: number;
10
+ operator: string;
11
+ createTime: Date | string | null;
12
+ }
13
+ export interface TaskStatsRow {
14
+ operator: string;
15
+ displayName: string;
16
+ performType: number;
17
+ createTime: Date | string | null;
18
+ finishTime: Date | string | null;
19
+ expireTime: Date | string | null;
20
+ }
7
21
  export interface ProcessRepository {
8
22
  findDefineById(id: string): Promise<ProcessDefine | null>;
9
23
  findDefineByName(name: string): Promise<ProcessDefine | null>;
@@ -45,6 +59,15 @@ export interface ProcessRepository {
45
59
  rows: TaskRow[];
46
60
  total: number;
47
61
  }>;
62
+ queryInstancesForStats(stateIn?: number[] | null, start?: Date | null, end?: Date | null): Promise<InstanceStatsRow[]>;
63
+ queryTasksForStats(taskState?: number, start?: Date | null, end?: Date | null): Promise<TaskStatsRow[]>;
64
+ statsPendingAndOverdueCount(): Promise<[number, number]>;
65
+ statsCompletedTaskAggregate(): Promise<[number, number, number, number]>;
66
+ statsAvgCompletedDurationSeconds(start?: Date | null, end?: Date | null): Promise<number>;
67
+ statsDefineGroup(start?: Date | null, end?: Date | null, limit?: number): Promise<Record<string, any>[]>;
68
+ statsStuckNodeGroup(limit?: number): Promise<Record<string, any>[]>;
69
+ statsStuckApproverGroup(limit?: number): Promise<Record<string, any>[]>;
70
+ statsCompletedInstanceDurations(start?: Date | null, end?: Date | null): Promise<number[]>;
48
71
  }
49
72
  export interface UserProvider {
50
73
  getUser(userId: string): Promise<UserInfo | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mldong/jeeflow",
3
- "version": "1.8.22",
3
+ "version": "1.8.25",
4
4
  "description": "jeeflow workflow engine — Node.js / TypeScript implementation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",