diff --git a/examples/app-crm/src/datasets/opportunity.dataset.ts b/examples/app-crm/src/datasets/opportunity.dataset.ts index 993d0422ed..54b60a3abf 100644 --- a/examples/app-crm/src/datasets/opportunity.dataset.ts +++ b/examples/app-crm/src/datasets/opportunity.dataset.ts @@ -18,6 +18,10 @@ export const OpportunityDataset = defineDataset({ dimensions: [ { name: 'stage', label: 'Stage', field: 'stage', type: 'string' }, + // Lookup dimension — the analytics layer resolves the account FK to its + // display name in `rows`, but exposes the raw FK via drillRawRows so a + // report drill filters by the stored id (ADR-0021 D2), not the name. + { name: 'account', label: 'Account', field: 'account', type: 'lookup' }, // ADR-0021 single-form: the monthly bucketing the trend widget used to carry // as `categoryGranularity: 'month'` now lives on the dimension itself. { name: 'close_date', label: 'Close Date', field: 'close_date', type: 'date', dateGranularity: 'month' }, diff --git a/examples/app-crm/src/reports/index.ts b/examples/app-crm/src/reports/index.ts index 5cb0f1dbce..257688117e 100644 --- a/examples/app-crm/src/reports/index.ts +++ b/examples/app-crm/src/reports/index.ts @@ -1,3 +1,4 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { SalesByStageReport } from './sales-by-stage.report.js'; +export { SalesByAccountReport } from './sales-by-account.report.js'; diff --git a/examples/app-crm/src/reports/sales-by-account.report.ts b/examples/app-crm/src/reports/sales-by-account.report.ts new file mode 100644 index 0000000000..499e3abce2 --- /dev/null +++ b/examples/app-crm/src/reports/sales-by-account.report.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type * as UI from '@objectstack/spec/ui'; + +/** + * Example report — total opportunity amount grouped by ACCOUNT. + * + * Bound to `opportunity_metrics` with rows = `account`, a LOOKUP dimension. + * The analytics layer resolves each account FK to its display name in `rows`, + * but exposes the raw FK via `drillRawRows` + `dimensionFields` (ADR-0021 D2), + * so drilling a row filters the opportunity list by the account's stored id — + * not its (possibly non-unique) display name. Paired with the currency-aware + * `total_amount` measure (USD), this exercises both render paths — Intl + * currency formatting AND raw-value lookup drill — end to end. + */ +export const SalesByAccountReport: UI.ReportInput = { + name: 'crm_sales_by_account', + label: 'Sales by Account', + description: 'Total opportunity amount grouped by account (lookup-dimension drill).', + type: 'summary', + dataset: 'opportunity_metrics', + rows: ['account'], + values: ['total_amount'], +}; 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 2a8c9ad399..dba49e6c2c 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts @@ -154,4 +154,23 @@ describe('AnalyticsService.queryDataset', () => { expect(result.object).toBeUndefined(); expect(result.drillRawRows).toBeUndefined(); }); + + it('marks a LOOKUP dimension drillable, exposing the raw FK for exact-match drill', async () => { + const byAccount = DatasetSchema.parse({ + name: 'sales_acct', label: 'Sales', object: 'opportunity', include: [], + dimensions: [{ name: 'account', field: 'account', type: 'lookup', label: 'Account' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', certified: true }], + }); + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => [{ account: 'acc_123', revenue: 1000 }], + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), + }); + const result = await svc.queryDataset(byAccount, { dimensions: ['account'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext) as any; + // A lookup dim IS drillable (unlike a date bucket): its raw FK is exposed so + // the report drill filters by the stored id, not the resolved display name. + expect(result.object).toBe('opportunity'); + expect(result.dimensionFields).toEqual({ account: 'account' }); + expect(result.drillRawRows).toEqual([{ account: 'acc_123' }]); + }); });