shutro-erp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -10
- package/dist/account_portal/.kosh/bindings.d.ts +22 -0
- package/dist/account_portal/.kosh/bindings.js +15 -0
- package/dist/account_portal/.kosh/schema.d.ts +1350 -0
- package/dist/account_portal/index.d.ts +3 -0
- package/dist/account_portal/index.js +73 -0
- package/dist/account_portal/module.d.ts +5983 -0
- package/dist/account_portal/module.js +18 -0
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +142 -9
- package/dist/accounting/chart.d.ts +40 -8
- package/dist/accounting/chart.js +124 -30
- package/dist/accounting/closing.d.ts +6 -1
- package/dist/accounting/closing.js +5 -1
- package/dist/accounting/demo.js +6 -5
- package/dist/accounting/index.d.ts +3 -3
- package/dist/accounting/index.js +200 -23
- package/dist/accounting/module.d.ts +3326 -1086
- package/dist/accounting/module.js +408 -66
- package/dist/accounting/payments.d.ts +25 -0
- package/dist/accounting/payments.js +96 -4
- package/dist/accounting/web/RegisterPayment.d.ts +3 -2
- package/dist/accounting/web/RegisterPayment.js +39 -6
- package/dist/accounting/web/SettingsPage.js +5 -1
- package/dist/accounting/web/messages.js +10 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/init.d.ts +5 -0
- package/dist/cli/init.js +195 -53
- package/dist/cli/launch.d.ts +16 -0
- package/dist/cli/launch.js +138 -0
- package/dist/cli/prompt.d.ts +35 -0
- package/dist/cli/prompt.js +46 -0
- package/dist/cli/templates.d.ts +11 -3
- package/dist/cli/templates.js +32 -7
- package/dist/cli/tui.d.ts +7 -0
- package/dist/cli/tui.js +254 -0
- package/dist/cli/ui.d.ts +25 -5
- package/dist/cli/ui.js +49 -9
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +3 -1
- package/dist/contacts/index.js +24 -1
- package/dist/contacts/module.d.ts +42 -0
- package/dist/contacts/module.js +3 -1
- package/dist/import/convert.d.ts +55 -0
- package/dist/import/convert.js +224 -0
- package/dist/import/csv.d.ts +28 -0
- package/dist/import/csv.js +115 -0
- package/dist/import/index.d.ts +33 -0
- package/dist/import/index.js +60 -0
- package/dist/import/mapping.d.ts +15 -0
- package/dist/import/mapping.js +33 -0
- package/dist/import/match.d.ts +24 -0
- package/dist/import/match.js +60 -0
- package/dist/import/models.d.ts +2 -0
- package/dist/import/models.js +102 -0
- package/dist/import/routes.d.ts +13 -0
- package/dist/import/routes.js +392 -0
- package/dist/import/run.d.ts +131 -0
- package/dist/import/run.js +568 -0
- package/dist/import/service.d.ts +153 -0
- package/dist/import/service.js +367 -0
- package/dist/import/web/ImportButton.d.ts +7 -0
- package/dist/import/web/ImportButton.js +31 -0
- package/dist/import/web/ImportWizard.d.ts +75 -0
- package/dist/import/web/ImportWizard.js +217 -0
- package/dist/import/web/index.d.ts +13 -0
- package/dist/import/web/index.js +18 -0
- package/dist/import/web/messages.d.ts +2 -0
- package/dist/import/web/messages.js +139 -0
- package/dist/import/worker.d.ts +3 -0
- package/dist/import/worker.js +52 -0
- package/dist/import/xlsx.d.ts +20 -0
- package/dist/import/xlsx.js +192 -0
- package/dist/l10n_bd/.kosh/bindings.d.ts +22 -0
- package/dist/l10n_bd/.kosh/bindings.js +15 -0
- package/dist/l10n_bd/.kosh/schema.d.ts +1281 -0
- package/dist/l10n_bd/documents.d.ts +8 -0
- package/dist/l10n_bd/documents.js +183 -0
- package/dist/l10n_bd/index.d.ts +8 -0
- package/dist/l10n_bd/index.js +156 -0
- package/dist/l10n_bd/module.d.ts +5705 -0
- package/dist/l10n_bd/module.js +163 -0
- package/dist/l10n_bd/mushak.d.ts +67 -0
- package/dist/l10n_bd/mushak.js +139 -0
- package/dist/l10n_bd/routes.d.ts +3 -0
- package/dist/l10n_bd/routes.js +205 -0
- package/dist/l10n_bd/web/MushakPage.d.ts +7 -0
- package/dist/l10n_bd/web/MushakPage.js +53 -0
- package/dist/l10n_bd/web/index.d.ts +13 -0
- package/dist/l10n_bd/web/index.js +18 -0
- package/dist/l10n_bd/web/messages.d.ts +2 -0
- package/dist/l10n_bd/web/messages.js +101 -0
- package/dist/l10n_in/.kosh/bindings.d.ts +22 -0
- package/dist/l10n_in/.kosh/bindings.js +15 -0
- package/dist/l10n_in/.kosh/schema.d.ts +1343 -0
- package/dist/l10n_in/documents.d.ts +8 -0
- package/dist/l10n_in/documents.js +114 -0
- package/dist/l10n_in/einvoice.d.ts +253 -0
- package/dist/l10n_in/einvoice.js +485 -0
- package/dist/l10n_in/gstr.d.ts +109 -0
- package/dist/l10n_in/gstr.js +430 -0
- package/dist/l10n_in/index.d.ts +20 -0
- package/dist/l10n_in/index.js +232 -0
- package/dist/l10n_in/module.d.ts +7823 -0
- package/dist/l10n_in/module.js +427 -0
- package/dist/l10n_in/routes-einvoice.d.ts +15 -0
- package/dist/l10n_in/routes-einvoice.js +124 -0
- package/dist/l10n_in/routes.d.ts +17 -0
- package/dist/l10n_in/routes.js +282 -0
- package/dist/l10n_in/web/EInvoicePanel.d.ts +8 -0
- package/dist/l10n_in/web/EInvoicePanel.js +63 -0
- package/dist/l10n_in/web/GstPage.d.ts +7 -0
- package/dist/l10n_in/web/GstPage.js +57 -0
- package/dist/l10n_in/web/GstSettings.d.ts +10 -0
- package/dist/l10n_in/web/GstSettings.js +36 -0
- package/dist/l10n_in/web/index.d.ts +15 -0
- package/dist/l10n_in/web/index.js +22 -0
- package/dist/l10n_in/web/messages.d.ts +2 -0
- package/dist/l10n_in/web/messages.js +166 -0
- package/dist/modules-sdk/demo.d.ts +2 -0
- package/dist/modules-sdk/demo.js +7 -3
- package/dist/modules-sdk/index.d.ts +2 -2
- package/dist/modules-sdk/index.js +1 -1
- package/dist/modules-sdk/manifest.d.ts +132 -0
- package/dist/modules-sdk/manifest.js +22 -0
- package/dist/notify/inbox.js +21 -0
- package/dist/notify/messages.js +4 -0
- package/dist/notify/models.d.ts +1 -1
- package/dist/notify/models.js +2 -0
- package/dist/notify/web/messages.js +4 -0
- package/dist/portal/.kosh/bindings.d.ts +22 -0
- package/dist/portal/.kosh/bindings.js +15 -0
- package/dist/portal/.kosh/schema.d.ts +346 -0
- package/dist/portal/announce.d.ts +16 -0
- package/dist/portal/announce.js +117 -0
- package/dist/portal/documents.d.ts +17 -0
- package/dist/portal/documents.js +61 -0
- package/dist/portal/index.d.ts +20 -0
- package/dist/portal/index.js +65 -0
- package/dist/portal/module.d.ts +1405 -0
- package/dist/portal/module.js +84 -0
- package/dist/portal/routes-documents.d.ts +2 -0
- package/dist/portal/routes-documents.js +391 -0
- package/dist/portal/routes.d.ts +13 -0
- package/dist/portal/routes.js +313 -0
- package/dist/portal/rules.d.ts +11 -0
- package/dist/portal/rules.js +25 -0
- package/dist/portal/web/PortalAccess.d.ts +8 -0
- package/dist/portal/web/PortalAccess.js +66 -0
- package/dist/portal/web/index.d.ts +12 -0
- package/dist/portal/web/index.js +17 -0
- package/dist/portal/web/messages.d.ts +2 -0
- package/dist/portal/web/messages.js +33 -0
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +12 -3
- package/dist/product/demo.js +6 -5
- package/dist/product/index.d.ts +1 -0
- package/dist/product/index.js +17 -4
- package/dist/product/module.d.ts +346 -14
- package/dist/product/module.js +48 -18
- package/dist/product/taxes.d.ts +59 -0
- package/dist/product/taxes.js +61 -0
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +3 -1
- package/dist/project/module.d.ts +20 -0
- package/dist/project_portal/.kosh/bindings.d.ts +22 -0
- package/dist/project_portal/.kosh/bindings.js +15 -0
- package/dist/project_portal/.kosh/schema.d.ts +671 -0
- package/dist/project_portal/index.d.ts +9 -0
- package/dist/project_portal/index.js +42 -0
- package/dist/project_portal/module.d.ts +2142 -0
- package/dist/project_portal/module.js +22 -0
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +15 -4
- package/dist/purchase/demo.js +3 -3
- package/dist/purchase/index.js +1 -1
- package/dist/purchase/module.d.ts +232 -8
- package/dist/purchase/module.js +51 -9
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +144 -10
- package/dist/purchase_account/module.d.ts +2008 -641
- package/dist/purchase_account/module.js +2 -2
- package/dist/purchase_portal/.kosh/bindings.d.ts +22 -0
- package/dist/purchase_portal/.kosh/bindings.js +15 -0
- package/dist/purchase_portal/.kosh/schema.d.ts +822 -0
- package/dist/purchase_portal/index.d.ts +9 -0
- package/dist/purchase_portal/index.js +53 -0
- package/dist/purchase_portal/module.d.ts +2758 -0
- package/dist/purchase_portal/module.js +18 -0
- package/dist/reporting/documents.js +20 -3
- package/dist/reporting/index.d.ts +1 -0
- package/dist/reporting/index.js +1 -0
- package/dist/reporting/routes-documents.js +9 -2
- package/dist/reporting/words.d.ts +11 -0
- package/dist/reporting/words.js +229 -0
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +144 -10
- package/dist/sale_account/module.d.ts +2008 -641
- package/dist/sale_account/module.js +3 -3
- package/dist/sale_portal/.kosh/bindings.d.ts +22 -0
- package/dist/sale_portal/.kosh/bindings.js +15 -0
- package/dist/sale_portal/.kosh/schema.d.ts +736 -0
- package/dist/sale_portal/index.d.ts +9 -0
- package/dist/sale_portal/index.js +71 -0
- package/dist/sale_portal/module.d.ts +2718 -0
- package/dist/sale_portal/module.js +19 -0
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +15 -4
- package/dist/sale_project/module.d.ts +390 -10
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +15 -4
- package/dist/sales/demo.js +3 -3
- package/dist/sales/index.js +67 -1
- package/dist/sales/module.d.ts +232 -8
- package/dist/sales/module.js +51 -12
- package/dist/server/auth/base.js +2 -0
- package/dist/server/auth/service.d.ts +2 -0
- package/dist/server/auth/service.js +7 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/server.js +26 -1
- package/dist/ui/api.d.ts +2 -0
- package/dist/ui/app.js +1 -1
- package/dist/ui/context.d.ts +3 -1
- package/dist/ui/context.js +2 -2
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.js +1 -1
- package/dist/ui/lib/format.d.ts +2 -0
- package/dist/ui/lib/format.js +3 -1
- package/dist/ui/views/FormView.js +19 -6
- package/package.json +50 -2
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Columns to fields (RFC 0014 §5.1 step 2): each header is matched to a field by its key or its label, ignoring case,
|
|
3
|
+
* spaces and punctuation, so an export of Kosh (headers are labels) re-imports without edits. Unmatched columns
|
|
4
|
+
* are skipped until the user maps them.
|
|
5
|
+
*/
|
|
6
|
+
import { importable } from './convert.js';
|
|
7
|
+
/** Lower case, letters and digits only (Bangla letters kept). */
|
|
8
|
+
export function normalize(text) {
|
|
9
|
+
return text
|
|
10
|
+
.normalize('NFKC')
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^\p{L}\p{M}\p{N}]+/gu, '');
|
|
13
|
+
}
|
|
14
|
+
/** The field each header most likely fills: an exact key, then an exact label; each field once. */
|
|
15
|
+
export function guessMapping(headers, fields) {
|
|
16
|
+
const candidates = fields.filter((f) => importable(f.kind));
|
|
17
|
+
const taken = new Set();
|
|
18
|
+
const byKey = new Map(candidates.map((f) => [normalize(f.key), f.key]));
|
|
19
|
+
const byLabel = new Map();
|
|
20
|
+
for (const f of candidates) {
|
|
21
|
+
const label = normalize(f.label);
|
|
22
|
+
if (!byLabel.has(label))
|
|
23
|
+
byLabel.set(label, f.key);
|
|
24
|
+
}
|
|
25
|
+
return headers.map((header, index) => {
|
|
26
|
+
const n = normalize(header);
|
|
27
|
+
const found = byKey.get(n) ?? byLabel.get(n) ?? null;
|
|
28
|
+
if (found === null || taken.has(found) || n === '')
|
|
29
|
+
return { index, header, field: null };
|
|
30
|
+
taken.add(found);
|
|
31
|
+
return { index, header, field: found };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation cells to record ids (RFC 0014 §5.1 step 3), read as the user: an external id (`contacts.partner_acme`),
|
|
3
|
+
* then a field the target keeps unique (a code, an email), then its display name — exactly, ignoring case, then as
|
|
4
|
+
* the one record whose name contains the text. Several matches are an error, never a guess.
|
|
5
|
+
*/
|
|
6
|
+
import { type Env } from '../server/orm.js';
|
|
7
|
+
/** How the records of a target model are found from text. */
|
|
8
|
+
export interface MatchTarget {
|
|
9
|
+
readonly model: string;
|
|
10
|
+
/** The field the display name comes from. */
|
|
11
|
+
readonly recName: string;
|
|
12
|
+
/** Stored char fields with a unique constraint of their own (besides the company). */
|
|
13
|
+
readonly keys: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
export type Match = {
|
|
16
|
+
readonly id: number;
|
|
17
|
+
readonly by: 'id' | 'external-id' | 'key' | 'name';
|
|
18
|
+
} | {
|
|
19
|
+
readonly error: 'not-found' | 'ambiguous';
|
|
20
|
+
};
|
|
21
|
+
/** The record `text` names in `target`, as the user sees it. */
|
|
22
|
+
export declare function matchOne(env: Env, target: MatchTarget, text: string): Promise<Match>;
|
|
23
|
+
/** Each distinct text once (a file names the same customer on many rows). */
|
|
24
|
+
export declare function matchAll(env: Env, target: MatchTarget, texts: Iterable<string>): Promise<Map<string, Match>>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation cells to record ids (RFC 0014 §5.1 step 3), read as the user: an external id (`contacts.partner_acme`),
|
|
3
|
+
* then a field the target keeps unique (a code, an email), then its display name — exactly, ignoring case, then as
|
|
4
|
+
* the one record whose name contains the text. Several matches are an error, never a guess.
|
|
5
|
+
*/
|
|
6
|
+
import { orm } from '../server/orm.js';
|
|
7
|
+
const ref = (env, name) => env.model(name);
|
|
8
|
+
const EXTERNAL_ID = /^[a-z0-9_]+\.[a-z0-9_.-]+$/i;
|
|
9
|
+
const escapeLike = (text) => text.replace(/[%_\\]/g, (m) => `\\${m}`);
|
|
10
|
+
/** The record `text` names in `target`, as the user sees it. */
|
|
11
|
+
export async function matchOne(env, target, text) {
|
|
12
|
+
const model = ref(env, target.model);
|
|
13
|
+
if (/^#\d+$/.test(text)) {
|
|
14
|
+
// `#42`: a record id, as an export with ids writes it.
|
|
15
|
+
const found = await model.search({ where: { id: Number(text.slice(1)) }, limit: 1 });
|
|
16
|
+
return found.length === 1 ? { id: found.ids[0], by: 'id' } : { error: 'not-found' };
|
|
17
|
+
}
|
|
18
|
+
if (EXTERNAL_ID.test(text)) {
|
|
19
|
+
try {
|
|
20
|
+
const id = Number(await env.refId(target.model, text));
|
|
21
|
+
const found = await model.search({ where: { id }, limit: 1 });
|
|
22
|
+
if (found.length === 1)
|
|
23
|
+
return { id, by: 'external-id' };
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (!(error instanceof orm.ShutroError))
|
|
27
|
+
throw error; // an unknown id: try the other ways
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const key of target.keys) {
|
|
31
|
+
const found = await model.search({ where: { [key]: text }, limit: 2 });
|
|
32
|
+
if (found.length === 1)
|
|
33
|
+
return { id: found.ids[0], by: 'key' };
|
|
34
|
+
if (found.length > 1)
|
|
35
|
+
return { error: 'ambiguous' };
|
|
36
|
+
}
|
|
37
|
+
const exact = await model.search({
|
|
38
|
+
where: { [target.recName]: { ilike: escapeLike(text) } },
|
|
39
|
+
limit: 2,
|
|
40
|
+
});
|
|
41
|
+
if (exact.length === 1)
|
|
42
|
+
return { id: exact.ids[0], by: 'name' };
|
|
43
|
+
if (exact.length > 1)
|
|
44
|
+
return { error: 'ambiguous' };
|
|
45
|
+
const partial = await model.search({
|
|
46
|
+
where: { [target.recName]: { ilike: `%${escapeLike(text)}%` } },
|
|
47
|
+
limit: 2,
|
|
48
|
+
});
|
|
49
|
+
if (partial.length === 1)
|
|
50
|
+
return { id: partial.ids[0], by: 'name' };
|
|
51
|
+
return { error: partial.length > 1 ? 'ambiguous' : 'not-found' };
|
|
52
|
+
}
|
|
53
|
+
/** Each distinct text once (a file names the same customer on many rows). */
|
|
54
|
+
export async function matchAll(env, target, texts) {
|
|
55
|
+
const out = new Map();
|
|
56
|
+
for (const text of texts)
|
|
57
|
+
if (!out.has(text))
|
|
58
|
+
out.set(text, await matchOne(env, target, text));
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The import's models (RFC 0014):
|
|
3
|
+
*
|
|
4
|
+
* - `import.session` (§5.1): the uploaded file, its mapping and options, and what became of it. A session belongs to
|
|
5
|
+
* the user who uploaded it; the routes read it as that user (record rule) and nobody else sees the file.
|
|
6
|
+
* - `import.progress` (§5.3): how far a background run is, written by the job in short transactions of its own (the
|
|
7
|
+
* job's transaction commits only at the end); read through the routes.
|
|
8
|
+
* - `import.mapping` (§5.4): a saved mapping (columns by header, options) for a model, the user's own or shared with
|
|
9
|
+
* every import user of the company.
|
|
10
|
+
*/
|
|
11
|
+
import { baseShutro } from '../server/index.js';
|
|
12
|
+
import { orm } from '../server/orm.js';
|
|
13
|
+
const { defineGroup, defineModel, defineModule, f } = orm;
|
|
14
|
+
const Session = defineModel('import.session', {
|
|
15
|
+
label: 'Import',
|
|
16
|
+
order: ['id desc'],
|
|
17
|
+
companyScoped: { required: true },
|
|
18
|
+
access: { 'import.user': ['read', 'write', 'create', 'unlink'] },
|
|
19
|
+
rules: {
|
|
20
|
+
own: {
|
|
21
|
+
groups: ['import.user'],
|
|
22
|
+
perms: ['read', 'write', 'create', 'unlink'],
|
|
23
|
+
domain: { user: { $env: 'uid' } },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
fields: {
|
|
27
|
+
user: f.many2one('res.users', { required: true, ondelete: 'cascade' }),
|
|
28
|
+
/** The model the rows become (`res.partner`). */
|
|
29
|
+
resModel: f.char({ required: true, label: 'Model' }),
|
|
30
|
+
fileName: f.char({ required: true, size: 255 }),
|
|
31
|
+
fileType: f.selection({ csv: 'CSV', xlsx: 'Excel' }, { required: true }),
|
|
32
|
+
file: f.binary({ required: true }),
|
|
33
|
+
/** The XLSX sheet read (the first by default). */
|
|
34
|
+
sheet: f.char({ size: 100 }),
|
|
35
|
+
/** `[{ index, header, field }]`. */
|
|
36
|
+
columns: f.json({ required: true, default: [] }),
|
|
37
|
+
/** Conversion options and the mode (`ImportOptions`). */
|
|
38
|
+
options: f.json({ required: true, default: {} }),
|
|
39
|
+
state: f.selection({ draft: 'Draft', running: 'Running', done: 'Imported', undone: 'Undone' }, { required: true, default: 'draft' }),
|
|
40
|
+
rowCount: f.integer({ required: true, default: 0, label: 'Rows' }),
|
|
41
|
+
/** The ids the commit created. */
|
|
42
|
+
createdIds: f.json({ label: 'Created records' }),
|
|
43
|
+
/** What undo needs (`UndoData`, RFC 0014 §5.5). */
|
|
44
|
+
undo: f.json(),
|
|
45
|
+
/** The errors and file rows of the records left out (`onError: 'skip'`), for the rejected-rows file. */
|
|
46
|
+
rejected: f.json(),
|
|
47
|
+
importedAt: f.datetime({ label: 'Imported on' }),
|
|
48
|
+
/** The last dry run or commit as the API shows it (background runs leave it here). */
|
|
49
|
+
result: f.json(),
|
|
50
|
+
/** Whether `result` is a dry run's. */
|
|
51
|
+
resultDry: f.boolean({ required: true, default: false }),
|
|
52
|
+
/** Why a background run could not finish (a fault, not the data). */
|
|
53
|
+
failure: f.char({ size: 500 }),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const Progress = defineModel('import.progress', {
|
|
57
|
+
label: 'Import progress',
|
|
58
|
+
access: {},
|
|
59
|
+
fields: {
|
|
60
|
+
session: f.many2one('import.session', { required: true, ondelete: 'cascade' }),
|
|
61
|
+
done: f.integer({ required: true, default: 0 }),
|
|
62
|
+
total: f.integer({ required: true, default: 0 }),
|
|
63
|
+
},
|
|
64
|
+
sqlConstraints: {
|
|
65
|
+
onePerSession: { unique: ['session'], message: 'One progress row per import.' },
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const Mapping = defineModel('import.mapping', {
|
|
69
|
+
label: 'Import mapping',
|
|
70
|
+
order: ['name'],
|
|
71
|
+
companyScoped: { required: true },
|
|
72
|
+
access: { 'import.user': ['read', 'write', 'create', 'unlink'] },
|
|
73
|
+
rules: {
|
|
74
|
+
visible: {
|
|
75
|
+
groups: ['import.user'],
|
|
76
|
+
perms: ['read'],
|
|
77
|
+
domain: { or: [{ user: { $env: 'uid' } }, { shared: true }] },
|
|
78
|
+
},
|
|
79
|
+
own: {
|
|
80
|
+
groups: ['import.user'],
|
|
81
|
+
perms: ['write', 'create', 'unlink'],
|
|
82
|
+
domain: { user: { $env: 'uid' } },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
fields: {
|
|
86
|
+
name: f.char({ required: true, size: 100 }),
|
|
87
|
+
resModel: f.char({ required: true, label: 'Model' }),
|
|
88
|
+
user: f.many2one('res.users', { required: true, ondelete: 'cascade', label: 'Owner' }),
|
|
89
|
+
/** Every import user of the company sees it (only its owner changes it). */
|
|
90
|
+
shared: f.boolean({ required: true, default: false }),
|
|
91
|
+
/** `[{ header, field }]`: by header, so a file with the columns in another order still matches. */
|
|
92
|
+
columns: f.json({ required: true, default: [] }),
|
|
93
|
+
options: f.json({ required: true, default: {} }),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
export const importShutro = defineModule({
|
|
97
|
+
name: 'import',
|
|
98
|
+
version: '0.1.0',
|
|
99
|
+
depends: [baseShutro],
|
|
100
|
+
groups: [defineGroup({ code: 'import.user', label: 'Import data', implies: ['base.user'] })],
|
|
101
|
+
models: [Session, Progress, Mapping],
|
|
102
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ModuleContext } from '../server/index.js';
|
|
2
|
+
import { type ImportService } from './service.js';
|
|
3
|
+
export interface ImportRoutesOptions {
|
|
4
|
+
/** Bytes per file. */
|
|
5
|
+
readonly maxBytes?: number;
|
|
6
|
+
/** Rows per file. */
|
|
7
|
+
readonly maxRows?: number;
|
|
8
|
+
/** Rows run in the request; above, a background job. */
|
|
9
|
+
readonly inlineRows?: number;
|
|
10
|
+
/** The service the worker shares (made from the context by default). */
|
|
11
|
+
readonly service?: ImportService;
|
|
12
|
+
}
|
|
13
|
+
export declare function registerImportRoutes(context: ModuleContext, options?: ImportRoutesOptions): void;
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The import API (RFC 0014 §5.7), for holders of `import.user` (and administrators) who also have the `import`
|
|
3
|
+
* permission and the create or write permission the mode needs on the model, checked on every call:
|
|
4
|
+
*
|
|
5
|
+
* - `POST /api/v1/imports?model=…[&sheet=…]` the file as the body (`X-Filename`) → the session: headers, the first
|
|
6
|
+
* rows, the mapping (a fitting saved mapping or template, else guessed)
|
|
7
|
+
* - `GET /api/v1/imports/:id` the same, later; with the progress of a background run
|
|
8
|
+
* - `PUT /api/v1/imports/:id/mapping` `{ columns: [{ index, field }], options }`
|
|
9
|
+
* - `POST /api/v1/imports/:id/apply-mapping` `{ mapping }`: a saved mapping's or template's columns and options
|
|
10
|
+
* - `POST /api/v1/imports/:id/save-mapping` `{ name, shared }`: the session's mapping, saved by header
|
|
11
|
+
* - `POST /api/v1/imports/:id/dry-run` the whole import, rolled back (a background job above 2,000 rows: 202)
|
|
12
|
+
* - `POST /api/v1/imports/:id/commit` the import (`Idempotency-Key`; a background job above 2,000 rows)
|
|
13
|
+
* - `POST /api/v1/imports/:id/undo` within 7 days, records nobody changed since
|
|
14
|
+
* - `GET /api/v1/imports/:id/rejected` the rows left out, as CSV with an Error column
|
|
15
|
+
* - `GET /api/v1/import-mappings?model=…` saved mappings (own and shared) and module templates
|
|
16
|
+
* - `DELETE /api/v1/import-mappings/:id` one of the user's own
|
|
17
|
+
* - `GET /api/v1/import-templates/:model[?mapping=…]` a sample workbook: the headers, and a sheet describing them
|
|
18
|
+
*
|
|
19
|
+
* Sessions are the uploader's own (a record rule); files are read on the server, never evaluated.
|
|
20
|
+
*/
|
|
21
|
+
import { workbook } from '../reporting/index.js';
|
|
22
|
+
import { announceChange, audit, HttpProblem, idempotent } from '../server/index.js';
|
|
23
|
+
import { guessMapping } from './mapping.js';
|
|
24
|
+
import { EXTERNAL_ID_KEY, ID_KEY, undoImport } from './run.js';
|
|
25
|
+
import { createImportService, dataRows, IMPORT_MAX_BYTES, IMPORT_ROWS, INLINE_ROWS, isXlsx, parseOptions, RUN_JOB, readTable, ref, shown, UNDO_DAYS, } from './service.js';
|
|
26
|
+
/** A cell of the rejected-rows file: quoted when needed; formulas kept as text (never run by a spreadsheet). */
|
|
27
|
+
function csvCell(cell) {
|
|
28
|
+
let text = cell === null
|
|
29
|
+
? ''
|
|
30
|
+
: cell instanceof Date
|
|
31
|
+
? cell.toISOString().replace(/T00:00:00\.000Z$/, '')
|
|
32
|
+
: String(cell);
|
|
33
|
+
if (/^[=+@\t\r]/.test(text) || /^-[^\d.]/.test(text))
|
|
34
|
+
text = `'${text}`;
|
|
35
|
+
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
36
|
+
}
|
|
37
|
+
const idOf = (params) => {
|
|
38
|
+
const id = Number(params.id);
|
|
39
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
40
|
+
throw new HttpProblem(400, 'invalid-id', 'Invalid id', 'The id is a positive integer.');
|
|
41
|
+
return id;
|
|
42
|
+
};
|
|
43
|
+
/** What a column of a template expects, in words (the template's second sheet). */
|
|
44
|
+
function expects(field) {
|
|
45
|
+
switch (field.kind) {
|
|
46
|
+
case ID_KEY:
|
|
47
|
+
return 'The record id (#42), to update records';
|
|
48
|
+
case EXTERNAL_ID_KEY:
|
|
49
|
+
return 'An external id (legacy.partner_42), to find the record again';
|
|
50
|
+
case 'boolean':
|
|
51
|
+
return 'Yes or No';
|
|
52
|
+
case 'date':
|
|
53
|
+
return 'A date: DD/MM/YYYY or YYYY-MM-DD';
|
|
54
|
+
case 'datetime':
|
|
55
|
+
return 'A date and time: DD/MM/YYYY HH:MM';
|
|
56
|
+
case 'integer':
|
|
57
|
+
case 'bigint':
|
|
58
|
+
return 'A whole number';
|
|
59
|
+
case 'float':
|
|
60
|
+
case 'decimal':
|
|
61
|
+
case 'monetary':
|
|
62
|
+
case 'quantity':
|
|
63
|
+
return 'A number: 1,250.50';
|
|
64
|
+
case 'selection':
|
|
65
|
+
case 'state':
|
|
66
|
+
return `One of: ${(field.options ?? []).map(([, label]) => label).join(', ')}`;
|
|
67
|
+
case 'many2one':
|
|
68
|
+
return `The name, code or external id of a record (${field.target?.model ?? ''})`;
|
|
69
|
+
case 'many2many':
|
|
70
|
+
return `Names, codes or external ids, separated by commas (${field.target?.model ?? ''})`;
|
|
71
|
+
default:
|
|
72
|
+
return 'Text';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function registerImportRoutes(context, options = {}) {
|
|
76
|
+
const { app, driver } = context;
|
|
77
|
+
const service = options.service ??
|
|
78
|
+
createImportService(context, options.inlineRows === undefined ? {} : { inlineRows: options.inlineRows });
|
|
79
|
+
const maxBytes = options.maxBytes ?? IMPORT_MAX_BYTES;
|
|
80
|
+
const maxRows = options.maxRows ?? IMPORT_ROWS;
|
|
81
|
+
const inlineRows = options.service?.inlineRows ?? options.inlineRows ?? INLINE_ROWS;
|
|
82
|
+
const { sessionOf, describe, fieldsOf, columnFields } = service;
|
|
83
|
+
const shownSession = async (env, id) => {
|
|
84
|
+
const session = await sessionOf(env, id);
|
|
85
|
+
return describe(env, session, readTable(session.file, session.fileType, session.sheet));
|
|
86
|
+
};
|
|
87
|
+
const draftOnly = (state) => {
|
|
88
|
+
if (state === 'running')
|
|
89
|
+
throw new HttpProblem(409, 'running', 'Running', 'This import is running in the background.');
|
|
90
|
+
if (state !== 'draft')
|
|
91
|
+
throw new HttpProblem(409, 'imported', 'Already imported', 'This file was imported.');
|
|
92
|
+
};
|
|
93
|
+
// Uploads: the body is the file. Own parser in an encapsulated context; the caller is checked before reading it.
|
|
94
|
+
app.register(async (uploads) => {
|
|
95
|
+
uploads.removeAllContentTypeParsers();
|
|
96
|
+
uploads.addContentTypeParser('*', { parseAs: 'buffer', bodyLimit: maxBytes }, (_req, body, done) => done(null, body));
|
|
97
|
+
uploads.addHook('onRequest', async (request) => {
|
|
98
|
+
if (request.identity === null)
|
|
99
|
+
throw new HttpProblem(401, 'unauthenticated', 'Sign in required', 'Sign in first.');
|
|
100
|
+
});
|
|
101
|
+
uploads.post('/api/v1/imports', async (request, reply) => {
|
|
102
|
+
const q = (request.query ?? {});
|
|
103
|
+
const name = String(q['model'] ?? '');
|
|
104
|
+
const sheet = typeof q['sheet'] === 'string' && q['sheet'] !== '' ? q['sheet'] : null;
|
|
105
|
+
const body = request.body;
|
|
106
|
+
if (!(body instanceof Uint8Array) || body.byteLength === 0)
|
|
107
|
+
throw new HttpProblem(400, 'invalid-body', 'Empty file', 'The request body is the file.');
|
|
108
|
+
let fileName = 'import';
|
|
109
|
+
try {
|
|
110
|
+
const header = request.headers['x-filename'];
|
|
111
|
+
fileName = decodeURIComponent(typeof header === 'string' ? header : 'import').slice(0, 255);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
throw new HttpProblem(400, 'invalid-name', 'Invalid file name', 'X-Filename is URI-encoded.');
|
|
115
|
+
}
|
|
116
|
+
const file = new Uint8Array(body);
|
|
117
|
+
const fileType = isXlsx(file) ? 'xlsx' : 'csv';
|
|
118
|
+
const table = readTable(file, fileType, sheet);
|
|
119
|
+
const created = await request.env(async (env) => {
|
|
120
|
+
service.guard(env);
|
|
121
|
+
const model = service.modelFor(env, name);
|
|
122
|
+
const rowCount = dataRows(table, {});
|
|
123
|
+
if (rowCount > maxRows)
|
|
124
|
+
throw new HttpProblem(413, 'too-many-rows', 'Too many rows', `The file has ${rowCount} rows; at most ${maxRows} are imported at once.`, 'Split the file.');
|
|
125
|
+
const headers = (table[0] ?? []).map((c) => (c === null ? '' : String(c)));
|
|
126
|
+
const fields = fieldsOf(env, model);
|
|
127
|
+
// A saved mapping or a template whose every header the file has, else a guess.
|
|
128
|
+
const applied = service.fitting(await service.mappingsFor(env, model.name), headers);
|
|
129
|
+
const columns = applied === null
|
|
130
|
+
? guessMapping(headers, columnFields(fields))
|
|
131
|
+
: service.applyMapping(applied, headers, fields);
|
|
132
|
+
const rows = await ref(env, 'import.session').create([
|
|
133
|
+
{
|
|
134
|
+
user: env.uid,
|
|
135
|
+
resModel: model.name,
|
|
136
|
+
fileName,
|
|
137
|
+
fileType,
|
|
138
|
+
file,
|
|
139
|
+
sheet,
|
|
140
|
+
columns,
|
|
141
|
+
options: applied?.options ?? {},
|
|
142
|
+
rowCount,
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
const session = await sessionOf(env, rows.ids[0]);
|
|
146
|
+
return {
|
|
147
|
+
...(await describe(env, session, table)),
|
|
148
|
+
applied: applied === null ? null : { id: applied.id, name: applied.name },
|
|
149
|
+
};
|
|
150
|
+
}, { write: true });
|
|
151
|
+
return reply.code(201).send(created);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
app.get('/api/v1/imports/:id', async (request) =>
|
|
155
|
+
// Write: progress is read in a system scope (Shutro request #12).
|
|
156
|
+
request.env((env) => shownSession(env, idOf(request.params)), { write: true }));
|
|
157
|
+
/** Checks and stores a mapping on a draft session. */
|
|
158
|
+
const storeMapping = async (env, id, input, given) => {
|
|
159
|
+
const session = await sessionOf(env, id);
|
|
160
|
+
draftOnly(session.state);
|
|
161
|
+
const table = readTable(session.file, session.fileType, session.sheet);
|
|
162
|
+
const headers = given.headerRow === false ? [] : (table[0] ?? []);
|
|
163
|
+
const width = Math.max(0, ...table.slice(0, 50).map((r) => r.length));
|
|
164
|
+
const fields = fieldsOf(env, session.model);
|
|
165
|
+
const seen = new Set();
|
|
166
|
+
const columns = [];
|
|
167
|
+
for (const raw of input) {
|
|
168
|
+
const c = (raw ?? {});
|
|
169
|
+
const index = Number(c['index']);
|
|
170
|
+
const field = c['field'] === null || c['field'] === undefined ? null : String(c['field']);
|
|
171
|
+
if (!Number.isInteger(index) || index < 0 || index >= width)
|
|
172
|
+
throw new HttpProblem(400, 'invalid-columns', 'Invalid columns', `No column ${index}.`);
|
|
173
|
+
if (columns.some((m) => m.index === index))
|
|
174
|
+
throw new HttpProblem(400, 'invalid-columns', 'Invalid columns', `Column ${index} is given twice.`);
|
|
175
|
+
if (field !== null) {
|
|
176
|
+
if (!fields.has(field) || fields.get(field)?.kind === 'one2many')
|
|
177
|
+
throw new HttpProblem(422, 'unknown-field', 'Field cannot be imported', `'${field}' is not a field you may import into.`);
|
|
178
|
+
if (seen.has(field))
|
|
179
|
+
throw new HttpProblem(422, 'duplicate-field', 'Field mapped twice', `'${field}' is mapped twice.`);
|
|
180
|
+
seen.add(field);
|
|
181
|
+
}
|
|
182
|
+
const header = headers[index];
|
|
183
|
+
columns.push({
|
|
184
|
+
index,
|
|
185
|
+
header: header === null || header === undefined ? '' : String(header),
|
|
186
|
+
field,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (given.key !== undefined && fields.get(given.key)?.keyable !== true)
|
|
190
|
+
throw new HttpProblem(422, 'invalid-key', 'Not a key', `'${given.key}' cannot find records: use the id, an external id or a unique field.`);
|
|
191
|
+
await session.rows.write({ columns, options: given, rowCount: dataRows(table, given) });
|
|
192
|
+
return describe(env, { ...session, columns, options: given }, table);
|
|
193
|
+
};
|
|
194
|
+
app.put('/api/v1/imports/:id/mapping', async (request) => {
|
|
195
|
+
const body = (request.body ?? {});
|
|
196
|
+
const input = body['columns'];
|
|
197
|
+
if (!Array.isArray(input))
|
|
198
|
+
throw new HttpProblem(400, 'invalid-columns', 'Invalid columns', 'columns is an array.');
|
|
199
|
+
const given = parseOptions(body['options']);
|
|
200
|
+
return request.env((env) => storeMapping(env, idOf(request.params), input, given), {
|
|
201
|
+
write: true,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
app.post('/api/v1/imports/:id/apply-mapping', async (request) => {
|
|
205
|
+
const wanted = String((request.body ?? {})['mapping'] ?? '');
|
|
206
|
+
return request.env(async (env) => {
|
|
207
|
+
const session = await sessionOf(env, idOf(request.params));
|
|
208
|
+
const mapping = (await service.mappingsFor(env, session.model.name)).find((m) => m.id === wanted);
|
|
209
|
+
if (mapping === undefined)
|
|
210
|
+
throw new HttpProblem(404, 'not-found', 'No such mapping', `No mapping '${wanted}'.`);
|
|
211
|
+
const table = readTable(session.file, session.fileType, session.sheet);
|
|
212
|
+
const headers = (table[0] ?? []).map((c) => (c === null ? '' : String(c)));
|
|
213
|
+
const columns = service.applyMapping(mapping, headers, fieldsOf(env, session.model));
|
|
214
|
+
return storeMapping(env, session.id, columns, mapping.options);
|
|
215
|
+
}, { write: true });
|
|
216
|
+
});
|
|
217
|
+
app.post('/api/v1/imports/:id/save-mapping', async (request, reply) => {
|
|
218
|
+
const body = (request.body ?? {});
|
|
219
|
+
const name = typeof body['name'] === 'string' ? body['name'].trim() : '';
|
|
220
|
+
if (name === '' || name.length > 100)
|
|
221
|
+
throw new HttpProblem(400, 'invalid-name', 'Invalid name', 'name is 1–100 characters.');
|
|
222
|
+
const saved = await request.env(async (env) => {
|
|
223
|
+
const session = await sessionOf(env, idOf(request.params));
|
|
224
|
+
const columns = session.columns.flatMap((c) => c.field === null || c.header === '' ? [] : [{ header: c.header, field: c.field }]);
|
|
225
|
+
if (columns.length === 0)
|
|
226
|
+
throw new HttpProblem(422, 'empty-mapping', 'Nothing to save', 'Map a column first.');
|
|
227
|
+
const rows = await ref(env, 'import.mapping').create([
|
|
228
|
+
{
|
|
229
|
+
name,
|
|
230
|
+
resModel: session.model.name,
|
|
231
|
+
user: env.uid,
|
|
232
|
+
shared: body['shared'] === true,
|
|
233
|
+
columns,
|
|
234
|
+
options: session.options,
|
|
235
|
+
},
|
|
236
|
+
]);
|
|
237
|
+
return { id: String(rows.ids[0]), name };
|
|
238
|
+
}, { write: true });
|
|
239
|
+
return reply.code(201).send(saved);
|
|
240
|
+
});
|
|
241
|
+
app.get('/api/v1/import-mappings', async (request) => {
|
|
242
|
+
const model = String((request.query ?? {})['model'] ?? '');
|
|
243
|
+
return request.env(async (env) => {
|
|
244
|
+
service.guard(env);
|
|
245
|
+
service.modelFor(env, model);
|
|
246
|
+
return { mappings: await service.mappingsFor(env, model) };
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
app.delete('/api/v1/import-mappings/:id', async (request, reply) => {
|
|
250
|
+
const id = idOf(request.params);
|
|
251
|
+
await request.env(async (env) => {
|
|
252
|
+
service.guard(env);
|
|
253
|
+
const found = await ref(env, 'import.mapping').search({ where: { id }, limit: 1 });
|
|
254
|
+
if (found.length === 0)
|
|
255
|
+
throw new HttpProblem(404, 'not-found', 'No such mapping', `No mapping #${id}.`);
|
|
256
|
+
await found.unlink(); // the record rule: owners only
|
|
257
|
+
}, { write: true });
|
|
258
|
+
return reply.code(204).send();
|
|
259
|
+
});
|
|
260
|
+
app.get('/api/v1/import-templates/:model', async (request, reply) => {
|
|
261
|
+
const name = String(request.params.model ?? '');
|
|
262
|
+
const wanted = request.query?.['mapping'];
|
|
263
|
+
const { file, fileName } = await request.env(async (env) => {
|
|
264
|
+
service.guard(env);
|
|
265
|
+
const model = service.modelFor(env, name);
|
|
266
|
+
const fields = fieldsOf(env, model);
|
|
267
|
+
let mapping;
|
|
268
|
+
if (typeof wanted === 'string' && wanted !== '') {
|
|
269
|
+
mapping = (await service.mappingsFor(env, name)).find((m) => m.id === wanted);
|
|
270
|
+
if (mapping === undefined)
|
|
271
|
+
throw new HttpProblem(404, 'not-found', 'No such mapping', `No mapping '${wanted}'.`);
|
|
272
|
+
}
|
|
273
|
+
// Without a mapping: every field of the record (lines aside), required ones first.
|
|
274
|
+
const columns = mapping?.columns.filter((c) => fields.has(c.field)) ??
|
|
275
|
+
columnFields(fields)
|
|
276
|
+
.filter((f) => f.line === null && f.kind !== ID_KEY && f.kind !== EXTERNAL_ID_KEY)
|
|
277
|
+
.sort((a, b) => Number(b.required) - Number(a.required))
|
|
278
|
+
.map((f) => ({ header: f.label, field: f.key }));
|
|
279
|
+
const help = columns.map((c) => {
|
|
280
|
+
const field = fields.get(c.field);
|
|
281
|
+
return [c.header, field.key, field.required ? 'Yes' : '', expects(field)];
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
file: workbook([
|
|
285
|
+
{ name: 'Data', rows: [columns.map((c) => c.header)], widths: columns.map(() => 20) },
|
|
286
|
+
{
|
|
287
|
+
name: 'Columns',
|
|
288
|
+
rows: [['Column', 'Field', 'Required', 'What to write'], ...help],
|
|
289
|
+
widths: [24, 24, 10, 60],
|
|
290
|
+
},
|
|
291
|
+
]),
|
|
292
|
+
fileName: `${(mapping?.name ?? model.label).replace(/[^\p{L}\p{N} _-]+/gu, '')} template.xlsx`,
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
return reply
|
|
296
|
+
.header('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
|
297
|
+
.header('content-disposition', `attachment; filename="template.xlsx"; filename*=UTF-8''${encodeURIComponent(fileName)}`)
|
|
298
|
+
.send(file);
|
|
299
|
+
});
|
|
300
|
+
/** A run in the request, or, for a large file, a background job (202). */
|
|
301
|
+
const start = async (request, env, dryRun) => {
|
|
302
|
+
const session = await sessionOf(env, idOf(request.params));
|
|
303
|
+
draftOnly(session.state);
|
|
304
|
+
if (session.rowCount <= inlineRows) {
|
|
305
|
+
const result = await service.execute(env, session, dryRun);
|
|
306
|
+
await service.record(env, session, result, dryRun, request.ip);
|
|
307
|
+
return { status: dryRun ? 200 : result.undo === null ? 422 : 201, body: shown(result) };
|
|
308
|
+
}
|
|
309
|
+
const mode = session.options.mode ?? 'create';
|
|
310
|
+
if (mode !== 'update')
|
|
311
|
+
env.access.check(session.model.name, 'create');
|
|
312
|
+
if (mode !== 'create')
|
|
313
|
+
env.access.check(session.model.name, 'write');
|
|
314
|
+
await session.rows.write({ state: 'running', result: null, failure: null });
|
|
315
|
+
await env.enqueue(RUN_JOB, { session: session.id, dryRun }, { key: `${RUN_JOB}:${session.id}` });
|
|
316
|
+
await service.startProgress(env, session.id, session.rowCount);
|
|
317
|
+
return { status: 202, body: await shownSession(env, session.id) };
|
|
318
|
+
};
|
|
319
|
+
app.post('/api/v1/imports/:id/dry-run', async (request, reply) => {
|
|
320
|
+
const outcome = await request.env((env) => start(request, env, true), { write: true });
|
|
321
|
+
return reply.status(outcome.status).send(outcome.body);
|
|
322
|
+
});
|
|
323
|
+
app.post('/api/v1/imports/:id/commit', async (request, reply) => {
|
|
324
|
+
const outcome = await request.env((env) => idempotent(request, env, driver, () => start(request, env, false)), { write: true });
|
|
325
|
+
for (const [name, value] of Object.entries(outcome.headers ?? {}))
|
|
326
|
+
reply.header(name, value);
|
|
327
|
+
if (outcome.replayed === true)
|
|
328
|
+
reply.header('idempotent-replayed', 'true');
|
|
329
|
+
return reply.status(outcome.status).send(outcome.body);
|
|
330
|
+
});
|
|
331
|
+
app.post('/api/v1/imports/:id/undo', async (request) => request.env(async (env) => {
|
|
332
|
+
const session = await sessionOf(env, idOf(request.params));
|
|
333
|
+
if (session.state !== 'done' || session.undo === null || session.importedAt === null)
|
|
334
|
+
throw new HttpProblem(409, 'not-imported', 'Nothing to undo', 'This file was not imported, or was undone.');
|
|
335
|
+
if (Date.now() - session.importedAt.getTime() > UNDO_DAYS * 86_400_000)
|
|
336
|
+
throw new HttpProblem(409, 'undo-expired', 'Too late to undo', `An import can be undone for ${UNDO_DAYS} days.`);
|
|
337
|
+
if (session.undo.created.length > 0)
|
|
338
|
+
env.access.check(session.model.name, 'unlink');
|
|
339
|
+
if (session.undo.updated.length > 0)
|
|
340
|
+
env.access.check(session.model.name, 'write');
|
|
341
|
+
const result = await undoImport(env, session.model, fieldsOf(env, session.model), session.undo);
|
|
342
|
+
await session.rows.write({ state: 'undone', undo: null });
|
|
343
|
+
if (session.undo.created.length > 0)
|
|
344
|
+
announceChange(env, driver, {
|
|
345
|
+
model: session.model.name,
|
|
346
|
+
ids: [...session.undo.created],
|
|
347
|
+
op: 'delete',
|
|
348
|
+
});
|
|
349
|
+
if (result.restored > 0)
|
|
350
|
+
announceChange(env, driver, {
|
|
351
|
+
model: session.model.name,
|
|
352
|
+
ids: session.undo.updated.map((u) => u.id),
|
|
353
|
+
op: 'update',
|
|
354
|
+
});
|
|
355
|
+
await audit(env, {
|
|
356
|
+
kind: 'data.import.undo',
|
|
357
|
+
summary: `Undid the import of ${session.fileName} into ${session.model.name}: ${result.deleted} deleted, ${result.restored} restored, ${result.changed.length} changed since.`,
|
|
358
|
+
targetModel: session.model.name,
|
|
359
|
+
details: { ...result, session: session.id },
|
|
360
|
+
ip: request.ip,
|
|
361
|
+
});
|
|
362
|
+
return result;
|
|
363
|
+
}, { write: true }));
|
|
364
|
+
// The rows left out, as CSV with an Error column: fix them and import the file again.
|
|
365
|
+
app.get('/api/v1/imports/:id/rejected', async (request, reply) => {
|
|
366
|
+
const { csv, name } = await request.env(async (env) => {
|
|
367
|
+
const session = await sessionOf(env, idOf(request.params));
|
|
368
|
+
const table = readTable(session.file, session.fileType, session.sheet);
|
|
369
|
+
const messages = new Map();
|
|
370
|
+
for (const e of session.rejected.errors)
|
|
371
|
+
messages.set(e.row, [...(messages.get(e.row) ?? []), e.message]);
|
|
372
|
+
const width = Math.max(0, ...table.slice(0, 50).map((r) => r.length));
|
|
373
|
+
const pad = (cells) => [
|
|
374
|
+
...cells,
|
|
375
|
+
...Array.from({ length: Math.max(0, width - cells.length) }, () => null),
|
|
376
|
+
];
|
|
377
|
+
const header = session.options.headerRow === false ? [] : [[...pad(table[0] ?? []), 'Error']];
|
|
378
|
+
const body = session.rejected.rows.map((n) => [
|
|
379
|
+
...pad(table[n - 1] ?? []),
|
|
380
|
+
(messages.get(n) ?? []).join(' '),
|
|
381
|
+
]);
|
|
382
|
+
return {
|
|
383
|
+
csv: [...header, ...body].map((r) => r.map(csvCell).join(',')).join('\r\n'),
|
|
384
|
+
name: `rejected-${session.fileName.replace(/\.[^.]*$/, '')}.csv`.replace(/["\\\r\n]/g, '_'),
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
return reply
|
|
388
|
+
.header('content-type', 'text/csv; charset=utf-8')
|
|
389
|
+
.header('content-disposition', `attachment; filename="${name}"`)
|
|
390
|
+
.send(`${csv}\r\n`); // the mark: Excel reads UTF-8 (Bangla) correctly
|
|
391
|
+
});
|
|
392
|
+
}
|