@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 +30 -0
- package/dist/cli.js +248 -0
- package/dist/commands/sales.js +902 -0
- package/dist/commands/sign.js +599 -0
- package/package.json +1 -1
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { notFoundError, usageError } from "../errors.js";
|
|
4
|
+
import { resolveReference } from "../model.js";
|
|
5
|
+
import { colorEnabled, printJson } from "../output.js";
|
|
6
|
+
import { authenticatedClient } from "../resource.js";
|
|
7
|
+
import { formatTable, humanCell } from "../table.js";
|
|
8
|
+
const CONFIG_NS = "euro_office_connector";
|
|
9
|
+
async function readConfigParam(client, key) {
|
|
10
|
+
const rows = (await client.executeKw("ir.config_parameter", "search_read", [
|
|
11
|
+
[["key", "=", key]],
|
|
12
|
+
], { fields: ["value"] }));
|
|
13
|
+
return rows[0]?.value;
|
|
14
|
+
}
|
|
15
|
+
function signJwtHS256(secret, payload) {
|
|
16
|
+
const header = { alg: "HS256", typ: "JWT" };
|
|
17
|
+
const enc = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
|
|
18
|
+
const base = `${enc(header)}.${enc({ payload })}`;
|
|
19
|
+
const sig = createHmac("sha256", secret).update(base).digest("base64url");
|
|
20
|
+
return `${base}.${sig}`;
|
|
21
|
+
}
|
|
22
|
+
async function readAttachment(client, attachmentId) {
|
|
23
|
+
const rows = (await client.executeKw("ir.attachment", "read", [[attachmentId]], {
|
|
24
|
+
fields: ["name", "mimetype", "datas", "file_size"],
|
|
25
|
+
}));
|
|
26
|
+
if (rows.length === 0) {
|
|
27
|
+
throw notFoundError("not_found", `ir.attachment ${attachmentId} not found`);
|
|
28
|
+
}
|
|
29
|
+
const row = rows[0];
|
|
30
|
+
const mimeType = row.mimetype && row.mimetype !== "text/plain"
|
|
31
|
+
? row.mimetype
|
|
32
|
+
: guessPdfMimetype(row.name);
|
|
33
|
+
if (!row.datas) {
|
|
34
|
+
throw usageError(`attachment ${attachmentId} (${row.name}) holds no file data`);
|
|
35
|
+
}
|
|
36
|
+
const data = Buffer.from(row.datas, "base64");
|
|
37
|
+
if (mimeType === "application/pdf" && !isPdfBuffer(data)) {
|
|
38
|
+
throw usageError(`attachment ${attachmentId} (${row.name}) is not a valid PDF`);
|
|
39
|
+
}
|
|
40
|
+
return { name: row.name, mimetype: mimeType, data };
|
|
41
|
+
}
|
|
42
|
+
function guessPdfMimetype(name) {
|
|
43
|
+
return /\.pdf$/i.test(name)
|
|
44
|
+
? "application/pdf"
|
|
45
|
+
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
46
|
+
}
|
|
47
|
+
function isPdfBuffer(data) {
|
|
48
|
+
return data.subarray(0, 5).toString("latin1") === "%PDF-";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Convert a .docx (or any office file the Document Server can read) to PDF
|
|
52
|
+
* using the self-hosted OnlyOffice Document Server behind Eurooffice, whose
|
|
53
|
+
* connection settings live in ir.config_parameter.
|
|
54
|
+
*/
|
|
55
|
+
async function convertToPdf(client, docServerUrl, jwtSecret, contentUrl, filename) {
|
|
56
|
+
const MAX_PDF_BYTES = 50 * 1024 * 1024;
|
|
57
|
+
const body = {
|
|
58
|
+
key: `odoo-cli-${randomBytes(9).toString("hex")}`,
|
|
59
|
+
filetype: "docx",
|
|
60
|
+
outputtype: "pdf",
|
|
61
|
+
title: filename,
|
|
62
|
+
url: contentUrl,
|
|
63
|
+
async: false,
|
|
64
|
+
};
|
|
65
|
+
const abort = new AbortController();
|
|
66
|
+
const timer = setTimeout(() => abort.abort(), 60_000);
|
|
67
|
+
let response;
|
|
68
|
+
try {
|
|
69
|
+
response = await fetch(`${docServerUrl.replace(/\/+$/, "")}/ConvertService.ashx`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: {
|
|
72
|
+
Authorization: `Bearer ${signJwtHS256(jwtSecret, body)}`,
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
Accept: "application/json",
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify(body),
|
|
77
|
+
signal: abort.signal,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
throw usageError(`OnlyOffice conversion failed: HTTP ${response.status} from ${docServerUrl}`);
|
|
85
|
+
}
|
|
86
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
87
|
+
let pdf;
|
|
88
|
+
if (contentType.includes("application/json")) {
|
|
89
|
+
const payload = (await response.json());
|
|
90
|
+
if (!payload.fileUrl) {
|
|
91
|
+
throw usageError("OnlyOffice conversion returned no fileUrl");
|
|
92
|
+
}
|
|
93
|
+
const fetchAbort = new AbortController();
|
|
94
|
+
const fetchTimer = setTimeout(() => fetchAbort.abort(), 60_000);
|
|
95
|
+
let pdfResponse;
|
|
96
|
+
try {
|
|
97
|
+
pdfResponse = await fetch(payload.fileUrl, { signal: fetchAbort.signal });
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
clearTimeout(fetchTimer);
|
|
101
|
+
}
|
|
102
|
+
if (!pdfResponse.ok) {
|
|
103
|
+
throw usageError(`Could not fetch converted PDF: HTTP ${pdfResponse.status}`);
|
|
104
|
+
}
|
|
105
|
+
const size = Number(pdfResponse.headers.get("content-length") ?? 0);
|
|
106
|
+
if (size > MAX_PDF_BYTES) {
|
|
107
|
+
throw usageError(`converted PDF exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB; refusing to load it into memory`);
|
|
108
|
+
}
|
|
109
|
+
pdf = Buffer.from(await pdfResponse.arrayBuffer());
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// Some Document Server versions answer with the binary directly.
|
|
113
|
+
const size = Number(response.headers.get("content-length") ?? 0);
|
|
114
|
+
if (size > MAX_PDF_BYTES) {
|
|
115
|
+
throw usageError(`converted PDF exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB; refusing to load it into memory`);
|
|
116
|
+
}
|
|
117
|
+
pdf = Buffer.from(await response.arrayBuffer());
|
|
118
|
+
}
|
|
119
|
+
if (pdf.length === 0) {
|
|
120
|
+
throw usageError("OnlyOffice conversion produced an empty PDF");
|
|
121
|
+
}
|
|
122
|
+
return pdf;
|
|
123
|
+
}
|
|
124
|
+
async function resolveDocument(client, ref) {
|
|
125
|
+
const id = Number.parseInt(ref, 10);
|
|
126
|
+
if (!Number.isFinite(id)) {
|
|
127
|
+
throw usageError(`document reference must be a numeric id, got ${JSON.stringify(ref)}`);
|
|
128
|
+
}
|
|
129
|
+
const rows = (await client.executeKw("documents.document", "read", [[id]], {
|
|
130
|
+
fields: ["name", "attachment_id", "mimetype"],
|
|
131
|
+
}));
|
|
132
|
+
if (rows.length === 0) {
|
|
133
|
+
throw notFoundError("not_found", `documents.document ${ref} not found`);
|
|
134
|
+
}
|
|
135
|
+
if (!rows[0].attachment_id) {
|
|
136
|
+
throw usageError(`documents.document ${ref} has no file attachment (URL/link document?)`);
|
|
137
|
+
}
|
|
138
|
+
return rows[0];
|
|
139
|
+
}
|
|
140
|
+
export async function signTemplatesListCommand(opts, globals) {
|
|
141
|
+
const client = await authenticatedClient(globals);
|
|
142
|
+
const domain = opts.archived ? [] : [["active", "=", true]];
|
|
143
|
+
const records = (await client.executeKw("sign.template", "search_read", [
|
|
144
|
+
domain,
|
|
145
|
+
["name", "active", "create_date"],
|
|
146
|
+
], {
|
|
147
|
+
limit: Number.parseInt(opts.limit, 10) || 50,
|
|
148
|
+
order: "id asc",
|
|
149
|
+
}));
|
|
150
|
+
if (globals.json) {
|
|
151
|
+
printJson({ model: "sign.template", count: records.length, records });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const headers = ["ID", "Name", "Active", "Created"];
|
|
155
|
+
const rows = records.map((t) => [
|
|
156
|
+
String(t.id),
|
|
157
|
+
humanCell(t.name),
|
|
158
|
+
t.active ? "yes" : "archived",
|
|
159
|
+
humanCell(t.create_date),
|
|
160
|
+
]);
|
|
161
|
+
process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
|
|
162
|
+
}
|
|
163
|
+
export async function signTemplatesShowCommand(idArg, opts, globals) {
|
|
164
|
+
const client = await authenticatedClient(globals);
|
|
165
|
+
const id = Number.parseInt(idArg, 10);
|
|
166
|
+
const fields = opts.allFields
|
|
167
|
+
? []
|
|
168
|
+
: ["name", "active", "attachment_id", "sign_item_ids", "create_date", "write_date"];
|
|
169
|
+
const records = (await client.executeKw("sign.template", "read", [[id]], {
|
|
170
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
171
|
+
}));
|
|
172
|
+
if (records.length === 0) {
|
|
173
|
+
throw notFoundError("not_found", `sign.template ${id} not found`);
|
|
174
|
+
}
|
|
175
|
+
if (globals.json) {
|
|
176
|
+
printJson(records[0]);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const rec = records[0];
|
|
180
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
181
|
+
const text = Array.isArray(v) && typeof v[0] === "number"
|
|
182
|
+
? `${v[1]} (id ${v[0]})`
|
|
183
|
+
: humanCell(v);
|
|
184
|
+
process.stdout.write(`${k.padEnd(22)} ${text}\n`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export async function signTemplatesCreateFromDocumentCommand(docRef, opts, globals) {
|
|
188
|
+
const client = await authenticatedClient(globals);
|
|
189
|
+
const doc = await resolveDocument(client, docRef);
|
|
190
|
+
if (!doc.attachment_id || !Array.isArray(doc.attachment_id)) {
|
|
191
|
+
throw usageError(`documents.document has no file attachment`);
|
|
192
|
+
}
|
|
193
|
+
const attachment = await readAttachment(client, doc.attachment_id[0]);
|
|
194
|
+
const needsConversion = attachment.mimetype !== "application/pdf";
|
|
195
|
+
const pdfName = needsConversion
|
|
196
|
+
? doc.name.replace(/\.[^.]+$/, "") + ".pdf"
|
|
197
|
+
: attachment.name.replace(/\.pdf$/i, "") + ".pdf";
|
|
198
|
+
if (globals.dryRun) {
|
|
199
|
+
const preview = {
|
|
200
|
+
model: "sign.template",
|
|
201
|
+
action: "create_from_document",
|
|
202
|
+
dry_run: true,
|
|
203
|
+
document: doc.name,
|
|
204
|
+
template_name: opts.name ?? pdfName.replace(/\.pdf$/i, ""),
|
|
205
|
+
source: needsConversion
|
|
206
|
+
? `${attachment.name} (would convert to PDF via the Document Server)`
|
|
207
|
+
: `${attachment.name} (already PDF, reused as-is)`,
|
|
208
|
+
};
|
|
209
|
+
if (globals.json) {
|
|
210
|
+
printJson(preview);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
process.stdout.write(`Would create sign.template "${preview.template_name}"\n source: ${preview.source}\n`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
let pdfData;
|
|
217
|
+
if (needsConversion) {
|
|
218
|
+
const docServerUrl = await readConfigParam(client, `${CONFIG_NS}.doc_server_public_url`);
|
|
219
|
+
const jwtSecret = await readConfigParam(client, `${CONFIG_NS}.doc_server_jwt_secret`);
|
|
220
|
+
const odooUrl = (await readConfigParam(client, `${CONFIG_NS}.doc_server_odoo_url`)) ??
|
|
221
|
+
globals.url;
|
|
222
|
+
if (!docServerUrl || !jwtSecret || !odooUrl) {
|
|
223
|
+
throw usageError("OnlyOffice Document Server is not configured on this instance", "expected ir.config_parameter keys euro_office_connector.doc_server_public_url, doc_server_jwt_secret, doc_server_odoo_url");
|
|
224
|
+
}
|
|
225
|
+
const token = randomBytes(24).toString("hex");
|
|
226
|
+
const tmpAttachmentId = (await client.executeKw("ir.attachment", "create", [
|
|
227
|
+
{
|
|
228
|
+
name: doc.name,
|
|
229
|
+
datas: attachment.data.toString("base64"),
|
|
230
|
+
access_token: token,
|
|
231
|
+
mimetype: attachment.mimetype,
|
|
232
|
+
},
|
|
233
|
+
]));
|
|
234
|
+
try {
|
|
235
|
+
pdfData = await convertToPdf(client, docServerUrl, jwtSecret, `${odooUrl.replace(/\/+$/, "")}/web/content/${tmpAttachmentId}/${encodeURIComponent(doc.name)}?access_token=${token}`, doc.name);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
await client.executeKw("ir.attachment", "unlink", [[tmpAttachmentId]]);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
pdfData = attachment.data;
|
|
243
|
+
}
|
|
244
|
+
const newAttachmentId = (await client.executeKw("ir.attachment", "create", [
|
|
245
|
+
{
|
|
246
|
+
name: pdfName,
|
|
247
|
+
datas: pdfData.toString("base64"),
|
|
248
|
+
mimetype: "application/pdf",
|
|
249
|
+
checksum: createHash("sha1").update(pdfData).digest("hex"),
|
|
250
|
+
file_size: pdfData.length,
|
|
251
|
+
},
|
|
252
|
+
]));
|
|
253
|
+
const templateId = (await client.executeKw("sign.template", "create", [
|
|
254
|
+
{ name: opts.name ?? `${pdfName.replace(/\.pdf$/i, "")}`, attachment_id: newAttachmentId },
|
|
255
|
+
]));
|
|
256
|
+
if (globals.json) {
|
|
257
|
+
printJson({ model: "sign.template", action: "create_from_document", id: templateId, attachments: [newAttachmentId] });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
process.stdout.write(`Created sign.template ${templateId} (${opts.name ?? pdfName})\n`);
|
|
261
|
+
}
|
|
262
|
+
async function resolveSignType(client, ref) {
|
|
263
|
+
if (typeof ref === "number")
|
|
264
|
+
return ref;
|
|
265
|
+
if (/^\d+$/.test(ref))
|
|
266
|
+
return Number.parseInt(ref, 10);
|
|
267
|
+
const rows = (await client.executeKw("sign.item.type", "search_read", [
|
|
268
|
+
[["item_type", "=", ref.toLowerCase()]],
|
|
269
|
+
], { fields: ["name"], limit: 1 }));
|
|
270
|
+
if (rows.length > 0)
|
|
271
|
+
return rows[0].id;
|
|
272
|
+
return resolveReference(client, "sign.item.type", ref, { label: "item type" }).then((r) => r.id);
|
|
273
|
+
}
|
|
274
|
+
async function resolveSignRole(client, ref) {
|
|
275
|
+
if (typeof ref === "number")
|
|
276
|
+
return ref;
|
|
277
|
+
if (/^\d+$/.test(ref))
|
|
278
|
+
return Number.parseInt(ref, 10);
|
|
279
|
+
const role = await resolveReference(client, "sign.item.role", ref, {
|
|
280
|
+
label: "role",
|
|
281
|
+
});
|
|
282
|
+
return role.id;
|
|
283
|
+
}
|
|
284
|
+
export async function signItemsAddCommand(templateRef, opts, globals) {
|
|
285
|
+
const client = await authenticatedClient(globals);
|
|
286
|
+
const templateId = Number.parseInt(templateRef, 10);
|
|
287
|
+
let raw;
|
|
288
|
+
try {
|
|
289
|
+
raw = await readFile(opts.file, "utf8");
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
throw usageError(`items spec file not found: ${opts.file}`);
|
|
293
|
+
}
|
|
294
|
+
let specs;
|
|
295
|
+
try {
|
|
296
|
+
const parsed = JSON.parse(raw);
|
|
297
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
298
|
+
throw new Error("expected a non-empty JSON array");
|
|
299
|
+
}
|
|
300
|
+
specs = parsed;
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
throw usageError(`invalid items spec: ${error.message}`, 'expected JSON like [{"type":"signature","page":1,"x":10,"y":60,"width":24,"height":6,"role":"Standard","name":"Signature"}] (coordinates in % of page)');
|
|
304
|
+
}
|
|
305
|
+
const commands = [];
|
|
306
|
+
specs.forEach((spec, index) => {
|
|
307
|
+
const label = `item #${index + 1} (${JSON.stringify(spec).slice(0, 120)})`;
|
|
308
|
+
if (spec.type === undefined || spec.type === null || spec.type === "") {
|
|
309
|
+
throw usageError(`${label}: missing "type"`);
|
|
310
|
+
}
|
|
311
|
+
for (const key of ["x", "y", "width", "height"]) {
|
|
312
|
+
const value = spec[key];
|
|
313
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
314
|
+
throw usageError(`${label}: "${key}" must be a number`);
|
|
315
|
+
}
|
|
316
|
+
if (value < 0 || value > 100) {
|
|
317
|
+
throw usageError(`${label}: "${key}" must be within 0–100 (% of page)`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (spec.width === 0 || spec.height === 0) {
|
|
321
|
+
throw usageError(`${label}: width and height must be > 0`);
|
|
322
|
+
}
|
|
323
|
+
if (spec.page !== undefined && (!Number.isInteger(spec.page) || spec.page < 1)) {
|
|
324
|
+
throw usageError(`${label}: "page" must be an integer >= 1`);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
for (const spec of specs) {
|
|
328
|
+
const t = await resolveSignType(client, spec.type);
|
|
329
|
+
const role = spec.role !== undefined ? await resolveSignRole(client, spec.role) : undefined;
|
|
330
|
+
commands.push([
|
|
331
|
+
0,
|
|
332
|
+
0,
|
|
333
|
+
{
|
|
334
|
+
type_id: t,
|
|
335
|
+
page: spec.page ?? 1,
|
|
336
|
+
posX: spec.x,
|
|
337
|
+
posY: spec.y,
|
|
338
|
+
width: spec.width,
|
|
339
|
+
height: spec.height,
|
|
340
|
+
required: spec.required ?? true,
|
|
341
|
+
name: spec.name,
|
|
342
|
+
...(role !== undefined ? { responsible_id: role } : {}),
|
|
343
|
+
},
|
|
344
|
+
]);
|
|
345
|
+
}
|
|
346
|
+
if (globals.dryRun) {
|
|
347
|
+
if (globals.json) {
|
|
348
|
+
printJson({ model: "sign.item", action: "add", dry_run: true, template: templateId, count: commands.length });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
process.stdout.write(`Would add ${commands.length} item(s) to sign.template ${templateId}\n`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
await client.executeKw("sign.template", "write", [[templateId], { sign_item_ids: commands }]);
|
|
355
|
+
const template = (await client.executeKw("sign.template", "read", [[templateId]], {
|
|
356
|
+
fields: ["name", "sign_item_ids"],
|
|
357
|
+
}));
|
|
358
|
+
if (globals.json) {
|
|
359
|
+
printJson({ model: "sign.template", action: "items_add", id: templateId, items: template[0]?.sign_item_ids.length ?? 0 });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
process.stdout.write(`Added ${commands.length} item(s); sign.template ${templateId} now has ${template[0]?.sign_item_ids.length ?? 0}\n`);
|
|
363
|
+
}
|
|
364
|
+
export async function signRequestsCreateCommand(templateRef, opts, globals) {
|
|
365
|
+
const client = await authenticatedClient(globals);
|
|
366
|
+
const templateId = Number.parseInt(templateRef, 10);
|
|
367
|
+
const templates = (await client.executeKw("sign.template", "read", [[templateId]], {
|
|
368
|
+
fields: ["name", "attachment_id"],
|
|
369
|
+
}));
|
|
370
|
+
if (templates.length === 0) {
|
|
371
|
+
throw notFoundError("not_found", `sign.template ${templateId} not found`);
|
|
372
|
+
}
|
|
373
|
+
const template = templates[0];
|
|
374
|
+
if (!template.attachment_id || !Array.isArray(template.attachment_id)) {
|
|
375
|
+
throw usageError(`sign.template ${templateId} has no attachment; (re)create it with sign templates create-from-document`);
|
|
376
|
+
}
|
|
377
|
+
const signerCount = opts.signer?.length ?? 0;
|
|
378
|
+
const roleCount = opts.role?.length ?? 0;
|
|
379
|
+
const state = opts.state ?? "shared";
|
|
380
|
+
if (state === "shared") {
|
|
381
|
+
if (signerCount > 0 || opts.confirmSend) {
|
|
382
|
+
throw usageError("shared (draft) requests are anonymous by design", "omit --signer, or use --state sent with --roles and --confirm-send");
|
|
383
|
+
}
|
|
384
|
+
if (roleCount !== 1) {
|
|
385
|
+
throw usageError("shared requests need exactly one role: --role <name|id>", "a shared link allows anyone with the link to sign as that role");
|
|
386
|
+
}
|
|
387
|
+
// A shared request can only carry ONE anonymous signer, while the
|
|
388
|
+
// server demands one signer per template role — so multi-role
|
|
389
|
+
// templates must go through --state sent (or a single-role template).
|
|
390
|
+
const templateItems = (await client.executeKw("sign.item", "search_read", [
|
|
391
|
+
[["template_id", "=", templateId]],
|
|
392
|
+
], { fields: ["responsible_id"] }));
|
|
393
|
+
const roleIds = new Set(templateItems
|
|
394
|
+
.map((i) => (Array.isArray(i.responsible_id) ? i.responsible_id[0] : undefined))
|
|
395
|
+
.filter((v) => typeof v === "number"));
|
|
396
|
+
if (roleIds.size > 1) {
|
|
397
|
+
throw usageError(`this template has ${roleIds.size} roles; a shared link can only serve one`, "build a single-role template, or preview the send with --state sent --dry-run before --confirm-send");
|
|
398
|
+
}
|
|
399
|
+
const roleId = await resolveSignRole(client, opts.role?.[0] ?? "");
|
|
400
|
+
const accessToken = randomBytes(16).toString("hex");
|
|
401
|
+
const requestId = (await client.executeKw("sign.request", "create", [
|
|
402
|
+
{
|
|
403
|
+
template_id: templateId,
|
|
404
|
+
reference: `Sign link — ${template.name}`,
|
|
405
|
+
state: "shared",
|
|
406
|
+
access_token: accessToken,
|
|
407
|
+
request_item_ids: [
|
|
408
|
+
[0, 0, { role_id: roleId, access_token: randomBytes(16).toString("hex") }],
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
]));
|
|
412
|
+
const items = (await client.executeKw("sign.request.item", "search_read", [
|
|
413
|
+
[["sign_request_id", "=", requestId]],
|
|
414
|
+
], { fields: ["access_token"] }));
|
|
415
|
+
const signUrl = `${await odooBaseUrl(client, globals)}/sign/document/${requestId}/${items[0]?.access_token ?? ""}`;
|
|
416
|
+
if (globals.json) {
|
|
417
|
+
printJson({ model: "sign.request", action: "create", id: requestId, state: "shared", sign_url: signUrl });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
process.stdout.write(`Created shared sign request ${requestId} (no email sent)\nSign link: ${signUrl}\n`);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (state !== "sent") {
|
|
424
|
+
throw usageError("--state must be 'shared' or 'sent'");
|
|
425
|
+
}
|
|
426
|
+
if (signerCount === 0 || signerCount !== roleCount) {
|
|
427
|
+
throw usageError("--state sent needs one --signer <partner> per --role <role> (repeat both)");
|
|
428
|
+
}
|
|
429
|
+
if (!opts.subject) {
|
|
430
|
+
throw usageError("--state sent requires --subject");
|
|
431
|
+
}
|
|
432
|
+
if (!opts.confirmSend) {
|
|
433
|
+
throw usageError("sending emails to signers is a sharp operation", "re-run with --confirm-send (or --dry-run to preview)");
|
|
434
|
+
}
|
|
435
|
+
const signerIds = [];
|
|
436
|
+
const signerEmails = [];
|
|
437
|
+
for (let i = 0; i < signerCount; i++) {
|
|
438
|
+
const partner = await resolveReference(client, "res.partner", opts.signer?.[i] ?? "", {
|
|
439
|
+
label: "signer",
|
|
440
|
+
searchFields: ["email", "name"],
|
|
441
|
+
});
|
|
442
|
+
const role = await resolveSignRole(client, opts.role?.[i] ?? "");
|
|
443
|
+
signerIds.push([0, 0, { partner_id: partner.id, role_id: role, mail_sent_order: 0 }]);
|
|
444
|
+
const row = (await client.executeKw("res.partner", "read", [[partner.id]], {
|
|
445
|
+
fields: ["email"],
|
|
446
|
+
}));
|
|
447
|
+
if (row[0]?.email)
|
|
448
|
+
signerEmails.push(row[0].email.toLowerCase());
|
|
449
|
+
}
|
|
450
|
+
if (globals.dryRun) {
|
|
451
|
+
const payload = {
|
|
452
|
+
template_id: templateId,
|
|
453
|
+
filename: template.attachment_id[1],
|
|
454
|
+
subject: opts.subject,
|
|
455
|
+
message: opts.message ?? "",
|
|
456
|
+
signer_ids: signerIds,
|
|
457
|
+
};
|
|
458
|
+
if (globals.json) {
|
|
459
|
+
printJson({ model: "sign.send.request", action: "send", dry_run: true, values: payload });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
process.stdout.write(`Would send sign request via wizard:\n` +
|
|
463
|
+
Object.entries(payload)
|
|
464
|
+
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
|
465
|
+
.join("\n") +
|
|
466
|
+
"\n");
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
// The wizard requires BOTH signer_ids (one row per signer) AND signer_id
|
|
470
|
+
// (the primary partner); without signer_id, send_request() faults with
|
|
471
|
+
// "empty partner" even though signer_ids are set (build quirk, 2026-09-23).
|
|
472
|
+
const primaryPartner = (await resolveReference(client, "res.partner", opts.signer?.[0] ?? "", {
|
|
473
|
+
label: "signer",
|
|
474
|
+
searchFields: ["email", "name"],
|
|
475
|
+
})).id;
|
|
476
|
+
const wizardId = (await client.executeKw("sign.send.request", "create", [
|
|
477
|
+
{
|
|
478
|
+
template_id: templateId,
|
|
479
|
+
filename: template.attachment_id[1],
|
|
480
|
+
subject: opts.subject,
|
|
481
|
+
message: opts.message ?? "",
|
|
482
|
+
signer_id: primaryPartner,
|
|
483
|
+
signer_ids: signerIds,
|
|
484
|
+
},
|
|
485
|
+
]));
|
|
486
|
+
const action = (await client.executeKw("sign.send.request", "send_request", [
|
|
487
|
+
[wizardId],
|
|
488
|
+
]));
|
|
489
|
+
const requestId = action?.context?.id;
|
|
490
|
+
if (!requestId) {
|
|
491
|
+
throw usageError("send_request returned no request id — inspect the wizard state");
|
|
492
|
+
}
|
|
493
|
+
// The wizard created the sign.request in the same transaction — surface
|
|
494
|
+
// its id (the `sign requests cancel <id>` safety valve is useless without
|
|
495
|
+
// it) and anchor the mail audit to the SERVER timestamp on that request.
|
|
496
|
+
const created = (await client.executeKw("sign.request", "read", [
|
|
497
|
+
[requestId],
|
|
498
|
+
], {
|
|
499
|
+
fields: ["reference", "state", "create_date", "access_token", "request_item_ids"],
|
|
500
|
+
}));
|
|
501
|
+
const request = created[0];
|
|
502
|
+
const mails = (await client.executeKw("mail.mail", "search_read", [
|
|
503
|
+
[["create_date", ">=", request?.create_date ?? ""], ["subject", "=", opts.subject]],
|
|
504
|
+
], { fields: ["email_to", "subject", "state"] }));
|
|
505
|
+
const scoped = mails.filter((m) => signerEmails.some((e) => (m.email_to ?? "").toLowerCase().includes(e)));
|
|
506
|
+
const signerItems = request
|
|
507
|
+
? (await client.executeKw("sign.request.item", "read", [request.request_item_ids], {
|
|
508
|
+
fields: ["partner_id", "access_token", "state"],
|
|
509
|
+
}))
|
|
510
|
+
: [];
|
|
511
|
+
const baseUrl = await odooBaseUrl(client, globals);
|
|
512
|
+
if (globals.json) {
|
|
513
|
+
printJson({
|
|
514
|
+
model: "sign.send.request",
|
|
515
|
+
action: "send",
|
|
516
|
+
wizard: wizardId,
|
|
517
|
+
sign_request: request?.id,
|
|
518
|
+
mails_queued: scoped.length,
|
|
519
|
+
mails: scoped,
|
|
520
|
+
sign_urls: signerItems.map((i) => `${baseUrl}/sign/document/${request?.id}/${i.access_token}`),
|
|
521
|
+
});
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
process.stdout.write(`Sent signature request for ${template.name}\n`);
|
|
525
|
+
if (request) {
|
|
526
|
+
process.stdout.write(`sign.request ${request.id} (state: ${request.state}) — cancel with: odoo sign requests cancel ${request.id}\n`);
|
|
527
|
+
for (const item of signerItems) {
|
|
528
|
+
process.stdout.write(` link ${item.partner_id[1]}: ${baseUrl}/sign/document/${request.id}/${item.access_token}\n`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (scoped.length < signerCount) {
|
|
532
|
+
process.stdout.write(`WARNING: only ${scoped.length}/${signerCount} expected mails found in queue — check the outgoing mail queue\n`);
|
|
533
|
+
}
|
|
534
|
+
for (const mail of scoped) {
|
|
535
|
+
process.stdout.write(` mail -> ${mail.email_to} [${mail.state}]\n`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async function odooBaseUrl(client, globals) {
|
|
539
|
+
// web.base.url is the public URL signers open; the connector's
|
|
540
|
+
// doc_server_odoo_url may be an internal address. Only fall back to
|
|
541
|
+
// internals when nothing better is configured.
|
|
542
|
+
const publicBase = await readConfigParam(client, "web.base.url");
|
|
543
|
+
const internalBase = await readConfigParam(client, `${CONFIG_NS}.doc_server_odoo_url`);
|
|
544
|
+
const base = publicBase ?? globals.url ?? internalBase ?? "";
|
|
545
|
+
if (!base) {
|
|
546
|
+
throw usageError("cannot determine the public Odoo URL for the sign link", "set web.base.url in the system parameters or pass --url");
|
|
547
|
+
}
|
|
548
|
+
return base.replace(/\/+$/, "");
|
|
549
|
+
}
|
|
550
|
+
export async function signRequestsListCommand(opts, globals) {
|
|
551
|
+
const client = await authenticatedClient(globals);
|
|
552
|
+
const domain = [];
|
|
553
|
+
if (opts.state)
|
|
554
|
+
domain.push(["state", "=", opts.state]);
|
|
555
|
+
const records = (await client.executeKw("sign.request", "search_read", [
|
|
556
|
+
domain,
|
|
557
|
+
["reference", "state", "nb_wait", "nb_total", "create_date"],
|
|
558
|
+
], {
|
|
559
|
+
limit: Number.parseInt(opts.limit, 10) || 30,
|
|
560
|
+
order: "id desc",
|
|
561
|
+
}));
|
|
562
|
+
if (globals.json) {
|
|
563
|
+
printJson({ model: "sign.request", count: records.length, records });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const headers = ["ID", "Reference", "State", "Waiting", "Signers", "Created"];
|
|
567
|
+
const rows = records.map((r) => [
|
|
568
|
+
String(r.id),
|
|
569
|
+
humanCell(r.reference),
|
|
570
|
+
r.state,
|
|
571
|
+
String(r.nb_wait),
|
|
572
|
+
`${r.nb_total - r.nb_wait}/${r.nb_total}`,
|
|
573
|
+
humanCell(r.create_date),
|
|
574
|
+
]);
|
|
575
|
+
process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
|
|
576
|
+
}
|
|
577
|
+
export async function signRequestsCancelCommand(idArg, opts, globals) {
|
|
578
|
+
const client = await authenticatedClient(globals);
|
|
579
|
+
const id = Number.parseInt(idArg, 10);
|
|
580
|
+
const rows = (await client.executeKw("sign.request", "read", [[id]], {
|
|
581
|
+
fields: ["state", "reference"],
|
|
582
|
+
}));
|
|
583
|
+
if (rows.length === 0) {
|
|
584
|
+
throw notFoundError("not_found", `sign.request ${id} not found`);
|
|
585
|
+
}
|
|
586
|
+
if (rows[0].state === "completed" || rows[0].state === "canceled") {
|
|
587
|
+
throw usageError(`sign.request ${id} is already ${rows[0].state}; nothing to cancel`);
|
|
588
|
+
}
|
|
589
|
+
if (globals.dryRun) {
|
|
590
|
+
process.stdout.write(`Would cancel sign.request ${id} (${rows[0].reference})\n`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
await client.executeKw("sign.request", "write", [[id], { state: "canceled" }]);
|
|
594
|
+
if (opts.json || globals.json) {
|
|
595
|
+
printJson({ model: "sign.request", action: "cancel", id, state: "canceled" });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
process.stdout.write(`Canceled sign.request ${id} (${rows[0].reference})\n`);
|
|
599
|
+
}
|