@bergetai/odoo-cli 0.12.1 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,902 @@
1
+ /**
2
+ * Sales surface (sale.order, sale.order.line, sale.subscription.plan).
3
+ *
4
+ * Custom commands (not the shared list/show engine) because order reads
5
+ * include order lines + partner details, creates take order lines as JSON,
6
+ * and the surface includes a portal-PDF download and an invoicing-precedent
7
+ * report. Helpers (resolveReference, authenticatedClient, table/json output,
8
+ * dry-run diffing style) mirror crm.ts/mutation.ts.
9
+ *
10
+ * Sending quotations is deliberately NOT implemented (see group help text).
11
+ */
12
+ import { readFile, stat, writeFile } from "node:fs/promises";
13
+ import { resolve } from "node:path";
14
+ import { resolveConfig } from "../config.js";
15
+ import { conflictError, networkError, notFoundError, usageError, } from "../errors.js";
16
+ import { buildFilterClauses, parseDomain, parseFilterScalar } from "../filter.js";
17
+ import { ModelFields, resolveReference } from "../model.js";
18
+ import { parseDate } from "../mutation.js";
19
+ import { colorEnabled, printJson, verbose as logVerbose } from "../output.js";
20
+ import { authenticatedClient, parseId, } from "../resource.js";
21
+ import { formatKeyValues, formatTable, humanCell } from "../table.js";
22
+ // ---------------------------------------------------------------------------
23
+ // shared constants
24
+ // ---------------------------------------------------------------------------
25
+ const SALE_ORDER = "sale.order";
26
+ const SALE_ORDER_LINE = "sale.order.line";
27
+ const SUBSCRIPTION_PLAN = "sale.subscription.plan";
28
+ const STATE_LABELS = {
29
+ draft: "Quotation",
30
+ sent: "Quotation Sent",
31
+ sale: "Sales Order",
32
+ cancel: "Cancelled",
33
+ };
34
+ const LIST_FIELDS = [
35
+ "id",
36
+ "name",
37
+ "partner_id",
38
+ "state",
39
+ "pricelist_id",
40
+ "currency_id",
41
+ "amount_untaxed",
42
+ "amount_total",
43
+ "user_id",
44
+ "validity_date",
45
+ "plan_id",
46
+ ];
47
+ const READ_FIELDS = [
48
+ "id",
49
+ "name",
50
+ "state",
51
+ "partner_id",
52
+ "pricelist_id",
53
+ "currency_id",
54
+ "payment_term_id",
55
+ "validity_date",
56
+ "opportunity_id",
57
+ "user_id",
58
+ "note",
59
+ "plan_id",
60
+ "is_subscription",
61
+ "recurring_total",
62
+ "recurring_monthly",
63
+ "access_token",
64
+ "amount_untaxed",
65
+ "amount_tax",
66
+ "amount_total",
67
+ "order_line",
68
+ "create_date",
69
+ ];
70
+ const LINE_FIELDS = [
71
+ "id",
72
+ "sequence",
73
+ "display_type",
74
+ "name",
75
+ "product_id",
76
+ "product_uom_qty",
77
+ "price_unit",
78
+ "discount",
79
+ "tax_id",
80
+ "price_subtotal",
81
+ "price_total",
82
+ ];
83
+ /** Header fields that `orders update --set` accepts (guardrail, §spec). */
84
+ const UPDATABLE_HEADER_FIELDS = new Set([
85
+ "plan_id",
86
+ "pricelist_id",
87
+ "payment_term_id",
88
+ "validity_date",
89
+ "note",
90
+ "opportunity_id",
91
+ "user_id",
92
+ ]);
93
+ /** read all of stdin as utf8 */
94
+ function readStdin() {
95
+ return new Promise((resolveP, reject) => {
96
+ const chunks = [];
97
+ process.stdin.on("data", (c) => chunks.push(c));
98
+ process.stdin.on("end", () => resolveP(Buffer.concat(chunks).toString("utf8")));
99
+ process.stdin.on("error", reject);
100
+ });
101
+ }
102
+ async function readLinesFile(path) {
103
+ let raw;
104
+ if (path === "-") {
105
+ if (process.stdin.isTTY) {
106
+ throw usageError("--lines - expects a JSON array on stdin, but stdin is a TTY", 'pipe a JSON array on stdin, e.g. echo \'[{"product_id":188,"price_unit":150}]\' | odoo sales orders create ... --lines -');
107
+ }
108
+ raw = await readStdin();
109
+ }
110
+ else {
111
+ try {
112
+ raw = await readFile(path, "utf8");
113
+ }
114
+ catch (cause) {
115
+ throw usageError(`could not read --lines file ${path}: ${cause.code ?? cause.message}`);
116
+ }
117
+ }
118
+ let parsed;
119
+ try {
120
+ parsed = JSON.parse(raw);
121
+ }
122
+ catch {
123
+ throw usageError(`--lines ${path === "-" ? "stdin" : path} is not valid JSON`, "expected a JSON array of order line objects");
124
+ }
125
+ if (!Array.isArray(parsed)) {
126
+ throw usageError("--lines must be a JSON array of order line objects", 'line format: {"display_type":"line_section","name":"..."} or {"product_id":188,"product_uom_qty":1,"price_unit":150,"tax_ids":[37]}');
127
+ }
128
+ return parsed;
129
+ }
130
+ /**
131
+ * Validate one line spec and build the sale.order.line wire dict. For product
132
+ * lines a missing name defaults to the product's display name and a missing
133
+ * price_unit to the product's list price (API create has no onchange).
134
+ */
135
+ async function buildLineValues(client, spec, index) {
136
+ if (typeof spec !== "object" || spec === null || Array.isArray(spec)) {
137
+ throw usageError(`line ${index + 1}: expected a JSON object`);
138
+ }
139
+ const values = {};
140
+ if (spec.display_type !== undefined) {
141
+ if (spec.display_type !== "line_section" && spec.display_type !== "line_note") {
142
+ throw usageError(`line ${index + 1}: invalid display_type ${JSON.stringify(spec.display_type)}`, "expected: line_section or line_note");
143
+ }
144
+ if (typeof spec.name !== "string" || spec.name === "") {
145
+ throw usageError(`line ${index + 1}: a ${spec.display_type} line requires "name"`);
146
+ }
147
+ values.display_type = spec.display_type;
148
+ values.name = spec.name;
149
+ return values;
150
+ }
151
+ if (typeof spec.product_id !== "number" || !Number.isInteger(spec.product_id)) {
152
+ throw usageError(`line ${index + 1}: a product line requires "product_id" (integer)`, 'or pass "display_type": "line_section"|"line_note" for section/note lines');
153
+ }
154
+ // Verify the product exists and pick up defaults (name, price) when omitted.
155
+ const products = (await client.executeKw("product.product", "read", [
156
+ [spec.product_id],
157
+ ], {
158
+ fields: ["display_name", "lst_price"],
159
+ }));
160
+ if (products.length === 0) {
161
+ throw notFoundError("reference_not_found", `line ${index + 1}: no product.product with id ${spec.product_id}`);
162
+ }
163
+ values.product_id = spec.product_id;
164
+ values.name =
165
+ typeof spec.name === "string" && spec.name !== ""
166
+ ? spec.name
167
+ : products[0].display_name;
168
+ values.product_uom_qty = spec.product_uom_qty ?? 1;
169
+ if (typeof values.product_uom_qty !== "number" ||
170
+ !Number.isFinite(values.product_uom_qty)) {
171
+ throw usageError(`line ${index + 1}: product_uom_qty must be a number`);
172
+ }
173
+ values.price_unit = spec.price_unit ?? products[0].lst_price;
174
+ if (typeof values.price_unit !== "number" || !Number.isFinite(values.price_unit)) {
175
+ throw usageError(`line ${index + 1}: price_unit must be a number`);
176
+ }
177
+ if (spec.tax_ids !== undefined) {
178
+ if (!Array.isArray(spec.tax_ids) ||
179
+ !spec.tax_ids.every((t) => typeof t === "number" && Number.isInteger(t))) {
180
+ throw usageError(`line ${index + 1}: tax_ids must be an array of integers`);
181
+ }
182
+ values.tax_id = [[6, 0, spec.tax_ids]];
183
+ }
184
+ return values;
185
+ }
186
+ async function buildAllLines(client, specs) {
187
+ const out = [];
188
+ for (let i = 0; i < specs.length; i++) {
189
+ out.push(await buildLineValues(client, specs[i], i));
190
+ }
191
+ return out;
192
+ }
193
+ export async function ordersListCommand(opts, globals) {
194
+ const client = await authenticatedClient(globals);
195
+ if (opts.domain && opts.filter && opts.filter.length > 0) {
196
+ throw usageError("--domain and --filter are mutually exclusive", "pick one filtering form");
197
+ }
198
+ const limit = parseCount(opts.limit, "--limit");
199
+ const offset = parseCount(opts.offset, "--offset");
200
+ const fieldsMeta = await ModelFields.load(client, SALE_ORDER);
201
+ const domain = [];
202
+ if (opts.partner) {
203
+ const partner = await resolveReference(client, "res.partner", opts.partner, {
204
+ label: "partner",
205
+ });
206
+ domain.push(["partner_id", "=", partner.id]);
207
+ logVerbose(`resolved --partner "${opts.partner}" -> [${partner.id}, "${partner.name}"]`, globals.verbose);
208
+ }
209
+ if (opts.state) {
210
+ if (STATE_LABELS[opts.state] === undefined) {
211
+ throw usageError(`invalid --state ${JSON.stringify(opts.state)}`, `expected one of: ${Object.keys(STATE_LABELS).join(", ")}`);
212
+ }
213
+ domain.push(["state", "=", opts.state]);
214
+ }
215
+ if (opts.domain) {
216
+ domain.push(...parseDomain(opts.domain));
217
+ }
218
+ else if (opts.filter && opts.filter.length > 0) {
219
+ domain.push(...buildFilterClauses(opts.filter, fieldsMeta, {
220
+ partner_id: "--partner",
221
+ user_id: "--filter user_id=<id>",
222
+ }));
223
+ }
224
+ const fields = opts.fields ? parseFieldList(opts.fields) : LIST_FIELDS;
225
+ for (const field of fields) {
226
+ if (field !== "id" && !fieldsMeta.get(field)) {
227
+ throw usageError(`unknown field ${JSON.stringify(field)} on ${SALE_ORDER}`, "run `odoo sales orders read <id> --json` to see available fields");
228
+ }
229
+ }
230
+ const order = opts.order ?? "id desc";
231
+ const [records, count] = await Promise.all([
232
+ client.executeKw(SALE_ORDER, "search_read", [domain], {
233
+ fields,
234
+ limit,
235
+ offset,
236
+ order,
237
+ }),
238
+ client.executeKw(SALE_ORDER, "search_count", [domain]),
239
+ ]);
240
+ if (globals.json) {
241
+ printJson({ model: SALE_ORDER, records, count, limit, offset });
242
+ return;
243
+ }
244
+ const headers = fields.map((f) => fieldsMeta.get(f)?.string ?? f);
245
+ const rows = records.map((rec) => fields.map((f) => {
246
+ if (f === "state")
247
+ return STATE_LABELS[String(rec.state)] ?? humanCell(rec.state);
248
+ return humanCell(rec[f]);
249
+ }));
250
+ process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
251
+ }
252
+ function parseCount(raw, flag) {
253
+ if (!/^\d+$/.test(raw)) {
254
+ throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
255
+ }
256
+ return Number(raw);
257
+ }
258
+ function parseFieldList(raw) {
259
+ const fields = raw
260
+ .split(",")
261
+ .map((f) => f.trim())
262
+ .filter((f) => f !== "");
263
+ if (fields.length === 0) {
264
+ throw usageError("--fields requires at least one field name");
265
+ }
266
+ return fields;
267
+ }
268
+ // ---------------------------------------------------------------------------
269
+ // orders read
270
+ // ---------------------------------------------------------------------------
271
+ export async function ordersReadCommand(idArg, globals) {
272
+ const client = await authenticatedClient(globals);
273
+ const id = parseId(idArg);
274
+ const fieldsMeta = await ModelFields.load(client, SALE_ORDER);
275
+ // Intersect with fields_get so edition-dependent fields degrade gracefully.
276
+ const fields = READ_FIELDS.filter((f) => f === "id" || fieldsMeta.get(f));
277
+ const rows = (await client.executeKw(SALE_ORDER, "read", [[id]], {
278
+ fields,
279
+ }));
280
+ if (rows.length === 0) {
281
+ throw notFoundError("record_not_found", `no ${SALE_ORDER} record with id ${id}`);
282
+ }
283
+ const record = rows[0];
284
+ const lineIds = record.order_line ?? [];
285
+ const lines = lineIds.length
286
+ ? (await client.executeKw(SALE_ORDER_LINE, "read", [lineIds], {
287
+ fields: LINE_FIELDS,
288
+ }))
289
+ : [];
290
+ lines.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
291
+ const partnerId = Array.isArray(record.partner_id)
292
+ ? record.partner_id[0]
293
+ : undefined;
294
+ const partner = partnerId
295
+ ? (await client.executeKw("res.partner", "read", [[partnerId]], {
296
+ fields: ["name", "vat", "street", "street2", "zip", "city", "country_id", "email"],
297
+ }))[0]
298
+ : undefined;
299
+ if (globals.json) {
300
+ printJson({ model: SALE_ORDER, record, order_lines: lines, partner });
301
+ return;
302
+ }
303
+ const color = colorEnabled(globals.noColor);
304
+ const pairs = [];
305
+ for (const f of fields) {
306
+ if (f === "order_line" || f === "access_token" || f === "partner_id")
307
+ continue;
308
+ const label = fieldsMeta.get(f)?.string ?? f;
309
+ const value = f === "state"
310
+ ? (STATE_LABELS[String(record.state)] ?? humanCell(record.state))
311
+ : humanCell(record[f]);
312
+ pairs.push([label, value]);
313
+ }
314
+ process.stdout.write(formatKeyValues(pairs, color) + "\n");
315
+ if (partner) {
316
+ const pp = [
317
+ ["Partner", humanCell(record.partner_id)],
318
+ ["VAT", humanCell(partner.vat)],
319
+ ["Street", humanCell(partner.street)],
320
+ ["Street 2", humanCell(partner.street2)],
321
+ ["Zip", humanCell(partner.zip)],
322
+ ["City", humanCell(partner.city)],
323
+ ["Country", humanCell(partner.country_id)],
324
+ ].filter(([, v]) => v !== "");
325
+ process.stdout.write("\n" + formatKeyValues(pp, color) + "\n");
326
+ }
327
+ if (lines.length > 0) {
328
+ const headers = ["ID", "Type", "Product", "Description", "Qty", "Unit Price", "Taxes", "Subtotal"];
329
+ const rowsOut = lines.map((l) => [
330
+ String(l.id),
331
+ l.display_type === "line_section"
332
+ ? "Section"
333
+ : l.display_type === "line_note"
334
+ ? "Note"
335
+ : "",
336
+ l.display_type ? "" : humanCell(l.product_id),
337
+ humanCell(l.name),
338
+ l.display_type ? "" : humanCell(l.product_uom_qty),
339
+ l.display_type ? "" : humanCell(l.price_unit),
340
+ Array.isArray(l.tax_id) ? l.tax_id.join(",") : "",
341
+ l.display_type ? "" : humanCell(l.price_subtotal),
342
+ ]);
343
+ process.stdout.write("\n" + formatTable(headers, rowsOut, color) + "\n");
344
+ }
345
+ }
346
+ export async function ordersCreateCommand(opts, globals) {
347
+ const client = await authenticatedClient(globals);
348
+ await ModelFields.load(client, SALE_ORDER);
349
+ if (!opts.partner) {
350
+ throw usageError("missing required --partner", '--partner <id|name> (field "partner_id") is required to create a sale.order record');
351
+ }
352
+ const values = {};
353
+ const resolved = {};
354
+ const resolveM2o = async (model, input, label, field, searchFields) => {
355
+ const r = await resolveReference(client, model, input, {
356
+ label,
357
+ searchFields,
358
+ });
359
+ values[field] = r.id;
360
+ resolved[field] = [r.id, r.name];
361
+ logVerbose(`resolved --${label} "${input}" -> [${r.id}, "${r.name}"]`, globals.verbose);
362
+ };
363
+ await resolveM2o("res.partner", opts.partner, "partner", "partner_id");
364
+ if (opts.pricelist) {
365
+ await resolveM2o("product.pricelist", opts.pricelist, "pricelist", "pricelist_id");
366
+ }
367
+ if (opts.paymentTerms) {
368
+ await resolveM2o("account.payment.term", opts.paymentTerms, "payment-terms", "payment_term_id");
369
+ }
370
+ if (opts.validity) {
371
+ values.validity_date = parseDate(opts.validity, "--validity");
372
+ }
373
+ if (opts.opportunity) {
374
+ await resolveM2o("crm.lead", opts.opportunity, "opportunity", "opportunity_id");
375
+ }
376
+ if (opts.salesperson) {
377
+ await resolveM2o("res.users", opts.salesperson, "salesperson", "user_id", [
378
+ "login",
379
+ "email",
380
+ "name",
381
+ ]);
382
+ }
383
+ if (opts.plan) {
384
+ await resolveM2o(SUBSCRIPTION_PLAN, opts.plan, "plan", "plan_id");
385
+ }
386
+ if (opts.note !== undefined) {
387
+ values.note = opts.note;
388
+ }
389
+ let lineValues = [];
390
+ if (opts.lines !== undefined) {
391
+ const specs = await readLinesFile(opts.lines);
392
+ lineValues = await buildAllLines(client, specs);
393
+ values.order_line = lineValues.map((lv) => [0, 0, lv]);
394
+ }
395
+ if (globals.dryRun) {
396
+ if (globals.json) {
397
+ printJson({
398
+ model: SALE_ORDER,
399
+ action: "create",
400
+ dry_run: true,
401
+ values,
402
+ ...(Object.keys(resolved).length > 0 ? { resolved } : {}),
403
+ });
404
+ return;
405
+ }
406
+ const linesOut = [`Would create ${SALE_ORDER}:`];
407
+ for (const [field, v] of Object.entries(values)) {
408
+ if (field === "order_line")
409
+ continue;
410
+ const r = resolved[field];
411
+ linesOut.push(` ${field}: ${r ? `[${r[0]}, ${JSON.stringify(r[1])}]` : JSON.stringify(v)}`);
412
+ }
413
+ if (lineValues.length > 0) {
414
+ linesOut.push(` order_line: ${lineValues.length} line(s)`);
415
+ for (const lv of lineValues) {
416
+ linesOut.push(` - ${JSON.stringify(lv)}`);
417
+ }
418
+ }
419
+ process.stdout.write(linesOut.join("\n") + "\n");
420
+ return;
421
+ }
422
+ const id = (await client.executeKw(SALE_ORDER, "create", [values]));
423
+ if (typeof id !== "number" || !Number.isInteger(id)) {
424
+ throw networkError(`unexpected create() response from the server: ${JSON.stringify(id)}`);
425
+ }
426
+ if (globals.json) {
427
+ printJson({
428
+ model: SALE_ORDER,
429
+ action: "create",
430
+ dry_run: false,
431
+ id,
432
+ ...(Object.keys(resolved).length > 0 ? { resolved } : {}),
433
+ });
434
+ return;
435
+ }
436
+ const color = colorEnabled(false) && process.stdout.isTTY === true;
437
+ const line = `Created ${SALE_ORDER} ${id}`;
438
+ process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
439
+ }
440
+ export async function ordersUpdateCommand(idArg, opts, globals) {
441
+ const client = await authenticatedClient(globals);
442
+ const id = parseId(idArg);
443
+ const fieldsMeta = await ModelFields.load(client, SALE_ORDER);
444
+ const entries = opts.set ?? [];
445
+ if (entries.length === 0) {
446
+ throw usageError("nothing to update", "pass at least one --set field=value");
447
+ }
448
+ const values = {};
449
+ for (const entry of entries) {
450
+ const eq = entry.indexOf("=");
451
+ if (eq <= 0) {
452
+ throw usageError(`invalid --set ${JSON.stringify(entry)}`, "expected the form field=value");
453
+ }
454
+ const field = entry.slice(0, eq);
455
+ // Guardrail: state transitions are a human action in the Odoo UI.
456
+ if (field === "state") {
457
+ throw usageError("refusing to set sale.order.state via the CLI", "state changes (confirm/cancel) happen in the Odoo UI by a human — update header fields only");
458
+ }
459
+ if (!UPDATABLE_HEADER_FIELDS.has(field)) {
460
+ throw usageError(`field ${JSON.stringify(field)} cannot be set via \`sales orders update\``, `updatable header fields: ${[...UPDATABLE_HEADER_FIELDS].join(", ")} — use \`sales lines\` for order lines`);
461
+ }
462
+ if (!fieldsMeta.get(field)) {
463
+ throw conflictError("unknown_field", `unknown field ${JSON.stringify(field)} on ${SALE_ORDER}`);
464
+ }
465
+ if (values[field] !== undefined) {
466
+ throw usageError(`field "${field}" given more than once`, "pass each field exactly once");
467
+ }
468
+ values[field] = parseFilterScalar(entry.slice(eq + 1));
469
+ }
470
+ // Read-first pass: existence check + before/after diff (mirrors mutation.ts).
471
+ const rows = (await client.executeKw(SALE_ORDER, "read", [[id]], {
472
+ fields: [...Object.keys(values), "display_name"],
473
+ }));
474
+ if (rows.length === 0) {
475
+ throw notFoundError("record_not_found", `no ${SALE_ORDER} record with id ${id}`);
476
+ }
477
+ const rec = rows[0];
478
+ const changes = {};
479
+ for (const [field, after] of Object.entries(values)) {
480
+ const before = rec[field];
481
+ const meta = fieldsMeta.get(field);
482
+ const equal = meta?.type === "many2one"
483
+ ? (Array.isArray(before) ? before[0] : before) === after
484
+ : before === after || (before === false && after === false);
485
+ if (!equal)
486
+ changes[field] = { before, after };
487
+ }
488
+ if (globals.dryRun) {
489
+ if (globals.json) {
490
+ printJson({
491
+ model: SALE_ORDER,
492
+ action: "update",
493
+ dry_run: true,
494
+ changes: { [String(id)]: changes },
495
+ });
496
+ return;
497
+ }
498
+ if (Object.keys(changes).length === 0) {
499
+ process.stdout.write(`Unchanged ${SALE_ORDER} ${id} (${humanCell(rec.display_name)}) — nothing to change\n`);
500
+ return;
501
+ }
502
+ const linesOut = [`Would update ${SALE_ORDER} ${id} (${humanCell(rec.display_name)}):`];
503
+ for (const [field, c] of Object.entries(changes)) {
504
+ linesOut.push(` ${field}: ${JSON.stringify(c.before)} -> ${JSON.stringify(c.after)}`);
505
+ }
506
+ process.stdout.write(linesOut.join("\n") + "\n");
507
+ return;
508
+ }
509
+ if (Object.keys(changes).length > 0) {
510
+ await client.executeKw(SALE_ORDER, "write", [[id], values]);
511
+ }
512
+ if (globals.json) {
513
+ printJson({
514
+ model: SALE_ORDER,
515
+ action: "update",
516
+ dry_run: false,
517
+ changes: { [String(id)]: changes },
518
+ });
519
+ return;
520
+ }
521
+ const color = colorEnabled(false) && process.stdout.isTTY === true;
522
+ const line = Object.keys(changes).length === 0
523
+ ? `Unchanged ${SALE_ORDER} ${id}`
524
+ : `Updated ${SALE_ORDER} ${id}`;
525
+ process.stdout.write((Object.keys(changes).length === 0
526
+ ? line
527
+ : color
528
+ ? `\x1b[33m${line}\x1b[0m`
529
+ : line) + "\n");
530
+ }
531
+ export async function linesAddCommand(orderIdArg, opts, globals) {
532
+ const client = await authenticatedClient(globals);
533
+ const orderId = parseId(orderIdArg);
534
+ if (opts.lines === undefined) {
535
+ throw usageError("missing required --lines", "pass --lines <json-file> or --lines - (stdin)");
536
+ }
537
+ const orders = (await client.executeKw(SALE_ORDER, "read", [[orderId]], {
538
+ fields: ["display_name", "state"],
539
+ }));
540
+ if (orders.length === 0) {
541
+ throw notFoundError("record_not_found", `no ${SALE_ORDER} record with id ${orderId}`);
542
+ }
543
+ const specs = await readLinesFile(opts.lines);
544
+ const lineValues = await buildAllLines(client, specs);
545
+ const withOrder = lineValues.map((lv) => ({ ...lv, order_id: orderId }));
546
+ if (globals.dryRun) {
547
+ if (globals.json) {
548
+ printJson({
549
+ model: SALE_ORDER_LINE,
550
+ action: "create",
551
+ dry_run: true,
552
+ order_id: orderId,
553
+ values: withOrder,
554
+ });
555
+ return;
556
+ }
557
+ const linesOut = [
558
+ `Would add ${withOrder.length} line(s) to ${SALE_ORDER} ${orderId} (${humanCell(orders[0].display_name)}):`,
559
+ ...withOrder.map((lv) => ` - ${JSON.stringify(lv)}`),
560
+ ];
561
+ process.stdout.write(linesOut.join("\n") + "\n");
562
+ return;
563
+ }
564
+ const ids = (await client.executeKw(SALE_ORDER_LINE, "create", [
565
+ withOrder,
566
+ ]));
567
+ if (!Array.isArray(ids) || !ids.every((i) => typeof i === "number")) {
568
+ throw networkError(`unexpected create() response from the server: ${JSON.stringify(ids)}`);
569
+ }
570
+ if (globals.json) {
571
+ printJson({
572
+ model: SALE_ORDER_LINE,
573
+ action: "create",
574
+ dry_run: false,
575
+ order_id: orderId,
576
+ ids,
577
+ });
578
+ return;
579
+ }
580
+ const color = colorEnabled(false) && process.stdout.isTTY === true;
581
+ const line = `Created ${ids.length} ${SALE_ORDER_LINE} record(s) on ${SALE_ORDER} ${orderId}: ${ids.join(", ")}`;
582
+ process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
583
+ }
584
+ export async function linesUpdateCommand(lineIdArg, opts, globals) {
585
+ const client = await authenticatedClient(globals);
586
+ const lineId = parseId(lineIdArg);
587
+ if (opts.lines === undefined) {
588
+ throw usageError("missing required --lines", "pass --lines <json-file> or --lines - (stdin) with a single line object");
589
+ }
590
+ const specs = await readLinesFile(opts.lines);
591
+ if (specs.length !== 1) {
592
+ throw usageError(`\`sales lines update\` expects exactly one line object (got ${specs.length})`);
593
+ }
594
+ const spec = specs[0];
595
+ const rows = (await client.executeKw(SALE_ORDER_LINE, "read", [[lineId]], {
596
+ fields: LINE_FIELDS,
597
+ }));
598
+ if (rows.length === 0) {
599
+ throw notFoundError("record_not_found", `no ${SALE_ORDER_LINE} record with id ${lineId}`);
600
+ }
601
+ const rec = rows[0];
602
+ // Build the update dict from the keys present in the spec (no defaults —
603
+ // update never invents values the caller did not pass).
604
+ const values = {};
605
+ if (spec.display_type !== undefined) {
606
+ if (spec.display_type !== "line_section" && spec.display_type !== "line_note") {
607
+ throw usageError(`invalid display_type ${JSON.stringify(spec.display_type)}`, "expected: line_section or line_note");
608
+ }
609
+ values.display_type = spec.display_type;
610
+ }
611
+ if (spec.name !== undefined)
612
+ values.name = spec.name;
613
+ if (spec.product_id !== undefined) {
614
+ if (typeof spec.product_id !== "number" || !Number.isInteger(spec.product_id)) {
615
+ throw usageError("product_id must be an integer");
616
+ }
617
+ values.product_id = spec.product_id;
618
+ }
619
+ if (spec.product_uom_qty !== undefined) {
620
+ if (typeof spec.product_uom_qty !== "number" || !Number.isFinite(spec.product_uom_qty)) {
621
+ throw usageError("product_uom_qty must be a number");
622
+ }
623
+ values.product_uom_qty = spec.product_uom_qty;
624
+ }
625
+ if (spec.price_unit !== undefined) {
626
+ if (typeof spec.price_unit !== "number" || !Number.isFinite(spec.price_unit)) {
627
+ throw usageError("price_unit must be a number");
628
+ }
629
+ values.price_unit = spec.price_unit;
630
+ }
631
+ if (spec.tax_ids !== undefined) {
632
+ if (!Array.isArray(spec.tax_ids) ||
633
+ !spec.tax_ids.every((t) => typeof t === "number" && Number.isInteger(t))) {
634
+ throw usageError("tax_ids must be an array of integers");
635
+ }
636
+ values.tax_id = [[6, 0, spec.tax_ids]];
637
+ }
638
+ if (Object.keys(values).length === 0) {
639
+ throw usageError("nothing to update", "the line object must set at least one of: display_type, name, product_id, product_uom_qty, price_unit, tax_ids");
640
+ }
641
+ const changes = {};
642
+ for (const [field, after] of Object.entries(values)) {
643
+ const before = rec[field];
644
+ let equal;
645
+ if (field === "product_id") {
646
+ equal = (Array.isArray(before) ? before[0] : before) === after;
647
+ }
648
+ else if (field === "tax_id") {
649
+ const afterIds = after[0]?.[2] ?? [];
650
+ equal =
651
+ JSON.stringify([...(before ?? [])].sort((a, b) => a - b)) ===
652
+ JSON.stringify([...afterIds].sort((a, b) => a - b));
653
+ }
654
+ else {
655
+ equal = before === after || (before === false && after === false);
656
+ }
657
+ if (!equal)
658
+ changes[field] = { before, after };
659
+ }
660
+ if (globals.dryRun) {
661
+ if (globals.json) {
662
+ printJson({
663
+ model: SALE_ORDER_LINE,
664
+ action: "update",
665
+ dry_run: true,
666
+ changes: { [String(lineId)]: changes },
667
+ });
668
+ return;
669
+ }
670
+ if (Object.keys(changes).length === 0) {
671
+ process.stdout.write(`Unchanged ${SALE_ORDER_LINE} ${lineId} — nothing to change\n`);
672
+ return;
673
+ }
674
+ const linesOut = [`Would update ${SALE_ORDER_LINE} ${lineId}:`];
675
+ for (const [field, c] of Object.entries(changes)) {
676
+ linesOut.push(` ${field}: ${JSON.stringify(c.before)} -> ${JSON.stringify(c.after)}`);
677
+ }
678
+ process.stdout.write(linesOut.join("\n") + "\n");
679
+ return;
680
+ }
681
+ if (Object.keys(changes).length > 0) {
682
+ await client.executeKw(SALE_ORDER_LINE, "write", [[lineId], values]);
683
+ }
684
+ if (globals.json) {
685
+ printJson({
686
+ model: SALE_ORDER_LINE,
687
+ action: "update",
688
+ dry_run: false,
689
+ changes: { [String(lineId)]: changes },
690
+ });
691
+ return;
692
+ }
693
+ const color = colorEnabled(false) && process.stdout.isTTY === true;
694
+ const line = Object.keys(changes).length === 0
695
+ ? `Unchanged ${SALE_ORDER_LINE} ${lineId}`
696
+ : `Updated ${SALE_ORDER_LINE} ${lineId}`;
697
+ process.stdout.write((Object.keys(changes).length === 0
698
+ ? line
699
+ : color
700
+ ? `\x1b[33m${line}\x1b[0m`
701
+ : line) + "\n");
702
+ }
703
+ export async function plansListCommand(opts, globals) {
704
+ const client = await authenticatedClient(globals);
705
+ await ModelFields.load(client, SUBSCRIPTION_PLAN);
706
+ const limit = parseCount(opts.limit, "--limit");
707
+ const records = (await client.executeKw(SUBSCRIPTION_PLAN, "search_read", [
708
+ [],
709
+ ], {
710
+ fields: ["id", "name", "billing_period_value", "billing_period_unit"],
711
+ limit,
712
+ order: "id asc",
713
+ }));
714
+ if (globals.json) {
715
+ printJson({ model: SUBSCRIPTION_PLAN, records, count: records.length });
716
+ return;
717
+ }
718
+ const headers = ["ID", "Name", "Billing Period Value", "Billing Period Unit"];
719
+ const rows = records.map((r) => [
720
+ String(r.id),
721
+ humanCell(r.name),
722
+ humanCell(r.billing_period_value),
723
+ humanCell(r.billing_period_unit),
724
+ ]);
725
+ process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
726
+ }
727
+ export async function invoicesPrecedentCommand(opts, globals) {
728
+ const client = await authenticatedClient(globals);
729
+ if (!opts.product) {
730
+ throw usageError("missing required --product", "pass --product <id|name> (product.product)");
731
+ }
732
+ const product = await resolveReference(client, "product.product", opts.product, { label: "product" });
733
+ const limit = parseCount(opts.limit, "--limit");
734
+ const domain = [
735
+ ["product_id", "=", product.id],
736
+ ["move_id.move_type", "=", "out_invoice"],
737
+ ["move_id.state", "=", "posted"],
738
+ ];
739
+ const lines = (await client.executeKw("account.move.line", "search_read", [
740
+ domain,
741
+ ], {
742
+ fields: [
743
+ "id",
744
+ "move_id",
745
+ "partner_id",
746
+ "currency_id",
747
+ "quantity",
748
+ "price_unit",
749
+ "price_subtotal",
750
+ ],
751
+ limit,
752
+ order: "id desc",
753
+ }));
754
+ const moveIds = [
755
+ ...new Set(lines
756
+ .map((l) => (Array.isArray(l.move_id) ? l.move_id[0] : undefined))
757
+ .filter((i) => i !== undefined)),
758
+ ];
759
+ const moves = moveIds.length
760
+ ? (await client.executeKw("account.move", "read", [moveIds], {
761
+ fields: ["id", "name", "invoice_date"],
762
+ }))
763
+ : [];
764
+ const moveById = new Map(moves.map((m) => [m.id, m]));
765
+ const records = lines
766
+ .map((l) => {
767
+ const moveId = Array.isArray(l.move_id) ? l.move_id[0] : 0;
768
+ const move = moveById.get(moveId);
769
+ return {
770
+ id: l.id,
771
+ invoice: move?.name ?? humanCell(l.move_id),
772
+ invoice_date: move?.invoice_date ?? "",
773
+ partner_id: l.partner_id,
774
+ currency_id: l.currency_id,
775
+ quantity: l.quantity,
776
+ price_unit: l.price_unit,
777
+ price_subtotal: l.price_subtotal,
778
+ };
779
+ })
780
+ .sort((a, b) => String(b.invoice_date).localeCompare(String(a.invoice_date)));
781
+ if (globals.json) {
782
+ printJson({
783
+ model: "account.move.line",
784
+ product: [product.id, product.name],
785
+ records,
786
+ count: records.length,
787
+ });
788
+ return;
789
+ }
790
+ const headers = ["Invoice", "Date", "Partner", "Currency", "Qty", "Unit Price", "Subtotal"];
791
+ const rows = records.map((r) => [
792
+ r.invoice,
793
+ r.invoice_date,
794
+ humanCell(r.partner_id),
795
+ humanCell(r.currency_id),
796
+ humanCell(r.quantity),
797
+ humanCell(r.price_unit),
798
+ humanCell(r.price_subtotal),
799
+ ]);
800
+ process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
801
+ }
802
+ export async function pdfCommand(idArg, opts, globals) {
803
+ const id = parseId(idArg);
804
+ if (!opts.out) {
805
+ throw usageError("missing required --out", "pass --out <path> for the downloaded PDF");
806
+ }
807
+ const resolved = await resolveConfig({
808
+ profile: globals.profile,
809
+ url: globals.url,
810
+ db: globals.db,
811
+ username: globals.username,
812
+ apiKeyFile: globals.apiKeyFile,
813
+ });
814
+ if (!resolved.url) {
815
+ throw usageError("missing required configuration: url");
816
+ }
817
+ const baseUrl = resolved.url.replace(/\/+$/, "");
818
+ const client = await authenticatedClient(globals);
819
+ const rows = (await client.executeKw(SALE_ORDER, "read", [[id]], {
820
+ fields: ["display_name", "access_token"],
821
+ }));
822
+ if (rows.length === 0) {
823
+ throw notFoundError("record_not_found", `no ${SALE_ORDER} record with id ${id}`);
824
+ }
825
+ let token = rows[0].access_token || undefined;
826
+ if (!token) {
827
+ // Some records report access_token=false over XML-RPC; get_portal_url
828
+ // generates/returns the tokenized URL instead.
829
+ const portalUrl = (await client.executeKw(SALE_ORDER, "get_portal_url", [
830
+ [id],
831
+ ]));
832
+ if (typeof portalUrl === "string") {
833
+ const match = /[?&]access_token=([^&]+)/.exec(portalUrl);
834
+ if (match)
835
+ token = decodeURIComponent(match[1]);
836
+ }
837
+ }
838
+ if (!token) {
839
+ throw notFoundError("token_not_found", `could not obtain an access token for ${SALE_ORDER} ${id}`, "open the quotation in the Odoo UI and check the portal link");
840
+ }
841
+ const pdfUrl = `${baseUrl}/my/orders/${id}?access_token=${encodeURIComponent(token)}&report_type=pdf`;
842
+ if (globals.dryRun) {
843
+ if (globals.json) {
844
+ printJson({
845
+ model: SALE_ORDER,
846
+ action: "pdf",
847
+ dry_run: true,
848
+ id,
849
+ out: resolve(opts.out),
850
+ url: pdfUrl,
851
+ });
852
+ return;
853
+ }
854
+ process.stdout.write(`Would download ${SALE_ORDER} ${id} (${rows[0].display_name}) portal PDF → ${resolve(opts.out)}\n`);
855
+ return;
856
+ }
857
+ const abort = new AbortController();
858
+ const timer = setTimeout(() => abort.abort(), 60_000);
859
+ let response;
860
+ try {
861
+ response = await fetch(pdfUrl, { signal: abort.signal });
862
+ }
863
+ catch (cause) {
864
+ throw networkError(`PDF download failed: ${cause.message}`, "check connectivity and that the portal URL is reachable");
865
+ }
866
+ finally {
867
+ clearTimeout(timer);
868
+ }
869
+ if (!response.ok) {
870
+ throw networkError(`PDF download failed: HTTP ${response.status} from ${baseUrl}`, "the access token may have been rotated — retry");
871
+ }
872
+ const buffer = Buffer.from(await response.arrayBuffer());
873
+ if (buffer.length < 5 || buffer.subarray(0, 5).toString("latin1") !== "%PDF-") {
874
+ throw networkError(`downloaded file is not a PDF (got ${buffer.length} bytes, unexpected header)`, "the portal may have returned a login page — the access token may be invalid");
875
+ }
876
+ const outPath = resolve(opts.out);
877
+ try {
878
+ await stat(outPath);
879
+ if (!opts.force) {
880
+ throw usageError(`file already exists: ${outPath}`, "pass --force to overwrite");
881
+ }
882
+ }
883
+ catch (cause) {
884
+ if (cause.code !== "ENOENT")
885
+ throw cause;
886
+ }
887
+ await writeFile(outPath, buffer);
888
+ if (globals.json) {
889
+ printJson({
890
+ model: SALE_ORDER,
891
+ action: "pdf",
892
+ dry_run: false,
893
+ id,
894
+ path: outPath,
895
+ bytes: buffer.length,
896
+ });
897
+ return;
898
+ }
899
+ const color = colorEnabled(false) && process.stdout.isTTY === true;
900
+ const line = `Downloaded ${SALE_ORDER} ${id} (${rows[0].display_name}) → ${outPath} (${buffer.length} bytes)`;
901
+ process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
902
+ }