@bergetai/odoo-cli 0.12.0 → 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.
package/README.md CHANGED
@@ -60,6 +60,17 @@ odoo documents records list --folder "Projects > Product Backlog" # nested path
60
60
  odoo documents records show 25
61
61
  odoo documents records download 25 --out ./spec.pdf
62
62
 
63
+ # Sales (sale.order) — quotations; sending stays a human step in the Odoo UI
64
+ odoo sales orders list --state sent --partner "Softmizer" --json
65
+ odoo sales orders read 11515 # header + partner + order lines
66
+ odoo sales orders create --partner 17682 --plan Monthly --lines lines.json --dry-run
67
+ odoo sales orders update 11515 --set plan_id=2 --dry-run
68
+ odoo sales lines add 11515 --lines extra.json
69
+ odoo sales lines update 11818 --lines fix.json
70
+ odoo sales plans list
71
+ odoo sales invoices precedent --product 188 # invoicing currency history
72
+ odoo sales pdf 11515 --out ./quotation.pdf
73
+
63
74
  # Mutations (support --dry-run)
64
75
  odoo crm leads create --name "New lead" --email info@example.com
65
76
  odoo crm leads update 42 --stage Won
@@ -144,6 +155,25 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
144
155
 
145
156
  ## Features
146
157
 
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
159
+ - Signature pipeline (`sign`) — Odoo Sign with safety rails learned from live incidents:
160
+ - `sign templates list/show` and `sign templates create-from-document <id>` — builds a
161
+ `sign.template` from a Documents file, converting non-PDF files (`.docx` etc.) to PDF through
162
+ the self-hosted OnlyOffice Document Server (connection + JWT read from
163
+ `ir.config_parameter` `euro_office_connector.*`; temp attachment with `access_token`,
164
+ cleaned up after conversion)
165
+ - `sign items-add <template> --file items.json` — place signature/date/text fields from a JSON
166
+ spec (coordinates in % of page; type and role resolve by id, `item_type`, or name). Added via
167
+ the template's one2many because direct `sign.item.create` hits an ACL on this build
168
+ - `sign requests list` and `sign requests create <template>` — **no draft state exists**:
169
+ creating a request with partners in default state emails them in the same transaction
170
+ (verified incident 2026-09-23). Default is therefore a `shared` link (anonymous signer, no
171
+ email) with a printed sign URL; `--state sent --signer … --role … --subject …` requires an
172
+ explicit `--confirm-send` and goes through the `sign.send.request` wizard so the email
173
+ carries a subject AND the signing link (a bare state flip sends a body-less email). A UTC
174
+ `mail.mail` audit is printed after send; `sign requests cancel <id>` is the safety valve.
175
+ Shared links require single-role templates (the guard errors with guidance on multi-role
176
+ templates, since the server only accepts one anonymous signer per link)
147
177
  - Footprint overview (`footprint <email>`) — one read-only invocation that enumerates everything tied to an email across the erasure-relevant modules (`res.partner` incl. archived, `res.users`, `crm.lead`, `mailing.*`, `helpdesk.ticket`, `mail.message`, `ir.attachment`), correlates the found partners into the RESTRICT accounting surfaces (`account.move`/`sale.order` incl. invoice & shipping addresses, `account.payment`), and classifies every record by erasure disposition: `delete`, `anonymize` (referenced — shell, never unlink), `keep` (bokföringslagen 7-year retention), `review` (account roots, legal-claim branch, internal users, unverifiable attachment parents). `--json` emits a structured per-group payload (fixed 11-group key order, `totalRecords`, per-group `found`/`total` server count/`truncated`/`dispositions` tally, many2one tuples normalized to `{id, name}`) with a `commandHint` on each actionable record, a top-level `runOrder` (SOP execution order), and decision-grade fields on partner records (`child_ids`/`user_ids`/`vat`). Review records with out-of-footprint parents are resolved inline — the parent's name is embedded in the reason (bounded by a per-model fetch cap; beyond it records keep an `inspectHint`). Every record tells you what to run next — or already answers the question. Read-only by design: verification (art. 12.6) is a human step and the argument must already be the verified email; record-rule hidden rows (e.g. `user_notification` messages) mean the raw SQL pre-flight stays the authoritative evidence. Never mutates anything
148
178
  - 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
149
179
  - 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
package/dist/cli.js CHANGED
@@ -12,6 +12,8 @@ import { versionCommand } from "./commands/version.js";
12
12
  import { partnersCreateCommand, partnersDeleteCommand, partnersListCommand, partnersShowCommand, partnersTagsCommand, partnersUpdateCommand, resPartner, } from "./commands/partners.js";
13
13
  import { usersDeleteCommand, usersShowCommand, } from "./commands/users.js";
