shutro-erp 0.2.1 → 0.3.1

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.
Files changed (273) hide show
  1. package/README.md +74 -67
  2. package/dist/account_portal/.kosh/bindings.d.ts +22 -0
  3. package/dist/account_portal/.kosh/bindings.js +15 -0
  4. package/dist/account_portal/.kosh/schema.d.ts +1380 -0
  5. package/dist/account_portal/index.d.ts +3 -0
  6. package/dist/account_portal/index.js +73 -0
  7. package/dist/account_portal/module.d.ts +5983 -0
  8. package/dist/account_portal/module.js +18 -0
  9. package/dist/accounting/.kosh/bindings.js +1 -1
  10. package/dist/accounting/.kosh/schema.d.ts +172 -9
  11. package/dist/accounting/chart.d.ts +40 -8
  12. package/dist/accounting/chart.js +124 -30
  13. package/dist/accounting/closing.d.ts +6 -1
  14. package/dist/accounting/closing.js +5 -1
  15. package/dist/accounting/demo.js +6 -5
  16. package/dist/accounting/index.d.ts +3 -3
  17. package/dist/accounting/index.js +200 -23
  18. package/dist/accounting/module.d.ts +3326 -1086
  19. package/dist/accounting/module.js +408 -66
  20. package/dist/accounting/payments.d.ts +25 -0
  21. package/dist/accounting/payments.js +96 -4
  22. package/dist/accounting/web/RegisterPayment.d.ts +3 -2
  23. package/dist/accounting/web/RegisterPayment.js +39 -6
  24. package/dist/accounting/web/SettingsPage.js +5 -1
  25. package/dist/accounting/web/messages.js +10 -0
  26. package/dist/assistant/routes.js +1 -1
  27. package/dist/automation/engine.js +1 -1
  28. package/dist/automation/web/messages.js +2 -2
  29. package/dist/cli/apps.d.ts +1 -1
  30. package/dist/cli/apps.js +14 -14
  31. package/dist/cli/bin.js +3 -0
  32. package/dist/cli/index.js +11 -11
  33. package/dist/cli/init.d.ts +1 -1
  34. package/dist/cli/init.js +224 -78
  35. package/dist/cli/launch.d.ts +16 -0
  36. package/dist/cli/launch.js +138 -0
  37. package/dist/cli/project.d.ts +7 -7
  38. package/dist/cli/project.js +25 -25
  39. package/dist/cli/prompt.d.ts +35 -0
  40. package/dist/cli/prompt.js +48 -2
  41. package/dist/cli/templates.d.ts +14 -6
  42. package/dist/cli/templates.js +61 -34
  43. package/dist/cli/tui.d.ts +7 -0
  44. package/dist/cli/tui.js +254 -0
  45. package/dist/cli/ui.d.ts +21 -8
  46. package/dist/cli/ui.js +65 -19
  47. package/dist/contacts/.kosh/bindings.js +1 -1
  48. package/dist/contacts/.kosh/schema.d.ts +33 -1
  49. package/dist/contacts/index.js +24 -1
  50. package/dist/contacts/module.d.ts +42 -0
  51. package/dist/contacts/module.js +3 -1
  52. package/dist/import/convert.d.ts +55 -0
  53. package/dist/import/convert.js +224 -0
  54. package/dist/import/csv.d.ts +28 -0
  55. package/dist/import/csv.js +115 -0
  56. package/dist/import/index.d.ts +33 -0
  57. package/dist/import/index.js +60 -0
  58. package/dist/import/mapping.d.ts +15 -0
  59. package/dist/import/mapping.js +33 -0
  60. package/dist/import/match.d.ts +24 -0
  61. package/dist/import/match.js +60 -0
  62. package/dist/import/models.d.ts +2 -0
  63. package/dist/import/models.js +102 -0
  64. package/dist/import/routes.d.ts +13 -0
  65. package/dist/import/routes.js +392 -0
  66. package/dist/import/run.d.ts +131 -0
  67. package/dist/import/run.js +568 -0
  68. package/dist/import/service.d.ts +153 -0
  69. package/dist/import/service.js +367 -0
  70. package/dist/import/web/ImportButton.d.ts +7 -0
  71. package/dist/import/web/ImportButton.js +31 -0
  72. package/dist/import/web/ImportWizard.d.ts +75 -0
  73. package/dist/import/web/ImportWizard.js +217 -0
  74. package/dist/import/web/index.d.ts +13 -0
  75. package/dist/import/web/index.js +18 -0
  76. package/dist/import/web/messages.d.ts +2 -0
  77. package/dist/import/web/messages.js +139 -0
  78. package/dist/import/worker.d.ts +3 -0
  79. package/dist/import/worker.js +52 -0
  80. package/dist/import/xlsx.d.ts +20 -0
  81. package/dist/import/xlsx.js +192 -0
  82. package/dist/l10n_bd/.kosh/bindings.d.ts +22 -0
  83. package/dist/l10n_bd/.kosh/bindings.js +15 -0
  84. package/dist/l10n_bd/.kosh/schema.d.ts +1311 -0
  85. package/dist/l10n_bd/documents.d.ts +8 -0
  86. package/dist/l10n_bd/documents.js +183 -0
  87. package/dist/l10n_bd/index.d.ts +8 -0
  88. package/dist/l10n_bd/index.js +156 -0
  89. package/dist/l10n_bd/module.d.ts +5705 -0
  90. package/dist/l10n_bd/module.js +163 -0
  91. package/dist/l10n_bd/mushak.d.ts +67 -0
  92. package/dist/l10n_bd/mushak.js +139 -0
  93. package/dist/l10n_bd/routes.d.ts +3 -0
  94. package/dist/l10n_bd/routes.js +205 -0
  95. package/dist/l10n_bd/web/MushakPage.d.ts +7 -0
  96. package/dist/l10n_bd/web/MushakPage.js +53 -0
  97. package/dist/l10n_bd/web/index.d.ts +13 -0
  98. package/dist/l10n_bd/web/index.js +18 -0
  99. package/dist/l10n_bd/web/messages.d.ts +2 -0
  100. package/dist/l10n_bd/web/messages.js +101 -0
  101. package/dist/l10n_in/.kosh/bindings.d.ts +22 -0
  102. package/dist/l10n_in/.kosh/bindings.js +15 -0
  103. package/dist/l10n_in/.kosh/schema.d.ts +1373 -0
  104. package/dist/l10n_in/documents.d.ts +8 -0
  105. package/dist/l10n_in/documents.js +114 -0
  106. package/dist/l10n_in/einvoice.d.ts +253 -0
  107. package/dist/l10n_in/einvoice.js +485 -0
  108. package/dist/l10n_in/gstr.d.ts +109 -0
  109. package/dist/l10n_in/gstr.js +430 -0
  110. package/dist/l10n_in/index.d.ts +20 -0
  111. package/dist/l10n_in/index.js +232 -0
  112. package/dist/l10n_in/module.d.ts +7823 -0
  113. package/dist/l10n_in/module.js +427 -0
  114. package/dist/l10n_in/routes-einvoice.d.ts +15 -0
  115. package/dist/l10n_in/routes-einvoice.js +124 -0
  116. package/dist/l10n_in/routes.d.ts +17 -0
  117. package/dist/l10n_in/routes.js +282 -0
  118. package/dist/l10n_in/web/EInvoicePanel.d.ts +8 -0
  119. package/dist/l10n_in/web/EInvoicePanel.js +63 -0
  120. package/dist/l10n_in/web/GstPage.d.ts +7 -0
  121. package/dist/l10n_in/web/GstPage.js +57 -0
  122. package/dist/l10n_in/web/GstSettings.d.ts +10 -0
  123. package/dist/l10n_in/web/GstSettings.js +36 -0
  124. package/dist/l10n_in/web/index.d.ts +15 -0
  125. package/dist/l10n_in/web/index.js +22 -0
  126. package/dist/l10n_in/web/messages.d.ts +2 -0
  127. package/dist/l10n_in/web/messages.js +166 -0
  128. package/dist/mail/index.js +2 -2
  129. package/dist/modules-sdk/demo.d.ts +2 -0
  130. package/dist/modules-sdk/demo.js +7 -3
  131. package/dist/modules-sdk/index.d.ts +2 -2
  132. package/dist/modules-sdk/index.js +1 -1
  133. package/dist/modules-sdk/loader.js +2 -2
  134. package/dist/modules-sdk/manifest.d.ts +132 -0
  135. package/dist/modules-sdk/manifest.js +22 -0
  136. package/dist/notify/inbox.js +21 -0
  137. package/dist/notify/messages.js +7 -3
  138. package/dist/notify/models.d.ts +1 -1
  139. package/dist/notify/models.js +2 -0
  140. package/dist/notify/routes.js +1 -1
  141. package/dist/notify/web/messages.js +5 -1
  142. package/dist/portal/.kosh/bindings.d.ts +22 -0
  143. package/dist/portal/.kosh/bindings.js +15 -0
  144. package/dist/portal/.kosh/schema.d.ts +376 -0
  145. package/dist/portal/announce.d.ts +16 -0
  146. package/dist/portal/announce.js +117 -0
  147. package/dist/portal/documents.d.ts +17 -0
  148. package/dist/portal/documents.js +61 -0
  149. package/dist/portal/index.d.ts +20 -0
  150. package/dist/portal/index.js +65 -0
  151. package/dist/portal/module.d.ts +1405 -0
  152. package/dist/portal/module.js +84 -0
  153. package/dist/portal/routes-documents.d.ts +2 -0
  154. package/dist/portal/routes-documents.js +391 -0
  155. package/dist/portal/routes.d.ts +13 -0
  156. package/dist/portal/routes.js +313 -0
  157. package/dist/portal/rules.d.ts +11 -0
  158. package/dist/portal/rules.js +25 -0
  159. package/dist/portal/web/PortalAccess.d.ts +8 -0
  160. package/dist/portal/web/PortalAccess.js +66 -0
  161. package/dist/portal/web/index.d.ts +12 -0
  162. package/dist/portal/web/index.js +17 -0
  163. package/dist/portal/web/messages.d.ts +2 -0
  164. package/dist/portal/web/messages.js +33 -0
  165. package/dist/product/.kosh/bindings.js +1 -1
  166. package/dist/product/.kosh/schema.d.ts +42 -3
  167. package/dist/product/demo.js +6 -5
  168. package/dist/product/index.d.ts +1 -0
  169. package/dist/product/index.js +17 -4
  170. package/dist/product/module.d.ts +346 -14
  171. package/dist/product/module.js +48 -18
  172. package/dist/product/taxes.d.ts +59 -0
  173. package/dist/product/taxes.js +61 -0
  174. package/dist/project/.kosh/bindings.js +1 -1
  175. package/dist/project/.kosh/schema.d.ts +33 -1
  176. package/dist/project/module.d.ts +20 -0
  177. package/dist/project_portal/.kosh/bindings.d.ts +22 -0
  178. package/dist/project_portal/.kosh/bindings.js +15 -0
  179. package/dist/project_portal/.kosh/schema.d.ts +701 -0
  180. package/dist/project_portal/index.d.ts +9 -0
  181. package/dist/project_portal/index.js +42 -0
  182. package/dist/project_portal/module.d.ts +2142 -0
  183. package/dist/project_portal/module.js +22 -0
  184. package/dist/purchase/.kosh/bindings.js +1 -1
  185. package/dist/purchase/.kosh/schema.d.ts +45 -4
  186. package/dist/purchase/demo.js +3 -3
  187. package/dist/purchase/index.js +1 -1
  188. package/dist/purchase/module.d.ts +232 -8
  189. package/dist/purchase/module.js +51 -9
  190. package/dist/purchase_account/.kosh/bindings.js +1 -1
  191. package/dist/purchase_account/.kosh/schema.d.ts +174 -10
  192. package/dist/purchase_account/module.d.ts +2008 -641
  193. package/dist/purchase_account/module.js +2 -2
  194. package/dist/purchase_portal/.kosh/bindings.d.ts +22 -0
  195. package/dist/purchase_portal/.kosh/bindings.js +15 -0
  196. package/dist/purchase_portal/.kosh/schema.d.ts +852 -0
  197. package/dist/purchase_portal/index.d.ts +9 -0
  198. package/dist/purchase_portal/index.js +53 -0
  199. package/dist/purchase_portal/module.d.ts +2758 -0
  200. package/dist/purchase_portal/module.js +18 -0
  201. package/dist/reporting/documents.js +20 -3
  202. package/dist/reporting/index.d.ts +1 -0
  203. package/dist/reporting/index.js +1 -0
  204. package/dist/reporting/routes-documents.js +9 -2
  205. package/dist/reporting/words.d.ts +11 -0
  206. package/dist/reporting/words.js +229 -0
  207. package/dist/sale_account/.kosh/bindings.js +1 -1
  208. package/dist/sale_account/.kosh/schema.d.ts +174 -10
  209. package/dist/sale_account/module.d.ts +2008 -641
  210. package/dist/sale_account/module.js +3 -3
  211. package/dist/sale_portal/.kosh/bindings.d.ts +22 -0
  212. package/dist/sale_portal/.kosh/bindings.js +15 -0
  213. package/dist/sale_portal/.kosh/schema.d.ts +766 -0
  214. package/dist/sale_portal/index.d.ts +9 -0
  215. package/dist/sale_portal/index.js +71 -0
  216. package/dist/sale_portal/module.d.ts +2718 -0
  217. package/dist/sale_portal/module.js +19 -0
  218. package/dist/sale_project/.kosh/bindings.js +1 -1
  219. package/dist/sale_project/.kosh/schema.d.ts +45 -4
  220. package/dist/sale_project/module.d.ts +390 -10
  221. package/dist/sales/.kosh/bindings.js +1 -1
  222. package/dist/sales/.kosh/schema.d.ts +45 -4
  223. package/dist/sales/demo.js +3 -3
  224. package/dist/sales/index.js +67 -1
  225. package/dist/sales/module.d.ts +232 -8
  226. package/dist/sales/module.js +51 -12
  227. package/dist/server/api/client.js +1 -1
  228. package/dist/server/api/openapi.js +17 -1
  229. package/dist/server/apps.js +5 -5
  230. package/dist/server/auth/api-keys.d.ts +13 -0
  231. package/dist/server/auth/api-keys.js +65 -0
  232. package/dist/server/auth/base.js +26 -0
  233. package/dist/server/auth/crypto.js +1 -1
  234. package/dist/server/auth/scopes.d.ts +12 -0
  235. package/dist/server/auth/scopes.js +51 -0
  236. package/dist/server/auth/service.d.ts +59 -1
  237. package/dist/server/auth/service.js +236 -6
  238. package/dist/server/demo.d.ts +1 -1
  239. package/dist/server/demo.js +1 -1
  240. package/dist/server/env-names.d.ts +2 -0
  241. package/dist/server/env-names.js +16 -0
  242. package/dist/server/host.js +5 -1
  243. package/dist/server/identity.d.ts +7 -0
  244. package/dist/server/index.d.ts +2 -1
  245. package/dist/server/index.js +2 -1
  246. package/dist/server/registry.js +1 -1
  247. package/dist/server/server.d.ts +5 -0
  248. package/dist/server/server.js +71 -6
  249. package/dist/settings/routes.js +1 -1
  250. package/dist/settings/web/ApiKeysPage.d.ts +8 -0
  251. package/dist/settings/web/ApiKeysPage.js +70 -0
  252. package/dist/settings/web/index.d.ts +1 -0
  253. package/dist/settings/web/index.js +4 -0
  254. package/dist/settings/web/messages.js +62 -0
  255. package/dist/storage/routes.js +1 -1
  256. package/dist/studio/build.js +1 -1
  257. package/dist/studio/generate.js +4 -4
  258. package/dist/studio/module.js +5 -5
  259. package/dist/studio/web/messages.js +2 -2
  260. package/dist/ui/api.d.ts +2 -0
  261. package/dist/ui/app.js +27 -4
  262. package/dist/ui/context.d.ts +3 -1
  263. package/dist/ui/context.js +2 -2
  264. package/dist/ui/i18n/messages.js +2 -2
  265. package/dist/ui/index.d.ts +1 -1
  266. package/dist/ui/index.js +1 -1
  267. package/dist/ui/lib/format.d.ts +2 -0
  268. package/dist/ui/lib/format.js +3 -1
  269. package/dist/ui/pwa/worker.js +2 -2
  270. package/dist/ui/registry.d.ts +15 -0
  271. package/dist/ui/registry.js +20 -0
  272. package/dist/ui/views/FormView.js +19 -6
  273. package/package.json +52 -4
