@bergetai/odoo-cli 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/cli.js +125 -2
- package/dist/commands/notes.js +99 -0
- package/dist/commands/project.js +409 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,6 +28,9 @@ odoo version
|
|
|
28
28
|
odoo crm leads list --open --limit 20
|
|
29
29
|
odoo crm leads show 42
|
|
30
30
|
odoo helpdesk tickets list --team Support --json
|
|
31
|
+
odoo project list --json
|
|
32
|
+
odoo project tasks list --project "Content Pipeline" --open
|
|
33
|
+
odoo project tasks show 850
|
|
31
34
|
|
|
32
35
|
# Mutations (support --dry-run)
|
|
33
36
|
odoo crm leads create --name "New lead" --email info@example.com
|
|
@@ -47,6 +50,8 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
|
|
|
47
50
|
|
|
48
51
|
- 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
|
|
49
52
|
- 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
|
+
- 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
|
+
- 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)
|
|
50
55
|
- 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`
|
|
51
56
|
- `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
|
|
52
57
|
- 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
|
|
@@ -66,6 +71,12 @@ against the live server:
|
|
|
66
71
|
- `execute_kw` kwargs must be a separate trailing dict param; packing it into
|
|
67
72
|
the positional-args array makes the server silently ignore it (this caused
|
|
68
73
|
`fields_get` to return full/empty structs).
|
|
74
|
+
- `project.task` has no `user_id` field; assignees are the many2many
|
|
75
|
+
`user_ids`. The `priority` selection is two-valued (0/Low, 1/High), not the
|
|
76
|
+
four-level scale used by CRM/Helpdesk. Tasks carry a direct `is_closed`
|
|
77
|
+
boolean, which `--open`/`--closed` filter on (authoritative, unlike stage
|
|
78
|
+
`fold`). Stages (`project.task.type`) link to projects via the many2many
|
|
79
|
+
`project_ids`, so `--stage` resolves within the `--project`.
|
|
69
80
|
|
|
70
81
|
## Development
|
|
71
82
|
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +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 } from "./commands/notes.js";
|
|
6
|
+
import { addNoteCommand, listMessagesCommand, } from "./commands/notes.js";
|
|
7
|
+
import { createTaskCommand, projectListCommand, projectShowCommand, tasksListCommand, tasksShowCommand, } from "./commands/project.js";
|
|
7
8
|
import { versionCommand } from "./commands/version.js";
|
|
8
9
|
import { CliError } from "./errors.js";
|
|
9
10
|
import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
|
|
@@ -79,7 +80,9 @@ Examples:
|
|
|
79
80
|
await run(authStatusCommand, program);
|
|
80
81
|
});
|
|
81
82
|
const crm = program.command("crm").description("CRM app (crm.lead)");
|
|
82
|
-
const leads = crm
|
|
83
|
+
const leads = crm
|
|
84
|
+
.command("records")
|
|
85
|
+
.description("leads and opportunities (crm.lead)");
|
|
83
86
|
leads
|
|
84
87
|
.command("list")
|
|
85
88
|
.description("search and list leads/opportunities with curated default columns")
|
|
@@ -205,6 +208,126 @@ Examples:
|
|
|
205
208
|
.action(async (id, opts) => {
|
|
206
209
|
await run((g) => addNoteCommand(id, opts, g), program);
|
|
207
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("list")
|
|
268
|
+
.description("search and list tasks in a project with curated columns")
|
|
269
|
+
.requiredOption("--project <name|id>", "project name or ID")
|
|
270
|
+
.option("--stage <name|id>", "filter by stage (resolved within the project)")
|
|
271
|
+
.option("--assignee <name|id>", "filter by assignee (login, email, or name)")
|
|
272
|
+
.option("--tag <name|id>", "filter by tag; repeatable, AND-ed", collect, [])
|
|
273
|
+
.option("--priority <level>", "filter by priority: 0 (low/normal) or 1 (high)")
|
|
274
|
+
.option("--open", "only open tasks (is_closed = false)")
|
|
275
|
+
.option("--closed", "only closed tasks (is_closed = true)")
|
|
276
|
+
.option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
|
|
277
|
+
.option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
|
|
278
|
+
.option("--fields <a,b,c>", "columns to display")
|
|
279
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
280
|
+
.option("--offset <n>", "skip the first n records", "0")
|
|
281
|
+
.option("--order <order>", 'sort order, e.g. "priority desc"', undefined)
|
|
282
|
+
.action(async (opts) => {
|
|
283
|
+
await run((g) => tasksListCommand(opts, g), program);
|
|
284
|
+
});
|
|
285
|
+
tasks
|
|
286
|
+
.command("show")
|
|
287
|
+
.description("read one task")
|
|
288
|
+
.argument("<id>", "task id")
|
|
289
|
+
.option("--fields <a,b,c>", "fields to display")
|
|
290
|
+
.option("--all-fields", "display every readable field")
|
|
291
|
+
.action(async (id, opts) => {
|
|
292
|
+
await run((g) => tasksShowCommand(id, opts, g), program);
|
|
293
|
+
});
|
|
294
|
+
tasks
|
|
295
|
+
.command("create")
|
|
296
|
+
.description("create a task in a project (preview with --dry-run)")
|
|
297
|
+
.requiredOption("--name <name>", "task name")
|
|
298
|
+
.requiredOption("--project <name|id>", "project name or ID")
|
|
299
|
+
.option("--stage <name|id>", "stage name or ID (within project)")
|
|
300
|
+
.option("--description <text>", "task description (HTML allowed)")
|
|
301
|
+
.option("--description-file <path>", "read description from file ('-' for stdin)")
|
|
302
|
+
.option("--tag <name|id>", "tag name or ID; repeatable, creates if missing", collect, [])
|
|
303
|
+
.option("--priority <level>", "priority: 0 (normal), 1 (high)")
|
|
304
|
+
.addHelpText("after", `
|
|
305
|
+
Examples:
|
|
306
|
+
# Create a simple task
|
|
307
|
+
$ odoo project tasks create --name "Fix bug" --project "Product Backlog"
|
|
308
|
+
|
|
309
|
+
# Create task in specific stage with tags
|
|
310
|
+
$ odoo project tasks create \\
|
|
311
|
+
--name "Bug: API timeout" \\
|
|
312
|
+
--project 58 \\
|
|
313
|
+
--stage "New / Ideas" \\
|
|
314
|
+
--tag Bug \\
|
|
315
|
+
--tag API \\
|
|
316
|
+
--description "Customer reports API timeout..."
|
|
317
|
+
|
|
318
|
+
# Create from file
|
|
319
|
+
$ odoo project tasks create \\
|
|
320
|
+
--name "Bug report from ticket #409" \\
|
|
321
|
+
--project "Product Backlog" \\
|
|
322
|
+
--stage "New / Ideas" \\
|
|
323
|
+
--tag Bug \\
|
|
324
|
+
--description-file bug-report.md
|
|
325
|
+
|
|
326
|
+
# Dry run
|
|
327
|
+
$ odoo project tasks create --name "Test" --project 58 --dry-run`)
|
|
328
|
+
.action(async (opts) => {
|
|
329
|
+
await run((g) => createTaskCommand(opts, g), program);
|
|
330
|
+
});
|
|
208
331
|
return program;
|
|
209
332
|
}
|
|
210
333
|
async function run(fn, program) {
|
package/dist/commands/notes.js
CHANGED
|
@@ -2,6 +2,7 @@ import { OdooClient } from "../client.js";
|
|
|
2
2
|
import { resolveConfig } from "../config.js";
|
|
3
3
|
import { notFoundError, usageError } from "../errors.js";
|
|
4
4
|
import { printJson, verbose as logVerbose } from "../output.js";
|
|
5
|
+
import { formatTable } from "../table.js";
|
|
5
6
|
export async function addNoteCommand(ticketId, opts, globals) {
|
|
6
7
|
const id = Number.parseInt(ticketId, 10);
|
|
7
8
|
if (Number.isNaN(id) || id <= 0) {
|
|
@@ -90,3 +91,101 @@ export async function addNoteCommand(ticketId, opts, globals) {
|
|
|
90
91
|
console.log(` Type: Internal note (not sent to customer)`);
|
|
91
92
|
}
|
|
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,409 @@
|
|
|
1
|
+
import { OdooClient } from "../client.js";
|
|
2
|
+
import { resolveConfig } from "../config.js";
|
|
3
|
+
import { notFoundError, usageError } from "../errors.js";
|
|
4
|
+
import { buildFilterClauses, parseDomain } from "../filter.js";
|
|
5
|
+
import { ModelFields, resolveReference } from "../model.js";
|
|
6
|
+
import { colorEnabled, printJson, verbose as logVerbose } from "../output.js";
|
|
7
|
+
import { authenticatedClient, listCommand, parseId, showCommand, } from "../resource.js";
|
|
8
|
+
import { formatKeyValues, formatTable, humanCell } from "../table.js";
|
|
9
|
+
/**
|
|
10
|
+
* Create a task in an Odoo project.
|
|
11
|
+
*
|
|
12
|
+
* Useful for converting support tickets into backlog items.
|
|
13
|
+
*/
|
|
14
|
+
export const projectProjects = {
|
|
15
|
+
model: "project.project",
|
|
16
|
+
showCommand: "odoo project show",
|
|
17
|
+
defaultListFields: ["id", "name", "user_id", "create_date"],
|
|
18
|
+
showExtraFields: ["partner_id", "tag_ids", "active", "write_date"],
|
|
19
|
+
resolutionHints: {
|
|
20
|
+
user_id: "--user",
|
|
21
|
+
tag_ids: "--tag",
|
|
22
|
+
},
|
|
23
|
+
priorityLabels: {},
|
|
24
|
+
pinnedDomain: [],
|
|
25
|
+
references: {
|
|
26
|
+
assignee: {
|
|
27
|
+
model: "res.users",
|
|
28
|
+
label: "project manager",
|
|
29
|
+
field: "user_id",
|
|
30
|
+
searchFields: ["login", "email", "name"],
|
|
31
|
+
},
|
|
32
|
+
tag: { model: "project.tags", label: "tag", field: "tag_ids" },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
export function projectListCommand(opts, globals) {
|
|
36
|
+
// --user maps onto the engine's generic assignee reference (user_id)
|
|
37
|
+
return listCommand(projectProjects, { ...opts, assignee: opts.user }, globals);
|
|
38
|
+
}
|
|
39
|
+
export function projectShowCommand(idArg, opts, globals) {
|
|
40
|
+
return showCommand(projectProjects, idArg, opts, globals);
|
|
41
|
+
}
|
|
42
|
+
export async function createTaskCommand(opts, globals) {
|
|
43
|
+
if (!opts.name || opts.name.trim().length === 0) {
|
|
44
|
+
throw usageError("task name cannot be empty", "provide --name");
|
|
45
|
+
}
|
|
46
|
+
if (!opts.project) {
|
|
47
|
+
throw usageError("project is required", "provide --project <name|id>");
|
|
48
|
+
}
|
|
49
|
+
const config = await resolveConfig(globals);
|
|
50
|
+
if (!config.url || !config.db || !config.username || !config.apiKey) {
|
|
51
|
+
throw usageError("missing configuration", "run 'odoo auth login' or set ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY");
|
|
52
|
+
}
|
|
53
|
+
const { parseDurationMs } = await import("../duration.js");
|
|
54
|
+
const timeoutMs = parseDurationMs(globals.timeout);
|
|
55
|
+
const client = new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
|
|
56
|
+
// Resolve project ID
|
|
57
|
+
let projectId;
|
|
58
|
+
const projectIdNum = Number.parseInt(opts.project, 10);
|
|
59
|
+
if (!Number.isNaN(projectIdNum)) {
|
|
60
|
+
projectId = projectIdNum;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// Search by name
|
|
64
|
+
const projects = (await client.executeKw("project.project", "search_read", [[["name", "=", opts.project]]], { fields: ["id", "name"], limit: 1 }));
|
|
65
|
+
if (!projects || projects.length === 0) {
|
|
66
|
+
throw notFoundError("project_not_found", `project "${opts.project}" not found`, "check the project name or use project ID");
|
|
67
|
+
}
|
|
68
|
+
projectId = projects[0].id;
|
|
69
|
+
}
|
|
70
|
+
// Resolve stage ID if provided
|
|
71
|
+
let stageId;
|
|
72
|
+
if (opts.stage) {
|
|
73
|
+
const stageIdNum = Number.parseInt(opts.stage, 10);
|
|
74
|
+
if (!Number.isNaN(stageIdNum)) {
|
|
75
|
+
stageId = stageIdNum;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
// Search by name within project
|
|
79
|
+
const stages = (await client.executeKw("project.task.type", "search_read", [
|
|
80
|
+
[
|
|
81
|
+
["name", "=", opts.stage],
|
|
82
|
+
["project_ids", "in", [projectId]],
|
|
83
|
+
],
|
|
84
|
+
], { fields: ["id", "name"], limit: 1 }));
|
|
85
|
+
if (!stages || stages.length === 0) {
|
|
86
|
+
throw notFoundError("stage_not_found", `stage "${opts.stage}" not found in project ${projectId}`, "check the stage name or use stage ID");
|
|
87
|
+
}
|
|
88
|
+
stageId = stages[0].id;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Resolve tag IDs if provided
|
|
92
|
+
const tagIds = [];
|
|
93
|
+
if (opts.tag && opts.tag.length > 0) {
|
|
94
|
+
for (const tagName of opts.tag) {
|
|
95
|
+
const tagIdNum = Number.parseInt(tagName, 10);
|
|
96
|
+
if (!Number.isNaN(tagIdNum)) {
|
|
97
|
+
tagIds.push(tagIdNum);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Search by name
|
|
101
|
+
const tags = (await client.executeKw("project.tags", "search_read", [[["name", "=", tagName]]], { fields: ["id", "name"], limit: 1 }));
|
|
102
|
+
if (tags && tags.length > 0) {
|
|
103
|
+
tagIds.push(tags[0].id);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// Create tag if it doesn't exist
|
|
107
|
+
if (globals.verbose) {
|
|
108
|
+
logVerbose(`Tag "${tagName}" not found, creating it`, globals.verbose);
|
|
109
|
+
}
|
|
110
|
+
const newTagId = (await client.executeKw("project.tags", "create", [
|
|
111
|
+
[{ name: tagName }],
|
|
112
|
+
]));
|
|
113
|
+
tagIds.push(newTagId);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Read description from file if specified
|
|
119
|
+
let description = opts.description || "";
|
|
120
|
+
if (opts.descriptionFile) {
|
|
121
|
+
const fs = await import("node:fs/promises");
|
|
122
|
+
try {
|
|
123
|
+
description = await fs.readFile(opts.descriptionFile, "utf-8");
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
throw usageError(`failed to read description file: ${opts.descriptionFile}`, error instanceof Error ? error.message : String(error));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Build task values
|
|
130
|
+
const taskValues = {
|
|
131
|
+
name: opts.name,
|
|
132
|
+
project_id: projectId,
|
|
133
|
+
description: description,
|
|
134
|
+
};
|
|
135
|
+
if (stageId) {
|
|
136
|
+
taskValues.stage_id = stageId;
|
|
137
|
+
}
|
|
138
|
+
if (tagIds.length > 0) {
|
|
139
|
+
taskValues.tag_ids = [[6, 0, tagIds]]; // Replace all tags
|
|
140
|
+
}
|
|
141
|
+
if (opts.priority) {
|
|
142
|
+
taskValues.priority = opts.priority;
|
|
143
|
+
}
|
|
144
|
+
if (globals.verbose) {
|
|
145
|
+
logVerbose(`Creating task: ${JSON.stringify(taskValues, null, 2)}`, globals.verbose);
|
|
146
|
+
}
|
|
147
|
+
if (globals.dryRun) {
|
|
148
|
+
const result = {
|
|
149
|
+
dry_run: true,
|
|
150
|
+
name: opts.name,
|
|
151
|
+
project_id: projectId,
|
|
152
|
+
stage_id: stageId,
|
|
153
|
+
tag_ids: tagIds,
|
|
154
|
+
description_length: description.length,
|
|
155
|
+
};
|
|
156
|
+
if (globals.json) {
|
|
157
|
+
printJson(result);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
console.log(`[DRY RUN] Would create task:`);
|
|
161
|
+
console.log(` Name: ${opts.name}`);
|
|
162
|
+
console.log(` Project: ${projectId}`);
|
|
163
|
+
if (stageId)
|
|
164
|
+
console.log(` Stage: ${stageId}`);
|
|
165
|
+
if (tagIds.length > 0)
|
|
166
|
+
console.log(` Tags: ${tagIds.join(", ")}`);
|
|
167
|
+
console.log(` Description: ${description.length} characters`);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const taskId = (await client.executeKw("project.task", "create", [
|
|
172
|
+
[taskValues],
|
|
173
|
+
]));
|
|
174
|
+
const result = {
|
|
175
|
+
task_id: taskId,
|
|
176
|
+
name: opts.name,
|
|
177
|
+
project_id: projectId,
|
|
178
|
+
stage_id: stageId,
|
|
179
|
+
tag_ids: tagIds,
|
|
180
|
+
};
|
|
181
|
+
if (globals.json) {
|
|
182
|
+
printJson(result);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
console.log(`✓ Task created (ID: ${taskId})`);
|
|
186
|
+
console.log(` Name: ${opts.name}`);
|
|
187
|
+
console.log(` Project: ${projectId}`);
|
|
188
|
+
if (stageId)
|
|
189
|
+
console.log(` Stage: ${stageId}`);
|
|
190
|
+
if (tagIds.length > 0)
|
|
191
|
+
console.log(` Tags: ${tagIds.join(", ")}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* project.task diverges from the shared list/show engine (resource.ts) in
|
|
196
|
+
* three ways that make a custom command cleaner than engine surgery:
|
|
197
|
+
* - assignee is user_ids (many2many), not user_id (many2one)
|
|
198
|
+
* - priority is a 2-value selection (0/Low, 1/High), not the engine's
|
|
199
|
+
* hardcoded low/medium/high/urgent map
|
|
200
|
+
* - stages scope via project_ids (many2many) on project.task.type, not
|
|
201
|
+
* team_id/team_ids
|
|
202
|
+
* The resolution/ordering mirrors listCommand (resource.ts) and the project
|
|
203
|
+
* resolution mirrors createTaskCommand above.
|
|
204
|
+
*/
|
|
205
|
+
const TASK_DEFAULT_LIST_FIELDS = [
|
|
206
|
+
"id",
|
|
207
|
+
"name",
|
|
208
|
+
"stage_id",
|
|
209
|
+
"user_ids",
|
|
210
|
+
"priority",
|
|
211
|
+
"date_deadline",
|
|
212
|
+
"is_closed",
|
|
213
|
+
];
|
|
214
|
+
const TASK_SHOW_EXTRA_FIELDS = [
|
|
215
|
+
"description",
|
|
216
|
+
"partner_id",
|
|
217
|
+
"tag_ids",
|
|
218
|
+
"parent_id",
|
|
219
|
+
"milestone_id",
|
|
220
|
+
"active",
|
|
221
|
+
"create_date",
|
|
222
|
+
"write_date",
|
|
223
|
+
];
|
|
224
|
+
const TASK_PRIORITY_LABELS = {
|
|
225
|
+
"0": "Low",
|
|
226
|
+
"1": "High",
|
|
227
|
+
};
|
|
228
|
+
/** Parse a task --priority into its raw Odoo selection value. */
|
|
229
|
+
function parseTaskPriority(raw) {
|
|
230
|
+
const v = raw.toLowerCase();
|
|
231
|
+
if (v === "0" || v === "normal" || v === "low")
|
|
232
|
+
return "0";
|
|
233
|
+
if (v === "1" || v === "high")
|
|
234
|
+
return "1";
|
|
235
|
+
throw usageError(`invalid --priority ${JSON.stringify(raw)}`, "expected one of: 0 (normal/low), 1 (high)");
|
|
236
|
+
}
|
|
237
|
+
function parseCount(raw, flag) {
|
|
238
|
+
if (!/^\d+$/.test(raw)) {
|
|
239
|
+
throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
|
|
240
|
+
}
|
|
241
|
+
return Number(raw);
|
|
242
|
+
}
|
|
243
|
+
function parseFieldList(raw) {
|
|
244
|
+
const fields = raw
|
|
245
|
+
.split(",")
|
|
246
|
+
.map((f) => f.trim())
|
|
247
|
+
.filter((f) => f !== "");
|
|
248
|
+
if (fields.length === 0) {
|
|
249
|
+
throw usageError("--fields requires at least one field name");
|
|
250
|
+
}
|
|
251
|
+
return fields;
|
|
252
|
+
}
|
|
253
|
+
/** Resolve a project by numeric id or exact name match (mirrors create). */
|
|
254
|
+
async function resolveProjectId(client, input) {
|
|
255
|
+
const num = Number.parseInt(input, 10);
|
|
256
|
+
if (!Number.isNaN(num))
|
|
257
|
+
return num;
|
|
258
|
+
const projects = (await client.executeKw("project.project", "search_read", [[["name", "=", input]]], { fields: ["id", "name"], limit: 1 }));
|
|
259
|
+
if (!projects || projects.length === 0) {
|
|
260
|
+
throw notFoundError("project_not_found", `project "${input}" not found`, "check the project name or use project ID");
|
|
261
|
+
}
|
|
262
|
+
return projects[0].id;
|
|
263
|
+
}
|
|
264
|
+
export async function tasksListCommand(opts, globals) {
|
|
265
|
+
const client = await authenticatedClient(globals);
|
|
266
|
+
if (opts.domain && opts.filter && opts.filter.length > 0) {
|
|
267
|
+
throw usageError("--domain and --filter are mutually exclusive", "pick one filtering form");
|
|
268
|
+
}
|
|
269
|
+
if (opts.open && opts.closed) {
|
|
270
|
+
throw usageError("--open and --closed are mutually exclusive");
|
|
271
|
+
}
|
|
272
|
+
const limit = parseCount(opts.limit, "--limit");
|
|
273
|
+
const offset = parseCount(opts.offset, "--offset");
|
|
274
|
+
// fields_get first: doubles as the module-installed check (exit 4).
|
|
275
|
+
const fieldsMeta = await ModelFields.load(client, "project.task");
|
|
276
|
+
// project_ids (many2many) on project.task.type scopes stage resolution.
|
|
277
|
+
const projectId = await resolveProjectId(client, opts.project);
|
|
278
|
+
logVerbose(`resolved --project "${opts.project}" -> ${projectId}`, globals.verbose);
|
|
279
|
+
const domain = [["project_id", "=", projectId]];
|
|
280
|
+
if (opts.stage) {
|
|
281
|
+
const stageIdNum = Number.parseInt(opts.stage, 10);
|
|
282
|
+
let stageId;
|
|
283
|
+
if (!Number.isNaN(stageIdNum)) {
|
|
284
|
+
stageId = stageIdNum;
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
const stages = (await client.executeKw("project.task.type", "search_read", [
|
|
288
|
+
[
|
|
289
|
+
["name", "=", opts.stage],
|
|
290
|
+
["project_ids", "in", [projectId]],
|
|
291
|
+
],
|
|
292
|
+
], { fields: ["id", "name"], limit: 1 }));
|
|
293
|
+
if (!stages || stages.length === 0) {
|
|
294
|
+
throw notFoundError("stage_not_found", `stage "${opts.stage}" not found in project ${projectId}`, "check the stage name or use stage ID");
|
|
295
|
+
}
|
|
296
|
+
stageId = stages[0].id;
|
|
297
|
+
}
|
|
298
|
+
domain.push(["stage_id", "=", stageId]);
|
|
299
|
+
logVerbose(`resolved --stage "${opts.stage}" -> ${stageId}`, globals.verbose);
|
|
300
|
+
}
|
|
301
|
+
if (opts.assignee) {
|
|
302
|
+
const user = await resolveReference(client, "res.users", opts.assignee, {
|
|
303
|
+
label: "assignee",
|
|
304
|
+
searchFields: ["login", "email", "name"],
|
|
305
|
+
});
|
|
306
|
+
// many2many membership
|
|
307
|
+
domain.push(["user_ids", "in", [user.id]]);
|
|
308
|
+
logVerbose(`resolved --assignee "${opts.assignee}" -> [${user.id}, "${user.name}"]`, globals.verbose);
|
|
309
|
+
}
|
|
310
|
+
for (const tag of opts.tag ?? []) {
|
|
311
|
+
const resolved = await resolveReference(client, "project.tags", tag, {
|
|
312
|
+
label: "tag",
|
|
313
|
+
});
|
|
314
|
+
domain.push(["tag_ids", "=", resolved.id]);
|
|
315
|
+
logVerbose(`resolved --tag "${tag}" -> [${resolved.id}, "${resolved.name}"]`, globals.verbose);
|
|
316
|
+
}
|
|
317
|
+
if (opts.priority) {
|
|
318
|
+
domain.push(["priority", "=", parseTaskPriority(opts.priority)]);
|
|
319
|
+
}
|
|
320
|
+
if (opts.open)
|
|
321
|
+
domain.push(["is_closed", "=", false]);
|
|
322
|
+
if (opts.closed)
|
|
323
|
+
domain.push(["is_closed", "=", true]);
|
|
324
|
+
if (opts.domain) {
|
|
325
|
+
domain.push(...parseDomain(opts.domain));
|
|
326
|
+
}
|
|
327
|
+
else if (opts.filter && opts.filter.length > 0) {
|
|
328
|
+
domain.push(...buildFilterClauses(opts.filter, fieldsMeta, {
|
|
329
|
+
stage_id: "--stage",
|
|
330
|
+
user_ids: "--assignee",
|
|
331
|
+
tag_ids: "--tag",
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
const fields = opts.fields
|
|
335
|
+
? parseFieldList(opts.fields)
|
|
336
|
+
: TASK_DEFAULT_LIST_FIELDS;
|
|
337
|
+
for (const field of fields) {
|
|
338
|
+
if (field !== "id" && !fieldsMeta.get(field)) {
|
|
339
|
+
throw usageError(`unknown field ${JSON.stringify(field)} on project.task`, `run \`odoo project tasks show <id> --all-fields\` to see available fields`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const order = opts.order ?? "id asc";
|
|
343
|
+
const [records, count] = await Promise.all([
|
|
344
|
+
client.executeKw("project.task", "search_read", [domain], {
|
|
345
|
+
fields,
|
|
346
|
+
limit,
|
|
347
|
+
offset,
|
|
348
|
+
order,
|
|
349
|
+
}),
|
|
350
|
+
client.executeKw("project.task", "search_count", [
|
|
351
|
+
domain,
|
|
352
|
+
]),
|
|
353
|
+
]);
|
|
354
|
+
if (globals.json) {
|
|
355
|
+
printJson({ model: "project.task", records, count, limit, offset });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const headers = fields.map((f) => fieldsMeta.get(f)?.string ?? f);
|
|
359
|
+
const rows = records.map((rec) => fields.map((f) => f === "priority" && typeof rec[f] === "string"
|
|
360
|
+
? (TASK_PRIORITY_LABELS[rec[f]] ?? String(rec[f]))
|
|
361
|
+
: humanCell(rec[f])));
|
|
362
|
+
const color = colorEnabled(globals.noColor);
|
|
363
|
+
process.stdout.write(formatTable(headers, rows, color) + "\n");
|
|
364
|
+
}
|
|
365
|
+
export async function tasksShowCommand(idArg, opts, globals) {
|
|
366
|
+
const client = await authenticatedClient(globals);
|
|
367
|
+
const id = parseId(idArg);
|
|
368
|
+
const fieldsMeta = await ModelFields.load(client, "project.task");
|
|
369
|
+
let fields;
|
|
370
|
+
if (opts.allFields) {
|
|
371
|
+
fields = undefined; // no fields kwarg => everything readable
|
|
372
|
+
}
|
|
373
|
+
else if (opts.fields) {
|
|
374
|
+
fields = parseFieldList(opts.fields);
|
|
375
|
+
for (const field of fields) {
|
|
376
|
+
if (field !== "id" && !fieldsMeta.get(field)) {
|
|
377
|
+
throw usageError(`unknown field ${JSON.stringify(field)} on project.task`, `run \`odoo project tasks show <id> --all-fields\` to see available fields`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
// Curated view: intersect with fields_get so edition-dependent fields
|
|
383
|
+
// degrade gracefully instead of faulting the read.
|
|
384
|
+
const curated = [
|
|
385
|
+
...new Set([...TASK_DEFAULT_LIST_FIELDS, ...TASK_SHOW_EXTRA_FIELDS]),
|
|
386
|
+
];
|
|
387
|
+
fields = curated.filter((f) => f === "id" || fieldsMeta.get(f));
|
|
388
|
+
}
|
|
389
|
+
const rows = (await client.executeKw("project.task", "read", [[id]], {
|
|
390
|
+
...(fields ? { fields } : {}),
|
|
391
|
+
}));
|
|
392
|
+
if (rows.length === 0) {
|
|
393
|
+
throw notFoundError("record_not_found", `no project.task record with id ${id}`);
|
|
394
|
+
}
|
|
395
|
+
const record = rows[0];
|
|
396
|
+
if (globals.json) {
|
|
397
|
+
printJson({ model: "project.task", record });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const keys = fields ?? Object.keys(record);
|
|
401
|
+
const pairs = keys.map((k) => [
|
|
402
|
+
fieldsMeta.get(k)?.string ?? k,
|
|
403
|
+
k === "priority" && typeof record[k] === "string"
|
|
404
|
+
? (TASK_PRIORITY_LABELS[record[k]] ?? String(record[k]))
|
|
405
|
+
: humanCell(record[k]),
|
|
406
|
+
]);
|
|
407
|
+
const color = colorEnabled(globals.noColor);
|
|
408
|
+
process.stdout.write(formatKeyValues(pairs, color) + "\n");
|
|
409
|
+
}
|