Skip to content

Commit 4cd46eb

Browse files
os-zhuangclaude
andcommitted
feat(analytics): propagate a measure's declared currency to the result field
A dataset measure could declare a `format` (carried onto the analytics result field so the client renders "$616,000"), but there was NO way to declare a currency CODE. The analytics result enrichment set `label` + `format` on each measure field but never `currency` — so the client's currency-aware formatting (Intl symbol from a declared currency, ADR-0021; objectui DatasetWidget/report renderer) could never fire against real data: every amount fell back to a plain number or a "$" literal baked into `format`, regardless of the actual currency. This adds the missing link, symmetric with `format`: - spec: `DatasetMeasure.currency` (ISO 4217, optional) — declared on the semantic layer when the aggregated field is a fixed-currency amount. - service-analytics: carry `measure.currency` onto the result field alongside `label`/`format`, so the renderer gets a real currency code. - example (app-crm): `opportunity_metrics` total/avg amount now declare `currency: 'USD'` (and drop the legacy `$` from `format`) so the pipeline reports render a locale-correct symbol via Intl. Tests: service-analytics 138 passed (+1: a measure's declared currency rides onto its result field). Spec builds; the new field is additive + optional. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 26d7df4 commit 4cd46eb

4 files changed

Lines changed: 37 additions & 2 deletions

File tree

examples/app-crm/src/datasets/opportunity.dataset.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const OpportunityDataset = defineDataset({
2525

2626
measures: [
2727
{ name: 'opp_count', label: 'Opportunities', aggregate: 'count' },
28-
{ name: 'total_amount', label: 'Total Amount', aggregate: 'sum', field: 'amount', format: '$0,0' },
29-
{ name: 'avg_amount', label: 'Avg Deal Size', aggregate: 'avg', field: 'amount', format: '$0,0' },
28+
{ name: 'total_amount', label: 'Total Amount', aggregate: 'sum', field: 'amount', format: '0,0', currency: 'USD' },
29+
{ name: 'avg_amount', label: 'Avg Deal Size', aggregate: 'avg', field: 'amount', format: '0,0', currency: 'USD' },
3030
],
3131
});

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,29 @@ describe('AnalyticsService.queryDataset', () => {
9999
expect(result.rows[0]).toEqual({ region: 'NA', revenue: 100 });
100100
});
101101

102+
it('enriches a measure column with its declared currency (ISO 4217)', async () => {
103+
const priced = DatasetSchema.parse({
104+
name: 'sales_priced', label: 'Sales', object: 'opportunity', include: [],
105+
dimensions: [{ name: 'stage', field: 'stage', type: 'string' }],
106+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', label: 'Revenue', format: '0,0', currency: 'USD', certified: true }],
107+
});
108+
const svc = new AnalyticsService({
109+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
110+
executeRawSql: async () => [{ stage: 'Won', revenue: 1000 }],
111+
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
112+
});
113+
const result = await svc.queryDataset(
114+
priced,
115+
{ dimensions: ['stage'], measures: ['revenue'] },
116+
{ tenantId: 'org_A' } as ExecutionContext,
117+
) as any;
118+
// The measure's declared currency rides onto the result field so the client
119+
// renders a locale-correct symbol via Intl (not a "$" baked into `format`).
120+
const revenueField = (result.fields ?? []).find((f: any) => f.name === 'revenue');
121+
expect(revenueField?.currency).toBe('USD');
122+
expect(revenueField?.format).toBe('0,0');
123+
});
124+
102125
it('enriches dimension columns with their dataset display label', async () => {
103126
const labeled = DatasetSchema.parse({
104127
name: 'sales2', label: 'Sales', object: 'opportunity', include: ['account'],

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,11 @@ export class AnalyticsService implements IAnalyticsService {
524524
if (!m) continue;
525525
if (f.label == null && typeof m.label === 'string') f.label = m.label;
526526
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`).
529+
const fc = f as { currency?: string };
530+
const mc = m as { currency?: string };
531+
if (fc.currency == null && mc.currency) fc.currency = mc.currency;
527532
}
528533
}
529534

packages/spec/src/ui/dataset.zod.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ export const DatasetMeasureSchema = lazySchema(() => z.object({
7272
filter: FilterConditionSchema.optional(),
7373
/** Display format, e.g. "$0,0.00", "0.0%". */
7474
format: z.string().optional(),
75+
/**
76+
* Display currency (ISO 4217, e.g. "USD", "CNY"). Carried onto the result
77+
* field so presentations render a locale-correct symbol via `Intl` rather
78+
* than a "$" baked into `format`. Declare it on the measure (the semantic
79+
* layer) when the aggregated field is a fixed-currency amount.
80+
*/
81+
currency: z.string().length(3).optional().describe('Display currency code (ISO 4217)'),
7582
/** Governance: a human-blessed metric — the review checkpoint. */
7683
certified: z.boolean().default(false).describe('Blessed metric (governance checkpoint)'),
7784
/**

0 commit comments

Comments
 (0)