From e10244c4c2503b404eba8c9c2672bb99e203f0f2 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:44:27 +0800 Subject: [PATCH 1/2] feat(analytics): resolve a monetary measure's currency via field config + tenant default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/__tests__/query-dataset.test.ts | 39 +++++++++++++++++++ .../src/analytics-service.ts | 27 +++++++++++-- .../services/service-analytics/src/plugin.ts | 7 ++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts index dba49e6c2c..402ce2bfbe 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts @@ -122,6 +122,45 @@ describe('AnalyticsService.queryDataset', () => { expect(revenueField?.format).toBe('0,0'); }); + // ── ADR-0053 currency chain (measure → field currencyConfig → tenant ctx) ── + function pricedSvc(rows: Array>, measureCurrency?: (o: string, f: string) => { type?: string; defaultCurrency?: string } | undefined) { + return new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => rows, + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), + ...(measureCurrency ? { measureCurrency } : {}), + }); + } + const moneyDataset = (measure: Record) => DatasetSchema.parse({ + name: 'money', label: 'Money', object: 'opportunity', include: [], + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', label: 'Revenue', ...measure }], + }); + + it('chain: a currency-FIELD measure (no explicit currency) inherits the field defaultCurrency', async () => { + const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency', defaultCurrency: 'EUR' } : undefined); + const r = await svc.queryDataset(moneyDataset({}), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o' } as ExecutionContext) as any; + expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('EUR'); + }); + + it('chain: a currency field with no defaultCurrency falls back to the tenant ctx.currency', async () => { + const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency' } : undefined); + const r = await svc.queryDataset(moneyDataset({}), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'GBP' } as ExecutionContext) as any; + expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('GBP'); + }); + + it('chain: an explicit measure currency wins over the field default and the tenant ctx', async () => { + const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency', defaultCurrency: 'EUR' } : undefined); + const r = await svc.queryDataset(moneyDataset({ currency: 'JPY' }), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'GBP' } as ExecutionContext) as any; + expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('JPY'); + }); + + it('chain: a NON-currency field measure never gets a currency (even with a tenant default)', async () => { + const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'number' } : undefined); + const r = await svc.queryDataset(moneyDataset({}), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'USD' } as ExecutionContext) as any; + expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBeUndefined(); + }); + it('enriches dimension columns with their dataset display label', async () => { const labeled = DatasetSchema.parse({ name: 'sales2', label: 'Sales', object: 'opportunity', include: ['account'], diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index c60f9d47d1..30e4201a3f 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -141,6 +141,14 @@ export interface AnalyticsServiceConfig { * provided, `queryDataset` validates that every declared `include` exists. */ relationshipResolver?: RelationshipResolver; + /** + * ADR-0053 currency chain — resolve a measure's SOURCE FIELD currency + * metadata so a monetary measure that omits an explicit `currency` falls back + * to the field's declared currency, then the tenant default (`ctx.currency`). + * Returns the source field's `type` and (fixed-mode) `defaultCurrency`; + * `undefined` for an unknown field. Non-`currency` fields never get a code. + */ + measureCurrency?: (object: string, field: string) => { type?: string; defaultCurrency?: string } | undefined; /** Pre-defined datasets to compile + register at construction (ADR-0021). */ datasets?: Dataset[]; /** @@ -204,6 +212,7 @@ export class AnalyticsService implements IAnalyticsService { private readonly datasetRegistry = new Map(); /** Optional object-graph resolver used when compiling datasets. */ private readonly relationshipResolver?: RelationshipResolver; + private readonly measureCurrency?: AnalyticsServiceConfig['measureCurrency']; /** Optional dimension display-label resolver (select options / lookup names). */ private readonly labelResolver?: DimensionLabelDeps; /** ADR-0037 P3: pending-seed row resolver for draft data preview. */ @@ -222,6 +231,7 @@ export class AnalyticsService implements IAnalyticsService { this.readScopeProvider = config.getReadScope; this.relationshipResolver = config.relationshipResolver; + this.measureCurrency = config.measureCurrency; this.labelResolver = config.labelResolver; this.draftRowsResolver = config.draftRowsResolver; @@ -524,11 +534,22 @@ export class AnalyticsService implements IAnalyticsService { if (!m) continue; if (f.label == null && typeof m.label === 'string') f.label = m.label; if (f.format == null && m.format) f.format = m.format; - // Carry the measure's declared currency so the renderer can render a - // locale-correct symbol via Intl (never a "$" baked into `format`). + // ADR-0053 currency chain. A MONETARY measure resolves its display + // currency from: explicit measure `currency` → source-field + // `currencyConfig.defaultCurrency` → tenant default (`ctx.currency`). A + // measure is monetary if it declares a currency OR aggregates a + // `currency`-type field; non-monetary measures (count, avg of a plain + // number) never receive a currency code. const fc = f as { currency?: string }; const mc = m as { currency?: string }; - if (fc.currency == null && mc.currency) fc.currency = mc.currency; + if (fc.currency == null) { + const meta = m.field ? this.measureCurrency?.(dataset.object, m.field) : undefined; + const monetary = !!mc.currency || meta?.type === 'currency'; + if (monetary) { + const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency; + if (resolved) fc.currency = resolved; + } + } } } diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index bc8002a485..d9fb93f46b 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -436,6 +436,13 @@ export class AnalyticsServicePlugin implements Plugin { coerceTemporalFilterValue, relationshipResolver, labelResolver, + // ADR-0053 — source-field currency metadata for the measure currency chain. + measureCurrency: (object: string, field: string) => { + const f = dataEngine()?.getObject?.(object)?.fields?.[field] as + | { type?: string; currencyConfig?: { defaultCurrency?: string } } + | undefined; + return f ? { type: f.type, defaultCurrency: f.currencyConfig?.defaultCurrency } : undefined; + }, draftRowsResolver, }; From bc9e1838a381ae6b37a456c14c778a56e651a898 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:44:47 +0800 Subject: [PATCH 2/2] chore: changeset for analytics measure-currency chain --- .changeset/analytics-measure-currency-chain.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/analytics-measure-currency-chain.md diff --git a/.changeset/analytics-measure-currency-chain.md b/.changeset/analytics-measure-currency-chain.md new file mode 100644 index 0000000000..e4078ff125 --- /dev/null +++ b/.changeset/analytics-measure-currency-chain.md @@ -0,0 +1,11 @@ +--- +"@objectstack/service-analytics": minor +--- + +Resolve a monetary measure's display currency via the field→tenant chain. + +A dataset measure-currency now resolves through: explicit measure `currency` → +source-field `currencyConfig.defaultCurrency` → tenant default (`ctx.currency`). +A measure is monetary iff it declares a currency or aggregates a `currency`-type +field, so count/avg-of-number measures never receive a code. Wires a +`measureCurrency` field-metadata resolver from the data engine's object schema.