@bergetai/odoo-cli 0.8.1 → 0.9.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.
- package/README.md +11 -1
- package/dist/cli.js +47 -1
- package/dist/commands/models.js +127 -0
- package/dist/commands/partners.js +456 -15
- package/dist/model.js +22 -0
- package/dist/mutation.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,11 +26,20 @@ odoo version
|
|
|
26
26
|
|
|
27
27
|
# Partners (companies and contacts — res.partner)
|
|
28
28
|
odoo partners list --company --json
|
|
29
|
+
odoo partners list --search "acme" --tag "Partner" --json # ilike on name/email, tag filter
|
|
29
30
|
odoo partners show 51
|
|
31
|
+
odoo partners tags # tags with usage counts
|
|
30
32
|
odoo partners create --name "Acme Corp" --is-company true --email info@acme.example
|
|
31
33
|
odoo partners update 51 --email new@acme.example --dry-run
|
|
34
|
+
odoo partners update 51 --add-tag "AI" --remove-tag "Draft" --dry-run
|
|
32
35
|
odoo partners delete 54 --dry-run
|
|
33
36
|
|
|
37
|
+
# Generic model introspection (any model with an XML-RPC API)
|
|
38
|
+
odoo models fields res.partner --filter "^email" --json
|
|
39
|
+
odoo models fields crm.lead --custom # only x_* custom fields
|
|
40
|
+
odoo models read res.partner --ids 51,52 --fields name,city
|
|
41
|
+
odoo models read crm.lead --search "microsoft" --json # ilike on name (max 100)
|
|
42
|
+
|
|
34
43
|
# List and inspect records
|
|
35
44
|
odoo crm leads list --open --limit 20
|
|
36
45
|
odoo crm leads show 42
|
|
@@ -75,11 +84,12 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
|
|
|
75
84
|
## Features
|
|
76
85
|
|
|
77
86
|
- 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
|
|
78
|
-
- Partners surface (`partners list/show/create/update/delete`) — `res.partner` CRUD with `--company`/`--individual` filters, `--archived` (include inactive), first-class flags for `--name`, `--email`, `--phone`, `--website`, `--parent` (m2o), `--is-company`, `--street`, `--city`, `--zip`, `--country` (m2o), `--comment`, full `--dry-run` support, `--fields`/`--all-fields`, table/JSON output
|
|
87
|
+
- Partners surface (`partners list/show/tags/create/update/delete`) — `res.partner` CRUD with `--company`/`--individual` filters, `--search` (case-insensitive name/email match), `--tag` (repeatable, resolves `res.partner.category` by exact name then ilike), `--archived` (include inactive), curated list columns (ID, Name, Company Type, City, Email, Is a Company, Tags, Salesperson), curated `show` view with tag names, salesperson, customer/supplier ranks plus every populated `x_studio*` custom field, `partners tags` (usage counts via `read_group`, sorted by usage), first-class mutation flags for `--name`, `--email`, `--phone`, `--website`, `--parent` (m2o), `--is-company`, `--street`, `--city`, `--zip`, `--country` (m2o), `--comment`, `--add-tag`/`--remove-tag` on update (unknown tags fail on add; removal silently skips tags the record lacks; protected fields `company_type` and `x_studio_invoice_approved` refuse writes), full `--dry-run` support, `--fields`/`--all-fields`, table/JSON output
|
|
79
88
|
- Sales teams surface (`crm teams list/show/create/update/delete`) — `--archived` (include inactive teams), `--team-leader` (team leader, login/email/name), `--active`/`--use-leads`/`--use-opportunities`/`--invoiced-target`/`--color`/`--sequence`, full `--dry-run` support, `--fields`/`--all-fields`, table/JSON output
|
|
80
89
|
- Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
|
|
81
90
|
- Read-only Project surface (`project list/show`) — `--user` (project manager) and `--tag` filters, archived projects excluded by default, module-not-installed error (exit 4)
|
|
82
91
|
- 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)
|
|
92
|
+
- Generic model surface (`models fields/read`) — introspect and read 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). Unknown models/fields use the standard error taxonomy
|
|
83
93
|
- 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)
|
|
84
94
|
- 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`
|
|
85
95
|
- `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
|
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,8 @@ import { activitiesListCommand, activityDoneCommand, activityUpdateCommand, sche
|
|
|
9
9
|
import { createTaskCommand, projectListCommand, projectShowCommand, tasksListCommand, tasksShowCommand, } from "./commands/project.js";
|
|
10
10
|
import { documentsDocuments, documentsListCommand, foldersListCommand, documentsShowCommand, documentsCreateCommand, documentsUpdateCommand, documentsDeleteCommand, treeCommand, uploadCommand, downloadCommand, } from "./commands/documents.js";
|
|
11
11
|
import { versionCommand } from "./commands/version.js";
|
|
12
|
-
import { partnersCreateCommand, partnersDeleteCommand, partnersListCommand, partnersShowCommand, partnersUpdateCommand, resPartner, } from "./commands/partners.js";
|
|
12
|
+
import { partnersCreateCommand, partnersDeleteCommand, partnersListCommand, partnersShowCommand, partnersTagsCommand, partnersUpdateCommand, resPartner, } from "./commands/partners.js";
|
|
13
|
+
import { modelsFieldsCommand, modelsReadCommand, } from "./commands/models.js";
|
|
13
14
|
import { CliError } from "./errors.js";
|
|
14
15
|
import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
|
|
15
16
|
import { printError } from "./output.js";
|
|
@@ -669,6 +670,8 @@ Examples:
|
|
|
669
670
|
.option("--archived", "include archived partners (active = false) instead of active only")
|
|
670
671
|
.option("--company", "only companies (is_company = true)")
|
|
671
672
|
.option("--individual", "only individuals (is_company = false)")
|
|
673
|
+
.option("--search <text>", "ilike search on name or email")
|
|
674
|
+
.option("--tag <name|id>", "filter by tag; repeatable, AND-ed", collect, [])
|
|
672
675
|
.option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
|
|
673
676
|
.option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
|
|
674
677
|
.option("--fields <a,b,c>", "columns to display")
|
|
@@ -696,6 +699,8 @@ Examples:
|
|
|
696
699
|
.command("update")
|
|
697
700
|
.description("update partners (preview with --dry-run)"), resPartner)
|
|
698
701
|
.argument("<id...>", "record ids")
|
|
702
|
+
.option("--add-tag <name>", "add a tag (res.partner.category); repeatable", collect, [])
|
|
703
|
+
.option("--remove-tag <name>", "remove a tag; repeatable, silently skipped when not present", collect, [])
|
|
699
704
|
.action(async (ids, opts) => {
|
|
700
705
|
await run((g) => partnersUpdateCommand(ids, opts, g), program);
|
|
701
706
|
});
|
|
@@ -706,6 +711,47 @@ Examples:
|
|
|
706
711
|
.action(async (ids) => {
|
|
707
712
|
await run((g) => partnersDeleteCommand(ids, g), program);
|
|
708
713
|
});
|
|
714
|
+
partners
|
|
715
|
+
.command("tags")
|
|
716
|
+
.description("list contact tags (res.partner.category) with usage counts")
|
|
717
|
+
.addHelpText("after", `
|
|
718
|
+
Examples:
|
|
719
|
+
$ odoo partners tags
|
|
720
|
+
$ odoo partners tags --json`)
|
|
721
|
+
.action(async () => {
|
|
722
|
+
await run((g) => partnersTagsCommand(g), program);
|
|
723
|
+
});
|
|
724
|
+
const models = program
|
|
725
|
+
.command("models")
|
|
726
|
+
.description("generic model introspection (any model with an XML-RPC API)");
|
|
727
|
+
models
|
|
728
|
+
.command("fields")
|
|
729
|
+
.description("list a model's fields (fields_get with type metadata)")
|
|
730
|
+
.argument("<model>", "model technical name, e.g. res.partner")
|
|
731
|
+
.option("--filter <regex>", "case-insensitive regex on field name or label")
|
|
732
|
+
.option("--custom", "only custom (x_*) fields")
|
|
733
|
+
.addHelpText("after", `
|
|
734
|
+
Examples:
|
|
735
|
+
$ odoo models fields res.partner
|
|
736
|
+
$ odoo models fields res.partner --filter "^email" --json
|
|
737
|
+
$ odoo models fields crm.lead --custom`)
|
|
738
|
+
.action(async (model, opts) => {
|
|
739
|
+
await run((g) => modelsFieldsCommand(model, opts, g), program);
|
|
740
|
+
});
|
|
741
|
+
models
|
|
742
|
+
.command("read")
|
|
743
|
+
.description("read up to 100 records by ids or name search")
|
|
744
|
+
.argument("<model>", "model technical name, e.g. res.partner")
|
|
745
|
+
.option("--ids <1,2,3>", "comma-separated record ids")
|
|
746
|
+
.option("--search <text>", "ilike search on the name field (used when --ids is omitted)")
|
|
747
|
+
.option("--fields <a,b,c>", "fields to read (default: name)")
|
|
748
|
+
.addHelpText("after", `
|
|
749
|
+
Examples:
|
|
750
|
+
$ odoo models read res.partner --ids 51,52 --fields name,city
|
|
751
|
+
$ odoo models read crm.lead --search microsoft --json`)
|
|
752
|
+
.action(async (model, opts) => {
|
|
753
|
+
await run((g) => modelsReadCommand(model, opts, g), program);
|
|
754
|
+
});
|
|
709
755
|
return program;
|
|
710
756
|
}
|
|
711
757
|
async function run(fn, program) {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { notFoundError, usageError } from "../errors.js";
|
|
2
|
+
import { fieldsGet, ModelFields } from "../model.js";
|
|
3
|
+
import { colorEnabled, printJson } from "../output.js";
|
|
4
|
+
import { authenticatedClient } from "../resource.js";
|
|
5
|
+
import { formatTable, humanCell } from "../table.js";
|
|
6
|
+
const MAX_READ = 100;
|
|
7
|
+
function parseFieldList(raw) {
|
|
8
|
+
const fields = raw
|
|
9
|
+
.split(",")
|
|
10
|
+
.map((f) => f.trim())
|
|
11
|
+
.filter((f) => f !== "");
|
|
12
|
+
if (fields.length === 0) {
|
|
13
|
+
throw usageError("--fields requires at least one field name");
|
|
14
|
+
}
|
|
15
|
+
return fields;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `odoo models fields <model>` — generic schema introspection via
|
|
19
|
+
* fields_get. Any model reachable through execute_kw works; an unknown
|
|
20
|
+
* model surfaces as the model_not_found taxonomy (exit 4).
|
|
21
|
+
*/
|
|
22
|
+
export async function modelsFieldsCommand(model, opts, globals) {
|
|
23
|
+
const client = await authenticatedClient(globals);
|
|
24
|
+
const raw = await fieldsGet(client, model, [
|
|
25
|
+
"string",
|
|
26
|
+
"type",
|
|
27
|
+
"selection",
|
|
28
|
+
"relation",
|
|
29
|
+
"required",
|
|
30
|
+
"store",
|
|
31
|
+
]);
|
|
32
|
+
let regex;
|
|
33
|
+
if (opts.filter !== undefined) {
|
|
34
|
+
try {
|
|
35
|
+
regex = new RegExp(opts.filter, "i");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw usageError(`invalid --filter regex ${JSON.stringify(opts.filter)}`, "expected a valid regular expression");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const names = Object.keys(raw)
|
|
42
|
+
.filter((name) => {
|
|
43
|
+
if (opts.custom && !name.startsWith("x_"))
|
|
44
|
+
return false;
|
|
45
|
+
if (!regex)
|
|
46
|
+
return true;
|
|
47
|
+
return regex.test(name) || regex.test(String(raw[name]?.string ?? ""));
|
|
48
|
+
})
|
|
49
|
+
.sort((a, b) => a.localeCompare(b));
|
|
50
|
+
const fields = names.map((name) => {
|
|
51
|
+
const meta = raw[name] ?? {};
|
|
52
|
+
return {
|
|
53
|
+
name,
|
|
54
|
+
string: meta.string,
|
|
55
|
+
type: meta.type,
|
|
56
|
+
selection: meta.selection,
|
|
57
|
+
...(meta.relation !== undefined
|
|
58
|
+
? { relation: meta.relation }
|
|
59
|
+
: {}),
|
|
60
|
+
required: meta.required === true,
|
|
61
|
+
store: meta.store !== false,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
if (globals.json) {
|
|
65
|
+
printJson({ model, fields });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const headers = ["Field", "Type", "Label", "Relation", "Required", "Store"];
|
|
69
|
+
const rows = fields.map((f) => [
|
|
70
|
+
f.name,
|
|
71
|
+
String(f.type ?? ""),
|
|
72
|
+
String(f.string ?? ""),
|
|
73
|
+
String(f.relation ?? ""),
|
|
74
|
+
f.required ? "yes" : "",
|
|
75
|
+
f.store ? "" : "no",
|
|
76
|
+
]);
|
|
77
|
+
process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `odoo models read <model>` — generic record reads. Exact ids via --ids or
|
|
81
|
+
* a name ilike search via --search; at most 100 records per call.
|
|
82
|
+
*/
|
|
83
|
+
export async function modelsReadCommand(model, opts, globals) {
|
|
84
|
+
if (opts.ids === undefined && opts.search === undefined) {
|
|
85
|
+
throw usageError("pass --ids or --search", "--ids reads exact records; --search ilike-matches the name field");
|
|
86
|
+
}
|
|
87
|
+
const client = await authenticatedClient(globals);
|
|
88
|
+
const meta = await ModelFields.load(client, model);
|
|
89
|
+
const fields = opts.fields !== undefined ? parseFieldList(opts.fields) : ["name"];
|
|
90
|
+
for (const field of fields) {
|
|
91
|
+
if (field !== "id" && !meta.get(field)) {
|
|
92
|
+
throw usageError(`unknown field ${JSON.stringify(field)} on ${model}`, "run `odoo models fields <model>` to list available fields");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
let records;
|
|
96
|
+
if (opts.ids !== undefined) {
|
|
97
|
+
const idStrings = opts.ids
|
|
98
|
+
.split(",")
|
|
99
|
+
.map((s) => s.trim())
|
|
100
|
+
.filter((s) => s !== "");
|
|
101
|
+
if (idStrings.length === 0) {
|
|
102
|
+
throw usageError("--ids requires at least one record id");
|
|
103
|
+
}
|
|
104
|
+
for (const s of idStrings) {
|
|
105
|
+
if (!/^\d+$/.test(s)) {
|
|
106
|
+
throw usageError(`invalid record id ${JSON.stringify(s)}`, "ids are positive integers, comma-separated");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const ids = idStrings.map(Number);
|
|
110
|
+
records = (await client.executeKw(model, "read", [ids], {
|
|
111
|
+
fields,
|
|
112
|
+
}));
|
|
113
|
+
if (records.length === 0) {
|
|
114
|
+
throw notFoundError("record_not_found", `no ${model} record(s) with id(s): ${ids.join(", ")}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
records = (await client.executeKw(model, "search_read", [[["name", "ilike", opts.search]]], { fields, limit: MAX_READ, order: "id asc" }));
|
|
119
|
+
}
|
|
120
|
+
if (globals.json) {
|
|
121
|
+
printJson({ model, records, count: records.length, limit: MAX_READ });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const headers = fields.map((f) => meta.get(f)?.string ?? f);
|
|
125
|
+
const rows = records.map((r) => fields.map((f) => humanCell(r[f])));
|
|
126
|
+
process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
|
|
127
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { ModelFields } from "../model.js";
|
|
2
|
-
import { authenticatedClient, showCommand, } from "../resource.js";
|
|
3
|
-
import { createCommand, deleteCommand,
|
|
1
|
+
import { ModelFields, resolveReference, } from "../model.js";
|
|
2
|
+
import { authenticatedClient, parseId, showCommand, } from "../resource.js";
|
|
3
|
+
import { buildValues, createCommand, deleteCommand, fieldValuesEqual, isFlatPair, } from "../mutation.js";
|
|
4
4
|
import { buildFilterClauses, parseDomain } from "../filter.js";
|
|
5
|
-
import { usageError } from "../errors.js";
|
|
6
|
-
import { colorEnabled, printJson } from "../output.js";
|
|
7
|
-
import { formatTable, humanCell } from "../table.js";
|
|
5
|
+
import { conflictError, notFoundError, usageError, } from "../errors.js";
|
|
6
|
+
import { colorEnabled, printJson, verbose as logVerbose, } from "../output.js";
|
|
7
|
+
import { formatKeyValues, formatTable, humanCell } from "../table.js";
|
|
8
|
+
import { XmlRpcFaultError } from "../xmlrpc.js";
|
|
8
9
|
function parseYesNoFlag(raw, flagName) {
|
|
9
10
|
const v = raw.toLowerCase();
|
|
10
11
|
if (v === "true" || v === "1" || v === "yes")
|
|
@@ -117,18 +118,19 @@ export const resPartner = {
|
|
|
117
118
|
defaultListFields: [
|
|
118
119
|
"id",
|
|
119
120
|
"name",
|
|
121
|
+
"company_type",
|
|
120
122
|
"is_company",
|
|
123
|
+
"city",
|
|
121
124
|
"email",
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"active",
|
|
125
|
-
"create_date",
|
|
125
|
+
"category_id",
|
|
126
|
+
"user_id",
|
|
126
127
|
],
|
|
127
128
|
showExtraFields: [
|
|
128
129
|
"website",
|
|
130
|
+
"phone",
|
|
129
131
|
"street",
|
|
130
|
-
"city",
|
|
131
132
|
"zip",
|
|
133
|
+
"parent_id",
|
|
132
134
|
"country_id",
|
|
133
135
|
"comment",
|
|
134
136
|
"company_id",
|
|
@@ -188,6 +190,16 @@ export async function partnersListCommand(opts, globals) {
|
|
|
188
190
|
domain.push(["is_company", "=", true]);
|
|
189
191
|
if (opts.individual)
|
|
190
192
|
domain.push(["is_company", "=", false]);
|
|
193
|
+
if (opts.search) {
|
|
194
|
+
// case-insensitive match on name OR email (crm-style ilike);
|
|
195
|
+
// domains are flat: the operator is a top-level element
|
|
196
|
+
domain.push("|", ["name", "ilike", opts.search], ["email", "ilike", opts.search]);
|
|
197
|
+
}
|
|
198
|
+
for (const tag of opts.tag ?? []) {
|
|
199
|
+
const resolved = await resolvePartnerTag(client, tag);
|
|
200
|
+
domain.push(["category_id", "=", resolved.id]);
|
|
201
|
+
logVerbose(`resolved --tag "${tag}" -> [${resolved.id}, "${resolved.name}"]`, globals.verbose);
|
|
202
|
+
}
|
|
191
203
|
if (opts.domain) {
|
|
192
204
|
domain.push(...parseDomain(opts.domain));
|
|
193
205
|
}
|
|
@@ -225,20 +237,449 @@ export async function partnersListCommand(opts, globals) {
|
|
|
225
237
|
printJson({ model: "res.partner", records, count, limit, offset });
|
|
226
238
|
return;
|
|
227
239
|
}
|
|
240
|
+
const categoryMap = fields.includes("category_id")
|
|
241
|
+
? await loadCategoryNames(client)
|
|
242
|
+
: new Map();
|
|
228
243
|
const headers = fields.map((f) => fieldsMeta.get(f)?.string ?? f);
|
|
229
|
-
const rows = records.map((rec) => fields.map((f) =>
|
|
244
|
+
const rows = records.map((rec) => fields.map((f) => f === "category_id"
|
|
245
|
+
? categoryNames(categoryMap, rec[f])
|
|
246
|
+
: humanCell(rec[f])));
|
|
230
247
|
const color = colorEnabled(globals.noColor);
|
|
231
248
|
process.stdout.write(formatTable(headers, rows, color) + "\n");
|
|
232
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Curated show view (§7): the key partner fields plus every populated
|
|
252
|
+
* x_studio* custom field as `name=value`. Explicit --fields/--all-fields
|
|
253
|
+
* requests are delegated to the shared show engine.
|
|
254
|
+
*/
|
|
255
|
+
const PARTNER_SHOW_FIELDS = [
|
|
256
|
+
"id",
|
|
257
|
+
"name",
|
|
258
|
+
"company_type",
|
|
259
|
+
"is_company",
|
|
260
|
+
"parent_id",
|
|
261
|
+
"website",
|
|
262
|
+
"email",
|
|
263
|
+
"phone",
|
|
264
|
+
"street",
|
|
265
|
+
"city",
|
|
266
|
+
"zip",
|
|
267
|
+
"country_id",
|
|
268
|
+
"category_id",
|
|
269
|
+
"user_id",
|
|
270
|
+
"customer_rank",
|
|
271
|
+
"supplier_rank",
|
|
272
|
+
"comment",
|
|
273
|
+
];
|
|
274
|
+
function isPopulated(value) {
|
|
275
|
+
return !(value === false || value === null || value === undefined || value === "");
|
|
276
|
+
}
|
|
233
277
|
export function partnersShowCommand(idArg, opts, globals) {
|
|
234
|
-
|
|
278
|
+
if (opts.fields || opts.allFields) {
|
|
279
|
+
return showCommand(resPartner, idArg, opts, globals);
|
|
280
|
+
}
|
|
281
|
+
return partnersShowCurated(idArg, globals);
|
|
282
|
+
}
|
|
283
|
+
async function partnersShowCurated(idArg, globals) {
|
|
284
|
+
const client = await authenticatedClient(globals);
|
|
285
|
+
const id = parseId(idArg);
|
|
286
|
+
const meta = await ModelFields.load(client, "res.partner");
|
|
287
|
+
const fields = [
|
|
288
|
+
...PARTNER_SHOW_FIELDS,
|
|
289
|
+
...meta.names().filter((n) => n.startsWith("x_studio")),
|
|
290
|
+
].filter((f) => f === "id" || meta.get(f));
|
|
291
|
+
const rows = (await client.executeKw("res.partner", "read", [[id]], {
|
|
292
|
+
fields,
|
|
293
|
+
}));
|
|
294
|
+
if (rows.length === 0) {
|
|
295
|
+
throw notFoundError("record_not_found", `no res.partner record with id ${id}`);
|
|
296
|
+
}
|
|
297
|
+
const record = rows[0];
|
|
298
|
+
if (globals.json) {
|
|
299
|
+
const pruned = {};
|
|
300
|
+
for (const [k, v] of Object.entries(record)) {
|
|
301
|
+
if (k.startsWith("x_studio") && !isPopulated(v))
|
|
302
|
+
continue;
|
|
303
|
+
pruned[k] = v;
|
|
304
|
+
}
|
|
305
|
+
printJson({ model: "res.partner", record: pruned });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const categoryMap = fields.includes("category_id")
|
|
309
|
+
? await loadCategoryNames(client)
|
|
310
|
+
: new Map();
|
|
311
|
+
const pairs = fields
|
|
312
|
+
.filter((f) => {
|
|
313
|
+
if (!f.startsWith("x_studio"))
|
|
314
|
+
return true;
|
|
315
|
+
// only populated x_studio fields appear in the curated view
|
|
316
|
+
return isPopulated(record[f]);
|
|
317
|
+
})
|
|
318
|
+
.map((f) => [
|
|
319
|
+
// custom fields render as technical `name=value`; curated use labels
|
|
320
|
+
f.startsWith("x_studio") ? f : (meta.get(f)?.string ?? f),
|
|
321
|
+
f === "category_id"
|
|
322
|
+
? categoryNames(categoryMap, record[f])
|
|
323
|
+
: humanCell(record[f]),
|
|
324
|
+
]);
|
|
325
|
+
process.stdout.write(formatKeyValues(pairs, colorEnabled(globals.noColor)) + "\n");
|
|
235
326
|
}
|
|
236
327
|
export function partnersCreateCommand(opts, globals) {
|
|
237
328
|
return createCommand(resPartner, opts, globals);
|
|
238
329
|
}
|
|
239
|
-
|
|
240
|
-
|
|
330
|
+
/**
|
|
331
|
+
* Fields that partners update refuses to write. `company_type` conversion
|
|
332
|
+
* (company <-> person) carries nested cleanup (company fields, vat, ranks)
|
|
333
|
+
* the CLI cannot perform; the studio approval field is protected by default.
|
|
334
|
+
*/
|
|
335
|
+
const PROTECTED_PARTNER_FIELDS = new Set([
|
|
336
|
+
"company_type",
|
|
337
|
+
"x_studio_invoice_approved",
|
|
338
|
+
]);
|
|
339
|
+
/**
|
|
340
|
+
* Coerce a --set/--values value to the field's Odoo type (§9). Partners
|
|
341
|
+
* update accepts atomic types plus many2one via numeric id — many2many/
|
|
342
|
+
* one2many need tag commands or JSON command values via --values and are
|
|
343
|
+
* refused.
|
|
344
|
+
*/
|
|
345
|
+
function coerceSetScalar(field, meta, value) {
|
|
346
|
+
const type = meta?.type ?? "char";
|
|
347
|
+
const fail = (expected) => {
|
|
348
|
+
throw usageError(`--set ${field} expects ${expected}`, "run `odoo models fields res.partner --filter ...` to check the field type");
|
|
349
|
+
};
|
|
350
|
+
if (type === "boolean") {
|
|
351
|
+
if (value === true || value === "true" || value === "1" || value === 1) {
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
if (value === false || value === "false" || value === "0" || value === 0) {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
return fail("a boolean (true/false)");
|
|
358
|
+
}
|
|
359
|
+
if (type === "integer") {
|
|
360
|
+
if (typeof value === "number" && Number.isInteger(value))
|
|
361
|
+
return value;
|
|
362
|
+
if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
|
|
363
|
+
return Number(value.trim());
|
|
364
|
+
}
|
|
365
|
+
return fail("an integer");
|
|
366
|
+
}
|
|
367
|
+
if (type === "float" || type === "monetary") {
|
|
368
|
+
const n = typeof value === "number" ? value : Number(String(value).trim());
|
|
369
|
+
if (Number.isFinite(n))
|
|
370
|
+
return n;
|
|
371
|
+
return fail("a number");
|
|
372
|
+
}
|
|
373
|
+
if (type === "many2one") {
|
|
374
|
+
if (typeof value === "number" && Number.isInteger(value))
|
|
375
|
+
return value;
|
|
376
|
+
return fail("a numeric record id");
|
|
377
|
+
}
|
|
378
|
+
if (type === "many2many" || type === "one2many") {
|
|
379
|
+
throw usageError(`--set ${field} is not supported for ${type} fields`, "use --add-tag/--remove-tag for partner tags");
|
|
380
|
+
}
|
|
381
|
+
return value;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Resolve a partner tag (res.partner.category) by exact name first, then
|
|
385
|
+
* ilike via the shared reference resolution (§4.3: never guesses).
|
|
386
|
+
*/
|
|
387
|
+
async function resolvePartnerTag(client, name) {
|
|
388
|
+
const exact = (await client.executeKw("res.partner.category", "search_read", [[["name", "=", name]]], { fields: ["display_name"], limit: 1 }));
|
|
389
|
+
if (exact.length === 1) {
|
|
390
|
+
return { id: exact[0].id, name: exact[0].display_name };
|
|
391
|
+
}
|
|
392
|
+
return resolveReference(client, "res.partner.category", name, {
|
|
393
|
+
label: "tag",
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
async function loadCategoryNames(client) {
|
|
397
|
+
const rows = (await client.executeKw("res.partner.category", "search_read", [], { fields: ["id", "name"], limit: 5000, order: "id asc" }));
|
|
398
|
+
return new Map(rows.map((r) => [r.id, r.name]));
|
|
399
|
+
}
|
|
400
|
+
/** Render a category id list as comma-separated tag names (unknowns as ids). */
|
|
401
|
+
function categoryNames(map, value) {
|
|
402
|
+
if (!Array.isArray(value))
|
|
403
|
+
return humanCell(value);
|
|
404
|
+
return value.map((id) => map.get(id) ?? String(id)).join(", ");
|
|
405
|
+
}
|
|
406
|
+
function uniqueInOrder(values) {
|
|
407
|
+
return [...new Set(values)];
|
|
408
|
+
}
|
|
409
|
+
function wireScalar(value) {
|
|
410
|
+
if (typeof value === "string")
|
|
411
|
+
return JSON.stringify(value);
|
|
412
|
+
if (isFlatPair(value))
|
|
413
|
+
return `[${value[0]}, ${JSON.stringify(value[1])}]`;
|
|
414
|
+
if (Array.isArray(value)) {
|
|
415
|
+
return `[${value.map((v) => String(v)).join(", ")}]`;
|
|
416
|
+
}
|
|
417
|
+
if (value === false || value === null || value === undefined) {
|
|
418
|
+
return String(value);
|
|
419
|
+
}
|
|
420
|
+
return JSON.stringify(value) ?? String(value);
|
|
421
|
+
}
|
|
422
|
+
export async function partnersUpdateCommand(idArgs, opts, globals) {
|
|
423
|
+
const spec = resPartner.mutation;
|
|
424
|
+
if (!spec)
|
|
425
|
+
throw usageError("res.partner has no mutation support configured");
|
|
426
|
+
const addTag = (opts.addTag ?? []).filter((t) => t.trim() !== "");
|
|
427
|
+
const removeTag = (opts.removeTag ?? []).filter((t) => t.trim() !== "");
|
|
428
|
+
const tagOps = addTag.length > 0 || removeTag.length > 0;
|
|
429
|
+
const client = await authenticatedClient(globals);
|
|
430
|
+
const fieldsMeta = await ModelFields.load(client, "res.partner");
|
|
431
|
+
const built = await buildValues(client, resPartner, spec, opts, "update", globals);
|
|
432
|
+
for (const field of Object.keys(built.values)) {
|
|
433
|
+
if (PROTECTED_PARTNER_FIELDS.has(field)) {
|
|
434
|
+
throw conflictError("protected_field", `field "${field}" is protected and cannot be modified via partners update`, "company_type conversions and protected studio fields must be changed in the Odoo UI");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// --set values are validated against the field's Odoo type (atomic types
|
|
438
|
+
// + many2one by numeric id only). First-class flags already parse their
|
|
439
|
+
// own values, so only --set/--values pass through coercion here.
|
|
440
|
+
const flagFields = new Set(spec.flags.map((f) => f.field));
|
|
441
|
+
for (const [field, value] of Object.entries(built.values)) {
|
|
442
|
+
if (flagFields.has(field))
|
|
443
|
+
continue;
|
|
444
|
+
built.values[field] = coerceSetScalar(field, fieldsMeta.get(field), value);
|
|
445
|
+
}
|
|
446
|
+
if (Object.keys(built.values).length === 0 && !tagOps) {
|
|
447
|
+
throw usageError("nothing to update", "pass at least one field flag, --set, --values, --add-tag, or --remove-tag");
|
|
448
|
+
}
|
|
449
|
+
const ids = idArgs.map(parseId);
|
|
450
|
+
// Resolve tags before reading records so unknown --add-tag fails fast.
|
|
451
|
+
const addEntries = new Map();
|
|
452
|
+
for (const name of addTag) {
|
|
453
|
+
const resolved = await resolvePartnerTag(client, name);
|
|
454
|
+
addEntries.set(resolved.id, resolved.name);
|
|
455
|
+
}
|
|
456
|
+
const removeEntries = [];
|
|
457
|
+
const unresolvedRemoves = [];
|
|
458
|
+
for (const name of removeTag) {
|
|
459
|
+
try {
|
|
460
|
+
const resolved = await resolvePartnerTag(client, name);
|
|
461
|
+
removeEntries.push({ id: resolved.id, name: resolved.name });
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
// unknown tag: removal is silent — the end state is already reached
|
|
465
|
+
if (error.code === "reference_not_found") {
|
|
466
|
+
unresolvedRemoves.push(name);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const readFields = [
|
|
473
|
+
...new Set([...Object.keys(built.values), "display_name", "category_id"]),
|
|
474
|
+
];
|
|
475
|
+
const rows = (await client.executeKw("res.partner", "read", [ids], {
|
|
476
|
+
fields: readFields,
|
|
477
|
+
}));
|
|
478
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
479
|
+
const missing = ids.filter((id) => !byId.has(id));
|
|
480
|
+
if (missing.length > 0) {
|
|
481
|
+
throw notFoundError("record_not_found", `no res.partner record(s) with id(s): ${missing.join(", ")}`, "no records were modified — update aborts the whole batch when any id is missing");
|
|
482
|
+
}
|
|
483
|
+
const diffs = ids.map((id) => {
|
|
484
|
+
const rec = byId.get(id);
|
|
485
|
+
const changes = {};
|
|
486
|
+
for (const [field, after] of Object.entries(built.values)) {
|
|
487
|
+
const before = rec[field];
|
|
488
|
+
if (!fieldValuesEqual(fieldsMeta.get(field)?.type, before, after)) {
|
|
489
|
+
changes[field] = { before, after };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
let tagNext;
|
|
493
|
+
const tagAdded = [];
|
|
494
|
+
const tagRemoved = [];
|
|
495
|
+
const tagSkipped = [...unresolvedRemoves];
|
|
496
|
+
if (tagOps) {
|
|
497
|
+
const current = (rec.category_id ?? []).filter((n) => typeof n === "number");
|
|
498
|
+
const next = [...current];
|
|
499
|
+
for (const [tid, tname] of addEntries) {
|
|
500
|
+
if (!next.includes(tid)) {
|
|
501
|
+
next.push(tid);
|
|
502
|
+
tagAdded.push(tname);
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
tagSkipped.push(tname);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
for (const entry of removeEntries) {
|
|
509
|
+
const idx = next.indexOf(entry.id);
|
|
510
|
+
if (idx >= 0) {
|
|
511
|
+
next.splice(idx, 1);
|
|
512
|
+
tagRemoved.push(entry.name);
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
tagSkipped.push(entry.name);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (!fieldValuesEqual("many2many", current, [[6, 0, next]])) {
|
|
519
|
+
changes.category_id = { before: current, after: next };
|
|
520
|
+
tagNext = next;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
id,
|
|
525
|
+
display_name: String(rec.display_name ?? id),
|
|
526
|
+
changes,
|
|
527
|
+
tagNext,
|
|
528
|
+
tagAdded,
|
|
529
|
+
tagRemoved,
|
|
530
|
+
tagSkipped,
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
const addedTags = uniqueInOrder(diffs.flatMap((d) => d.tagAdded));
|
|
534
|
+
const removedTags = uniqueInOrder(diffs.flatMap((d) => d.tagRemoved));
|
|
535
|
+
const skippedTags = uniqueInOrder(diffs.flatMap((d) => d.tagSkipped));
|
|
536
|
+
// write first, then report (dry-run or post-write confirmation);
|
|
537
|
+
// no-op records are never written (§5.4 single-call atomic batches).
|
|
538
|
+
const changed = diffs.filter((d) => Object.keys(d.changes).length > 0);
|
|
539
|
+
if (!globals.dryRun && changed.length > 0) {
|
|
540
|
+
// write per distinct payload (tag sets may differ between records)
|
|
541
|
+
const groups = new Map();
|
|
542
|
+
for (const d of changed) {
|
|
543
|
+
const values = { ...built.values };
|
|
544
|
+
if (d.tagNext !== undefined) {
|
|
545
|
+
values.category_id = [[6, 0, d.tagNext]];
|
|
546
|
+
}
|
|
547
|
+
const key = JSON.stringify(values);
|
|
548
|
+
const group = groups.get(key);
|
|
549
|
+
if (group) {
|
|
550
|
+
group.ids.push(d.id);
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
groups.set(key, { ids: [d.id], values });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
for (const group of groups.values()) {
|
|
557
|
+
try {
|
|
558
|
+
await client.executeKw("res.partner", "write", [group.ids, group.values]);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
if (error instanceof XmlRpcFaultError) {
|
|
562
|
+
throw conflictError("server_fault", `the server rejected the update (fault ${error.faultCode})`, "retry with --verbose to see the full server response");
|
|
563
|
+
}
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (globals.json) {
|
|
569
|
+
const changes = {};
|
|
570
|
+
for (const d of diffs)
|
|
571
|
+
changes[String(d.id)] = d.changes;
|
|
572
|
+
printJson({
|
|
573
|
+
model: "res.partner",
|
|
574
|
+
action: "update",
|
|
575
|
+
dry_run: globals.dryRun,
|
|
576
|
+
changes,
|
|
577
|
+
...(addTag.length > 0 || removeTag.length > 0
|
|
578
|
+
? {
|
|
579
|
+
added_tags: addedTags,
|
|
580
|
+
removed_tags: removedTags,
|
|
581
|
+
skipped_tags: skippedTags,
|
|
582
|
+
}
|
|
583
|
+
: {}),
|
|
584
|
+
});
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (globals.dryRun) {
|
|
588
|
+
const tagNames = tagOps
|
|
589
|
+
? await loadCategoryNames(client)
|
|
590
|
+
: new Map();
|
|
591
|
+
process.stdout.write(renderPartnerUpdatePlan(diffs, built, tagNames));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
595
|
+
const lines = [];
|
|
596
|
+
for (const d of diffs) {
|
|
597
|
+
if (Object.keys(d.changes).length === 0) {
|
|
598
|
+
lines.push(`Unchanged res.partner ${d.id}`);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const line = `Updated res.partner ${d.id}`;
|
|
602
|
+
lines.push(color ? `\x1b[33m${line}\x1b[0m` : line);
|
|
603
|
+
}
|
|
604
|
+
if (addedTags.length > 0)
|
|
605
|
+
lines.push(` tags added: ${addedTags.join(", ")}`);
|
|
606
|
+
if (removedTags.length > 0) {
|
|
607
|
+
lines.push(` tags removed: ${removedTags.join(", ")}`);
|
|
608
|
+
}
|
|
609
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
610
|
+
}
|
|
611
|
+
function renderPartnerUpdatePlan(diffs, built, tagNames = new Map()) {
|
|
612
|
+
const lines = [];
|
|
613
|
+
for (const d of diffs) {
|
|
614
|
+
if (Object.keys(d.changes).length === 0) {
|
|
615
|
+
lines.push(`Unchanged res.partner ${d.id} (${d.display_name}) — nothing to change`);
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
lines.push(`Would update res.partner ${d.id} (${d.display_name}):`);
|
|
619
|
+
for (const [field, change] of Object.entries(d.changes)) {
|
|
620
|
+
if (field === "category_id") {
|
|
621
|
+
lines.push(` tags: ${categoryNames(tagNames, change.before)} -> ${categoryNames(tagNames, change.after)}`);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
const entry = built.resolved[field];
|
|
625
|
+
const after = entry && isFlatPair(entry) && !Array.isArray(change.after)
|
|
626
|
+
? entry
|
|
627
|
+
: change.after;
|
|
628
|
+
lines.push(` ${field}: ${wireScalar(change.before)} -> ${wireScalar(after)}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const added = uniqueInOrder(diffs.flatMap((d) => d.tagAdded));
|
|
632
|
+
const removed = uniqueInOrder(diffs.flatMap((d) => d.tagRemoved));
|
|
633
|
+
const skipped = uniqueInOrder(diffs.flatMap((d) => d.tagSkipped));
|
|
634
|
+
if (added.length > 0)
|
|
635
|
+
lines.push(` tags added: ${added.join(", ")}`);
|
|
636
|
+
if (removed.length > 0)
|
|
637
|
+
lines.push(` tags removed: ${removed.join(", ")}`);
|
|
638
|
+
if (skipped.length > 0) {
|
|
639
|
+
lines.push(` tags skipped (no change): ${skipped.join(", ")}`);
|
|
640
|
+
}
|
|
641
|
+
return lines.join("\n") + "\n";
|
|
241
642
|
}
|
|
242
643
|
export function partnersDeleteCommand(idArgs, globals) {
|
|
243
644
|
return deleteCommand(resPartner, idArgs, globals);
|
|
244
645
|
}
|
|
646
|
+
/**
|
|
647
|
+
* `odoo partners tags` — res.partner.category with usage counts. Counts come
|
|
648
|
+
* from a read_group over res.partner (active partners only, matching list
|
|
649
|
+
* defaults); unused categories report 0. Sorted by usage descending, then
|
|
650
|
+
* name ascending.
|
|
651
|
+
*/
|
|
652
|
+
export async function partnersTagsCommand(globals) {
|
|
653
|
+
const client = await authenticatedClient(globals);
|
|
654
|
+
await ModelFields.load(client, "res.partner.category");
|
|
655
|
+
const [categories, groups] = await Promise.all([
|
|
656
|
+
client.executeKw("res.partner.category", "search_read", [], { fields: ["id", "name", "parent_id"] }),
|
|
657
|
+
client.executeKw("res.partner", "read_group", [[], ["id"], ["category_id"]]),
|
|
658
|
+
]);
|
|
659
|
+
const counts = new Map();
|
|
660
|
+
for (const g of groups) {
|
|
661
|
+
const id = Array.isArray(g.category_id) ? g.category_id[0] : false;
|
|
662
|
+
if (id !== false)
|
|
663
|
+
counts.set(id, g.category_id_count ?? 0);
|
|
664
|
+
}
|
|
665
|
+
const rows = categories
|
|
666
|
+
.map((c) => ({
|
|
667
|
+
id: c.id,
|
|
668
|
+
name: c.name,
|
|
669
|
+
parent_id: c.parent_id,
|
|
670
|
+
partner_count: counts.get(c.id) ?? 0,
|
|
671
|
+
}))
|
|
672
|
+
.sort((a, b) => b.partner_count - a.partner_count || a.name.localeCompare(b.name));
|
|
673
|
+
if (globals.json) {
|
|
674
|
+
printJson({ model: "res.partner.category", categories: rows });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const headers = ["ID", "Name", "Parent", "PartnerCount"];
|
|
678
|
+
const table = rows.map((c) => [
|
|
679
|
+
String(c.id),
|
|
680
|
+
c.name,
|
|
681
|
+
humanCell(c.parent_id),
|
|
682
|
+
String(c.partner_count),
|
|
683
|
+
]);
|
|
684
|
+
process.stdout.write(formatTable(headers, table, colorEnabled(globals.noColor)) + "\n");
|
|
685
|
+
}
|
package/dist/model.js
CHANGED
|
@@ -37,6 +37,9 @@ export class ModelFields {
|
|
|
37
37
|
get(name) {
|
|
38
38
|
return this.fields[name];
|
|
39
39
|
}
|
|
40
|
+
names() {
|
|
41
|
+
return Object.keys(this.fields);
|
|
42
|
+
}
|
|
40
43
|
}
|
|
41
44
|
const INVALID_MODEL_RE = /(?:Invalid model|Model not found)[: ]+['"]?([\w.]+)['"]?/;
|
|
42
45
|
/**
|
|
@@ -54,6 +57,25 @@ function mapModelMissing(error, model) {
|
|
|
54
57
|
}
|
|
55
58
|
return error;
|
|
56
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* fields_get with caller-chosen attributes (used by the generic `models`
|
|
62
|
+
* surface). Unknown models are mapped onto the model_not_found taxonomy.
|
|
63
|
+
*/
|
|
64
|
+
export async function fieldsGet(client, model, attributes) {
|
|
65
|
+
let result;
|
|
66
|
+
try {
|
|
67
|
+
result = await client.executeKw(model, "fields_get", [[]], {
|
|
68
|
+
attributes,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw mapModelMissing(error, model);
|
|
73
|
+
}
|
|
74
|
+
if (typeof result !== "object" || result === null) {
|
|
75
|
+
throw notFoundError("model_not_found", `model ${JSON.stringify(model)} not found on the server`, `the ${model.split(".")[0]} module may not be installed`);
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
57
79
|
/**
|
|
58
80
|
* Resolve a record reference by numeric id or case-insensitive name
|
|
59
81
|
* (SPEC §4.3). Exactly one match required; zero matches list available
|
package/dist/mutation.js
CHANGED
|
@@ -370,7 +370,7 @@ export async function updateCommand(config, idArgs, opts, globals) {
|
|
|
370
370
|
* no-op detection (§5.4) — Odoo's `write` returns True whether or not anything
|
|
371
371
|
* changed, so this diffing is client-side only.
|
|
372
372
|
*/
|
|
373
|
-
function fieldValuesEqual(type, before, after) {
|
|
373
|
+
export function fieldValuesEqual(type, before, after) {
|
|
374
374
|
if (type === "many2one") {
|
|
375
375
|
const beforeId = Array.isArray(before) ? before[0] : before;
|
|
376
376
|
return beforeId === after;
|
|
@@ -530,7 +530,7 @@ function renderCreatePlan(config, built) {
|
|
|
530
530
|
return lines.join("\n") + "\n";
|
|
531
531
|
}
|
|
532
532
|
/** True for a single m2o resolved pair [id, "name"] stored flat. */
|
|
533
|
-
function isFlatPair(x) {
|
|
533
|
+
export function isFlatPair(x) {
|
|
534
534
|
return (Array.isArray(x) &&
|
|
535
535
|
x.length === 2 &&
|
|
536
536
|
typeof x[0] === "number" &&
|