@bergetai/odoo-cli 0.12.4 → 0.12.7
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 +7 -2
- package/dist/cli.js +74 -2
- package/dist/commands/models.js +58 -0
- package/dist/commands/sales.js +229 -1
- package/dist/filter.js +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,8 @@ odoo models fields res.partner --filter "^email" --json
|
|
|
46
46
|
odoo models fields crm.lead --custom # only x_* custom fields
|
|
47
47
|
odoo models read res.partner --ids 51,52 --fields name,city
|
|
48
48
|
odoo models read crm.lead --search "microsoft" --json # ilike on name (max 100)
|
|
49
|
+
odoo models search mail.activity --domain '[["user_id","=",39],["date_deadline","<=","2026-09-25"]]' --json
|
|
50
|
+
odoo models search mail.activity --filter user_id=39 --fields res_id,summary,date_deadline --json
|
|
49
51
|
|
|
50
52
|
# List and inspect records
|
|
51
53
|
odoo crm leads list --open --limit 20
|
|
@@ -68,6 +70,9 @@ odoo sales orders update 11515 --set plan_id=2 --dry-run
|
|
|
68
70
|
odoo sales lines add 11515 --lines extra.json
|
|
69
71
|
odoo sales lines update 11818 --lines fix.json
|
|
70
72
|
odoo sales plans list
|
|
73
|
+
odoo sales plans pricing list --pricelist "SEK price list" # recurring prices (sale.subscription.pricing)
|
|
74
|
+
odoo sales plans pricing create --pricelist 11 --product 188 --plan Yearly --price 19800 --dry-run
|
|
75
|
+
odoo sales plans pricing update 131 --price 19800 --dry-run
|
|
71
76
|
odoo sales invoices precedent --product 188 # invoicing currency history
|
|
72
77
|
odoo sales pdf 11515 --out ./quotation.pdf
|
|
73
78
|
|
|
@@ -155,7 +160,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
|
|
|
155
160
|
|
|
156
161
|
## Features
|
|
157
162
|
|
|
158
|
-
- Sales surface (`sales`) — quotations and sales orders (`sale.order`) without the raw XML-RPC plumbing: `orders list` (curated columns incl. pricelist/currency, `--partner`/`--state` filters plus `--filter`/`--domain`), `orders read` (full header, partner with VAT/address, and the order lines with sections/notes), `orders create` (first-class flags for `--partner`/`--pricelist`/`--payment-terms`/`--validity`/`--opportunity`/`--salesperson`/`--plan`/`--note`, order lines as a JSON array via `--lines <file>`/`-` supporting `line_section`/`line_note` lines and product lines with `tax_ids`; missing line name/price default from the product), `orders update` (header-only `--set` with an allowlist — setting `state` is refused by design: confirm/cancel is a human step in the Odoo UI), `lines add`/`lines update` (same JSON line format), `plans list` (`sale.subscription.plan`), `invoices precedent --product` (posted customer-invoice lines for a product, newest first — invoicing currency precedent), and `pdf <id> --out <path>` (portal quotation PDF via the order's `access_token`, verified `%PDF` magic). **Sending quotations is deliberately not implemented** — it is done by a human via 'Send by Email' in the Odoo UI (gives the customer Accept & Sign), CLI policy 2026-09-24
|
|
163
|
+
- Sales surface (`sales`) — quotations and sales orders (`sale.order`) without the raw XML-RPC plumbing: `orders list` (curated columns incl. pricelist/currency, `--partner`/`--state` filters plus `--filter`/`--domain`), `orders read` (full header, partner with VAT/address, and the order lines with sections/notes), `orders create` (first-class flags for `--partner`/`--pricelist`/`--payment-terms`/`--validity`/`--opportunity`/`--salesperson`/`--plan`/`--note`, order lines as a JSON array via `--lines <file>`/`-` supporting `line_section`/`line_note` lines and product lines with `tax_ids`; missing line name/price default from the product), `orders update` (header-only `--set` with an allowlist — setting `state` is refused by design: confirm/cancel is a human step in the Odoo UI), `lines add`/`lines update` (same JSON line format), `plans list` (`sale.subscription.plan`), `plans pricing list`/`create`/`update` (recurring prices, `sale.subscription.pricing` — one rule per pricelist+product+plan; subscription order lines price from these rules, not from `product.pricelist.item`), `invoices precedent --product` (posted customer-invoice lines for a product, newest first — invoicing currency precedent), and `pdf <id> --out <path>` (portal quotation PDF via the order's `access_token`, verified `%PDF` magic). **Sending quotations is deliberately not implemented** — it is done by a human via 'Send by Email' in the Odoo UI (gives the customer Accept & Sign), CLI policy 2026-09-24
|
|
159
164
|
- Signature pipeline (`sign`) — Odoo Sign with safety rails learned from live incidents:
|
|
160
165
|
- `sign templates list/show` and `sign templates create-from-document <id>` — builds a
|
|
161
166
|
`sign.template` from a Documents file, converting non-PDF files (`.docx` etc.) to PDF through
|
|
@@ -181,7 +186,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
|
|
|
181
186
|
- Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
|
|
182
187
|
- Read-only Project surface (`project list/show`) — `--user` (project manager) and `--tag` filters, archived projects excluded by default, module-not-installed error (exit 4)
|
|
183
188
|
- 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)
|
|
184
|
-
- Generic model surface (`models fields/read`) — introspect and
|
|
189
|
+
- Generic model surface (`models fields/read/search`) — introspect, read and search any model exposed through `execute_kw`: `fields` lists `fields_get` metadata (type, label, selection, relation, required, store) with `--filter <regex>` (case-insensitive on field name or label) and `--custom` (only `x_*` fields); `read` fetches up to 100 records by `--ids 1,2,3` or a name ilike `--search`, with `--fields a,b,c` (default: name); `search` runs an arbitrary domain via `--domain <json>` (or repeatable `--filter f=v`, mutually exclusive) with `--fields` (default: id[,name]), `--limit`/`--offset`/`--order`, and `--include-archived` (runs with `active_test: false`; eager-fetches rows other list engines hide by default). Unknown models/fields use the standard error taxonomy
|
|
185
190
|
- Documents surface (`documents`) — `tree` (hierarchical folder listing with `--count`/`--depth`/`--folder`), `records list/show` (folders excluded by default, `--type folder|binary|url|all`, `--folder`/`--owner`/`--partner`/`--tag`), `folders list`, `records create` (folder via `--folder-type folder`, link via `--link <url>`), `records update` (move via `--folder`), `records delete`, `upload` (local file → base64 `datas`, MIME guessed from extension, `--dry-run` previews without sending bytes), and `records download <id>` (binary document → local file, `--out <path>`/`--force`, `--out -` streams to stdout). Everywhere a `--folder` is accepted, folders resolve by name, numeric id, or nested `'Parent > Child'` path (segments resolved within their parent, which disambiguates repeated folder names). Folders are `documents.document` rows with `type='folder'`; the hierarchy is `folder_id`. Module-not-installed error (exit 4)
|
|
186
191
|
- 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`
|
|
187
192
|
- `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
|
package/dist/cli.js
CHANGED
|
@@ -13,10 +13,10 @@ import { partnersCreateCommand, partnersDeleteCommand, partnersListCommand, part
|
|
|
13
13
|
import { usersDeleteCommand, usersShowCommand, } from "./commands/users.js";
|
|
14
14
|
import { attachmentsDeleteCommand, attachmentsDownloadCommand, attachmentsListCommand, attachmentsShowCommand, } from "./commands/attachments.js";
|
|
15
15
|
import { signTemplatesCreateFromDocumentCommand, signTemplatesListCommand, signTemplatesShowCommand, signItemsAddCommand, signRequestsCreateCommand, signRequestsListCommand, signRequestsCancelCommand, } from "./commands/sign.js";
|
|
16
|
-
import { invoicesPrecedentCommand, linesAddCommand, linesUpdateCommand, ordersCreateCommand, ordersListCommand, ordersReadCommand, ordersUpdateCommand, pdfCommand, plansListCommand, } from "./commands/sales.js";
|
|
16
|
+
import { invoicesPrecedentCommand, linesAddCommand, linesUpdateCommand, ordersCreateCommand, ordersListCommand, ordersReadCommand, ordersUpdateCommand, pdfCommand, plansListCommand, plansPricingCreateCommand, plansPricingListCommand, plansPricingUpdateCommand, } from "./commands/sales.js";
|
|
17
17
|
import { messagesDeleteCommand, messagesListCommand, messagesScrubCommand, messagesShowCommand, } from "./commands/messages.js";
|
|
18
18
|
import { contactsDeleteCommand, contactsListCommand, contactsShowCommand, listsListCommand, listsShowCommand, tracesDeleteCommand, tracesListCommand, tracesShowCommand, } from "./commands/marketing.js";
|
|
19
|
-
import { modelsFieldsCommand, modelsReadCommand, } from "./commands/models.js";
|
|
19
|
+
import { modelsFieldsCommand, modelsReadCommand, modelsSearchCommand, } from "./commands/models.js";
|
|
20
20
|
import { footprintCommand, } from "./commands/footprint.js";
|
|
21
21
|
import { CliError } from "./errors.js";
|
|
22
22
|
import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
|
|
@@ -1033,6 +1033,59 @@ Examples:
|
|
|
1033
1033
|
.action(async (opts) => {
|
|
1034
1034
|
await run((g) => plansListCommand(opts, g), program);
|
|
1035
1035
|
});
|
|
1036
|
+
const salesPlansPricing = salesPlans
|
|
1037
|
+
.command("pricing")
|
|
1038
|
+
.description("recurring prices per pricelist/product/plan (sale.subscription.pricing)");
|
|
1039
|
+
salesPlansPricing
|
|
1040
|
+
.command("list")
|
|
1041
|
+
.description("list recurring pricing rules")
|
|
1042
|
+
.option("--pricelist <name|id>", "filter by pricelist (product.pricelist)")
|
|
1043
|
+
.option("--product <name|id>", "filter by product (product.template)")
|
|
1044
|
+
.option("--plan <name|id>", "filter by plan (sale.subscription.plan)")
|
|
1045
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
1046
|
+
.addHelpText("after", `
|
|
1047
|
+
Subscription order lines price from these rules, not from
|
|
1048
|
+
product.pricelist.item. If an order's pricelist has no rule for the order's
|
|
1049
|
+
plan, the line silently prices at 0 — check here before switching pricelists.
|
|
1050
|
+
|
|
1051
|
+
Examples:
|
|
1052
|
+
$ odoo sales plans pricing list --pricelist "SEK price list"
|
|
1053
|
+
$ odoo sales plans pricing list --product "Berget Code Seat" --json`)
|
|
1054
|
+
.action(async (opts) => {
|
|
1055
|
+
await run((g) => plansPricingListCommand(opts, g), program);
|
|
1056
|
+
});
|
|
1057
|
+
salesPlansPricing
|
|
1058
|
+
.command("create")
|
|
1059
|
+
.description("create a recurring pricing rule (preview with --dry-run)")
|
|
1060
|
+
.requiredOption("--pricelist <name|id>", "pricelist (product.pricelist)")
|
|
1061
|
+
.requiredOption("--product <name|id>", "product (product.template)")
|
|
1062
|
+
.requiredOption("--plan <name|id>", "recurring plan (sale.subscription.plan)")
|
|
1063
|
+
.requiredOption("--price <amount>", "recurring price (number)")
|
|
1064
|
+
.addHelpText("after", `
|
|
1065
|
+
Only one rule per (pricelist, product, plan) is allowed — duplicates are
|
|
1066
|
+
rejected; use 'sales plans pricing update' to change an existing rule.
|
|
1067
|
+
|
|
1068
|
+
Examples:
|
|
1069
|
+
$ odoo sales plans pricing create --pricelist "SEK price list" --product 188 --plan Yearly --price 19800 --dry-run
|
|
1070
|
+
$ odoo sales plans pricing create --pricelist 11 --product 188 --plan 2 --price 19800`)
|
|
1071
|
+
.action(async (opts) => {
|
|
1072
|
+
await run((g) => plansPricingCreateCommand(opts, g), program);
|
|
1073
|
+
});
|
|
1074
|
+
salesPlansPricing
|
|
1075
|
+
.command("update")
|
|
1076
|
+
.description("update a recurring pricing rule (preview with --dry-run)")
|
|
1077
|
+
.argument("<id>", "sale.subscription.pricing id")
|
|
1078
|
+
.option("--pricelist <name|id>", "pricelist (product.pricelist)")
|
|
1079
|
+
.option("--product <name|id>", "product (product.template)")
|
|
1080
|
+
.option("--plan <name|id>", "recurring plan (sale.subscription.plan)")
|
|
1081
|
+
.option("--price <amount>", "recurring price (number)")
|
|
1082
|
+
.addHelpText("after", `
|
|
1083
|
+
Examples:
|
|
1084
|
+
$ odoo sales plans pricing update 131 --price 19800 --dry-run
|
|
1085
|
+
$ odoo sales plans pricing update 131 --plan Yearly`)
|
|
1086
|
+
.action(async (id, opts) => {
|
|
1087
|
+
await run((g) => plansPricingUpdateCommand(id, opts, g), program);
|
|
1088
|
+
});
|
|
1036
1089
|
const salesInvoices = sales
|
|
1037
1090
|
.command("invoices")
|
|
1038
1091
|
.description("invoicing reports (account.move.line)");
|
|
@@ -1320,6 +1373,25 @@ Examples:
|
|
|
1320
1373
|
.action(async (model, opts) => {
|
|
1321
1374
|
await run((g) => modelsReadCommand(model, opts, g), program);
|
|
1322
1375
|
});
|
|
1376
|
+
models
|
|
1377
|
+
.command("search")
|
|
1378
|
+
.description("search records by domain (search_read; use --fields, --limit, --order)")
|
|
1379
|
+
.argument("<model>", "model technical name, e.g. mail.activity")
|
|
1380
|
+
.option("--domain <json>", 'Odoo domain as a JSON array, e.g. [["date_deadline","<=","2026-01-31"]]')
|
|
1381
|
+
.option("--filter <field=value>", "equality filter on raw values (repeatable); many2one values must be numeric ids", collect, [])
|
|
1382
|
+
.option("--fields <a,b,c>", "fields to fetch (default: id[,name])")
|
|
1383
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
1384
|
+
.option("--offset <n>", "skip the first n records", "0")
|
|
1385
|
+
.option("--order <order>", 'sort order, e.g. "date_deadline asc"')
|
|
1386
|
+
.option("--include-archived", "match archived records too (runs with active_test: false)")
|
|
1387
|
+
.addHelpText("after", `
|
|
1388
|
+
Examples:
|
|
1389
|
+
$ odoo models search mail.activity --domain '[["user_id","=",39],["date_deadline","<=","2026-09-25"]]' --json
|
|
1390
|
+
$ odoo models search mail.activity --filter user_id=39 --fields res_id,summary,date_deadline --json
|
|
1391
|
+
$ odoo models search crm.lead --domain '[["team_id","=",2]]' --order "create_date desc" --limit 20 --json`)
|
|
1392
|
+
.action(async (model, opts) => {
|
|
1393
|
+
await run((g) => modelsSearchCommand(model, opts, g), program);
|
|
1394
|
+
});
|
|
1323
1395
|
return program;
|
|
1324
1396
|
}
|
|
1325
1397
|
async function run(fn, program) {
|
package/dist/commands/models.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
import { buildFilterClauses, parseDomain } from "../filter.js";
|
|
1
2
|
import { notFoundError, usageError } from "../errors.js";
|
|
2
3
|
import { fieldsGet, ModelFields } from "../model.js";
|
|
3
4
|
import { colorEnabled, printJson } from "../output.js";
|
|
4
5
|
import { authenticatedClient } from "../resource.js";
|
|
5
6
|
import { formatTable, humanCell } from "../table.js";
|
|
6
7
|
const MAX_READ = 100;
|
|
8
|
+
function parseCount(raw, flag) {
|
|
9
|
+
if (!/^\d+$/.test(raw)) {
|
|
10
|
+
throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
|
|
11
|
+
}
|
|
12
|
+
return Number(raw);
|
|
13
|
+
}
|
|
7
14
|
function parseFieldList(raw) {
|
|
8
15
|
const fields = raw
|
|
9
16
|
.split(",")
|
|
@@ -125,3 +132,54 @@ export async function modelsReadCommand(model, opts, globals) {
|
|
|
125
132
|
const rows = records.map((r) => fields.map((f) => humanCell(r[f])));
|
|
126
133
|
process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
|
|
127
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* `models search <model>` — generic domain search over any model reachable
|
|
137
|
+
* through execute_kw. The read-side counterpart to `models read` when the
|
|
138
|
+
* name-ilike search is not enough: arbitrary domains via JSON --domain, or
|
|
139
|
+
* another model's filter surface via repeatable --filter f=v (id equality on
|
|
140
|
+
* many2one fields). Sqlite-style paging via --limit/--offset; ordering via
|
|
141
|
+
* --order. --include-archived disables active_test so archived rows are
|
|
142
|
+
* matched; on models without an `active` field it is a no-op.
|
|
143
|
+
*/
|
|
144
|
+
export async function modelsSearchCommand(model, opts, globals) {
|
|
145
|
+
if (opts.domain !== undefined && opts.filter && opts.filter.length > 0) {
|
|
146
|
+
throw usageError("--domain and --filter are mutually exclusive", "pick one filtering form");
|
|
147
|
+
}
|
|
148
|
+
const limit = parseCount(opts.limit, "--limit");
|
|
149
|
+
const offset = parseCount(opts.offset, "--offset");
|
|
150
|
+
const client = await authenticatedClient(globals);
|
|
151
|
+
const meta = await ModelFields.load(client, model);
|
|
152
|
+
const domain = [];
|
|
153
|
+
if (opts.domain !== undefined) {
|
|
154
|
+
domain.push(...parseDomain(opts.domain));
|
|
155
|
+
}
|
|
156
|
+
else if (opts.filter && opts.filter.length > 0) {
|
|
157
|
+
domain.push(...buildFilterClauses(opts.filter, meta, {}, `run \`odoo models fields ${model}\` to list available fields`));
|
|
158
|
+
}
|
|
159
|
+
const defaultFields = meta.get("name") ? ["id", "name"] : ["id"];
|
|
160
|
+
const fields = opts.fields !== undefined ? parseFieldList(opts.fields) : defaultFields;
|
|
161
|
+
for (const field of fields) {
|
|
162
|
+
if (field !== "id" && !meta.get(field)) {
|
|
163
|
+
throw usageError(`unknown field ${JSON.stringify(field)} on ${model}`, "run `odoo models fields <model>` to list available fields");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const context = opts.includeArchived
|
|
167
|
+
? { active_test: false }
|
|
168
|
+
: undefined;
|
|
169
|
+
const kwargs = { fields, limit, offset, order: opts.order ?? "id asc" };
|
|
170
|
+
if (context)
|
|
171
|
+
kwargs.context = context;
|
|
172
|
+
const [records, count] = await Promise.all([
|
|
173
|
+
client.executeKw(model, "search_read", [domain], kwargs),
|
|
174
|
+
client.executeKw(model, "search_count", [domain], {
|
|
175
|
+
...(context ? { context } : {}),
|
|
176
|
+
}),
|
|
177
|
+
]);
|
|
178
|
+
if (globals.json) {
|
|
179
|
+
printJson({ model, records, count, limit, offset });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const headers = fields.map((f) => meta.get(f)?.string ?? f);
|
|
183
|
+
const rows = records.map((r) => fields.map((f) => humanCell(r[f])));
|
|
184
|
+
process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
|
|
185
|
+
}
|
package/dist/commands/sales.js
CHANGED
|
@@ -15,7 +15,8 @@ import { resolveConfig } from "../config.js";
|
|
|
15
15
|
import { conflictError, networkError, notFoundError, usageError, } from "../errors.js";
|
|
16
16
|
import { buildFilterClauses, parseDomain, parseFilterScalar } from "../filter.js";
|
|
17
17
|
import { ModelFields, resolveReference } from "../model.js";
|
|
18
|
-
import { parseDate } from "../mutation.js";
|
|
18
|
+
import { parseDate, parseDecimal } from "../mutation.js";
|
|
19
|
+
import { XmlRpcFaultError } from "../xmlrpc.js";
|
|
19
20
|
import { colorEnabled, printJson, verbose as logVerbose } from "../output.js";
|
|
20
21
|
import { authenticatedClient, parseId, } from "../resource.js";
|
|
21
22
|
import { formatKeyValues, formatTable, humanCell } from "../table.js";
|
|
@@ -25,6 +26,7 @@ import { formatKeyValues, formatTable, humanCell } from "../table.js";
|
|
|
25
26
|
const SALE_ORDER = "sale.order";
|
|
26
27
|
const SALE_ORDER_LINE = "sale.order.line";
|
|
27
28
|
const SUBSCRIPTION_PLAN = "sale.subscription.plan";
|
|
29
|
+
const SUBSCRIPTION_PRICING = "sale.subscription.pricing";
|
|
28
30
|
const STATE_LABELS = {
|
|
29
31
|
draft: "Quotation",
|
|
30
32
|
sent: "Quotation Sent",
|
|
@@ -724,6 +726,232 @@ export async function plansListCommand(opts, globals) {
|
|
|
724
726
|
]);
|
|
725
727
|
process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
|
|
726
728
|
}
|
|
729
|
+
// ---------------------------------------------------------------------------
|
|
730
|
+
// plans pricing — recurring prices per pricelist/product/plan
|
|
731
|
+
// (sale.subscription.pricing). Subscription order lines price from these
|
|
732
|
+
// rules, NOT from product.pricelist.item — an order whose pricelist lacks a
|
|
733
|
+
// pricing rule for the plan on the order silently prices the line at 0.
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
const PRICING_FIELDS = [
|
|
736
|
+
"id",
|
|
737
|
+
"pricelist_id",
|
|
738
|
+
"product_template_id",
|
|
739
|
+
"plan_id",
|
|
740
|
+
"price",
|
|
741
|
+
];
|
|
742
|
+
async function resolvePricingRefs(client, opts, globals) {
|
|
743
|
+
const refs = {};
|
|
744
|
+
if (opts.pricelist !== undefined) {
|
|
745
|
+
refs.pricelist = await resolveReference(client, "product.pricelist", opts.pricelist, { label: "pricelist" });
|
|
746
|
+
logVerbose(`resolved --pricelist "${opts.pricelist}" -> [${refs.pricelist.id}, "${refs.pricelist.name}"]`, globals.verbose);
|
|
747
|
+
}
|
|
748
|
+
if (opts.product !== undefined) {
|
|
749
|
+
refs.product = await resolveReference(client, "product.template", opts.product, { label: "product template" });
|
|
750
|
+
logVerbose(`resolved --product "${opts.product}" -> [${refs.product.id}, "${refs.product.name}"]`, globals.verbose);
|
|
751
|
+
}
|
|
752
|
+
if (opts.plan !== undefined) {
|
|
753
|
+
refs.plan = await resolveReference(client, SUBSCRIPTION_PLAN, opts.plan, {
|
|
754
|
+
label: "plan",
|
|
755
|
+
});
|
|
756
|
+
logVerbose(`resolved --plan "${opts.plan}" -> [${refs.plan.id}, "${refs.plan.name}"]`, globals.verbose);
|
|
757
|
+
}
|
|
758
|
+
return refs;
|
|
759
|
+
}
|
|
760
|
+
export async function plansPricingListCommand(opts, globals) {
|
|
761
|
+
const client = await authenticatedClient(globals);
|
|
762
|
+
await ModelFields.load(client, SUBSCRIPTION_PRICING);
|
|
763
|
+
const limit = parseCount(opts.limit, "--limit");
|
|
764
|
+
const refs = await resolvePricingRefs(client, opts, globals);
|
|
765
|
+
const domain = [];
|
|
766
|
+
if (refs.pricelist)
|
|
767
|
+
domain.push(["pricelist_id", "=", refs.pricelist.id]);
|
|
768
|
+
if (refs.product)
|
|
769
|
+
domain.push(["product_template_id", "=", refs.product.id]);
|
|
770
|
+
if (refs.plan)
|
|
771
|
+
domain.push(["plan_id", "=", refs.plan.id]);
|
|
772
|
+
const records = (await client.executeKw(SUBSCRIPTION_PRICING, "search_read", [
|
|
773
|
+
domain,
|
|
774
|
+
], {
|
|
775
|
+
fields: PRICING_FIELDS,
|
|
776
|
+
limit,
|
|
777
|
+
order: "id asc",
|
|
778
|
+
}));
|
|
779
|
+
if (globals.json) {
|
|
780
|
+
printJson({ model: SUBSCRIPTION_PRICING, records, count: records.length });
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const headers = ["ID", "Pricelist", "Product", "Plan", "Price"];
|
|
784
|
+
const rows = records.map((r) => [
|
|
785
|
+
String(r.id),
|
|
786
|
+
humanCell(r.pricelist_id),
|
|
787
|
+
humanCell(r.product_template_id),
|
|
788
|
+
humanCell(r.plan_id),
|
|
789
|
+
humanCell(r.price),
|
|
790
|
+
]);
|
|
791
|
+
process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
|
|
792
|
+
}
|
|
793
|
+
export async function plansPricingCreateCommand(opts, globals) {
|
|
794
|
+
const client = await authenticatedClient(globals);
|
|
795
|
+
await ModelFields.load(client, SUBSCRIPTION_PRICING);
|
|
796
|
+
const refs = await resolvePricingRefs(client, opts, globals);
|
|
797
|
+
const price = parseDecimal(opts.price, "--price");
|
|
798
|
+
// Guardrail: one rule per (pricelist, product, plan) — a duplicate would
|
|
799
|
+
// make line pricing ambiguous.
|
|
800
|
+
const duplicates = (await client.executeKw(SUBSCRIPTION_PRICING, "search_read", [
|
|
801
|
+
[
|
|
802
|
+
["pricelist_id", "=", refs.pricelist.id],
|
|
803
|
+
["product_template_id", "=", refs.product.id],
|
|
804
|
+
["plan_id", "=", refs.plan.id],
|
|
805
|
+
],
|
|
806
|
+
], { fields: ["id", "price"], limit: 5 }));
|
|
807
|
+
if (duplicates.length > 0) {
|
|
808
|
+
throw conflictError("duplicate_pricing", `a pricing rule already exists for pricelist ${refs.pricelist.id}, product ${refs.product.id}, plan ${refs.plan.id} (id ${duplicates[0].id}, price ${duplicates[0].price})`, `use 'sales plans pricing update ${duplicates[0].id}' to change it`);
|
|
809
|
+
}
|
|
810
|
+
const values = {
|
|
811
|
+
pricelist_id: refs.pricelist.id,
|
|
812
|
+
product_template_id: refs.product.id,
|
|
813
|
+
plan_id: refs.plan.id,
|
|
814
|
+
price,
|
|
815
|
+
};
|
|
816
|
+
if (globals.dryRun) {
|
|
817
|
+
if (globals.json) {
|
|
818
|
+
printJson({
|
|
819
|
+
model: SUBSCRIPTION_PRICING,
|
|
820
|
+
action: "create",
|
|
821
|
+
dry_run: true,
|
|
822
|
+
values,
|
|
823
|
+
});
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const lines = [
|
|
827
|
+
`Would create ${SUBSCRIPTION_PRICING}:`,
|
|
828
|
+
` pricelist_id: [${refs.pricelist.id}, ${JSON.stringify(refs.pricelist.name)}]`,
|
|
829
|
+
` product_template_id: [${refs.product.id}, ${JSON.stringify(refs.product.name)}]`,
|
|
830
|
+
` plan_id: [${refs.plan.id}, ${JSON.stringify(refs.plan.name)}]`,
|
|
831
|
+
` price: ${price}`,
|
|
832
|
+
];
|
|
833
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
let id;
|
|
837
|
+
try {
|
|
838
|
+
id = await client.executeKw(SUBSCRIPTION_PRICING, "create", [values]);
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
if (!(error instanceof XmlRpcFaultError) &&
|
|
842
|
+
error.exitCode === 6) {
|
|
843
|
+
throw networkError(error.message, "the request reached the server; the record may have been created — verify with list before retrying");
|
|
844
|
+
}
|
|
845
|
+
if (error instanceof XmlRpcFaultError) {
|
|
846
|
+
throw conflictError("server_fault", `the server rejected the create (fault ${error.faultCode})`, "retry with --verbose to see the full server response");
|
|
847
|
+
}
|
|
848
|
+
throw error;
|
|
849
|
+
}
|
|
850
|
+
if (typeof id !== "number" || !Number.isInteger(id)) {
|
|
851
|
+
throw networkError(`unexpected create() response from the server: ${JSON.stringify(id)}`);
|
|
852
|
+
}
|
|
853
|
+
if (globals.json) {
|
|
854
|
+
printJson({
|
|
855
|
+
model: SUBSCRIPTION_PRICING,
|
|
856
|
+
action: "create",
|
|
857
|
+
dry_run: false,
|
|
858
|
+
id,
|
|
859
|
+
});
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
863
|
+
const line = `Created ${SUBSCRIPTION_PRICING} ${id}`;
|
|
864
|
+
process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
|
|
865
|
+
}
|
|
866
|
+
export async function plansPricingUpdateCommand(idArg, opts, globals) {
|
|
867
|
+
const pricingId = parseId(idArg);
|
|
868
|
+
const client = await authenticatedClient(globals);
|
|
869
|
+
await ModelFields.load(client, SUBSCRIPTION_PRICING);
|
|
870
|
+
const refs = await resolvePricingRefs(client, opts, globals);
|
|
871
|
+
const values = {};
|
|
872
|
+
if (refs.pricelist)
|
|
873
|
+
values.pricelist_id = refs.pricelist.id;
|
|
874
|
+
if (refs.product)
|
|
875
|
+
values.product_template_id = refs.product.id;
|
|
876
|
+
if (refs.plan)
|
|
877
|
+
values.plan_id = refs.plan.id;
|
|
878
|
+
if (opts.price !== undefined)
|
|
879
|
+
values.price = parseDecimal(opts.price, "--price");
|
|
880
|
+
if (Object.keys(values).length === 0) {
|
|
881
|
+
throw usageError("nothing to update", "pass at least one of --pricelist, --product, --plan, --price");
|
|
882
|
+
}
|
|
883
|
+
// Read-first pass: existence check + diff, mirroring orders update.
|
|
884
|
+
const rows = (await client.executeKw(SUBSCRIPTION_PRICING, "read", [
|
|
885
|
+
[pricingId],
|
|
886
|
+
], { fields: PRICING_FIELDS }));
|
|
887
|
+
if (rows.length === 0) {
|
|
888
|
+
throw notFoundError("record_not_found", `no ${SUBSCRIPTION_PRICING} record with id ${pricingId}`);
|
|
889
|
+
}
|
|
890
|
+
const rec = rows[0];
|
|
891
|
+
const changes = {};
|
|
892
|
+
for (const [field, after] of Object.entries(values)) {
|
|
893
|
+
const before = rec[field];
|
|
894
|
+
const beforeId = Array.isArray(before) ? before[0] : before;
|
|
895
|
+
if (beforeId !== after) {
|
|
896
|
+
changes[field] = { before, after };
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (globals.dryRun) {
|
|
900
|
+
if (globals.json) {
|
|
901
|
+
printJson({
|
|
902
|
+
model: SUBSCRIPTION_PRICING,
|
|
903
|
+
action: "update",
|
|
904
|
+
dry_run: true,
|
|
905
|
+
changes: { [String(pricingId)]: changes },
|
|
906
|
+
});
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const lines = [];
|
|
910
|
+
if (Object.keys(changes).length === 0) {
|
|
911
|
+
lines.push(`Unchanged ${SUBSCRIPTION_PRICING} ${pricingId} — nothing to change`);
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
lines.push(`Would update ${SUBSCRIPTION_PRICING} ${pricingId}:`);
|
|
915
|
+
for (const [field, change] of Object.entries(changes)) {
|
|
916
|
+
lines.push(` ${field}: ${humanCell(change.before)} -> ${humanCell(change.after)}`);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (Object.keys(changes).length > 0) {
|
|
923
|
+
try {
|
|
924
|
+
await client.executeKw(SUBSCRIPTION_PRICING, "write", [
|
|
925
|
+
[pricingId],
|
|
926
|
+
values,
|
|
927
|
+
]);
|
|
928
|
+
}
|
|
929
|
+
catch (error) {
|
|
930
|
+
if (error instanceof XmlRpcFaultError) {
|
|
931
|
+
throw conflictError("server_fault", `the server rejected the update (fault ${error.faultCode})`, "retry with --verbose to see the full server response");
|
|
932
|
+
}
|
|
933
|
+
throw error;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (globals.json) {
|
|
937
|
+
printJson({
|
|
938
|
+
model: SUBSCRIPTION_PRICING,
|
|
939
|
+
action: "update",
|
|
940
|
+
dry_run: false,
|
|
941
|
+
changes: { [String(pricingId)]: changes },
|
|
942
|
+
});
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
946
|
+
const line = Object.keys(changes).length === 0
|
|
947
|
+
? `Unchanged ${SUBSCRIPTION_PRICING} ${pricingId}`
|
|
948
|
+
: `Updated ${SUBSCRIPTION_PRICING} ${pricingId}`;
|
|
949
|
+
process.stdout.write((Object.keys(changes).length === 0
|
|
950
|
+
? line
|
|
951
|
+
: color
|
|
952
|
+
? `\x1b[33m${line}\x1b[0m`
|
|
953
|
+
: line) + "\n");
|
|
954
|
+
}
|
|
727
955
|
export async function invoicesPrecedentCommand(opts, globals) {
|
|
728
956
|
const client = await authenticatedClient(globals);
|
|
729
957
|
if (!opts.product) {
|
package/dist/filter.js
CHANGED
|
@@ -33,7 +33,7 @@ export function parseFilterScalar(raw) {
|
|
|
33
33
|
* many2one fields only accept numeric ids — a non-numeric value is a usage
|
|
34
34
|
* error pointing at the resolution flags, never a silently empty result.
|
|
35
35
|
*/
|
|
36
|
-
export function buildFilterClauses(filters, fields, resolutionHints) {
|
|
36
|
+
export function buildFilterClauses(filters, fields, resolutionHints, unknownFieldHint) {
|
|
37
37
|
const clauses = [];
|
|
38
38
|
for (const filter of filters) {
|
|
39
39
|
const eq = filter.indexOf("=");
|
|
@@ -44,7 +44,8 @@ export function buildFilterClauses(filters, fields, resolutionHints) {
|
|
|
44
44
|
const rawValue = filter.slice(eq + 1);
|
|
45
45
|
const meta = fields.get(field);
|
|
46
46
|
if (!meta) {
|
|
47
|
-
throw usageError(`unknown field ${JSON.stringify(field)} in --filter`,
|
|
47
|
+
throw usageError(`unknown field ${JSON.stringify(field)} in --filter`, unknownFieldHint ??
|
|
48
|
+
"check the field name with `odoo crm leads show <id> --all-fields`");
|
|
48
49
|
}
|
|
49
50
|
const value = parseFilterScalar(rawValue);
|
|
50
51
|
if (meta.type === "many2one" &&
|