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.
Files changed (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +16 -7
  3. package/docs/configuration.md +28 -5
  4. package/docs/recipes/socrata.md +38 -0
  5. package/docs/release.md +1 -1
  6. package/docs/report-formats.md +6 -2
  7. package/docs/report-schema.json +32 -6
  8. package/docs/report-schema.md +6 -4
  9. package/docs/scenario-report-schema.json +2 -2
  10. package/lib/adapters/dist/jsonpath.d.ts +2 -2
  11. package/lib/adapters/dist/jsonpath.js +14 -6
  12. package/lib/adapters/dist/rest.js +8 -2
  13. package/lib/cli/dist/version.d.ts +1 -1
  14. package/lib/cli/dist/version.js +1 -1
  15. package/lib/core/dist/aggregates.d.ts +8 -3
  16. package/lib/core/dist/aggregates.js +30 -3
  17. package/lib/core/dist/calendar.d.ts +3 -0
  18. package/lib/core/dist/calendar.js +30 -0
  19. package/lib/core/dist/config.d.ts +33 -6
  20. package/lib/core/dist/config.js +41 -2
  21. package/lib/core/dist/engine.js +13 -6
  22. package/lib/core/dist/groups.d.ts +1 -1
  23. package/lib/core/dist/groups.js +33 -11
  24. package/lib/core/dist/html-patterns.d.ts +26 -0
  25. package/lib/core/dist/html-patterns.js +11 -3
  26. package/lib/core/dist/html.js +23 -16
  27. package/lib/core/dist/human.js +30 -3
  28. package/lib/core/dist/labels.d.ts +12 -3
  29. package/lib/core/dist/money-display.js +12 -7
  30. package/lib/core/dist/normalize.js +10 -2
  31. package/lib/core/dist/reconciliation-display.js +8 -5
  32. package/lib/core/dist/reconciliation.js +29 -23
  33. package/lib/core/dist/source-state.d.ts +1 -0
  34. package/lib/core/dist/source-state.js +2 -1
  35. package/lib/core/dist/types.d.ts +3 -2
  36. package/lib/scenarios/dist/config.d.ts +14 -0
  37. package/package.json +2 -1
@@ -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 = BigInt(actual) - BigInt(reference);
158
- const amount = (value) => `${value > 0n ? '+' : ''}${(currency ?? rule?.currency) ? formatMoneyMajor(value, currency ?? rule?.currency ?? 'USD') : numberText(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: BigInt(parts.missingRows.amount),
163
+ value: parts.missingRows.amount,
163
164
  color: 'page',
164
- show: BigInt(parts.missingRows.amount) !== 0n,
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: BigInt(part?.amount ?? 0),
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: BigInt(part.amount),
178
+ value: part.amount,
178
179
  color: 'round',
179
- show: BigInt(part.amount) !== 0n,
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: BigInt(parts.uncertain?.amount ?? 0),
184
+ value: parts.uncertain?.amount ?? 0n,
184
185
  color: 'tz',
185
186
  show: !!parts.uncertain?.keys.length,
186
187
  },
187
- { label: 'Unexplained remainder', value: BigInt(parts.remainder), color: 'review', show: true },
188
+ { label: 'Unexplained remainder', value: parts.remainder, color: 'review', show: true },
188
189
  ].filter((part) => part.show);
189
- const total = buckets.reduce((sum, part) => sum + (part.value < 0n ? -part.value : part.value), 0n);
190
- const accounted = BigInt(parts.remainder) === 0n && !parts.uncertain?.keys.length;
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) => `<i style="width:${total ? Number(((part.value < 0n ? -part.value : part.value) * 10000n) / total) / 100 : 0}%;background:var(--c-${part.color})"></i>`)
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('');
@@ -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)} ${value(group.expected)} ${labels.entity(group.entity)?.pluralLabel ?? 'records'} show ${value(group.actual)} in ${source}; ${numberText(group.matchingCount ?? 0)} match.`,
152
- nextStep: `Check refresh timing and synchronization in ${source}; preserve the distinct ${field} values.`,
153
- hint: `Likely a stale or lagging ${field}.`,
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: ("invalid" | "missing" | "extra" | "mismatch" | "aggregate" | "duplicate")[] | Partial<Record<"invalid" | "missing" | "extra" | "mismatch" | "aggregate" | "duplicate", number>>;
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
- const delta = actual - expected;
131
- return `${delta > 0 ? '+' : ''}${delta.toLocaleString('en-US', { maximumFractionDigits: 12 })}`.replace(/^-/, '−');
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.values[source] === aggregate.values[reference];
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) => BigInt(part.remainder) === 0n && !part.uncertain?.keys.length)
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' ? rule.format : (rule.format?.[source] ?? config?.datetimeFormat),
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 text = String(value).trim();
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 ? (aggregate.valuesByCurrency?.[source]?.[currency] ?? 0) : aggregate.values[source];
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
- (BigInt(actual) === BigInt(expected) &&
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 = BigInt(value);
27
- return `${n > 0n ? '+' : ''}${currency ? formatMoneyMajor(n, currency) : n.toLocaleString('en-US').replace(/^-/, '−')}`;
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 - expected;
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 = BigInt(parts.uncertain.amount) + difference;
53
+ parts.uncertain.amount = sum(parts.uncertain.amount, difference);
51
54
  }
52
55
  else if (!actualRow) {
53
- parts.missingRows.amount = BigInt(parts.missingRows.amount) + difference;
54
- if (expected !== 0n)
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 = BigInt(parts.extraRows.amount) + difference;
60
- if (actual !== 0n)
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 !== 0n) {
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 += entry.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 = BigInt(parts.valueDifferences.amount) + entry.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 ? (aggregate.valuesByCurrency?.[source]?.[currency] ?? 0) : aggregate.values[source];
149
- const expected = currency ? (aggregate.valuesByCurrency?.[reference]?.[currency] ?? 0) : aggregate.values[reference];
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
- parts.remainder =
153
- BigInt(actual) -
154
- BigInt(expected) -
155
- BigInt(parts.missingRows.amount) -
156
- BigInt(parts.extraRows?.amount ?? 0) -
157
- BigInt(parts.valueDifferences?.amount ?? 0) -
158
- BigInt(parts.uncertain?.amount ?? 0) -
159
- parts.patterns.reduce((sum, item) => sum + BigInt(item.amount), 0n);
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 && BigInt(parts.remainder) === 0n && !parts.uncertain?.keys.length)
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,
@@ -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[];