diff --git a/.changeset/27195-record-currency-door-names-home-field.md b/.changeset/27195-record-currency-door-names-home-field.md new file mode 100644 index 00000000000..9997dcd83ab --- /dev/null +++ b/.changeset/27195-record-currency-door-names-home-field.md @@ -0,0 +1,12 @@ +--- +'@objectstack/service-analytics': patch +--- + +fix(service-analytics): the refusal of an ungrouped aggregate over a record-currency amount names the home field as the remedy for a consolidated total (ADR-0144 D6) + +Clause-②: yes (widening) +Metadata-protocol: none + +- **Now.** A `sum`, `avg`, `min` or `max` over a `currencyMode: 'record'` amount that does not group by its code field is still refused `INVALID_FIELD` / 400 before anything is read, with the same code, status, member, field and object. Its message now names both remedies. For one total in the home currency, point the measure at the amount's home field: by name (`homeAmount.field`) when the host relays it, or else as "the field its `currencyConfig.homeAmount.field` names", with the declaration to add if it has none. For one row per currency, add the dimension over the code field. Before, the message named only the grouping. +- **Unchanged.** A measure over the home field is no record-mode amount, so it is served unchanged: ungrouped, grouped by the code field, and as a `totals` grand total, labelled with the home currency. +- **The widening.** `AnalyticsServiceConfig.sourceFieldMeta` may answer one more optional key, `homeAmountField`: the amount's `currencyConfig.homeAmount.field`, beside a `currencyField` only. `AnalyticsServicePlugin` relays it. A host that does not answer it gets the message without the field's name. diff --git a/packages/services/service-analytics/src/__tests__/home-amount-consolidated-measure.test.ts b/packages/services/service-analytics/src/__tests__/home-amount-consolidated-measure.test.ts new file mode 100644 index 00000000000..d4ce0b29235 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/home-amount-consolidated-measure.test.ts @@ -0,0 +1,420 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D6, multi-currency M3-1] A consolidated total over a record-mode + * amount is a plain `sum` over its stored home field, through the dataset + * compile + query path, with no code of its own. + * + * ## What these pins hold + * + * - **The proof.** Rows in three record currencies (USD, EUR and the home + * CNY) are written through the engine, whose write-time projection (D5, + * `home-amount-projection.ts` in `@objectstack/objectql`) stores each + * amount converted into the home currency in the declared home field. A + * dataset measure `sum` over that field answers the home-currency total, + * ungrouped (a KPI), grouped by the code field, and as a `totals` grand + * total; the column carries the home currency (the tenant's, because the + * home field is `dynamic`) and no per-row `currencyField`. The raw `sum` + * over the record amount of the same rows is a different number (it adds + * dollars to euros), which the controls show. + * - **The door's words.** The ungrouped `sum` over the record amount is + * still refused `INVALID_FIELD` / 400 before anything is read, and its + * message now names both remedies: the declared home field by name, or the + * grouping over the code field. An amount that declares no `homeAmount` + * reads the declaration to add instead. + * + * ## The axes + * + * Three stores: the draft-preview evaluator (in memory, over the very rows + * the projection wrote, read back from the store), SQLite, and PostgreSQL. + * The "memory" cell is that evaluator, ⛔ not `driver-memory` (whose test + * consumers are a closed census, `check:driver-memory-census`). + * The SQLite cell always runs. The PostgreSQL cell runs where + * `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; CI provisions + * that variable for this package in the Temporal Conformance job + * (`.github/workflows/ci.yml`). The live cell owns a schema of its own, + * dropped before and after. Both SQL cells run both strategies + * (`NativeSQLStrategy`, the plugin's own capabilities; `ObjectQLStrategy`, + * narrowed to the engine aggregate). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { AnalyticsResult } from '@objectstack/spec/contracts'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const OBJECT = 'os27195_fx_invoice'; +const BARE = 'os27195_fx_bare'; +const PG_SCHEMA = 'os27195_home_measure'; + +/** The rate table's shape (`sys_exchange_rate`, owned by platform-objects; a stand-in here, as in objectql's own pins). */ +const RATE_OBJECT = { + name: 'sys_exchange_rate', + label: 'Exchange rate', + fields: { + currency: { name: 'currency', type: 'text' as const, valueDomain: 'iso_4217_currency' }, + rate: { name: 'rate', type: 'number' as const, scale: 12, precision: 24 }, + effective_date: { name: 'effective_date', type: 'date' as const }, + }, +}; + +const INVOICE = { + name: OBJECT, + label: 'FX invoice', + fields: { + currency_code: { name: 'currency_code', type: 'text' as const, valueDomain: 'iso_4217_currency' }, + invoice_date: { name: 'invoice_date', type: 'date' as const }, + amount: { + name: 'amount', + type: 'currency' as const, + currencyConfig: { + currencyMode: 'record' as const, + currencyField: 'currency_code', + homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }, + }, + }, + amount_home: { name: 'amount_home', type: 'currency' as const, readonly: true }, + amount_rate: { name: 'amount_rate', type: 'number' as const, readonly: true }, + }, +}; + +/** The same record amount with no `homeAmount`: the refusal has no home field to name. */ +const BARE_INVOICE = { + name: BARE, + label: 'FX invoice without a home field', + fields: { + currency_code: { name: 'currency_code', type: 'text' as const, valueDomain: 'iso_4217_currency' }, + amount: { + name: 'amount', + type: 'currency' as const, + currencyConfig: { currencyMode: 'record' as const, currencyField: 'currency_code' }, + }, + }, +}; + +/** A line per invoice, whose cube reaches the invoice's amounts through a declared join. */ +const LINE = 'os27195_fx_line'; +const LINE_OBJECT = { + name: LINE, + label: 'FX invoice line', + fields: { + note: { name: 'note', type: 'text' as const }, + invoice: { name: 'invoice', type: 'lookup' as const, reference: OBJECT }, + }, +}; +const LINE_CUBE = { + name: 'os27195_line_cube', + title: 'Lines over their invoice amounts', + sql: LINE, + public: true, + joins: { invoice: { name: OBJECT } }, + measures: { + invoice_raw: { type: 'sum', sql: 'invoice.amount', label: 'Invoice amount (record currency)' }, + invoice_home: { type: 'sum', sql: 'invoice.amount_home', label: 'Invoice amount (home currency)' }, + }, + dimensions: { + invoice_code: { type: 'string', sql: 'invoice.currency_code', label: 'Invoice currency' }, + }, +}; + +const HOME = 'CNY'; + +/** "1 home unit = rate units of the foreign currency" (ADR-0144 D7 parameter 3), so home = amount ÷ rate. */ +const RATES: ReadonlyArray<[currency: string, effective_date: string, rate: number]> = [ + ['USD', '2026-01-01', 0.125], + ['EUR', '2026-01-01', 0.1], +]; + +/** Three record currencies; the home amount each row's projection stores is in the comment. */ +const ROWS = [ + { id: 'h1', currency_code: 'USD', invoice_date: '2026-03-01', amount: 100 }, // 800 + { id: 'h2', currency_code: 'USD', invoice_date: '2026-03-02', amount: 20 }, // 160 + { id: 'h3', currency_code: 'EUR', invoice_date: '2026-03-03', amount: 100 }, // 1000 + { id: 'h4', currency_code: 'CNY', invoice_date: '2026-03-04', amount: 50 }, // 50 (rate 1) +] as const; + +const HOME_TOTAL = 2010; +const HOME_BY_CODE = { USD: 960, EUR: 1000, CNY: 50 }; +/** What the raw amounts add to: dollars, euros and yuan as one number — the shape the door refuses. */ +const RAW_SUM = 270; + +const DATASET = DatasetSchema.parse({ + name: 'os27195_consolidated', + label: 'Invoices, consolidated', + object: OBJECT, + dimensions: [{ name: 'currency', field: 'currency_code', type: 'string' }], + measures: [ + { name: 'home_total', aggregate: 'sum', field: 'amount_home' }, + { name: 'raw_total', aggregate: 'sum', field: 'amount' }, + { name: 'invoices', aggregate: 'count' }, + ], +}); + +const BARE_DATASET = DatasetSchema.parse({ + name: 'os27195_bare', + label: 'Invoices without a home field', + object: BARE, + dimensions: [{ name: 'currency', field: 'currency_code', type: 'string' }], + measures: [{ name: 'raw_total', aggregate: 'sum', field: 'amount' }], +}); + +const CTX = { currency: HOME } as ExecutionContext; +const SYS = { context: { isSystem: true } } as const; + +type Face = 'native' | 'objectql'; +const FACES: readonly Face[] = ['native', 'objectql']; + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL + ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL, searchPath: [PG_SCHEMA] } + : null), + }, +]; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, trace() {}, fatal() {}, child() { return quiet; } }; + +type Refusal = Error & { code?: string; status?: number; member?: string; param?: string; field?: string; object?: string }; + +const refusalOf = (p: Promise): Promise => + p.then( + () => { + throw new Error('expected a refusal, but the query resolved'); + }, + (e) => e as Refusal, + ); + +const column = (result: AnalyticsResult, name: string) => + (result.fields ?? []).find((f) => f.name === name) as + | { name: string; currency?: string; currencyField?: string } + | undefined; + +const byCode = (rows: unknown[], measure: string): Record => + Object.fromEntries((rows as Array>).map((r) => [String(r.currency), Number(r[measure])])); + +/** The live cell's own schema, created or dropped on a connection outside it. */ +async function pgSchema(statement: 'create' | 'drop'): Promise { + const admin = new SqlDriver({ client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } as never); + try { + await admin.execute(`drop schema if exists ${PG_SCHEMA} cascade`); + if (statement === 'create') await admin.execute(`create schema ${PG_SCHEMA}`); + } finally { + await admin.disconnect().catch(() => {}); + } +} + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[ADR-0144 D6 · M3-1] a consolidated measure over the stored home field (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let engine: ObjectQL; + /** Raw-SQL statements and engine aggregates that read the invoice objects. */ + const reads = { rawSql: 0, aggregate: 0 }; + const services: Partial> = {}; + /** The rows as the projection stored them, read back — the draft preview's seed. */ + let stored: Array> = []; + + beforeAll(async () => { + if (cell.id === 'pg') await pgSchema('create'); + const driver = new SqlDriver(config as never); + engine = new ObjectQL({ logger: quiet } as never); + engine.registerDriver(driver as never, true); + await engine.init(); + for (const object of [RATE_OBJECT, INVOICE, BARE_INVOICE, LINE_OBJECT]) engine.registry.registerObject(object as never); + await engine.syncSchemas(); + await installDefaultOrganization(engine as never); + engine.setHomeLocalizationResolver(async () => ({ currency: HOME })); + for (const [currency, effective_date, rate] of RATES) { + await engine.insert('sys_exchange_rate', { currency, effective_date, rate }, SYS as never); + } + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as never, SYS as never); + for (const row of ROWS) { + await engine.insert(BARE, { id: row.id, currency_code: row.currency_code, amount: row.amount } as never, SYS as never); + } + for (const row of ROWS) await engine.insert(LINE, { id: `l_${row.id}`, note: row.id, invoice: row.id } as never, SYS as never); + stored = (await engine.find(OBJECT, { context: SYS.context } as never)) as Array>; + + const realExecute = (engine as any).execute.bind(engine); + (engine as any).execute = (sql: unknown, opts?: { object?: string }) => { + if (opts?.object === OBJECT || opts?.object === BARE || opts?.object === LINE) reads.rawSql += 1; + return realExecute(sql, opts); + }; + const realAggregate = engine.aggregate.bind(engine); + (engine as any).aggregate = (object: string, ...rest: unknown[]) => { + if (object === OBJECT || object === BARE || object === LINE) reads.aggregate += 1; + return (realAggregate as any)(object, ...rest); + }; + + // A `'protocol'` service answering one pending seed draft of the STORED + // rows, which routes `previewDrafts` onto the in-memory evaluator. + const pendingSeedProtocol = { + getMetaItems: async () => [{ name: 'os27195_seed', object: OBJECT }], + getMetaItem: async () => ({ item: { records: stored } }), + }; + for (const [face, caps] of [ + ['native', undefined], + ['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], + ] as const) { + const registered: Record = {}; + const provided: Record = { data: engine, protocol: pendingSeedProtocol }; + await new AnalyticsServicePlugin({ cubes: [LINE_CUBE], ...(caps ? { queryCapabilities: caps } : {}) } as never).init({ + getService: (name: string) => provided[name] ?? registered[name], + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + services[face] = registered.analytics as AnalyticsService; + } + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + if (cell.id === 'pg') await pgSchema('drop'); + }); + + it('the projection stored each row in the home currency (the fixture the proof reads)', () => { + const home = Object.fromEntries(stored.map((r) => [r.id, [Number(r.amount_home), Number(r.amount_rate)]])); + expect(home).toEqual({ h1: [800, 0.125], h2: [160, 0.125], h3: [1000, 0.1], h4: [50, 1] }); + }); + + for (const face of FACES) { + describe(`${face} strategy`, () => { + it('a sum over the home field, ungrouped, answers the home-currency total, labelled with the home currency', async () => { + const result = await services[face]!.queryDataset(DATASET, { measures: ['home_total', 'invoices'] }, CTX); + const [row] = result.rows as Array>; + expect(Number(row!.home_total)).toBe(HOME_TOTAL); + expect(Number(row!.invoices)).toBe(ROWS.length); + expect(column(result, 'home_total')?.currency).toBe(HOME); + expect(column(result, 'home_total')?.currencyField).toBeUndefined(); + }); + + it('grouped by the code field: each currency\'s home subtotal, and a grand total equal to the KPI', async () => { + const result = await services[face]!.queryDataset( + DATASET, + { dimensions: ['currency'], measures: ['home_total', 'raw_total'], totals: { groupings: [[]] } }, + CTX, + ).catch((e: Refusal) => e); + // The grand total over `raw_total` drops the code, so the door refuses + // it; the home field alone is served under the same grand total. + expect((result as Refusal).code).toBe('INVALID_FIELD'); + expect((result as Refusal).status).toBe(400); + expect((result as Refusal).member).toBe('raw_total'); + + const served = await services[face]!.queryDataset( + DATASET, + { dimensions: ['currency'], measures: ['home_total'], totals: { groupings: [[]] } }, + CTX, + ); + expect(byCode(served.rows, 'home_total')).toEqual(HOME_BY_CODE); + expect(Number((served.totals?.[0]?.rows[0] as Record).home_total)).toBe(HOME_TOTAL); + expect(column(served, 'home_total')?.currency).toBe(HOME); + expect(column(served, 'home_total')?.currencyField).toBeUndefined(); + }); + + it('the control: grouped by the code, the raw amount is served per currency and does not add to the home total', async () => { + const result = await services[face]!.queryDataset(DATASET, { dimensions: ['currency'], measures: ['raw_total'] }, CTX); + expect(byCode(result.rows, 'raw_total')).toEqual({ USD: 120, EUR: 100, CNY: 50 }); + expect(Object.values(byCode(result.rows, 'raw_total')).reduce((a, b) => a + b, 0)).toBe(RAW_SUM); + expect(column(result, 'raw_total')?.currencyField).toBe('currency'); + }); + + it('the ungrouped sum over the record amount is refused before anything is read, and the words name the home field and the grouping', async () => { + const before = { ...reads }; + const err = await refusalOf(services[face]!.queryDataset(DATASET, { measures: ['raw_total'] }, CTX)); + expect(err.code, err.message).toBe('INVALID_FIELD'); + expect(err.status).toBe(400); + expect(err.member).toBe('raw_total'); + expect(err.field).toBe('amount'); + expect(err.object).toBe(OBJECT); + expect(err.message).toContain(`point the measure at 'amount_home', the home-currency amount object '${OBJECT}' declares for 'amount'`); + expect(err.message).toContain("add the dimension over 'currency_code' to the query"); + expect(reads.rawSql - before.rawSql).toBe(0); + expect(reads.aggregate - before.aggregate).toBe(0); + }); + + it('an amount declaring no homeAmount reads the home-field remedy without a name, and the grouping', async () => { + const err = await refusalOf(services[face]!.queryDataset(BARE_DATASET, { measures: ['raw_total'] }, CTX)); + expect(err.code, err.message).toBe('INVALID_FIELD'); + expect(err.status).toBe(400); + expect(err.object).toBe(BARE); + expect(err.message).toContain( + `point the measure at the home field of 'amount' (the field its \`currencyConfig.homeAmount.field\` names on object '${BARE}'; declare \`homeAmount\` there first if it has none)`, + ); + expect(err.message).toContain("add the dimension over 'currency_code' to the query"); + }); + + it('the inferred cube (no registered dataset): `amount_home_sum` is served, `amount_sum` is refused naming amount_home', async () => { + const served = await services[face]!.query({ cube: OBJECT, measures: ['amount_home_sum'] } as never, CTX); + expect(Number((served.rows[0] as Record).amount_home_sum)).toBe(HOME_TOTAL); + const err = await refusalOf(services[face]!.query({ cube: OBJECT, measures: ['amount_sum'] } as never, CTX)); + expect(err.code, err.message).toBe('INVALID_FIELD'); + expect(err.status).toBe(400); + expect(err.message).toContain("point the measure at 'amount_home'"); + }); + + it('over a relationship path, the ungrouped raw amount is refused naming the joined home field and code field', async () => { + const before = { ...reads }; + const err = await refusalOf(services[face]!.query({ cube: LINE_CUBE.name, measures: ['invoice_raw'] } as never, CTX)); + expect(err.code, err.message).toBe('INVALID_FIELD'); + expect(err.status).toBe(400); + expect(err.member).toBe('invoice_raw'); + expect(err.field).toBe('invoice.amount'); + expect(err.object).toBe(OBJECT); + expect(err.message).toContain(`point the measure at 'invoice.amount_home', the home-currency amount object '${OBJECT}' declares for 'amount'`); + expect(err.message).toContain("add the dimension over 'invoice.currency_code' to the query"); + expect(reads.rawSql - before.rawSql).toBe(0); + expect(reads.aggregate - before.aggregate).toBe(0); + }); + }); + } + + it('over a relationship path, a sum over the joined home field is served: the home-currency total (native strategy)', async () => { + const served = await services.native!.query({ cube: LINE_CUBE.name, measures: ['invoice_home'] } as never, CTX); + expect(Number((served.rows[0] as Record).invoice_home)).toBe(HOME_TOTAL); + }); + + describe('the draft preview (the in-memory evaluator, over the stored rows)', () => { + it('a sum over the home field answers the home-currency total, ungrouped and grouped by the code', async () => { + const kpi = await services.native!.queryDataset(DATASET, { measures: ['home_total'] }, CTX, { previewDrafts: true }); + expect(Number((kpi.rows[0] as Record).home_total)).toBe(HOME_TOTAL); + expect(column(kpi, 'home_total')?.currency).toBe(HOME); + const grouped = await services.native!.queryDataset( + DATASET, + { dimensions: ['currency'], measures: ['home_total'] }, + CTX, + { previewDrafts: true }, + ); + expect(byCode(grouped.rows, 'home_total')).toEqual(HOME_BY_CODE); + }); + + it('the ungrouped raw amount is refused with the same words', async () => { + const err = await refusalOf( + services.native!.queryDataset(DATASET, { measures: ['raw_total'] }, CTX, { previewDrafts: true }), + ); + expect(err.code, err.message).toBe('INVALID_FIELD'); + expect(err.status).toBe(400); + expect(err.member).toBe('raw_total'); + expect(err.message).toContain("point the measure at 'amount_home'"); + }); + }); + }, + ); +} diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index eccf156b269..fe12deab3d9 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -1215,6 +1215,10 @@ export interface AnalyticsServiceConfig { * Such an amount has one currency per ROW, so its column carries the * `currencyField` pointer instead of a `currency`, and the cube door * refuses an aggregate of it that does not group by that field. + * [ADR-0144 D6, #27195] `homeAmountField` is that amount's stored + * home-currency field: answer `currencyConfig.homeAmount.field` ONLY + * beside a `currencyField`, and `undefined` otherwise. One reader: the + * cube door's refusal names it as the consolidated-total remedy. * - Percent scale (objectui#3136): a measure over a `percent` field inherits * that field's storage scale via `percentScaleOf`, so a renderer scales by * declared metadata instead of guessing from the value. @@ -1244,7 +1248,14 @@ export interface AnalyticsServiceConfig { sourceFieldMeta?: ( object: string, field: string, - ) => { type?: string; multiple?: boolean; defaultCurrency?: string; currencyField?: string; max?: number } | undefined; + ) => { + type?: string; + multiple?: boolean; + defaultCurrency?: string; + currencyField?: string; + homeAmountField?: string; + max?: number; + } | undefined; /** * [#15684] The SQL dialect of the datasource backing `object` — `'sqlite'`, * `'postgres'`, `'mysql'`, or `undefined` when the host cannot answer. @@ -3936,7 +3947,11 @@ export class AnalyticsService implements IAnalyticsService { path: sql, }; }, - (o, field) => fieldMeta(o, field)?.currencyField, + (o, field) => { + const meta = fieldMeta(o, field); + if (meta?.currencyField === undefined) return undefined; + return { codeField: meta.currencyField, homeField: meta.homeAmountField }; + }, ); } diff --git a/packages/services/service-analytics/src/cube-record-currency-door.ts b/packages/services/service-analytics/src/cube-record-currency-door.ts index db2bf8eb311..3a9e1b1d513 100644 --- a/packages/services/service-analytics/src/cube-record-currency-door.ts +++ b/packages/services/service-analytics/src/cube-record-currency-door.ts @@ -53,9 +53,13 @@ * * `INVALID_FIELD` / 400 through `invalidMemberError` (ADR-0112), with the * column and its object attached, as the #21044 door attaches them: a verdict - * about one member the request named. The words carry the prescription: - * group by the code field. A consolidated total aggregates the stored home - * field instead (ADR-0144 D6), which the write-time projection (D5) fills. + * about one member the request named. The words carry both prescriptions + * (#27195): for one consolidated total, aggregate the stored home field + * instead (ADR-0144 D6), which the write-time projection (D5) fills — named + * when the amount declares `currencyConfig.homeAmount`, and the declaration + * to add when it does not — or group by the code field for one row per + * currency. A measure over the home field is no record-mode amount (the home + * field is `dynamic`), so this door never judges it: it is served unchanged. */ import type { AnalyticsQuery } from '@objectstack/spec/contracts'; @@ -75,15 +79,17 @@ export const AMOUNT_AGGREGATES: ReadonlySet = new Set(['sum', 'avg', 'mi * reference) — or `undefined` when it resolves to no declared measure. * @param dimensionColumnOf - The column a `dimensions` entry groups by, located * the same way, or `null` when this door cannot pin one. - * @param recordCurrencyFieldOf - The `currencyConfig.currencyField` of a column - * declared `currencyMode: 'record'`, or `undefined` for any other column. + * @param recordCurrencyOf - For a column declared `currencyMode: 'record'`, its + * `currencyConfig.currencyField` and, when it declares `homeAmount`, the + * home field (`homeAmount.field`) on the same object; `undefined` for any + * other column. */ export function assertRecordCurrencyAggregatesGrouped( query: AnalyticsQuery, cubeName: string, measureOf: (member: string) => { type: unknown; column: MeasureColumn | null } | undefined, dimensionColumnOf: (member: string) => MeasureColumn | null, - recordCurrencyFieldOf: (object: string, field: string) => string | undefined, + recordCurrencyOf: (object: string, field: string) => { codeField: string; homeField?: string } | undefined, ): void { const measures = query.measures ?? []; if (measures.length === 0) return; @@ -92,20 +98,26 @@ export function assertRecordCurrencyAggregatesGrouped( const measure = measureOf(member); if (!measure?.column || typeof measure.type !== 'string' || !AMOUNT_AGGREGATES.has(measure.type)) continue; const target = measure.column; - const codeField = recordCurrencyFieldOf(target.object, target.column); - if (codeField === undefined) continue; + const declared = recordCurrencyOf(target.object, target.column); + if (declared === undefined) continue; + const { codeField, homeField } = declared; grouped ??= (query.dimensions ?? []).map(dimensionColumnOf); if (grouped.some((d) => d?.object === target.object && d.column === codeField)) continue; - const codePath = target.path === target.column - ? codeField - : `${target.path.slice(0, target.path.lastIndexOf('.') + 1)}${codeField}`; + const prefix = target.path === target.column ? '' : target.path.slice(0, target.path.lastIndexOf('.') + 1); + const codePath = `${prefix}${codeField}`; + const homeRemedy = homeField !== undefined + ? `point the measure at '${prefix}${homeField}', the home-currency amount object '${target.object}' ` + + `declares for '${target.column}' (\`homeAmount.field\`), for one total in the home currency` + : `for one total in the home currency, point the measure at the home field of '${target.column}' ` + + `(the field its \`currencyConfig.homeAmount.field\` names on object '${target.object}'; declare ` + + '`homeAmount` there first if it has none)'; const err = invalidMemberError( `Measure '${member}' on cube '${cubeName}' takes the ${measure.type} of field '${target.path}', ` + `a record-currency amount whose currency is each record's '${codeField}', but the query does not ` - + `group by '${codePath}', so the query was NOT run. Add the dimension over '${codePath}' to the ` - + `query, which answers one row per currency. Without the grouping the ${measure.type} would ` - + 'combine amounts in different currencies into one number.', + + `group by '${codePath}', so the query was NOT run. Without the grouping the ${measure.type} would ` + + `combine amounts in different currencies into one number. Either ${homeRemedy}; or add the ` + + `dimension over '${codePath}' to the query, which answers one row per currency.`, { member, param: 'measures', cube: cubeName }, ) as Error & { field?: string; object?: string }; err.field = target.path; diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 805fcd1b84e..17183b2e7ac 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -1440,7 +1440,12 @@ export class AnalyticsServicePlugin implements Plugin { type?: string; multiple?: boolean; max?: number; - currencyConfig?: { currencyMode?: string; defaultCurrency?: string; currencyField?: string }; + currencyConfig?: { + currencyMode?: string; + defaultCurrency?: string; + currencyField?: string; + homeAmount?: { field?: string }; + }; } | undefined; if (!f) return undefined; @@ -1453,12 +1458,18 @@ export class AnalyticsServicePlugin implements Plugin { const recordCurrencyField = f.currencyConfig?.currencyMode === 'record' ? f.currencyConfig.currencyField : undefined; + // [ADR-0144 D6, #27195] …and the home field beside it, which the cube + // door's refusal names as the consolidated-total remedy. + const homeAmountField = recordCurrencyField !== undefined + ? f.currencyConfig?.homeAmount?.field + : undefined; return { type: f.type, multiple: f.multiple === true, max: f.max, defaultCurrency: fixedCurrency, currencyField: recordCurrencyField, + homeAmountField, }; }, // #5033 — the datasource an object is bound to, used ONLY to name the