@bergetai/odoo-cli 0.9.2 → 0.11.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.
@@ -0,0 +1,843 @@
1
+ import { ModelFields } from "../model.js";
2
+ import { authenticatedClient } from "../resource.js";
3
+ import { usageError } from "../errors.js";
4
+ import { colorEnabled, printJson, verbose as logVerbose } from "../output.js";
5
+ import { formatTable, humanCell } from "../table.js";
6
+ /** Minimal inline accounting configs (from the removed erasure-base-commands
7
+ * plan §2.1–2.3): read-only modules. */
8
+ export const accountMove = {
9
+ model: "account.move",
10
+ showCommand: "odoo invoices show",
11
+ defaultListFields: [
12
+ "id",
13
+ "name",
14
+ "invoice_date",
15
+ "state",
16
+ "move_type",
17
+ "partner_id",
18
+ "invoice_partner_display_name",
19
+ ],
20
+ showExtraFields: ["create_date", "write_date"],
21
+ resolutionHints: {},
22
+ priorityLabels: {},
23
+ pinnedDomain: [],
24
+ references: {},
25
+ };
26
+ export const saleOrder = {
27
+ model: "sale.order",
28
+ showCommand: "odoo orders show",
29
+ defaultListFields: [
30
+ "id",
31
+ "name",
32
+ "state",
33
+ "partner_id",
34
+ "partner_invoice_id",
35
+ "partner_shipping_id",
36
+ "date_order",
37
+ ],
38
+ showExtraFields: ["create_date", "write_date"],
39
+ resolutionHints: {},
40
+ priorityLabels: {},
41
+ pinnedDomain: [],
42
+ references: {},
43
+ };
44
+ export const accountPayment = {
45
+ model: "account.payment",
46
+ showCommand: "odoo payments show",
47
+ defaultListFields: [
48
+ "id",
49
+ "name",
50
+ "state",
51
+ "partner_id",
52
+ "amount",
53
+ "currency_id",
54
+ "date",
55
+ ],
56
+ showExtraFields: ["create_date", "write_date"],
57
+ resolutionHints: {},
58
+ priorityLabels: {},
59
+ pinnedDomain: [],
60
+ references: {},
61
+ };
62
+ /** Linked groups are actionable in this order — identity first (portal users
63
+ * block partner unlink: res_users.partner_id is ondelete=restrict), partner
64
+ * shells last. keep-groups (invoices/orders/payments) are reference surfaces,
65
+ * not steps, and are excluded. */
66
+ export const RUN_ORDER = [
67
+ "users",
68
+ "leads",
69
+ "mailingContacts",
70
+ "mailingTraces",
71
+ "tickets",
72
+ "messages",
73
+ "attachments",
74
+ "partners",
75
+ ];
76
+ /** Odoo OR-domain over clause groups: "|" c1 "|" c2 c3 (prefix notation).
77
+ * Implicit conjunction cannot nest under "|", so groups with more than one
78
+ * clause are wrapped in an explicit "&". An empty OR matches nothing. */
79
+ function orGroups(groups) {
80
+ const wrap = (g) => (g.length > 1 ? ["&", ...g] : [...g]);
81
+ const present = groups.filter((g) => g.length > 0).map(wrap);
82
+ if (present.length === 0)
83
+ return [["id", "=", 0]];
84
+ let acc = [...present[present.length - 1]];
85
+ for (let i = present.length - 2; i >= 0; i--) {
86
+ acc = ["|", ...present[i], ...acc];
87
+ }
88
+ return acc;
89
+ }
90
+ function m2oId(value) {
91
+ const v = value;
92
+ return Array.isArray(v) && typeof v[0] === "number" ? v[0] : false;
93
+ }
94
+ function or(...groups) {
95
+ return orGroups(groups);
96
+ }
97
+ const GROUPS = [
98
+ {
99
+ group: "partners",
100
+ config: {
101
+ model: "res.partner",
102
+ showCommand: "odoo partners show",
103
+ defaultListFields: [],
104
+ showExtraFields: [],
105
+ resolutionHints: {},
106
+ priorityLabels: {},
107
+ pinnedDomain: [],
108
+ references: {},
109
+ },
110
+ fields: [
111
+ "id",
112
+ "name",
113
+ "email",
114
+ "parent_id",
115
+ "is_company",
116
+ "active",
117
+ // step-2/4 decision inputs: identities on the partner, member
118
+ // contacts (sole-member test), and the personnummer-derived vat
119
+ // that step 4 must blank on enskild-firma shells
120
+ "user_ids",
121
+ "child_ids",
122
+ "vat",
123
+ ],
124
+ reason: "",
125
+ },
126
+ {
127
+ group: "users",
128
+ config: {
129
+ model: "res.users",
130
+ showCommand: "odoo users show",
131
+ defaultListFields: [],
132
+ showExtraFields: [],
133
+ resolutionHints: {},
134
+ priorityLabels: {},
135
+ pinnedDomain: [],
136
+ references: {},
137
+ },
138
+ fields: ["id", "login", "email", "share", "active", "partner_id"],
139
+ reason: "",
140
+ },
141
+ {
142
+ group: "leads",
143
+ config: {
144
+ model: "crm.lead",
145
+ showCommand: "odoo crm records show",
146
+ defaultListFields: [],
147
+ showExtraFields: [],
148
+ resolutionHints: {},
149
+ priorityLabels: {},
150
+ pinnedDomain: [],
151
+ references: {},
152
+ },
153
+ fields: ["id", "name", "email_from", "partner_id", "active"],
154
+ reason: "CRM lead for the requesting person (no retention exception)",
155
+ hint: (_r, id) => `odoo crm records delete ${id} --dry-run`,
156
+ },
157
+ {
158
+ group: "mailingContacts",
159
+ config: {
160
+ model: "mailing.contact",
161
+ showCommand: "odoo marketing contacts show",
162
+ defaultListFields: [],
163
+ showExtraFields: [],
164
+ resolutionHints: {},
165
+ priorityLabels: {},
166
+ pinnedDomain: [],
167
+ references: {},
168
+ },
169
+ fields: ["id", "email", "name"],
170
+ reason: "marketing contact — strict delete (TDD decision #7)",
171
+ hint: (_r, id) => `odoo marketing contacts delete ${id} --dry-run`,
172
+ },
173
+ {
174
+ group: "mailingTraces",
175
+ config: {
176
+ model: "mailing.trace",
177
+ showCommand: "odoo marketing traces show",
178
+ defaultListFields: [],
179
+ showExtraFields: [],
180
+ resolutionHints: {},
181
+ priorityLabels: {},
182
+ pinnedDomain: [],
183
+ references: {},
184
+ },
185
+ fields: ["id", "email", "model", "res_id", "trace_status"],
186
+ reason: "send trace with denormalized email — no cascade from contact deletion (invariant 4)",
187
+ hint: (_r, id) => `odoo marketing traces delete ${id} --dry-run`,
188
+ },
189
+ {
190
+ group: "tickets",
191
+ config: {
192
+ model: "helpdesk.ticket",
193
+ showCommand: "odoo helpdesk tickets show",
194
+ defaultListFields: [],
195
+ showExtraFields: [],
196
+ resolutionHints: {},
197
+ priorityLabels: {},
198
+ pinnedDomain: [],
199
+ references: {},
200
+ },
201
+ fields: ["id", "name", "partner_id", "partner_email", "team_id", "active"],
202
+ reason: "delete unless an active legal claim exists (Flow A step 6)",
203
+ hint: (_r, id) => `odoo helpdesk tickets delete ${id} --dry-run`,
204
+ },
205
+ {
206
+ group: "messages",
207
+ config: {
208
+ model: "mail.message",
209
+ showCommand: "odoo crm records message-delete",
210
+ defaultListFields: [],
211
+ showExtraFields: [],
212
+ resolutionHints: {},
213
+ priorityLabels: {},
214
+ pinnedDomain: [],
215
+ references: {},
216
+ },
217
+ fields: [
218
+ "id",
219
+ "model",
220
+ "res_id",
221
+ "author_id",
222
+ "email_from",
223
+ "date",
224
+ "body",
225
+ ],
226
+ tableExcludeFields: ["body"],
227
+ reason: "correspondence on a to-be-deleted record",
228
+ },
229
+ {
230
+ group: "attachments",
231
+ config: {
232
+ model: "ir.attachment",
233
+ showCommand: "odoo attachments show",
234
+ defaultListFields: [],
235
+ showExtraFields: [],
236
+ resolutionHints: {},
237
+ priorityLabels: {},
238
+ pinnedDomain: [],
239
+ references: {},
240
+ },
241
+ fields: ["id", "name", "res_model", "res_id"],
242
+ reason: "",
243
+ hint: (_r, id) => `odoo attachments delete ${id} --dry-run`,
244
+ },
245
+ {
246
+ group: "invoices",
247
+ config: accountMove,
248
+ fields: [
249
+ "id",
250
+ "name",
251
+ "invoice_date",
252
+ "state",
253
+ "move_type",
254
+ "partner_id",
255
+ "invoice_partner_display_name",
256
+ ],
257
+ reason: "accounting record — bokföringslagen 7 kap. 2 § (7 years)",
258
+ },
259
+ {
260
+ group: "orders",
261
+ config: saleOrder,
262
+ fields: [
263
+ "id",
264
+ "name",
265
+ "state",
266
+ "partner_id",
267
+ "partner_invoice_id",
268
+ "partner_shipping_id",
269
+ "date_order",
270
+ ],
271
+ reason: "accounting/order documentation — RESTRICT reference surface (§6.2.4)",
272
+ },
273
+ {
274
+ group: "payments",
275
+ config: accountPayment,
276
+ fields: ["id", "name", "state", "partner_id", "amount", "date"],
277
+ reason: "accounting record — bokföringslagen 7 kap. 2 § (7 years)",
278
+ },
279
+ ];
280
+ /** search_read + search_count in one round trip (same context, so the count
281
+ * reflects the same rule/active filtering). The count drives truncation
282
+ * flags: a group can silently cap (server-side limit or record rules) and
283
+ * the request register needs exact footprint counts (TDD step 1). */
284
+ async function searchRowsWithCount(client, model, domain, fields, limit, includeArchived) {
285
+ // fields_get doubles as the module-installed check; curated fields
286
+ // intersect with the schema so edition-dependent fields degrade
287
+ // gracefully (helpdesk.ticket.ticket_type_id precedent).
288
+ const meta = await ModelFields.load(client, model);
289
+ const select = fields.filter((f) => f === "id" || meta.get(f));
290
+ // Archived rows are still personal data (TDD decision #10): search with
291
+ // active_test disabled unless --active-only asks for the UI's view.
292
+ const context = includeArchived ? { context: { active_test: false } } : {};
293
+ const [rows, total] = (await Promise.all([
294
+ client.executeKw(model, "search_read", [domain], {
295
+ fields: select.length > 0 ? select : undefined,
296
+ limit,
297
+ ...context,
298
+ }),
299
+ client.executeKw(model, "search_count", [domain], context),
300
+ ]));
301
+ return { rows, total };
302
+ }
303
+ function parseCount(raw, flag) {
304
+ if (!/^\d+$/.test(raw)) {
305
+ throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
306
+ }
307
+ return Number(raw);
308
+ }
309
+ const GROUP_ORDER = GROUPS.map((g) => g.group);
310
+ export async function footprintCommand(emailArg, opts, globals) {
311
+ const email = emailArg.trim();
312
+ if (email === "") {
313
+ throw usageError("email must not be empty");
314
+ }
315
+ const limit = parseCount(opts.limit ?? "500", "--limit");
316
+ const includeArchived = !opts.activeOnly;
317
+ const client = await authenticatedClient(globals);
318
+ const groupErrors = [];
319
+ const rows = new Map();
320
+ const groupTotals = {};
321
+ async function runGroup(group, model, fn) {
322
+ try {
323
+ const result = await fn();
324
+ rows.set(group, result.rows);
325
+ groupTotals[group] = result.total;
326
+ }
327
+ catch (error) {
328
+ groupErrors.push({
329
+ group,
330
+ model,
331
+ message: error.message,
332
+ });
333
+ rows.set(group, []);
334
+ }
335
+ }
336
+ // Phase A: email-keyed enumeration (independent of each other).
337
+ await Promise.all([
338
+ runGroup("partners", "res.partner", () => searchRowsWithCount(client, "res.partner", [["email", "ilike", email]], [
339
+ "id",
340
+ "name",
341
+ "email",
342
+ "parent_id",
343
+ "is_company",
344
+ "active",
345
+ "user_ids",
346
+ "child_ids",
347
+ "vat",
348
+ ], limit, includeArchived)),
349
+ runGroup("mailingContacts", "mailing.contact", () => searchRowsWithCount(client, "mailing.contact", or([["email", "ilike", email]], [["name", "ilike", email]]), ["id", "email", "name"], limit, includeArchived)),
350
+ runGroup("mailingTraces", "mailing.trace", () => searchRowsWithCount(client, "mailing.trace", [["email", "ilike", email]], ["id", "email", "model", "res_id", "trace_status"], limit, includeArchived)),
351
+ ]);
352
+ // Phase B: users (direct login/email, and partner_id of found partners).
353
+ const partnerRows = rows.get("partners") ?? [];
354
+ const phase1PartnerIds = partnerRows.map((r) => r.id);
355
+ await runGroup("users", "res.users", () => searchRowsWithCount(client, "res.users", or([["login", "ilike", email]], [["email", "ilike", email]], phase1PartnerIds.length > 0
356
+ ? [["partner_id", "in", phase1PartnerIds]]
357
+ : []), ["id", "login", "email", "share", "active", "partner_id"], limit, includeArchived));
358
+ // Correlation set: every partner id the person touches (their contacts,
359
+ // plus partners referenced directly by sign-in identities — the
360
+ // root-attached-portal-user pattern from TDD step 2 needs this).
361
+ const userRows = rows.get("users") ?? [];
362
+ const partnerIds = [
363
+ ...new Set([
364
+ ...phase1PartnerIds,
365
+ ...userRows
366
+ .map((r) => m2oId(r.partner_id))
367
+ .filter((v) => v !== false),
368
+ ]),
369
+ ];
370
+ // Phase C: partner-keyed stores. Leads join here (not phase A) so the
371
+ // OR branch can correlate on partner_id — a lead may carry the person's
372
+ // partner instead of their email.
373
+ await Promise.all([
374
+ runGroup("leads", "crm.lead", () => searchRowsWithCount(client, "crm.lead", or([["email_from", "ilike", email]], partnerIds.length > 0 ? [["partner_id", "in", partnerIds]] : []), ["id", "name", "email_from", "partner_id", "active"], limit, includeArchived)),
375
+ runGroup("tickets", "helpdesk.ticket", () => searchRowsWithCount(client, "helpdesk.ticket", or(partnerIds.length > 0 ? [["partner_id", "in", partnerIds]] : [], [
376
+ ["partner_email", "ilike", email],
377
+ ]), ["id", "name", "partner_id", "partner_email", "team_id", "active"], limit, includeArchived)),
378
+ runGroup("invoices", "account.move", () => searchRowsWithCount(client, "account.move", partnerIds.length > 0
379
+ ? [["partner_id", "in", partnerIds]]
380
+ : [["id", "=", -1]], [
381
+ "id",
382
+ "name",
383
+ "invoice_date",
384
+ "state",
385
+ "move_type",
386
+ "partner_id",
387
+ "invoice_partner_display_name",
388
+ ], limit, includeArchived)),
389
+ runGroup("orders", "sale.order", () => searchRowsWithCount(client, "sale.order", or(partnerIds.length > 0 ? [["partner_id", "in", partnerIds]] : [], partnerIds.length > 0
390
+ ? [["partner_invoice_id", "in", partnerIds]]
391
+ : [], partnerIds.length > 0
392
+ ? [["partner_shipping_id", "in", partnerIds]]
393
+ : []), [
394
+ "id",
395
+ "name",
396
+ "state",
397
+ "partner_id",
398
+ "partner_invoice_id",
399
+ "partner_shipping_id",
400
+ "date_order",
401
+ ], limit, includeArchived)),
402
+ runGroup("payments", "account.payment", () => searchRowsWithCount(client, "account.payment", partnerIds.length > 0
403
+ ? [["partner_id", "in", partnerIds]]
404
+ : [["id", "=", -1]], ["id", "name", "state", "partner_id", "amount", "date"], limit, includeArchived)),
405
+ ]);
406
+ // Phase C2: contact verdicts — the §6.2.4 pre-flight on the RESTRICT
407
+ // surfaces. Computed per contact: posted account.move, any
408
+ // account.payment, or a confirmed/done sale.order referencing the
409
+ // partner through any of its three reference fields → anonymize,
410
+ // never unlink (invariant 1).
411
+ const invoiceRows = rows.get("invoices") ?? [];
412
+ const orderRows = rows.get("orders") ?? [];
413
+ const paymentRows = rows.get("payments") ?? [];
414
+ const referencedBy = [
415
+ [
416
+ "posted invoice (account.move)",
417
+ refPartnerIds(invoiceRows.filter((r) => r.state === "posted"), ["partner_id"]),
418
+ ],
419
+ ["payment (account.payment)", refPartnerIds(paymentRows, ["partner_id"])],
420
+ [
421
+ "confirmed order (sale.order)",
422
+ refPartnerIds(orderRows.filter((r) => r.state === "sale" || r.state === "done"), ["partner_id", "partner_invoice_id", "partner_shipping_id"]),
423
+ ],
424
+ ];
425
+ const referenceGroupsErrored = groupErrors.some((g) => ["invoices", "orders", "payments"].includes(g.group));
426
+ // Phase D: messages on/around the found records.
427
+ const ticketRows = rows.get("tickets") ?? [];
428
+ const leadRows = rows.get("leads") ?? [];
429
+ const ticketIds = ticketRows.map((r) => r.id);
430
+ const leadIds = leadRows.map((r) => r.id);
431
+ const authorIds = partnerIds;
432
+ await runGroup("messages", "mail.message", () => searchRowsWithCount(client, "mail.message", or([["email_from", "ilike", email]], authorIds.length > 0 ? [["author_id", "in", authorIds]] : [], ticketIds.length > 0
433
+ ? [
434
+ ["model", "=", "helpdesk.ticket"],
435
+ ["res_id", "in", ticketIds],
436
+ ]
437
+ : [], leadIds.length > 0
438
+ ? [
439
+ ["model", "=", "crm.lead"],
440
+ ["res_id", "in", leadIds],
441
+ ]
442
+ : [],
443
+ // notes ON the partners themselves — the database audit found
444
+ // provisioning notes ("Portal Access Granted (<email>)")
445
+ // authored by the service account on the partner record, which
446
+ // no email/author/parent branch reaches
447
+ partnerIds.length > 0
448
+ ? [
449
+ ["model", "=", "res.partner"],
450
+ ["res_id", "in", partnerIds],
451
+ ]
452
+ : []), ["id", "model", "res_id", "author_id", "email_from", "date", "body"], limit, includeArchived));
453
+ // Phase E: attachments — by name, and by (res_model, res_id) pairs on
454
+ // the parents found above (invariant 4: nothing cascades) — including
455
+ // the partners themselves: avatars are stored as ir.attachment rows on
456
+ // res.partner and are never removed by partner deletion.
457
+ const messageRows = rows.get("messages") ?? [];
458
+ const parentPairs = new Map();
459
+ for (const [model, groupRows] of [
460
+ ["helpdesk.ticket", ticketRows],
461
+ ["mail.message", messageRows],
462
+ ["crm.lead", leadRows],
463
+ ["res.partner", partnerRows],
464
+ ]) {
465
+ const ids = new Set(groupRows.map((r) => r.id));
466
+ if (ids.size > 0)
467
+ parentPairs.set(model, ids);
468
+ }
469
+ await runGroup("attachments", "ir.attachment", () => searchRowsWithCount(client, "ir.attachment", or([["name", "ilike", email]], ...[...parentPairs.entries()].map(([model, ids]) => [
470
+ ["res_model", "=", model],
471
+ ["res_id", "in", [...ids]],
472
+ ])), ["id", "name", "res_model", "res_id"], limit, includeArchived));
473
+ const attachmentRows = rows.get("attachments") ?? [];
474
+ // Phase F: resolve out-of-scope parents INLINE for `review` records so
475
+ // the output names the thing instead of pointing at it (bounded — see
476
+ // DETAIL_FETCH_CAP). a parent's name answers "what is this?" without a
477
+ // follow-up command; beyond the cap, records keep their inspectHint.
478
+ const detailRefs = new Map();
479
+ const refFor = (row) => [
480
+ (row.model ?? row.res_model),
481
+ (row.res_id ?? row.id),
482
+ ];
483
+ for (const row of messageRows) {
484
+ const [parentModel, parentId] = refFor(row);
485
+ const onTicket = parentModel === "helpdesk.ticket" && ticketIds.includes(parentId);
486
+ const onLead = parentModel === "crm.lead" && leadIds.includes(parentId);
487
+ const onPartner = parentModel === "res.partner" && partnerIds.includes(parentId);
488
+ if (!onTicket && !onLead && !onPartner && parentModel && parentId) {
489
+ const set = detailRefs.get(parentModel) ?? new Set();
490
+ set.add(parentId);
491
+ detailRefs.set(parentModel, set);
492
+ }
493
+ }
494
+ for (const row of attachmentRows) {
495
+ if (row.res_model !== "account.move" &&
496
+ !parentPairs.get(row.res_model)?.has(row.res_id)) {
497
+ const set = detailRefs.get(row.res_model) ?? new Set();
498
+ set.add(row.res_id);
499
+ detailRefs.set(row.res_model, set);
500
+ }
501
+ }
502
+ const DETAIL_FETCH_CAP = 25;
503
+ const detailNames = new Map(); // "model#id" → display name
504
+ const overCap = [...detailRefs.entries()].filter(([, ids]) => ids.size > DETAIL_FETCH_CAP);
505
+ if (overCap.length > 0) {
506
+ logVerbose(`skipping inline detail resolution for ${overCap.length} over-cap model(s)`, globals.verbose);
507
+ }
508
+ for (const [model, ids] of [...detailRefs.entries()].filter(([, ids]) => ids.size <= DETAIL_FETCH_CAP)) {
509
+ try {
510
+ // display_name is computed and safe; name may be absent on
511
+ // some models — intersect with fields_get
512
+ const meta = await ModelFields.load(client, model);
513
+ const fields = ["name", "display_name"].filter((f) => meta.get(f));
514
+ const parents = (await client.executeKw(model, "read", [[...ids]], fields.length > 0 ? { fields } : {}));
515
+ for (const p of parents) {
516
+ detailNames.set(`${model}#${p.id}`, String(p.display_name ?? p.name ?? `#${p.id}`));
517
+ }
518
+ }
519
+ catch {
520
+ // unreadable parent (record rules / missing module):
521
+ // records keep their inspectHint
522
+ }
523
+ }
524
+ // Assemble records with dispositions.
525
+ const records = [];
526
+ const summary = {};
527
+ const specByGroup = new Map(GROUPS.map((g) => [g.group, g]));
528
+ function push(group, row, disposition, reason, hint, inspectHint) {
529
+ const spec = specByGroup.get(group);
530
+ records.push({
531
+ group,
532
+ model: spec?.config.model ?? "",
533
+ id: row.id,
534
+ disposition,
535
+ reason,
536
+ fields: row,
537
+ ...(hint ? { commandHint: hint } : {}),
538
+ ...(inspectHint ? { inspectHint } : {}),
539
+ });
540
+ }
541
+ // partners — verdict computed (anonymize vs delete), roots always review.
542
+ const partnerDispById = new Map();
543
+ for (const row of partnerRows) {
544
+ const id = row.id;
545
+ let disposition;
546
+ let reason;
547
+ if (m2oId(row.parent_id) === false) {
548
+ disposition = "review";
549
+ reason =
550
+ "account root — sole-member/enskild-firma handling (Flow A step 4)";
551
+ }
552
+ else if (referenceGroupsErrored) {
553
+ disposition = "review";
554
+ reason =
555
+ "reference check unavailable (accounting group error) — run the §6.2.4 pre-flight before deleting";
556
+ }
557
+ else if (referencedBy.some(([, ids]) => ids.has(id))) {
558
+ disposition = "anonymize";
559
+ const sources = referencedBy
560
+ .filter(([, ids]) => ids.has(id))
561
+ .map(([label]) => label);
562
+ reason = `referenced by ${sources.join(", ")} — anonymize, never unlink (invariant 1)`;
563
+ }
564
+ else {
565
+ disposition = "delete";
566
+ reason =
567
+ "no RESTRICT references found — confirm with the §6.2.4 pre-flight before unlink";
568
+ }
569
+ const hint = disposition === "delete"
570
+ ? `odoo partners delete ${id} --dry-run`
571
+ : disposition === "anonymize"
572
+ ? `odoo partners update ${id} --set name='Raderad kontaktperson (ID ${id})' --set email=null --set phone=null --set function=null`
573
+ : undefined;
574
+ // step-4 decision inputs (child_ids/user_ids/vat) are fetched on the
575
+ // partners group itself, so the root review needs no follow-up command
576
+ partnerDispById.set(id, disposition);
577
+ push("partners", row, disposition, reason, hint);
578
+ }
579
+ // users — portal deletion is the SOP step 2; internal users are listed
580
+ // but never removable via the CLI.
581
+ for (const row of userRows) {
582
+ if (row.share === false) {
583
+ push("users", row, "review", "internal user (share=false) — never removed via the CLI");
584
+ }
585
+ else {
586
+ push("users", row, "delete", "portal sign-in identity — delete before partner unlink (res_users.partner_id is ondelete=restrict)", `odoo users delete ${row.id} --dry-run`);
587
+ }
588
+ }
589
+ for (const row of leadRows) {
590
+ push("leads", row, "delete", specByGroup.get("leads").reason, `odoo crm records delete ${row.id} --dry-run`);
591
+ }
592
+ for (const row of rows.get("mailingContacts") ?? []) {
593
+ push("mailingContacts", row, "delete", specByGroup.get("mailingContacts").reason, `odoo marketing contacts delete ${row.id} --dry-run`);
594
+ }
595
+ for (const row of rows.get("mailingTraces") ?? []) {
596
+ push("mailingTraces", row, "delete", specByGroup.get("mailingTraces").reason, `odoo marketing traces delete ${row.id} --dry-run`);
597
+ }
598
+ for (const row of ticketRows) {
599
+ push("tickets", row, "delete", specByGroup.get("tickets").reason, `odoo helpdesk tickets delete ${row.id} --dry-run`);
600
+ }
601
+ for (const row of messageRows) {
602
+ const parentModel = row.model;
603
+ const parentId = row.res_id;
604
+ const onTicket = parentModel === "helpdesk.ticket" && ticketIds.includes(parentId);
605
+ const onLead = parentModel === "crm.lead" && leadIds.includes(parentId);
606
+ if (onTicket) {
607
+ push("messages", row, "delete", `correspondence on to-be-deleted ticket #${parentId}`,
608
+ // ticket unlink removes its thread messages; the next runnable
609
+ // command is the parent's, not a per-message delete
610
+ `odoo helpdesk tickets delete ${parentId} --dry-run`);
611
+ }
612
+ else if (onLead) {
613
+ push("messages", row, "delete", `correspondence on to-be-deleted lead #${parentId}`, `odoo crm records delete ${parentId} --dry-run`);
614
+ }
615
+ else if (parentModel === "res.partner" && partnerIds.includes(parentId)) {
616
+ // provisioning/system notes ON the partner record itself — found
617
+ // in prod ("Portal Access Granted (<email>)"), authored by the
618
+ // service account, carrying the email. On a to-be-deleted partner
619
+ // the note dies with the unlink; on a kept shell (anonymize /
620
+ // review) it must be deleted explicitly.
621
+ const pd = partnerDispById.get(parentId);
622
+ if (pd === "delete") {
623
+ push("messages", row, "delete", `system note on to-be-deleted partner #${parentId}`, `odoo partners delete ${parentId} --dry-run`);
624
+ }
625
+ else {
626
+ push("messages", row, "delete", `system note on kept partner #${parentId} (shell/active) — carries the email; delete explicitly (partner unlink won't remove it)`);
627
+ }
628
+ }
629
+ else {
630
+ // e.g. project.task / calendar.event / mailing.mailing — parents
631
+ // outside the Flow A deletion set are never deleted by the SOP;
632
+ // deleting stray messages there is a human judgment call.
633
+ const detailKey = `${parentModel}#${parentId}`;
634
+ const name = detailNames.get(detailKey);
635
+ push("messages", row, "review", name
636
+ ? `correspondence on ${parentModel} #${parentId} (“${name}”) — parent is outside the erasure scope; verify before deleting`
637
+ : `correspondence on ${parentModel} #${parentId} — parent is outside the erasure scope; verify before deleting`, undefined,
638
+ // when the parent resolved inline the question is answered;
639
+ // only unfetched parents keep an inspect hint
640
+ name
641
+ ? undefined
642
+ : `odoo models read ${parentModel} --ids ${parentId} --json`);
643
+ }
644
+ }
645
+ for (const row of attachmentRows) {
646
+ const parentKey = `${row.res_model}#${row.res_id}`;
647
+ if (row.res_model === "account.move") {
648
+ push("attachments", row, "keep", "linked to account.move — bokföringsunderlag (7 years)");
649
+ }
650
+ else if (row.res_model === "res.partner") {
651
+ // partner avatars are personal data and NEVER cascade with the
652
+ // partner (invariant 4) — explicit delete whether the partner is
653
+ // being deleted or kept as an anonymized shell (step 4)
654
+ const pd = partnerDispById.get(row.res_id);
655
+ push("attachments", row, "delete", pd === "delete"
656
+ ? `attached to to-be-deleted partner #${row.res_id} — explicit delete (attachments never cascade)`
657
+ : `attached to kept partner #${row.res_id} (shell/active) — avatar is personal data; explicit delete (step 4)`, `odoo attachments delete ${row.id} --dry-run`);
658
+ }
659
+ else if (parentPairs.get(row.res_model)?.has(row.res_id)) {
660
+ push("attachments", row, "delete", `attached to ${row.res_model} #${row.res_id} — deletes with its parent`, `odoo attachments delete ${row.id} --dry-run`);
661
+ }
662
+ else {
663
+ const name = detailNames.get(parentKey);
664
+ push("attachments", row, "review", name
665
+ ? `name matches the email, parent (${parentKey}, “${name}”) is not in the footprint — verify ownership`
666
+ : `name matches the email, but parent (${parentKey}) is not in the footprint — verify ownership`, undefined, name ? undefined : `odoo attachments show ${row.id} --json`);
667
+ }
668
+ }
669
+ for (const [group, groupRows] of [
670
+ ["invoices", invoiceRows],
671
+ ["orders", orderRows],
672
+ ["payments", paymentRows],
673
+ ]) {
674
+ const reason = specByGroup.get(group)?.reason ?? "";
675
+ for (const row of groupRows) {
676
+ push(group, row, "keep", reason);
677
+ }
678
+ }
679
+ records.sort((a, b) => GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group) ||
680
+ a.id - b.id);
681
+ for (const g of GROUP_ORDER) {
682
+ summary[g] = records.filter((r) => r.group === g).length;
683
+ }
684
+ summary.total = records.length;
685
+ for (const g of GROUP_ORDER) {
686
+ if (groupTotals[g] === undefined || groupErrors.some((e) => e.group === g))
687
+ continue;
688
+ groupTotals[g] = Math.max(groupTotals[g], summary[g] ?? 0);
689
+ }
690
+ // search_count reflects the record-rule/active filter too; a gap vs the
691
+ // returned rows means truncation (--limit or a server-side cap)
692
+ const truncatedGroups = GROUP_ORDER.filter((g) => !groupErrors.some((e) => e.group === g) &&
693
+ groupTotals[g] > (summary[g] ?? 0));
694
+ if (globals.json) {
695
+ printJson(toJsonReport({
696
+ email,
697
+ generatedAt: new Date().toISOString(),
698
+ summary,
699
+ groupTotals,
700
+ truncatedGroups,
701
+ records,
702
+ groupErrors,
703
+ }));
704
+ return;
705
+ }
706
+ printFootprintTable({
707
+ email,
708
+ generatedAt: new Date().toISOString(),
709
+ summary,
710
+ groupTotals,
711
+ truncatedGroups,
712
+ records,
713
+ groupErrors,
714
+ }, globals);
715
+ }
716
+ function refPartnerIds(groupRows, fields) {
717
+ const out = new Set();
718
+ for (const row of groupRows) {
719
+ for (const field of fields) {
720
+ const id = m2oId(row[field]);
721
+ if (id !== false)
722
+ out.add(id);
723
+ }
724
+ }
725
+ return out;
726
+ }
727
+ /** Normalize one Odoo-native value for the structured JSON payload. */
728
+ function normalizeFieldValue(value) {
729
+ if (Array.isArray(value) &&
730
+ value.length === 2 &&
731
+ typeof value[0] === "number" &&
732
+ typeof value[1] === "string") {
733
+ return { id: value[0], name: value[1] };
734
+ }
735
+ // many2many: arrays of [id, name] tuples → arrays of {id, name}
736
+ if (Array.isArray(value) &&
737
+ value.length > 0 &&
738
+ value.every((v) => Array.isArray(v) &&
739
+ v.length === 2 &&
740
+ typeof v[0] === "number" &&
741
+ typeof v[1] === "string")) {
742
+ return value.map((v) => ({ id: v[0], name: v[1] }));
743
+ }
744
+ return value;
745
+ }
746
+ /** Build the structured per-group JSON payload from the flat assembly. */
747
+ function toJsonReport(flat) {
748
+ const groups = {};
749
+ for (const group of GROUP_ORDER) {
750
+ const spec = specByGroupLocal(group);
751
+ const error = flat.groupErrors.find((e) => e.group === group);
752
+ const groupRecords = flat.records.filter((r) => r.group === group);
753
+ const total = error
754
+ ? null
755
+ : Math.max(flat.groupTotals[group] ?? 0, groupRecords.length);
756
+ const truncated = !error && total !== null && total > groupRecords.length;
757
+ const dispositions = {
758
+ delete: 0,
759
+ anonymize: 0,
760
+ keep: 0,
761
+ review: 0,
762
+ };
763
+ for (const r of groupRecords)
764
+ dispositions[r.disposition]++;
765
+ groups[group] = {
766
+ model: spec.config.model,
767
+ found: groupRecords.length,
768
+ total,
769
+ truncated,
770
+ dispositions,
771
+ ...(error ? { error: error.message } : {}),
772
+ records: groupRecords.map((r) => {
773
+ const fields = {};
774
+ for (const [k, v] of Object.entries(r.fields)) {
775
+ fields[k] = normalizeFieldValue(v);
776
+ }
777
+ return {
778
+ id: r.id,
779
+ disposition: r.disposition,
780
+ reason: r.reason,
781
+ ...(r.commandHint ? { commandHint: r.commandHint } : {}),
782
+ ...(r.inspectHint ? { inspectHint: r.inspectHint } : {}),
783
+ fields,
784
+ };
785
+ }),
786
+ };
787
+ }
788
+ return {
789
+ email: flat.email,
790
+ generatedAt: flat.generatedAt,
791
+ totalRecords: flat.records.length,
792
+ truncatedGroups: GROUP_ORDER.filter((g) => groups[g].truncated),
793
+ runOrder: [...RUN_ORDER],
794
+ groups,
795
+ };
796
+ }
797
+ function printFootprintTable(report, globals) {
798
+ const out = process.stdout;
799
+ out.write(`Footprint for ${report.email} — ${report.summary.total} record(s)\n`);
800
+ out.write(`Suggested order: ${RUN_ORDER.join(" → ")} (keep-groups: verified, not run)\n`);
801
+ if (report.summary.total === 0 && report.groupErrors.length === 0) {
802
+ return;
803
+ }
804
+ for (const group of GROUP_ORDER) {
805
+ const spec = specByGroupLocal(group);
806
+ const groupRecords = report.records.filter((r) => r.group === group);
807
+ const groupError = report.groupErrors.find((g) => g.group === group);
808
+ out.write(`\n${group} (${spec.config.model})`);
809
+ if (groupError) {
810
+ out.write(` — ERROR: ${groupError.message}\n`);
811
+ continue;
812
+ }
813
+ if (report.truncatedGroups.includes(group)) {
814
+ out.write(` — TRUNCATED: showing ${groupRecords.length} of ${report.groupTotals[group]} (raise --limit)`);
815
+ }
816
+ out.write(`\n`);
817
+ if (groupRecords.length === 0)
818
+ continue;
819
+ const meta = groupRecords[0].fields;
820
+ const keys = Object.keys(meta).filter((k) => !(spec.tableExcludeFields ?? []).includes(k));
821
+ const headers = [...keys, "Disposition"];
822
+ const body = groupRecords.map((r) => [
823
+ ...keys.map((k) => humanCell(r.fields[k])),
824
+ r.disposition,
825
+ ]);
826
+ out.write(formatTable(headers, body, colorEnabled(globals.noColor)) + "\n");
827
+ for (const r of groupRecords) {
828
+ out.write(` #${r.id} ${r.disposition}: ${r.reason}\n`);
829
+ if (r.commandHint) {
830
+ out.write(` → ${r.commandHint}\n`);
831
+ }
832
+ if (r.inspectHint) {
833
+ out.write(` → (inspect) ${r.inspectHint}\n`);
834
+ }
835
+ }
836
+ }
837
+ }
838
+ function specByGroupLocal(group) {
839
+ const spec = GROUPS.find((g) => g.group === group);
840
+ if (!spec)
841
+ throw new Error(`unknown group ${group}`);
842
+ return spec;
843
+ }