@hedeqiang/dsh-nl2sql 0.12.2 → 0.13.1
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/CHANGELOG.md +24 -1
- package/README.md +22 -0
- package/README.zh-CN.md +22 -0
- package/bin/nl2sql-mcp.mjs +103 -0
- package/cordis.patch.yml +4 -0
- package/docs/mcp.md +119 -0
- package/index.js +17 -1
- package/lib/client.js +1680 -6
- package/package.json +7 -2
- package/service/alerts.js +20 -6
- package/service/features.js +21 -0
- package/service/forecast.js +252 -0
- package/service/freshness.js +747 -0
- package/service/mcp.js +677 -0
- package/service/pptx.js +362 -0
- package/service/present.js +516 -0
- package/service/proactive.js +531 -0
- package/service/query.js +3 -2
- package/service/reconcile.js +565 -0
- package/service/shared.js +27 -0
- package/service/subscriptions.js +563 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-datasource comparison / reconciliation (对账 / 核对).
|
|
3
|
+
*
|
|
4
|
+
* Two read-only queries (any two data sources, or the same one twice) run
|
|
5
|
+
* through runQuery — so admission, access policy, row-level rules, masking and
|
|
6
|
+
* audit all apply — and both results (≤ 200k rows per side) are joined on key
|
|
7
|
+
* columns in an in-memory SQLite database. The outcome: matched keys, keys only
|
|
8
|
+
* on one side, value mismatches per compare column (numeric tolerance, absolute
|
|
9
|
+
* or relative; string trim / case options), per-side totals, and detail tables
|
|
10
|
+
* shaped like query results (columns / rows) so the page's ResultView, Excel
|
|
11
|
+
* export and IM push can use them.
|
|
12
|
+
*
|
|
13
|
+
* Saved jobs live in state.reconciles; runs are recorded as 'reconcileRun'
|
|
14
|
+
* records (summary + first 200 differences); a scheduler job runs due ones daily.
|
|
15
|
+
*/
|
|
16
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
17
|
+
import { admitSql } from '../sql-guard.js'
|
|
18
|
+
import { UserError, fmtNum, lastDailySlot, newId, oneOf, requireText, upsert } from './shared.js'
|
|
19
|
+
|
|
20
|
+
/** Rows loaded per side. */
|
|
21
|
+
export const SIDE_ROW_CAP = 200000
|
|
22
|
+
/** Rows kept in each detail table (summary counts are always complete). */
|
|
23
|
+
export const DETAIL_LIMIT = 10000
|
|
24
|
+
/** Differences kept in a run record. */
|
|
25
|
+
export const RECORD_DIFFS = 200
|
|
26
|
+
const MAX_KEYS = 8
|
|
27
|
+
const MAX_COMPARE = 20
|
|
28
|
+
|
|
29
|
+
// ------------------------------------------------------------ value rules
|
|
30
|
+
|
|
31
|
+
const NUMERIC = /^[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?$/
|
|
32
|
+
const CANON_INT = /^-?(0|[1-9]\d*)(\.\d+)?$/
|
|
33
|
+
const DATE_LIKE = /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?(Z|[+-]\d{2}:?\d{2})?$/
|
|
34
|
+
|
|
35
|
+
/** A finite number for numbers / numeric strings, else undefined. */
|
|
36
|
+
export function toNumber(v) {
|
|
37
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : undefined
|
|
38
|
+
if (typeof v === 'bigint') return Number(v)
|
|
39
|
+
if (typeof v === 'boolean') return v ? 1 : 0
|
|
40
|
+
if (typeof v === 'string') {
|
|
41
|
+
const s = v.trim()
|
|
42
|
+
if (s && NUMERIC.test(s)) { const n = Number(s); return Number.isFinite(n) ? n : undefined }
|
|
43
|
+
}
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Date / datetime strings from different engines ("2026-01-01T00:00:00.000Z" vs "2026-01-01 00:00:00"). */
|
|
48
|
+
function normDate(s) {
|
|
49
|
+
if (!DATE_LIKE.test(s)) return s
|
|
50
|
+
return s.replace('T', ' ').replace(/(\.0+)?(Z|[+-]00:?00)?$/, '').replace(/ 00:00(:00)?$/, '')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normText(v, { trim = true, ignoreCase = false } = {}) {
|
|
54
|
+
let s = typeof v === 'string' ? v : typeof v === 'object' ? JSON.stringify(v) : String(v)
|
|
55
|
+
if (trim) s = s.trim()
|
|
56
|
+
s = normDate(s)
|
|
57
|
+
if (ignoreCase) s = s.toLowerCase()
|
|
58
|
+
return s
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Canonical key text: equal keys from MySQL / PostgreSQL / ES / files compare equal. */
|
|
62
|
+
export function normKey(v, opts = {}) {
|
|
63
|
+
if (v === null || v === undefined) return '\u0000NULL'
|
|
64
|
+
if (typeof v === 'number' || typeof v === 'bigint') return String(v)
|
|
65
|
+
const s = normText(v, opts)
|
|
66
|
+
// "1.00" and 1 are the same key; codes with leading zeros ("007") and long ids stay text.
|
|
67
|
+
if (CANON_INT.test(s) && s.length < 16) return String(Number(s))
|
|
68
|
+
return s
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Are two values equal under a compare rule? Numbers (or numeric strings on
|
|
73
|
+
* both sides) use the tolerance — absolute, or relative to the larger
|
|
74
|
+
* magnitude — everything else compares as normalized text. NULL equals NULL only.
|
|
75
|
+
*/
|
|
76
|
+
export function valuesEqual(a, b, rule = {}, options = {}) {
|
|
77
|
+
const an = a === null || a === undefined
|
|
78
|
+
const bn = b === null || b === undefined
|
|
79
|
+
if (an || bn) return an && bn
|
|
80
|
+
const na = toNumber(a)
|
|
81
|
+
const nb = toNumber(b)
|
|
82
|
+
if (na !== undefined && nb !== undefined) {
|
|
83
|
+
const diff = Math.abs(na - nb)
|
|
84
|
+
const tol = Math.max(0, Number(rule.tolerance) || 0)
|
|
85
|
+
const scale = Math.max(Math.abs(na), Math.abs(nb))
|
|
86
|
+
const eps = 1e-9 * Math.max(1, scale) // float noise: 0.1 + 0.2 vs 0.3
|
|
87
|
+
return rule.relative ? diff <= tol * scale + eps : diff <= tol + eps
|
|
88
|
+
}
|
|
89
|
+
return normText(a, options) === normText(b, options)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ------------------------------------------------------------ spec
|
|
93
|
+
|
|
94
|
+
function mapping(list, what, max) {
|
|
95
|
+
if (!Array.isArray(list)) return []
|
|
96
|
+
if (list.length > max) throw new UserError('nl2sql/invalid', `${what}最多 ${max} 个`)
|
|
97
|
+
return list.map((x) => {
|
|
98
|
+
const item = typeof x === 'string' ? { left: x } : (x ?? {})
|
|
99
|
+
const left = String(item.left ?? '').trim()
|
|
100
|
+
const right = String(item.right ?? '').trim() || left
|
|
101
|
+
if (!left) throw new UserError('nl2sql/invalid', `${what}需要填写左侧列名`)
|
|
102
|
+
return { ...item, left, right }
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Validate the comparison part of a definition (keys, compare columns, options). */
|
|
107
|
+
export function normalizeSpec({ keys, compare, options } = {}) {
|
|
108
|
+
const k = mapping(keys, '键列', MAX_KEYS).map(({ left, right }) => ({ left, right }))
|
|
109
|
+
if (!k.length) throw new UserError('nl2sql/invalid', '至少需要一个键列(用于把两边的行对上)')
|
|
110
|
+
const c = mapping(compare, '比较列', MAX_COMPARE).map(({ left, right, tolerance, relative }) => {
|
|
111
|
+
const tol = tolerance === undefined || tolerance === null || tolerance === '' ? 0 : Number(tolerance)
|
|
112
|
+
if (!Number.isFinite(tol) || tol < 0) throw new UserError('nl2sql/invalid', `比较列 ${left} 的容差必须是非负数`)
|
|
113
|
+
return { left, right, tolerance: tol, relative: Boolean(relative) }
|
|
114
|
+
})
|
|
115
|
+
return { keys: k, compare: c, options: { trim: options?.trim !== false, ignoreCase: Boolean(options?.ignoreCase) } }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function columnIndex(result, name, side, what) {
|
|
119
|
+
const exact = result.columns.findIndex(c => c.name === name)
|
|
120
|
+
if (exact >= 0) return exact
|
|
121
|
+
const ci = result.columns.findIndex(c => c.name.toLowerCase() === name.toLowerCase())
|
|
122
|
+
if (ci >= 0) return ci
|
|
123
|
+
throw new UserError('nl2sql/invalid', `${side}结果中没有${what}「${name}」。可用列:${result.columns.map(c => c.name).join('、')}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const labelOf = (m) => (m.left === m.right ? m.left : `${m.left}/${m.right}`)
|
|
127
|
+
|
|
128
|
+
// ------------------------------------------------------------ engine
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Compare two query results (pure; no service). `left` / `right` are results
|
|
132
|
+
* ({ columns:[{name,kind,masked?}], rows, truncated? }); `spec` from normalizeSpec.
|
|
133
|
+
* Returns { summary, tables: { summary, mismatches, onlyLeft, onlyRight }, diffs, warnings }.
|
|
134
|
+
*/
|
|
135
|
+
export function compareResults(left, right, spec, { leftName = '左侧', rightName = '右侧', detailLimit = DETAIL_LIMIT, recordDiffs = RECORD_DIFFS } = {}) {
|
|
136
|
+
const { keys, compare, options } = spec
|
|
137
|
+
const lk = keys.map(k => columnIndex(left, k.left, '左侧', '键列'))
|
|
138
|
+
const rk = keys.map(k => columnIndex(right, k.right, '右侧', '键列'))
|
|
139
|
+
const lc = compare.map(c => columnIndex(left, c.left, '左侧', '比较列'))
|
|
140
|
+
const rc = compare.map(c => columnIndex(right, c.right, '右侧', '比较列'))
|
|
141
|
+
const warnings = []
|
|
142
|
+
for (const [res, idx, side] of [[left, lk, leftName], [right, rk, rightName]]) {
|
|
143
|
+
const masked = idx.filter(i => res.columns[i].masked).map(i => res.columns[i].name)
|
|
144
|
+
if (masked.length) warnings.push(`${side}的键列 ${masked.join('、')} 被脱敏,脱敏后的值可能无法正确匹配`)
|
|
145
|
+
if (res.truncated) warnings.push(`${side}结果超过 ${(res.rows?.length ?? 0).toLocaleString('en-US')} 行被截断,只比较了已加载的行,对账结果不完整`)
|
|
146
|
+
}
|
|
147
|
+
const db = new DatabaseSync(':memory:')
|
|
148
|
+
try {
|
|
149
|
+
const kc = keys.map((_, i) => `k${i}`)
|
|
150
|
+
const vc = compare.map((_, i) => `c${i}`)
|
|
151
|
+
const dk = keys.map((_, i) => `d${i}`) // display key values (original)
|
|
152
|
+
for (const t of ['L', 'R']) {
|
|
153
|
+
db.exec(`CREATE TABLE ${t} (${[...kc.map(c => `${c} TEXT NOT NULL`), ...dk, ...vc, '_row INTEGER'].join(', ')}, PRIMARY KEY (${kc.join(', ')})) WITHOUT ROWID`)
|
|
154
|
+
}
|
|
155
|
+
const bindable = (v) => (v === undefined ? null : typeof v === 'boolean' ? (v ? 1 : 0) : typeof v === 'bigint' ? String(v) : v !== null && typeof v === 'object' ? JSON.stringify(v) : v)
|
|
156
|
+
const load = (table, res, kIdx, cIdx) => {
|
|
157
|
+
const stmt = db.prepare(`INSERT OR IGNORE INTO ${table} VALUES (${new Array(kc.length * 2 + vc.length + 1).fill('?').join(', ')})`)
|
|
158
|
+
const totals = compare.map(() => ({ sum: 0, numeric: 0 }))
|
|
159
|
+
let unique = 0
|
|
160
|
+
let dups = 0
|
|
161
|
+
db.exec('BEGIN')
|
|
162
|
+
try {
|
|
163
|
+
res.rows.forEach((row, n) => {
|
|
164
|
+
const values = cIdx.map(i => bindable(row[i]))
|
|
165
|
+
const r = stmt.run(...kIdx.map(i => normKey(row[i], options)), ...kIdx.map(i => bindable(row[i])), ...values, n)
|
|
166
|
+
if (Number(r.changes) === 1) {
|
|
167
|
+
unique++
|
|
168
|
+
values.forEach((v, j) => { const x = toNumber(v); if (x !== undefined) { totals[j].sum += x; totals[j].numeric++ } })
|
|
169
|
+
} else dups++
|
|
170
|
+
})
|
|
171
|
+
db.exec('COMMIT')
|
|
172
|
+
} catch (e) { db.exec('ROLLBACK'); throw e }
|
|
173
|
+
return { rows: res.rows.length, unique, duplicates: dups, totals }
|
|
174
|
+
}
|
|
175
|
+
const L = load('L', left, lk, lc)
|
|
176
|
+
const R = load('R', right, rk, rc)
|
|
177
|
+
if (L.duplicates) warnings.push(`${leftName}有 ${L.duplicates} 行键重复,重复行未参与比较(以第一行为准)`)
|
|
178
|
+
if (R.duplicates) warnings.push(`${rightName}有 ${R.duplicates} 行键重复,重复行未参与比较(以第一行为准)`)
|
|
179
|
+
db.function('n2s_ne', { deterministic: true }, (i, a, b) => (valuesEqual(a, b, compare[i], options) ? 0 : 1))
|
|
180
|
+
const on = kc.map(c => `L.${c} = R.${c}`).join(' AND ')
|
|
181
|
+
const ne = (i) => `n2s_ne(${i}, L.c${i}, R.c${i})`
|
|
182
|
+
const agg = db.prepare(`SELECT COUNT(*) AS matched${compare.map((_, i) => `, COALESCE(SUM(${ne(i)}), 0) AS m${i}`).join('')}${compare.length ? `, COALESCE(SUM(${compare.map((_, i) => ne(i)).join(' OR ')}), 0) AS mm` : ', 0 AS mm'} FROM L JOIN R ON ${on}`).get()
|
|
183
|
+
const matched = Number(agg.matched)
|
|
184
|
+
const mismatchedRows = Number(agg.mm)
|
|
185
|
+
const onlyLeft = L.unique - matched
|
|
186
|
+
const onlyRight = R.unique - matched
|
|
187
|
+
const union = matched + onlyLeft + onlyRight
|
|
188
|
+
const columns = compare.map((c, i) => {
|
|
189
|
+
const lt = L.totals[i]
|
|
190
|
+
const rt = R.totals[i]
|
|
191
|
+
const numeric = lt.numeric > 0 || rt.numeric > 0
|
|
192
|
+
return {
|
|
193
|
+
left: c.left, right: c.right, label: labelOf(c), tolerance: c.tolerance, relative: c.relative,
|
|
194
|
+
mismatches: Number(agg[`m${i}`]),
|
|
195
|
+
...(numeric ? { leftTotal: round(lt.sum), rightTotal: round(rt.sum), diff: round(lt.sum - rt.sum) } : {}),
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
const summary = {
|
|
199
|
+
leftRows: L.rows, rightRows: R.rows, leftUnique: L.unique, rightUnique: R.unique,
|
|
200
|
+
duplicates: { left: L.duplicates, right: R.duplicates },
|
|
201
|
+
matched, onlyLeft, onlyRight, mismatchedRows,
|
|
202
|
+
consistent: matched - mismatchedRows,
|
|
203
|
+
keyMatchRate: union ? matched / union : 1,
|
|
204
|
+
matchRate: union ? (matched - mismatchedRows) / union : 1,
|
|
205
|
+
columns,
|
|
206
|
+
truncated: { left: Boolean(left.truncated), right: Boolean(right.truncated) },
|
|
207
|
+
complete: !left.truncated && !right.truncated,
|
|
208
|
+
}
|
|
209
|
+
// Totals follow from the row comparison (each matched row agrees within its tolerance).
|
|
210
|
+
summary.hasDiff = onlyLeft > 0 || onlyRight > 0 || mismatchedRows > 0
|
|
211
|
+
|
|
212
|
+
// ---- detail tables, shaped like query results
|
|
213
|
+
const keyCols = keys.map((k, i) => ({ name: k.left, kind: left.columns[lk[i]].kind === 'number' ? 'number' : 'text' }))
|
|
214
|
+
const numericCol = (i) => columns[i].leftTotal !== undefined
|
|
215
|
+
const mmCols = [...keyCols]
|
|
216
|
+
compare.forEach((c, i) => {
|
|
217
|
+
mmCols.push({ name: `${labelOf(c)} [${leftName}]`, kind: numericCol(i) ? 'number' : 'text' }, { name: `${labelOf(c)} [${rightName}]`, kind: numericCol(i) ? 'number' : 'text' })
|
|
218
|
+
if (numericCol(i)) mmCols.push({ name: `${labelOf(c)} 差值`, kind: 'number' })
|
|
219
|
+
})
|
|
220
|
+
mmCols.push({ name: '差异列', kind: 'text' })
|
|
221
|
+
const mmRows = compare.length ? db.prepare(`SELECT ${[...dk.map(c => `L.${c}`), ...compare.flatMap((_, i) => [`L.c${i} AS l${i}`, `R.c${i} AS r${i}`, `${ne(i)} AS f${i}`])].join(', ')} FROM L JOIN R ON ${on} WHERE ${compare.map((_, i) => ne(i)).join(' OR ')} ORDER BY L._row LIMIT ${detailLimit + 1}`).all() : []
|
|
222
|
+
const mismatchRows = mmRows.slice(0, detailLimit).map(r => {
|
|
223
|
+
const out = dk.map(c => r[c])
|
|
224
|
+
const bad = []
|
|
225
|
+
compare.forEach((c, i) => {
|
|
226
|
+
out.push(r[`l${i}`], r[`r${i}`])
|
|
227
|
+
if (numericCol(i)) { const a = toNumber(r[`l${i}`]); const b = toNumber(r[`r${i}`]); out.push(a !== undefined && b !== undefined ? round(a - b) : null) }
|
|
228
|
+
if (r[`f${i}`]) bad.push(labelOf(c))
|
|
229
|
+
})
|
|
230
|
+
out.push(bad.join('、'))
|
|
231
|
+
return out
|
|
232
|
+
})
|
|
233
|
+
const sideTable = (from, other, res, cIdx) => {
|
|
234
|
+
const cols = [...keyCols, ...compare.map((c, i) => ({ name: from === 'L' ? c.left : c.right, kind: numericCol(i) ? 'number' : (res.columns[cIdx[i]].kind === 'time' ? 'time' : 'text') }))]
|
|
235
|
+
const rows = db.prepare(`SELECT ${[...dk, ...vc].join(', ')} FROM ${from} WHERE NOT EXISTS (SELECT 1 FROM ${other} WHERE ${kc.map(c => `${other}.${c} = ${from}.${c}`).join(' AND ')}) ORDER BY _row LIMIT ${detailLimit + 1}`).all()
|
|
236
|
+
return { columns: cols, rows: rows.slice(0, detailLimit).map(r => [...dk, ...vc].map(c => r[c])), more: rows.length > detailLimit }
|
|
237
|
+
}
|
|
238
|
+
const ol = sideTable('L', 'R', left, lc)
|
|
239
|
+
const or = sideTable('R', 'L', right, rc)
|
|
240
|
+
const sumCols = [{ name: '项目', kind: 'text' }, { name: leftName, kind: 'number' }, { name: rightName, kind: 'number' }, { name: '差值', kind: 'number' }]
|
|
241
|
+
const sumRows = [
|
|
242
|
+
['行数', L.rows, R.rows, L.rows - R.rows],
|
|
243
|
+
['匹配的键', matched, matched, 0],
|
|
244
|
+
['仅一侧存在', onlyLeft, onlyRight, onlyLeft - onlyRight],
|
|
245
|
+
...(compare.length ? [['值不一致的行', mismatchedRows, mismatchedRows, 0]] : []),
|
|
246
|
+
...columns.filter(c => c.leftTotal !== undefined).map(c => [`合计 ${c.label}`, c.leftTotal, c.rightTotal, c.diff]),
|
|
247
|
+
]
|
|
248
|
+
const table = (columns, rows, more) => ({ columns, rows, rowCount: rows.length, truncated: Boolean(more) })
|
|
249
|
+
const tables = {
|
|
250
|
+
summary: table(sumCols, sumRows, false),
|
|
251
|
+
mismatches: table(mmCols, mismatchRows, mmRows.length > detailLimit),
|
|
252
|
+
onlyLeft: table(ol.columns, ol.rows, ol.more),
|
|
253
|
+
onlyRight: table(or.columns, or.rows, or.more),
|
|
254
|
+
}
|
|
255
|
+
// First differences for the run record (compact objects).
|
|
256
|
+
const keyObj = (row) => Object.fromEntries(keys.map((k, i) => [k.left, row[i]]))
|
|
257
|
+
const diffs = []
|
|
258
|
+
for (const row of mismatchRows) {
|
|
259
|
+
if (diffs.length >= recordDiffs) break
|
|
260
|
+
let p = keys.length
|
|
261
|
+
const values = {}
|
|
262
|
+
compare.forEach((c, i) => { values[labelOf(c)] = { left: row[p], right: row[p + 1] }; p += numericCol(i) ? 3 : 2 })
|
|
263
|
+
diffs.push({ type: 'mismatch', key: keyObj(row), columns: row[row.length - 1], values })
|
|
264
|
+
}
|
|
265
|
+
for (const [type, t] of [['onlyLeft', ol], ['onlyRight', or]]) {
|
|
266
|
+
for (const row of t.rows) {
|
|
267
|
+
if (diffs.length >= recordDiffs) break
|
|
268
|
+
diffs.push({ type, key: keyObj(row), values: Object.fromEntries(compare.map((c, i) => [labelOf(c), row[keys.length + i]])) })
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { summary, tables, diffs, warnings }
|
|
272
|
+
} finally {
|
|
273
|
+
db.close()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function round(n) {
|
|
278
|
+
return Number.isFinite(n) ? Number(n.toFixed(6)) : n
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const pct = (r) => `${(r * 100).toFixed(r === 1 || r === 0 ? 0 : 2)}%`
|
|
282
|
+
|
|
283
|
+
/** Markdown summary for IM pushes and the agent. */
|
|
284
|
+
export function summaryMarkdown(run) {
|
|
285
|
+
if (run.error) return `**对账失败**:${run.error}`
|
|
286
|
+
const s = run.summary
|
|
287
|
+
const lines = []
|
|
288
|
+
lines.push(s.hasDiff ? `**发现差异** · 一致率 **${pct(s.matchRate)}**` : `<font color='green'>**两边一致**</font> · 一致率 **${pct(s.matchRate)}**`)
|
|
289
|
+
lines.push('')
|
|
290
|
+
lines.push(`- 左侧 **${run.left.datasourceName}**:${s.leftRows.toLocaleString('en-US')} 行${s.truncated.left ? '(已截断)' : ''}`)
|
|
291
|
+
lines.push(`- 右侧 **${run.right.datasourceName}**:${s.rightRows.toLocaleString('en-US')} 行${s.truncated.right ? '(已截断)' : ''}`)
|
|
292
|
+
lines.push(`- 匹配的键 ${s.matched.toLocaleString('en-US')},仅左侧 ${s.onlyLeft > 0 ? `<font color='red'>${s.onlyLeft}</font>` : 0},仅右侧 ${s.onlyRight > 0 ? `<font color='red'>${s.onlyRight}</font>` : 0}${s.columns.length ? `,值不一致 ${s.mismatchedRows > 0 ? `<font color='red'>${s.mismatchedRows}</font>` : 0} 行` : ''}`)
|
|
293
|
+
const withTotals = s.columns.filter(c => c.leftTotal !== undefined)
|
|
294
|
+
if (s.columns.length) {
|
|
295
|
+
lines.push('')
|
|
296
|
+
lines.push(`| 比较列 | 不一致行 |${withTotals.length ? ' 左侧合计 | 右侧合计 | 差值 |' : ''}`)
|
|
297
|
+
lines.push(`|---|---|${withTotals.length ? '---|---|---|' : ''}`)
|
|
298
|
+
for (const c of s.columns) lines.push(`| ${c.label}${c.tolerance ? `(容差 ${c.relative ? `${c.tolerance * 100}%` : c.tolerance})` : ''} | ${c.mismatches} |${withTotals.length ? (c.leftTotal !== undefined ? ` ${fmtNum(c.leftTotal)} | ${fmtNum(c.rightTotal)} | ${fmtNum(c.diff)} |` : ' — | — | — |') : ''}`)
|
|
299
|
+
}
|
|
300
|
+
if (run.warnings?.length) { lines.push(''); for (const w of run.warnings) lines.push(`> ⚠️ ${w}`) }
|
|
301
|
+
return lines.join('\n')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ------------------------------------------------------------ service methods
|
|
305
|
+
|
|
306
|
+
const RUNTIME = ['lastRun', 'lastScheduledAt']
|
|
307
|
+
|
|
308
|
+
export const methods = {
|
|
309
|
+
/**
|
|
310
|
+
* Run one reconciliation: both queries via runQuery (actor-aware), compare,
|
|
311
|
+
* and remember the detail tables as results (ResultView / export / push).
|
|
312
|
+
*/
|
|
313
|
+
async reconcile({ left, right, keys, compare, options, title, actor, sessionId, source = 'reconcile' }) {
|
|
314
|
+
const spec = normalizeSpec({ keys, compare, options })
|
|
315
|
+
actor ??= this.actorFor(sessionId)
|
|
316
|
+
if (!left?.sql || !right?.sql) throw new UserError('nl2sql/invalid', '两侧都需要填写 SQL')
|
|
317
|
+
const lds = this.datasourceForSession(sessionId, left.datasourceId ?? left.datasource)
|
|
318
|
+
const rds = this.datasourceForSession(sessionId, right.datasourceId ?? right.datasource)
|
|
319
|
+
this.assertAccess(actor, lds)
|
|
320
|
+
this.assertAccess(actor, rds)
|
|
321
|
+
const name = String(title ?? '').trim().slice(0, 80) || `${lds.name} ↔ ${rds.name}`
|
|
322
|
+
const started = Date.now()
|
|
323
|
+
const run = async (ds, sql, side) => {
|
|
324
|
+
try {
|
|
325
|
+
return await this.runQuery({ sql, datasourceId: ds.id, sessionId, actor, title: `对账 · ${name} · ${side}`, source, confirmScan: true, maxRows: SIDE_ROW_CAP, chart: { type: 'table' } })
|
|
326
|
+
} catch (error) {
|
|
327
|
+
throw new UserError(error.code ?? 'nl2sql/error', `${side}(${ds.name})查询失败:${error.message}`)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const [lr, rr] = await Promise.all([run(lds, left.sql, '左侧'), run(rds, right.sql, '右侧')])
|
|
331
|
+
const sameName = lds.name === rds.name
|
|
332
|
+
const leftName = sameName ? '左侧' : lds.name
|
|
333
|
+
const rightName = sameName ? '右侧' : rds.name
|
|
334
|
+
const cmp = compareResults(lr, rr, spec, { leftName, rightName })
|
|
335
|
+
const base = { datasourceId: lds.id, datasourceName: `${lds.name} ↔ ${rds.name}`, sql: `-- 左侧(${lds.name})\n${lr.sql}\n\n-- 右侧(${rds.name})\n${rr.sql}`, elapsedMs: Date.now() - started, chart: { type: 'table' }, reconcile: true }
|
|
336
|
+
const make = (key, label) => {
|
|
337
|
+
const t = cmp.tables[key]
|
|
338
|
+
const r = { ...base, resultId: newId('r'), title: `${name} · ${label}`, columns: t.columns, rows: t.rows, rowCount: t.rowCount, truncated: t.truncated, suggestedChart: { type: 'table' }, createdAt: Date.now(), reconcilePart: key }
|
|
339
|
+
this.remember(r)
|
|
340
|
+
return r
|
|
341
|
+
}
|
|
342
|
+
const results = [
|
|
343
|
+
make('summary', '汇总'),
|
|
344
|
+
make('mismatches', '值不一致'),
|
|
345
|
+
make('onlyLeft', `仅${leftName}`),
|
|
346
|
+
make('onlyRight', `仅${rightName}`),
|
|
347
|
+
]
|
|
348
|
+
const out = {
|
|
349
|
+
title: name,
|
|
350
|
+
at: started,
|
|
351
|
+
elapsedMs: Date.now() - started,
|
|
352
|
+
left: { datasourceId: lds.id, datasourceName: lds.name, resultId: lr.resultId, rows: lr.rowCount, truncated: lr.truncated, label: leftName },
|
|
353
|
+
right: { datasourceId: rds.id, datasourceName: rds.name, resultId: rr.resultId, rows: rr.rowCount, truncated: rr.truncated, label: rightName },
|
|
354
|
+
keys: spec.keys,
|
|
355
|
+
compare: spec.compare,
|
|
356
|
+
options: spec.options,
|
|
357
|
+
summary: cmp.summary,
|
|
358
|
+
warnings: cmp.warnings,
|
|
359
|
+
diffs: cmp.diffs,
|
|
360
|
+
results,
|
|
361
|
+
}
|
|
362
|
+
out.markdown = summaryMarkdown(out)
|
|
363
|
+
return out
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------- saved jobs
|
|
367
|
+
|
|
368
|
+
listReconciles() {
|
|
369
|
+
return (this.state.reconciles ?? []).map(r => ({
|
|
370
|
+
...r,
|
|
371
|
+
leftName: this.findDatasource(r.left?.datasourceId)?.name ?? r.left?.datasourceId,
|
|
372
|
+
rightName: this.findDatasource(r.right?.datasourceId)?.name ?? r.right?.datasourceId,
|
|
373
|
+
channelName: r.channelId ? (this.state.channels.find(c => c.id === r.channelId)?.name ?? null) : null,
|
|
374
|
+
}))
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
/** Validate a definition (shared by save and dry run). */
|
|
378
|
+
normalizeReconcile(input = {}, { allowNew = true } = {}) {
|
|
379
|
+
const existing = input.id ? (this.state.reconciles ?? []).find(r => r.id === input.id) : undefined
|
|
380
|
+
if (input.id && !existing && !allowNew) throw new UserError('nl2sql/invalid', '对账任务不存在')
|
|
381
|
+
const r = existing ? structuredClone(existing) : { id: newId('rc'), createdAt: Date.now() }
|
|
382
|
+
r.name = requireText(input.name ?? r.name, 'name', 80)
|
|
383
|
+
const side = (inp, cur, label) => {
|
|
384
|
+
const ds = this.requireDatasource(inp?.datasourceId ?? inp?.datasource ?? cur?.datasourceId)
|
|
385
|
+
const sql = requireText(inp?.sql ?? cur?.sql, `${label} SQL`, 20000)
|
|
386
|
+
admitSql(sql, { dialect: ds.type ?? 'mysql' })
|
|
387
|
+
return { datasourceId: ds.id, sql }
|
|
388
|
+
}
|
|
389
|
+
r.left = side(input.left, r.left, '左侧')
|
|
390
|
+
r.right = side(input.right, r.right, '右侧')
|
|
391
|
+
const spec = normalizeSpec({ keys: input.keys ?? r.keys, compare: input.compare ?? r.compare, options: input.options ?? r.options })
|
|
392
|
+
Object.assign(r, spec)
|
|
393
|
+
const sch = { ...(r.schedule ?? {}), ...(input.schedule ?? {}) }
|
|
394
|
+
const time = /^([01]\d|2[0-3]):[0-5]\d$/.test(String(sch.time ?? '')) ? sch.time : '09:00'
|
|
395
|
+
r.schedule = { time, enabled: Boolean(sch.enabled) }
|
|
396
|
+
if (input.channelId !== undefined) r.channelId = input.channelId && this.state.channels.some(c => c.id === input.channelId) ? input.channelId : null
|
|
397
|
+
r.channelId ??= null
|
|
398
|
+
r.notifyOn = oneOf(input.notifyOn ?? r.notifyOn ?? 'diff', ['diff', 'always'], 'notifyOn')
|
|
399
|
+
r.updatedAt = Date.now()
|
|
400
|
+
return r
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
async saveReconcile(input) {
|
|
404
|
+
const r = this.normalizeReconcile(input, { allowNew: false })
|
|
405
|
+
const current = (this.state.reconciles ?? []).find(x => x.id === r.id)
|
|
406
|
+
// Runtime fields come from the stored object (a run may have finished meanwhile).
|
|
407
|
+
if (current) for (const k of RUNTIME) { if (current[k] === undefined) delete r[k]; else r[k] = current[k] }
|
|
408
|
+
// New or rescheduled: the schedule starts counting from now (no immediate catch-up run).
|
|
409
|
+
if (!current || current.schedule?.time !== r.schedule.time || (!current.schedule?.enabled && r.schedule.enabled)) r.lastScheduledAt = Date.now()
|
|
410
|
+
this.state.reconciles = upsert(this.state.reconciles ?? [], r)
|
|
411
|
+
await this.save()
|
|
412
|
+
return r
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
async deleteReconcile(id) {
|
|
416
|
+
this.state.reconciles = (this.state.reconciles ?? []).filter(r => r.id !== id)
|
|
417
|
+
await this.save()
|
|
418
|
+
return { deleted: id }
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Run a saved job ({ id }) — recorded, pushed per notifyOn unless dryRun /
|
|
423
|
+
* push:false — or dry-run an unsaved definition ({ definition }).
|
|
424
|
+
*/
|
|
425
|
+
async runReconcile({ id, definition, dryRun = false, push = true, source = 'ui' } = {}) {
|
|
426
|
+
if (!id) {
|
|
427
|
+
const draft = this.normalizeReconcile({ ...(definition ?? {}), id: undefined, name: definition?.name || '试运行' })
|
|
428
|
+
return this.reconcile({ ...draft, title: draft.name, source: 'reconcile' })
|
|
429
|
+
}
|
|
430
|
+
const job = (this.state.reconciles ?? []).find(r => r.id === id)
|
|
431
|
+
if (!job) throw new UserError('nl2sql/invalid', '对账任务不存在')
|
|
432
|
+
if (dryRun) return this.reconcile({ ...job, title: job.name, source: 'reconcile' })
|
|
433
|
+
return this.withJobLock(`job:reconcile:${id}`, () => this.runSavedReconcile(job, { push, source }))
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
async runSavedReconcile(job, { push = true, source = 'ui' } = {}) {
|
|
437
|
+
const runId = newId('rr')
|
|
438
|
+
let out
|
|
439
|
+
let error
|
|
440
|
+
try { out = await this.reconcile({ ...job, title: job.name, source: 'reconcile' }) } catch (e) { error = e }
|
|
441
|
+
const rec = {
|
|
442
|
+
id: runId, reconcileId: job.id, name: job.name, at: Date.now(), source,
|
|
443
|
+
status: error ? 'error' : out.summary.hasDiff ? 'diff' : 'ok',
|
|
444
|
+
...(error ? { error: String(error.message).slice(0, 500) } : {
|
|
445
|
+
elapsedMs: out.elapsedMs, left: out.left, right: out.right, summary: out.summary, warnings: out.warnings,
|
|
446
|
+
diffs: out.diffs.slice(0, RECORD_DIFFS), resultIds: out.results.map(r => r.resultId),
|
|
447
|
+
}),
|
|
448
|
+
}
|
|
449
|
+
let pushed = false
|
|
450
|
+
if (push && job.channelId && (error || rec.status === 'diff' || job.notifyOn === 'always')) {
|
|
451
|
+
try {
|
|
452
|
+
const title = error ? `❌ 对账失败:${job.name}` : rec.status === 'diff' ? `⚠️ 对账差异:${job.name}` : `✅ 对账一致:${job.name}`
|
|
453
|
+
const results = error ? [] : out.results.filter((r, i) => i === 0 || r.rows.length > 0)
|
|
454
|
+
await this.sendToChannel({ channelId: job.channelId, title, text: error ? `**对账失败**:${error.message}` : out.markdown, results, source: 'reconcile', template: error || rec.status === 'diff' ? 'red' : 'green' })
|
|
455
|
+
pushed = true
|
|
456
|
+
} catch (e) { rec.pushError = String(e.message).slice(0, 300) }
|
|
457
|
+
}
|
|
458
|
+
rec.pushed = pushed
|
|
459
|
+
this.records.put('reconcileRun', rec)
|
|
460
|
+
const cur = (this.state.reconciles ?? []).find(r => r.id === job.id)
|
|
461
|
+
if (cur) cur.lastRun = { at: rec.at, runId, status: rec.status, ...(error ? { error: rec.error } : { matchRate: out.summary.matchRate, matched: out.summary.matched, onlyLeft: out.summary.onlyLeft, onlyRight: out.summary.onlyRight, mismatchedRows: out.summary.mismatchedRows, complete: out.summary.complete }), pushed }
|
|
462
|
+
await this.save()
|
|
463
|
+
if (error) return { ...rec, ok: false }
|
|
464
|
+
return { ...out, runId, status: rec.status, pushed, ...(rec.pushError ? { pushError: rec.pushError } : {}) }
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
reconcileRuns({ id, limit = 50 } = {}) {
|
|
468
|
+
return this.records.list('reconcileRun', { ref: id, limit: Math.min(Math.max(Number(limit) || 50, 1), 500) })
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
/** Scheduler: each enabled job runs once per daily slot (catches up once after downtime). */
|
|
472
|
+
jobs_reconcile(now) {
|
|
473
|
+
return (this.state.reconciles ?? []).filter(r => r.schedule?.enabled).map(r => ({
|
|
474
|
+
id: `reconcile:${r.id}`,
|
|
475
|
+
due: (at) => (r.lastScheduledAt ?? r.createdAt ?? 0) < lastDailySlot(r.schedule.time, at ?? now),
|
|
476
|
+
run: async () => {
|
|
477
|
+
const cur = (this.state.reconciles ?? []).find(x => x.id === r.id)
|
|
478
|
+
if (!cur) return
|
|
479
|
+
cur.lastScheduledAt = Date.now()
|
|
480
|
+
await this.runSavedReconcile(cur, { push: true, source: 'schedule' })
|
|
481
|
+
},
|
|
482
|
+
}))
|
|
483
|
+
},
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ------------------------------------------------------------ agent tool
|
|
487
|
+
|
|
488
|
+
const SIDE_PARAM = (helpers, label) => ({
|
|
489
|
+
type: 'object',
|
|
490
|
+
description: `${label} query.`,
|
|
491
|
+
properties: {
|
|
492
|
+
datasource: { ...helpers.DATASOURCE_PARAM, description: 'Data source id or name for this side. Omit to use the one bound to this session.' },
|
|
493
|
+
sql: { type: 'string', description: 'One read-only SQL statement in that data source\'s dialect. Return the key columns and the columns to compare; aggregate to the reconciliation grain (e.g. per day / per order) rather than raw rows when possible.' },
|
|
494
|
+
},
|
|
495
|
+
required: ['sql'],
|
|
496
|
+
additionalProperties: false,
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
function registerTool(ctx, service, helpers) {
|
|
500
|
+
const { displayMeta, textBlock } = helpers
|
|
501
|
+
ctx.tools.register({
|
|
502
|
+
name: 'nl2sql_reconcile',
|
|
503
|
+
description: 'Reconcile / compare two query results (对账 / 核对 / 对比) — typically the same business numbers from two different data sources, or two queries on one source. Runs both read-only queries, joins their rows on key columns, and reports matched keys, keys only in one side, value mismatches per compare column (with optional numeric tolerance) and per-side totals. The user sees a summary and the difference tables; you receive the summary plus the first differences.',
|
|
504
|
+
parameters: {
|
|
505
|
+
type: 'object',
|
|
506
|
+
properties: {
|
|
507
|
+
left: SIDE_PARAM(helpers, 'Left (reference) side'),
|
|
508
|
+
right: SIDE_PARAM(helpers, 'Right side'),
|
|
509
|
+
keys: { type: 'array', description: 'Key columns that identify a row on both sides (e.g. order id, or day + channel). right defaults to left when the names are the same.', items: { type: 'object', properties: { left: { type: 'string' }, right: { type: 'string' } }, required: ['left'], additionalProperties: false } },
|
|
510
|
+
compare: { type: 'array', description: 'Columns whose values must agree. tolerance: allowed absolute numeric difference (or a fraction like 0.001 when relative=true).', items: { type: 'object', properties: { left: { type: 'string' }, right: { type: 'string' }, tolerance: { type: 'number' }, relative: { type: 'boolean' } }, required: ['left'], additionalProperties: false } },
|
|
511
|
+
options: { type: 'object', properties: { trim: { type: 'boolean', description: 'Trim spaces before comparing text (default true).' }, ignoreCase: { type: 'boolean', description: 'Case-insensitive text comparison (default false).' } }, additionalProperties: false },
|
|
512
|
+
title: { type: 'string', description: 'Short title in the user\'s language.' },
|
|
513
|
+
},
|
|
514
|
+
required: ['left', 'right', 'keys'],
|
|
515
|
+
additionalProperties: false,
|
|
516
|
+
},
|
|
517
|
+
output: {
|
|
518
|
+
schema: { type: 'object' },
|
|
519
|
+
render: (_args, value) => textBlock(JSON.stringify(value.model, null, 1)),
|
|
520
|
+
presentationMeta: (_args, value) => value.display,
|
|
521
|
+
},
|
|
522
|
+
timeoutMs: 660000,
|
|
523
|
+
presentCall: (args) => ({ card: 'generic', title: args?.title ? `对账:${args.title}` : '对账 / 对比两份数据', kind: 'read' }),
|
|
524
|
+
execute: async (args, exec) => {
|
|
525
|
+
try {
|
|
526
|
+
const sessionId = exec.agent?.id
|
|
527
|
+
const actor = service.actorFor(sessionId)
|
|
528
|
+
service.assertCanWriteSql(actor, '自由编写对账 SQL')
|
|
529
|
+
const out = await service.reconcile({
|
|
530
|
+
left: { datasourceId: args.left?.datasource, sql: args.left?.sql },
|
|
531
|
+
right: { datasourceId: args.right?.datasource, sql: args.right?.sql },
|
|
532
|
+
keys: args.keys, compare: args.compare ?? [], options: args.options, title: args.title, actor, sessionId, source: 'agent',
|
|
533
|
+
})
|
|
534
|
+
// Detail rows reach the model only when both data sources allow rows.
|
|
535
|
+
const privacy = [out.left.datasourceId, out.right.datasourceId].map(id => service.settingsFor(service.findDatasource(id)).resultToModel)
|
|
536
|
+
const rows = privacy.every(p => p === 'rows')
|
|
537
|
+
const model = {
|
|
538
|
+
title: out.title,
|
|
539
|
+
left: out.left, right: out.right,
|
|
540
|
+
summary: { ...out.summary, matchRate: Number(out.summary.matchRate.toFixed(4)), keyMatchRate: Number(out.summary.keyMatchRate.toFixed(4)) },
|
|
541
|
+
warnings: out.warnings,
|
|
542
|
+
resultIds: Object.fromEntries(out.results.map(r => [r.reconcilePart, r.resultId])),
|
|
543
|
+
...(rows ? { firstDiffs: out.diffs.slice(0, 20) } : { firstDiffs: `hidden by privacy settings (${privacy.join('/')}); ${out.diffs.length} differences shown to the user` }),
|
|
544
|
+
}
|
|
545
|
+
const display = JSON.parse(JSON.stringify({ reconcile: true, title: out.title, left: out.left, right: out.right, summary: out.summary, warnings: out.warnings, results: out.results.map(r => displayMeta(r)) }))
|
|
546
|
+
return { model, display }
|
|
547
|
+
} catch (error) {
|
|
548
|
+
throw error instanceof UserError ? new Error(error.message) : error
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export const FEATURE = {
|
|
555
|
+
name: 'reconcile',
|
|
556
|
+
methods,
|
|
557
|
+
rpc: (service) => ({
|
|
558
|
+
listReconciles: () => service.listReconciles(),
|
|
559
|
+
saveReconcile: (p) => service.saveReconcile(p ?? {}),
|
|
560
|
+
deleteReconcile: (p) => service.deleteReconcile(p?.id),
|
|
561
|
+
runReconcile: (p) => service.runReconcile({ id: p?.id, definition: p?.definition, dryRun: Boolean(p?.dryRun), push: p?.push !== false, source: 'ui' }),
|
|
562
|
+
reconcileRuns: (p) => service.reconcileRuns(p ?? {}),
|
|
563
|
+
}),
|
|
564
|
+
tools: (ctx, service, helpers) => registerTool(ctx, service, helpers),
|
|
565
|
+
}
|
package/service/shared.js
CHANGED
|
@@ -217,6 +217,20 @@ export const RECORD_KINDS = [['history', 'history'], ['feedback', 'feedback'], [
|
|
|
217
217
|
export const RECORD_LIMITS = { history: 20000, feedback: 5000, delivery: 5000, alertEvent: 5000, admin: 5000, evalRun: 200, schemaChange: 1000 }
|
|
218
218
|
/** Record kinds and the field used as their filter key. */
|
|
219
219
|
export const RECORD_REF = { history: 'datasourceId', feedback: 'datasourceId', delivery: 'channelId', alertEvent: 'alertId', admin: 'action', evalRun: 'datasourceId', schemaChange: 'datasourceId' }
|
|
220
|
+
// Reconciliation runs (service/reconcile.js): summary + first 200 differences per run.
|
|
221
|
+
RECORD_LIMITS.reconcileRun = 2000
|
|
222
|
+
RECORD_REF.reconcileRun = 'reconcileId'
|
|
223
|
+
// Freshness stale / recovered events and data quality runs (service/freshness.js).
|
|
224
|
+
RECORD_LIMITS.freshnessEvent = 5000
|
|
225
|
+
RECORD_REF.freshnessEvent = 'datasourceId'
|
|
226
|
+
RECORD_LIMITS.qualityRun = 1000
|
|
227
|
+
RECORD_REF.qualityRun = 'datasourceId'
|
|
228
|
+
// Lark Bitable sync runs (service/present.js).
|
|
229
|
+
RECORD_LIMITS.bitableRun = 2000
|
|
230
|
+
RECORD_REF.bitableRun = 'syncId'
|
|
231
|
+
// Daily digest runs (service/proactive.js).
|
|
232
|
+
RECORD_LIMITS.digestRun = 1000
|
|
233
|
+
RECORD_REF.digestRun = 'digestId'
|
|
220
234
|
|
|
221
235
|
/**
|
|
222
236
|
* Growing logs (audit history, feedback, push deliveries, alert events, admin
|
|
@@ -422,3 +436,16 @@ export function fmtNum(v) {
|
|
|
422
436
|
return Number.isInteger(n) ? n.toLocaleString('en-US') : n.toFixed(2)
|
|
423
437
|
}
|
|
424
438
|
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Daily slot helper for scheduler jobs: the most recent local `HH:MM` slot at or
|
|
442
|
+
* before `now` (yesterday's when today's is still ahead). A job is due when its
|
|
443
|
+
* last run is older than that slot (catches up once after downtime).
|
|
444
|
+
*/
|
|
445
|
+
export function lastDailySlot(hhmm, now = new Date()) {
|
|
446
|
+
const [hh, mm] = String(hhmm || '09:00').split(':').map(Number)
|
|
447
|
+
const slot = new Date(now)
|
|
448
|
+
slot.setHours(hh || 0, mm || 0, 0, 0)
|
|
449
|
+
if (slot > now) slot.setDate(slot.getDate() - 1)
|
|
450
|
+
return slot.getTime()
|
|
451
|
+
}
|