@@ -0,0 +1,568 @@
1
+ /**
2
+ * The import itself (RFC 0014 §5.1–§5.5): rows of cells become records of a model through the ORM, as the user.
3
+ *
4
+ * - Records are created, updated or both (`mode`), matched on a key column: the record id, an external id, or a
5
+ * field the model keeps unique (a code, an email).
6
+ * - A row whose own columns are empty continues the record above: its `lines.*` cells add a line (§5.2). Lines of an
7
+ * updated record replace its lines, so a file exported with its lines re-imports to the same records.
8
+ * - A dry run does all of it — conversions, relation matches, creates and writes, computes, sequences, constraints,
9
+ * record rules — in a savepoint that is rolled back. A commit is all-or-nothing, or with `onError: 'skip'` keeps
10
+ * the records without errors.
11
+ * - A commit returns what undo needs (§5.5): the created ids, the previous values of updated records and their lines,
12
+ * and each record's `updatedAt` after the import, to tell records changed since.
13
+ *
14
+ * Errors name their row (as numbered in the file), column and cell.
15
+ */
16
+ import { problemOf } from '../server/index.js';
17
+ import { orm } from '../server/orm.js';
18
+ import { convertCell, importable, } from './convert.js';
19
+ import { matchAll } from './match.js';
20
+ /** The record id as a key column (`#42` or `42`, as an export writes it). */
21
+ export const ID_KEY = 'id';
22
+ /** An external id as a key column (`legacy.partner_42`; a bare `partner_42` is `__import__.partner_42`). */
23
+ export const EXTERNAL_ID_KEY = 'externalId';
24
+ /** The module part of external ids an import gives (Shutro `env.setExternalId`). */
25
+ export const IMPORT_MODULE = '__import__';
26
+ /** Records written per savepoint (and per progress report). */
27
+ export const WRITE_BATCH = 1000;
28
+ /** Thrown to roll back a savepoint; never escapes `runImport`. */
29
+ class Rollback extends Error {
30
+ }
31
+ const ref = (env, name) => env.model(name);
32
+ const empty = (c) => c === undefined || c === null || (typeof c === 'string' && c.trim() === '');
33
+ /** Field-mapped errors of a failing write: the columns of the fields the error names, else the row. */
34
+ function writeErrors(error, row, columnsByField) {
35
+ const problem = problemOf(error);
36
+ if (problem.status >= 500)
37
+ throw error; // a fault, not the data
38
+ const fields = error.fields;
39
+ const named = Array.isArray(fields)
40
+ ? fields.filter((f) => typeof f === 'string' && columnsByField.has(f))
41
+ : [];
42
+ const message = problem.detail ?? problem.title;
43
+ if (named.length === 0)
44
+ return [{ row, column: null, field: null, code: 'rejected', message }];
45
+ return named.map((field) => ({
46
+ row,
47
+ column: columnsByField.get(field) ?? null,
48
+ field,
49
+ code: 'rejected',
50
+ message,
51
+ }));
52
+ }
53
+ /** The full external id of a key cell. */
54
+ export const externalIdOf = (text) => text.includes('.') ? text : `${IMPORT_MODULE}.${text}`;
55
+ /**
56
+ * Imports `table` (cells by row) into `model` with `columns`. With `dryRun`, nothing is kept. Runs in the caller's
57
+ * transaction, in savepoints of its own.
58
+ */
59
+ export async function runImport(env, model, fields, table, columns, options) {
60
+ const first = options.headerRow === false ? 0 : 1;
61
+ const mode = options.mode ?? 'create';
62
+ const skip = options.onError === 'skip';
63
+ const mapped = columns.filter((c) => c.field !== null && fields.has(c.field));
64
+ const columnsByField = new Map(mapped.map((c) => [c.field, c.index]));
65
+ const own = mapped.filter((c) => fields.get(c.field).line === null);
66
+ const lineColumns = mapped.filter((c) => fields.get(c.field).line !== null);
67
+ const keyField = options.key === undefined ? undefined : fields.get(options.key);
68
+ const keyColumn = options.key === undefined ? undefined : columnsByField.get(options.key);
69
+ const errors = [];
70
+ const fail = (draft, error) => {
71
+ errors.push(error);
72
+ if (draft !== undefined)
73
+ draft.ok = false;
74
+ };
75
+ // Mapping errors: every row would fail the same way, so they are said once and nothing is written.
76
+ if (mode !== 'create' && (keyField === undefined || keyColumn === undefined))
77
+ errors.push({
78
+ row: first,
79
+ column: null,
80
+ field: options.key ?? null,
81
+ code: 'missing-key',
82
+ message: 'Updating records needs a key column: the id, an external id or a unique field.',
83
+ });
84
+ if (mode === 'create')
85
+ for (const field of fields.values())
86
+ if (field.required && field.line === null && !columnsByField.has(field.key))
87
+ errors.push({
88
+ row: first,
89
+ column: null,
90
+ field: field.key,
91
+ code: 'missing-required',
92
+ message: `${field.label} is required: map a column to it.`,
93
+ });
94
+ const mappingFailed = errors.length > 0;
95
+ // Rows to records; cells to values; relation texts collected to match each once.
96
+ const drafts = [];
97
+ const relationTexts = new Map();
98
+ const convert = (draft, column, cell, row) => {
99
+ const field = fields.get(column.field);
100
+ if (field.kind === ID_KEY || field.kind === EXTERNAL_ID_KEY) {
101
+ if (!empty(cell))
102
+ draft.key ??= String(cell).trim();
103
+ return undefined;
104
+ }
105
+ const converted = convertCell(field, cell, options);
106
+ if (converted === undefined)
107
+ return undefined;
108
+ if ('error' in converted) {
109
+ fail(draft, { row, column: column.index, field: field.key, ...converted.error });
110
+ return undefined;
111
+ }
112
+ let value = converted.value;
113
+ if (field.kind === 'many2many')
114
+ value = String(value)
115
+ .split(/[,;\n]/)
116
+ .map((t) => t.trim())
117
+ .filter((t) => t !== '');
118
+ if (field.target !== null) {
119
+ const texts = relationTexts.get(field.key) ?? new Set();
120
+ for (const t of Array.isArray(value) ? value : [value])
121
+ texts.add(String(t));
122
+ relationTexts.set(field.key, texts);
123
+ }
124
+ return value;
125
+ };
126
+ for (let r = first; r < table.length; r++) {
127
+ const cells = table[r] ?? [];
128
+ if (cells.every(empty))
129
+ continue;
130
+ const previous = drafts.at(-1);
131
+ const continues = previous !== undefined &&
132
+ own.every((c) => empty(cells[c.index])) &&
133
+ lineColumns.some((c) => !empty(cells[c.index]));
134
+ let draft;
135
+ if (continues) {
136
+ draft = previous;
137
+ draft.rows.push(r + 1);
138
+ }
139
+ else {
140
+ draft = {
141
+ row: r + 1,
142
+ rows: [r + 1],
143
+ values: {},
144
+ lines: new Map(),
145
+ key: null,
146
+ ok: true,
147
+ existing: null,
148
+ };
149
+ drafts.push(draft);
150
+ for (const column of own) {
151
+ const value = convert(draft, column, cells[column.index] ?? null, r + 1);
152
+ if (value !== undefined)
153
+ draft.values[column.field] = value;
154
+ }
155
+ if (keyField !== undefined && keyField.kind !== ID_KEY && keyField.kind !== EXTERNAL_ID_KEY) {
156
+ const value = draft.values[keyField.key];
157
+ if (value !== undefined)
158
+ draft.key = String(value);
159
+ }
160
+ }
161
+ const lines = new Map();
162
+ for (const column of lineColumns) {
163
+ const field = fields.get(column.field);
164
+ if (empty(cells[column.index]))
165
+ continue;
166
+ const line = lines.get(field.line) ?? {};
167
+ const value = convert(draft, column, cells[column.index] ?? null, r + 1);
168
+ if (value !== undefined)
169
+ line[field.sub] = value;
170
+ lines.set(field.line, line);
171
+ }
172
+ for (const [o2m, values] of lines) {
173
+ const list = draft.lines.get(o2m) ?? [];
174
+ list.push({ row: r + 1, values });
175
+ draft.lines.set(o2m, list);
176
+ }
177
+ }
178
+ // Relations: texts to ids, as the user; an unmatched text is an error on its cell.
179
+ const matches = {};
180
+ for (const [key, texts] of relationTexts) {
181
+ const target = fields.get(key).target;
182
+ matches[key] = Object.fromEntries(await matchAll(env, target, texts));
183
+ }
184
+ const resolve = (draft, values, prefix, row) => {
185
+ for (const [sub, value] of Object.entries(values)) {
186
+ const path = prefix === '' ? sub : `${prefix}.${sub}`;
187
+ const field = fields.get(path);
188
+ if (field.target === null)
189
+ continue;
190
+ const ids = [];
191
+ for (const t of Array.isArray(value) ? value : [value]) {
192
+ const text = String(t);
193
+ const match = matches[path]?.[text];
194
+ if ('id' in match) {
195
+ ids.push(match.id);
196
+ continue;
197
+ }
198
+ fail(draft, {
199
+ row,
200
+ column: columnsByField.get(path) ?? null,
201
+ field: path,
202
+ code: match.error,
203
+ message: match.error === 'ambiguous'
204
+ ? `${field.label}: "${text}" matches several records; use its code or external id.`
205
+ : `${field.label}: no record "${text}" was found.`,
206
+ });
207
+ }
208
+ values[sub] = field.kind === 'many2many' ? { set: ids } : ids[0];
209
+ }
210
+ };
211
+ for (const draft of drafts) {
212
+ resolve(draft, draft.values, '', draft.row);
213
+ for (const [o2m, lines] of draft.lines)
214
+ for (const line of lines)
215
+ resolve(draft, line.values, o2m, line.row);
216
+ }
217
+ // Keys: the existing record of each draft (as the user), duplicates in the file, what the mode allows.
218
+ if (keyField !== undefined && keyColumn !== undefined) {
219
+ const seen = new Set();
220
+ const keyError = (draft, code, message) => fail(draft, { row: draft.row, column: keyColumn, field: keyField.key, code, message });
221
+ const byKey = new Map();
222
+ const keys = drafts.flatMap((d) => (d.key === null ? [] : [d.key]));
223
+ if (keyField.kind === ID_KEY) {
224
+ const ids = keys
225
+ .map((k) => Number(k.replace(/^#/, '')))
226
+ .filter((n) => Number.isSafeInteger(n) && n > 0);
227
+ const found = ids.length === 0
228
+ ? []
229
+ : (await ref(env, model.name).search({ where: { id: { in: ids } } })).ids;
230
+ for (const id of found)
231
+ byKey.set(String(id), [id]);
232
+ }
233
+ else if (keyField.kind === EXTERNAL_ID_KEY) {
234
+ for (const k of new Set(keys))
235
+ try {
236
+ const id = Number(await env.refId(model.name, externalIdOf(k)));
237
+ const visible = await ref(env, model.name).search({ where: { id }, limit: 1 });
238
+ if (visible.length === 1)
239
+ byKey.set(k, [id]);
240
+ }
241
+ catch (error) {
242
+ if (!(error instanceof orm.UnknownExternalIdError))
243
+ throw error; // unknown: no match
244
+ }
245
+ }
246
+ else if (keys.length > 0) {
247
+ const found = await ref(env, model.name).search({
248
+ where: { [keyField.key]: { in: [...new Set(keys)] } },
249
+ });
250
+ for (const row of (await found.load([keyField.key])).read([keyField.key])) {
251
+ const k = String(row[keyField.key]);
252
+ byKey.set(k, [...(byKey.get(k) ?? []), row['id']]);
253
+ }
254
+ }
255
+ for (const draft of drafts) {
256
+ if (draft.key === null) {
257
+ if (mode !== 'create')
258
+ keyError(draft, 'missing-key', `${keyField.label} is empty: it finds the record to update.`);
259
+ continue;
260
+ }
261
+ const k = keyField.kind === ID_KEY ? draft.key.replace(/^#/, '') : draft.key;
262
+ if (seen.has(k)) {
263
+ keyError(draft, 'duplicate-key', `${keyField.label} "${draft.key}" is on an earlier row too.`);
264
+ continue;
265
+ }
266
+ seen.add(k);
267
+ const found = byKey.get(k) ?? [];
268
+ if (found.length > 1)
269
+ keyError(draft, 'ambiguous', `${keyField.label} "${draft.key}" matches several records.`);
270
+ else if (found.length === 1) {
271
+ if (mode === 'create')
272
+ keyError(draft, 'exists', `${keyField.label} "${draft.key}" is an existing record.`);
273
+ else
274
+ draft.existing = found[0];
275
+ }
276
+ else if (mode === 'update')
277
+ keyError(draft, 'no-match', `No record has ${keyField.label} "${draft.key}".`);
278
+ else if (keyField.kind === ID_KEY)
279
+ keyError(draft, 'no-match', `No record has the id "${draft.key}"; ids cannot be chosen.`);
280
+ }
281
+ }
282
+ const ready = mappingFailed ? [] : drafts.filter((d) => d.ok);
283
+ const skipped = drafts.length - ready.length;
284
+ const created = [];
285
+ const updated = [];
286
+ let undo = null;
287
+ let failed = 0;
288
+ const failedDrafts = new Set();
289
+ const keep = !options.dryRun && (errors.length === 0 || (skip && !mappingFailed));
290
+ // The values of a write: own fields, and the lines (an update replaces them).
291
+ const linesFields = (o2m) => [...fields.values()].filter((f) => f.line === o2m && f.kind !== ID_KEY).map((f) => f.sub);
292
+ const valuesOf = (draft, oldLines) => {
293
+ const values = { ...draft.values };
294
+ for (const [o2m, lines] of draft.lines)
295
+ values[o2m] = {
296
+ ...(oldLines.has(o2m) ? { delete: oldLines.get(o2m) } : {}),
297
+ create: lines.map((l) => l.values),
298
+ };
299
+ return values;
300
+ };
301
+ /** Before values of the drafts to update: the written fields, and the lines they replace. */
302
+ const beforeOf = async (tx, list) => {
303
+ const out = new Map();
304
+ for (const draft of list) {
305
+ const id = draft.existing;
306
+ const keys = Object.keys(draft.values);
307
+ const o2ms = [...draft.lines.keys()];
308
+ let records = ref(tx, model.name).browse([id]);
309
+ if (keys.length > 0)
310
+ records = await records.load(keys);
311
+ if (o2ms.length > 0)
312
+ records = await records.load(Object.fromEntries(o2ms.map((k) => [k, true])));
313
+ const [row] = records.read([...keys, ...o2ms]);
314
+ const before = {};
315
+ for (const key of keys)
316
+ before[key] = row?.[key] ?? null;
317
+ const lines = {};
318
+ const lineIds = new Map();
319
+ for (const o2m of o2ms) {
320
+ const ids = row?.[o2m] ?? [];
321
+ lineIds.set(o2m, ids);
322
+ const subs = linesFields(o2m);
323
+ const lineModel = lineModelOf(fields, o2m);
324
+ lines[o2m] =
325
+ ids.length === 0 || lineModel === null
326
+ ? []
327
+ : (await ref(tx, lineModel).browse(ids).load(subs))
328
+ .read(subs)
329
+ .map(({ id: _, ...rest }) => rest);
330
+ }
331
+ out.set(id, { before, lines, lineIds });
332
+ }
333
+ return out;
334
+ };
335
+ const write = async (tx, list) => {
336
+ const creates = list.filter((d) => d.existing === null);
337
+ const updates = list.filter((d) => d.existing !== null);
338
+ const before = await beforeOf(tx, updates);
339
+ const made = creates.length === 0
340
+ ? []
341
+ : (await ref(tx, model.name).create(creates.map((d) => valuesOf(d, new Map())))).ids;
342
+ for (const [i, draft] of creates.entries())
343
+ if (keyField?.kind === EXTERNAL_ID_KEY && draft.key !== null)
344
+ await tx.setExternalId(model.name, made[i], externalIdOf(draft.key));
345
+ for (const draft of updates)
346
+ await ref(tx, model.name)
347
+ .browse([draft.existing])
348
+ .write(valuesOf(draft, before.get(draft.existing)?.lineIds ?? new Map()));
349
+ await tx.flush();
350
+ return { made: [...made], updates: updates.map((d) => d.existing), before };
351
+ };
352
+ try {
353
+ await env.transaction(async (tx) => {
354
+ const done = [];
355
+ // Batches of records, each in a savepoint; a failing batch again record by record, to find each failure.
356
+ await options.onProgress?.(0, ready.length);
357
+ for (let start = 0; start < ready.length; start += WRITE_BATCH) {
358
+ const batch = ready.slice(start, start + WRITE_BATCH);
359
+ try {
360
+ done.push(await tx.transaction((b) => write(b, batch)));
361
+ }
362
+ catch (error) {
363
+ if (problemOf(error).status >= 500)
364
+ throw error;
365
+ for (const draft of batch)
366
+ try {
367
+ done.push(await tx.transaction((one) => write(one, [draft])));
368
+ }
369
+ catch (rowError) {
370
+ failed += 1;
371
+ failedDrafts.add(draft);
372
+ errors.push(...writeErrors(rowError, draft.row, columnsByField));
373
+ }
374
+ }
375
+ await options.onProgress?.(Math.min(start + WRITE_BATCH, ready.length), ready.length);
376
+ }
377
+ for (const d of done) {
378
+ created.push(...d.made);
379
+ updated.push(...d.updates);
380
+ }
381
+ if (!keep || (!skip && errors.length > 0))
382
+ throw new Rollback();
383
+ const touched = [...created, ...updated];
384
+ const stamps = {};
385
+ if (touched.length > 0)
386
+ for (const row of (await ref(tx, model.name).browse(touched).load(['updatedAt'])).read([
387
+ 'updatedAt',
388
+ ]))
389
+ stamps[String(row['id'])] = String(row['updatedAt']);
390
+ undo = {
391
+ created,
392
+ updated: done.flatMap((d) => [...d.before].map(([id, b]) => ({ id, before: b.before, lines: b.lines }))),
393
+ stamps,
394
+ };
395
+ });
396
+ }
397
+ catch (error) {
398
+ if (!(error instanceof Rollback))
399
+ throw error;
400
+ const wouldCreate = created.length;
401
+ const rejectedRows = drafts.filter((d) => !d.ok || failedDrafts.has(d)).flatMap((d) => d.rows);
402
+ const wouldUpdate = updated.length;
403
+ return {
404
+ rows: drafts.length,
405
+ created: [],
406
+ updated: [],
407
+ wouldCreate: options.dryRun ? wouldCreate : 0,
408
+ wouldUpdate: options.dryRun ? wouldUpdate : 0,
409
+ skipped: skip ? skipped + failed : 0,
410
+ rejectedRows: skip ? rejectedRows : [],
411
+ errors: sortErrors(errors),
412
+ matches,
413
+ undo: null,
414
+ };
415
+ }
416
+ return {
417
+ rows: drafts.length,
418
+ created,
419
+ updated,
420
+ wouldCreate: 0,
421
+ wouldUpdate: 0,
422
+ skipped: skipped + failed,
423
+ rejectedRows: drafts.filter((d) => !d.ok || failedDrafts.has(d)).flatMap((d) => d.rows),
424
+ errors: sortErrors(errors),
425
+ matches,
426
+ undo,
427
+ };
428
+ }
429
+ function sortErrors(errors) {
430
+ return [...errors].sort((a, b) => a.row - b.row || (a.column ?? -1) - (b.column ?? -1));
431
+ }
432
+ /** The model of a one2many's lines, from its line fields. */
433
+ function lineModelOf(fields, o2m) {
434
+ return fields.get(o2m)?.target?.model ?? null;
435
+ }
436
+ /** A value as `read()` gave it, as a write takes it back. */
437
+ function writable(kind, value) {
438
+ if (value === null || value === undefined)
439
+ return value === undefined ? null : value;
440
+ if (kind === 'many2many')
441
+ return { set: value };
442
+ if (kind === 'datetime' && typeof value === 'string')
443
+ return new Date(value);
444
+ if (typeof value === 'object' && !Array.isArray(value) && 'amount' in value)
445
+ return value.amount; // money: the amount (the currency is its own field)
446
+ return value;
447
+ }
448
+ /**
449
+ * Undoes an import (RFC 0014 §5.5), as the user: deletes the records it created and restores the values it changed,
450
+ * for the records nobody changed since (their `updatedAt` is still the import's). The others are listed.
451
+ */
452
+ export async function undoImport(env, model, fields, undo) {
453
+ const touched = [...undo.created, ...undo.updated.map((u) => u.id)];
454
+ const current = new Map();
455
+ if (touched.length > 0) {
456
+ const found = await ref(env, model.name).search({ where: { id: { in: touched } } });
457
+ for (const row of (await found.load(['updatedAt'])).read(['updatedAt']))
458
+ current.set(row['id'], String(row['updatedAt']));
459
+ }
460
+ const unchanged = (id) => current.has(id) && current.get(id) === undo.stamps[String(id)];
461
+ const changed = touched.filter((id) => current.has(id) && !unchanged(id));
462
+ const toDelete = undo.created.filter(unchanged);
463
+ if (toDelete.length > 0)
464
+ await ref(env, model.name).browse(toDelete).unlink();
465
+ let restored = 0;
466
+ for (const u of undo.updated) {
467
+ if (!unchanged(u.id))
468
+ continue;
469
+ const values = {};
470
+ for (const [key, value] of Object.entries(u.before))
471
+ values[key] = writable(fields.get(key)?.kind ?? '', value);
472
+ for (const [o2m, lines] of Object.entries(u.lines)) {
473
+ let records = ref(env, model.name).browse([u.id]);
474
+ records = await records.load({ [o2m]: true });
475
+ const [row] = records.read([o2m]);
476
+ values[o2m] = {
477
+ delete: row?.[o2m] ?? [],
478
+ create: lines.map((line) => Object.fromEntries(Object.entries(line).map(([sub, v]) => [
479
+ sub,
480
+ writable(fields.get(`${o2m}.${sub}`)?.kind ?? '', v),
481
+ ]))),
482
+ };
483
+ }
484
+ await ref(env, model.name).browse([u.id]).write(values);
485
+ restored += 1;
486
+ }
487
+ await env.flush();
488
+ return { deleted: toDelete.length, restored, changed };
489
+ }
490
+ function fieldEntries(env, model, catalog, line) {
491
+ const out = [];
492
+ const keys = new Set(catalog.targets(model.name)?.keys ?? []);
493
+ for (const f of model.fields) {
494
+ if (f.readonly || !importable(f.kind) || f.key === 'id' || f.key === 'company')
495
+ continue;
496
+ if (!env.access.canField(model.name, f.key, 'write'))
497
+ continue;
498
+ let target = null;
499
+ if (f.kind === 'many2one' || f.kind === 'many2many') {
500
+ if (f.target === null || !env.access.can(f.target, 'read'))
501
+ continue;
502
+ target = catalog.targets(f.target);
503
+ if (target === null)
504
+ continue;
505
+ }
506
+ out.push({
507
+ key: line === null ? f.key : `${line}.${f.key}`,
508
+ kind: f.kind,
509
+ label: f.label,
510
+ required: f.requiredOnCreate && f.kind !== 'many2many', // a set is never missing: it is empty
511
+ options: f.options,
512
+ target,
513
+ line,
514
+ sub: f.key,
515
+ keyable: line === null && keys.has(f.key),
516
+ });
517
+ }
518
+ return out;
519
+ }
520
+ /**
521
+ * The fields of `model` the user may fill by import, by path: its own, the key columns (`id`, `externalId`), and
522
+ * the fields of the lines of each one2many the user may write and whose lines they may create (without the link
523
+ * back to the record).
524
+ */
525
+ export function importFields(env, model, catalog) {
526
+ const out = new Map();
527
+ const key = (k, label) => ({
528
+ key: k,
529
+ kind: k,
530
+ label,
531
+ required: false,
532
+ options: null,
533
+ target: null,
534
+ line: null,
535
+ sub: k,
536
+ keyable: true,
537
+ });
538
+ out.set(ID_KEY, key(ID_KEY, 'ID'));
539
+ out.set(EXTERNAL_ID_KEY, key(EXTERNAL_ID_KEY, 'External ID'));
540
+ for (const f of fieldEntries(env, model, catalog, null))
541
+ out.set(f.key, f);
542
+ for (const f of model.fields) {
543
+ if (f.kind !== 'one2many' || f.readonly || f.target === null)
544
+ continue;
545
+ const lines = catalog.models.get(f.target);
546
+ if (lines === undefined ||
547
+ !env.access.canField(model.name, f.key, 'write') ||
548
+ !env.access.can(f.target, 'create'))
549
+ continue;
550
+ const inverse = catalog.inverseOf(model.name, f.key);
551
+ // The one2many itself, for its lines' model (not a column of its own).
552
+ out.set(f.key, {
553
+ key: f.key,
554
+ kind: 'one2many',
555
+ label: f.label,
556
+ required: false,
557
+ options: null,
558
+ target: { model: f.target, recName: lines.recName ?? 'name', keys: [] },
559
+ line: null,
560
+ sub: f.key,
561
+ keyable: false,
562
+ });
563
+ for (const sub of fieldEntries(env, lines, catalog, f.key))
564
+ if (sub.sub !== inverse)
565
+ out.set(sub.key, { ...sub, label: `${f.label} › ${sub.label}`, required: false });
566
+ }
567
+ return out;
568
+ }