@bergetai/odoo-cli 0.3.0 → 0.3.3
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 +4 -0
- package/dist/cli.js +183 -2
- package/dist/commands/activities.js +239 -0
- package/dist/commands/crm.js +17 -0
- package/dist/commands/documents.js +402 -0
- package/dist/commands/notes.js +66 -49
- package/dist/mutation.js +8 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,9 @@ odoo helpdesk tickets list --team Support --json
|
|
|
31
31
|
odoo project list --json
|
|
32
32
|
odoo project tasks list --project "Content Pipeline" --open
|
|
33
33
|
odoo project tasks show 850
|
|
34
|
+
odoo documents tree --count
|
|
35
|
+
odoo documents records list --folder "Product Backlog" --limit 20
|
|
36
|
+
odoo documents records show 25
|
|
34
37
|
|
|
35
38
|
# Mutations (support --dry-run)
|
|
36
39
|
odoo crm leads create --name "New lead" --email info@example.com
|
|
@@ -52,6 +55,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
|
|
|
52
55
|
- Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
|
|
53
56
|
- Read-only Project surface (`project list/show`) — `--user` (project manager) and `--tag` filters, archived projects excluded by default, module-not-installed error (exit 4)
|
|
54
57
|
- Read-only Project task surface (`project tasks list/show`) — `--project` (required on list, resolved by name or id), project-scoped `--stage`, `--assignee` (many2many `user_ids`), `--tag`, `--priority` (0/Low, 1/High), `--open`/`--closed` via the task's `is_closed` flag, `--domain`/`--filter`, table/JSON output with `search_count` envelope, module-not-installed error (exit 4)
|
|
58
|
+
- Documents surface (`documents`) — `tree` (hierarchical folder listing with `--count`/`--depth`/`--folder`), `records list/show` (folders excluded by default, `--type folder|binary|url|all`, `--folder`/`--owner`/`--partner`/`--tag`), `folders list`, `records create` (folder via `--folder-type folder`, link via `--link <url>`), `records update` (move via `--folder`), `records delete`, and `upload` (local file → base64 `datas`, MIME guessed from extension, `--dry-run` previews without sending bytes). Folders are `documents.document` rows with `type='folder'`; the hierarchy is `folder_id`. Module-not-installed error (exit 4)
|
|
55
59
|
- Writes (`create`, `update`, `delete`) — single-call atomic batch `write`, client-side no-op diffing, delete convergence with `skipped` ids, post-failure "record may have been created" hint on `create`
|
|
56
60
|
- `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
|
|
57
61
|
- Internal notes (`add-note`) — creates internal Log Notes only (subtype_id=2, message_type=comment), **NEVER sends email to customer**, by design prevents accidental customer communication
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,9 @@ import { authLoginCommand, authStatusCommand } from "./commands/auth.js";
|
|
|
4
4
|
import { leadsCreateCommand, leadsDeleteCommand, leadsListCommand, leadsShowCommand, leadsUpdateCommand, crmLeads, } from "./commands/crm.js";
|
|
5
5
|
import { ticketsCreateCommand, ticketsDeleteCommand, ticketsListCommand, ticketsShowCommand, ticketsUpdateCommand, helpdeskTickets, } from "./commands/helpdesk.js";
|
|
6
6
|
import { addNoteCommand, listMessagesCommand, } from "./commands/notes.js";
|
|
7
|
+
import { activitiesListCommand, activityDoneCommand, scheduleCommand, } from "./commands/activities.js";
|
|
7
8
|
import { createTaskCommand, projectListCommand, projectShowCommand, tasksListCommand, tasksShowCommand, } from "./commands/project.js";
|
|
9
|
+
import { documentsDocuments, documentsListCommand, foldersListCommand, documentsShowCommand, documentsCreateCommand, documentsUpdateCommand, documentsDeleteCommand, treeCommand, uploadCommand, } from "./commands/documents.js";
|
|
8
10
|
import { versionCommand } from "./commands/version.js";
|
|
9
11
|
import { CliError } from "./errors.js";
|
|
10
12
|
import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
|
|
@@ -129,6 +131,100 @@ Examples:
|
|
|
129
131
|
.action(async (ids) => {
|
|
130
132
|
await run((g) => leadsDeleteCommand(ids, g), program);
|
|
131
133
|
});
|
|
134
|
+
leads
|
|
135
|
+
.command("add-note")
|
|
136
|
+
.description("add an internal Log Note to a lead/opportunity (NEVER sends email)")
|
|
137
|
+
.argument("<id>", "record id")
|
|
138
|
+
.option("--body <text>", "note body (HTML allowed)")
|
|
139
|
+
.option("--body-file <path>", "read note body from file ('-' for stdin)")
|
|
140
|
+
.addHelpText("after", `
|
|
141
|
+
IMPORTANT: This command creates INTERNAL notes only. These notes:
|
|
142
|
+
• Are NEVER sent to the customer via email
|
|
143
|
+
• Are only visible to internal users in Odoo
|
|
144
|
+
• Use subtype_id=2 (Note) and message_type=comment
|
|
145
|
+
• Cannot be used for customer-facing communication
|
|
146
|
+
|
|
147
|
+
This is by design to prevent accidental customer emails.
|
|
148
|
+
|
|
149
|
+
Examples:
|
|
150
|
+
$ odoo crm records add-note 338 --body "Internal note text"
|
|
151
|
+
$ odoo crm records add-note 338 --body-file note.md
|
|
152
|
+
$ cat note.md | odoo crm records add-note 338 --body-file -`)
|
|
153
|
+
.action(async (id, opts) => {
|
|
154
|
+
await run((g) => addNoteCommand(id, opts, g, "crm.lead"), program);
|
|
155
|
+
});
|
|
156
|
+
leads
|
|
157
|
+
.command("messages")
|
|
158
|
+
.description("list all messages for a record (emails, comments, notes)")
|
|
159
|
+
.argument("<id>", "record id")
|
|
160
|
+
.option("--limit <n>", "max messages to fetch", "50")
|
|
161
|
+
.option("--message-type <type>", "filter by message type (email, comment, notification)")
|
|
162
|
+
.option("--subtype <id>", "filter by subtype ID")
|
|
163
|
+
.option("--author <id>", "filter by author ID")
|
|
164
|
+
.option("--since <date>", "only messages after this date (YYYY-MM-DD HH:MM:SS)")
|
|
165
|
+
.addHelpText("after", `
|
|
166
|
+
Examples:
|
|
167
|
+
# List all messages for lead 338
|
|
168
|
+
$ odoo crm records messages 338
|
|
169
|
+
|
|
170
|
+
# List only internal notes
|
|
171
|
+
$ odoo crm records messages 338 --subtype 2
|
|
172
|
+
|
|
173
|
+
# JSON output for scripting
|
|
174
|
+
$ odoo crm records messages 338 --json`)
|
|
175
|
+
.action(async (id, opts) => {
|
|
176
|
+
await run((g) => listMessagesCommand(id, opts, g, "crm.lead"), program);
|
|
177
|
+
});
|
|
178
|
+
leads
|
|
179
|
+
.command("schedule")
|
|
180
|
+
.description("schedule a new activity on a record (preview with --dry-run)")
|
|
181
|
+
.argument("<id>", "record id")
|
|
182
|
+
.requiredOption("--type <name|id>", "activity type (e.g. To-Do, Call, Meeting)")
|
|
183
|
+
.requiredOption("--deadline <yyyy-mm-dd>", "activity deadline")
|
|
184
|
+
.requiredOption("--summary <text>", "short summary (max 1 sentence)")
|
|
185
|
+
.option("--note <text>", "optional note (1-2 lines, point to the log)")
|
|
186
|
+
.option("--assignee <name|id>", "assignee (login, email, or name; default: you)")
|
|
187
|
+
.addHelpText("after", `
|
|
188
|
+
A scheduled activity is the source of truth for a record's next step.
|
|
189
|
+
An opportunity without a scheduled activity is a red flag.
|
|
190
|
+
|
|
191
|
+
Examples:
|
|
192
|
+
$ odoo crm records schedule 338 --type "To-Do" --deadline 2026-08-25 \\
|
|
193
|
+
--summary "Send quote to Fredrik"
|
|
194
|
+
$ odoo crm records schedule 338 --type Meeting --deadline 2026-09-01 \\
|
|
195
|
+
--summary "Kickoff call" --assignee erik@berget.ai
|
|
196
|
+
$ odoo crm records schedule 338 --type "To-Do" --deadline 2026-08-25 \\
|
|
197
|
+
--summary "Send quote" --dry-run`)
|
|
198
|
+
.action(async (id, opts) => {
|
|
199
|
+
await run((g) => scheduleCommand(id, opts, g), program);
|
|
200
|
+
});
|
|
201
|
+
leads
|
|
202
|
+
.command("activities")
|
|
203
|
+
.description("list activities for a record")
|
|
204
|
+
.argument("<id>", "record id")
|
|
205
|
+
.option("--open", "only open (pending/planned) activities")
|
|
206
|
+
.option("--limit <n>", "max activities to fetch", "50")
|
|
207
|
+
.addHelpText("after", `
|
|
208
|
+
Examples:
|
|
209
|
+
$ odoo crm records activities 338
|
|
210
|
+
$ odoo crm records activities 338 --open --json`)
|
|
211
|
+
.action(async (id, opts) => {
|
|
212
|
+
await run((g) => activitiesListCommand(id, opts, g), program);
|
|
213
|
+
});
|
|
214
|
+
leads
|
|
215
|
+
.command("activity-done")
|
|
216
|
+
.description("mark activities as done via action_done (preview with --dry-run)")
|
|
217
|
+
.argument("<id...>", "activity ids")
|
|
218
|
+
.addHelpText("after", `
|
|
219
|
+
Uses action_done — the only reliable way to close an activity in Odoo
|
|
220
|
+
(writing state='done' directly does not work).
|
|
221
|
+
|
|
222
|
+
Examples:
|
|
223
|
+
$ odoo crm records activity-done 714
|
|
224
|
+
$ odoo crm records activity-done 714 721 --dry-run`)
|
|
225
|
+
.action(async (ids) => {
|
|
226
|
+
await run((g) => activityDoneCommand(ids, g), program);
|
|
227
|
+
});
|
|
132
228
|
const helpdesk = program
|
|
133
229
|
.command("helpdesk")
|
|
134
230
|
.description("Helpdesk app (helpdesk.ticket, Enterprise)");
|
|
@@ -206,7 +302,7 @@ Examples:
|
|
|
206
302
|
$ odoo helpdesk tickets add-note 398 --body-file note.md
|
|
207
303
|
$ cat note.md | odoo helpdesk tickets add-note 398 --body-file -`)
|
|
208
304
|
.action(async (id, opts) => {
|
|
209
|
-
await run((g) => addNoteCommand(id, opts, g), program);
|
|
305
|
+
await run((g) => addNoteCommand(id, opts, g, "helpdesk.ticket"), program);
|
|
210
306
|
});
|
|
211
307
|
tickets
|
|
212
308
|
.command("messages")
|
|
@@ -234,7 +330,7 @@ Examples:
|
|
|
234
330
|
# JSON output for scripting
|
|
235
331
|
$ odoo helpdesk tickets messages 398 --json`)
|
|
236
332
|
.action(async (id, opts) => {
|
|
237
|
-
await run((g) => listMessagesCommand(id, opts, g), program);
|
|
333
|
+
await run((g) => listMessagesCommand(id, opts, g, "helpdesk.ticket"), program);
|
|
238
334
|
});
|
|
239
335
|
const project = program
|
|
240
336
|
.command("project")
|
|
@@ -328,6 +424,91 @@ Examples:
|
|
|
328
424
|
.action(async (opts) => {
|
|
329
425
|
await run((g) => createTaskCommand(opts, g), program);
|
|
330
426
|
});
|
|
427
|
+
const documents = program
|
|
428
|
+
.command("documents")
|
|
429
|
+
.description("Documents app (documents.document, Enterprise)");
|
|
430
|
+
const records = documents
|
|
431
|
+
.command("records")
|
|
432
|
+
.description("files, links and folders");
|
|
433
|
+
records
|
|
434
|
+
.command("list")
|
|
435
|
+
.description("search and list documents (folders excluded by default)")
|
|
436
|
+
.option("--type <type>", "record type: document (default), folder, or all")
|
|
437
|
+
.option("--folder <name|id>", "only records directly in this folder (resolved by name)")
|
|
438
|
+
.option("--owner <name|id>", "filter by owner (login, email, or name)")
|
|
439
|
+
.option("--partner <name|id>", "filter by linked contact")
|
|
440
|
+
.option("--tag <name|id>", "filter by tag; repeatable, AND-ed", collect, [])
|
|
441
|
+
.option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
|
|
442
|
+
.option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
|
|
443
|
+
.option("--fields <a,b,c>", "columns to display")
|
|
444
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
445
|
+
.option("--offset <n>", "skip the first n records", "0")
|
|
446
|
+
.option("--order <order>", 'sort order, e.g. "create_date desc"', undefined)
|
|
447
|
+
.action(async (opts) => {
|
|
448
|
+
await run((g) => documentsListCommand(opts, g), program);
|
|
449
|
+
});
|
|
450
|
+
records
|
|
451
|
+
.command("show")
|
|
452
|
+
.description("read one document or folder")
|
|
453
|
+
.argument("<id>", "record id")
|
|
454
|
+
.option("--fields <a,b,c>", "fields to display")
|
|
455
|
+
.option("--all-fields", "display every readable field")
|
|
456
|
+
.action(async (id, opts) => {
|
|
457
|
+
await run((g) => documentsShowCommand(id, opts, g), program);
|
|
458
|
+
});
|
|
459
|
+
addMutationFlags(records
|
|
460
|
+
.command("create")
|
|
461
|
+
.description("create a folder or URL link (preview with --dry-run; upload files via 'documents upload')"), documentsDocuments).action(async (opts) => {
|
|
462
|
+
await run((g) => documentsCreateCommand(opts, g), program);
|
|
463
|
+
});
|
|
464
|
+
addMutationFlags(records
|
|
465
|
+
.command("update")
|
|
466
|
+
.description("update documents (preview with --dry-run)"), documentsDocuments)
|
|
467
|
+
.argument("<id...>", "record ids")
|
|
468
|
+
.action(async (ids, opts) => {
|
|
469
|
+
await run((g) => documentsUpdateCommand(ids, opts, g), program);
|
|
470
|
+
});
|
|
471
|
+
records
|
|
472
|
+
.command("delete")
|
|
473
|
+
.description("delete documents (preview with --dry-run; unknown ids are skipped)")
|
|
474
|
+
.argument("<id...>", "record ids")
|
|
475
|
+
.action(async (ids) => {
|
|
476
|
+
await run((g) => documentsDeleteCommand(ids, g), program);
|
|
477
|
+
});
|
|
478
|
+
const folders = documents.command("folders").description("folders");
|
|
479
|
+
folders
|
|
480
|
+
.command("list")
|
|
481
|
+
.description("list all folders")
|
|
482
|
+
.option("--fields <a,b,c>", "columns to display")
|
|
483
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
484
|
+
.option("--offset <n>", "skip the first n records", "0")
|
|
485
|
+
.option("--order <order>", 'sort order, e.g. "name asc"', undefined)
|
|
486
|
+
.action(async (opts) => {
|
|
487
|
+
await run((g) => foldersListCommand(opts, g), program);
|
|
488
|
+
});
|
|
489
|
+
documents
|
|
490
|
+
.command("tree")
|
|
491
|
+
.description("print the folder tree with document counts")
|
|
492
|
+
.option("--folder <name|id>", "start from this folder (default: roots)")
|
|
493
|
+
.option("--depth <n>", "max depth", "10")
|
|
494
|
+
.option("--count", "show the number of documents per folder")
|
|
495
|
+
.action(async (opts) => {
|
|
496
|
+
await run((g) => treeCommand(opts, g), program);
|
|
497
|
+
});
|
|
498
|
+
documents
|
|
499
|
+
.command("upload")
|
|
500
|
+
.description("upload a local file as a document (preview with --dry-run)")
|
|
501
|
+
.requiredOption("--file <path>", "local file path ('-' for stdin)")
|
|
502
|
+
.option("--name <name>", "document name (default: file basename)")
|
|
503
|
+
.option("--folder <name|id>", "target folder (resolved by name)")
|
|
504
|
+
.option("--owner <name|id>", "owner (login, email, or name)")
|
|
505
|
+
.option("--partner <name|id>", "linked contact")
|
|
506
|
+
.option("--tag <name|id>", "tag; repeatable, AND-ed", collect, [])
|
|
507
|
+
.option("--description <text>", "description")
|
|
508
|
+
.option("--mimetype <type>", "MIME type (default: guessed from extension)")
|
|
509
|
+
.action(async (opts) => {
|
|
510
|
+
await run((g) => uploadCommand(opts, g), program);
|
|
511
|
+
});
|
|
331
512
|
return program;
|
|
332
513
|
}
|
|
333
514
|
async function run(fn, program) {
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { OdooClient } from "../client.js";
|
|
2
|
+
import { resolveConfig } from "../config.js";
|
|
3
|
+
import { notFoundError, usageError } from "../errors.js";
|
|
4
|
+
import { parseDate } from "../mutation.js";
|
|
5
|
+
import { resolveReference } from "../model.js";
|
|
6
|
+
import { printJson, verbose as logVerbose } from "../output.js";
|
|
7
|
+
import { formatTable } from "../table.js";
|
|
8
|
+
/**
|
|
9
|
+
* Scheduled activities (mail.activity) for crm.lead records.
|
|
10
|
+
*
|
|
11
|
+
* A `mail.activity` is the source of truth for a lead's next step. It carries
|
|
12
|
+
* a type (To-Do, Call, Meeting, Email, ...), a deadline, an assignee, and a
|
|
13
|
+
* short note. `action_done` marks it complete (the only reliable way to close
|
|
14
|
+
* an activity — writing state='done' directly does not work in Odoo).
|
|
15
|
+
*/
|
|
16
|
+
const CRM_LEAD_MODEL = "crm.lead";
|
|
17
|
+
async function buildClient(globals) {
|
|
18
|
+
const config = await resolveConfig(globals);
|
|
19
|
+
if (!config.url || !config.db || !config.username || !config.apiKey) {
|
|
20
|
+
throw usageError("missing configuration", "run 'odoo auth login' or set ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY");
|
|
21
|
+
}
|
|
22
|
+
const { parseDurationMs } = await import("../duration.js");
|
|
23
|
+
const timeoutMs = parseDurationMs(globals.timeout);
|
|
24
|
+
return new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
|
|
25
|
+
}
|
|
26
|
+
/** Resolve the ir.model id for a technical model name (e.g. "crm.lead"). */
|
|
27
|
+
async function resolveResModelId(client, technicalName) {
|
|
28
|
+
const rows = (await client.executeKw("ir.model", "search_read", [[["model", "=", technicalName]]], { fields: ["id"], limit: 1 }));
|
|
29
|
+
if (rows.length === 0) {
|
|
30
|
+
throw notFoundError("model_not_found", `no ir.model for ${JSON.stringify(technicalName)}`);
|
|
31
|
+
}
|
|
32
|
+
return rows[0].id;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Schedule a new activity on a crm.lead record.
|
|
36
|
+
*/
|
|
37
|
+
export async function scheduleCommand(recordId, opts, globals) {
|
|
38
|
+
const id = Number.parseInt(recordId, 10);
|
|
39
|
+
if (Number.isNaN(id) || id <= 0) {
|
|
40
|
+
throw usageError(`invalid lead id: ${recordId}`, "provide a positive integer");
|
|
41
|
+
}
|
|
42
|
+
const client = await buildClient(globals);
|
|
43
|
+
// Verify the lead exists
|
|
44
|
+
const leads = (await client.executeKw(CRM_LEAD_MODEL, "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
|
|
45
|
+
if (!leads || leads.length === 0) {
|
|
46
|
+
throw notFoundError("record_not_found", `crm.lead #${id} does not exist`, "check the lead id");
|
|
47
|
+
}
|
|
48
|
+
// Resolve the activity type by name or id
|
|
49
|
+
const activityType = await resolveReference(client, "mail.activity.type", opts.type, {
|
|
50
|
+
label: "activity type",
|
|
51
|
+
});
|
|
52
|
+
// Resolve the assignee (defaults to the authenticated user)
|
|
53
|
+
let assigneeId;
|
|
54
|
+
let assigneeName;
|
|
55
|
+
if (opts.assignee) {
|
|
56
|
+
const user = await resolveReference(client, "res.users", opts.assignee, {
|
|
57
|
+
label: "user",
|
|
58
|
+
searchFields: ["login", "email", "name"],
|
|
59
|
+
});
|
|
60
|
+
assigneeId = user.id;
|
|
61
|
+
assigneeName = user.name;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// The authenticated user's own id is not directly exposed; resolve the
|
|
65
|
+
// current user via the uid returned by authenticate.
|
|
66
|
+
const uid = await client.authenticate();
|
|
67
|
+
const me = (await client.executeKw("res.users", "search_read", [[["id", "=", uid]]], { fields: ["id", "name"], limit: 1 }));
|
|
68
|
+
assigneeId = me[0]?.id ?? uid;
|
|
69
|
+
assigneeName = me[0]?.name ?? String(uid);
|
|
70
|
+
}
|
|
71
|
+
const resModelId = await resolveResModelId(client, CRM_LEAD_MODEL);
|
|
72
|
+
const deadline = parseDate(opts.deadline, "--deadline");
|
|
73
|
+
const values = {
|
|
74
|
+
res_model_id: resModelId,
|
|
75
|
+
res_id: id,
|
|
76
|
+
activity_type_id: activityType.id,
|
|
77
|
+
user_id: assigneeId,
|
|
78
|
+
date_deadline: deadline,
|
|
79
|
+
summary: opts.summary,
|
|
80
|
+
...(opts.note ? { note: opts.note } : {}),
|
|
81
|
+
};
|
|
82
|
+
if (globals.verbose) {
|
|
83
|
+
logVerbose(`Scheduling activity on crm.lead #${id} (${leads[0].name})`, globals.verbose);
|
|
84
|
+
logVerbose(`Activity values: ${JSON.stringify(values, null, 2)}`, globals.verbose);
|
|
85
|
+
}
|
|
86
|
+
if (globals.dryRun) {
|
|
87
|
+
if (globals.json) {
|
|
88
|
+
printJson({
|
|
89
|
+
model: "mail.activity",
|
|
90
|
+
action: "create",
|
|
91
|
+
dry_run: true,
|
|
92
|
+
record: { model: CRM_LEAD_MODEL, id, name: leads[0].name },
|
|
93
|
+
values,
|
|
94
|
+
resolved: {
|
|
95
|
+
activity_type_id: [activityType.id, activityType.name],
|
|
96
|
+
user_id: [assigneeId, assigneeName],
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.log(`[DRY RUN] Would schedule activity on crm.lead #${id} — ${leads[0].name}:`);
|
|
102
|
+
console.log(` Type: ${activityType.name} (${activityType.id})`);
|
|
103
|
+
console.log(` Assignee: ${assigneeName} (${assigneeId})`);
|
|
104
|
+
console.log(` Deadline: ${deadline}`);
|
|
105
|
+
console.log(` Summary: ${opts.summary}`);
|
|
106
|
+
if (opts.note)
|
|
107
|
+
console.log(` Note: ${opts.note}`);
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const activityId = (await client.executeKw("mail.activity", "create", [
|
|
112
|
+
values,
|
|
113
|
+
]));
|
|
114
|
+
if (globals.json) {
|
|
115
|
+
printJson({
|
|
116
|
+
model: "mail.activity",
|
|
117
|
+
action: "create",
|
|
118
|
+
dry_run: false,
|
|
119
|
+
id: activityId,
|
|
120
|
+
record: { model: CRM_LEAD_MODEL, id, name: leads[0].name },
|
|
121
|
+
resolved: {
|
|
122
|
+
activity_type_id: [activityType.id, activityType.name],
|
|
123
|
+
user_id: [assigneeId, assigneeName],
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
console.log(`✓ Activity scheduled (ID: ${activityId})`);
|
|
129
|
+
console.log(` Lead: crm.lead #${id} — ${leads[0].name}`);
|
|
130
|
+
console.log(` Type: ${activityType.name} · Deadline: ${deadline} · Assignee: ${assigneeName}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Mark one or more activities as done via `action_done`.
|
|
135
|
+
*/
|
|
136
|
+
export async function activityDoneCommand(activityIds, globals) {
|
|
137
|
+
const ids = activityIds.map((raw) => {
|
|
138
|
+
const n = Number.parseInt(raw, 10);
|
|
139
|
+
if (Number.isNaN(n) || n <= 0) {
|
|
140
|
+
throw usageError(`invalid activity id: ${raw}`, "provide a positive integer");
|
|
141
|
+
}
|
|
142
|
+
return n;
|
|
143
|
+
});
|
|
144
|
+
const client = await buildClient(globals);
|
|
145
|
+
// Verify the activities exist
|
|
146
|
+
const rows = (await client.executeKw("mail.activity", "search_read", [[["id", "in", ids]]], { fields: ["id", "summary", "state"], limit: ids.length }));
|
|
147
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
148
|
+
const missing = ids.filter((id) => !byId.has(id));
|
|
149
|
+
if (missing.length > 0) {
|
|
150
|
+
throw notFoundError("record_not_found", `no mail.activity record(s) with id(s): ${missing.join(", ")}`, "no activities were modified — aborts the whole batch when any id is missing");
|
|
151
|
+
}
|
|
152
|
+
if (globals.dryRun) {
|
|
153
|
+
if (globals.json) {
|
|
154
|
+
printJson({
|
|
155
|
+
model: "mail.activity",
|
|
156
|
+
action: "action_done",
|
|
157
|
+
dry_run: true,
|
|
158
|
+
ids,
|
|
159
|
+
activities: rows.map((r) => ({ id: r.id, summary: r.summary, state: r.state })),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
console.log(`[DRY RUN] Would mark ${ids.length} activity(ies) as done:`);
|
|
164
|
+
for (const r of rows) {
|
|
165
|
+
console.log(` #${r.id} — ${r.summary} (${r.state})`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
await client.executeKw("mail.activity", "action_done", [ids]);
|
|
171
|
+
if (globals.json) {
|
|
172
|
+
printJson({
|
|
173
|
+
model: "mail.activity",
|
|
174
|
+
action: "action_done",
|
|
175
|
+
dry_run: false,
|
|
176
|
+
done: ids,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
console.log(`✓ Marked ${ids.length} activity(ies) as done: ${ids.join(", ")}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* List activities for a crm.lead record.
|
|
185
|
+
*/
|
|
186
|
+
export async function activitiesListCommand(recordId, opts, globals) {
|
|
187
|
+
const id = Number.parseInt(recordId, 10);
|
|
188
|
+
if (Number.isNaN(id) || id <= 0) {
|
|
189
|
+
throw usageError(`invalid lead id: ${recordId}`, "provide a positive integer");
|
|
190
|
+
}
|
|
191
|
+
const client = await buildClient(globals);
|
|
192
|
+
// Verify the lead exists
|
|
193
|
+
const leads = (await client.executeKw(CRM_LEAD_MODEL, "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
|
|
194
|
+
if (!leads || leads.length === 0) {
|
|
195
|
+
throw notFoundError("record_not_found", `crm.lead #${id} does not exist`, "check the lead id");
|
|
196
|
+
}
|
|
197
|
+
const resModelId = await resolveResModelId(client, CRM_LEAD_MODEL);
|
|
198
|
+
const domain = [
|
|
199
|
+
["res_model_id", "=", resModelId],
|
|
200
|
+
["res_id", "=", id],
|
|
201
|
+
];
|
|
202
|
+
if (opts.open) {
|
|
203
|
+
domain.push(["state", "in", ["pending", "planned", "a_review"]]);
|
|
204
|
+
}
|
|
205
|
+
const limit = opts.limit ? Number.parseInt(opts.limit, 10) : 50;
|
|
206
|
+
const activities = (await client.executeKw("mail.activity", "search_read", [domain], {
|
|
207
|
+
fields: [
|
|
208
|
+
"id",
|
|
209
|
+
"activity_type_id",
|
|
210
|
+
"date_deadline",
|
|
211
|
+
"summary",
|
|
212
|
+
"note",
|
|
213
|
+
"state",
|
|
214
|
+
"user_id",
|
|
215
|
+
],
|
|
216
|
+
limit,
|
|
217
|
+
order: "date_deadline asc",
|
|
218
|
+
}));
|
|
219
|
+
if (globals.json) {
|
|
220
|
+
printJson({
|
|
221
|
+
model: "mail.activity",
|
|
222
|
+
record: { model: CRM_LEAD_MODEL, id, name: leads[0].name },
|
|
223
|
+
count: activities.length,
|
|
224
|
+
activities,
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
console.log(`Activities for crm.lead #${id}: ${leads[0].name}`);
|
|
229
|
+
console.log(`Found ${activities.length} activities\n`);
|
|
230
|
+
const rows = activities.map((a) => [
|
|
231
|
+
a.id.toString(),
|
|
232
|
+
a.state,
|
|
233
|
+
a.activity_type_id ? a.activity_type_id[1] : "",
|
|
234
|
+
a.date_deadline ? String(a.date_deadline) : "",
|
|
235
|
+
a.user_id ? a.user_id[1] : "",
|
|
236
|
+
a.summary,
|
|
237
|
+
]);
|
|
238
|
+
console.log(formatTable(["ID", "State", "Type", "Deadline", "Assignee", "Summary"], rows, !globals.noColor));
|
|
239
|
+
}
|
package/dist/commands/crm.js
CHANGED
|
@@ -134,7 +134,24 @@ const crmLeadsMutation = {
|
|
|
134
134
|
field: "description",
|
|
135
135
|
help: "Notes",
|
|
136
136
|
},
|
|
137
|
+
{
|
|
138
|
+
flags: "--lead-type <type>",
|
|
139
|
+
option: "leadType",
|
|
140
|
+
field: "x_studio_lead_type",
|
|
141
|
+
help: "Lead type: Inbound|Outbound|Not sure (default: Inbound)",
|
|
142
|
+
parse: (raw, flagName) => {
|
|
143
|
+
const v = raw.trim();
|
|
144
|
+
const valid = ["Inbound", "Outbound", "Not sure"];
|
|
145
|
+
if (!valid.includes(v)) {
|
|
146
|
+
throw usageError(`invalid ${flagName} ${JSON.stringify(raw)}`, `expected one of: ${valid.join(", ")}`);
|
|
147
|
+
}
|
|
148
|
+
return v;
|
|
149
|
+
},
|
|
150
|
+
},
|
|
137
151
|
],
|
|
152
|
+
createDefaultValues: {
|
|
153
|
+
x_studio_lead_type: "Inbound",
|
|
154
|
+
},
|
|
138
155
|
};
|
|
139
156
|
export const crmLeads = {
|
|
140
157
|
model: "crm.lead",
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, resolve } from "node:path";
|
|
4
|
+
import { usageError } from "../errors.js";
|
|
5
|
+
import { ModelFields, resolveReference } from "../model.js";
|
|
6
|
+
import { createCommand, deleteCommand, updateCommand, } from "../mutation.js";
|
|
7
|
+
import { colorEnabled, printJson } from "../output.js";
|
|
8
|
+
import { authenticatedClient, listCommand, showCommand, } from "../resource.js";
|
|
9
|
+
import { formatTable, humanCell } from "../table.js";
|
|
10
|
+
const FOLDER_DOMAIN = [["type", "=", "folder"]];
|
|
11
|
+
/** documents.document mutation flags (folders + binary files share the model). */
|
|
12
|
+
const documentsMutation = {
|
|
13
|
+
flags: [
|
|
14
|
+
{
|
|
15
|
+
flags: "--name <name>",
|
|
16
|
+
option: "name",
|
|
17
|
+
field: "name",
|
|
18
|
+
help: "Document or folder name",
|
|
19
|
+
requiredOnCreate: true,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
flags: "--folder <name|id>",
|
|
23
|
+
option: "folder",
|
|
24
|
+
field: "folder_id",
|
|
25
|
+
help: "Parent folder",
|
|
26
|
+
reference: { model: "documents.document", label: "folder" },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
flags: "--owner <name|id>",
|
|
30
|
+
option: "owner",
|
|
31
|
+
field: "owner_id",
|
|
32
|
+
help: "Owner (login, email, or name)",
|
|
33
|
+
reference: {
|
|
34
|
+
model: "res.users",
|
|
35
|
+
label: "user",
|
|
36
|
+
searchFields: ["login", "email", "name"],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
flags: "--partner <name|id>",
|
|
41
|
+
option: "partner",
|
|
42
|
+
field: "partner_id",
|
|
43
|
+
help: "Related partner",
|
|
44
|
+
reference: { model: "res.partner", label: "partner" },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
flags: "--tag <name|id>",
|
|
48
|
+
option: "tag",
|
|
49
|
+
field: "tag_ids",
|
|
50
|
+
help: "Tag; repeatable, replaces all tags",
|
|
51
|
+
multi: true,
|
|
52
|
+
reference: { model: "documents.tag", label: "tag" },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
flags: "--description <text>",
|
|
56
|
+
option: "description",
|
|
57
|
+
field: "description",
|
|
58
|
+
help: "Description",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
flags: "--link <url>",
|
|
62
|
+
option: "link",
|
|
63
|
+
field: "url",
|
|
64
|
+
help: "External URL (creates a link document)",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
flags: "--folder-type <folder>",
|
|
68
|
+
option: "folderType",
|
|
69
|
+
field: "type",
|
|
70
|
+
help: "Create a folder instead of a document (pass --folder-type folder)",
|
|
71
|
+
parse: (raw, flagName) => {
|
|
72
|
+
if (raw !== "folder") {
|
|
73
|
+
throw usageError(`invalid ${flagName} value ${JSON.stringify(raw)}`, "expected: folder");
|
|
74
|
+
}
|
|
75
|
+
return "folder";
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
export const documentsDocuments = {
|
|
81
|
+
model: "documents.document",
|
|
82
|
+
showCommand: "odoo documents records show",
|
|
83
|
+
defaultListFields: [
|
|
84
|
+
"id",
|
|
85
|
+
"name",
|
|
86
|
+
"type",
|
|
87
|
+
"folder_id",
|
|
88
|
+
"mimetype",
|
|
89
|
+
"file_size",
|
|
90
|
+
"owner_id",
|
|
91
|
+
"create_date",
|
|
92
|
+
],
|
|
93
|
+
showExtraFields: [
|
|
94
|
+
"partner_id",
|
|
95
|
+
"tag_ids",
|
|
96
|
+
"description",
|
|
97
|
+
"url",
|
|
98
|
+
"access_internal",
|
|
99
|
+
"access_via_link",
|
|
100
|
+
"access_url",
|
|
101
|
+
"is_editable_attachment",
|
|
102
|
+
"handler",
|
|
103
|
+
"active",
|
|
104
|
+
"shortcut_document_id",
|
|
105
|
+
"write_date",
|
|
106
|
+
],
|
|
107
|
+
resolutionHints: {
|
|
108
|
+
folder_id: "--folder",
|
|
109
|
+
owner_id: "--owner",
|
|
110
|
+
partner_id: "--partner",
|
|
111
|
+
tag_ids: "--tag",
|
|
112
|
+
},
|
|
113
|
+
priorityLabels: {},
|
|
114
|
+
pinnedDomain: [["type", "!=", "folder"]],
|
|
115
|
+
references: {
|
|
116
|
+
assignee: {
|
|
117
|
+
model: "res.users",
|
|
118
|
+
label: "user",
|
|
119
|
+
field: "owner_id",
|
|
120
|
+
searchFields: ["login", "email", "name"],
|
|
121
|
+
},
|
|
122
|
+
tag: { model: "documents.tag", label: "tag", field: "tag_ids" },
|
|
123
|
+
},
|
|
124
|
+
mutation: documentsMutation,
|
|
125
|
+
extraFilters: async (opts, client) => {
|
|
126
|
+
const o = opts;
|
|
127
|
+
const clauses = [];
|
|
128
|
+
if (o.folder !== undefined) {
|
|
129
|
+
clauses.push(["folder_id", "=", await resolveFolderId(client, o.folder)]);
|
|
130
|
+
}
|
|
131
|
+
if (o.type === "folder") {
|
|
132
|
+
clauses.push(["type", "=", "folder"]);
|
|
133
|
+
}
|
|
134
|
+
else if (o.type === "binary") {
|
|
135
|
+
clauses.push(["type", "=", "binary"]);
|
|
136
|
+
}
|
|
137
|
+
else if (o.type === "url") {
|
|
138
|
+
clauses.push(["type", "=", "url"]);
|
|
139
|
+
}
|
|
140
|
+
else if (o.type === "all") {
|
|
141
|
+
// no extra clause — but pinnedDomain excludes folders, so drop it
|
|
142
|
+
// (handled by caller clearing pinnedDomain)
|
|
143
|
+
}
|
|
144
|
+
return clauses;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* List folders only (type = 'folder'). Same list engine, pinned domain
|
|
149
|
+
* flipped to folders-only.
|
|
150
|
+
*/
|
|
151
|
+
export const documentsFolders = {
|
|
152
|
+
...documentsDocuments,
|
|
153
|
+
showCommand: "odoo documents folders show",
|
|
154
|
+
pinnedDomain: FOLDER_DOMAIN,
|
|
155
|
+
};
|
|
156
|
+
export const documentsAll = {
|
|
157
|
+
...documentsDocuments,
|
|
158
|
+
pinnedDomain: [],
|
|
159
|
+
};
|
|
160
|
+
export function documentsListCommand(opts, globals) {
|
|
161
|
+
const config = opts.type === "all"
|
|
162
|
+
? documentsAll
|
|
163
|
+
: opts.type === "folder"
|
|
164
|
+
? documentsFolders
|
|
165
|
+
: documentsDocuments;
|
|
166
|
+
return listCommand(config, opts, globals);
|
|
167
|
+
}
|
|
168
|
+
export function foldersListCommand(opts, globals) {
|
|
169
|
+
return listCommand(documentsFolders, opts, globals);
|
|
170
|
+
}
|
|
171
|
+
export function documentsShowCommand(idArg, opts, globals) {
|
|
172
|
+
return showCommand(documentsAll, idArg, opts, globals);
|
|
173
|
+
}
|
|
174
|
+
export function documentsCreateCommand(opts, globals) {
|
|
175
|
+
const config = opts.folderType === "folder" ? documentsAll : documentsDocuments;
|
|
176
|
+
return createCommand(config, opts, globals);
|
|
177
|
+
}
|
|
178
|
+
export function documentsUpdateCommand(idArgs, opts, globals) {
|
|
179
|
+
return updateCommand(documentsAll, idArgs, opts, globals);
|
|
180
|
+
}
|
|
181
|
+
export function documentsDeleteCommand(idArgs, globals) {
|
|
182
|
+
return deleteCommand(documentsAll, idArgs, globals);
|
|
183
|
+
}
|
|
184
|
+
export async function treeCommand(opts, globals) {
|
|
185
|
+
const client = await authenticatedClient(globals);
|
|
186
|
+
await ModelFields.load(client, "documents.document"); // module-installed check
|
|
187
|
+
const maxDepth = Number(opts.depth);
|
|
188
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 1) {
|
|
189
|
+
throw usageError(`invalid --depth value ${JSON.stringify(opts.depth)}`, "expected a positive integer");
|
|
190
|
+
}
|
|
191
|
+
// Root resolution: --folder given → that folder as root; otherwise roots.
|
|
192
|
+
let rootId = false;
|
|
193
|
+
let rootName = "Documents";
|
|
194
|
+
if (opts.folder !== undefined) {
|
|
195
|
+
rootId = await resolveFolderId(client, opts.folder);
|
|
196
|
+
const rows = (await client.executeKw("documents.document", "read", [
|
|
197
|
+
[rootId],
|
|
198
|
+
["name"],
|
|
199
|
+
]));
|
|
200
|
+
rootName = rows[0]?.name ?? rootName;
|
|
201
|
+
}
|
|
202
|
+
const [folders, docs] = await Promise.all([
|
|
203
|
+
client.executeKw("documents.document", "search_read", [FOLDER_DOMAIN], { fields: ["id", "name", "folder_id"], limit: 5000 }),
|
|
204
|
+
opts.count
|
|
205
|
+
? client.executeKw("documents.document", "search_read", [[["type", "!=", "folder"]]], { fields: ["id", "folder_id"], limit: 50000 })
|
|
206
|
+
: Promise.resolve([]),
|
|
207
|
+
]);
|
|
208
|
+
const countByFolder = new Map();
|
|
209
|
+
for (const d of docs) {
|
|
210
|
+
const fid = Array.isArray(d.folder_id) ? d.folder_id[0] : false;
|
|
211
|
+
if (fid !== false) {
|
|
212
|
+
countByFolder.set(fid, (countByFolder.get(fid) ?? 0) + 1);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const nodes = new Map();
|
|
216
|
+
for (const f of folders) {
|
|
217
|
+
nodes.set(f.id, {
|
|
218
|
+
id: f.id,
|
|
219
|
+
name: f.name,
|
|
220
|
+
parentId: Array.isArray(f.folder_id) ? f.folder_id[0] : false,
|
|
221
|
+
documentCount: countByFolder.get(f.id) ?? 0,
|
|
222
|
+
children: [],
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
for (const node of nodes.values()) {
|
|
226
|
+
if (node.parentId !== false) {
|
|
227
|
+
const parent = nodes.get(node.parentId);
|
|
228
|
+
if (parent)
|
|
229
|
+
parent.children.push(node);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Root set: explicit folder, or all folders without a parent in the map.
|
|
233
|
+
const roots = [];
|
|
234
|
+
if (rootId !== false) {
|
|
235
|
+
const root = nodes.get(rootId);
|
|
236
|
+
if (root)
|
|
237
|
+
roots.push(root);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
for (const node of nodes.values()) {
|
|
241
|
+
if (node.parentId === false || !nodes.has(node.parentId)) {
|
|
242
|
+
roots.push(node);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
roots.sort((a, b) => a.name.localeCompare(b.name));
|
|
247
|
+
if (globals.json) {
|
|
248
|
+
const toJson = (n) => ({
|
|
249
|
+
id: n.id,
|
|
250
|
+
name: n.name,
|
|
251
|
+
document_count: n.documentCount,
|
|
252
|
+
children: n.children
|
|
253
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
254
|
+
.map(toJson),
|
|
255
|
+
});
|
|
256
|
+
printJson({ root: rootName, tree: roots.map(toJson) });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const lines = [];
|
|
260
|
+
const renderRec = (node, prefix, depth) => {
|
|
261
|
+
const countSuffix = opts.count ? ` (${node.documentCount})` : "";
|
|
262
|
+
lines.push(`${prefix}${node.name}${countSuffix}`);
|
|
263
|
+
if (depth >= maxDepth)
|
|
264
|
+
return;
|
|
265
|
+
const children = [...node.children].sort((a, b) => a.name.localeCompare(b.name));
|
|
266
|
+
for (let i = 0; i < children.length; i++) {
|
|
267
|
+
const last = i === children.length - 1;
|
|
268
|
+
renderRec(children[i], prefix + (last ? "└── " : "├── "), depth + 1);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
for (const root of roots) {
|
|
272
|
+
renderRec(root, "", 1);
|
|
273
|
+
}
|
|
274
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
275
|
+
}
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// upload — local file → binary document
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
const MIME_BY_EXT = {
|
|
280
|
+
".txt": "text/plain",
|
|
281
|
+
".md": "text/markdown",
|
|
282
|
+
".csv": "text/csv",
|
|
283
|
+
".json": "application/json",
|
|
284
|
+
".pdf": "application/pdf",
|
|
285
|
+
".png": "image/png",
|
|
286
|
+
".jpg": "image/jpeg",
|
|
287
|
+
".jpeg": "image/jpeg",
|
|
288
|
+
".gif": "image/gif",
|
|
289
|
+
".webp": "image/webp",
|
|
290
|
+
".svg": "image/svg+xml",
|
|
291
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
292
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
293
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
294
|
+
".doc": "application/msword",
|
|
295
|
+
".xls": "application/vnd.ms-excel",
|
|
296
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
297
|
+
".zip": "application/zip",
|
|
298
|
+
".html": "text/html",
|
|
299
|
+
".xml": "application/xml",
|
|
300
|
+
};
|
|
301
|
+
export async function uploadCommand(opts, globals) {
|
|
302
|
+
const client = await authenticatedClient(globals);
|
|
303
|
+
const fieldsMeta = await ModelFields.load(client, "documents.document");
|
|
304
|
+
const filePath = resolve(opts.file);
|
|
305
|
+
let fileStat;
|
|
306
|
+
try {
|
|
307
|
+
fileStat = await stat(filePath);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
throw usageError(`file not found: ${opts.file}`);
|
|
311
|
+
}
|
|
312
|
+
if (!fileStat.isFile()) {
|
|
313
|
+
throw usageError(`not a regular file: ${opts.file}`);
|
|
314
|
+
}
|
|
315
|
+
const name = opts.name ?? basename(filePath);
|
|
316
|
+
const mimetype = opts.mimetype ??
|
|
317
|
+
MIME_BY_EXT[extname(filePath).toLowerCase()] ??
|
|
318
|
+
"application/octet-stream";
|
|
319
|
+
// Read + base64
|
|
320
|
+
const buffer = await readFile(filePath);
|
|
321
|
+
const datas = buffer.toString("base64");
|
|
322
|
+
const checksum = createHash("sha1").update(buffer).digest("hex");
|
|
323
|
+
const values = {
|
|
324
|
+
name,
|
|
325
|
+
mimetype,
|
|
326
|
+
file_size: fileStat.size,
|
|
327
|
+
checksum,
|
|
328
|
+
datas,
|
|
329
|
+
};
|
|
330
|
+
if (opts.folder !== undefined) {
|
|
331
|
+
values.folder_id = await resolveFolderId(client, opts.folder);
|
|
332
|
+
}
|
|
333
|
+
if (opts.owner !== undefined) {
|
|
334
|
+
const user = await resolveReference(client, "res.users", opts.owner, {
|
|
335
|
+
label: "user",
|
|
336
|
+
searchFields: ["login", "email", "name"],
|
|
337
|
+
});
|
|
338
|
+
values.owner_id = user.id;
|
|
339
|
+
}
|
|
340
|
+
if (opts.partner !== undefined) {
|
|
341
|
+
const partner = await resolveReference(client, "res.partner", opts.partner, { label: "partner" });
|
|
342
|
+
values.partner_id = partner.id;
|
|
343
|
+
}
|
|
344
|
+
if (opts.tag && opts.tag.length > 0) {
|
|
345
|
+
const ids = [];
|
|
346
|
+
for (const t of opts.tag) {
|
|
347
|
+
const tag = await resolveReference(client, "documents.tag", t, {
|
|
348
|
+
label: "tag",
|
|
349
|
+
});
|
|
350
|
+
ids.push(tag.id);
|
|
351
|
+
}
|
|
352
|
+
values.tag_ids = [[6, 0, ids]];
|
|
353
|
+
}
|
|
354
|
+
if (opts.description !== undefined) {
|
|
355
|
+
values.description = opts.description;
|
|
356
|
+
}
|
|
357
|
+
if (globals.dryRun) {
|
|
358
|
+
const preview = { ...values, datas: `<${buffer.length} bytes base64>` };
|
|
359
|
+
if (globals.json) {
|
|
360
|
+
printJson({
|
|
361
|
+
model: "documents.document",
|
|
362
|
+
action: "upload",
|
|
363
|
+
dry_run: true,
|
|
364
|
+
values: preview,
|
|
365
|
+
});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
process.stdout.write(`Would upload documents.document:\n` +
|
|
369
|
+
Object.entries(preview)
|
|
370
|
+
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
|
371
|
+
.join("\n") +
|
|
372
|
+
"\n");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const id = (await client.executeKw("documents.document", "create", [
|
|
376
|
+
values,
|
|
377
|
+
]));
|
|
378
|
+
if (globals.json) {
|
|
379
|
+
printJson({
|
|
380
|
+
model: "documents.document",
|
|
381
|
+
action: "upload",
|
|
382
|
+
dry_run: false,
|
|
383
|
+
id,
|
|
384
|
+
name,
|
|
385
|
+
mimetype,
|
|
386
|
+
file_size: fileStat.size,
|
|
387
|
+
});
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
391
|
+
const line = `Uploaded documents.document ${id} (${name})`;
|
|
392
|
+
process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
|
|
393
|
+
}
|
|
394
|
+
/** Resolve a folder by name or id (must be type=folder). */
|
|
395
|
+
async function resolveFolderId(client, input) {
|
|
396
|
+
const resolved = await resolveReference(client, "documents.document", input, {
|
|
397
|
+
label: "folder",
|
|
398
|
+
domain: FOLDER_DOMAIN,
|
|
399
|
+
});
|
|
400
|
+
return resolved.id;
|
|
401
|
+
}
|
|
402
|
+
export { formatTable, humanCell };
|
package/dist/commands/notes.js
CHANGED
|
@@ -3,12 +3,27 @@ import { resolveConfig } from "../config.js";
|
|
|
3
3
|
import { notFoundError, usageError } from "../errors.js";
|
|
4
4
|
import { printJson, verbose as logVerbose } from "../output.js";
|
|
5
5
|
import { formatTable } from "../table.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
const THREAD_MODELS = {
|
|
7
|
+
"helpdesk.ticket": { model: "helpdesk.ticket", label: "ticket" },
|
|
8
|
+
"crm.lead": { model: "crm.lead", label: "lead" },
|
|
9
|
+
};
|
|
10
|
+
function threadModel(model) {
|
|
11
|
+
const spec = THREAD_MODELS[model];
|
|
12
|
+
if (!spec) {
|
|
13
|
+
throw usageError(`unsupported thread model ${JSON.stringify(model)}`, `expected one of: ${Object.keys(THREAD_MODELS).join(", ")}`);
|
|
14
|
+
}
|
|
15
|
+
return spec;
|
|
16
|
+
}
|
|
17
|
+
async function buildClient(globals) {
|
|
18
|
+
const config = await resolveConfig(globals);
|
|
19
|
+
if (!config.url || !config.db || !config.username || !config.apiKey) {
|
|
20
|
+
throw usageError("missing configuration", "run 'odoo auth login' or set ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY");
|
|
10
21
|
}
|
|
11
|
-
|
|
22
|
+
const { parseDurationMs } = await import("../duration.js");
|
|
23
|
+
const timeoutMs = parseDurationMs(globals.timeout);
|
|
24
|
+
return new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
|
|
25
|
+
}
|
|
26
|
+
async function readBody(opts) {
|
|
12
27
|
let body = opts.body;
|
|
13
28
|
if (opts.bodyFile) {
|
|
14
29
|
const fs = await import("node:fs/promises");
|
|
@@ -22,39 +37,45 @@ export async function addNoteCommand(ticketId, opts, globals) {
|
|
|
22
37
|
if (!body || body.trim().length === 0) {
|
|
23
38
|
throw usageError("note body cannot be empty", "provide --body or --body-file");
|
|
24
39
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
return body;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Add an internal Log Note to a mail-thread record (ticket or lead).
|
|
44
|
+
*/
|
|
45
|
+
export async function addNoteCommand(recordId, opts, globals, model) {
|
|
46
|
+
const spec = threadModel(model);
|
|
47
|
+
const id = Number.parseInt(recordId, 10);
|
|
48
|
+
if (Number.isNaN(id) || id <= 0) {
|
|
49
|
+
throw usageError(`invalid ${spec.label} id: ${recordId}`, "provide a positive integer");
|
|
28
50
|
}
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
throw notFoundError("ticket_not_found", `helpdesk ticket #${id} does not exist`, "check the ticket id");
|
|
51
|
+
const body = await readBody(opts);
|
|
52
|
+
const client = await buildClient(globals);
|
|
53
|
+
// Verify the record exists
|
|
54
|
+
const rows = (await client.executeKw(spec.model, "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
|
|
55
|
+
if (!rows || rows.length === 0) {
|
|
56
|
+
throw notFoundError("record_not_found", `${spec.model} #${id} does not exist`, `check the ${spec.label} id`);
|
|
36
57
|
}
|
|
37
58
|
// Create internal Log Note
|
|
38
59
|
// CRITICAL: These parameters ensure the note is INTERNAL ONLY
|
|
39
60
|
// - subtype_id=2: "Note" subtype (internal, not sent to customer)
|
|
40
61
|
// - message_type="comment": Internal comment (not email)
|
|
41
|
-
// - author_id: Use the authenticated user's partner_id
|
|
42
62
|
const noteParams = {
|
|
43
|
-
model:
|
|
63
|
+
model: spec.model,
|
|
44
64
|
res_id: id,
|
|
45
65
|
body: body,
|
|
46
66
|
message_type: "comment",
|
|
47
67
|
subtype_id: 2, // Note subtype = internal only, NEVER sends email
|
|
48
68
|
};
|
|
49
69
|
if (globals.verbose) {
|
|
50
|
-
logVerbose(`Creating internal note for
|
|
70
|
+
logVerbose(`Creating internal note for ${spec.model} #${id} (${rows[0].name})`, globals.verbose);
|
|
51
71
|
logVerbose(`Note parameters: ${JSON.stringify(noteParams, null, 2)}`, globals.verbose);
|
|
52
72
|
}
|
|
53
73
|
if (globals.dryRun) {
|
|
54
74
|
const result = {
|
|
55
75
|
dry_run: true,
|
|
56
|
-
|
|
57
|
-
|
|
76
|
+
model: spec.model,
|
|
77
|
+
record_id: id,
|
|
78
|
+
record_name: rows[0].name,
|
|
58
79
|
note_type: "internal_log_note",
|
|
59
80
|
will_send_email: false,
|
|
60
81
|
body_preview: body.substring(0, 100) + (body.length > 100 ? "..." : ""),
|
|
@@ -65,7 +86,7 @@ export async function addNoteCommand(ticketId, opts, globals) {
|
|
|
65
86
|
}
|
|
66
87
|
else {
|
|
67
88
|
console.log(`[DRY RUN] Would create internal Log Note:`);
|
|
68
|
-
console.log(`
|
|
89
|
+
console.log(` Record: ${spec.model} #${id} — ${rows[0].name}`);
|
|
69
90
|
console.log(` Type: Internal note (never sent to customer)`);
|
|
70
91
|
console.log(` Body length: ${body.length} characters`);
|
|
71
92
|
console.log(` Body preview: ${result.body_preview}`);
|
|
@@ -77,8 +98,9 @@ export async function addNoteCommand(ticketId, opts, globals) {
|
|
|
77
98
|
]));
|
|
78
99
|
const result = {
|
|
79
100
|
note_id: noteId,
|
|
80
|
-
|
|
81
|
-
|
|
101
|
+
model: spec.model,
|
|
102
|
+
record_id: id,
|
|
103
|
+
record_name: rows[0].name,
|
|
82
104
|
note_type: "internal_log_note",
|
|
83
105
|
email_sent: false,
|
|
84
106
|
};
|
|
@@ -87,37 +109,31 @@ export async function addNoteCommand(ticketId, opts, globals) {
|
|
|
87
109
|
}
|
|
88
110
|
else {
|
|
89
111
|
console.log(`✓ Internal Log Note created (ID: ${noteId})`);
|
|
90
|
-
console.log(`
|
|
112
|
+
console.log(` Record: ${spec.model} #${id} — ${rows[0].name}`);
|
|
91
113
|
console.log(` Type: Internal note (not sent to customer)`);
|
|
92
114
|
}
|
|
93
115
|
}
|
|
94
116
|
/**
|
|
95
|
-
* List messages for a
|
|
117
|
+
* List messages for a mail-thread record (emails, comments, notes).
|
|
96
118
|
*
|
|
97
|
-
* Fetches all messages
|
|
98
|
-
*
|
|
99
|
-
* for a response.
|
|
119
|
+
* Fetches all messages associated with the record. Useful for analyzing
|
|
120
|
+
* history and determining if the customer is waiting for a response.
|
|
100
121
|
*/
|
|
101
|
-
export async function listMessagesCommand(
|
|
102
|
-
const
|
|
122
|
+
export async function listMessagesCommand(recordId, opts, globals, model) {
|
|
123
|
+
const spec = threadModel(model);
|
|
124
|
+
const id = Number.parseInt(recordId, 10);
|
|
103
125
|
if (Number.isNaN(id) || id <= 0) {
|
|
104
|
-
throw usageError(`invalid
|
|
126
|
+
throw usageError(`invalid ${spec.label} id: ${recordId}`, "provide a positive integer");
|
|
105
127
|
}
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const timeoutMs = parseDurationMs(globals.timeout);
|
|
112
|
-
const client = new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
|
|
113
|
-
// Verify ticket exists
|
|
114
|
-
const tickets = (await client.executeKw("helpdesk.ticket", "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
|
|
115
|
-
if (!tickets || tickets.length === 0) {
|
|
116
|
-
throw notFoundError("ticket_not_found", `helpdesk ticket #${id} does not exist`, "check the ticket id");
|
|
128
|
+
const client = await buildClient(globals);
|
|
129
|
+
// Verify the record exists
|
|
130
|
+
const rows = (await client.executeKw(spec.model, "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
|
|
131
|
+
if (!rows || rows.length === 0) {
|
|
132
|
+
throw notFoundError("record_not_found", `${spec.model} #${id} does not exist`, `check the ${spec.label} id`);
|
|
117
133
|
}
|
|
118
134
|
// Build domain for messages
|
|
119
135
|
const domain = [
|
|
120
|
-
["model", "=",
|
|
136
|
+
["model", "=", spec.model],
|
|
121
137
|
["res_id", "=", id],
|
|
122
138
|
];
|
|
123
139
|
// Add optional filters
|
|
@@ -141,7 +157,7 @@ export async function listMessagesCommand(ticketId, opts, globals) {
|
|
|
141
157
|
}
|
|
142
158
|
const limit = opts.limit ? Number.parseInt(opts.limit, 10) : 50;
|
|
143
159
|
if (globals.verbose) {
|
|
144
|
-
logVerbose(`Fetching messages for
|
|
160
|
+
logVerbose(`Fetching messages for ${spec.model} #${id}`, globals.verbose);
|
|
145
161
|
logVerbose(`Domain: ${JSON.stringify(domain)}`, globals.verbose);
|
|
146
162
|
}
|
|
147
163
|
const messages = (await client.executeKw("mail.message", "search_read", [domain], {
|
|
@@ -160,16 +176,17 @@ export async function listMessagesCommand(ticketId, opts, globals) {
|
|
|
160
176
|
}));
|
|
161
177
|
if (globals.json) {
|
|
162
178
|
printJson({
|
|
163
|
-
|
|
164
|
-
|
|
179
|
+
model: spec.model,
|
|
180
|
+
record_id: id,
|
|
181
|
+
record_name: rows[0].name,
|
|
165
182
|
message_count: messages.length,
|
|
166
183
|
messages,
|
|
167
184
|
});
|
|
168
185
|
}
|
|
169
186
|
else {
|
|
170
|
-
console.log(`Messages for
|
|
187
|
+
console.log(`Messages for ${spec.model} #${id}: ${rows[0].name}`);
|
|
171
188
|
console.log(`Found ${messages.length} messages\n`);
|
|
172
|
-
const
|
|
189
|
+
const rowsOut = messages.map((msg) => {
|
|
173
190
|
const author = msg.author_id ? msg.author_id[1] : msg.email_from || "System";
|
|
174
191
|
const subtype = msg.subtype_id ? msg.subtype_id[1] : "";
|
|
175
192
|
const bodyPreview = msg.body
|
|
@@ -186,6 +203,6 @@ export async function listMessagesCommand(ticketId, opts, globals) {
|
|
|
186
203
|
bodyPreview + (msg.body.length > 60 ? "..." : ""),
|
|
187
204
|
];
|
|
188
205
|
});
|
|
189
|
-
console.log(formatTable(["ID", "Date", "Type", "Subtype", "Author", "Preview"],
|
|
206
|
+
console.log(formatTable(["ID", "Date", "Type", "Subtype", "Author", "Preview"], rowsOut, !globals.noColor));
|
|
190
207
|
}
|
|
191
208
|
}
|
package/dist/mutation.js
CHANGED
|
@@ -40,13 +40,10 @@ export async function buildValues(client, config, spec, opts, mode, globals) {
|
|
|
40
40
|
const resolvedByOption = {};
|
|
41
41
|
const claim = (field, source) => {
|
|
42
42
|
const prev = sourceOf[field];
|
|
43
|
-
if (prev === undefined) {
|
|
43
|
+
if (prev === undefined || prev === "default") {
|
|
44
44
|
sourceOf[field] = source;
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
|
-
if (prev === "pinned") {
|
|
48
|
-
throw usageError(`field "${field}" is pinned by this collection and cannot be overridden`, `remove --${kebab(/* context only */ "")}: the ${config.model.split(".")[0]} surface fixes ${field}`);
|
|
49
|
-
}
|
|
50
47
|
throw usageError(`field "${field}" given by both ${prev} and ${source}`, "pass each field exactly once (SPEC §4.2)");
|
|
51
48
|
};
|
|
52
49
|
// 1. Pinned create values first (e.g. type=lead). They cannot be overridden
|
|
@@ -56,6 +53,13 @@ export async function buildValues(client, config, spec, opts, mode, globals) {
|
|
|
56
53
|
values[k] = v;
|
|
57
54
|
sourceOf[k] = "pinned";
|
|
58
55
|
}
|
|
56
|
+
// 1b. Default create values — can be overridden by explicit flags/--set/--values.
|
|
57
|
+
for (const [k, v] of Object.entries(spec.createDefaultValues ?? {})) {
|
|
58
|
+
if (values[k] === undefined) {
|
|
59
|
+
values[k] = v;
|
|
60
|
+
sourceOf[k] = "default";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
59
63
|
}
|
|
60
64
|
// 2. Exclusive groups (--partner vs --customer-*/--customer-name, §6.3).
|
|
61
65
|
for (const group of spec.exclusiveGroups ?? []) {
|