Skip to content

Commit 70609af

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(analytics): resolve a monetary measure's currency via field config + tenant default (#2121)
* feat(analytics): resolve a monetary measure's currency via field config + tenant default Phase 1b of unifying currency resolution (builds on ExecutionContext.currency). The dataset measure-currency enrichment honored ONLY an explicit measure `currency` literal — so a measure summing a currency field, or any measure on a tenant with a configured default currency, rendered symbol-less unless the author restated the code. Now a MONETARY measure resolves its display currency through the documented chain: explicit measure `currency` → source-field `currencyConfig.defaultCurrency` → tenant default (`ctx.currency`, from the localization setting wired in #2119). A measure is monetary iff it declares a currency OR aggregates a `currency`-type field — so count / avg-of-number measures never get a (wrong) currency code. - analytics-service: `AnalyticsServiceConfig.measureCurrency(object, field)` → source-field `{ type, defaultCurrency }`; enrichment applies the chain, gated on monetary type. - plugin: wires `measureCurrency` from the data engine's `getObject().fields` (same accessor the label resolver already uses). Tests: query-dataset 15 passed (+4: field-default inheritance, tenant-ctx fallback, explicit-override precedence, and non-currency measures staying code-less). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: changeset for analytics measure-currency chain --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 220ce5b commit 70609af

4 files changed

Lines changed: 81 additions & 3 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
Resolve a monetary measure's display currency via the field→tenant chain.
6+
7+
A dataset measure-currency now resolves through: explicit measure `currency`
8+
source-field `currencyConfig.defaultCurrency` → tenant default (`ctx.currency`).
9+
A measure is monetary iff it declares a currency or aggregates a `currency`-type
10+
field, so count/avg-of-number measures never receive a code. Wires a
11+
`measureCurrency` field-metadata resolver from the data engine's object schema.

packages/services/service-analytics/src/__tests__/query-dataset.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,45 @@ describe('AnalyticsService.queryDataset', () => {
122122
expect(revenueField?.format).toBe('0,0');
123123
});
124124

