diff --git a/.changeset/28194-count-is-not-an-amount.md b/.changeset/28194-count-is-not-an-amount.md new file mode 100644 index 00000000000..45fa85008eb --- /dev/null +++ b/.changeset/28194-count-is-not-an-amount.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-analytics': patch +--- + +fix(service-analytics): a dataset `count` that names an amount field is no amount: the cross-currency guard no longer compares it, and its result column carries no currency + +Clause-②: yes (widening) +Metadata-protocol: none + +A `count` counts rows, not money, also when it names a field (`{ aggregate: 'count', field: 'amount' }` counts the records with an amount). Two readers still treated such a count as an amount in the field's currency: + +- **The compile-time currency guard.** A derived measure over an amount and such a count was refused `DATASET_INVALID` / 400 as "amounts in different currencies": for example `ratio(usd_total, priced_invoices)`, an average amount per priced invoice, when the counted field is a record-currency amount or fixed to another code. It now compiles. Only an amount aggregate (`sum`, `avg`, `min`, `max`) resolves a currency for the guard, so a `count` is never compared, whatever field it names or `currency` it declares. Unchanged: a derived measure over two amounts in different `fixed` codes, or a record-currency amount and another currency, is still refused, and a measure `currency` that contradicts its field's `fixed` code is still refused on a `count` too. +- **The result column.** A `count` over a `fixed`-currency or tenant-currency amount carried that currency on its column (`fields[].currency`), so a dashboard tile counting priced invoices printed `€3` instead of `3`. The column now carries no currency; a `sum` / `avg` / `min` / `max` over the same field keeps it, and a measure that declares its own `currency` keeps it as before. diff --git a/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts b/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts index 4a8c2b358b1..91ae58a4f07 100644 --- a/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts +++ b/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts @@ -26,6 +26,8 @@ * combining a record-mode amount with another currency or two different * `fixed` codes. The `fixed` arms reach boot registration, `queryDataset` * and the draft preview alike (ruling 6486427200 on #8345). + * - A `count` reads no amount, over an amount field or not: guard 2 does not + * compare it and its column carries no currency, in any arm of the chain. * * The SQLite cell runs everywhere; the strategies are the axis here, not the * dialect (the door stands ahead of both and reads nothing). @@ -347,6 +349,53 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { }); }); + // A `count` over an amount reads no amount, so its column is no money in any + // arm of the chain: neither the field's `fixed` code nor the tenant's. + describe('a count over an amount field carries no currency', () => { + const COUNTS = DatasetSchema.parse({ + name: 'os28194_count_currency', + label: 'Count currency', + object: OBJECT, + dimensions: [], + measures: [ + { name: 'priced_eur', aggregate: 'count', field: 'amt_eur' }, + { name: 'priced_tenant', aggregate: 'count', field: 'amt_tenant' }, + // Controls: an amount aggregate over the same fields keeps its currency. + { name: 'eur', aggregate: 'sum', field: 'amt_eur' }, + { name: 'tenant', aggregate: 'sum', field: 'amt_tenant' }, + ], + }); + const expectColumns = (result: AnalyticsResult) => { + expect(column(result, 'priced_eur')?.currency).toBeUndefined(); + expect(column(result, 'priced_tenant')?.currency).toBeUndefined(); + expect(column(result, 'eur')?.currency).toBe('EUR'); + expect(column(result, 'tenant')?.currency).toBe('CNY'); + }; + + for (const face of FACES) { + it(`${face} strategy: a count over a fixed or a tenant-currency amount is a plain number; a sum keeps the code`, async () => { + const result = await services[face]!.queryDataset( + COUNTS, + { measures: ['priced_eur', 'priced_tenant', 'eur', 'tenant'] }, + CTX, + ); + expectColumns(result); + const row = (result.rows as Array>)[0]; + expect(Number(row?.priced_eur)).toBe(3); + }); + } + + it('the draft-preview path labels the same columns', async () => { + const result = await services.objectql!.queryDataset( + COUNTS, + { measures: ['priced_eur', 'priced_tenant', 'eur', 'tenant'] }, + CTX, + { previewDrafts: true }, + ); + expectColumns(result); + }); + }); + describe('compile-time guards on measure currencies', () => { const datasetWith = (measures: unknown[]) => DatasetSchema.parse({ @@ -506,6 +555,38 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { ).toBeUndefined(); }); + // A `count` counts rows, not money (ruling 6486427200 on #8345): only an + // amount aggregate resolves a currency, so a count is no operand guard 2 + // compares, whatever field it names or currency it declares. + const counts = [ + { name: 'priced_invoices', aggregate: 'count', field: 'amount' }, + { name: 'priced_eur', aggregate: 'count', field: 'amt_eur' }, + { name: 'invoices_usd', aggregate: 'count', currency: 'USD' }, + ]; + for (const op of ['ratio', 'sum', 'difference'] as const) { + it(`accepted: a derived ${op} of an amount and a count over an amount field, or a count declaring a currency`, () => { + expect( + compileRefusal([ + ...base, + ...counts, + { name: 'usd_by_priced', derived: { op, of: ['usd', 'priced_invoices'] } }, + { name: 'usd_by_priced_eur', derived: { op, of: ['usd', 'priced_eur'] } }, + { name: 'total_by_priced_eur', derived: { op, of: ['total', 'priced_eur'] } }, + { name: 'total_by_invoices_usd', derived: { op, of: ['total', 'invoices_usd'] } }, + ])?.message, + ).toBeUndefined(); + }); + } + + it('CONTROL: beside those counts, two fixed codes and a record amount against a fixed one stay refused', () => { + expectRefused([...base, ...counts, { name: 'mixed', derived: { op: 'sum', of: ['usd', 'eur'] } }], 'mixed'); + expectRefused([...base, ...counts, { name: 'mixed', derived: { op: 'ratio', of: ['total', 'usd'] } }], 'mixed'); + }); + + it("CONTROL: a count's measure currency contradicting its fixed field is still refused (guard 1 reads the field)", () => { + expectRefused([{ name: 'priced_eur_as_usd', aggregate: 'count', field: 'amt_eur', currency: 'USD' }], 'priced_eur_as_usd'); + }); + // The request door and the draft preview compile through the same // `compileDataset` call as registration, so both fixed shapes refuse there too. const fixedShapes = [ diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 091ec4edaa0..0ea0b0f30d4 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -3245,9 +3245,10 @@ export class AnalyticsService implements IAnalyticsService { // from: explicit measure `currency` → the source field's FIXED // currency (`sourceFieldMeta().defaultCurrency`, relayed only under // `currencyMode: 'fixed'` — #20091) → 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. + // A measure is monetary if it declares a currency OR takes an amount + // aggregate (`AMOUNT_AGGREGATES`) over a `currency`-type field; + // non-monetary measures (a count, over an amount field too, or an avg + // of a plain number) never receive a currency code. // // [ADR-0144 D2] A `record`-mode amount has one currency per ROW, held // in its code field, so no single code describes the column and no @@ -3277,7 +3278,8 @@ export class AnalyticsService implements IAnalyticsService { : undefined; if (fc.currencyField == null && codeColumn !== undefined) fc.currencyField = codeColumn; } else if (fc.currency == null) { - const monetary = !!mc.currency || meta?.type === 'currency' || derivedCode !== undefined; + const amount = meta?.type === 'currency' && AMOUNT_AGGREGATES.has(m.aggregate ?? ''); + const monetary = !!mc.currency || amount || derivedCode !== undefined; if (monetary) { const resolved = mc.currency ?? meta?.defaultCurrency ?? derivedCode ?? context?.currency; if (resolved) fc.currency = resolved; diff --git a/packages/services/service-analytics/src/measure-currency-coherence.ts b/packages/services/service-analytics/src/measure-currency-coherence.ts index f8e78da6b43..985caf62601 100644 --- a/packages/services/service-analytics/src/measure-currency-coherence.ts +++ b/packages/services/service-analytics/src/measure-currency-coherence.ts @@ -28,8 +28,11 @@ * - one is a record-mode amount, and the other is another code field, a * code or the tenant's; or * - both resolve to `fixed` codes, and the codes differ. - * Non-monetary operands (a `count`) are not compared, so an amount per - * invoice stays legal. + * Non-monetary operands are not compared, so an amount per invoice stays + * legal: only an amount aggregate (`AMOUNT_AGGREGATES`) resolves a + * currency, so a `count` resolves to none whatever field it names or + * currency it declares (ADR-0144 open item 12: "a `count` operand is not + * compared"). * * ⛔ Not judged (the ruling's own list): a `fixed` code against the tenant's * currency, which is not known at compile ("cannot answer, do not block"); a @@ -59,6 +62,7 @@ */ import type { Dataset, DatasetMeasure } from '@objectstack/spec/ui'; +import { AMOUNT_AGGREGATES } from './cube-record-currency-door.js'; import { datasetInvalidError } from './dataset-refusal.js'; /** @@ -129,7 +133,7 @@ export function measureCurrencyResolver(dataset: Dataset, declaredCurrency: Decl ? { ...first, fixed: common.every((c) => c.kind === 'code' && c.fixed) } : first; } - } else if (!m.duration) { + } else if (!m.duration && AMOUNT_AGGREGATES.has(m.aggregate ?? '')) { const field = fieldOf(m); if (field?.currencyField !== undefined) r = { kind: 'record', codeField: field.currencyField }; else if (m.currency) r = { kind: 'code', code: m.currency, fixed: field?.defaultCurrency === m.currency };