@bergetai/odoo-cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -16
- package/dist/cli.js +125 -6
- package/dist/commands/crm.js +25 -3
- package/dist/commands/notes.js +191 -0
- package/dist/commands/project.js +190 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,14 @@ Agent-first CLI for the [Odoo External API](https://www.odoo.com/documentation/1
|
|
|
4
4
|
|
|
5
5
|
Every command is non-interactive, scriptable, and supports `--json` for stable machine-readable output.
|
|
6
6
|
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @bergetai/odoo-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Node.js 20 or later.
|
|
14
|
+
|
|
7
15
|
## Usage
|
|
8
16
|
|
|
9
17
|
```bash
|
|
@@ -15,30 +23,38 @@ odoo auth status --json
|
|
|
15
23
|
|
|
16
24
|
# Unauthenticated connectivity check
|
|
17
25
|
odoo version
|
|
26
|
+
|
|
27
|
+
# List and inspect records
|
|
28
|
+
odoo crm leads list --open --limit 20
|
|
29
|
+
odoo crm leads show 42
|
|
30
|
+
odoo helpdesk tickets list --team Support --json
|
|
31
|
+
odoo project list --json
|
|
32
|
+
|
|
33
|
+
# Mutations (support --dry-run)
|
|
34
|
+
odoo crm leads create --name "New lead" --email info@example.com
|
|
35
|
+
odoo crm leads update 42 --stage Won
|
|
36
|
+
odoo crm leads delete 42 --yes
|
|
37
|
+
|
|
38
|
+
# Internal notes (NEVER sends email to customer)
|
|
39
|
+
odoo helpdesk tickets add-note 398 --body "Internal note text"
|
|
40
|
+
odoo helpdesk tickets add-note 398 --body-file note.md
|
|
18
41
|
```
|
|
19
42
|
|
|
20
43
|
Configuration precedence: flags > environment (`ODOO_URL`, `ODOO_DB`, `ODOO_USERNAME`, `ODOO_API_KEY`) > `$XDG_CONFIG_HOME/odoo/config.json`. The API key is never accepted as a flag value.
|
|
21
44
|
|
|
22
45
|
Note: `odoo --version` prints the CLI's own version; `odoo version` queries the server.
|
|
23
46
|
|
|
24
|
-
##
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
npm install
|
|
28
|
-
npm run build # compile to dist/
|
|
29
|
-
npm test # build + unit/e2e tests against a fake XML-RPC server
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Status
|
|
47
|
+
## Features
|
|
33
48
|
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
49
|
+
- Read-only CRM surface (`crm leads list/show`) — default columns, `--fields`/`--all-fields`, `--limit`/`--offset`/`--order`, `--domain`/`--filter` with `fields_get` type checking, reference resolution (`--stage`/`--team`/`--assignee`/`--tag`), table/JSON output with `search_count` envelope
|
|
50
|
+
- Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
|
|
51
|
+
- Read-only Project surface (`project list/show`) — `--user` (project manager) and `--tag` filters, archived projects excluded by default, module-not-installed error (exit 4)
|
|
52
|
+
- 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`
|
|
53
|
+
- `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
|
|
54
|
+
- 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
|
|
55
|
+
- Config sources and precedence with atomic `0600` config writes, XML-RPC transport with timeouts and HTTPS enforcement, and an auth failure taxonomy (db missing / bad credentials / unreachable)
|
|
40
56
|
|
|
41
|
-
##
|
|
57
|
+
## Compatibility
|
|
42
58
|
|
|
43
59
|
Validated against a real Odoo 18.0+e instance. Schema realities discovered
|
|
44
60
|
against the live server:
|
|
@@ -52,3 +68,19 @@ against the live server:
|
|
|
52
68
|
- `execute_kw` kwargs must be a separate trailing dict param; packing it into
|
|
53
69
|
the positional-args array makes the server silently ignore it (this caused
|
|
54
70
|
`fields_get` to return full/empty structs).
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm install
|
|
76
|
+
npm run build # compile to dist/
|
|
77
|
+
npm test # build + unit/e2e tests against a fake XML-RPC server
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Contributing
|
|
81
|
+
|
|
82
|
+
Issues and pull requests are welcome at [github.com/berget-ai/odoo-cli](https://github.com/berget-ai/odoo-cli).
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
[MIT](LICENSE)
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,8 @@ import { Command, CommanderError } from "commander";
|
|
|
3
3
|
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
|
+
import { addNoteCommand, listMessagesCommand, } from "./commands/notes.js";
|
|
7
|
+
import { createTaskCommand, projectListCommand, projectShowCommand, } from "./commands/project.js";
|
|
6
8
|
import { versionCommand } from "./commands/version.js";
|
|
7
9
|
import { CliError } from "./errors.js";
|
|
8
10
|
import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
|
|
@@ -78,15 +80,18 @@ Examples:
|
|
|
78
80
|
await run(authStatusCommand, program);
|
|
79
81
|
});
|
|
80
82
|
const crm = program.command("crm").description("CRM app (crm.lead)");
|
|
81
|
-
const leads = crm
|
|
83
|
+
const leads = crm
|
|
84
|
+
.command("records")
|
|
85
|
+
.description("leads and opportunities (crm.lead)");
|
|
82
86
|
leads
|
|
83
87
|
.command("list")
|
|
84
|
-
.description("search and list leads with curated default columns")
|
|
88
|
+
.description("search and list leads/opportunities with curated default columns")
|
|
85
89
|
.option("--stage <name|id>", "filter by stage (resolved by name)")
|
|
86
90
|
.option("--team <name|id>", "filter by sales team (resolved by name)")
|
|
87
91
|
.option("--assignee <name|id>", "filter by salesperson (login, email, or name)")
|
|
88
92
|
.option("--tag <name|id>", "filter by tag; repeatable, AND-ed", collect, [])
|
|
89
93
|
.option("--priority <level>", "filter by priority: low, medium, high, urgent")
|
|
94
|
+
.option("--type <lead|opportunity>", "filter by record type (default: all)")
|
|
90
95
|
.option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
|
|
91
96
|
.option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
|
|
92
97
|
.option("--fields <a,b,c>", "columns to display")
|
|
@@ -98,7 +103,7 @@ Examples:
|
|
|
98
103
|
});
|
|
99
104
|
leads
|
|
100
105
|
.command("show")
|
|
101
|
-
.description("read one
|
|
106
|
+
.description("read one record")
|
|
102
107
|
.argument("<id>", "record id")
|
|
103
108
|
.option("--fields <a,b,c>", "fields to display")
|
|
104
109
|
.option("--all-fields", "display every readable field")
|
|
@@ -107,19 +112,19 @@ Examples:
|
|
|
107
112
|
});
|
|
108
113
|
addMutationFlags(leads
|
|
109
114
|
.command("create")
|
|
110
|
-
.description("create a
|
|
115
|
+
.description("create a record (preview with --dry-run)"), crmLeads).action(async (opts) => {
|
|
111
116
|
await run((g) => leadsCreateCommand(opts, g), program);
|
|
112
117
|
});
|
|
113
118
|
addMutationFlags(leads
|
|
114
119
|
.command("update")
|
|
115
|
-
.description("update
|
|
120
|
+
.description("update records (preview with --dry-run)"), crmLeads)
|
|
116
121
|
.argument("<id...>", "record ids")
|
|
117
122
|
.action(async (ids, opts) => {
|
|
118
123
|
await run((g) => leadsUpdateCommand(ids, opts, g), program);
|
|
119
124
|
});
|
|
120
125
|
leads
|
|
121
126
|
.command("delete")
|
|
122
|
-
.description("delete
|
|
127
|
+
.description("delete records (preview with --dry-run; unknown ids are skipped)")
|
|
123
128
|
.argument("<id...>", "record ids")
|
|
124
129
|
.action(async (ids) => {
|
|
125
130
|
await run((g) => leadsDeleteCommand(ids, g), program);
|
|
@@ -181,6 +186,120 @@ the raw form.`)
|
|
|
181
186
|
.action(async (ids) => {
|
|
182
187
|
await run((g) => ticketsDeleteCommand(ids, g), program);
|
|
183
188
|
});
|
|
189
|
+
tickets
|
|
190
|
+
.command("add-note")
|
|
191
|
+
.description("add an internal Log Note to a ticket (NEVER sends email to customer)")
|
|
192
|
+
.argument("<id>", "ticket id")
|
|
193
|
+
.option("--body <text>", "note body (HTML allowed)")
|
|
194
|
+
.option("--body-file <path>", "read note body from file ('-' for stdin)")
|
|
195
|
+
.addHelpText("after", `
|
|
196
|
+
IMPORTANT: This command creates INTERNAL notes only. These notes:
|
|
197
|
+
• Are NEVER sent to the customer via email
|
|
198
|
+
• Are only visible to internal users in Odoo
|
|
199
|
+
• Use subtype_id=2 (Note) and message_type=comment
|
|
200
|
+
• Cannot be used for customer-facing communication
|
|
201
|
+
|
|
202
|
+
This is by design to prevent accidental customer emails.
|
|
203
|
+
|
|
204
|
+
Examples:
|
|
205
|
+
$ odoo helpdesk tickets add-note 398 --body "Internal note text"
|
|
206
|
+
$ odoo helpdesk tickets add-note 398 --body-file note.md
|
|
207
|
+
$ cat note.md | odoo helpdesk tickets add-note 398 --body-file -`)
|
|
208
|
+
.action(async (id, opts) => {
|
|
209
|
+
await run((g) => addNoteCommand(id, opts, g), program);
|
|
210
|
+
});
|
|
211
|
+
tickets
|
|
212
|
+
.command("messages")
|
|
213
|
+
.description("list all messages for a ticket (emails, comments, notes)")
|
|
214
|
+
.argument("<id>", "ticket id")
|
|
215
|
+
.option("--limit <n>", "max messages to fetch", "50")
|
|
216
|
+
.option("--message-type <type>", "filter by message type (email, comment, notification)")
|
|
217
|
+
.option("--subtype <id>", "filter by subtype ID")
|
|
218
|
+
.option("--author <id>", "filter by author ID")
|
|
219
|
+
.option("--since <date>", "only messages after this date (YYYY-MM-DD HH:MM:SS)")
|
|
220
|
+
.addHelpText("after", `
|
|
221
|
+
Examples:
|
|
222
|
+
# List all messages for ticket 398
|
|
223
|
+
$ odoo helpdesk tickets messages 398
|
|
224
|
+
|
|
225
|
+
# List only emails from customer
|
|
226
|
+
$ odoo helpdesk tickets messages 398 --message-type email
|
|
227
|
+
|
|
228
|
+
# List only internal notes
|
|
229
|
+
$ odoo helpdesk tickets messages 398 --subtype 2
|
|
230
|
+
|
|
231
|
+
# List messages from last 7 days
|
|
232
|
+
$ odoo helpdesk tickets messages 398 --since "2026-08-14 00:00:00"
|
|
233
|
+
|
|
234
|
+
# JSON output for scripting
|
|
235
|
+
$ odoo helpdesk tickets messages 398 --json`)
|
|
236
|
+
.action(async (id, opts) => {
|
|
237
|
+
await run((g) => listMessagesCommand(id, opts, g), program);
|
|
238
|
+
});
|
|
239
|
+
const project = program
|
|
240
|
+
.command("project")
|
|
241
|
+
.description("Project app (project.project, project.task)");
|
|
242
|
+
project
|
|
243
|
+
.command("list")
|
|
244
|
+
.description("search and list projects with curated default columns")
|
|
245
|
+
.option("--user <name|id>", "filter by project manager (login, email, or name)")
|
|
246
|
+
.option("--tag <name|id>", "filter by tag; repeatable, AND-ed", collect, [])
|
|
247
|
+
.option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
|
|
248
|
+
.option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
|
|
249
|
+
.option("--fields <a,b,c>", "columns to display")
|
|
250
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
251
|
+
.option("--offset <n>", "skip the first n records", "0")
|
|
252
|
+
.option("--order <order>", 'sort order, e.g. "name asc"', undefined)
|
|
253
|
+
.action(async (opts) => {
|
|
254
|
+
await run((g) => projectListCommand(opts, g), program);
|
|
255
|
+
});
|
|
256
|
+
project
|
|
257
|
+
.command("show")
|
|
258
|
+
.description("read one project")
|
|
259
|
+
.argument("<id>", "project id")
|
|
260
|
+
.option("--fields <a,b,c>", "fields to display")
|
|
261
|
+
.option("--all-fields", "display every readable field")
|
|
262
|
+
.action(async (id, opts) => {
|
|
263
|
+
await run((g) => projectShowCommand(id, opts, g), program);
|
|
264
|
+
});
|
|
265
|
+
const tasks = project.command("tasks").description("project tasks");
|
|
266
|
+
tasks
|
|
267
|
+
.command("create")
|
|
268
|
+
.description("create a task in a project (preview with --dry-run)")
|
|
269
|
+
.requiredOption("--name <name>", "task name")
|
|
270
|
+
.requiredOption("--project <name|id>", "project name or ID")
|
|
271
|
+
.option("--stage <name|id>", "stage name or ID (within project)")
|
|
272
|
+
.option("--description <text>", "task description (HTML allowed)")
|
|
273
|
+
.option("--description-file <path>", "read description from file ('-' for stdin)")
|
|
274
|
+
.option("--tag <name|id>", "tag name or ID; repeatable, creates if missing", collect, [])
|
|
275
|
+
.option("--priority <level>", "priority: 0 (normal), 1 (high)")
|
|
276
|
+
.addHelpText("after", `
|
|
277
|
+
Examples:
|
|
278
|
+
# Create a simple task
|
|
279
|
+
$ odoo project tasks create --name "Fix bug" --project "Product Backlog"
|
|
280
|
+
|
|
281
|
+
# Create task in specific stage with tags
|
|
282
|
+
$ odoo project tasks create \\
|
|
283
|
+
--name "Bug: API timeout" \\
|
|
284
|
+
--project 58 \\
|
|
285
|
+
--stage "New / Ideas" \\
|
|
286
|
+
--tag Bug \\
|
|
287
|
+
--tag API \\
|
|
288
|
+
--description "Customer reports API timeout..."
|
|
289
|
+
|
|
290
|
+
# Create from file
|
|
291
|
+
$ odoo project tasks create \\
|
|
292
|
+
--name "Bug report from ticket #409" \\
|
|
293
|
+
--project "Product Backlog" \\
|
|
294
|
+
--stage "New / Ideas" \\
|
|
295
|
+
--tag Bug \\
|
|
296
|
+
--description-file bug-report.md
|
|
297
|
+
|
|
298
|
+
# Dry run
|
|
299
|
+
$ odoo project tasks create --name "Test" --project 58 --dry-run`)
|
|
300
|
+
.action(async (opts) => {
|
|
301
|
+
await run((g) => createTaskCommand(opts, g), program);
|
|
302
|
+
});
|
|
184
303
|
return program;
|
|
185
304
|
}
|
|
186
305
|
async function run(fn, program) {
|
package/dist/commands/crm.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { usageError } from "../errors.js";
|
|
1
2
|
import { createCommand, deleteCommand, parseDate, parseDecimal, parsePriorityFlag, parseProbability, updateCommand, } from "../mutation.js";
|
|
2
3
|
import { listCommand, showCommand, } from "../resource.js";
|
|
3
4
|
/** CRM create/update field→flag mapping (§5.3). */
|
|
4
5
|
const crmLeadsMutation = {
|
|
5
|
-
createPinnedValues: { type: "lead" },
|
|
6
6
|
flags: [
|
|
7
7
|
{
|
|
8
8
|
flags: "--name <name>",
|
|
@@ -115,6 +115,19 @@ const crmLeadsMutation = {
|
|
|
115
115
|
help: "Lead medium",
|
|
116
116
|
reference: { model: "utm.medium", label: "medium" },
|
|
117
117
|
},
|
|
118
|
+
{
|
|
119
|
+
flags: "--type <lead|opportunity>",
|
|
120
|
+
option: "type",
|
|
121
|
+
field: "type",
|
|
122
|
+
help: "Record type: lead (default) or opportunity",
|
|
123
|
+
parse: (raw, flagName) => {
|
|
124
|
+
const v = raw.toLowerCase();
|
|
125
|
+
if (v !== "lead" && v !== "opportunity") {
|
|
126
|
+
throw usageError(`invalid ${flagName} ${JSON.stringify(raw)}`, "expected: lead or opportunity");
|
|
127
|
+
}
|
|
128
|
+
return v;
|
|
129
|
+
},
|
|
130
|
+
},
|
|
118
131
|
{
|
|
119
132
|
flags: "--description <text>",
|
|
120
133
|
option: "description",
|
|
@@ -125,7 +138,7 @@ const crmLeadsMutation = {
|
|
|
125
138
|
};
|
|
126
139
|
export const crmLeads = {
|
|
127
140
|
model: "crm.lead",
|
|
128
|
-
showCommand: "odoo crm
|
|
141
|
+
showCommand: "odoo crm records show",
|
|
129
142
|
defaultListFields: [
|
|
130
143
|
"id",
|
|
131
144
|
"name",
|
|
@@ -161,7 +174,7 @@ export const crmLeads = {
|
|
|
161
174
|
"2": "High",
|
|
162
175
|
"3": "Very High",
|
|
163
176
|
},
|
|
164
|
-
pinnedDomain: [
|
|
177
|
+
pinnedDomain: [],
|
|
165
178
|
references: {
|
|
166
179
|
team: { model: "crm.team", label: "team", field: "team_id" },
|
|
167
180
|
stage: {
|
|
@@ -180,6 +193,15 @@ export const crmLeads = {
|
|
|
180
193
|
},
|
|
181
194
|
// Phase 3 (§4.4, §4.2, §5.4): dry-run plans for create/update/delete.
|
|
182
195
|
mutation: crmLeadsMutation,
|
|
196
|
+
// --type filter: lead (default), opportunity, or all
|
|
197
|
+
extraFilters: (opts) => {
|
|
198
|
+
const o = opts;
|
|
199
|
+
if (o.type === "lead" || o.type === "opportunity") {
|
|
200
|
+
return Promise.resolve([["type", "=", o.type]]);
|
|
201
|
+
}
|
|
202
|
+
// default: no filter (both leads and opportunities)
|
|
203
|
+
return Promise.resolve([]);
|
|
204
|
+
},
|
|
183
205
|
};
|
|
184
206
|
export function leadsListCommand(opts, globals) {
|
|
185
207
|
return listCommand(crmLeads, opts, globals);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { OdooClient } from "../client.js";
|
|
2
|
+
import { resolveConfig } from "../config.js";
|
|
3
|
+
import { notFoundError, usageError } from "../errors.js";
|
|
4
|
+
import { printJson, verbose as logVerbose } from "../output.js";
|
|
5
|
+
import { formatTable } from "../table.js";
|
|
6
|
+
export async function addNoteCommand(ticketId, opts, globals) {
|
|
7
|
+
const id = Number.parseInt(ticketId, 10);
|
|
8
|
+
if (Number.isNaN(id) || id <= 0) {
|
|
9
|
+
throw usageError(`invalid ticket id: ${ticketId}`, "provide a positive integer");
|
|
10
|
+
}
|
|
11
|
+
// Read body from file if specified
|
|
12
|
+
let body = opts.body;
|
|
13
|
+
if (opts.bodyFile) {
|
|
14
|
+
const fs = await import("node:fs/promises");
|
|
15
|
+
try {
|
|
16
|
+
body = await fs.readFile(opts.bodyFile, "utf-8");
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
throw usageError(`failed to read body file: ${opts.bodyFile}`, error instanceof Error ? error.message : String(error));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (!body || body.trim().length === 0) {
|
|
23
|
+
throw usageError("note body cannot be empty", "provide --body or --body-file");
|
|
24
|
+
}
|
|
25
|
+
const config = await resolveConfig(globals);
|
|
26
|
+
if (!config.url || !config.db || !config.username || !config.apiKey) {
|
|
27
|
+
throw usageError("missing configuration", "run 'odoo auth login' or set ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY");
|
|
28
|
+
}
|
|
29
|
+
const { parseDurationMs } = await import("../duration.js");
|
|
30
|
+
const timeoutMs = parseDurationMs(globals.timeout);
|
|
31
|
+
const client = new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
|
|
32
|
+
// Verify ticket exists
|
|
33
|
+
const tickets = (await client.executeKw("helpdesk.ticket", "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
|
|
34
|
+
if (!tickets || tickets.length === 0) {
|
|
35
|
+
throw notFoundError("ticket_not_found", `helpdesk ticket #${id} does not exist`, "check the ticket id");
|
|
36
|
+
}
|
|
37
|
+
// Create internal Log Note
|
|
38
|
+
// CRITICAL: These parameters ensure the note is INTERNAL ONLY
|
|
39
|
+
// - subtype_id=2: "Note" subtype (internal, not sent to customer)
|
|
40
|
+
// - message_type="comment": Internal comment (not email)
|
|
41
|
+
// - author_id: Use the authenticated user's partner_id
|
|
42
|
+
const noteParams = {
|
|
43
|
+
model: "helpdesk.ticket",
|
|
44
|
+
res_id: id,
|
|
45
|
+
body: body,
|
|
46
|
+
message_type: "comment",
|
|
47
|
+
subtype_id: 2, // Note subtype = internal only, NEVER sends email
|
|
48
|
+
};
|
|
49
|
+
if (globals.verbose) {
|
|
50
|
+
logVerbose(`Creating internal note for ticket #${id} (${tickets[0].name})`, globals.verbose);
|
|
51
|
+
logVerbose(`Note parameters: ${JSON.stringify(noteParams, null, 2)}`, globals.verbose);
|
|
52
|
+
}
|
|
53
|
+
if (globals.dryRun) {
|
|
54
|
+
const result = {
|
|
55
|
+
dry_run: true,
|
|
56
|
+
ticket_id: id,
|
|
57
|
+
ticket_name: tickets[0].name,
|
|
58
|
+
note_type: "internal_log_note",
|
|
59
|
+
will_send_email: false,
|
|
60
|
+
body_preview: body.substring(0, 100) + (body.length > 100 ? "..." : ""),
|
|
61
|
+
body_length: body.length,
|
|
62
|
+
};
|
|
63
|
+
if (globals.json) {
|
|
64
|
+
printJson(result);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
console.log(`[DRY RUN] Would create internal Log Note:`);
|
|
68
|
+
console.log(` Ticket: #${id} — ${tickets[0].name}`);
|
|
69
|
+
console.log(` Type: Internal note (never sent to customer)`);
|
|
70
|
+
console.log(` Body length: ${body.length} characters`);
|
|
71
|
+
console.log(` Body preview: ${result.body_preview}`);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const noteId = (await client.executeKw("mail.message", "create", [
|
|
76
|
+
[noteParams],
|
|
77
|
+
]));
|
|
78
|
+
const result = {
|
|
79
|
+
note_id: noteId,
|
|
80
|
+
ticket_id: id,
|
|
81
|
+
ticket_name: tickets[0].name,
|
|
82
|
+
note_type: "internal_log_note",
|
|
83
|
+
email_sent: false,
|
|
84
|
+
};
|
|
85
|
+
if (globals.json) {
|
|
86
|
+
printJson(result);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
console.log(`✓ Internal Log Note created (ID: ${noteId})`);
|
|
90
|
+
console.log(` Ticket: #${id} — ${tickets[0].name}`);
|
|
91
|
+
console.log(` Type: Internal note (not sent to customer)`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* List messages for a helpdesk ticket.
|
|
96
|
+
*
|
|
97
|
+
* Fetches all messages (emails, comments, notes) associated with a ticket.
|
|
98
|
+
* Useful for analyzing ticket history and determining if customer is waiting
|
|
99
|
+
* for a response.
|
|
100
|
+
*/
|
|
101
|
+
export async function listMessagesCommand(ticketId, opts, globals) {
|
|
102
|
+
const id = Number.parseInt(ticketId, 10);
|
|
103
|
+
if (Number.isNaN(id) || id <= 0) {
|
|
104
|
+
throw usageError(`invalid ticket id: ${ticketId}`, "provide a positive integer");
|
|
105
|
+
}
|
|
106
|
+
const config = await resolveConfig(globals);
|
|
107
|
+
if (!config.url || !config.db || !config.username || !config.apiKey) {
|
|
108
|
+
throw usageError("missing configuration", "run 'odoo auth login' or set ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY");
|
|
109
|
+
}
|
|
110
|
+
const { parseDurationMs } = await import("../duration.js");
|
|
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");
|
|
117
|
+
}
|
|
118
|
+
// Build domain for messages
|
|
119
|
+
const domain = [
|
|
120
|
+
["model", "=", "helpdesk.ticket"],
|
|
121
|
+
["res_id", "=", id],
|
|
122
|
+
];
|
|
123
|
+
// Add optional filters
|
|
124
|
+
if (opts.messageType) {
|
|
125
|
+
domain.push(["message_type", "=", opts.messageType]);
|
|
126
|
+
}
|
|
127
|
+
if (opts.subtype) {
|
|
128
|
+
const subtypeId = Number.parseInt(opts.subtype, 10);
|
|
129
|
+
if (!Number.isNaN(subtypeId)) {
|
|
130
|
+
domain.push(["subtype_id", "=", subtypeId]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (opts.author) {
|
|
134
|
+
const authorId = Number.parseInt(opts.author, 10);
|
|
135
|
+
if (!Number.isNaN(authorId)) {
|
|
136
|
+
domain.push(["author_id", "=", authorId]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (opts.since) {
|
|
140
|
+
domain.push(["date", ">=", opts.since]);
|
|
141
|
+
}
|
|
142
|
+
const limit = opts.limit ? Number.parseInt(opts.limit, 10) : 50;
|
|
143
|
+
if (globals.verbose) {
|
|
144
|
+
logVerbose(`Fetching messages for ticket #${id}`, globals.verbose);
|
|
145
|
+
logVerbose(`Domain: ${JSON.stringify(domain)}`, globals.verbose);
|
|
146
|
+
}
|
|
147
|
+
const messages = (await client.executeKw("mail.message", "search_read", [domain], {
|
|
148
|
+
fields: [
|
|
149
|
+
"id",
|
|
150
|
+
"date",
|
|
151
|
+
"author_id",
|
|
152
|
+
"body",
|
|
153
|
+
"message_type",
|
|
154
|
+
"subtype_id",
|
|
155
|
+
"email_from",
|
|
156
|
+
"partner_ids",
|
|
157
|
+
],
|
|
158
|
+
limit,
|
|
159
|
+
order: "date asc",
|
|
160
|
+
}));
|
|
161
|
+
if (globals.json) {
|
|
162
|
+
printJson({
|
|
163
|
+
ticket_id: id,
|
|
164
|
+
ticket_name: tickets[0].name,
|
|
165
|
+
message_count: messages.length,
|
|
166
|
+
messages,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
console.log(`Messages for ticket #${id}: ${tickets[0].name}`);
|
|
171
|
+
console.log(`Found ${messages.length} messages\n`);
|
|
172
|
+
const rows = messages.map((msg) => {
|
|
173
|
+
const author = msg.author_id ? msg.author_id[1] : msg.email_from || "System";
|
|
174
|
+
const subtype = msg.subtype_id ? msg.subtype_id[1] : "";
|
|
175
|
+
const bodyPreview = msg.body
|
|
176
|
+
.replace(/<[^>]*>/g, "") // Strip HTML
|
|
177
|
+
.replace(/\s+/g, " ")
|
|
178
|
+
.trim()
|
|
179
|
+
.substring(0, 60);
|
|
180
|
+
return [
|
|
181
|
+
msg.id.toString(),
|
|
182
|
+
msg.date,
|
|
183
|
+
msg.message_type,
|
|
184
|
+
subtype,
|
|
185
|
+
author,
|
|
186
|
+
bodyPreview + (msg.body.length > 60 ? "..." : ""),
|
|
187
|
+
];
|
|
188
|
+
});
|
|
189
|
+
console.log(formatTable(["ID", "Date", "Type", "Subtype", "Author", "Preview"], rows, !globals.noColor));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { OdooClient } from "../client.js";
|
|
2
|
+
import { resolveConfig } from "../config.js";
|
|
3
|
+
import { notFoundError, usageError } from "../errors.js";
|
|
4
|
+
import { printJson, verbose as logVerbose } from "../output.js";
|
|
5
|
+
import { listCommand, showCommand, } from "../resource.js";
|
|
6
|
+
/**
|
|
7
|
+
* Create a task in an Odoo project.
|
|
8
|
+
*
|
|
9
|
+
* Useful for converting support tickets into backlog items.
|
|
10
|
+
*/
|
|
11
|
+
export const projectProjects = {
|
|
12
|
+
model: "project.project",
|
|
13
|
+
showCommand: "odoo project show",
|
|
14
|
+
defaultListFields: ["id", "name", "user_id", "create_date"],
|
|
15
|
+
showExtraFields: ["partner_id", "tag_ids", "active", "write_date"],
|
|
16
|
+
resolutionHints: {
|
|
17
|
+
user_id: "--user",
|
|
18
|
+
tag_ids: "--tag",
|
|
19
|
+
},
|
|
20
|
+
priorityLabels: {},
|
|
21
|
+
pinnedDomain: [],
|
|
22
|
+
references: {
|
|
23
|
+
assignee: {
|
|
24
|
+
model: "res.users",
|
|
25
|
+
label: "project manager",
|
|
26
|
+
field: "user_id",
|
|
27
|
+
searchFields: ["login", "email", "name"],
|
|
28
|
+
},
|
|
29
|
+
tag: { model: "project.tags", label: "tag", field: "tag_ids" },
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
export function projectListCommand(opts, globals) {
|
|
33
|
+
// --user maps onto the engine's generic assignee reference (user_id)
|
|
34
|
+
return listCommand(projectProjects, { ...opts, assignee: opts.user }, globals);
|
|
35
|
+
}
|
|
36
|
+
export function projectShowCommand(idArg, opts, globals) {
|
|
37
|
+
return showCommand(projectProjects, idArg, opts, globals);
|
|
38
|
+
}
|
|
39
|
+
export async function createTaskCommand(opts, globals) {
|
|
40
|
+
if (!opts.name || opts.name.trim().length === 0) {
|
|
41
|
+
throw usageError("task name cannot be empty", "provide --name");
|
|
42
|
+
}
|
|
43
|
+
if (!opts.project) {
|
|
44
|
+
throw usageError("project is required", "provide --project <name|id>");
|
|
45
|
+
}
|
|
46
|
+
const config = await resolveConfig(globals);
|
|
47
|
+
if (!config.url || !config.db || !config.username || !config.apiKey) {
|
|
48
|
+
throw usageError("missing configuration", "run 'odoo auth login' or set ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY");
|
|
49
|
+
}
|
|
50
|
+
const { parseDurationMs } = await import("../duration.js");
|
|
51
|
+
const timeoutMs = parseDurationMs(globals.timeout);
|
|
52
|
+
const client = new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
|
|
53
|
+
// Resolve project ID
|
|
54
|
+
let projectId;
|
|
55
|
+
const projectIdNum = Number.parseInt(opts.project, 10);
|
|
56
|
+
if (!Number.isNaN(projectIdNum)) {
|
|
57
|
+
projectId = projectIdNum;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// Search by name
|
|
61
|
+
const projects = (await client.executeKw("project.project", "search_read", [[["name", "=", opts.project]]], { fields: ["id", "name"], limit: 1 }));
|
|
62
|
+
if (!projects || projects.length === 0) {
|
|
63
|
+
throw notFoundError("project_not_found", `project "${opts.project}" not found`, "check the project name or use project ID");
|
|
64
|
+
}
|
|
65
|
+
projectId = projects[0].id;
|
|
66
|
+
}
|
|
67
|
+
// Resolve stage ID if provided
|
|
68
|
+
let stageId;
|
|
69
|
+
if (opts.stage) {
|
|
70
|
+
const stageIdNum = Number.parseInt(opts.stage, 10);
|
|
71
|
+
if (!Number.isNaN(stageIdNum)) {
|
|
72
|
+
stageId = stageIdNum;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Search by name within project
|
|
76
|
+
const stages = (await client.executeKw("project.task.type", "search_read", [
|
|
77
|
+
[
|
|
78
|
+
["name", "=", opts.stage],
|
|
79
|
+
["project_ids", "in", [projectId]],
|
|
80
|
+
],
|
|
81
|
+
], { fields: ["id", "name"], limit: 1 }));
|
|
82
|
+
if (!stages || stages.length === 0) {
|
|
83
|
+
throw notFoundError("stage_not_found", `stage "${opts.stage}" not found in project ${projectId}`, "check the stage name or use stage ID");
|
|
84
|
+
}
|
|
85
|
+
stageId = stages[0].id;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Resolve tag IDs if provided
|
|
89
|
+
const tagIds = [];
|
|
90
|
+
if (opts.tag && opts.tag.length > 0) {
|
|
91
|
+
for (const tagName of opts.tag) {
|
|
92
|
+
const tagIdNum = Number.parseInt(tagName, 10);
|
|
93
|
+
if (!Number.isNaN(tagIdNum)) {
|
|
94
|
+
tagIds.push(tagIdNum);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// Search by name
|
|
98
|
+
const tags = (await client.executeKw("project.tags", "search_read", [[["name", "=", tagName]]], { fields: ["id", "name"], limit: 1 }));
|
|
99
|
+
if (tags && tags.length > 0) {
|
|
100
|
+
tagIds.push(tags[0].id);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// Create tag if it doesn't exist
|
|
104
|
+
if (globals.verbose) {
|
|
105
|
+
logVerbose(`Tag "${tagName}" not found, creating it`, globals.verbose);
|
|
106
|
+
}
|
|
107
|
+
const newTagId = (await client.executeKw("project.tags", "create", [
|
|
108
|
+
[{ name: tagName }],
|
|
109
|
+
]));
|
|
110
|
+
tagIds.push(newTagId);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Read description from file if specified
|
|
116
|
+
let description = opts.description || "";
|
|
117
|
+
if (opts.descriptionFile) {
|
|
118
|
+
const fs = await import("node:fs/promises");
|
|
119
|
+
try {
|
|
120
|
+
description = await fs.readFile(opts.descriptionFile, "utf-8");
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
throw usageError(`failed to read description file: ${opts.descriptionFile}`, error instanceof Error ? error.message : String(error));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Build task values
|
|
127
|
+
const taskValues = {
|
|
128
|
+
name: opts.name,
|
|
129
|
+
project_id: projectId,
|
|
130
|
+
description: description,
|
|
131
|
+
};
|
|
132
|
+
if (stageId) {
|
|
133
|
+
taskValues.stage_id = stageId;
|
|
134
|
+
}
|
|
135
|
+
if (tagIds.length > 0) {
|
|
136
|
+
taskValues.tag_ids = [[6, 0, tagIds]]; // Replace all tags
|
|
137
|
+
}
|
|
138
|
+
if (opts.priority) {
|
|
139
|
+
taskValues.priority = opts.priority;
|
|
140
|
+
}
|
|
141
|
+
if (globals.verbose) {
|
|
142
|
+
logVerbose(`Creating task: ${JSON.stringify(taskValues, null, 2)}`, globals.verbose);
|
|
143
|
+
}
|
|
144
|
+
if (globals.dryRun) {
|
|
145
|
+
const result = {
|
|
146
|
+
dry_run: true,
|
|
147
|
+
name: opts.name,
|
|
148
|
+
project_id: projectId,
|
|
149
|
+
stage_id: stageId,
|
|
150
|
+
tag_ids: tagIds,
|
|
151
|
+
description_length: description.length,
|
|
152
|
+
};
|
|
153
|
+
if (globals.json) {
|
|
154
|
+
printJson(result);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
console.log(`[DRY RUN] Would create task:`);
|
|
158
|
+
console.log(` Name: ${opts.name}`);
|
|
159
|
+
console.log(` Project: ${projectId}`);
|
|
160
|
+
if (stageId)
|
|
161
|
+
console.log(` Stage: ${stageId}`);
|
|
162
|
+
if (tagIds.length > 0)
|
|
163
|
+
console.log(` Tags: ${tagIds.join(", ")}`);
|
|
164
|
+
console.log(` Description: ${description.length} characters`);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const taskId = (await client.executeKw("project.task", "create", [
|
|
169
|
+
[taskValues],
|
|
170
|
+
]));
|
|
171
|
+
const result = {
|
|
172
|
+
task_id: taskId,
|
|
173
|
+
name: opts.name,
|
|
174
|
+
project_id: projectId,
|
|
175
|
+
stage_id: stageId,
|
|
176
|
+
tag_ids: tagIds,
|
|
177
|
+
};
|
|
178
|
+
if (globals.json) {
|
|
179
|
+
printJson(result);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
console.log(`✓ Task created (ID: ${taskId})`);
|
|
183
|
+
console.log(` Name: ${opts.name}`);
|
|
184
|
+
console.log(` Project: ${projectId}`);
|
|
185
|
+
if (stageId)
|
|
186
|
+
console.log(` Stage: ${stageId}`);
|
|
187
|
+
if (tagIds.length > 0)
|
|
188
|
+
console.log(` Tags: ${tagIds.join(", ")}`);
|
|
189
|
+
}
|
|
190
|
+
}
|