@marcfargas/odoo-skills 0.2.0 → 0.4.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.
- package/README.md +6 -2
- package/package.json +1 -1
- package/skills/SKILL.md +7 -2
- package/skills/modules/accounting.md +322 -0
- package/skills/modules/attendance.md +185 -0
- package/skills/modules/contracts.md +180 -0
- package/skills/modules/timesheets.md +181 -0
- package/skills/oca/mis-builder.md +61 -0
package/README.md
CHANGED
|
@@ -33,6 +33,7 @@ npx @marcfargas/create-odoo-skills my-odoo-skills
|
|
|
33
33
|
| [introspection](./skills/base/introspection.md) | Discover models and fields dynamically |
|
|
34
34
|
| [properties](./skills/base/properties.md) | Dynamic user-defined fields |
|
|
35
35
|
| [modules](./skills/base/modules.md) | Module lifecycle management |
|
|
36
|
+
| [skill-generation](./skills/base/skill-generation.md) | How to create new skills |
|
|
36
37
|
|
|
37
38
|
### Mail System
|
|
38
39
|
|
|
@@ -46,9 +47,12 @@ npx @marcfargas/create-odoo-skills my-odoo-skills
|
|
|
46
47
|
|
|
47
48
|
| Module | Required Odoo Modules | What it teaches |
|
|
48
49
|
|--------|----------------------|-----------------|
|
|
50
|
+
| [accounting](./skills/modules/accounting.md) | `account` | Accounting patterns, cash discovery, reconciliation, PnL, validation |
|
|
51
|
+
| [attendance](./skills/modules/attendance.md) | `hr_attendance` | Clock in/out, presence tracking |
|
|
52
|
+
| [contracts](./skills/modules/contracts.md) | `contract` (OCA) | Recurring contracts, billing schedules, revenue projection |
|
|
49
53
|
| [timesheets](./skills/modules/timesheets.md) | `hr_timesheet` | Time tracking on projects |
|
|
50
|
-
| [
|
|
51
|
-
| [mis-builder](./skills/oca/mis-builder.md) | `mis_builder` | OCA financial reports |
|
|
54
|
+
| [mis-builder](./skills/oca/mis-builder.md) | `mis_builder` | OCA financial reports (reading, computing, exporting) |
|
|
55
|
+
| [mis-builder-dev](./skills/oca/mis-builder-dev.md) | `mis_builder` | OCA financial reports (creating, editing, expression language) |
|
|
52
56
|
|
|
53
57
|
## Prerequisites
|
|
54
58
|
|
package/package.json
CHANGED
package/skills/SKILL.md
CHANGED
|
@@ -36,6 +36,9 @@ Domain-specific helpers are accessed via lazy getters on the client:
|
|
|
36
36
|
|----------|-------------|-----------|
|
|
37
37
|
| `client.mail.*` | Post notes & messages on chatter | `mail/chatter.md` |
|
|
38
38
|
| `client.modules.*` | Install, uninstall, check modules | `base/modules.md` |
|
|
39
|
+
| `client.accounting.*` | Cash discovery, reconciliation, partner resolution | `modules/accounting.md` |
|
|
40
|
+
| `client.attendance.*` | Clock in/out, presence tracking | `modules/attendance.md` |
|
|
41
|
+
| `client.timesheets.*` | Timer start/stop, time logging | `modules/timesheets.md` |
|
|
39
42
|
|
|
40
43
|
Core CRUD (`searchRead`, `create`, `write`, `unlink`, etc.) stays directly on `client`.
|
|
41
44
|
|
|
@@ -88,8 +91,10 @@ Load by reading the path shown below:
|
|
|
88
91
|
|
|
89
92
|
| Skill | Path | Required Modules | Description |
|
|
90
93
|
|-------|------|------------------|-------------|
|
|
91
|
-
| accounting | `modules/accounting.md` | `account` | Accounting patterns, cashflow, reconciliation,
|
|
92
|
-
|
|
|
94
|
+
| accounting | `modules/accounting.md` | `account` | Accounting patterns, cashflow, reconciliation, PnL, validation |
|
|
95
|
+
| contracts | `modules/contracts.md` | `contract` (OCA) | Recurring contracts, billing schedules, revenue projection |
|
|
96
|
+
| attendance | `modules/attendance.md` | `hr_attendance` | Clock in/out, presence tracking (`client.attendance.*`) |
|
|
97
|
+
| timesheets | `modules/timesheets.md` | `hr_timesheet` | Timer start/stop, time logging on projects (`client.timesheets.*`) |
|
|
93
98
|
| mis-builder | `oca/mis-builder.md` | `mis_builder`, `date_range`, `report_xlsx` | MIS Builder — reading, computing, exporting reports |
|
|
94
99
|
| mis-builder-dev | `oca/mis-builder-dev.md` | `mis_builder`, `date_range`, `report_xlsx` | MIS Builder — creating & editing report templates, expression language, styling |
|
|
95
100
|
|
|
@@ -4,6 +4,8 @@ Hard-won knowledge from building accounting dashboards and cashflow tools agains
|
|
|
4
4
|
|
|
5
5
|
**Required modules**: `account` (Invoicing/Accounting)
|
|
6
6
|
|
|
7
|
+
**Optional modules**: `account_loan` (loan management), `contract` (recurring contracts — see `contracts.md`)
|
|
8
|
+
|
|
7
9
|
## Critical API Gotchas
|
|
8
10
|
|
|
9
11
|
### searchRead Default Limit is 100
|
|
@@ -68,6 +70,86 @@ const lines = await client.searchRead('account.loan.line',
|
|
|
68
70
|
);
|
|
69
71
|
```
|
|
70
72
|
|
|
73
|
+
## Account Move Fundamentals
|
|
74
|
+
|
|
75
|
+
### Move Types
|
|
76
|
+
|
|
77
|
+
`account.move` uses `move_type` to distinguish document types:
|
|
78
|
+
|
|
79
|
+
| `move_type` | Description |
|
|
80
|
+
|-------------|-------------|
|
|
81
|
+
| `out_invoice` | Customer invoice |
|
|
82
|
+
| `out_refund` | Customer credit note |
|
|
83
|
+
| `in_invoice` | Supplier invoice |
|
|
84
|
+
| `in_refund` | Supplier credit note |
|
|
85
|
+
| `entry` | General journal entry (payroll, depreciation, closing, adjustments, bank statements) |
|
|
86
|
+
|
|
87
|
+
### Always Filter Posted State
|
|
88
|
+
|
|
89
|
+
Draft and cancelled entries must be excluded from all financial analysis. **This is the single most common mistake.**
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// ✅ On account.move — filter by state
|
|
93
|
+
const moves = await client.searchRead('account.move',
|
|
94
|
+
[['state', '=', 'posted'], ['date', '>=', '2025-01-01']],
|
|
95
|
+
{ fields: ['name', 'date', 'amount_total'], limit: 0 }
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// ✅ On account.move.line — filter by parent_state
|
|
99
|
+
const lines = await client.searchRead('account.move.line',
|
|
100
|
+
[['parent_state', '=', 'posted'], ['date', '>=', '2025-01-01']],
|
|
101
|
+
{ fields: ['account_id', 'debit', 'credit', 'balance'], limit: 0 }
|
|
102
|
+
);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Sign Convention for PnL Analysis
|
|
106
|
+
|
|
107
|
+
`balance = debit - credit` uses natural debit sign. For PnL analysis this is counterintuitive because income accounts (7xx, credit balances) appear negative.
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// ✅ Invert sign for PnL: income → positive, expenses → negative
|
|
111
|
+
// Then SUM naturally gives EBITDA / operating result
|
|
112
|
+
const pnlLines = lines.map(l => ({
|
|
113
|
+
...l,
|
|
114
|
+
pnl_amount: -(l.debit - l.credit), // or equivalently: l.credit - l.debit
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
// SUM(pnl_amount) where account 7xx → positive (income)
|
|
118
|
+
// SUM(pnl_amount) where account 6xx → negative (expenses)
|
|
119
|
+
// Total SUM → operating result
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### amount_untaxed vs balance Aggregation
|
|
123
|
+
|
|
124
|
+
Two valid approaches — pick one, never mix:
|
|
125
|
+
|
|
126
|
+
| Approach | Source | Use for |
|
|
127
|
+
|----------|--------|---------|
|
|
128
|
+
| `account.move.amount_untaxed` | Invoice header | Invoice-level analysis, quick totals |
|
|
129
|
+
| `SUM(account.move.line.balance)` on 6xx/7xx | Journal items | Per-line, per-partner, per-account granularity |
|
|
130
|
+
|
|
131
|
+
### Detecting Year-End Closing Entries
|
|
132
|
+
|
|
133
|
+
`move_type='entry'` includes operational entries (payroll, depreciation) AND year-end closing entries. Closing entries distort operational PnL.
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// Detect closing entries: any sibling line on account 129x (resultado del ejercicio)
|
|
137
|
+
async function isClosingEntry(client: OdooClient, moveId: number): Promise<boolean> {
|
|
138
|
+
const lines = await client.searchRead('account.move.line',
|
|
139
|
+
[['move_id', '=', moveId]],
|
|
140
|
+
{ fields: ['account_id'], limit: 0 }
|
|
141
|
+
);
|
|
142
|
+
return lines.some(l => {
|
|
143
|
+
const code = Array.isArray(l.account_id) ? l.account_id[1] : String(l.account_id);
|
|
144
|
+
return /^129/.test(code);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// When querying PnL, exclude closing entries
|
|
149
|
+
// Option 1: Pre-filter move IDs
|
|
150
|
+
// Option 2: Exclude account 129x lines and check remaining moves
|
|
151
|
+
```
|
|
152
|
+
|
|
71
153
|
## Cash Account Discovery
|
|
72
154
|
|
|
73
155
|
### Use Journals, Not Account Code Prefixes
|
|
@@ -86,6 +168,37 @@ const cashAccountIds = journals
|
|
|
86
168
|
.filter(Boolean);
|
|
87
169
|
```
|
|
88
170
|
|
|
171
|
+
### Exclude Transit and Third-Party Accounts
|
|
172
|
+
|
|
173
|
+
Not everything in bank journals is your money:
|
|
174
|
+
|
|
175
|
+
| Account | Description | Include in cash? |
|
|
176
|
+
|---------|-------------|------------------|
|
|
177
|
+
| 57x | Bank/cash accounts | ✅ Yes |
|
|
178
|
+
| 5201x | Credit line drawdowns | ✅ Yes — drawn credit is liquid |
|
|
179
|
+
| 5200x | Short-term loan reclassifications | ❌ No — accounting reclassification, not cash |
|
|
180
|
+
| 555 | Transient / pending application | ❌ No — not the company's money |
|
|
181
|
+
| 561 | Client provisions (suplidos) | ❌ No — third-party funds held temporarily |
|
|
182
|
+
|
|
183
|
+
### Cash Balance from GL, Not from Movements
|
|
184
|
+
|
|
185
|
+
Summing cash movements within a date range misses prior history and opening balances. True cash balance = cumulative GL balance:
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
// ✅ CORRECT: Cash balance at a point in time from GL
|
|
189
|
+
const cashBalance = await client.searchRead('account.move.line',
|
|
190
|
+
[
|
|
191
|
+
['account_id', 'in', cashAccountIds],
|
|
192
|
+
['parent_state', '=', 'posted'],
|
|
193
|
+
['date', '<=', '2025-06-30'],
|
|
194
|
+
],
|
|
195
|
+
{ fields: ['balance'], limit: 0 }
|
|
196
|
+
);
|
|
197
|
+
const totalCash = cashBalance.reduce((sum, l) => sum + l.balance, 0);
|
|
198
|
+
|
|
199
|
+
// ❌ WRONG: Only summing movements in a period misses opening balance
|
|
200
|
+
```
|
|
201
|
+
|
|
89
202
|
## Bank Statement Patterns
|
|
90
203
|
|
|
91
204
|
### Partner Is on the Counterpart Line, Not the Bank Line
|
|
@@ -160,6 +273,47 @@ if (reconcileId) {
|
|
|
160
273
|
}
|
|
161
274
|
```
|
|
162
275
|
|
|
276
|
+
### Days-to-Pay Calculation
|
|
277
|
+
|
|
278
|
+
Use `full_reconcile_id` to calculate how long an invoice takes to get paid:
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
async function calculateDaysToPay(
|
|
282
|
+
client: OdooClient, invoiceId: number
|
|
283
|
+
): Promise<number | null> {
|
|
284
|
+
// Get receivable/payable lines from the invoice
|
|
285
|
+
const invoiceLines = await client.searchRead('account.move.line',
|
|
286
|
+
[
|
|
287
|
+
['move_id', '=', invoiceId],
|
|
288
|
+
['full_reconcile_id', '!=', false],
|
|
289
|
+
['account_id.account_type', 'in', ['asset_receivable', 'liability_payable']],
|
|
290
|
+
],
|
|
291
|
+
{ fields: ['full_reconcile_id', 'date'], limit: 0 }
|
|
292
|
+
);
|
|
293
|
+
if (!invoiceLines.length) return null;
|
|
294
|
+
|
|
295
|
+
const reconcileId = Array.isArray(invoiceLines[0].full_reconcile_id)
|
|
296
|
+
? invoiceLines[0].full_reconcile_id[0]
|
|
297
|
+
: invoiceLines[0].full_reconcile_id;
|
|
298
|
+
const invoiceDate = invoiceLines[0].date;
|
|
299
|
+
|
|
300
|
+
// Find all lines in the reconciliation group — the latest date is the payment
|
|
301
|
+
const allReconLines = await client.searchRead('account.move.line',
|
|
302
|
+
[['full_reconcile_id', '=', reconcileId]],
|
|
303
|
+
{ fields: ['date'], limit: 0 }
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const paymentDate = allReconLines.reduce((max, l) =>
|
|
307
|
+
l.date > max ? l.date : max, allReconLines[0].date
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const msPerDay = 86_400_000;
|
|
311
|
+
return Math.round(
|
|
312
|
+
(new Date(paymentDate).getTime() - new Date(invoiceDate).getTime()) / msPerDay
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
163
317
|
## Loan Accounting
|
|
164
318
|
|
|
165
319
|
### Loan Moves Don't Touch Cash
|
|
@@ -169,6 +323,42 @@ if (reconcileId) {
|
|
|
169
323
|
- Use `account.loan` for: outstanding balances, payment schedules, projections
|
|
170
324
|
- Do NOT use for: cashflow classification (the actual cash movement is a separate bank entry)
|
|
171
325
|
|
|
326
|
+
### Bank Loans vs Tax Deferrals
|
|
327
|
+
|
|
328
|
+
The `account.loan` module handles both bank loans and tax payment deferrals. Distinguish by the short-term account:
|
|
329
|
+
|
|
330
|
+
| `short_term_loan_account_id` | Type | Cashflow Category |
|
|
331
|
+
|-------------------------------|------|-------------------|
|
|
332
|
+
| 475x (Hacienda Pública) | Tax deferral (APLAZ) | Operating — tax payments |
|
|
333
|
+
| 520x / 170x (Deudas) | Bank loan | Financing — debt repayment |
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
const loans = await client.searchRead('account.loan',
|
|
337
|
+
[],
|
|
338
|
+
{ fields: ['name', 'short_term_loan_account_id', 'long_term_loan_account_id'], limit: 0 }
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
for (const loan of loans) {
|
|
342
|
+
const stAccount = Array.isArray(loan.short_term_loan_account_id)
|
|
343
|
+
? loan.short_term_loan_account_id[1] : '';
|
|
344
|
+
const isBank = /^(520|170)/.test(stAccount);
|
|
345
|
+
const isTaxDeferral = /^475/.test(stAccount);
|
|
346
|
+
console.log(`${loan.name}: ${isBank ? 'BANK LOAN' : isTaxDeferral ? 'TAX DEFERRAL' : 'OTHER'}`);
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Loan Lines Link to Accounting Entries
|
|
351
|
+
|
|
352
|
+
Each `account.loan.line` has a `move_ids` field linking to the journal entries it generated. Use this to tag bank movements as loan repayments:
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
const loanLines = await client.searchRead('account.loan.line',
|
|
356
|
+
[['loan_id', '=', loanId], ['move_ids', '!=', false]],
|
|
357
|
+
{ fields: ['date', 'payment_amount', 'move_ids'], limit: 0 }
|
|
358
|
+
);
|
|
359
|
+
// move_ids links to account.move records — these are the loan accounting entries
|
|
360
|
+
```
|
|
361
|
+
|
|
172
362
|
### Loan Repayments Go Through Payables
|
|
173
363
|
|
|
174
364
|
Loan payments typically flow: 520/170 → 410/411 → bank. Look for patterns in the move reference:
|
|
@@ -191,8 +381,140 @@ const isLoanRepayment = loanPattern.test(line.name || '') || loanPattern.test(li
|
|
|
191
381
|
| 555 | Transient/pending | Not the company's money |
|
|
192
382
|
| 57x | Bank/cash | Discover via journals, not code prefix |
|
|
193
383
|
|
|
384
|
+
## PnL & Cash Flow Structure (Spanish GAAP / PGC)
|
|
385
|
+
|
|
386
|
+
### Account Code Ranges
|
|
387
|
+
|
|
388
|
+
Spanish Plan General Contable maps 3-digit prefixes to PnL categories:
|
|
389
|
+
|
|
390
|
+
| Range | Category | Examples |
|
|
391
|
+
|-------|----------|----------|
|
|
392
|
+
| 600-609 | Purchases / COGS | 600 Compras mercaderías, 607 Subcontratas |
|
|
393
|
+
| 620-629 | External services | 621 Arrendamientos, 623 Servicios profesionales |
|
|
394
|
+
| 630-639 | Taxes | 631 Otros tributos |
|
|
395
|
+
| 640-649 | Personnel costs | 640 Sueldos y salarios, 642 SS empresa |
|
|
396
|
+
| 650-659 | Other operating expenses | |
|
|
397
|
+
| 660-669 | Financial expenses | 662 Intereses deudas, 663 Pérdidas valores |
|
|
398
|
+
| 680-689 | Depreciation / amortization | 681 Amortización inmovilizado |
|
|
399
|
+
| 690-699 | Provisions | |
|
|
400
|
+
| 700-709 | Sales | 700 Ventas, 705 Prestaciones servicios |
|
|
401
|
+
| 740-749 | Subsidies / grants | |
|
|
402
|
+
| 760-769 | Financial income | 763 Beneficios valores |
|
|
403
|
+
| 790-797 | Provision reversals | |
|
|
404
|
+
|
|
405
|
+
### Non-Cash Items
|
|
406
|
+
|
|
407
|
+
These accounts represent non-cash movements — exclude from cashflow analysis:
|
|
408
|
+
|
|
409
|
+
- **680-689**: Depreciation / amortization
|
|
410
|
+
- **690-699**: Provisions (dotaciones)
|
|
411
|
+
- **790-797**: Provision reversals
|
|
412
|
+
- **663/763**: Fair value gains/losses on financial instruments
|
|
413
|
+
|
|
414
|
+
### Cash Flow Statement (EFE) Sections
|
|
415
|
+
|
|
416
|
+
Per PGC, the Estado de Flujos de Efectivo has three sections:
|
|
417
|
+
|
|
418
|
+
| Section | Description | Typical Accounts |
|
|
419
|
+
|---------|-------------|------------------|
|
|
420
|
+
| Explotación | Operating activities | 6xx/7xx (PnL) + working capital changes (430, 410, 475) |
|
|
421
|
+
| Inversión | Investing activities | 2xx (fixed assets), 25x (financial investments) |
|
|
422
|
+
| Financiación | Financing activities | 17x/52x (debt), 1x (equity) |
|
|
423
|
+
|
|
424
|
+
Working capital changes (movement in receivables, payables, tax accounts) go under Operating.
|
|
425
|
+
|
|
426
|
+
## Validation Patterns for Financial Data
|
|
427
|
+
|
|
428
|
+
### Total Invariance
|
|
429
|
+
|
|
430
|
+
When reclassifying, splitting, or transforming financial amounts, the total must NEVER change:
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
function validateTotalInvariance(
|
|
434
|
+
before: number[], after: number[], tolerance = 0.01
|
|
435
|
+
): void {
|
|
436
|
+
const sumBefore = before.reduce((a, b) => a + b, 0);
|
|
437
|
+
const sumAfter = after.reduce((a, b) => a + b, 0);
|
|
438
|
+
if (Math.abs(sumBefore - sumAfter) > tolerance) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`Total invariance violated: before=${sumBefore.toFixed(2)}, after=${sumAfter.toFixed(2)}, ` +
|
|
441
|
+
`diff=${(sumAfter - sumBefore).toFixed(2)}`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### Floating-Point Tolerance
|
|
448
|
+
|
|
449
|
+
Rounding differences are inevitable in accounting. Use €0.01 tolerance:
|
|
450
|
+
|
|
451
|
+
```typescript
|
|
452
|
+
// ✅ Accounting equality check
|
|
453
|
+
const isEqual = (a: number, b: number) => Math.abs(a - b) <= 0.01;
|
|
454
|
+
|
|
455
|
+
// ❌ Never use strict equality for monetary amounts
|
|
456
|
+
// if (total === expected) { ... }
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Strict Mode: Fail on Bad Data
|
|
460
|
+
|
|
461
|
+
Don't continue processing with validation errors. Log, save, and throw:
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
if (!isEqual(computedTotal, expectedTotal)) {
|
|
465
|
+
console.error(`Validation failed: computed=${computedTotal}, expected=${expectedTotal}`);
|
|
466
|
+
// Save partial results for debugging, then throw
|
|
467
|
+
throw new Error('Financial validation failed — see logs');
|
|
468
|
+
}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
## Service Accessor (`client.accounting.*`)
|
|
472
|
+
|
|
473
|
+
Most patterns in this document are available as programmatic helpers via the accounting service:
|
|
474
|
+
|
|
475
|
+
```typescript
|
|
476
|
+
import { createClient } from '@marcfargas/odoo-client';
|
|
477
|
+
const client = await createClient();
|
|
478
|
+
|
|
479
|
+
// Cash account discovery
|
|
480
|
+
const cashAccounts = await client.accounting.discoverCashAccounts();
|
|
481
|
+
const cashIds = await client.accounting.getCashAccountIds();
|
|
482
|
+
|
|
483
|
+
// Cash balance from GL (not movements)
|
|
484
|
+
const balance = await client.accounting.getCashBalance(cashIds, '2025-06-30');
|
|
485
|
+
|
|
486
|
+
// Partner resolution from bank statement counterparts
|
|
487
|
+
const partner = await client.accounting.resolvePartnerFromMove(moveId, cashIds);
|
|
488
|
+
if (partner.isBatchPayment) {
|
|
489
|
+
console.log('Batch payment to:', partner.allPartnerIds);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Reconciliation tracing
|
|
493
|
+
const trace = await client.accounting.traceReconciliation(fullReconcileId);
|
|
494
|
+
|
|
495
|
+
// Closing entry detection
|
|
496
|
+
if (await client.accounting.isClosingEntry(moveId)) { /* skip */ }
|
|
497
|
+
|
|
498
|
+
// Days-to-pay calculation
|
|
499
|
+
const result = await client.accounting.calculateDaysToPay(invoiceId);
|
|
500
|
+
if (result) console.log(`Paid in ${result.days} days`);
|
|
501
|
+
|
|
502
|
+
// Query posted move lines (parent_state='posted' auto-applied, limit defaults to all)
|
|
503
|
+
const pnlLines = await client.accounting.getPostedMoveLines(
|
|
504
|
+
[['account_id.code', '=like', '7%'], ['date', '>=', '2025-01-01']],
|
|
505
|
+
{ fields: ['account_id', 'debit', 'credit', 'balance'] }
|
|
506
|
+
);
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Standalone functions are also exported for advanced composition:
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
import { discoverCashAccounts, getPostedMoveLines } from '@marcfargas/odoo-client';
|
|
513
|
+
```
|
|
514
|
+
|
|
194
515
|
## Related Documents
|
|
195
516
|
|
|
196
517
|
- [connection.md](../base/connection.md) - Authentication
|
|
518
|
+
- [contracts.md](./contracts.md) - Recurring contracts and billing
|
|
197
519
|
- [domains.md](../base/domains.md) - Query filters (especially date ranges, dot notation)
|
|
198
520
|
- [search.md](../base/search.md) - Search patterns
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Attendance (hr_attendance)
|
|
2
|
+
|
|
3
|
+
Track employee presence with clock-in/clock-out using Odoo's attendance system.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The `hr_attendance` module tracks when employees are physically present (in the office, on-site, etc.). Each attendance record has a `check_in` time and optionally a `check_out` time. Odoo enforces that an employee can only have one open (no `check_out`) attendance at a time.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Authenticated OdooClient connection
|
|
12
|
+
- Module: **hr_attendance** (must be installed)
|
|
13
|
+
- Depends on: **hr**
|
|
14
|
+
|
|
15
|
+
## Key Models
|
|
16
|
+
|
|
17
|
+
| Model | Description |
|
|
18
|
+
|-------|-------------|
|
|
19
|
+
| `hr.attendance` | Attendance records (clock in/out) |
|
|
20
|
+
| `hr.employee` | Employees who clock in/out |
|
|
21
|
+
|
|
22
|
+
## Service Accessor
|
|
23
|
+
|
|
24
|
+
Access attendance operations via `client.attendance`:
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
const client = await createClient();
|
|
28
|
+
|
|
29
|
+
// Clock in
|
|
30
|
+
const record = await client.attendance.clockIn();
|
|
31
|
+
|
|
32
|
+
// Check status
|
|
33
|
+
const status = await client.attendance.getStatus();
|
|
34
|
+
|
|
35
|
+
// Clock out
|
|
36
|
+
const closed = await client.attendance.clockOut();
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
All methods accept an optional `employeeId` parameter. When omitted, the current user's linked `hr.employee` record is used automatically.
|
|
40
|
+
|
|
41
|
+
## Field Reference
|
|
42
|
+
|
|
43
|
+
### hr.attendance
|
|
44
|
+
|
|
45
|
+
| Field | Type | Required | Description |
|
|
46
|
+
|-------|------|----------|-------------|
|
|
47
|
+
| `employee_id` | Many2one → hr.employee | Yes | Employee who is present |
|
|
48
|
+
| `check_in` | Datetime | Yes | When the employee clocked in |
|
|
49
|
+
| `check_out` | Datetime | No | When the employee clocked out (false = still present) |
|
|
50
|
+
| `worked_hours` | Float | Computed | Hours between check_in and check_out |
|
|
51
|
+
|
|
52
|
+
## Checking Module Installation
|
|
53
|
+
|
|
54
|
+
```typescript testable id="attendance-check-module" needs="client" expect="result.installed === true"
|
|
55
|
+
const installed = await client.modules.isModuleInstalled('hr_attendance');
|
|
56
|
+
return { installed };
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Clock In
|
|
60
|
+
|
|
61
|
+
```typescript testable id="attendance-clock-in" needs="client" creates="hr.attendance" expect="result.success === true"
|
|
62
|
+
// Clock in (uses current user's employee)
|
|
63
|
+
const record = await client.attendance.clockIn();
|
|
64
|
+
trackRecord('hr.attendance', record.id);
|
|
65
|
+
|
|
66
|
+
// Check the record
|
|
67
|
+
const checkInTime = record.check_in; // UTC datetime string
|
|
68
|
+
const isOpen = !record.check_out; // true — still clocked in
|
|
69
|
+
|
|
70
|
+
// Clock out to clean up
|
|
71
|
+
await client.attendance.clockOut();
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
success: true,
|
|
75
|
+
attendanceId: record.id,
|
|
76
|
+
checkIn: checkInTime,
|
|
77
|
+
wasOpen: isOpen
|
|
78
|
+
};
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Clock Out
|
|
82
|
+
|
|
83
|
+
```typescript testable id="attendance-clock-out" needs="client" creates="hr.attendance" expect="result.success === true"
|
|
84
|
+
// Must be clocked in first
|
|
85
|
+
await client.attendance.clockIn();
|
|
86
|
+
|
|
87
|
+
// Clock out
|
|
88
|
+
const record = await client.attendance.clockOut();
|
|
89
|
+
trackRecord('hr.attendance', record.id);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
success: true,
|
|
93
|
+
attendanceId: record.id,
|
|
94
|
+
checkIn: record.check_in,
|
|
95
|
+
checkOut: record.check_out,
|
|
96
|
+
workedHours: record.worked_hours
|
|
97
|
+
};
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Check Status
|
|
101
|
+
|
|
102
|
+
```typescript testable id="attendance-status" needs="client" expect="result.success === true"
|
|
103
|
+
const status = await client.attendance.getStatus();
|
|
104
|
+
|
|
105
|
+
if (status.checkedIn) {
|
|
106
|
+
const att = status.currentAttendance!;
|
|
107
|
+
// Employee is in the office since att.check_in
|
|
108
|
+
} else {
|
|
109
|
+
// Employee is not in the office
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
success: true,
|
|
114
|
+
checkedIn: status.checkedIn,
|
|
115
|
+
employeeName: status.employee[1]
|
|
116
|
+
};
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## List Attendance Records
|
|
120
|
+
|
|
121
|
+
```typescript testable id="attendance-list" needs="client" expect="result.success === true"
|
|
122
|
+
const today = new Date().toISOString().split('T')[0];
|
|
123
|
+
|
|
124
|
+
const records = await client.attendance.list({
|
|
125
|
+
dateFrom: today,
|
|
126
|
+
dateTo: today,
|
|
127
|
+
limit: 20,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const totalHours = records.reduce((sum, r) => sum + (r.worked_hours || 0), 0);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
success: true,
|
|
134
|
+
count: records.length,
|
|
135
|
+
totalHours
|
|
136
|
+
};
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Filter by Employee
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// Get attendance for a specific employee
|
|
143
|
+
const records = await client.attendance.list({
|
|
144
|
+
employeeId: 42,
|
|
145
|
+
dateFrom: '2026-02-01',
|
|
146
|
+
dateTo: '2026-02-28',
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Standalone Functions
|
|
151
|
+
|
|
152
|
+
For advanced composition, standalone functions are also exported:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { clockIn, clockOut, getStatus, listAttendances } from '@marcfargas/odoo-client';
|
|
156
|
+
|
|
157
|
+
const record = await clockIn(client, employeeId);
|
|
158
|
+
const closed = await clockOut(client, employeeId);
|
|
159
|
+
const status = await getStatus(client, employeeId);
|
|
160
|
+
const list = await listAttendances(client, { employeeId: 42, limit: 10 });
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Important Notes
|
|
164
|
+
|
|
165
|
+
### One Open Attendance Per Employee
|
|
166
|
+
|
|
167
|
+
Odoo enforces via `_check_validity` constraint that an employee can only have **one open attendance** (no `check_out`) at a time. Attempting to clock in when already clocked in will throw an `OdooValidationError`.
|
|
168
|
+
|
|
169
|
+
### Employee ↔ User Relationship
|
|
170
|
+
|
|
171
|
+
- `employee_id` links to `hr.employee`
|
|
172
|
+
- Each employee has a `user_id` linking to `res.users`
|
|
173
|
+
- The service auto-resolves the current user's employee when `employeeId` is omitted
|
|
174
|
+
|
|
175
|
+
### Datetime Handling
|
|
176
|
+
|
|
177
|
+
- `check_in` and `check_out` are stored in **UTC** as `YYYY-MM-DD HH:MM:SS`
|
|
178
|
+
- Odoo applies the user's timezone for display in the UI
|
|
179
|
+
- `worked_hours` is computed as `(check_out - check_in)` in hours
|
|
180
|
+
|
|
181
|
+
## Related Documents
|
|
182
|
+
|
|
183
|
+
- [timesheets.md](./timesheets.md) — Time tracking on projects (different from attendance)
|
|
184
|
+
- [../base/crud.md](../base/crud.md) — CRUD operations
|
|
185
|
+
- [../base/modules.md](../base/modules.md) — Module management
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# OCA Contracts — Recurring Invoicing
|
|
2
|
+
|
|
3
|
+
Patterns for working with the OCA `contract` module for recurring billing, revenue projection, and subscription-style invoicing.
|
|
4
|
+
|
|
5
|
+
**Required modules**: `contract` (from [OCA/contract](https://github.com/OCA/contract))
|
|
6
|
+
|
|
7
|
+
## Key Models
|
|
8
|
+
|
|
9
|
+
| Model | Description |
|
|
10
|
+
|-------|-------------|
|
|
11
|
+
| `contract.contract` | Contract header — partner, journal, billing schedule |
|
|
12
|
+
| `contract.line` | Individual contract lines — product, price, recurrence |
|
|
13
|
+
|
|
14
|
+
## Reading Contracts
|
|
15
|
+
|
|
16
|
+
### Include Archived/Ended Contracts
|
|
17
|
+
|
|
18
|
+
Ended and cancelled contracts are archived (`active=False`). Use `active_test: false` to see them:
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
// ✅ All contracts, including ended/cancelled
|
|
22
|
+
const contracts = await client.searchRead('contract.contract',
|
|
23
|
+
[],
|
|
24
|
+
{
|
|
25
|
+
fields: [
|
|
26
|
+
'name', 'partner_id', 'active', 'recurring_next_date',
|
|
27
|
+
'date_start', 'date_end', 'contract_line_ids',
|
|
28
|
+
],
|
|
29
|
+
context: { active_test: false },
|
|
30
|
+
limit: 0,
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Reading Contract Lines
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
const lines = await client.searchRead('contract.line',
|
|
39
|
+
[['contract_id', '=', contractId]],
|
|
40
|
+
{
|
|
41
|
+
fields: [
|
|
42
|
+
'name', 'product_id', 'quantity', 'price_unit', 'price_subtotal',
|
|
43
|
+
'recurring_rule_type', 'recurring_interval', 'recurring_next_date',
|
|
44
|
+
'date_start', 'date_end', 'is_canceled',
|
|
45
|
+
],
|
|
46
|
+
limit: 0,
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Billing Schedule
|
|
52
|
+
|
|
53
|
+
### Recurrence Fields
|
|
54
|
+
|
|
55
|
+
| Field | Values | Description |
|
|
56
|
+
|-------|--------|-------------|
|
|
57
|
+
| `recurring_rule_type` | `daily`, `weekly`, `monthly`, `monthlylastday`, `quarterly`, `semesterly`, `yearly` | Billing frequency unit |
|
|
58
|
+
| `recurring_interval` | integer | How many units between billings |
|
|
59
|
+
| `recurring_next_date` | date | Next scheduled billing date |
|
|
60
|
+
|
|
61
|
+
### Price Is Per Billing Cycle, NOT Monthly
|
|
62
|
+
|
|
63
|
+
**Critical**: `contract.line.price_subtotal` is the amount per billing event. A yearly contract billed annually has ONE payment for the full amount — not twelve monthly portions.
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
// ❌ WRONG — dividing yearly price by 12 gives wrong monthly amount
|
|
67
|
+
// if the contract actually bills once a year
|
|
68
|
+
const monthlyRevenue = line.price_subtotal / 12;
|
|
69
|
+
|
|
70
|
+
// ✅ CORRECT — respect the billing schedule
|
|
71
|
+
const billingCycleDays = {
|
|
72
|
+
daily: 1 * line.recurring_interval,
|
|
73
|
+
weekly: 7 * line.recurring_interval,
|
|
74
|
+
monthly: 30 * line.recurring_interval,
|
|
75
|
+
quarterly: 90 * line.recurring_interval,
|
|
76
|
+
semesterly: 180 * line.recurring_interval,
|
|
77
|
+
yearly: 365 * line.recurring_interval,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const cycleDays = billingCycleDays[line.recurring_rule_type] ?? 30;
|
|
81
|
+
const dailyRate = line.price_subtotal / cycleDays;
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Revenue Projection
|
|
85
|
+
|
|
86
|
+
### Event-Based Projection
|
|
87
|
+
|
|
88
|
+
Generate one billing event per contract line per billing date. This matches how Odoo actually generates invoices:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
interface BillingEvent {
|
|
92
|
+
contractId: number;
|
|
93
|
+
lineId: number;
|
|
94
|
+
date: string;
|
|
95
|
+
amount: number;
|
|
96
|
+
partnerId: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function projectBillingEvents(
|
|
100
|
+
line: any,
|
|
101
|
+
contract: any,
|
|
102
|
+
fromDate: string,
|
|
103
|
+
toDate: string,
|
|
104
|
+
): BillingEvent[] {
|
|
105
|
+
const events: BillingEvent[] = [];
|
|
106
|
+
let nextDate = new Date(line.recurring_next_date || fromDate);
|
|
107
|
+
const end = new Date(toDate);
|
|
108
|
+
const contractEnd = line.date_end ? new Date(line.date_end) : null;
|
|
109
|
+
|
|
110
|
+
while (nextDate <= end) {
|
|
111
|
+
if (contractEnd && nextDate > contractEnd) break;
|
|
112
|
+
if (nextDate >= new Date(fromDate)) {
|
|
113
|
+
events.push({
|
|
114
|
+
contractId: Array.isArray(contract.id) ? contract.id[0] : contract.id,
|
|
115
|
+
lineId: line.id,
|
|
116
|
+
date: nextDate.toISOString().slice(0, 10),
|
|
117
|
+
amount: line.price_subtotal,
|
|
118
|
+
partnerId: Array.isArray(contract.partner_id)
|
|
119
|
+
? contract.partner_id[0] : contract.partner_id,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Advance by recurring_interval × recurring_rule_type
|
|
124
|
+
switch (line.recurring_rule_type) {
|
|
125
|
+
case 'daily':
|
|
126
|
+
nextDate.setDate(nextDate.getDate() + line.recurring_interval);
|
|
127
|
+
break;
|
|
128
|
+
case 'weekly':
|
|
129
|
+
nextDate.setDate(nextDate.getDate() + 7 * line.recurring_interval);
|
|
130
|
+
break;
|
|
131
|
+
case 'monthly':
|
|
132
|
+
case 'monthlylastday':
|
|
133
|
+
nextDate.setMonth(nextDate.getMonth() + line.recurring_interval);
|
|
134
|
+
break;
|
|
135
|
+
case 'quarterly':
|
|
136
|
+
nextDate.setMonth(nextDate.getMonth() + 3 * line.recurring_interval);
|
|
137
|
+
break;
|
|
138
|
+
case 'semesterly':
|
|
139
|
+
nextDate.setMonth(nextDate.getMonth() + 6 * line.recurring_interval);
|
|
140
|
+
break;
|
|
141
|
+
case 'yearly':
|
|
142
|
+
nextDate.setFullYear(nextDate.getFullYear() + line.recurring_interval);
|
|
143
|
+
break;
|
|
144
|
+
default:
|
|
145
|
+
nextDate.setMonth(nextDate.getMonth() + line.recurring_interval);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return events;
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### `recurring_next_date` Can Be Far in the Future
|
|
154
|
+
|
|
155
|
+
The contract module can pre-generate invoices ahead of schedule. When computing reference dates or filtering, don't use `MAX(recurring_next_date)` without a reasonable cap:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
// ❌ Dangerous — could be years ahead
|
|
159
|
+
const maxDate = contracts.reduce((max, c) =>
|
|
160
|
+
c.recurring_next_date > max ? c.recurring_next_date : max, '');
|
|
161
|
+
|
|
162
|
+
// ✅ Cap to a reasonable horizon
|
|
163
|
+
const horizon = new Date();
|
|
164
|
+
horizon.setFullYear(horizon.getFullYear() + 1);
|
|
165
|
+
const maxDate = horizon.toISOString().slice(0, 10);
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Common Account Patterns
|
|
169
|
+
|
|
170
|
+
| Account Range | Typical Contract Use |
|
|
171
|
+
|---------------|---------------------|
|
|
172
|
+
| 700 | Sales of goods (product contracts) |
|
|
173
|
+
| 705 | Service revenue (service contracts) |
|
|
174
|
+
| 759 | Other operating income |
|
|
175
|
+
|
|
176
|
+
## Related Documents
|
|
177
|
+
|
|
178
|
+
- [accounting.md](./accounting.md) - Accounting patterns, cashflow, reconciliation
|
|
179
|
+
- [../base/domains.md](../base/domains.md) - Query filter syntax
|
|
180
|
+
- [../base/search.md](../base/search.md) - Search patterns
|
|
@@ -6,6 +6,10 @@ Track employee time on projects and tasks using Odoo's timesheet system.
|
|
|
6
6
|
|
|
7
7
|
The `hr_timesheet` module allows employees to log time spent on projects and tasks. Time entries are stored in `account.analytic.line` with project/task context.
|
|
8
8
|
|
|
9
|
+
Two workflows:
|
|
10
|
+
1. **Timer-based**: Start a timer → work → stop the timer (duration auto-computed)
|
|
11
|
+
2. **Manual**: Log completed work with known hours
|
|
12
|
+
|
|
9
13
|
## Prerequisites
|
|
10
14
|
|
|
11
15
|
- Authenticated OdooClient connection
|
|
@@ -21,6 +25,39 @@ The `hr_timesheet` module allows employees to log time spent on projects and tas
|
|
|
21
25
|
| `project.task` | Tasks within projects |
|
|
22
26
|
| `hr.employee` | Employees who log time |
|
|
23
27
|
|
|
28
|
+
## Service Accessor
|
|
29
|
+
|
|
30
|
+
Access timesheet operations via `client.timesheets`:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
const client = await createClient();
|
|
34
|
+
|
|
35
|
+
// Timer workflow
|
|
36
|
+
const entry = await client.timesheets.startTimer({
|
|
37
|
+
description: 'Feature development',
|
|
38
|
+
projectId: 5,
|
|
39
|
+
taskId: 42,
|
|
40
|
+
});
|
|
41
|
+
// ... work ...
|
|
42
|
+
const stopped = await client.timesheets.stopTimer(entry.id);
|
|
43
|
+
console.log(`Logged ${stopped.unit_amount.toFixed(2)} hours`);
|
|
44
|
+
|
|
45
|
+
// Manual logging
|
|
46
|
+
await client.timesheets.logTime({
|
|
47
|
+
description: 'Code review',
|
|
48
|
+
projectId: 5,
|
|
49
|
+
hours: 1.5,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Check running timers (entries with unit_amount = 0)
|
|
53
|
+
const running = await client.timesheets.getRunningTimers();
|
|
54
|
+
|
|
55
|
+
// List entries
|
|
56
|
+
const entries = await client.timesheets.list({ projectId: 5 });
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
All methods accept an optional `employeeId`. When omitted, the current user's linked `hr.employee` record is used automatically.
|
|
60
|
+
|
|
24
61
|
## Field Reference
|
|
25
62
|
|
|
26
63
|
### account.analytic.line (Timesheet Entry)
|
|
@@ -36,6 +73,7 @@ The `hr_timesheet` module allows employees to log time spent on projects and tas
|
|
|
36
73
|
| `company_id` | Many2one → res.company | Yes | Company (defaults to current) |
|
|
37
74
|
| `amount` | Monetary | Yes | Cost amount (auto-calculated from hourly cost) |
|
|
38
75
|
| `user_id` | Many2one → res.users | No | Related user |
|
|
76
|
+
| `create_date` | Datetime | Auto | Record creation time (used to compute elapsed time for timers) |
|
|
39
77
|
|
|
40
78
|
## Checking Module Installation
|
|
41
79
|
|
|
@@ -362,8 +400,151 @@ The `amount` field is calculated based on:
|
|
|
362
400
|
- `unit_amount` (hours) x employee's `hourly_cost`
|
|
363
401
|
- Set hourly cost on `hr.employee.hourly_cost`
|
|
364
402
|
|
|
403
|
+
## Timer Operations (via service accessor)
|
|
404
|
+
|
|
405
|
+
### Start a Timer
|
|
406
|
+
|
|
407
|
+
```typescript testable id="timesheets-start-timer" needs="client" creates="account.analytic.line" expect="result.success === true"
|
|
408
|
+
// Find a project with timesheets enabled
|
|
409
|
+
const [project] = await client.searchRead('project.project', [
|
|
410
|
+
['allow_timesheets', '=', true]
|
|
411
|
+
], { fields: ['id', 'name'], limit: 1 });
|
|
412
|
+
|
|
413
|
+
if (!project) {
|
|
414
|
+
throw new Error('No project with timesheets enabled');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Start a timer
|
|
418
|
+
const entry = await client.timesheets.startTimer({
|
|
419
|
+
description: 'Working on feature X',
|
|
420
|
+
projectId: project.id,
|
|
421
|
+
});
|
|
422
|
+
trackRecord('account.analytic.line', entry.id);
|
|
423
|
+
|
|
424
|
+
// Timer is now running — unit_amount = 0
|
|
425
|
+
const isRunning = entry.unit_amount === 0;
|
|
426
|
+
|
|
427
|
+
// Stop it to clean up
|
|
428
|
+
await client.timesheets.stopTimer(entry.id);
|
|
429
|
+
|
|
430
|
+
return { success: true, entryId: entry.id, wasRunning: isRunning };
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### Stop a Timer
|
|
434
|
+
|
|
435
|
+
```typescript testable id="timesheets-stop-timer" needs="client" creates="account.analytic.line" expect="result.success === true"
|
|
436
|
+
const [project] = await client.searchRead('project.project', [
|
|
437
|
+
['allow_timesheets', '=', true]
|
|
438
|
+
], { fields: ['id'], limit: 1 });
|
|
439
|
+
|
|
440
|
+
// Start then stop
|
|
441
|
+
const entry = await client.timesheets.startTimer({
|
|
442
|
+
description: 'Timer to stop',
|
|
443
|
+
projectId: project.id,
|
|
444
|
+
});
|
|
445
|
+
trackRecord('account.analytic.line', entry.id);
|
|
446
|
+
|
|
447
|
+
// Wait briefly so there's measurable time
|
|
448
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
449
|
+
|
|
450
|
+
const stopped = await client.timesheets.stopTimer(entry.id);
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
success: true,
|
|
454
|
+
hoursLogged: stopped.unit_amount,
|
|
455
|
+
timerStopped: stopped.unit_amount > 0
|
|
456
|
+
};
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Find Running Timers
|
|
460
|
+
|
|
461
|
+
```typescript testable id="timesheets-running-timers" needs="client" expect="result.success === true"
|
|
462
|
+
const running = await client.timesheets.getRunningTimers();
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
success: true,
|
|
466
|
+
runningCount: running.length,
|
|
467
|
+
entries: running.map(e => ({
|
|
468
|
+
id: e.id,
|
|
469
|
+
description: e.name,
|
|
470
|
+
createdAt: e.create_date
|
|
471
|
+
}))
|
|
472
|
+
};
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### Log Completed Time (No Timer)
|
|
476
|
+
|
|
477
|
+
```typescript testable id="timesheets-log-time" needs="client" creates="account.analytic.line" expect="result.success === true"
|
|
478
|
+
const [project] = await client.searchRead('project.project', [
|
|
479
|
+
['allow_timesheets', '=', true]
|
|
480
|
+
], { fields: ['id'], limit: 1 });
|
|
481
|
+
|
|
482
|
+
const entry = await client.timesheets.logTime({
|
|
483
|
+
description: 'Completed code review',
|
|
484
|
+
projectId: project.id,
|
|
485
|
+
hours: 1.5,
|
|
486
|
+
});
|
|
487
|
+
trackRecord('account.analytic.line', entry.id);
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
success: true,
|
|
491
|
+
entryId: entry.id,
|
|
492
|
+
hours: entry.unit_amount,
|
|
493
|
+
description: entry.name
|
|
494
|
+
};
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### List Timesheet Entries
|
|
498
|
+
|
|
499
|
+
```typescript testable id="timesheets-list" needs="client" expect="result.success === true"
|
|
500
|
+
const entries = await client.timesheets.list({
|
|
501
|
+
limit: 10,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const totalHours = entries.reduce((sum, e) => sum + (e.unit_amount || 0), 0);
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
success: true,
|
|
508
|
+
count: entries.length,
|
|
509
|
+
totalHours
|
|
510
|
+
};
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
## Timer Architecture
|
|
514
|
+
|
|
515
|
+
The timer concept is simple — it's just the `unit_amount` field:
|
|
516
|
+
|
|
517
|
+
- **Running clock** = `unit_amount = 0` (entry without duration)
|
|
518
|
+
- **Closed entry** = `unit_amount > 0` (duration filled)
|
|
519
|
+
|
|
520
|
+
This is standard `hr_timesheet` behavior. No extra modules needed. The OCA module
|
|
521
|
+
[`project_timesheet_time_control`](https://github.com/OCA/project/tree/17.0/project_timesheet_time_control)
|
|
522
|
+
adds UI buttons (Start/Stop/Resume) for this workflow, but the data model is the same.
|
|
523
|
+
|
|
524
|
+
The `client.timesheets` service wraps this into a clean API:
|
|
525
|
+
- `startTimer()` → creates entry with `unit_amount = 0`
|
|
526
|
+
- `stopTimer()` → computes elapsed time from `create_date`, writes `unit_amount`
|
|
527
|
+
- `getRunningTimers()` → searches for entries with `unit_amount = 0`
|
|
528
|
+
|
|
529
|
+
## Standalone Functions
|
|
530
|
+
|
|
531
|
+
For advanced composition, standalone functions are also exported:
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
import {
|
|
535
|
+
startTimer, stopTimer, getRunningTimers, logTime, listTimesheets
|
|
536
|
+
} from '@marcfargas/odoo-client';
|
|
537
|
+
|
|
538
|
+
const entry = await startTimer(client, { description: 'Work', projectId: 5 });
|
|
539
|
+
const stopped = await stopTimer(client, entry.id);
|
|
540
|
+
const running = await getRunningTimers(client, employeeId);
|
|
541
|
+
const logged = await logTime(client, { description: 'Review', projectId: 5, hours: 2 });
|
|
542
|
+
const list = await listTimesheets(client, { projectId: 5 });
|
|
543
|
+
```
|
|
544
|
+
|
|
365
545
|
## Related Documents
|
|
366
546
|
|
|
547
|
+
- [attendance.md](./attendance.md) - Clock in/out (physical presence, separate from time tracking)
|
|
367
548
|
- [crud.md](../base/crud.md) - CRUD operations
|
|
368
549
|
- [search.md](../base/search.md) - Search patterns
|
|
369
550
|
- [domains.md](../base/domains.md) - Domain filters
|
|
@@ -299,6 +299,67 @@ These templates use Spanish PGCE 2008 account codes (7XX for income, 6XX for exp
|
|
|
299
299
|
| `currency_id` | many2one | No | Target currency |
|
|
300
300
|
| `landscape_pdf` | boolean | No | PDF orientation |
|
|
301
301
|
|
|
302
|
+
## Gotchas
|
|
303
|
+
|
|
304
|
+
### Archived Budgets Need `active_test: false`
|
|
305
|
+
|
|
306
|
+
MIS budgets (`mis.budget`) are often archived after the period ends. Without the context flag, they become invisible — easy to miss because "it worked last month."
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
// ✅ Include archived budgets
|
|
310
|
+
const budgets = await client.searchRead('mis.budget',
|
|
311
|
+
[],
|
|
312
|
+
{ fields: ['name', 'active'], context: { active_test: false }, limit: 0 }
|
|
313
|
+
);
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### `mis.report.instance` Has NO `active` Field
|
|
317
|
+
|
|
318
|
+
Don't filter by `active` — the field doesn't exist and will cause an error:
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
// ❌ WRONG — will error
|
|
322
|
+
const instances = await client.searchRead('mis.report.instance',
|
|
323
|
+
[['active', '=', true]], { fields: ['name'] }
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// ✅ CORRECT — no active field, just query directly
|
|
327
|
+
const instances = await client.searchRead('mis.report.instance',
|
|
328
|
+
[], { fields: ['name'] }
|
|
329
|
+
);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### `mis.report.instance.period` Has NO `company_id` Field
|
|
333
|
+
|
|
334
|
+
Company filtering is on the **instance** (`mis.report.instance`), not on individual periods. Don't try to filter periods by company.
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
// ❌ WRONG — company_id doesn't exist on periods
|
|
338
|
+
const periods = await client.searchRead('mis.report.instance.period',
|
|
339
|
+
[['company_id', '=', 1]], { fields: ['name'] }
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// ✅ CORRECT — filter at the instance level
|
|
343
|
+
const instances = await client.searchRead('mis.report.instance',
|
|
344
|
+
[['company_id', '=', 1]],
|
|
345
|
+
{ fields: ['name', 'period_ids'] }
|
|
346
|
+
);
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Budget Account Codes Differ from Actual Accounting
|
|
350
|
+
|
|
351
|
+
Budgets often use different account codes than the general ledger. Example: a person budgeted as "employee" (account 640) but actually paid as "subcontractor" (account 607).
|
|
352
|
+
|
|
353
|
+
**Solution**: Build an explicit equivalence table mapping budget codes to actual codes. Always adjust the budget side — the actual accounting entries are correct.
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
// Equivalence table: budget code → actual code(s)
|
|
357
|
+
const budgetEquivalences: Record<string, string[]> = {
|
|
358
|
+
'640': ['640', '607'], // Budget "employees" includes some subcontractors
|
|
359
|
+
'621': ['621', '622'], // Budget "leases" includes both rent types
|
|
360
|
+
};
|
|
361
|
+
```
|
|
362
|
+
|
|
302
363
|
## Error Handling
|
|
303
364
|
|
|
304
365
|
```typescript
|