logiparity 1.1.0 → 1.2.0
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 +18 -0
- package/README.md +16 -7
- package/docs/configuration.md +28 -5
- package/docs/recipes/socrata.md +38 -0
- package/docs/release.md +1 -1
- package/docs/report-formats.md +6 -2
- package/docs/report-schema.json +32 -6
- package/docs/report-schema.md +6 -4
- package/docs/scenario-report-schema.json +2 -2
- package/lib/adapters/dist/jsonpath.d.ts +2 -2
- package/lib/adapters/dist/jsonpath.js +14 -6
- package/lib/adapters/dist/rest.js +8 -2
- package/lib/cli/dist/version.d.ts +1 -1
- package/lib/cli/dist/version.js +1 -1
- package/lib/core/dist/aggregates.d.ts +8 -3
- package/lib/core/dist/aggregates.js +30 -3
- package/lib/core/dist/calendar.d.ts +3 -0
- package/lib/core/dist/calendar.js +30 -0
- package/lib/core/dist/config.d.ts +33 -6
- package/lib/core/dist/config.js +41 -2
- package/lib/core/dist/engine.js +13 -6
- package/lib/core/dist/groups.d.ts +1 -1
- package/lib/core/dist/groups.js +33 -11
- package/lib/core/dist/html-patterns.d.ts +26 -0
- package/lib/core/dist/html-patterns.js +11 -3
- package/lib/core/dist/html.js +23 -16
- package/lib/core/dist/human.js +30 -3
- package/lib/core/dist/labels.d.ts +12 -3
- package/lib/core/dist/money-display.js +12 -7
- package/lib/core/dist/normalize.js +10 -2
- package/lib/core/dist/reconciliation-display.js +8 -5
- package/lib/core/dist/reconciliation.js +29 -23
- package/lib/core/dist/source-state.d.ts +1 -0
- package/lib/core/dist/source-state.js +2 -1
- package/lib/core/dist/types.d.ts +3 -2
- package/lib/scenarios/dist/config.d.ts +14 -0
- package/package.json +2 -1
package/lib/core/dist/html.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DecimalSum, displayDecimal, negateDecimal, sumDecimal } from './aggregates.js';
|
|
1
2
|
import { reportHeader, reportHero, findingDonut, findingsSection, reportDocument } from './html-layout.js';
|
|
2
3
|
import { redactTree, secretValues } from './diagnostics.js';
|
|
3
4
|
import { aggregateDisplay, rawAggregateDisplay, aggregateDifference, aggregateStatus } from './money-display.js';
|
|
@@ -147,50 +148,56 @@ function reconciliationCards(report, presentation) {
|
|
|
147
148
|
return partitions.flatMap(({ currency, parts }) => {
|
|
148
149
|
const actual = currency
|
|
149
150
|
? (aggregate.valuesByCurrency?.[source]?.[currency] ?? 0n)
|
|
150
|
-
: aggregate.values[source], reference = currency
|
|
151
|
+
: (aggregate.exactValues?.[source] ?? aggregate.values[source]), reference = currency
|
|
151
152
|
? (aggregate.valuesByCurrency?.[entity.reference]?.[currency] ?? 0n)
|
|
152
|
-
: aggregate.values[entity.reference];
|
|
153
|
+
: (aggregate.exactValues?.[entity.reference] ?? aggregate.values[entity.reference]);
|
|
153
154
|
if (actual === undefined || reference === undefined || actual === reference)
|
|
154
155
|
return [];
|
|
155
156
|
const definition = labels.entity(entity.entity)?.aggregates.find((item) => item.name === aggregate.name);
|
|
156
157
|
const rule = definition?.type === 'sum' ? labels.entity(entity.entity)?.compare[definition.field] : undefined;
|
|
157
|
-
const difference =
|
|
158
|
-
const amount = (value) => `${value
|
|
158
|
+
const difference = sumDecimal(actual, negateDecimal(String(reference)));
|
|
159
|
+
const amount = (value) => `${sumDecimal(value) !== '0' && !String(value).startsWith('-') ? '+' : ''}${(currency ?? rule?.currency) ? formatMoneyMajor(BigInt(value), currency ?? rule?.currency ?? 'USD') : displayDecimal(value)}`;
|
|
159
160
|
const buckets = [
|
|
160
161
|
{
|
|
161
162
|
label: `Missing rows (${numberText(parts.missingRows.count)})`,
|
|
162
|
-
value:
|
|
163
|
+
value: parts.missingRows.amount,
|
|
163
164
|
color: 'page',
|
|
164
|
-
show:
|
|
165
|
+
show: sumDecimal(parts.missingRows.amount) !== '0',
|
|
165
166
|
},
|
|
166
167
|
...[
|
|
167
168
|
{ label: 'Extra rows', part: parts.extraRows },
|
|
168
169
|
{ label: 'Value differences', part: parts.valueDifferences },
|
|
169
170
|
].map(({ label, part }) => ({
|
|
170
171
|
label: `${label} (${numberText(part?.count ?? 0)})`,
|
|
171
|
-
value:
|
|
172
|
+
value: part?.amount ?? 0n,
|
|
172
173
|
color: 'round',
|
|
173
174
|
show: !!part?.count,
|
|
174
175
|
})),
|
|
175
176
|
...parts.patterns.map((part) => ({
|
|
176
177
|
label: `${BigInt(report.groups?.[part.group]?.delta ?? 0) >= -3n && BigInt(report.groups?.[part.group]?.delta ?? 0) <= 3n ? 'Rounding' : 'Fixed differences'} (${numberText(part.count)})`,
|
|
177
|
-
value:
|
|
178
|
+
value: part.amount,
|
|
178
179
|
color: 'round',
|
|
179
|
-
show:
|
|
180
|
+
show: sumDecimal(part.amount) !== '0',
|
|
180
181
|
})),
|
|
181
182
|
{
|
|
182
183
|
label: `Uncertain lines${parts.uncertain?.keys.length ? ': ' + uncertainKeyText(parts.uncertain.keys) : ''}`,
|
|
183
|
-
value:
|
|
184
|
+
value: parts.uncertain?.amount ?? 0n,
|
|
184
185
|
color: 'tz',
|
|
185
186
|
show: !!parts.uncertain?.keys.length,
|
|
186
187
|
},
|
|
187
|
-
{ label: 'Unexplained remainder', value:
|
|
188
|
+
{ label: 'Unexplained remainder', value: parts.remainder, color: 'review', show: true },
|
|
188
189
|
].filter((part) => part.show);
|
|
189
|
-
const total =
|
|
190
|
-
const
|
|
190
|
+
const total = new DecimalSum();
|
|
191
|
+
for (const part of buckets)
|
|
192
|
+
total.add(String(part.value).startsWith('-') ? negateDecimal(part.value) : part.value);
|
|
193
|
+
const accounted = sumDecimal(parts.remainder) === '0' && !parts.uncertain?.keys.length;
|
|
191
194
|
const bars = buckets
|
|
192
|
-
.filter((part) => part.value)
|
|
193
|
-
.map((part) =>
|
|
195
|
+
.filter((part) => sumDecimal(part.value) !== '0')
|
|
196
|
+
.map((part) => {
|
|
197
|
+
const absolute = new DecimalSum();
|
|
198
|
+
absolute.add(String(part.value).startsWith('-') ? negateDecimal(part.value) : part.value);
|
|
199
|
+
return `<i style="width:${absolute.percentOf(total)}%;background:var(--c-${part.color})"></i>`;
|
|
200
|
+
})
|
|
194
201
|
.join('');
|
|
195
202
|
const rows = buckets
|
|
196
203
|
.map((part) => `<div><span><i style="background:var(--c-${part.color})"></i>${escape(part.label)}</span><b>${escape(amount(part.value))}${part.label === 'Unexplained remainder' && accounted ? ' · accounted for' : ''}</b></div>`)
|
|
@@ -311,7 +318,7 @@ export function htmlReport(report, presentation = {}) {
|
|
|
311
318
|
const causeCards = cards
|
|
312
319
|
.map((card, index) => {
|
|
313
320
|
const info = kindInfo[card.kind], group = groups[card.groups[0] ?? -1], words = group ? patternWords(group, presentation) : undefined;
|
|
314
|
-
return `<article class="card pat" style="--c:var(--c-${info.color});--soft:var(--c-${info.color}-soft)"><div class="ic">${icon(info.path)}</div><div class="kind">${escape(card.label)}</div><div class="delta">${escape(card.headline)}</div><p>${escape(card.hint)}</p><div class="viz" title="${escape(words?.sentence ?? '')}">${card.chart}</div><p class="next-step"><b>Next step:</b> ${escape(card.nextStep)}</p><button class="btn btn-soft" data-card="${index}" type="button" aria-pressed="false">Show these findings</button></article>`;
|
|
321
|
+
return `<article class="card pat" style="--c:var(--c-${info.color});--soft:var(--c-${info.color}-soft)"><div class="ic">${icon(info.path)}</div><div class="kind">${escape(card.label)}</div><div class="delta">${escape(card.headline)}</div><p${card.kind === 'formatting_difference' ? ' style="white-space:pre-wrap"' : ''}>${escape(card.hint)}</p><div class="viz" title="${escape(words?.sentence ?? '')}">${card.chart}</div><p class="next-step"><b>Next step:</b> ${escape(card.nextStep)}</p><button class="btn btn-soft" data-card="${index}" type="button" aria-pressed="false">Show these findings</button></article>`;
|
|
315
322
|
})
|
|
316
323
|
.join('');
|
|
317
324
|
const options = (values) => values.map(([raw, label]) => `<option value="${escape(raw)}">${escape(label)}</option>`).join('');
|
package/lib/core/dist/human.js
CHANGED
|
@@ -145,12 +145,33 @@ export function patternWords(group, presentation = {}) {
|
|
|
145
145
|
};
|
|
146
146
|
}
|
|
147
147
|
const value = (input) => presentationValue(input, group.entity, group.field, false, presentation);
|
|
148
|
+
const reference = labels.source(group.entity, Object.keys(labels.entity(group.entity)?.sources ?? {})[0] ?? 'reference');
|
|
149
|
+
if (group.kind === 'formatting_difference') {
|
|
150
|
+
const difference = group.cause === 'case_only'
|
|
151
|
+
? 'letter case'
|
|
152
|
+
: group.cause === 'whitespace_only'
|
|
153
|
+
? 'whitespace'
|
|
154
|
+
: 'letter case and whitespace';
|
|
155
|
+
return {
|
|
156
|
+
label: 'Formatting difference',
|
|
157
|
+
sentence: `${numberText(group.count)} ${field} values in ${source} differ from ${reference} only in ${difference} (${JSON.stringify(group.actual)} vs ${JSON.stringify(group.expected)}).`,
|
|
158
|
+
hint: 'Likely a formatting difference.',
|
|
159
|
+
nextStep: `Consider ${group.cause === 'whitespace_only' ? 'whitespaceFold: true' : group.cause === 'case_only' ? 'caseFold: true' : 'caseFold: true and whitespaceFold: true'} for this field after reviewing the raw values.`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (group.kind === 'number_change')
|
|
163
|
+
return {
|
|
164
|
+
label: 'Repeated numeric change',
|
|
165
|
+
sentence: `${numberText(group.count)} ${field} values in ${source} are ${value(group.actual)} where ${reference} has ${value(group.expected)}.`,
|
|
166
|
+
hint: 'Repeated numeric values differ between sources.',
|
|
167
|
+
nextStep: `Check how ${field} is derived or updated in each source.`,
|
|
168
|
+
};
|
|
148
169
|
if (group.cause === 'stale_value')
|
|
149
170
|
return {
|
|
150
171
|
label: 'Stale or lagging values',
|
|
151
|
-
sentence: `${numberText(group.count)} of ${numberText(group.referenceCount ?? group.count)} ${
|
|
152
|
-
nextStep:
|
|
153
|
-
hint:
|
|
172
|
+
sentence: `${numberText(group.count)} of ${numberText(group.referenceCount ?? group.count)} ${labels.entity(group.entity)?.pluralLabel ?? 'records'} with ${field} ${value(group.expected)} in ${reference} show ${value(group.actual)} in ${source}; ${numberText(group.matchingCount ?? 0)} match.`,
|
|
173
|
+
nextStep: 'Compare refresh timing in both sources and the field definitions.',
|
|
174
|
+
hint: 'Likely a lagging update or a different definition of the field.',
|
|
154
175
|
};
|
|
155
176
|
return {
|
|
156
177
|
label: 'Value mapping',
|
|
@@ -190,6 +211,12 @@ export function mergedPatternWords(groups, presentation = {}) {
|
|
|
190
211
|
const words = patternWords(first, presentation);
|
|
191
212
|
if (values.length === 1)
|
|
192
213
|
return words;
|
|
214
|
+
if (first.kind === 'formatting_difference' || first.kind === 'number_change' || first.cause === 'stale_value')
|
|
215
|
+
return {
|
|
216
|
+
...words,
|
|
217
|
+
sentence: values.map((group) => patternWords(group, presentation).sentence).join(' '),
|
|
218
|
+
nextStep: [...new Set(values.map((group) => patternWords(group, presentation).nextStep))].join(' '),
|
|
219
|
+
};
|
|
193
220
|
const labels = reportLabels(presentation);
|
|
194
221
|
return {
|
|
195
222
|
...words,
|
|
@@ -12,7 +12,9 @@ export declare function reportLabels(presentation: ReportPresentation): {
|
|
|
12
12
|
fields: Record<string, string | {
|
|
13
13
|
path: string;
|
|
14
14
|
label?: string | undefined;
|
|
15
|
+
missing?: null | undefined;
|
|
15
16
|
}>;
|
|
17
|
+
absentAsNull: boolean;
|
|
16
18
|
items: string;
|
|
17
19
|
headers: Record<string, string>;
|
|
18
20
|
headersEnv: Record<string, string | {
|
|
@@ -73,6 +75,7 @@ export declare function reportLabels(presentation: ReportPresentation): {
|
|
|
73
75
|
group?: string | undefined;
|
|
74
76
|
} | undefined;
|
|
75
77
|
datetimeFormat?: string | undefined;
|
|
78
|
+
dateFormat?: string | undefined;
|
|
76
79
|
label?: string | undefined;
|
|
77
80
|
rollup?: {
|
|
78
81
|
by: string | string[];
|
|
@@ -109,6 +112,7 @@ export declare function reportLabels(presentation: ReportPresentation): {
|
|
|
109
112
|
group?: string | undefined;
|
|
110
113
|
} | undefined;
|
|
111
114
|
datetimeFormat?: string | undefined;
|
|
115
|
+
dateFormat?: string | undefined;
|
|
112
116
|
label?: string | undefined;
|
|
113
117
|
rollup?: {
|
|
114
118
|
by: string | string[];
|
|
@@ -143,6 +147,7 @@ export declare function reportLabels(presentation: ReportPresentation): {
|
|
|
143
147
|
group?: string | undefined;
|
|
144
148
|
} | undefined;
|
|
145
149
|
datetimeFormat?: string | undefined;
|
|
150
|
+
dateFormat?: string | undefined;
|
|
146
151
|
label?: string | undefined;
|
|
147
152
|
rollup?: {
|
|
148
153
|
by: string | string[];
|
|
@@ -183,6 +188,7 @@ export declare function reportLabels(presentation: ReportPresentation): {
|
|
|
183
188
|
group?: string | undefined;
|
|
184
189
|
} | undefined;
|
|
185
190
|
datetimeFormat?: string | undefined;
|
|
191
|
+
dateFormat?: string | undefined;
|
|
186
192
|
label?: string | undefined;
|
|
187
193
|
rollup?: {
|
|
188
194
|
by: string | string[];
|
|
@@ -219,6 +225,7 @@ export declare function reportLabels(presentation: ReportPresentation): {
|
|
|
219
225
|
group?: string | undefined;
|
|
220
226
|
} | undefined;
|
|
221
227
|
datetimeFormat?: string | undefined;
|
|
228
|
+
dateFormat?: string | undefined;
|
|
222
229
|
label?: string | undefined;
|
|
223
230
|
rollup?: {
|
|
224
231
|
by: string | string[];
|
|
@@ -233,11 +240,12 @@ export declare function reportLabels(presentation: ReportPresentation): {
|
|
|
233
240
|
} | undefined;
|
|
234
241
|
}>;
|
|
235
242
|
compare: Record<string, {
|
|
236
|
-
type: "string" | "number" | "enum" | "money" | "datetime";
|
|
243
|
+
type: "string" | "number" | "date" | "enum" | "money" | "datetime";
|
|
237
244
|
allowCurrencySymbol: boolean;
|
|
238
245
|
negativeParentheses: boolean;
|
|
239
246
|
allowNumber: boolean;
|
|
240
247
|
caseFold: boolean;
|
|
248
|
+
whitespaceFold: boolean;
|
|
241
249
|
emptyAsNull: boolean;
|
|
242
250
|
tolerance: number;
|
|
243
251
|
toleranceSeconds: number;
|
|
@@ -272,7 +280,7 @@ export declare function reportLabels(presentation: ReportPresentation): {
|
|
|
272
280
|
tolerance: number;
|
|
273
281
|
label?: string | undefined;
|
|
274
282
|
})[];
|
|
275
|
-
failOn: ("
|
|
283
|
+
failOn: ("missing" | "invalid" | "extra" | "mismatch" | "aggregate" | "duplicate")[] | Partial<Record<"missing" | "invalid" | "extra" | "mismatch" | "aggregate" | "duplicate", number>>;
|
|
276
284
|
label?: string | undefined;
|
|
277
285
|
pluralLabel?: string | undefined;
|
|
278
286
|
} | undefined;
|
|
@@ -281,11 +289,12 @@ export declare function reportLabels(presentation: ReportPresentation): {
|
|
|
281
289
|
all: (name: string) => string;
|
|
282
290
|
};
|
|
283
291
|
export declare function presentationRule(entity: EntityConfig | undefined, field: string, aggregate: boolean): {
|
|
284
|
-
type: "string" | "number" | "enum" | "money" | "datetime";
|
|
292
|
+
type: "string" | "number" | "date" | "enum" | "money" | "datetime";
|
|
285
293
|
allowCurrencySymbol: boolean;
|
|
286
294
|
negativeParentheses: boolean;
|
|
287
295
|
allowNumber: boolean;
|
|
288
296
|
caseFold: boolean;
|
|
297
|
+
whitespaceFold: boolean;
|
|
289
298
|
emptyAsNull: boolean;
|
|
290
299
|
tolerance: number;
|
|
291
300
|
toleranceSeconds: number;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { humanize, presentationRule } from './labels.js';
|
|
2
2
|
import { displayValue } from './report-utils.js';
|
|
3
|
+
import { displayDecimal, negateDecimal, sumDecimal } from './aggregates.js';
|
|
3
4
|
export function formatDatetimeUtc(epoch) {
|
|
4
5
|
if (!Number.isSafeInteger(epoch))
|
|
5
6
|
return undefined;
|
|
@@ -39,6 +40,8 @@ export function presentationValue(value, entityName, field, aggregate, presentat
|
|
|
39
40
|
return displayValue(value);
|
|
40
41
|
if (rule?.type === 'datetime' && typeof value === 'number')
|
|
41
42
|
return readableDatetime(value, presentation.displayTimezone) ?? plain;
|
|
43
|
+
if (rule?.type === 'number' && typeof value === 'string' && /^-?\d+(?:\.\d+)?$/.test(value))
|
|
44
|
+
return displayDecimal(value);
|
|
42
45
|
if (rule?.type === 'enum' && typeof value === 'string')
|
|
43
46
|
return humanize(value);
|
|
44
47
|
if (rule?.type !== 'money' || !rule.currency || integerValue(value) === undefined)
|
|
@@ -95,7 +98,7 @@ export function aggregateDisplay(aggregate, source, entity, presentation) {
|
|
|
95
98
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
96
99
|
.map(([currency, value]) => formatMoneyMajor(BigInt(value), currency))
|
|
97
100
|
.join('; ');
|
|
98
|
-
return presentationValue(aggregate.values[source], entity, aggregate.name, true, presentation);
|
|
101
|
+
return presentationValue(aggregate.exactValues?.[source] ?? aggregate.values[source], entity, aggregate.name, true, presentation);
|
|
99
102
|
}
|
|
100
103
|
export function rawAggregateDisplay(aggregate, source, entity, presentation) {
|
|
101
104
|
if (aggregate.type === 'count')
|
|
@@ -126,9 +129,10 @@ export function presentationDifference(expected, actual, entityName, field, aggr
|
|
|
126
129
|
return `${delta > 0n ? '+' : ''}${formatMoneyMajor(delta, rule.currency)}`;
|
|
127
130
|
return `${delta > 0n ? '+' : ''}${delta.toLocaleString('en-US')}`.replace(/^-/, '−');
|
|
128
131
|
}
|
|
129
|
-
if (typeof expected === 'number' && typeof actual === 'number')
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
if ((typeof expected === 'number' && typeof actual === 'number') ||
|
|
133
|
+
(rule?.type === 'number' && expected !== undefined && actual !== undefined)) {
|
|
134
|
+
const delta = sumDecimal(String(actual), negateDecimal(String(expected)));
|
|
135
|
+
return `${delta !== '0' && !delta.startsWith('-') ? '+' : ''}${displayDecimal(delta)}`;
|
|
132
136
|
}
|
|
133
137
|
return '—';
|
|
134
138
|
}
|
|
@@ -152,7 +156,7 @@ export function aggregateDifference(aggregate, source, reference, entity, presen
|
|
|
152
156
|
return `${delta > 0n ? '+' : ''}${formatMoneyMajor(delta, currency)}`;
|
|
153
157
|
})
|
|
154
158
|
.join('; ');
|
|
155
|
-
return presentationDifference(aggregate.values[reference], aggregate.values[source], entity, aggregate.name, true, presentation);
|
|
159
|
+
return presentationDifference(aggregate.exactValues?.[reference] ?? aggregate.values[reference], aggregate.exactValues?.[source] ?? aggregate.values[source], entity, aggregate.name, true, presentation);
|
|
156
160
|
}
|
|
157
161
|
export function aggregateStatus(aggregate, source, reference) {
|
|
158
162
|
if (aggregate.sourceStatus?.[source] === 'not_mapped')
|
|
@@ -170,7 +174,8 @@ export function aggregateStatus(aggregate, source, reference) {
|
|
|
170
174
|
const matches = currencies.length
|
|
171
175
|
? currencies.every((currency) => BigInt(aggregate.valuesByCurrency?.[source]?.[currency] ?? 0) ===
|
|
172
176
|
BigInt(aggregate.valuesByCurrency?.[reference]?.[currency] ?? 0))
|
|
173
|
-
: aggregate.
|
|
177
|
+
: (aggregate.exactValues?.[source] ?? aggregate.values[source]) ===
|
|
178
|
+
(aggregate.exactValues?.[reference] ?? aggregate.values[reference]);
|
|
174
179
|
if (matches)
|
|
175
180
|
return 'Matches';
|
|
176
181
|
const buckets = aggregate.reconciliationByCurrency?.[source];
|
|
@@ -179,7 +184,7 @@ export function aggregateStatus(aggregate, source, reference) {
|
|
|
179
184
|
: aggregate.reconciliation?.[source]
|
|
180
185
|
? [aggregate.reconciliation[source]]
|
|
181
186
|
: [];
|
|
182
|
-
return parts.length && parts.every((part) =>
|
|
187
|
+
return parts.length && parts.every((part) => sumDecimal(part.remainder) === '0' && !part.uncertain?.keys.length)
|
|
183
188
|
? 'Accounted for'
|
|
184
189
|
: 'Needs review';
|
|
185
190
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { DateTime } from 'luxon';
|
|
2
2
|
import { wallClock } from './types.js';
|
|
3
|
+
import { normalizeDate } from './calendar.js';
|
|
3
4
|
export function sourceRule(rule, config, source = '') {
|
|
4
5
|
return (rule && {
|
|
5
6
|
...rule,
|
|
6
7
|
locale: rule.locale ?? config?.locale ?? 'en-US',
|
|
7
8
|
numberFormat: rule.numberFormat ?? config?.numberFormat,
|
|
8
|
-
format: typeof rule.format === 'string'
|
|
9
|
+
format: typeof rule.format === 'string'
|
|
10
|
+
? rule.format
|
|
11
|
+
: (rule.format?.[source] ?? (rule.type === 'date' ? config?.dateFormat : config?.datetimeFormat)),
|
|
9
12
|
});
|
|
10
13
|
}
|
|
11
14
|
const localeFormats = new Map();
|
|
@@ -178,6 +181,8 @@ export function normalize(value, rule, source) {
|
|
|
178
181
|
return null;
|
|
179
182
|
if (value instanceof Date) {
|
|
180
183
|
const local = Reflect.get(value, wallClock);
|
|
184
|
+
if (rule?.type === 'date')
|
|
185
|
+
return normalizeDate(typeof local === 'string' ? local.slice(0, 10) : value.toISOString().slice(0, 10), undefined, rule.locale ?? 'en-US');
|
|
181
186
|
if (typeof local === 'string' && rule?.type === 'datetime') {
|
|
182
187
|
const isoRule = { ...rule, format: undefined };
|
|
183
188
|
try {
|
|
@@ -193,7 +198,8 @@ export function normalize(value, rule, source) {
|
|
|
193
198
|
}
|
|
194
199
|
if (!['string', 'number', 'boolean', 'bigint'].includes(typeof value))
|
|
195
200
|
throw new Error('Expected a scalar field; map a scalar JSONPath or file column.');
|
|
196
|
-
const
|
|
201
|
+
const trimmed = String(value).trim();
|
|
202
|
+
const text = rule?.whitespaceFold && ['string', 'enum'].includes(rule.type) ? trimmed.replace(/\s+/gu, ' ') : trimmed;
|
|
197
203
|
if (text === '' && (rule?.emptyAsNull ?? true))
|
|
198
204
|
return null;
|
|
199
205
|
if (!rule) {
|
|
@@ -229,6 +235,8 @@ export function normalize(value, rule, source) {
|
|
|
229
235
|
}
|
|
230
236
|
case 'datetime':
|
|
231
237
|
return normalizeDatetime(text, rule, source);
|
|
238
|
+
case 'date':
|
|
239
|
+
return normalizeDate(text, typeof rule.format === 'string' ? rule.format : rule.format?.[source], rule.locale ?? 'en-US');
|
|
232
240
|
}
|
|
233
241
|
}
|
|
234
242
|
export function equalValues(a, b, rule) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { reportLabels, uncertainKeyText } from './labels.js';
|
|
2
2
|
import { formatMoneyMajor } from './money-display.js';
|
|
3
|
+
import { displayDecimal, sumDecimal } from './aggregates.js';
|
|
3
4
|
export function reconciliationDetails(report, presentation = {}, onlyEntity) {
|
|
4
5
|
const labels = reportLabels(presentation), lines = [];
|
|
5
6
|
for (const entity of report.entities) {
|
|
@@ -11,20 +12,22 @@ export function reconciliationDetails(report, presentation = {}, onlyEntity) {
|
|
|
11
12
|
? Object.entries(value).map(([currency, parts]) => ({ currency, parts: parts }))
|
|
12
13
|
: [{ currency: undefined, parts: value }];
|
|
13
14
|
for (const { currency, parts } of partitions) {
|
|
14
|
-
const actual = currency
|
|
15
|
+
const actual = currency
|
|
16
|
+
? (aggregate.valuesByCurrency?.[source]?.[currency] ?? 0)
|
|
17
|
+
: (aggregate.exactValues?.[source] ?? aggregate.values[source]);
|
|
15
18
|
const expected = currency
|
|
16
19
|
? (aggregate.valuesByCurrency?.[entity.reference]?.[currency] ?? 0)
|
|
17
|
-
: aggregate.values[entity.reference];
|
|
20
|
+
: (aggregate.exactValues?.[entity.reference] ?? aggregate.values[entity.reference]);
|
|
18
21
|
if (actual === undefined ||
|
|
19
22
|
expected === undefined ||
|
|
20
|
-
(
|
|
23
|
+
(sumDecimal(actual) === sumDecimal(expected) &&
|
|
21
24
|
!parts.uncertain?.keys.length &&
|
|
22
25
|
!parts.missingRows.count &&
|
|
23
26
|
!parts.extraRows?.count))
|
|
24
27
|
continue;
|
|
25
28
|
const amount = (value) => {
|
|
26
|
-
const n =
|
|
27
|
-
return `${n
|
|
29
|
+
const n = sumDecimal(value);
|
|
30
|
+
return `${n !== '0' && !n.startsWith('-') ? '+' : ''}${currency ? formatMoneyMajor(BigInt(n), currency) : displayDecimal(n)}`;
|
|
28
31
|
};
|
|
29
32
|
const components = [
|
|
30
33
|
`Missing rows (${parts.missingRows.count.toLocaleString('en-US')}): ${amount(parts.missingRows.amount)}`,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { matchesFilter } from './filters.js';
|
|
2
|
+
import { negateDecimal, sumDecimal } from './aggregates.js';
|
|
3
|
+
const sum = (...values) => values.every((v) => typeof v === 'bigint') ? values.reduce((a, b) => a + b, 0n) : sumDecimal(...values);
|
|
4
|
+
const zero = (value) => sumDecimal(value) === '0';
|
|
2
5
|
const ledgers = new WeakMap();
|
|
3
6
|
function contributes(entity, definition, row, source) {
|
|
4
7
|
return !!row && matchesFilter(row, definition.where, entity, source);
|
|
@@ -12,7 +15,7 @@ function contribution(entity, definition, state, key, source, currency) {
|
|
|
12
15
|
if (currency && state.currencies.get(key)?.get(definition.field) !== currency)
|
|
13
16
|
return 0n;
|
|
14
17
|
const value = row[definition.field];
|
|
15
|
-
return typeof value === 'bigint' ? value : 0n;
|
|
18
|
+
return typeof value === 'bigint' ? value : typeof value === 'number' ? sumDecimal(value) : 0n;
|
|
16
19
|
}
|
|
17
20
|
function collectBucket(entity, definition, baseline, current, source, reference, currency) {
|
|
18
21
|
const parts = {
|
|
@@ -43,25 +46,25 @@ function collectBucket(entity, definition, baseline, current, source, reference,
|
|
|
43
46
|
if (codes.length && codes.every((code) => code !== currency))
|
|
44
47
|
continue;
|
|
45
48
|
}
|
|
46
|
-
const difference = actual
|
|
49
|
+
const difference = sum(actual, negateDecimal(expected));
|
|
47
50
|
if (current.excluded.get(definition.name)?.has(key) || baseline.excluded.get(definition.name)?.has(key)) {
|
|
48
51
|
parts.uncertain?.keys.push(current.parts.get(key) ?? baseline.parts.get(key) ?? [key]);
|
|
49
52
|
if (parts.uncertain)
|
|
50
|
-
parts.uncertain.amount =
|
|
53
|
+
parts.uncertain.amount = sum(parts.uncertain.amount, difference);
|
|
51
54
|
}
|
|
52
55
|
else if (!actualRow) {
|
|
53
|
-
parts.missingRows.amount =
|
|
54
|
-
if (expected
|
|
56
|
+
parts.missingRows.amount = sum(parts.missingRows.amount, difference);
|
|
57
|
+
if (!zero(expected))
|
|
55
58
|
parts.missingRows.count++;
|
|
56
59
|
}
|
|
57
60
|
else if (!expectedRow) {
|
|
58
61
|
if (parts.extraRows) {
|
|
59
|
-
parts.extraRows.amount =
|
|
60
|
-
if (actual
|
|
62
|
+
parts.extraRows.amount = sum(parts.extraRows.amount, difference);
|
|
63
|
+
if (!zero(actual))
|
|
61
64
|
parts.extraRows.count++;
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
|
-
else if (difference
|
|
67
|
+
else if (!zero(difference)) {
|
|
65
68
|
const moved = contributes(entity, definition, expectedRow, reference) !== contributes(entity, definition, actualRow, source);
|
|
66
69
|
const currencyField = definition.type === 'sum'
|
|
67
70
|
? (entity.compare[definition.field]?.currencyField ??
|
|
@@ -92,8 +95,6 @@ function collectBucket(entity, definition, baseline, current, source, reference,
|
|
|
92
95
|
}
|
|
93
96
|
export function collectReconciliation(entity, baseline, current, source, reference, summary) {
|
|
94
97
|
for (const definition of entity.aggregates) {
|
|
95
|
-
if (definition.type === 'sum' && entity.compare[definition.field]?.type !== 'money')
|
|
96
|
-
continue;
|
|
97
98
|
const aggregate = summary.aggregates?.find((item) => item.name === definition.name);
|
|
98
99
|
if (!aggregate || aggregate.values[source] === undefined || aggregate.values[reference] === undefined)
|
|
99
100
|
continue;
|
|
@@ -133,30 +134,35 @@ function attributeDifferences(report, entity, parts, source) {
|
|
|
133
134
|
if (group >= 0) {
|
|
134
135
|
const part = patterns.get(group) ?? { group, count: 0, amount: 0n };
|
|
135
136
|
part.count++;
|
|
136
|
-
part.amount
|
|
137
|
+
part.amount = sum(part.amount, entry.amount);
|
|
137
138
|
patterns.set(group, part);
|
|
138
139
|
}
|
|
139
140
|
else {
|
|
140
141
|
parts.valueDifferences.count++;
|
|
141
|
-
parts.valueDifferences.amount =
|
|
142
|
+
parts.valueDifferences.amount = sum(parts.valueDifferences.amount, entry.amount);
|
|
142
143
|
}
|
|
143
144
|
}
|
|
144
145
|
parts.patterns = [...patterns.values()];
|
|
145
146
|
}
|
|
146
147
|
function reconcileBucket(report, entity, aggregate, source, reference, parts, currency) {
|
|
147
148
|
attributeDifferences(report, entity, parts, source);
|
|
148
|
-
const actual = currency
|
|
149
|
-
|
|
149
|
+
const actual = currency
|
|
150
|
+
? (aggregate.valuesByCurrency?.[source]?.[currency] ?? 0)
|
|
151
|
+
: (aggregate.exactValues?.[source] ?? aggregate.values[source]);
|
|
152
|
+
const expected = currency
|
|
153
|
+
? (aggregate.valuesByCurrency?.[reference]?.[currency] ?? 0)
|
|
154
|
+
: (aggregate.exactValues?.[reference] ?? aggregate.values[reference]);
|
|
150
155
|
if (actual === undefined || expected === undefined)
|
|
151
156
|
return;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
const numeric = entity.aggregates.some((d) => d.name === aggregate.name && d.type === 'sum' && entity.compare[d.field]?.type === 'number');
|
|
158
|
+
const remainder = sumDecimal(String(actual), negateDecimal(String(expected)), ...[
|
|
159
|
+
parts.missingRows.amount,
|
|
160
|
+
parts.extraRows?.amount ?? 0n,
|
|
161
|
+
parts.valueDifferences?.amount ?? 0n,
|
|
162
|
+
parts.uncertain?.amount ?? 0n,
|
|
163
|
+
...parts.patterns.map((p) => p.amount),
|
|
164
|
+
].map(negateDecimal));
|
|
165
|
+
parts.remainder = numeric ? remainder : BigInt(remainder);
|
|
160
166
|
parts.causes = [
|
|
161
167
|
...(parts.missingRows.count ? ['missing rows'] : []),
|
|
162
168
|
...(parts.extraRows?.count ? ['extra rows'] : []),
|
|
@@ -172,7 +178,7 @@ function reconcileBucket(report, entity, aggregate, source, reference, parts, cu
|
|
|
172
178
|
item.source === source &&
|
|
173
179
|
item.field === aggregate.name &&
|
|
174
180
|
item.currency === currency);
|
|
175
|
-
if (finding &&
|
|
181
|
+
if (finding && zero(parts.remainder) && !parts.uncertain?.keys.length)
|
|
176
182
|
finding.accounting = { causes: parts.causes, remainder: '0' };
|
|
177
183
|
}
|
|
178
184
|
export function reconcileAggregates(report, entities) {
|
|
@@ -22,6 +22,7 @@ export interface SourceState {
|
|
|
22
22
|
identities: Map<string, number>;
|
|
23
23
|
sums: Map<string, Map<string, DecimalSum>>;
|
|
24
24
|
measurements: Map<string, number | bigint | undefined>;
|
|
25
|
+
exactMeasurements: Map<string, string>;
|
|
25
26
|
excluded: Map<string, Set<string>>;
|
|
26
27
|
count: number;
|
|
27
28
|
position: number;
|
|
@@ -190,7 +190,7 @@ function measureSource(entity, source, config, state) {
|
|
|
190
190
|
const excluded = new Set([...state.uncertain].flatMap(([key, uncertainFields]) => required.some((field) => uncertainFields.has(field)) ? [key] : []));
|
|
191
191
|
state.excluded.set(aggregate.name, excluded);
|
|
192
192
|
try {
|
|
193
|
-
state.measurements.set(aggregate.name, measureAggregate(aggregate, entity, source, { ...config, fields }, new Map([...state.rows].filter(([key]) => !excluded.has(key)))));
|
|
193
|
+
state.measurements.set(aggregate.name, measureAggregate(aggregate, entity, source, { ...config, fields }, new Map([...state.rows].filter(([key]) => !excluded.has(key))), state.exactMeasurements));
|
|
194
194
|
}
|
|
195
195
|
catch (error) {
|
|
196
196
|
throw new Error(`Aggregate ${aggregate.name}: ${errorText(error)}`);
|
|
@@ -261,6 +261,7 @@ export async function readSource(entity, source, config, read, secrets, report,
|
|
|
261
261
|
identities: new Map(),
|
|
262
262
|
sums: new Map(),
|
|
263
263
|
measurements: new Map(),
|
|
264
|
+
exactMeasurements: new Map(),
|
|
264
265
|
excluded: new Map(),
|
|
265
266
|
count: 0,
|
|
266
267
|
position: 0,
|
package/lib/core/dist/types.d.ts
CHANGED
|
@@ -96,6 +96,7 @@ export interface AggregateResult {
|
|
|
96
96
|
name: string;
|
|
97
97
|
type: 'count' | 'sum';
|
|
98
98
|
values: Record<string, number | bigint>;
|
|
99
|
+
exactValues?: Record<string, string>;
|
|
99
100
|
rawValues?: Record<string, number | bigint>;
|
|
100
101
|
valuesByCurrency?: Record<string, Record<string, bigint | string>>;
|
|
101
102
|
rawValuesByCurrency?: Record<string, Record<string, bigint | string>>;
|
|
@@ -126,10 +127,10 @@ export interface FindingGroup {
|
|
|
126
127
|
field: string;
|
|
127
128
|
count: number;
|
|
128
129
|
currency?: string;
|
|
129
|
-
cause?: 'timezone' | 'pagination' | 'stale_value';
|
|
130
|
+
cause?: 'timezone' | 'pagination' | 'stale_value' | 'case_only' | 'whitespace_only' | 'case_whitespace';
|
|
130
131
|
referenceCount?: number;
|
|
131
132
|
matchingCount?: number;
|
|
132
|
-
kind: 'datetime_offset' | 'money_delta' | 'value_mapping' | 'missing_stride' | 'extra_stride';
|
|
133
|
+
kind: 'datetime_offset' | 'money_delta' | 'value_mapping' | 'formatting_difference' | 'number_change' | 'missing_stride' | 'extra_stride';
|
|
133
134
|
delta?: string;
|
|
134
135
|
stride?: string;
|
|
135
136
|
keys?: string[];
|
|
@@ -143,7 +143,9 @@ export declare const targetSchema: z.ZodObject<{
|
|
|
143
143
|
fields: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
144
144
|
path: z.ZodString;
|
|
145
145
|
label: z.ZodOptional<z.ZodString>;
|
|
146
|
+
missing: z.ZodOptional<z.ZodNull>;
|
|
146
147
|
}, z.core.$strict>]>>;
|
|
148
|
+
absentAsNull: z.ZodDefault<z.ZodBoolean>;
|
|
147
149
|
items: z.ZodDefault<z.ZodString>;
|
|
148
150
|
headers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
149
151
|
headersEnv: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
@@ -199,6 +201,7 @@ export declare const targetSchema: z.ZodObject<{
|
|
|
199
201
|
group: z.ZodOptional<z.ZodString>;
|
|
200
202
|
}, z.core.$strict>>;
|
|
201
203
|
datetimeFormat: z.ZodOptional<z.ZodString>;
|
|
204
|
+
dateFormat: z.ZodOptional<z.ZodString>;
|
|
202
205
|
type: z.ZodLiteral<"rest">;
|
|
203
206
|
label: z.ZodOptional<z.ZodString>;
|
|
204
207
|
rollup: z.ZodOptional<z.ZodObject<{
|
|
@@ -239,6 +242,7 @@ export declare const targetSchema: z.ZodObject<{
|
|
|
239
242
|
group: z.ZodOptional<z.ZodString>;
|
|
240
243
|
}, z.core.$strict>>;
|
|
241
244
|
datetimeFormat: z.ZodOptional<z.ZodString>;
|
|
245
|
+
dateFormat: z.ZodOptional<z.ZodString>;
|
|
242
246
|
type: z.ZodLiteral<"csv">;
|
|
243
247
|
label: z.ZodOptional<z.ZodString>;
|
|
244
248
|
rollup: z.ZodOptional<z.ZodObject<{
|
|
@@ -273,6 +277,7 @@ export declare const targetSchema: z.ZodObject<{
|
|
|
273
277
|
group: z.ZodOptional<z.ZodString>;
|
|
274
278
|
}, z.core.$strict>>;
|
|
275
279
|
datetimeFormat: z.ZodOptional<z.ZodString>;
|
|
280
|
+
dateFormat: z.ZodOptional<z.ZodString>;
|
|
276
281
|
type: z.ZodLiteral<"xlsx">;
|
|
277
282
|
label: z.ZodOptional<z.ZodString>;
|
|
278
283
|
rollup: z.ZodOptional<z.ZodObject<{
|
|
@@ -313,6 +318,7 @@ export declare const targetSchema: z.ZodObject<{
|
|
|
313
318
|
group: z.ZodOptional<z.ZodString>;
|
|
314
319
|
}, z.core.$strict>>;
|
|
315
320
|
datetimeFormat: z.ZodOptional<z.ZodString>;
|
|
321
|
+
dateFormat: z.ZodOptional<z.ZodString>;
|
|
316
322
|
type: z.ZodLiteral<"ui">;
|
|
317
323
|
label: z.ZodOptional<z.ZodString>;
|
|
318
324
|
rollup: z.ZodOptional<z.ZodObject<{
|
|
@@ -349,6 +355,7 @@ export declare const targetSchema: z.ZodObject<{
|
|
|
349
355
|
group: z.ZodOptional<z.ZodString>;
|
|
350
356
|
}, z.core.$strict>>;
|
|
351
357
|
datetimeFormat: z.ZodOptional<z.ZodString>;
|
|
358
|
+
dateFormat: z.ZodOptional<z.ZodString>;
|
|
352
359
|
type: z.ZodLiteral<"sql">;
|
|
353
360
|
label: z.ZodOptional<z.ZodString>;
|
|
354
361
|
rollup: z.ZodOptional<z.ZodObject<{
|
|
@@ -448,7 +455,9 @@ export declare function loadTarget(file: string, env: NodeJS.ProcessEnv): Promis
|
|
|
448
455
|
fields: Record<string, string | {
|
|
449
456
|
path: string;
|
|
450
457
|
label?: string | undefined;
|
|
458
|
+
missing?: null | undefined;
|
|
451
459
|
}>;
|
|
460
|
+
absentAsNull: boolean;
|
|
452
461
|
items: string;
|
|
453
462
|
headers: Record<string, string>;
|
|
454
463
|
headersEnv: Record<string, string | {
|
|
@@ -509,6 +518,7 @@ export declare function loadTarget(file: string, env: NodeJS.ProcessEnv): Promis
|
|
|
509
518
|
group?: string | undefined;
|
|
510
519
|
} | undefined;
|
|
511
520
|
datetimeFormat?: string | undefined;
|
|
521
|
+
dateFormat?: string | undefined;
|
|
512
522
|
label?: string | undefined;
|
|
513
523
|
rollup?: {
|
|
514
524
|
by: string | string[];
|
|
@@ -545,6 +555,7 @@ export declare function loadTarget(file: string, env: NodeJS.ProcessEnv): Promis
|
|
|
545
555
|
group?: string | undefined;
|
|
546
556
|
} | undefined;
|
|
547
557
|
datetimeFormat?: string | undefined;
|
|
558
|
+
dateFormat?: string | undefined;
|
|
548
559
|
label?: string | undefined;
|
|
549
560
|
rollup?: {
|
|
550
561
|
by: string | string[];
|
|
@@ -579,6 +590,7 @@ export declare function loadTarget(file: string, env: NodeJS.ProcessEnv): Promis
|
|
|
579
590
|
group?: string | undefined;
|
|
580
591
|
} | undefined;
|
|
581
592
|
datetimeFormat?: string | undefined;
|
|
593
|
+
dateFormat?: string | undefined;
|
|
582
594
|
label?: string | undefined;
|
|
583
595
|
rollup?: {
|
|
584
596
|
by: string | string[];
|
|
@@ -619,6 +631,7 @@ export declare function loadTarget(file: string, env: NodeJS.ProcessEnv): Promis
|
|
|
619
631
|
group?: string | undefined;
|
|
620
632
|
} | undefined;
|
|
621
633
|
datetimeFormat?: string | undefined;
|
|
634
|
+
dateFormat?: string | undefined;
|
|
622
635
|
label?: string | undefined;
|
|
623
636
|
rollup?: {
|
|
624
637
|
by: string | string[];
|
|
@@ -655,6 +668,7 @@ export declare function loadTarget(file: string, env: NodeJS.ProcessEnv): Promis
|
|
|
655
668
|
group?: string | undefined;
|
|
656
669
|
} | undefined;
|
|
657
670
|
datetimeFormat?: string | undefined;
|
|
671
|
+
dateFormat?: string | undefined;
|
|
658
672
|
label?: string | undefined;
|
|
659
673
|
rollup?: {
|
|
660
674
|
by: string | string[];
|