Skip to content

Commit a8ec04d

Browse files
feat(platform-objects): exchange rates import from CSV through the generic import door (#28401)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoBvx33UvpFTTw3UFuQN9Tmg)_ Fixes #27190 Clause-②: no Metadata-protocol: none domain:server#1 · os-tesla · session_01L9qibyvv7cwzqLtsvHGmby ## 维护者速读 - **改了什么:** 实测确认通用异步导入(`sys_import_job`,`POST /data/sys_exchange_rate/import/jobs`)原样就能导汇率 CSV,不需要新导入器;本 PR 补一条端到端钉住测试,打开汇率列表页的「导入」按钮(`userActions: { import: true }`),并在多币种文档里加一段说明。 - **为什么改:** 财务一年 12 行 × N 币种要一次导完;接口早就能导,但汇率表属 `system-data`,列表页的导入入口默认关着,原测试写明「等 #27190 证明后再开」。 - **风险与回滚:** 低。只是一处对象声明加一行,谁能导入仍由原来的写权限(组织管理员)决定;回滚即删这一行。 - **先看:** `packages/platform-objects/src/system/sys-exchange-rate.object.ts`(`userActions` 一行)、`packages/rest/src/import-exchange-rate-csv.test.ts`、`content/docs/data-modeling/currency.mdx`。 - **两分钟验证:** `pnpm --filter @objectstack/rest exec vitest run src/import-exchange-rate-csv.test.ts`,预期 3 条全过:三行 CSV 全部落库、`source` 列照写、重复 (币种, 日期) 那一行被拒而同文件其余行照常落库。 - **需要你做的:** 无,席位落地。 ## What changes - **`sys_exchange_rate` opts in to CSV import** (`packages/platform-objects`): `userActions: { import: true }`. `system-data` keeps import off by default; this object turns it on because the generic door serves it end to end (measured below). Authorization is unchanged: the import door asks the same write grant as New and Edit, which `organization_admin` holds and `member_default` does not. The declaration test now pins `import: true` (its old title said "no CSV import until #27190 proves it"). - **Pin test** `packages/rest/src/import-exchange-rate-csv.test.ts`: the real `sys_exchange_rate` and `sys_import_job` on a real sqlite engine, driven through the async import routes: - three CSV lines land as three rows, the twelve-decimal rate intact, `source` at its default `manual`; - a `source` column reading `import` is written as given; - a line repeating a `(currency, effective_date)` already on file is refused for that row alone (`UNIQUE_VIOLATION`), the next line of the same file lands, and the stored rate is untouched. - **Docs**: one paragraph under "The exchange rates" in `content/docs/data-modeling/currency.mdx`: the three columns, both doors, the per-row duplicate refusal, and the `source` column. - **Changeset** `27190-exchange-rate-csv-import.md` (`@objectstack/platform-objects` patch). No importer was added: the measurement found the generic one serves the table. ## Tests <details><summary>Measurement on a fresh dev server (base 3ba5206, before any edit)</summary> `pnpm dev:crm -- --fresh -p 38719`, signed in as the dev admin, `POST /api/v1/data/sys_exchange_rate/import/jobs` with `format: csv`: - three lines (EUR 0.92 2026-01-01, JPY 149.123456789012 2026-01-01, GBP 0.79 2026-02-01): `201`, job `succeeded`, `created 3`, `errors 0`; the list read back all three with the JPY rate's twelve decimals, `source: manual`, and the organization id stamped. - a second file repeating EUR 2026-01-01 plus a CHF line: job `succeeded`, `created 1`, `errors 1`; row 1 `UNIQUE_VIOLATION`, row 2 created. - a file with a `source` column reading `import`: the row stored `source: import`. - after the change (platform-objects rebuilt, server restarted): `/api/v1/meta/object/sys_exchange_rate` serves `userActions: { import: true }`, and the three-line import still answers `succeeded 3 0`. </details> <details><summary>Package suites and ablation</summary> - `pnpm --filter @objectstack/rest test`: Test Files 356 passed (356), Tests 6846 passed, 332 skipped (at 3ce2973; the last commit only replaces a route lookup in the new test, re-run alone 3/3 at ff0eabc). - `pnpm --filter @objectstack/platform-objects test`: Test Files 104 passed (104), Tests 1474 passed. - `pnpm --filter @objectstack/rest typecheck` and `pnpm --filter @objectstack/platform-objects typecheck`: exit 0 (rest at ff0eabc, after fixing three TS18048 in the new test that `check:test-typecheck` caught). - `pnpm exec eslint --no-inline-config packages/platform-objects packages/rest`: exit 0. - Ablation of the refusal pin: the unique key on `(currency, effective_date)` replaced by a plain index (`scripts/ablation-replace.mjs`, platform-objects rebuilt, `ablation-dist-preflight` found the marker in 8 dist files). The duplicate-refusal case went red: expected `created 1, errors 1`, received `created 2, errors 0` (the duplicate landed); the other two cases stayed green. Restored (blob equals HEAD, `git diff HEAD` empty), rebuilt, preflight `--absent` clean, 3/3 green. </details> ## Acceptance notes - **Imported rows say `manual` unless the file says otherwise.** The `source` field's description reads "loaded from a file (import)", but the generic import writes the field's default, `manual`, when the CSV carries no `source` column; nothing in the import path stamps `import`. This PR documents the column and pins that it is honoured. Making the import stamp it by itself is a design choice beyond this card (an import-origin signal on the write context, or a shipped import mapping with a `constant` transform), so it goes to the seat as an open question, not into this diff. `sys_exchange_rate` has not been released yet (its changeset is still pending), so no shipped row carries the mislabel. - The card's two-minute check ("导入一份 CSV 后列表页出现各行") needed the list page's Import button, which `system-data` kept off; the dispatch order expected a pin test and a docs line only, so turning the affordance on is a declared addition, one line, in its own commit. - Not measured: the Console import wizard itself (the dev server ran without a built Console); the affordance reaching it is measured through `/meta`. A rank-and-file member's refusal at the door is unchanged authorization and was not re-measured. ## Verification <details><summary>Derived gates (dispatch-gates --commands, 5 paths vs merge base 3ba5206)</summary> All 90 derived commands ran with stdin closed, exit codes captured before any pipe. 88 exited 0 on the first pass; the two that did not: - `node scripts/check-plugin-teardown-shape.mjs --self-test`: exit 3, the shallow clone lacked its pinned fixture commit; after fetching that one commit it exited 0 (48 cases). - `pnpm check:query-options-erasure`: exit 1, the new test grew the test surface 236 → 238 with two untyped engine-option casts; fixed in 3ce2973 by dropping the casts, re-run exit 0 (236, at the ceiling, none new). The whole list is re-run on the final head ff0eabc; its reconciliation is recorded in the card report. </details> The branch is 9 commits behind `main`; none of them touches a file this PR changes, so it was not merged before opening (CI builds the merge ref). --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibyvv7cwzqLtsvHGmby)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3fb1a24 commit a8ec04d

5 files changed

Lines changed: 188 additions & 2 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
feat(platform-objects): Exchange Rates offers CSV import on its list page
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`sys_exchange_rate` now opts in to CSV import (`userActions: { import: true }`), so an organization administrator loads a year of rates for every currency from one file — the columns `currency`, `rate` and `effective_date` — from Setup → Exchange Rates, or through `POST /api/v1/data/sys_exchange_rate/import/jobs`. The import is the generic one, with no importer of its own: a line repeating a currency and effective date already on file is refused for that row alone (`UNIQUE_VIOLATION`) while the rest of the file lands, and a rate keeps its twelve decimals. A file marks its rows imported with a `source` column reading `import`; an omitted one takes the default, `manual`. Who may import is the same write grant that guards New and Edit.

‎content/docs/data-modeling/currency.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,14 @@ states how many units of the foreign currency **one unit of the home currency**
253253
with home currency USD, an EUR row of `0.92` means 1 USD = 0.92 EUR. A rate keeps up to
254254
twelve decimals.
255255

256+
A year of rates loads from one CSV with the columns `currency`, `rate` and
257+
`effective_date`, through the list page's **Import** or the generic
258+
[import door](/docs/data-modeling/import-mappings) (`POST /api/v1/data/sys_exchange_rate/import/jobs`).
259+
A line repeating a currency and effective date already on file is refused for that row
260+
alone (`UNIQUE_VIOLATION`) and the rest of the file still lands; add a `source` column
261+
reading `import` to mark the rows imported, since an omitted one takes the default,
262+
`manual`.
263+
256264
### What a write stores
257265

258266
When a record is inserted, and whenever an update changes its amount, its code or its

‎packages/platform-objects/src/system/sys-exchange-rate.object.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,14 @@ describe('sys_exchange_rate — declaration (multi-currency M1-1)', () => {
8181
expect((SysExchangeRate as any).requiredPermissions).toBeUndefined();
8282
});
8383

84-
it('offers New / Edit / Delete to whoever holds the grant, and no CSV import until #27190 proves it', () => {
84+
it('offers New / Edit / Delete and CSV import to whoever holds the grant', () => {
85+
// `system-data` leaves import off by default; this object opts in, because
86+
// the generic import door serves it end to end (rest
87+
// `import-exchange-rate-csv.test.ts` pins the door on the real object).
8588
const affordances = resolveCrudAffordances(SysExchangeRate as any);
8689
expect(SysExchangeRate.managedBy).toBe('system-data');
87-
expect(affordances).toMatchObject({ create: true, edit: true, delete: true, import: false });
90+
expect(SysExchangeRate.userActions).toEqual({ import: true });
91+
expect(affordances).toMatchObject({ create: true, edit: true, delete: true, import: true });
8892
});
8993

9094
it('exposes the generic data API, so Setup can list and write it', () => {

‎packages/platform-objects/src/system/sys-exchange-rate.object.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ export const SysExchangeRate = ObjectSchema.create({
7272
isSystem: true,
7373
// Platform-defined schema, organization-administered data.
7474
managedBy: 'system-data',
75+
// CSV import is opt-in on `system-data`; rates opt in (multi-currency M1-2):
76+
// a finance team loads a year of rates for every currency in one file through
77+
// the generic import door (`sys_import_job`), which serves this object as is —
78+
// a line repeating a (currency, effective_date) on file is refused for that
79+
// row alone by the unique key below. Who may import is the same write grant
80+
// that guards New / Edit; a file marks its rows imported with a `source`
81+
// column (an omitted one takes the default, `manual`).
82+
userActions: { import: true },
7583
description:
7684
"Exchange rates from the organization's home currency to a foreign currency, by effective date — " +
7785
'the rates foreign-currency amounts are converted to the home currency with.',
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Multi-currency M1-2 (epic #8345): a finance team loads a year of exchange
5+
* rates from one CSV through the GENERIC async import door
6+
* (`POST /data/sys_exchange_rate/import/jobs`, `sys_import_job`) — no importer
7+
* of its own. This file pins that the generic door serves the platform's real
8+
* `sys_exchange_rate` (`@objectstack/platform-objects`, not a mirror), on a
9+
* real sqlite engine, end to end:
10+
*
11+
* - a CSV of `currency, rate, effective_date` lands one row per line, with the
12+
* rate's twelve decimals intact and `source` at its declared default;
13+
* - a `source` column, when the file carries one, is written as given — which
14+
* is how an imported row says it was imported;
15+
* - a line repeating a `(currency, effective_date)` already on file is refused
16+
* for that row alone (`UNIQUE_VIOLATION`, the object's organization-scoped
17+
* unique key) while the other lines of the same file still land.
18+
*/
19+
20+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
21+
import { ObjectQL } from '@objectstack/objectql';
22+
import { SqlDriver } from '@objectstack/driver-sql';
23+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
24+
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
25+
import { SysImportJob } from '@objectstack/platform-objects/audit';
26+
import { SysExchangeRate } from '@objectstack/platform-objects/system';
27+
import { RestServer } from './rest-server';
28+
import { DEFAULT_ORGANIZATION_ID, installDefaultOrganization } from './default-organization.testkit.js';
29+
30+
const liveEngines: ObjectQL[] = [];
31+
afterEach(async () => {
32+
while (liveEngines.length) {
33+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
34+
}
35+
});
36+
37+
function createMockServer() {
38+
const noop = () => {};
39+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
40+
}
41+
42+
function makeRes() {
43+
const res: any = {
44+
write: () => true, end: () => {},
45+
header: () => res,
46+
status: (code: number) => { res._status = code; return res; },
47+
json: (body: any) => { res._json = body; return res; },
48+
};
49+
return res;
50+
}
51+
52+
async function boot() {
53+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
54+
const engine = new ObjectQL();
55+
liveEngines.push(engine);
56+
engine.registerDriver(driver, true);
57+
await engine.init();
58+
engine.registry.registerObject(SysExchangeRate as any);
59+
engine.registry.registerObject(SysImportJob as any);
60+
await engine.syncSchemas();
61+
await installDefaultOrganization(engine);
62+
for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
63+
if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any);
64+
}
65+
const protocol = new ObjectStackProtocolImplementation(engine as any);
66+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
67+
(rest as any).resolveExecCtx = async () => ({ userId: 'finance-admin', organizationId: DEFAULT_ORGANIZATION_ID });
68+
rest.registerRoutes();
69+
const routes = rest.getRoutes();
70+
const find = (method: string, path: string) => {
71+
const route = routes.find((r: any) => r.method === method && r.path === path);
72+
if (!route) throw new Error(`route not registered: ${method} ${path}`);
73+
return route;
74+
};
75+
return {
76+
engine,
77+
create: find('POST', '/api/v1/data/:object/import/jobs'),
78+
progress: find('GET', '/api/v1/data/import/jobs/:jobId'),
79+
results: find('GET', '/api/v1/data/import/jobs/:jobId/results'),
80+
};
81+
}
82+
83+
type Booted = Awaited<ReturnType<typeof boot>>;
84+
85+
/** POST the CSV to the async door and wait for the job's terminal state. */
86+
async function importCsv(ctx: Booted, csv: string): Promise<{ job: any; results: any[] }> {
87+
const res = makeRes();
88+
await ctx.create.handler({ params: { object: 'sys_exchange_rate' }, body: { format: 'csv', csv } } as any, res);
89+
expect(res._status).toBe(201);
90+
const jobId = res._json.jobId;
91+
for (let i = 0; i < 200; i++) {
92+
const p = makeRes();
93+
await ctx.progress.handler({ params: { jobId }, query: {} } as any, p);
94+
if (['succeeded', 'failed', 'cancelled'].includes(p._json?.status)) {
95+
const r = makeRes();
96+
await ctx.results.handler({ params: { jobId }, query: {} } as any, r);
97+
return { job: p._json, results: r._json.results };
98+
}
99+
await new Promise((resolve) => setTimeout(resolve, 5));
100+
}
101+
throw new Error(`import job ${jobId} did not finish`);
102+
}
103+
104+
const rates = async (ctx: Booted) =>
105+
(await ctx.engine.find('sys_exchange_rate', {}))
106+
.map((r: any) => ({ currency: r.currency, rate: Number(r.rate), effective_date: String(r.effective_date).slice(0, 10), source: r.source }))
107+
.sort((a: any, b: any) => `${a.currency}${a.effective_date}`.localeCompare(`${b.currency}${b.effective_date}`));
108+
109+
describe('sys_exchange_rate — CSV through the generic async import door (multi-currency M1-2)', () => {
110+
let ctx: Booted;
111+
beforeEach(async () => {
112+
ctx = await boot();
113+
});
114+
115+
it('lands one rate per CSV line, twelve decimals intact, source at its default', async () => {
116+
const { job } = await importCsv(ctx, [
117+
'currency,rate,effective_date',
118+
'EUR,0.92,2026-01-01',
119+
'JPY,149.123456789012,2026-01-01',
120+
'GBP,0.79,2026-02-01',
121+
].join('\n'));
122+
123+
expect(job).toMatchObject({ status: 'succeeded', total: 3, created: 3, errors: 0 });
124+
expect(await rates(ctx)).toEqual([
125+
{ currency: 'EUR', rate: 0.92, effective_date: '2026-01-01', source: 'manual' },
126+
{ currency: 'GBP', rate: 0.79, effective_date: '2026-02-01', source: 'manual' },
127+
{ currency: 'JPY', rate: 149.123456789012, effective_date: '2026-01-01', source: 'manual' },
128+
]);
129+
});
130+
131+
it('writes a source column as given, so a file can mark its rows imported', async () => {
132+
const { job } = await importCsv(ctx, 'currency,rate,effective_date,source\nCHF,0.88,2026-01-01,import\n');
133+
134+
expect(job).toMatchObject({ status: 'succeeded', created: 1, errors: 0 });
135+
expect(await rates(ctx)).toEqual([{ currency: 'CHF', rate: 0.88, effective_date: '2026-01-01', source: 'import' }]);
136+
});
137+
138+
it('refuses a line repeating a (currency, effective_date) already on file, and lands the rest of the file', async () => {
139+
await importCsv(ctx, 'currency,rate,effective_date\nEUR,0.92,2026-01-01\n');
140+
141+
const { job, results } = await importCsv(ctx, [
142+
'currency,rate,effective_date',
143+
'EUR,0.93,2026-01-01', // same currency, same date: refused
144+
'EUR,0.94,2026-02-01', // same currency, next month: a new rate
145+
].join('\n'));
146+
147+
expect(job).toMatchObject({ status: 'succeeded', total: 2, created: 1, errors: 1 });
148+
expect(results[0]).toMatchObject({ row: 1, ok: false, code: 'UNIQUE_VIOLATION' });
149+
expect(results[1]).toMatchObject({ row: 2, ok: true, action: 'created' });
150+
// The stored January rate is the first file's, untouched by the refused line.
151+
expect(await rates(ctx)).toEqual([
152+
{ currency: 'EUR', rate: 0.92, effective_date: '2026-01-01', source: 'manual' },
153+
{ currency: 'EUR', rate: 0.94, effective_date: '2026-02-01', source: 'manual' },
154+
]);
155+
});
156+
});

0 commit comments

Comments
 (0)