14
14
  import { attachmentsDeleteCommand, attachmentsDownloadCommand, attachmentsListCommand, attachmentsShowCommand, } from "./commands/attachments.js";
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";
15
17
  import { messagesDeleteCommand, messagesListCommand, messagesScrubCommand, messagesShowCommand, } from "./commands/messages.js";
16
18
  import { contactsDeleteCommand, contactsListCommand, contactsShowCommand, listsListCommand, listsShowCommand, tracesDeleteCommand, tracesListCommand, tracesShowCommand, } from "./commands/marketing.js";
17
19
  import { modelsFieldsCommand, modelsReadCommand, } from "./commands/models.js";
@@ -821,6 +823,252 @@ Examples:
821
823
  .action(async (ids) => {
822
824
  await run((g) => attachmentsDeleteCommand(ids, g), program);
823
825
  });
826
+ const sign = program
827
+ .command("sign")
828
+ .description("Sign app (sign.template, sign.request) — safe signing pipeline");
829
+ const signTemplates = sign
830
+ .command("templates")
831
+ .description("signature templates");
832
+ signTemplates
833
+ .command("list")
834
+ .description("list signature templates")
835
+ .option("--archived", "include archived templates")
836
+ .option("--limit <n>", "max templates (default: 50)", "50")
837
+ .option("--json", "emit a single JSON object on stdout")
838
+ .action(async (opts) => {
839
+ await run((g) => signTemplatesListCommand(opts, g), program);
840
+ });
841
+ signTemplates
842
+ .command("show")
843
+ .description("read one signature template")
844
+ .argument("<id>", "sign.template id")
845
+ .option("--all-fields", "display every readable field")
846
+ .option("--json", "emit a single JSON object on stdout")
847
+ .action(async (id, opts) => {
848
+ await run((g) => signTemplatesShowCommand(id, opts, g), program);
849
+ });
850
+ signTemplates
851
+ .command("create-from-document")
852
+ .description("create a sign.template from a Documents file (converts non-PDF via the Document Server)")
853
+ .argument("<document>", "documents.document id")
854
+ .option("--name <name>", "template name (default: document name)")
855
+ .option("--json", "emit a single JSON object on stdout")
856
+ .action(async (doc, opts) => {
857
+ await run((g) => signTemplatesCreateFromDocumentCommand(doc, opts, g), program);
858
+ });
859
+ sign
860
+ .command("items-add")
861
+ .description("add signature fields from a JSON spec, via the template (direct sign.item writes are ACL-blocked)")
862
+ .argument("<template>", "sign.template id")
863
+ .requiredOption("--file <path>", "JSON items spec (coordinates in % of page)")
864
+ .option("--json", "emit a single JSON object on stdout")
865
+ .addHelpText("after", `
866
+ Spec: [{"type":"signature","page":3,"x":12,"y":65,"width":24,"height":6,
867
+ "role":"Standard","name":"Signature","required":true}]
868
+ "type" accepts a sign.item.type id, item_type (signature/date/text/...),
869
+ or a name; "role" a sign.item.role id or name.
870
+
871
+ Examples:
872
+ $ odoo sign items-add 12 --file items.json --dry-run
873
+ $ odoo sign items-add 12 --file items.json`)
874
+ .action(async (templateId, opts) => {
875
+ await run((g) => signItemsAddCommand(templateId, opts, g), program);
876
+ });
877
+ const signRequests = sign
878
+ .command("requests")
879
+ .description("signature requests");
880
+ signRequests
881
+ .command("list")
882
+ .description("list signature requests (newest first)")
883
+ .option("--state <state>", "filter by state (shared/sent/completed/canceled)")
884
+ .option("--limit <n>", "max requests (default: 30)", "30")
885
+ .option("--json", "emit a single JSON object on stdout")
886
+ .action(async (opts) => {
887
+ await run((g) => signRequestsListCommand(opts, g), program);
888
+ });
889
+ signRequests
890
+ .command("create")
891
+ .description("create a signature request. Default: shared draft link (anonymous, no email). --state sent emails the signers — requires --confirm-send")
892
+ .argument("<template>", "sign.template id")
893
+ .option("-s, --state <state>", "shared (default) or sent")
894
+ .option("--role <name|id>", "role for shared links (exactly one); repeatable for --state sent", collect, [])
895
+ .option("--signer <name|id>", "signer (partner email or id) for --state sent; one per --role", collect, [])
896
+ .option("--subject <subject>", "email subject (required with --state sent)")
897
+ .option("--message <text>", "email message body (optional)")
898
+ .option("--confirm-send", "required with --state sent — emails go out immediately")
899
+ .option("--json", "emit a single JSON object on stdout")
900
+ .addHelpText("after", `
901
+ sign.request has NO draft state: creating a request with partners emails
902
+ them in the same transaction. Shared links are the only safe pre-state.
903
+
904
+ Examples:
905
+ $ odoo sign requests create 12 --role Customer --json
906
+ $ odoo sign requests create 12 --state sent --subject "Sign" \\
907
+ --signer markus@example.com --role Customer --confirm-send`)
908
+ .action(async (templateId, opts) => {
909
+ await run((g) => signRequestsCreateCommand(templateId, opts, g), program);
910
+ });
911
+ signRequests
912
+ .command("cancel")
913
+ .description("cancel a signature request (safety valve; preview with --dry-run)")
914
+ .argument("<id>", "sign.request id")
915
+ .option("--json", "emit a single JSON object on stdout")
916
+ .action(async (id, opts) => {
917
+ await run((g) => signRequestsCancelCommand(id, opts, g), program);
918
+ });
919
+ const sales = program
920
+ .command("sales")
921
+ .description("Sales app (sale.order, sale.order.line, sale.subscription.plan)")
922
+ .addHelpText("after", `
923
+ Sending quotations is done by a human via 'Send by Email' in the Odoo UI
924
+ (gives the customer Accept & Sign). Not available in this CLI by policy
925
+ (2026-09-24).`);
926
+ const salesOrders = sales
927
+ .command("orders")
928
+ .description("quotations and sales orders (sale.order)");
929
+ salesOrders
930
+ .command("list")
931
+ .description("search and list orders with curated default columns")
932
+ .option("--partner <name|id>", "filter by customer (resolved by name)")
933
+ .option("--state <state>", "filter by state: draft, sent, sale, cancel")
934
+ .option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
935
+ .option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
936
+ .option("--fields <a,b,c>", "columns to display")
937
+ .option("--limit <n>", "max records (default: 50)", "50")
938
+ .option("--offset <n>", "skip the first n records", "0")
939
+ .option("--order <order>", 'sort order, e.g. "amount_total desc"', undefined)
940
+ .action(async (opts) => {
941
+ await run((g) => ordersListCommand(opts, g), program);
942
+ });
943
+ salesOrders
944
+ .command("read")
945
+ .description("read one order in full, including order lines and partner details")
946
+ .argument("<id>", "order id")
947
+ .action(async (id) => {
948
+ await run((g) => ordersReadCommand(id, g), program);
949
+ });
950
+ salesOrders
951
+ .command("create")
952
+ .description("create a quotation (preview with --dry-run)")
953
+ .requiredOption("--partner <id|name>", "customer (res.partner id, or name)")
954
+ .option("--pricelist <id|name>", "pricelist (product.pricelist)")
955
+ .option("--payment-terms <id|name>", "payment terms (account.payment.term)")
956
+ .option("--validity <yyyy-mm-dd>", "quotation validity date")
957
+ .option("--opportunity <id|name>", "linked opportunity (crm.lead)")
958
+ .option("--salesperson <id|email|name>", "salesperson (res.users)")
959
+ .option("--plan <id|name>", "subscription plan (sale.subscription.plan)")
960
+ .option("--note <html>", "terms & conditions note (HTML allowed)")
961
+ .option("--lines <file>", "order lines as a JSON array from a file ('-' for stdin)")
962
+ .addHelpText("after", `
963
+ Line JSON format: an array of objects —
964
+ {"display_type":"line_section","name":"Section title"}
965
+ {"display_type":"line_note","name":"Note text"}
966
+ {"product_id":188,"product_uom_qty":1,"price_unit":150,"tax_ids":[37]}
967
+ On product lines, name defaults to the product name and price_unit to the
968
+ product list price; product_uom_qty defaults to 1.
969
+
970
+ Examples:
971
+ $ odoo sales orders create --partner 17682 --lines lines.json --dry-run
972
+ $ odoo sales orders create --partner "Acme AB" --pricelist "SEK price list" \\
973
+ --payment-terms "30 Days" --validity 2026-10-24 --plan Monthly --lines -`)
974
+ .action(async (opts) => {
975
+ await run((g) => ordersCreateCommand(opts, g), program);
976
+ });
977
+ salesOrders
978
+ .command("update")
979
+ .description("update order header fields (preview with --dry-run; state is NOT settable)")
980
+ .argument("<id>", "order id")
981
+ .option("--set <f=v>", "set a header field (plan_id, pricelist_id, payment_term_id, validity_date, note, opportunity_id, user_id); repeatable", collect, [])
982
+ .addHelpText("after", `
983
+ State changes (confirm/cancel) are deliberately NOT possible here — they
984
+ happen in the Odoo UI by a human. Sending quotations is likewise a human
985
+ step ('Send by Email' in the UI). Order lines are managed via 'sales lines'.
986
+
987
+ Examples:
988
+ $ odoo sales orders update 11515 --set plan_id=1 --dry-run
989
+ $ odoo sales orders update 11515 --set validity_date=2026-10-24 --set note="<p>Updated</p>"`)
990
+ .action(async (id, opts) => {
991
+ await run((g) => ordersUpdateCommand(id, opts, g), program);
992
+ });
993
+ const salesLines = sales
994
+ .command("lines")
995
+ .description("order lines (sale.order.line)");
996
+ salesLines
997
+ .command("add")
998
+ .description("add order lines to an order (preview with --dry-run)")
999
+ .argument("<order-id>", "sale.order id")
1000
+ .requiredOption("--lines <file>", "order lines as a JSON array from a file ('-' for stdin)")
1001
+ .addHelpText("after", `
1002
+ Same line JSON format as 'sales orders create'.
1003
+
1004
+ Examples:
1005
+ $ odoo sales lines add 11515 --lines extra-lines.json --dry-run
1006
+ $ echo '[{"product_id":188,"product_uom_qty":1,"price_unit":150,"tax_ids":[37]}]' | odoo sales lines add 11515 --lines -`)
1007
+ .action(async (orderId, opts) => {
1008
+ await run((g) => linesAddCommand(orderId, opts, g), program);
1009
+ });
1010
+ salesLines
1011
+ .command("update")
1012
+ .description("update one order line (preview with --dry-run)")
1013
+ .argument("<line-id>", "sale.order.line id")
1014
+ .requiredOption("--lines <file>", "single line object as JSON from a file ('-' for stdin)")
1015
+ .addHelpText("after", `
1016
+ The JSON must contain exactly one line object; only the keys present are
1017
+ updated (display_type, name, product_id, product_uom_qty, price_unit,
1018
+ tax_ids).
1019
+
1020
+ Examples:
1021
+ $ odoo sales lines update 20123 --lines line-fix.json --dry-run
1022
+ $ echo '[{"price_unit":175}]' | odoo sales lines update 20123 --lines -`)
1023
+ .action(async (lineId, opts) => {
1024
+ await run((g) => linesUpdateCommand(lineId, opts, g), program);
1025
+ });
1026
+ const salesPlans = sales
1027
+ .command("plans")
1028
+ .description("subscription plans (sale.subscription.plan)");
1029
+ salesPlans
1030
+ .command("list")
1031
+ .description("list subscription plans")
1032
+ .option("--limit <n>", "max records (default: 50)", "50")
1033
+ .action(async (opts) => {
1034
+ await run((g) => plansListCommand(opts, g), program);
1035
+ });
1036
+ const salesInvoices = sales
1037
+ .command("invoices")
1038
+ .description("invoicing reports (account.move.line)");
1039
+ salesInvoices
1040
+ .command("precedent")
1041
+ .description("posted customer-invoice lines for a product (newest first) — invoicing currency precedent")
1042
+ .requiredOption("--product <id|name>", "product (product.product)")
1043
+ .option("--limit <n>", "max records (default: 20)", "20")
1044
+ .addHelpText("after", `
1045
+ Searches account.move.line where the product matches and the parent move is
1046
+ a posted customer invoice (move_type=out_invoice, state=posted). Use it to
1047
+ determine the invoicing currency precedent for a product before quoting.
1048
+
1049
+ Examples:
1050
+ $ odoo sales invoices precedent --product 188
1051
+ $ odoo sales invoices precedent --product 188 --limit 5 --json`)
1052
+ .action(async (opts) => {
1053
+ await run((g) => invoicesPrecedentCommand(opts, g), program);
1054
+ });
1055
+ sales
1056
+ .command("pdf")
1057
+ .description("download the portal quotation PDF for an order")
1058
+ .argument("<id>", "order id")
1059
+ .requiredOption("--out <path>", "output path for the PDF")
1060
+ .option("--force", "overwrite an existing output file")
1061
+ .addHelpText("after", `
1062
+ Reads the order's access_token (falling back to get_portal_url) and
1063
+ downloads {base_url}/my/orders/<id>?access_token=<token>&report_type=pdf.
1064
+ The downloaded file is verified to start with %PDF.
1065
+
1066
+ Examples:
1067
+ $ odoo sales pdf 11515 --out ./quotation.pdf
1068
+ $ odoo sales pdf 11515 --out ./quotation.pdf --dry-run`)
1069
+ .action(async (id, opts) => {
1070
+ await run((g) => pdfCommand(id, opts, g), program);
1071
+ });
824
1072
  const messages = program
825
1073
  .command("messages")
826
1074
  .description("chatter messages (mail.message), erasure-scoped");