@bergetai/odoo-cli 0.12.7 → 0.14.1
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 +13 -0
- package/dist/cli.js +114 -0
- package/dist/commands/expenses.js +350 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,6 +97,19 @@ odoo attachments download 31337 --out ./spec.pdf
|
|
|
97
97
|
odoo attachments delete 31337 --dry-run
|
|
98
98
|
odoo attachments delete 31337
|
|
99
99
|
|
|
100
|
+
# Expenses (hr.expense) — receipts attached as ir.attachment
|
|
101
|
+
odoo expenses list --json
|
|
102
|
+
odoo expenses show 137
|
|
103
|
+
odoo expenses categories --json
|
|
104
|
+
odoo expenses create --name "Taxi Arlanda" --date 2026-10-14 --amount 249.50 \
|
|
105
|
+
--currency SEK --category "Travel & Accommodation" --attach receipt.pdf --dry-run
|
|
106
|
+
odoo expenses create --name "Taxi Arlanda" --date 2026-10-14 --amount 249.50 \
|
|
107
|
+
--category "Travel & Accommodation" --attach receipt.pdf
|
|
108
|
+
odoo expenses attach 137 --file kvitto.pdf
|
|
109
|
+
odoo expenses delete 137 --dry-run
|
|
110
|
+
odoo expenses report create --name "Utlägg sept 2026" --expense 137 --expense 138 --submit --dry-run
|
|
111
|
+
odoo expenses report create --name "Utlägg sept 2026" --expense 137 --expense 138 --submit
|
|
112
|
+
|
|
100
113
|
# Marketing (mailing.*) — email is the key; trace deletion is always explicit
|
|
101
114
|
odoo marketing lists list
|
|
102
115
|
odoo marketing lists show 11
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ 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 { expensesAttachCommand, expensesCategoriesCommand, expensesCreateCommand, expensesDeleteCommand, expensesListCommand, expensesReportCreateCommand, expensesShowCommand, } from "./commands/expenses.js";
|
|
15
16
|
import { signTemplatesCreateFromDocumentCommand, signTemplatesListCommand, signTemplatesShowCommand, signItemsAddCommand, signRequestsCreateCommand, signRequestsListCommand, signRequestsCancelCommand, } from "./commands/sign.js";
|
|
16
17
|
import { invoicesPrecedentCommand, linesAddCommand, linesUpdateCommand, ordersCreateCommand, ordersListCommand, ordersReadCommand, ordersUpdateCommand, pdfCommand, plansListCommand, plansPricingCreateCommand, plansPricingListCommand, plansPricingUpdateCommand, } from "./commands/sales.js";
|
|
17
18
|
import { messagesDeleteCommand, messagesListCommand, messagesScrubCommand, messagesShowCommand, } from "./commands/messages.js";
|
|
@@ -823,6 +824,119 @@ Examples:
|
|
|
823
824
|
.action(async (ids) => {
|
|
824
825
|
await run((g) => attachmentsDeleteCommand(ids, g), program);
|
|
825
826
|
});
|
|
827
|
+
const expenses = program
|
|
828
|
+
.command("expenses")
|
|
829
|
+
.description("Expenses app (hr.expense, Enterprise) — receipts and outlays");
|
|
830
|
+
expenses
|
|
831
|
+
.command("list")
|
|
832
|
+
.description("list expenses; record rules scope visibility to own expenses unless Expense Manager")
|
|
833
|
+
.option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
|
|
834
|
+
.option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
|
|
835
|
+
.option("--fields <a,b,c>", "columns to display")
|
|
836
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
837
|
+
.option("--offset <n>", "skip the first n records", "0")
|
|
838
|
+
.option("--order <order>", 'sort order, e.g. "date desc"', "date desc")
|
|
839
|
+
.addHelpText("after", `
|
|
840
|
+
Examples:
|
|
841
|
+
$ odoo expenses list --json
|
|
842
|
+
$ odoo expenses list --filter state=draft
|
|
843
|
+
$ odoo expenses list --domain '[["date",">=","2026-01-01"]]' --json`)
|
|
844
|
+
.action(async (opts) => {
|
|
845
|
+
await run((g) => expensesListCommand(opts, g), program);
|
|
846
|
+
});
|
|
847
|
+
expenses
|
|
848
|
+
.command("show")
|
|
849
|
+
.description("read one expense")
|
|
850
|
+
.argument("<id>", "record id")
|
|
851
|
+
.option("--fields <a,b,c>", "fields to display")
|
|
852
|
+
.option("--all-fields", "display every readable field")
|
|
853
|
+
.action(async (id, opts) => {
|
|
854
|
+
await run((g) => expensesShowCommand(id, opts, g), program);
|
|
855
|
+
});
|
|
856
|
+
expenses
|
|
857
|
+
.command("categories")
|
|
858
|
+
.description("list expense categories (product.product where can_be_expensed)")
|
|
859
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
860
|
+
.option("--offset <n>", "skip the first n records", "0")
|
|
861
|
+
.action(async (opts) => {
|
|
862
|
+
await run((g) => expensesCategoriesCommand(opts, g), program);
|
|
863
|
+
});
|
|
864
|
+
expenses
|
|
865
|
+
.command("create")
|
|
866
|
+
.description("create an expense, optionally attaching one or more receipt files")
|
|
867
|
+
.requiredOption("--name <text>", "short description, e.g. 'Taxi Arlanda'")
|
|
868
|
+
.requiredOption("--date <yyyy-mm-dd>", "receipt date")
|
|
869
|
+
.requiredOption("--amount <n>", "total amount incl. VAT, e.g. 249.50")
|
|
870
|
+
.requiredOption("--category <name|id>", "expense category (see 'expenses categories')")
|
|
871
|
+
.option("--currency <code>", "currency code, e.g. SEK (default: company currency)")
|
|
872
|
+
.option("--payment-mode <mode>", "own (employee to reimburse, default) or company (company card)")
|
|
873
|
+
.option("--employee <name|id>", "expense owner (default: employee linked to the authenticated user)")
|
|
874
|
+
.option("--description <text>", "internal note / details")
|
|
875
|
+
.option("--attach <file>", "receipt file to attach (pdf/jpg/png/...); repeatable", collect, [])
|
|
876
|
+
.addHelpText("after", `
|
|
877
|
+
The receipt file(s) are stored as ir.attachment rows linked to the expense
|
|
878
|
+
(res_model=hr.expense), the same way the web UI stores dropped files.
|
|
879
|
+
|
|
880
|
+
Examples:
|
|
881
|
+
$ odoo expenses create --name "Taxi Arlanda" --date 2026-10-14 \\
|
|
882
|
+
--amount 249.50 --currency SEK --category "Travel & Accommodation" \\
|
|
883
|
+
--attach receipt.pdf --dry-run
|
|
884
|
+
$ odoo expenses create --name "Lunch kundmöte" --date 2026-10-10 \\
|
|
885
|
+
--amount 342 --category Meals --attach kvitto1.jpg --attach kvitto2.jpg --json`)
|
|
886
|
+
.action(async (opts) => {
|
|
887
|
+
await run((g) => expensesCreateCommand(opts, g), program);
|
|
888
|
+
});
|
|
889
|
+
const expensesReport = expenses
|
|
890
|
+
.command("report")
|
|
891
|
+
.description("expense reports (hr.expense.sheet) — collect and submit");
|
|
892
|
+
expensesReport
|
|
893
|
+
.command("create")
|
|
894
|
+
.description("create an expense report from draft expenses; add --submit to send it for approval")
|
|
895
|
+
.requiredOption("--name <text>", "report name, e.g. 'Utlägg september 2026'")
|
|
896
|
+
.requiredOption("--expense <id>", "expense id to include (draft, not yet reported); repeatable", collect, [])
|
|
897
|
+
.option("--submit", "submit the report for approval immediately (state: Submitted)", false)
|
|
898
|
+
.option("--employee <name|id>", "report owner (default: employee linked to the authenticated user)")
|
|
899
|
+
.addHelpText("after", `
|
|
900
|
+
Creates an hr.expense.sheet and links the given expenses to it (same as
|
|
901
|
+
"Create Report" in the web UI). Approval (action_approve_sheet) is NOT
|
|
902
|
+
supported — that step belongs to the approver in the Odoo UI.
|
|
903
|
+
|
|
904
|
+
Examples:
|
|
905
|
+
$ odoo expenses report create --name "Utlägg sept 2026" \\
|
|
906
|
+
--expense 138 --expense 139 --dry-run
|
|
907
|
+
$ odoo expenses report create --name "Utlägg sept 2026" \\
|
|
908
|
+
--expense 138 --expense 139 --submit --json`)
|
|
909
|
+
.action(async (opts) => {
|
|
910
|
+
await run((g) => expensesReportCreateCommand(opts, g), program);
|
|
911
|
+
});
|
|
912
|
+
expenses
|
|
913
|
+
.command("attach")
|
|
914
|
+
.description("attach a receipt file to an existing expense")
|
|
915
|
+
.argument("<id>", "expense id")
|
|
916
|
+
.requiredOption("--file <path>", "receipt file (pdf/jpg/png/...)")
|
|
917
|
+
.option("--name <name>", "attachment name (default: file basename)")
|
|
918
|
+
.addHelpText("after", `
|
|
919
|
+
Example:
|
|
920
|
+
$ odoo expenses attach 42 --file kvitto.pdf --dry-run
|
|
921
|
+
$ odoo expenses attach 42 --file kvitto.pdf`)
|
|
922
|
+
.action(async (id, opts) => {
|
|
923
|
+
await run((g) => expensesAttachCommand(id, opts, g), program);
|
|
924
|
+
});
|
|
925
|
+
expenses
|
|
926
|
+
.command("delete")
|
|
927
|
+
.description("delete expenses (preview with --dry-run; attachments are NOT removed, use 'attachments delete')")
|
|
928
|
+
.argument("<id...>", "record ids")
|
|
929
|
+
.addHelpText("after", `
|
|
930
|
+
Note: receipt files (ir.attachment) are soft-linked and survive expense
|
|
931
|
+
deletion — remove them explicitly via 'attachments list --res-model
|
|
932
|
+
hr.expense --res-id <id>' + 'attachments delete'.
|
|
933
|
+
|
|
934
|
+
Example:
|
|
935
|
+
$ odoo expenses delete 137 --dry-run
|
|
936
|
+
$ odoo expenses delete 137`)
|
|
937
|
+
.action(async (ids) => {
|
|
938
|
+
await run((g) => expensesDeleteCommand(ids, g), program);
|
|
939
|
+
});
|
|
826
940
|
const sign = program
|
|
827
941
|
.command("sign")
|
|
828
942
|
.description("Sign app (sign.template, sign.request) — safe signing pipeline");
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
import { ModelFields, resolveReference } from "../model.js";
|
|
4
|
+
import { authenticatedClient, listCommand, parseId, showCommand, } from "../resource.js";
|
|
5
|
+
import { parseDate, parseDecimal, deleteCommand } from "../mutation.js";
|
|
6
|
+
import { notFoundError, usageError } from "../errors.js";
|
|
7
|
+
import { colorEnabled, printJson } from "../output.js";
|
|
8
|
+
/**
|
|
9
|
+
* Expenses app (hr.expense, Enterprise). Record rules limit visibility to
|
|
10
|
+
* own expenses unless the user is an Expense Manager — list output is
|
|
11
|
+
* therefore per-user by design.
|
|
12
|
+
*
|
|
13
|
+
* Receipt files are stored as plain ir.attachment rows with
|
|
14
|
+
* res_model="hr.expense" / res_id=<expense id> (the same mechanism the web
|
|
15
|
+
* UI uses when you drop a file on an expense).
|
|
16
|
+
*/
|
|
17
|
+
export const hrExpense = {
|
|
18
|
+
model: "hr.expense",
|
|
19
|
+
showCommand: "odoo expenses show",
|
|
20
|
+
defaultListFields: [
|
|
21
|
+
"id",
|
|
22
|
+
"name",
|
|
23
|
+
"date",
|
|
24
|
+
"total_amount",
|
|
25
|
+
"currency_id",
|
|
26
|
+
"product_id",
|
|
27
|
+
"payment_mode",
|
|
28
|
+
"state",
|
|
29
|
+
],
|
|
30
|
+
showExtraFields: [
|
|
31
|
+
"description",
|
|
32
|
+
"employee_id",
|
|
33
|
+
"unit_amount",
|
|
34
|
+
"quantity",
|
|
35
|
+
"tax_ids",
|
|
36
|
+
"sheet_id",
|
|
37
|
+
"write_date",
|
|
38
|
+
],
|
|
39
|
+
resolutionHints: {
|
|
40
|
+
product_id: "--category",
|
|
41
|
+
employee_id: "--employee",
|
|
42
|
+
},
|
|
43
|
+
priorityLabels: {},
|
|
44
|
+
pinnedDomain: [],
|
|
45
|
+
references: {},
|
|
46
|
+
};
|
|
47
|
+
export function expensesListCommand(opts, globals) {
|
|
48
|
+
return listCommand(hrExpense, opts, globals);
|
|
49
|
+
}
|
|
50
|
+
export function expensesShowCommand(idArg, opts, globals) {
|
|
51
|
+
return showCommand(hrExpense, idArg, opts, globals);
|
|
52
|
+
}
|
|
53
|
+
export function expensesDeleteCommand(idArgs, globals) {
|
|
54
|
+
return deleteCommand(hrExpense, idArgs, globals);
|
|
55
|
+
}
|
|
56
|
+
export async function expensesCategoriesCommand(opts, globals) {
|
|
57
|
+
const client = await authenticatedClient(globals);
|
|
58
|
+
const limit = Number(opts.limit);
|
|
59
|
+
const offset = Number(opts.offset);
|
|
60
|
+
if (!Number.isInteger(limit) || limit < 0)
|
|
61
|
+
throw usageError(`invalid --limit value ${JSON.stringify(opts.limit)}`);
|
|
62
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
63
|
+
throw usageError(`invalid --offset value ${JSON.stringify(opts.offset)}`);
|
|
64
|
+
const domain = [["can_be_expensed", "=", true]];
|
|
65
|
+
const records = (await client.executeKw("product.product", "search_read", [
|
|
66
|
+
domain,
|
|
67
|
+
], {
|
|
68
|
+
fields: ["id", "name", "standard_price"],
|
|
69
|
+
limit,
|
|
70
|
+
offset,
|
|
71
|
+
order: "name asc",
|
|
72
|
+
}));
|
|
73
|
+
if (globals.json) {
|
|
74
|
+
printJson({ model: "product.product", records, count: records.length, limit, offset });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const r of records) {
|
|
78
|
+
process.stdout.write(`${r.id}\t${r.name}\n`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// create — expense + optional receipt file attachments
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
const MIME_BY_EXT = {
|
|
84
|
+
".pdf": "application/pdf",
|
|
85
|
+
".png": "image/png",
|
|
86
|
+
".jpg": "image/jpeg",
|
|
87
|
+
".jpeg": "image/jpeg",
|
|
88
|
+
".gif": "image/gif",
|
|
89
|
+
".webp": "image/webp",
|
|
90
|
+
".heic": "image/heic",
|
|
91
|
+
".txt": "text/plain",
|
|
92
|
+
};
|
|
93
|
+
const PAYMENT_MODES = {
|
|
94
|
+
own: "own_account",
|
|
95
|
+
company: "company_account",
|
|
96
|
+
};
|
|
97
|
+
/** Find the hr.employee linked to the authenticated user (default owner). */
|
|
98
|
+
async function currentEmployee(client) {
|
|
99
|
+
const uid = await client.authenticate();
|
|
100
|
+
const rows = (await client.executeKw("hr.employee", "search_read", [
|
|
101
|
+
[["user_id", "=", uid]],
|
|
102
|
+
], { fields: ["id"], limit: 1 }));
|
|
103
|
+
if (rows.length === 0) {
|
|
104
|
+
throw notFoundError("employee_not_found", "no hr.employee record linked to the authenticated user", "pass --employee explicitly, or link an employee to your user in Odoo");
|
|
105
|
+
}
|
|
106
|
+
return rows[0].id;
|
|
107
|
+
}
|
|
108
|
+
async function prepareAttachment(file) {
|
|
109
|
+
const filePath = resolve(file);
|
|
110
|
+
let fileStat;
|
|
111
|
+
try {
|
|
112
|
+
fileStat = await stat(filePath);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw usageError(`file not found: ${file}`);
|
|
116
|
+
}
|
|
117
|
+
if (!fileStat.isFile())
|
|
118
|
+
throw usageError(`not a regular file: ${file}`);
|
|
119
|
+
const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
|
|
120
|
+
const buffer = await readFile(filePath);
|
|
121
|
+
return {
|
|
122
|
+
path: filePath,
|
|
123
|
+
name: basename(filePath),
|
|
124
|
+
mimetype: MIME_BY_EXT[ext] ?? "application/octet-stream",
|
|
125
|
+
datas: buffer.toString("base64"),
|
|
126
|
+
size: fileStat.size,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export async function expensesCreateCommand(opts, globals) {
|
|
130
|
+
const client = await authenticatedClient(globals);
|
|
131
|
+
await ModelFields.load(client, "hr.expense");
|
|
132
|
+
const date = parseDate(opts.date, "--date");
|
|
133
|
+
const amount = parseDecimal(opts.amount, "--amount");
|
|
134
|
+
if (amount <= 0) {
|
|
135
|
+
throw usageError("--amount must be greater than zero");
|
|
136
|
+
}
|
|
137
|
+
const category = await resolveReference(client, "product.product", opts.category, {
|
|
138
|
+
label: "expense category",
|
|
139
|
+
domain: [["can_be_expensed", "=", true]],
|
|
140
|
+
});
|
|
141
|
+
const paymentMode = opts.paymentMode !== undefined
|
|
142
|
+
? (() => {
|
|
143
|
+
const key = opts.paymentMode.toLowerCase();
|
|
144
|
+
const mode = PAYMENT_MODES[key] ??
|
|
145
|
+
(Object.values(PAYMENT_MODES).includes(key) ? key : undefined);
|
|
146
|
+
if (mode === undefined) {
|
|
147
|
+
throw usageError(`invalid --payment-mode ${JSON.stringify(opts.paymentMode)}`, `expected one of: ${Object.keys(PAYMENT_MODES).join(", ")} (own = employee to reimburse, company = company card)`);
|
|
148
|
+
}
|
|
149
|
+
return mode;
|
|
150
|
+
})()
|
|
151
|
+
: "own_account";
|
|
152
|
+
const currency = opts.currency !== undefined
|
|
153
|
+
? await resolveReference(client, "res.currency", opts.currency, {
|
|
154
|
+
label: "currency",
|
|
155
|
+
exactFields: ["name"],
|
|
156
|
+
})
|
|
157
|
+
: undefined;
|
|
158
|
+
const employeeId = opts.employee !== undefined
|
|
159
|
+
? (await resolveReference(client, "hr.employee", opts.employee, {
|
|
160
|
+
label: "employee",
|
|
161
|
+
})).id
|
|
162
|
+
: await currentEmployee(client);
|
|
163
|
+
const attachments = [];
|
|
164
|
+
for (const file of opts.attach ?? []) {
|
|
165
|
+
attachments.push(await prepareAttachment(file));
|
|
166
|
+
}
|
|
167
|
+
const values = {
|
|
168
|
+
name: opts.name,
|
|
169
|
+
date,
|
|
170
|
+
total_amount: amount,
|
|
171
|
+
product_id: category.id,
|
|
172
|
+
payment_mode: paymentMode,
|
|
173
|
+
employee_id: employeeId,
|
|
174
|
+
};
|
|
175
|
+
if (currency !== undefined)
|
|
176
|
+
values.currency_id = currency.id;
|
|
177
|
+
if (opts.description !== undefined)
|
|
178
|
+
values.description = opts.description;
|
|
179
|
+
if (globals.dryRun) {
|
|
180
|
+
const preview = {
|
|
181
|
+
...values,
|
|
182
|
+
attachments: attachments.map((a) => `${a.name} (${a.size} bytes)`),
|
|
183
|
+
};
|
|
184
|
+
if (globals.json) {
|
|
185
|
+
printJson({ model: "hr.expense", action: "create", dry_run: true, values: preview });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
process.stdout.write(`Would create hr.expense:\n` +
|
|
189
|
+
Object.entries(preview)
|
|
190
|
+
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
|
191
|
+
.join("\n") +
|
|
192
|
+
"\n");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const id = (await client.executeKw("hr.expense", "create", [values]));
|
|
196
|
+
const uploaded = [];
|
|
197
|
+
for (const a of attachments) {
|
|
198
|
+
const attId = (await client.executeKw("ir.attachment", "create", [
|
|
199
|
+
{
|
|
200
|
+
name: a.name,
|
|
201
|
+
mimetype: a.mimetype,
|
|
202
|
+
datas: a.datas,
|
|
203
|
+
res_model: "hr.expense",
|
|
204
|
+
res_id: id,
|
|
205
|
+
},
|
|
206
|
+
]));
|
|
207
|
+
uploaded.push({ id: attId, name: a.name });
|
|
208
|
+
}
|
|
209
|
+
if (globals.json) {
|
|
210
|
+
printJson({
|
|
211
|
+
model: "hr.expense",
|
|
212
|
+
action: "create",
|
|
213
|
+
dry_run: false,
|
|
214
|
+
id,
|
|
215
|
+
name: opts.name,
|
|
216
|
+
attachments: uploaded,
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
221
|
+
const attSuffix = uploaded.length > 0 ? ` + ${uploaded.length} attachment(s)` : "";
|
|
222
|
+
const line = `Created hr.expense ${id} (${opts.name})${attSuffix}`;
|
|
223
|
+
process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
|
|
224
|
+
}
|
|
225
|
+
export async function expensesReportCreateCommand(opts, globals) {
|
|
226
|
+
const client = await authenticatedClient(globals);
|
|
227
|
+
const ids = opts.expense.map(parseId);
|
|
228
|
+
if (ids.length === 0) {
|
|
229
|
+
throw usageError("pass at least one --expense <id>");
|
|
230
|
+
}
|
|
231
|
+
const rows = (await client.executeKw("hr.expense", "read", [ids], {
|
|
232
|
+
fields: ["id", "name", "state", "sheet_id", "employee_id", "total_amount", "currency_id"],
|
|
233
|
+
}));
|
|
234
|
+
if (rows.length !== ids.length) {
|
|
235
|
+
const found = new Set(rows.map((r) => r.id));
|
|
236
|
+
const missing = ids.filter((id) => !found.has(id));
|
|
237
|
+
throw notFoundError("record_not_found", `no hr.expense record(s) with id(s): ${missing.join(", ")}`, "expenses are only visible to their owner unless you are an Expense Manager");
|
|
238
|
+
}
|
|
239
|
+
const reported = rows.filter((r) => r.sheet_id !== false);
|
|
240
|
+
if (reported.length > 0) {
|
|
241
|
+
throw usageError(`expense(s) already on a report: ${reported.map((r) => `${r.id} (${r.name})`).join(", ")}`);
|
|
242
|
+
}
|
|
243
|
+
const employeeId = opts.employee !== undefined
|
|
244
|
+
? (await resolveReference(client, "hr.employee", opts.employee, {
|
|
245
|
+
label: "employee",
|
|
246
|
+
})).id
|
|
247
|
+
: await currentEmployee(client);
|
|
248
|
+
const values = {
|
|
249
|
+
name: opts.name,
|
|
250
|
+
employee_id: employeeId,
|
|
251
|
+
expense_line_ids: [[6, 0, ids]],
|
|
252
|
+
};
|
|
253
|
+
if (globals.dryRun) {
|
|
254
|
+
const preview = { ...values, submit: opts.submit === true };
|
|
255
|
+
if (globals.json) {
|
|
256
|
+
printJson({ model: "hr.expense.sheet", action: "create", dry_run: true, values: preview });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
process.stdout.write(`Would create hr.expense.sheet:\n` +
|
|
260
|
+
Object.entries(preview)
|
|
261
|
+
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
|
262
|
+
.join("\n") +
|
|
263
|
+
"\n");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const id = (await client.executeKw("hr.expense.sheet", "create", [values]));
|
|
267
|
+
let state = "draft";
|
|
268
|
+
if (opts.submit === true) {
|
|
269
|
+
await client.executeKw("hr.expense.sheet", "action_submit_sheet", [[id]]);
|
|
270
|
+
state = "submit";
|
|
271
|
+
}
|
|
272
|
+
const [created] = (await client.executeKw("hr.expense.sheet", "read", [[id]], {
|
|
273
|
+
fields: ["total_amount", "currency_id"],
|
|
274
|
+
}));
|
|
275
|
+
if (globals.json) {
|
|
276
|
+
printJson({
|
|
277
|
+
model: "hr.expense.sheet",
|
|
278
|
+
action: "create",
|
|
279
|
+
dry_run: false,
|
|
280
|
+
id,
|
|
281
|
+
name: opts.name,
|
|
282
|
+
expense_ids: ids,
|
|
283
|
+
state,
|
|
284
|
+
total_amount: created.total_amount,
|
|
285
|
+
currency: created.currency_id?.[1],
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
290
|
+
const line = `Created hr.expense.sheet ${id} (${opts.name}) with ${ids.length} expense(s)` +
|
|
291
|
+
(opts.submit === true ? " — submitted" : "");
|
|
292
|
+
process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
|
|
293
|
+
}
|
|
294
|
+
export async function expensesAttachCommand(idArg, opts, globals) {
|
|
295
|
+
const id = parseId(idArg);
|
|
296
|
+
const client = await authenticatedClient(globals);
|
|
297
|
+
// Verify the expense exists (and is visible under record rules).
|
|
298
|
+
const rows = (await client.executeKw("hr.expense", "read", [[id]], {
|
|
299
|
+
fields: ["name"],
|
|
300
|
+
}));
|
|
301
|
+
if (rows.length === 0) {
|
|
302
|
+
throw notFoundError("record_not_found", `no hr.expense record with id ${id}`, "expenses are only visible to their owner unless you are an Expense Manager");
|
|
303
|
+
}
|
|
304
|
+
const prepared = await prepareAttachment(opts.file);
|
|
305
|
+
const name = opts.name ?? prepared.name;
|
|
306
|
+
if (globals.dryRun) {
|
|
307
|
+
if (globals.json) {
|
|
308
|
+
printJson({
|
|
309
|
+
model: "ir.attachment",
|
|
310
|
+
action: "attach",
|
|
311
|
+
dry_run: true,
|
|
312
|
+
values: {
|
|
313
|
+
name,
|
|
314
|
+
mimetype: prepared.mimetype,
|
|
315
|
+
res_model: "hr.expense",
|
|
316
|
+
res_id: id,
|
|
317
|
+
datas: `<${prepared.size} bytes base64>`,
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
process.stdout.write(`Would attach ${name} (${prepared.size} bytes) to hr.expense ${id}\n`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const attId = (await client.executeKw("ir.attachment", "create", [
|
|
326
|
+
{
|
|
327
|
+
name,
|
|
328
|
+
mimetype: prepared.mimetype,
|
|
329
|
+
datas: prepared.datas,
|
|
330
|
+
res_model: "hr.expense",
|
|
331
|
+
res_id: id,
|
|
332
|
+
},
|
|
333
|
+
]));
|
|
334
|
+
if (globals.json) {
|
|
335
|
+
printJson({
|
|
336
|
+
model: "ir.attachment",
|
|
337
|
+
action: "attach",
|
|
338
|
+
dry_run: false,
|
|
339
|
+
id: attId,
|
|
340
|
+
name,
|
|
341
|
+
res_model: "hr.expense",
|
|
342
|
+
res_id: id,
|
|
343
|
+
file_size: prepared.size,
|
|
344
|
+
});
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
348
|
+
const line = `Attached ${name} to hr.expense ${id} (ir.attachment ${attId})`;
|
|
349
|
+
process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
|
|
350
|
+
}
|