@bradtech/sales-skills 1.0.0

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.
@@ -0,0 +1,566 @@
1
+ import { CliCommand } from '@quatrain/cli';
2
+ import { XmlRpcClient } from '@quatrain/api-xmlrpc';
3
+ import { Skills } from '@quatrain/skills';
4
+
5
+ export class OdooClient {
6
+ private db: string;
7
+ private username: string;
8
+ private password: string;
9
+ private uid!: number;
10
+ private commonClient: XmlRpcClient;
11
+ private objectClient: XmlRpcClient;
12
+
13
+ constructor() {
14
+ const urlStr = process.env.ODOO_URL;
15
+ this.db = process.env.ODOO_DB || '';
16
+ this.username = process.env.ODOO_USER || '';
17
+ this.password = process.env.ODOO_PASSWORD || '';
18
+
19
+ if (!urlStr || !this.db || !this.username || !this.password) {
20
+ Skills.error(
21
+ 'Error: ODOO_URL, ODOO_DB, ODOO_USER, and ODOO_PASSWORD must be defined in your .env file.'
22
+ );
23
+ process.exit(1);
24
+ }
25
+
26
+ try {
27
+ const url = new URL(urlStr);
28
+ const isHttps = url.protocol === 'https:';
29
+ const port = url.port ? parseInt(url.port, 10) : (isHttps ? 443 : 80);
30
+
31
+ this.commonClient = new XmlRpcClient({
32
+ host: url.hostname,
33
+ port,
34
+ path: '/xmlrpc/2/common',
35
+ secure: isHttps
36
+ });
37
+
38
+ this.objectClient = new XmlRpcClient({
39
+ host: url.hostname,
40
+ port,
41
+ path: '/xmlrpc/2/object',
42
+ secure: isHttps
43
+ });
44
+ } catch (err: any) {
45
+ Skills.error(`Error parsing Odoo URL: ${err.message}`);
46
+ process.exit(1);
47
+ }
48
+ }
49
+
50
+ async authenticate(): Promise<void> {
51
+ try {
52
+ const value = await this.commonClient.methodCall(
53
+ 'authenticate',
54
+ [this.db, this.username, this.password, {}]
55
+ );
56
+ if (!value) {
57
+ throw new Error('Authentication failed. Check your Odoo credentials.');
58
+ }
59
+ this.uid = value;
60
+ } catch (err: any) {
61
+ Skills.error(`Odoo Authentication Error: ${err.message}`);
62
+ process.exit(1);
63
+ }
64
+ }
65
+
66
+ private async executeKw(model: string, method: string, args: any[], kwargs: any = {}): Promise<any> {
67
+ return this.objectClient.methodCall(
68
+ 'execute_kw',
69
+ [this.db, this.uid, this.password, model, method, args, kwargs]
70
+ );
71
+ }
72
+
73
+ async findCountryId(countryNameOrCode?: string): Promise<number | null> {
74
+ if (!countryNameOrCode) return null;
75
+ try {
76
+ const domain = [
77
+ '|',
78
+ ['name', '=ilike', countryNameOrCode],
79
+ ['code', '=ilike', countryNameOrCode]
80
+ ];
81
+ const countryIds = await this.executeKw('res.country', 'search', [domain]);
82
+ return countryIds.length > 0 ? countryIds[0] : null;
83
+ } catch (err: any) {
84
+ Skills.warn(`Warning searching country '${countryNameOrCode}': ${err.message}`);
85
+ return null;
86
+ }
87
+ }
88
+
89
+ async findActivityTypeId(nameQuery = 'todo'): Promise<number | null> {
90
+ try {
91
+ let domain = [
92
+ '|',
93
+ ['name', '=ilike', nameQuery],
94
+ ['name', '=ilike', 'to do']
95
+ ];
96
+ let typeIds = await this.executeKw('mail.activity.type', 'search', [domain]);
97
+
98
+ if (typeIds.length === 0 && nameQuery === 'todo') {
99
+ const domainFr = [['name', 'ilike', 'faire']];
100
+ typeIds = await this.executeKw('mail.activity.type', 'search', [domainFr]);
101
+ }
102
+
103
+ if (typeIds.length === 0) {
104
+ typeIds = await this.executeKw('mail.activity.type', 'search', [[]]);
105
+ }
106
+
107
+ return typeIds.length > 0 ? typeIds[0] : null;
108
+ } catch (err: any) {
109
+ Skills.warn(`Warning searching activity type '${nameQuery}': ${err.message}`);
110
+ return null;
111
+ }
112
+ }
113
+
114
+ async findModelId(modelName: string): Promise<number | null> {
115
+ try {
116
+ const modelIds = await this.executeKw('ir.model', 'search', [[['model', '=', modelName]]]);
117
+ return modelIds.length > 0 ? modelIds[0] : null;
118
+ } catch (err: any) {
119
+ Skills.warn(`Warning searching model '${modelName}': ${err.message}`);
120
+ return null;
121
+ }
122
+ }
123
+
124
+ async createCompany(
125
+ name: string,
126
+ email?: string,
127
+ phone?: string,
128
+ city?: string,
129
+ country?: string,
130
+ street?: string
131
+ ) {
132
+ const values: any = {
133
+ name,
134
+ is_company: true,
135
+ company_type: 'company'
136
+ };
137
+
138
+ if (email) values.email = email;
139
+ if (phone) values.phone = phone;
140
+ if (city) values.city = city;
141
+ if (street) values.street = street;
142
+
143
+ const countryId = await this.findCountryId(country);
144
+ if (countryId) values.country_id = countryId;
145
+
146
+ const companyId = await this.executeKw('res.partner', 'create', [values]);
147
+ return {
148
+ id: companyId,
149
+ name,
150
+ type: 'company',
151
+ city: city || null,
152
+ street: street || null,
153
+ country_id: countryId
154
+ };
155
+ }
156
+
157
+ async createContact(
158
+ name: string,
159
+ companyId?: number,
160
+ email?: string,
161
+ phone?: string,
162
+ city?: string,
163
+ country?: string,
164
+ functionName?: string,
165
+ street?: string
166
+ ) {
167
+ const values: any = {
168
+ name,
169
+ is_company: false,
170
+ company_type: 'person'
171
+ };
172
+
173
+ if (companyId) values.parent_id = companyId;
174
+ if (email) values.email = email;
175
+ if (phone) values.phone = phone;
176
+ if (city) values.city = city;
177
+ if (functionName) values.function = functionName;
178
+ if (street) values.street = street;
179
+
180
+ const countryId = await this.findCountryId(country);
181
+ if (countryId) values.country_id = countryId;
182
+
183
+ const contactId = await this.executeKw('res.partner', 'create', [values]);
184
+ return {
185
+ id: contactId,
186
+ name,
187
+ company_id: companyId || null,
188
+ type: 'contact',
189
+ city: city || null,
190
+ street: street || null,
191
+ country_id: countryId,
192
+ function: functionName || null
193
+ };
194
+ }
195
+
196
+ async updatePartner(partnerId: number, rawValues: any) {
197
+ const values = { ...rawValues };
198
+ if ('country' in values && values.country) {
199
+ const countryId = await this.findCountryId(values.country);
200
+ if (countryId) {
201
+ values.country_id = countryId;
202
+ }
203
+ delete values.country;
204
+ }
205
+
206
+ // Clean up undefined or null values
207
+ const cleanValues: any = {};
208
+ for (const [k, v] of Object.entries(values)) {
209
+ if (v !== undefined && v !== null) {
210
+ cleanValues[k] = v;
211
+ }
212
+ }
213
+
214
+ if (Object.keys(cleanValues).length > 0) {
215
+ await this.executeKw('res.partner', 'write', [[partnerId], cleanValues]);
216
+ }
217
+ return { id: partnerId, updated_fields: Object.keys(cleanValues) };
218
+ }
219
+
220
+ async createOpportunity(name: string, partnerId: number, revenue = 0.0, description?: string) {
221
+ const values: any = {
222
+ name,
223
+ partner_id: partnerId,
224
+ type: 'opportunity'
225
+ };
226
+ if (revenue) values.planned_revenue = revenue;
227
+ if (description) values.description = description;
228
+
229
+ const opportunityId = await this.executeKw('crm.lead', 'create', [values]);
230
+ return {
231
+ id: opportunityId,
232
+ name,
233
+ partner_id: partnerId,
234
+ planned_revenue: revenue
235
+ };
236
+ }
237
+
238
+ async createMeeting(name: string, startDatetime: string, durationHours = 1.0, partnerIds?: number[]) {
239
+ // Computes stop datetime to prevent Odoo timezone/validation error
240
+ // Start datetime expected in format YYYY-MM-DD HH:MM:SS
241
+ const startStrNorm = startDatetime.replace(' ', 'T');
242
+ const startDt = new Date(startStrNorm);
243
+ if (isNaN(startDt.getTime())) {
244
+ Skills.error(`Invalid start datetime format: ${startDatetime}`);
245
+ process.exit(1);
246
+ }
247
+
248
+ const endDt = new Date(startDt.getTime() + durationHours * 60 * 60 * 1000);
249
+
250
+ // Format back to YYYY-MM-DD HH:MM:SS for Odoo
251
+ const formatOdooDate = (date: Date) => {
252
+ const pad = (num: number) => String(num).padStart(2, '0');
253
+ const yyyy = date.getUTCFullYear();
254
+ const mm = pad(date.getUTCMonth() + 1);
255
+ const dd = pad(date.getUTCDate());
256
+ const hh = pad(date.getUTCHours());
257
+ const min = pad(date.getUTCMinutes());
258
+ const ss = pad(date.getUTCSeconds());
259
+ return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;
260
+ };
261
+
262
+ const startOdoo = formatOdooDate(startDt);
263
+ const stopOdoo = formatOdooDate(endDt);
264
+
265
+ const values: any = {
266
+ name,
267
+ start: startOdoo,
268
+ stop: stopOdoo,
269
+ duration: durationHours
270
+ };
271
+
272
+ if (partnerIds && partnerIds.length > 0) {
273
+ values.partner_ids = [[6, 0, partnerIds]];
274
+ }
275
+
276
+ const eventId = await this.executeKw('calendar.event', 'create', [values]);
277
+ return {
278
+ id: eventId,
279
+ name,
280
+ start: startOdoo,
281
+ stop: stopOdoo,
282
+ duration: durationHours,
283
+ partner_ids: partnerIds || null
284
+ };
285
+ }
286
+
287
+ async createActivity(resModel: string, resId: number, summary: string, note?: string, activityTypeName = 'todo') {
288
+ const activityTypeId = await this.findActivityTypeId(activityTypeName);
289
+ const resModelId = await this.findModelId(resModel);
290
+
291
+ const values: any = {
292
+ res_model: resModel,
293
+ res_id: resId,
294
+ summary
295
+ };
296
+
297
+ if (resModelId) values.res_model_id = resModelId;
298
+ if (note) values.note = note;
299
+ if (activityTypeId) values.activity_type_id = activityTypeId;
300
+
301
+ const activityId = await this.executeKw('mail.activity', 'create', [values]);
302
+ return {
303
+ id: activityId,
304
+ res_model: resModel,
305
+ res_id: resId,
306
+ summary,
307
+ activity_type_id: activityTypeId
308
+ };
309
+ }
310
+
311
+ async resetCrm() {
312
+ const leadIds = await this.executeKw('crm.lead', 'search', [[]]);
313
+ if (leadIds.length > 0) {
314
+ await this.executeKw('crm.lead', 'unlink', [leadIds]);
315
+ }
316
+ return { deleted_ids: leadIds, count: leadIds.length };
317
+ }
318
+
319
+ async searchOpportunities(query?: string, limit = 100) {
320
+ const domain: any[] = [['type', '=', 'opportunity']];
321
+ if (query) {
322
+ domain.push(['name', 'ilike', query]);
323
+ }
324
+
325
+ const fields = [
326
+ 'id',
327
+ 'name',
328
+ 'partner_id',
329
+ 'stage_id',
330
+ 'planned_revenue',
331
+ 'probability',
332
+ 'create_date'
333
+ ];
334
+
335
+ return this.executeKw('crm.lead', 'search_read', [domain], { fields, limit });
336
+ }
337
+
338
+ async searchPartners(query?: string, isCompany?: boolean, limit = 100) {
339
+ const domain: any[] = [];
340
+ if (isCompany !== undefined) {
341
+ domain.push(['is_company', '=', isCompany]);
342
+ }
343
+ if (query) {
344
+ domain.push(['name', 'ilike', query]);
345
+ }
346
+
347
+ const fields = [
348
+ 'id',
349
+ 'name',
350
+ 'is_company',
351
+ 'email',
352
+ 'phone',
353
+ 'city',
354
+ 'country_id'
355
+ ];
356
+
357
+ return this.executeKw('res.partner', 'search_read', [domain], { fields, limit });
358
+ }
359
+ }
360
+
361
+ async function main() {
362
+ const program = new CliCommand();
363
+ program
364
+ .name('odoo_cli')
365
+ .description('Odoo ERP Integration CLI wrapper');
366
+
367
+ // Command: create-company
368
+ program
369
+ .command('create-company')
370
+ .description('Create a new company partner')
371
+ .requiredOption('--name <name>', 'Company name')
372
+ .option('--email <email>', 'Company email')
373
+ .option('--phone <phone>', 'Company phone')
374
+ .option('--city <city>', 'Company city')
375
+ .option('--country <country>', 'Company country name or code')
376
+ .option('--street <street>', 'Company street address')
377
+ .requiredOption('--output <path>', 'Output JSON file path')
378
+ .action(async (options) => {
379
+ const client = new OdooClient();
380
+ await client.authenticate();
381
+ const result = await client.createCompany(
382
+ options.name,
383
+ options.email,
384
+ options.phone,
385
+ options.city,
386
+ options.country,
387
+ options.street
388
+ );
389
+ await Skills.writeOutput(result, options.output);
390
+ });
391
+
392
+ // Command: create-contact
393
+ program
394
+ .command('create-contact')
395
+ .description('Create a new individual contact')
396
+ .requiredOption('--name <name>', 'Contact name')
397
+ .option('--company-id <id>', 'Parent company ID', (val) => parseInt(val, 10))
398
+ .option('--email <email>', 'Contact email')
399
+ .option('--phone <phone>', 'Contact phone')
400
+ .option('--city <city>', 'Contact city')
401
+ .option('--country <country>', 'Contact country name or code')
402
+ .option('--street <street>', 'Contact street address')
403
+ .option('--function <job>', 'Contact job position/function')
404
+ .requiredOption('--output <path>', 'Output JSON file path')
405
+ .action(async (options) => {
406
+ const client = new OdooClient();
407
+ await client.authenticate();
408
+ const result = await client.createContact(
409
+ options.name,
410
+ options.companyId,
411
+ options.email,
412
+ options.phone,
413
+ options.city,
414
+ options.country,
415
+ options.function,
416
+ options.street
417
+ );
418
+ await Skills.writeOutput(result, options.output);
419
+ });
420
+
421
+ // Command: update-partner
422
+ program
423
+ .command('update-partner')
424
+ .description('Update an existing partner (contact or company)')
425
+ .requiredOption('--id <id>', 'Partner ID to update', (val) => parseInt(val, 10))
426
+ .option('--name <name>', 'Updated name')
427
+ .option('--email <email>', 'Updated email')
428
+ .option('--phone <phone>', 'Updated phone')
429
+ .option('--city <city>', 'Updated city')
430
+ .option('--country <country>', 'Updated country')
431
+ .option('--street <street>', 'Updated street address')
432
+ .option('--function <job>', 'Updated job position/function')
433
+ .option('--company-id <id>', 'Updated parent company ID', (val) => parseInt(val, 10))
434
+ .requiredOption('--output <path>', 'Output JSON file path')
435
+ .action(async (options) => {
436
+ const client = new OdooClient();
437
+ await client.authenticate();
438
+ const values = {
439
+ name: options.name,
440
+ email: options.email,
441
+ phone: options.phone,
442
+ city: options.city,
443
+ country: options.country,
444
+ street: options.street,
445
+ function: options.function,
446
+ parent_id: options.companyId
447
+ };
448
+ const result = await client.updatePartner(options.id, values);
449
+ await Skills.writeOutput(result, options.output);
450
+ });
451
+
452
+ // Command: create-opportunity
453
+ program
454
+ .command('create-opportunity')
455
+ .description('Create a CRM opportunity')
456
+ .requiredOption('--name <subject>', 'Opportunity subject/name')
457
+ .requiredOption('--partner-id <id>', 'Partner ID linked to the opportunity', (val) => parseInt(val, 10))
458
+ .option('--revenue <amount>', 'Estimated revenue', (val) => parseFloat(val), 0.0)
459
+ .option('--description <notes>', 'Internal description notes')
460
+ .requiredOption('--output <path>', 'Output JSON file path')
461
+ .action(async (options) => {
462
+ const client = new OdooClient();
463
+ await client.authenticate();
464
+ const result = await client.createOpportunity(
465
+ options.name,
466
+ options.partnerId,
467
+ options.revenue,
468
+ options.description
469
+ );
470
+ await Skills.writeOutput(result, options.output);
471
+ });
472
+
473
+ // Command: create-meeting
474
+ program
475
+ .command('create-meeting')
476
+ .description('Create a calendar event / meeting')
477
+ .requiredOption('--name <subject>', 'Meeting subject')
478
+ .requiredOption('--start <datetime>', 'Start datetime YYYY-MM-DD HH:MM:SS')
479
+ .option('--duration <hours>', 'Duration in hours', (val) => parseFloat(val), 1.0)
480
+ .option('--partner-ids <ids>', 'Comma-separated partner IDs', (val) => val.split(',').map((x) => parseInt(x.trim(), 10)))
481
+ .requiredOption('--output <path>', 'Output JSON file path')
482
+ .action(async (options) => {
483
+ const client = new OdooClient();
484
+ await client.authenticate();
485
+ const result = await client.createMeeting(
486
+ options.name,
487
+ options.start,
488
+ options.duration,
489
+ options.partnerIds
490
+ );
491
+ await Skills.writeOutput(result, options.output);
492
+ });
493
+
494
+ // Command: create-activity
495
+ program
496
+ .command('create-activity')
497
+ .description('Create a planned activity on a model')
498
+ .requiredOption('--model <model>', 'Target model (res.partner or crm.lead)')
499
+ .requiredOption('--res-id <id>', 'Target record ID', (val) => parseInt(val, 10))
500
+ .requiredOption('--summary <title>', 'Activity summary')
501
+ .option('--note <html-text>', 'HTML formatted activity note')
502
+ .option('--type <type>', 'Activity type (todo, email, call)', 'todo')
503
+ .requiredOption('--output <path>', 'Output JSON file path')
504
+ .action(async (options) => {
505
+ const client = new OdooClient();
506
+ await client.authenticate();
507
+ const result = await client.createActivity(
508
+ options.model,
509
+ options.resId,
510
+ options.summary,
511
+ options.note,
512
+ options.type
513
+ );
514
+ await Skills.writeOutput(result, options.output);
515
+ });
516
+
517
+ // Command: reset-crm
518
+ program
519
+ .command('reset-crm')
520
+ .description('Delete all CRM leads/opportunities')
521
+ .requiredOption('--output <path>', 'Output JSON file path')
522
+ .action(async (options) => {
523
+ const client = new OdooClient();
524
+ await client.authenticate();
525
+ const result = await client.resetCrm();
526
+ await Skills.writeOutput(result, options.output);
527
+ });
528
+
529
+ // Command: search-opportunities
530
+ program
531
+ .command('search-opportunities')
532
+ .description('Search and list CRM opportunities')
533
+ .option('--query <text>', 'Search query matching opportunity name')
534
+ .requiredOption('--limit <number>', 'Max results limit', (val) => parseInt(val, 10))
535
+ .requiredOption('--output <path>', 'Output JSON file path')
536
+ .action(async (options) => {
537
+ const client = new OdooClient();
538
+ await client.authenticate();
539
+ const result = await client.searchOpportunities(options.query, options.limit);
540
+ await Skills.writeOutput(result, options.output);
541
+ });
542
+
543
+ // Command: search-partners
544
+ program
545
+ .command('search-partners')
546
+ .description('Search and list partners (contacts or companies)')
547
+ .option('--query <text>', 'Search query matching partner name')
548
+ .option('--is-company <boolean>', 'Filter by company status (true or false)', (val) => val === 'true')
549
+ .requiredOption('--limit <number>', 'Max results limit', (val) => parseInt(val, 10))
550
+ .requiredOption('--output <path>', 'Output JSON file path')
551
+ .action(async (options) => {
552
+ const client = new OdooClient();
553
+ await client.authenticate();
554
+ const result = await client.searchPartners(options.query, options.isCompany, options.limit);
555
+ await Skills.writeOutput(result, options.output);
556
+ });
557
+
558
+ await program.parseAsync(process.argv);
559
+ }
560
+
561
+ if (import.meta.main) {
562
+ main().catch((err) => {
563
+ Skills.error(`Unexpected process error: ${err.message}`);
564
+ process.exit(1);
565
+ });
566
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2022",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "moduleDetection": "force",
11
+ "noEmit": true,
12
+ "baseUrl": ".",
13
+ "paths": {
14
+ "@quatrain/skills": ["../../QUATRAIN/Core/packages/skills/src/index.ts"],
15
+ "@quatrain/log": ["../../QUATRAIN/Core/packages/log/src/index.ts"],
16
+ "@quatrain/core": ["../../QUATRAIN/Core/packages/core/src/index.ts"],
17
+ "@quatrain/types": ["../../QUATRAIN/Core/packages/types/src/index.ts"],
18
+ "@quatrain/api-client": ["../../QUATRAIN/Core/packages/api-client/src/index.ts"],
19
+ "@quatrain/cli": ["../../QUATRAIN/Core/packages/cli/src/index.ts"],
20
+ "@quatrain/api-xmlrpc": ["../../QUATRAIN/Core/packages/api-xmlrpc/src/index.ts"]
21
+ }
22
+ },
23
+ "include": ["skills/**/*"]
24
+ }