125+
// ── ADR-0053 currency chain (measure → field currencyConfig → tenant ctx) ──
126+
function pricedSvc(rows: Array<Record<string, unknown>>, measureCurrency?: (o: string, f: string) => { type?: string; defaultCurrency?: string } | undefined) {
127+
return new AnalyticsService({
128+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
129+
executeRawSql: async () => rows,
130+
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
131+
...(measureCurrency ? { measureCurrency } : {}),
132+
});
133+
}
134+
const moneyDataset = (measure: Record<string, unknown>) => DatasetSchema.parse({
135+
name: 'money', label: 'Money', object: 'opportunity', include: [],
136+
dimensions: [{ name: 'stage', field: 'stage', type: 'string' }],
137+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', label: 'Revenue', ...measure }],
138+
});
139+
140+
it('chain: a currency-FIELD measure (no explicit currency) inherits the field defaultCurrency', async () => {
141+
const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency', defaultCurrency: 'EUR' } : undefined);
142+
const r = await svc.queryDataset(moneyDataset({}), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o' } as ExecutionContext) as any;
143+
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('EUR');
144+
});
145+
146+
it('chain: a currency field with no defaultCurrency falls back to the tenant ctx.currency', async () => {
147+
const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency' } : undefined);
148+
const r = await svc.queryDataset(moneyDataset({}), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'GBP' } as ExecutionContext) as any;
149+
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('GBP');
150+
});
151+
152+
it('chain: an explicit measure currency wins over the field default and the tenant ctx', async () => {
153+
const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency', defaultCurrency: 'EUR' } : undefined);
154+
const r = await svc.queryDataset(moneyDataset({ currency: 'JPY' }), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'GBP' } as ExecutionContext) as any;
155+
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('JPY');
156+
});
157+
158+
it('chain: a NON-currency field measure never gets a currency (even with a tenant default)', async () => {
159+
const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'number' } : undefined);
160+
const r = await svc.queryDataset(moneyDataset({}), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'USD' } as ExecutionContext) as any;
161+
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBeUndefined();
162+
});
163+
125164
it('enriches dimension columns with their dataset display label', async () => {
126165
const labeled = DatasetSchema.parse({
127166
name: 'sales2', label: 'Sales', object: 'opportunity', include: ['account'],

packages/services/service-analytics/src/analytics-service.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,14 @@ export interface AnalyticsServiceConfig {
141141
* provided, `queryDataset` validates that every declared `include` exists.
142142
*/
143143
relationshipResolver?: RelationshipResolver;
144+
/**
145+
* ADR-0053 currency chain — resolve a measure's SOURCE FIELD currency
146+
* metadata so a monetary measure that omits an explicit `currency` falls back
147+
* to the field's declared currency, then the tenant default (`ctx.currency`).
148+
* Returns the source field's `type` and (fixed-mode) `defaultCurrency`;
149+
* `undefined` for an unknown field. Non-`currency` fields never get a code.
150+
*/
151+
measureCurrency?: (object: string, field: string) => { type?: string; defaultCurrency?: string } | undefined;
144152
/** Pre-defined datasets to compile + register at construction (ADR-0021). */
145153
datasets?: Dataset[];
146154
/**
@@ -204,6 +212,7 @@ export class AnalyticsService implements IAnalyticsService {
204212
private readonly datasetRegistry = new Map<string, CompiledDataset>();
205213
/** Optional object-graph resolver used when compiling datasets. */
206214
private readonly relationshipResolver?: RelationshipResolver;
215+
private readonly measureCurrency?: AnalyticsServiceConfig['measureCurrency'];
207216
/** Optional dimension display-label resolver (select options / lookup names). */
208217
private readonly labelResolver?: DimensionLabelDeps;
209218
/** ADR-0037 P3: pending-seed row resolver for draft data preview. */
@@ -222,6 +231,7 @@ export class AnalyticsService implements IAnalyticsService {
222231

223232
this.readScopeProvider = config.getReadScope;
224233
this.relationshipResolver = config.relationshipResolver;
234+
this.measureCurrency = config.measureCurrency;
225235
this.labelResolver = config.labelResolver;
226236
this.draftRowsResolver = config.draftRowsResolver;
227237

@@ -524,11 +534,22 @@ export class AnalyticsService implements IAnalyticsService {
524534
if (!m) continue;
525535
if (f.label == null && typeof m.label === 'string') f.label = m.label;
526536
if (f.format == null && m.format) f.format = m.format;
527-
// Carry the measure's declared currency so the renderer can render a
528-
// locale-correct symbol via Intl (never a "$" baked into `format`).
537+
// ADR-0053 currency chain. A MONETARY measure resolves its display
538+
// currency from: explicit measure `currency` → source-field
539+
// `currencyConfig.defaultCurrency` → tenant default (`ctx.currency`). A
540+
// measure is monetary if it declares a currency OR aggregates a
541+
// `currency`-type field; non-monetary measures (count, avg of a plain
542+
// number) never receive a currency code.
529543
const fc = f as { currency?: string };
530544
const mc = m as { currency?: string };
531-
if (fc.currency == null && mc.currency) fc.currency = mc.currency;
545+
if (fc.currency == null) {
546+
const meta = m.field ? this.measureCurrency?.(dataset.object, m.field) : undefined;
547+
const monetary = !!mc.currency || meta?.type === 'currency';
548+
if (monetary) {
549+
const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
550+
if (resolved) fc.currency = resolved;
551+
}
552+
}
532553
}
533554
}
534555

packages/services/service-analytics/src/plugin.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,13 @@ export class AnalyticsServicePlugin implements Plugin {
436436
coerceTemporalFilterValue,
437437
relationshipResolver,
438438
labelResolver,
439+
// ADR-0053 — source-field currency metadata for the measure currency chain.
440+
measureCurrency: (object: string, field: string) => {
441+
const f = dataEngine()?.getObject?.(object)?.fields?.[field] as
442+
| { type?: string; currencyConfig?: { defaultCurrency?: string } }
443+
| undefined;
444+
return f ? { type: f.type, defaultCurrency: f.currencyConfig?.defaultCurrency } : undefined;
445+
},
439446
draftRowsResolver,
440447
};
441448

0 commit comments

Comments
 (0)