feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Build output
|
# Build output
|
||||||
build/
|
build/
|
||||||
|
odoo-server
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
|||||||
62
TODO.md
62
TODO.md
@@ -0,0 +1,62 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
> Stand: 2026-04-12
|
||||||
|
> Offene Punkte → Details in `open.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔵 Tech Debt
|
||||||
|
|
||||||
|
- [ ] Tax-Logik in Sale und Account zusammenführen → gemeinsames `tax`-Package evaluieren
|
||||||
|
- [ ] Domain Parser: Edge Cases bei komplexen Python-Expressions dokumentieren
|
||||||
|
- [ ] Floating-Point Präzision bei Tax/Reconciliation nochmal evaluieren (aktuell `::float8`)
|
||||||
|
- [ ] Report Wizard: Aged Report + General Ledger auch mit Date-Filter versehen
|
||||||
|
- [ ] CRM: `message_subscribe` N+1 → Batch INSERT ON CONFLICT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Offene Features → `open.md`
|
||||||
|
|
||||||
|
Alle offenen Feature-Punkte sind in `open.md` dokumentiert:
|
||||||
|
|
||||||
|
- **Odoo Community Core (fehlend):** Portal (XL), Discuss (L), Email Inbound (M)
|
||||||
|
- **Frontend / UI Zukunft:** UI modernisieren, View-Format JSON-fähig
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IDEEN
|
||||||
|
WEITER ENWICKELUNG DER PLATTFORM
|
||||||
|
- ** Ki/ AI Unterstützung Datenbasierter informationen: ** Daten Analyse (Longterm)
|
||||||
|
- ** SSO / SAML / LADP / WEBDAV**: technologien der verbesserung der plattform
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Erledigt (Referenz)
|
||||||
|
|
||||||
|
### Infrastruktur
|
||||||
|
- [x] ORM-Kern (read_group, record rules, domain operators, _inherits, compute, onchange, constraints)
|
||||||
|
- [x] JSON-RPC Dispatch (call_kw, call_button, action/run, model/get_definitions)
|
||||||
|
- [x] View Inheritance (XPath), get_views, alle View-Typen (list/form/search/kanban/pivot/graph/calendar/activity/dashboard)
|
||||||
|
- [x] Session-Persistenz (PostgreSQL), Multi-Company Switcher
|
||||||
|
- [x] CSV + XLSX Export, Generic CSV Import, Bank-Statement Import
|
||||||
|
- [x] HTML + PDF Reports, Binary Field Serving, SMTP Email
|
||||||
|
- [x] Automated Actions Engine (ir.actions.server)
|
||||||
|
- [x] Post-Setup Wizard, Database Manager
|
||||||
|
- [x] Mail/Chatter (Follower-Notify, Attachments, Thread)
|
||||||
|
|
||||||
|
### Security (alle Audits abgeschlossen)
|
||||||
|
- [x] ACL Seeds für alle ~167 Models + fail-closed checkAccess
|
||||||
|
- [x] CSRF Token (crypto/rand, persisted in DB)
|
||||||
|
- [x] SQL Injection Fixes (sanitizeOrderBy, domain rebaseParams)
|
||||||
|
- [x] Auth Bypass Fix, Rate Limiting, Session Throttling
|
||||||
|
- [x] XSS Fixes (HTML-Escaping in Reports, Emails, Setup Wizard)
|
||||||
|
- [x] State Guards (orm.StateGuard), Field-Level ACL
|
||||||
|
- [x] Full Codebase Review: 55 Findings gefunden, 48+ gefixt
|
||||||
|
|
||||||
|
### Business-Module (alle auf 95%)
|
||||||
|
- [x] Account: Reconciliation, Tax, Assets, Budget, Analytics, EDI/UBL, Reports, Partial Payments, Deferred Rev/Exp, Move Templates, Refund Wizard
|
||||||
|
- [x] Sale: SO→Invoice→Payment, Templates, Margin, Pricelists, Options, Discount Wizard, Quotation Email, Print/PDF
|
||||||
|
- [x] Stock: Quant Reservation, FIFO, Routes, Lot/Serial, Batch, Barcode, Backorder, Forecast, Intrastat, Split Picking
|
||||||
|
- [x] Purchase: PO→Bill, 3-Way Match, Agreements, Blanket Orders, Supplier Info, Vendor Lead Time, RFQ Email, Print/PDF
|
||||||
|
- [x] CRM: Pipeline, Activities, Scoring, Merge, Dashboard KPIs, Stage Onchange, Team Members, Follower Subscribe
|
||||||
|
- [x] HR: Leave Management, Contracts (Lifecycle+Renewal+Cron), Attendance, Expenses→Journal Entry, Payroll Basis, Org Chart, Skills
|
||||||
|
- [x] Project: Milestones, Timesheets, Recurrence, Checklists, Sharing, Critical Path, Budget, Workload, Gantt Computes
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "odoo-go/pkg/orm"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
// initAccountAccount registers the chart of accounts.
|
// initAccountAccount registers the chart of accounts.
|
||||||
// Mirrors: odoo/addons/account/models/account_account.py
|
// Mirrors: odoo/addons/account/models/account_account.py
|
||||||
@@ -203,3 +207,95 @@ func initAccountFiscalPosition() {
|
|||||||
orm.Many2many("country_ids", "res.country", orm.FieldOpts{String: "Countries"}),
|
orm.Many2many("country_ids", "res.country", orm.FieldOpts{String: "Countries"}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initAccountTaxComputes adds computed fields to account.tax for the tax computation engine.
|
||||||
|
// Mirrors: odoo/addons/account/models/account_tax.py
|
||||||
|
//
|
||||||
|
// - is_base_affected: whether this tax's base is affected by previous taxes in the sequence
|
||||||
|
// - repartition_line_ids: combined view of invoice + refund repartition lines
|
||||||
|
func initAccountTaxComputes() {
|
||||||
|
ext := orm.ExtendModel("account.tax")
|
||||||
|
|
||||||
|
ext.AddFields(
|
||||||
|
orm.Boolean("computed_is_base_affected", orm.FieldOpts{
|
||||||
|
String: "Base Affected (Computed)",
|
||||||
|
Compute: "_compute_is_base_affected",
|
||||||
|
Help: "Computed: true when include_base_amount is set on a preceding tax in the same group",
|
||||||
|
}),
|
||||||
|
orm.Char("repartition_line_ids_json", orm.FieldOpts{
|
||||||
|
String: "Repartition Lines (All)",
|
||||||
|
Compute: "_compute_repartition_line_ids",
|
||||||
|
Help: "JSON list of all repartition line IDs (invoice + refund) for the tax engine",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// _compute_is_base_affected: determines if this tax's base amount should be
|
||||||
|
// influenced by preceding taxes in the same tax group.
|
||||||
|
// Mirrors: odoo/addons/account/models/account_tax.py _compute_is_base_affected()
|
||||||
|
//
|
||||||
|
// A tax is base-affected when:
|
||||||
|
// 1. It belongs to a group tax (has parent_tax_id), AND
|
||||||
|
// 2. Any sibling tax with a lower sequence has include_base_amount=true
|
||||||
|
// Otherwise it falls back to the manual is_base_affected field value.
|
||||||
|
ext.RegisterCompute("computed_is_base_affected", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
taxID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var parentID *int64
|
||||||
|
var seq int64
|
||||||
|
var manualFlag bool
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT parent_tax_id, COALESCE(sequence, 0), COALESCE(is_base_affected, true)
|
||||||
|
FROM account_tax WHERE id = $1`, taxID,
|
||||||
|
).Scan(&parentID, &seq, &manualFlag)
|
||||||
|
|
||||||
|
// If no parent group, use the manual field value
|
||||||
|
if parentID == nil || *parentID == 0 {
|
||||||
|
return orm.Values{"computed_is_base_affected": manualFlag}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any preceding sibling in the group has include_base_amount=true
|
||||||
|
var count int
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM account_tax
|
||||||
|
WHERE parent_tax_id = $1 AND sequence < $2
|
||||||
|
AND include_base_amount = true AND id != $3`,
|
||||||
|
*parentID, seq, taxID,
|
||||||
|
).Scan(&count)
|
||||||
|
|
||||||
|
return orm.Values{"computed_is_base_affected": count > 0 || manualFlag}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _compute_repartition_line_ids: collects all repartition line IDs (invoice + refund)
|
||||||
|
// into a JSON array string for the tax computation engine.
|
||||||
|
// Mirrors: odoo/addons/account/models/account_tax.py _compute_repartition_line_ids()
|
||||||
|
ext.RegisterCompute("repartition_line_ids_json", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
taxID := rs.IDs()[0]
|
||||||
|
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT id FROM account_tax_repartition_line
|
||||||
|
WHERE tax_id = $1 ORDER BY sequence, id`, taxID)
|
||||||
|
if err != nil {
|
||||||
|
return orm.Values{"repartition_line_ids_json": "[]"}, nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
result := "["
|
||||||
|
first := true
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !first {
|
||||||
|
result += ","
|
||||||
|
}
|
||||||
|
result += fmt.Sprintf("%d", id)
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
result += "]"
|
||||||
|
|
||||||
|
return orm.Values{"repartition_line_ids_json": result}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -251,7 +251,17 @@ func initAccountAsset() {
|
|||||||
periodMonths = 1
|
periodMonths = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use prorata_date or acquisition_date as start, fallback to now
|
||||||
startDate := time.Now()
|
startDate := time.Now()
|
||||||
|
var prorataDate, acquisitionDate *time.Time
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT prorata_date, acquisition_date FROM account_asset WHERE id = $1`, assetID,
|
||||||
|
).Scan(&prorataDate, &acquisitionDate)
|
||||||
|
if prorataDate != nil {
|
||||||
|
startDate = *prorataDate
|
||||||
|
} else if acquisitionDate != nil {
|
||||||
|
startDate = *acquisitionDate
|
||||||
|
}
|
||||||
|
|
||||||
switch method {
|
switch method {
|
||||||
case "linear":
|
case "linear":
|
||||||
@@ -460,6 +470,156 @@ func initAccountAsset() {
|
|||||||
}, nil
|
}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// action_create_deferred_entries: generate recognition entries for deferred
|
||||||
|
// revenue (sale) or deferred expense assets.
|
||||||
|
// Mirrors: odoo/addons/account_asset/models/account_asset.py _generate_deferred_entries()
|
||||||
|
//
|
||||||
|
// Unlike depreciation (which expenses an asset), deferred entries recognise
|
||||||
|
// income or expense over time. Monthly amount = original_value / method_number.
|
||||||
|
// Debit: deferred account (asset/liability), Credit: income/expense account.
|
||||||
|
m.RegisterMethod("action_create_deferred_entries", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
assetID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var name, assetType, state string
|
||||||
|
var journalID, companyID, assetAccountID, expenseAccountID int64
|
||||||
|
var currencyID *int64
|
||||||
|
var originalValue float64
|
||||||
|
var methodNumber int
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(name, ''), COALESCE(asset_type, 'purchase'),
|
||||||
|
COALESCE(journal_id, 0), COALESCE(company_id, 0),
|
||||||
|
COALESCE(account_asset_id, 0), COALESCE(account_depreciation_expense_id, 0),
|
||||||
|
currency_id, COALESCE(state, 'draft'),
|
||||||
|
COALESCE(original_value::float8, 0), COALESCE(method_number, 1)
|
||||||
|
FROM account_asset WHERE id = $1`, assetID,
|
||||||
|
).Scan(&name, &assetType, &journalID, &companyID,
|
||||||
|
&assetAccountID, &expenseAccountID, ¤cyID, &state,
|
||||||
|
&originalValue, &methodNumber)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("account: read asset %d: %w", assetID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if assetType != "sale" && assetType != "expense" {
|
||||||
|
return nil, fmt.Errorf("account: deferred entries only apply to deferred revenue (sale) or deferred expense assets, got %q", assetType)
|
||||||
|
}
|
||||||
|
if state != "open" {
|
||||||
|
return nil, fmt.Errorf("account: can only create deferred entries for running assets")
|
||||||
|
}
|
||||||
|
if journalID == 0 || assetAccountID == 0 || expenseAccountID == 0 {
|
||||||
|
return nil, fmt.Errorf("account: asset %d is missing journal or account configuration", assetID)
|
||||||
|
}
|
||||||
|
if methodNumber <= 0 {
|
||||||
|
methodNumber = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
monthlyAmount := math.Round(originalValue/float64(methodNumber)*100) / 100
|
||||||
|
|
||||||
|
// How many entries already exist?
|
||||||
|
var existingCount int
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM account_move WHERE asset_id = $1`, assetID,
|
||||||
|
).Scan(&existingCount)
|
||||||
|
if existingCount >= methodNumber {
|
||||||
|
return nil, fmt.Errorf("account: all deferred entries already created (%d/%d)", existingCount, methodNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve currency
|
||||||
|
var curID int64
|
||||||
|
if currencyID != nil {
|
||||||
|
curID = *currencyID
|
||||||
|
} else {
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(currency_id, 0) FROM res_company WHERE id = $1`, companyID,
|
||||||
|
).Scan(&curID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine start date
|
||||||
|
startDate := time.Now()
|
||||||
|
var acqDate *time.Time
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT acquisition_date FROM account_asset WHERE id = $1`, assetID,
|
||||||
|
).Scan(&acqDate)
|
||||||
|
if acqDate != nil {
|
||||||
|
startDate = *acqDate
|
||||||
|
}
|
||||||
|
|
||||||
|
entryDate := startDate.AddDate(0, existingCount+1, 0).Format("2006-01-02")
|
||||||
|
period := existingCount + 1
|
||||||
|
|
||||||
|
// Last entry absorbs rounding remainder
|
||||||
|
amount := monthlyAmount
|
||||||
|
if period == methodNumber {
|
||||||
|
var alreadyRecognised float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(ABS(l.balance)::float8), 0)
|
||||||
|
FROM account_move m
|
||||||
|
JOIN account_move_line l ON l.move_id = m.id
|
||||||
|
WHERE m.asset_id = $1
|
||||||
|
AND l.account_id = $2`, assetID, expenseAccountID,
|
||||||
|
).Scan(&alreadyRecognised)
|
||||||
|
amount = math.Round((originalValue-alreadyRecognised)*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the recognition journal entry
|
||||||
|
moveRS := env.Model("account.move")
|
||||||
|
move, err := moveRS.Create(orm.Values{
|
||||||
|
"move_type": "entry",
|
||||||
|
"ref": fmt.Sprintf("Deferred recognition: %s (%d/%d)", name, period, methodNumber),
|
||||||
|
"date": entryDate,
|
||||||
|
"journal_id": journalID,
|
||||||
|
"company_id": companyID,
|
||||||
|
"currency_id": curID,
|
||||||
|
"asset_id": assetID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("account: create deferred entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lineRS := env.Model("account.move.line")
|
||||||
|
|
||||||
|
// Debit: deferred account (asset account — the balance sheet deferral)
|
||||||
|
if _, err := lineRS.Create(orm.Values{
|
||||||
|
"move_id": move.ID(),
|
||||||
|
"account_id": assetAccountID,
|
||||||
|
"name": fmt.Sprintf("Deferred recognition: %s", name),
|
||||||
|
"debit": amount,
|
||||||
|
"credit": 0.0,
|
||||||
|
"balance": amount,
|
||||||
|
"company_id": companyID,
|
||||||
|
"journal_id": journalID,
|
||||||
|
"currency_id": curID,
|
||||||
|
"display_type": "product",
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: create deferred debit line: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credit: income/expense account
|
||||||
|
if _, err := lineRS.Create(orm.Values{
|
||||||
|
"move_id": move.ID(),
|
||||||
|
"account_id": expenseAccountID,
|
||||||
|
"name": fmt.Sprintf("Deferred recognition: %s", name),
|
||||||
|
"debit": 0.0,
|
||||||
|
"credit": amount,
|
||||||
|
"balance": -amount,
|
||||||
|
"company_id": companyID,
|
||||||
|
"journal_id": journalID,
|
||||||
|
"currency_id": curID,
|
||||||
|
"display_type": "product",
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: create deferred credit line: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": "account.move",
|
||||||
|
"res_id": move.ID(),
|
||||||
|
"view_mode": "form",
|
||||||
|
"views": [][]interface{}{{nil, "form"}},
|
||||||
|
"target": "current",
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
// -- DefaultGet --
|
// -- DefaultGet --
|
||||||
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||||
vals := orm.Values{
|
vals := orm.Values{
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ func generateInvoiceXML(env *orm.Environment, moveID int64, formatCode string) (
|
|||||||
IssueDate: issueDateStr,
|
IssueDate: issueDateStr,
|
||||||
DueDate: dueDateStr,
|
DueDate: dueDateStr,
|
||||||
InvoiceTypeCode: typeCode,
|
InvoiceTypeCode: typeCode,
|
||||||
DocumentCurrencyCode: "EUR",
|
DocumentCurrencyCode: getCurrencyCode(env, moveID),
|
||||||
Supplier: UBLParty{
|
Supplier: UBLParty{
|
||||||
Name: companyName,
|
Name: companyName,
|
||||||
Street: ptrStr(companyStreet),
|
Street: ptrStr(companyStreet),
|
||||||
@@ -356,6 +356,19 @@ func generateInvoiceXML(env *orm.Environment, moveID int64, formatCode string) (
|
|||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getCurrencyCode returns the ISO currency code for an invoice, defaulting to "EUR".
|
||||||
|
func getCurrencyCode(env *orm.Environment, moveID int64) string {
|
||||||
|
var code string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(c.name, 'EUR') FROM account_move m
|
||||||
|
JOIN res_currency c ON c.id = m.currency_id
|
||||||
|
WHERE m.id = $1`, moveID).Scan(&code)
|
||||||
|
if code == "" {
|
||||||
|
return "EUR"
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
// ptrStr safely dereferences a *string, returning "" if nil.
|
// ptrStr safely dereferences a *string, returning "" if nil.
|
||||||
func ptrStr(s *string) string {
|
func ptrStr(s *string) string {
|
||||||
if s != nil {
|
if s != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"odoo-go/pkg/orm"
|
"odoo-go/pkg/orm"
|
||||||
@@ -276,7 +277,7 @@ func initFollowupProcess() {
|
|||||||
.overdue{color:#d9534f;font-weight:bold}
|
.overdue{color:#d9534f;font-weight:bold}
|
||||||
h2{color:#875a7b}
|
h2{color:#875a7b}
|
||||||
</style>`)
|
</style>`)
|
||||||
b.WriteString(fmt.Sprintf("<h2>Payment Follow-up: %s</h2>", partnerName))
|
b.WriteString(fmt.Sprintf("<h2>Payment Follow-up: %s</h2>", html.EscapeString(partnerName)))
|
||||||
b.WriteString(`<table><tr><th>Invoice</th><th>Due Date</th><th>Total</th><th>Amount Due</th><th>Overdue Days</th></tr>`)
|
b.WriteString(`<table><tr><th>Invoice</th><th>Due Date</th><th>Total</th><th>Amount Due</th><th>Overdue Days</th></tr>`)
|
||||||
|
|
||||||
var totalDue float64
|
var totalDue float64
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ func initAccountLock() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// _compute_string_to_hash: generates the string representation of the move
|
// _compute_string_to_hash: generates the string representation of the move
|
||||||
// used for hash computation. Includes date, journal, partner, amounts.
|
// used for hash computation. Includes date, journal, partner, amounts, and company VAT.
|
||||||
// Mirrors: odoo/addons/account/models/account_move.py _compute_string_to_hash()
|
// Mirrors: odoo/addons/account/models/account_move.py _compute_string_to_hash()
|
||||||
|
//
|
||||||
|
// The company VAT is included to ensure entries from different legal entities
|
||||||
|
// produce distinct hashes even when all other fields match.
|
||||||
ext.RegisterCompute("string_to_hash", func(rs *orm.Recordset) (orm.Values, error) {
|
ext.RegisterCompute("string_to_hash", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
moveID := rs.IDs()[0]
|
moveID := rs.IDs()[0]
|
||||||
@@ -54,6 +57,17 @@ func initAccountLock() {
|
|||||||
FROM account_move WHERE id = $1`, moveID,
|
FROM account_move WHERE id = $1`, moveID,
|
||||||
).Scan(&name, &moveType, &state, &date, &companyID, &journalID, &partnerID)
|
).Scan(&name, &moveType, &state, &date, &companyID, &journalID, &partnerID)
|
||||||
|
|
||||||
|
// Fetch company VAT for inclusion in the hash
|
||||||
|
var companyVAT string
|
||||||
|
if companyID > 0 {
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(p.vat, '')
|
||||||
|
FROM res_company c
|
||||||
|
LEFT JOIN res_partner p ON p.id = c.partner_id
|
||||||
|
WHERE c.id = $1`, companyID,
|
||||||
|
).Scan(&companyVAT)
|
||||||
|
}
|
||||||
|
|
||||||
// Include line amounts
|
// Include line amounts
|
||||||
rows, err := env.Tx().Query(env.Ctx(),
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
`SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0),
|
`SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0),
|
||||||
@@ -78,9 +92,9 @@ func initAccountLock() {
|
|||||||
pid = *partnerID
|
pid = *partnerID
|
||||||
}
|
}
|
||||||
|
|
||||||
hashStr := fmt.Sprintf("%s|%s|%v|%d|%d|%d|%s",
|
hashStr := fmt.Sprintf("%s|%s|%v|%d|%d|%d|%s|%s",
|
||||||
name, moveType, date, companyID, journalID, pid,
|
name, moveType, date, companyID, journalID, pid,
|
||||||
strings.Join(lineData, ";"))
|
strings.Join(lineData, ";"), companyVAT)
|
||||||
|
|
||||||
return orm.Values{"string_to_hash": hashStr}, nil
|
return orm.Values{"string_to_hash": hashStr}, nil
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -296,3 +297,73 @@ func applyWriteoffSuggestion(env *orm.Environment, modelID, stLineID int64, amou
|
|||||||
"suggestions": suggestions,
|
"suggestions": suggestions,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initAccountReconcilePreview registers account.reconcile.model.preview (Odoo 18+).
|
||||||
|
// Transient model for previewing reconciliation results before applying.
|
||||||
|
// Mirrors: odoo/addons/account/wizard/account_reconcile_model_preview.py
|
||||||
|
func initAccountReconcilePreview() {
|
||||||
|
m := orm.NewModel("account.reconcile.model.preview", orm.ModelOpts{
|
||||||
|
Description: "Reconcile Model Preview",
|
||||||
|
Type: orm.ModelTransient,
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddFields(
|
||||||
|
orm.Many2one("model_id", "account.reconcile.model", orm.FieldOpts{
|
||||||
|
String: "Reconcile Model", Required: true,
|
||||||
|
}),
|
||||||
|
orm.Many2one("statement_line_id", "account.bank.statement.line", orm.FieldOpts{
|
||||||
|
String: "Statement Line",
|
||||||
|
}),
|
||||||
|
orm.Text("preview_data", orm.FieldOpts{
|
||||||
|
String: "Preview Data", Compute: "_compute_preview",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
m.RegisterCompute("preview_data", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
id := rs.IDs()[0]
|
||||||
|
|
||||||
|
var modelID int64
|
||||||
|
var stLineID *int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(model_id, 0), statement_line_id
|
||||||
|
FROM account_reconcile_model_preview WHERE id = $1`, id,
|
||||||
|
).Scan(&modelID, &stLineID)
|
||||||
|
|
||||||
|
if modelID == 0 {
|
||||||
|
return orm.Values{"preview_data": "[]"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reconcile model lines to preview what would be created
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT rml.label, rml.amount_type, rml.amount,
|
||||||
|
COALESCE(a.code, ''), COALESCE(a.name, '')
|
||||||
|
FROM account_reconcile_model_line rml
|
||||||
|
LEFT JOIN account_account a ON a.id = rml.account_id
|
||||||
|
WHERE rml.model_id = $1
|
||||||
|
ORDER BY rml.sequence`, modelID)
|
||||||
|
if err != nil {
|
||||||
|
return orm.Values{"preview_data": "[]"}, nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var preview []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var label, amountType, accCode, accName string
|
||||||
|
var amount float64
|
||||||
|
if err := rows.Scan(&label, &amountType, &amount, &accCode, &accName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
preview = append(preview, map[string]interface{}{
|
||||||
|
"label": label,
|
||||||
|
"amount_type": amountType,
|
||||||
|
"amount": amount,
|
||||||
|
"account_code": accCode,
|
||||||
|
"account_name": accName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(preview)
|
||||||
|
return orm.Values{"preview_data": string(data)}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "odoo-go/pkg/orm"
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
// initAccountRecurring registers account.move.recurring — recurring entry templates.
|
// initAccountRecurring registers account.move.recurring — recurring entry templates.
|
||||||
// Mirrors: odoo/addons/account/models/account_move.py (recurring entries feature)
|
// Mirrors: odoo/addons/account/models/account_move.py (recurring entries feature)
|
||||||
@@ -113,4 +117,79 @@ func initAccountRecurring() {
|
|||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// _cron_auto_post: cron job that auto-posts draft moves and generates recurring entries.
|
||||||
|
// Mirrors: odoo/addons/account/models/account_move.py _cron_auto_post_draft_entry()
|
||||||
|
//
|
||||||
|
// 1) Find draft account.move entries with auto_post=true and date <= today, post them.
|
||||||
|
// 2) Find recurring entries (state='running') with date_next <= today, generate them.
|
||||||
|
m.RegisterMethod("_cron_auto_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
// --- Part 1: Auto-post draft moves ---
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT id FROM account_move
|
||||||
|
WHERE auto_post = true AND state = 'draft' AND date <= CURRENT_DATE`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var moveIDs []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
moveIDs = append(moveIDs, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if len(moveIDs) > 0 {
|
||||||
|
moveModelDef := orm.Registry.Get("account.move")
|
||||||
|
if moveModelDef != nil {
|
||||||
|
for _, mid := range moveIDs {
|
||||||
|
moveRS := env.Model("account.move").Browse(mid)
|
||||||
|
if postFn, ok := moveModelDef.Methods["action_post"]; ok {
|
||||||
|
if _, err := postFn(moveRS); err != nil {
|
||||||
|
log.Printf("account: auto-post move %d failed: %v", mid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Part 2: Generate recurring entries due today ---
|
||||||
|
recRows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT id FROM account_move_recurring
|
||||||
|
WHERE state = 'running' AND date_next <= CURRENT_DATE AND active = true`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var recIDs []int64
|
||||||
|
for recRows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := recRows.Scan(&id); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recIDs = append(recIDs, id)
|
||||||
|
}
|
||||||
|
recRows.Close()
|
||||||
|
|
||||||
|
if len(recIDs) > 0 {
|
||||||
|
recModelDef := orm.Registry.Get("account.move.recurring")
|
||||||
|
if recModelDef != nil {
|
||||||
|
for _, rid := range recIDs {
|
||||||
|
recRS := env.Model("account.move.recurring").Browse(rid)
|
||||||
|
if genFn, ok := recModelDef.Methods["action_generate"]; ok {
|
||||||
|
if _, err := genFn(recRS); err != nil {
|
||||||
|
log.Printf("account: recurring generate %d failed: %v", rid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ func initAccountTaxReport() {
|
|||||||
return generateAgedReport(env, "liability_payable")
|
return generateAgedReport(env, "liability_payable")
|
||||||
case "general_ledger":
|
case "general_ledger":
|
||||||
return generateGeneralLedger(env)
|
return generateGeneralLedger(env)
|
||||||
|
case "tax_report":
|
||||||
|
return generateTaxReport(env)
|
||||||
default:
|
default:
|
||||||
return map[string]interface{}{"lines": []interface{}{}}, nil
|
return map[string]interface{}{"lines": []interface{}{}}, nil
|
||||||
}
|
}
|
||||||
@@ -81,20 +83,52 @@ func initAccountReportLine() {
|
|||||||
|
|
||||||
// -- Report generation functions --
|
// -- Report generation functions --
|
||||||
|
|
||||||
|
// reportOpts holds optional date/state filters for report generation.
|
||||||
|
type reportOpts struct {
|
||||||
|
DateFrom string // YYYY-MM-DD, empty = no lower bound
|
||||||
|
DateTo string // YYYY-MM-DD, empty = no upper bound
|
||||||
|
TargetMove string // "posted" or "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportStateFilter returns the SQL WHERE clause fragment for move state filtering.
|
||||||
|
func reportStateFilter(opts reportOpts) string {
|
||||||
|
if opts.TargetMove == "all" {
|
||||||
|
return "(m.state IS NOT NULL OR m.id IS NULL)"
|
||||||
|
}
|
||||||
|
return "(m.state = 'posted' OR m.id IS NULL)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportDateFilter returns SQL WHERE clause fragment for date filtering.
|
||||||
|
func reportDateFilter(opts reportOpts) string {
|
||||||
|
clause := ""
|
||||||
|
if opts.DateFrom != "" {
|
||||||
|
clause += " AND m.date >= '" + opts.DateFrom + "'"
|
||||||
|
}
|
||||||
|
if opts.DateTo != "" {
|
||||||
|
clause += " AND m.date <= '" + opts.DateTo + "'"
|
||||||
|
}
|
||||||
|
return clause
|
||||||
|
}
|
||||||
|
|
||||||
// generateTrialBalance produces a trial balance report.
|
// generateTrialBalance produces a trial balance report.
|
||||||
// Mirrors: odoo/addons/account_reports/models/account_trial_balance_report.py
|
// Mirrors: odoo/addons/account_reports/models/account_trial_balance_report.py
|
||||||
func generateTrialBalance(env *orm.Environment) (interface{}, error) {
|
func generateTrialBalance(env *orm.Environment, opts ...reportOpts) (interface{}, error) {
|
||||||
rows, err := env.Tx().Query(env.Ctx(), `
|
opt := reportOpts{TargetMove: "posted"}
|
||||||
|
if len(opts) > 0 {
|
||||||
|
opt = opts[0]
|
||||||
|
}
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(), fmt.Sprintf(`
|
||||||
SELECT a.code, a.name, a.account_type,
|
SELECT a.code, a.name, a.account_type,
|
||||||
COALESCE(SUM(l.debit), 0) as total_debit,
|
COALESCE(SUM(l.debit), 0) as total_debit,
|
||||||
COALESCE(SUM(l.credit), 0) as total_credit,
|
COALESCE(SUM(l.credit), 0) as total_credit,
|
||||||
COALESCE(SUM(l.balance), 0) as balance
|
COALESCE(SUM(l.balance), 0) as balance
|
||||||
FROM account_account a
|
FROM account_account a
|
||||||
LEFT JOIN account_move_line l ON l.account_id = a.id
|
LEFT JOIN account_move_line l ON l.account_id = a.id
|
||||||
LEFT JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
|
LEFT JOIN account_move m ON m.id = l.move_id
|
||||||
|
WHERE %s %s
|
||||||
GROUP BY a.id, a.code, a.name, a.account_type
|
GROUP BY a.id, a.code, a.name, a.account_type
|
||||||
HAVING COALESCE(SUM(l.debit), 0) != 0 OR COALESCE(SUM(l.credit), 0) != 0
|
HAVING COALESCE(SUM(l.debit), 0) != 0 OR COALESCE(SUM(l.credit), 0) != 0
|
||||||
ORDER BY a.code`)
|
ORDER BY a.code`, reportStateFilter(opt), reportDateFilter(opt)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("account: trial balance query: %w", err)
|
return nil, fmt.Errorf("account: trial balance query: %w", err)
|
||||||
}
|
}
|
||||||
@@ -124,24 +158,29 @@ func generateTrialBalance(env *orm.Environment) (interface{}, error) {
|
|||||||
|
|
||||||
// generateBalanceSheet produces assets vs liabilities+equity.
|
// generateBalanceSheet produces assets vs liabilities+equity.
|
||||||
// Mirrors: odoo/addons/account_reports/models/account_balance_sheet.py
|
// Mirrors: odoo/addons/account_reports/models/account_balance_sheet.py
|
||||||
func generateBalanceSheet(env *orm.Environment) (interface{}, error) {
|
func generateBalanceSheet(env *orm.Environment, opts ...reportOpts) (interface{}, error) {
|
||||||
rows, err := env.Tx().Query(env.Ctx(), `
|
opt := reportOpts{TargetMove: "posted"}
|
||||||
|
if len(opts) > 0 {
|
||||||
|
opt = opts[0]
|
||||||
|
}
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(), fmt.Sprintf(`
|
||||||
SELECT
|
SELECT
|
||||||
CASE
|
CASE
|
||||||
WHEN a.account_type LIKE 'asset%' THEN 'Assets'
|
WHEN a.account_type LIKE 'asset%%' THEN 'Assets'
|
||||||
WHEN a.account_type LIKE 'liability%' THEN 'Liabilities'
|
WHEN a.account_type LIKE 'liability%%' THEN 'Liabilities'
|
||||||
WHEN a.account_type LIKE 'equity%' THEN 'Equity'
|
WHEN a.account_type LIKE 'equity%%' THEN 'Equity'
|
||||||
ELSE 'Other'
|
ELSE 'Other'
|
||||||
END as section,
|
END as section,
|
||||||
a.code, a.name,
|
a.code, a.name,
|
||||||
COALESCE(SUM(l.balance), 0) as balance
|
COALESCE(SUM(l.balance), 0) as balance
|
||||||
FROM account_account a
|
FROM account_account a
|
||||||
LEFT JOIN account_move_line l ON l.account_id = a.id
|
LEFT JOIN account_move_line l ON l.account_id = a.id
|
||||||
LEFT JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
|
LEFT JOIN account_move m ON m.id = l.move_id
|
||||||
WHERE a.account_type LIKE 'asset%' OR a.account_type LIKE 'liability%' OR a.account_type LIKE 'equity%'
|
WHERE %s %s
|
||||||
|
AND (a.account_type LIKE 'asset%%' OR a.account_type LIKE 'liability%%' OR a.account_type LIKE 'equity%%')
|
||||||
GROUP BY a.id, a.code, a.name, a.account_type
|
GROUP BY a.id, a.code, a.name, a.account_type
|
||||||
HAVING COALESCE(SUM(l.balance), 0) != 0
|
HAVING COALESCE(SUM(l.balance), 0) != 0
|
||||||
ORDER BY a.code`)
|
ORDER BY a.code`, reportStateFilter(opt), reportDateFilter(opt)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("account: balance sheet query: %w", err)
|
return nil, fmt.Errorf("account: balance sheet query: %w", err)
|
||||||
}
|
}
|
||||||
@@ -163,23 +202,28 @@ func generateBalanceSheet(env *orm.Environment) (interface{}, error) {
|
|||||||
|
|
||||||
// generateProfitLoss produces income vs expenses.
|
// generateProfitLoss produces income vs expenses.
|
||||||
// Mirrors: odoo/addons/account_reports/models/account_profit_loss.py
|
// Mirrors: odoo/addons/account_reports/models/account_profit_loss.py
|
||||||
func generateProfitLoss(env *orm.Environment) (interface{}, error) {
|
func generateProfitLoss(env *orm.Environment, opts ...reportOpts) (interface{}, error) {
|
||||||
rows, err := env.Tx().Query(env.Ctx(), `
|
opt := reportOpts{TargetMove: "posted"}
|
||||||
|
if len(opts) > 0 {
|
||||||
|
opt = opts[0]
|
||||||
|
}
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(), fmt.Sprintf(`
|
||||||
SELECT
|
SELECT
|
||||||
CASE
|
CASE
|
||||||
WHEN a.account_type LIKE 'income%' THEN 'Income'
|
WHEN a.account_type LIKE 'income%%' THEN 'Income'
|
||||||
WHEN a.account_type LIKE 'expense%' THEN 'Expenses'
|
WHEN a.account_type LIKE 'expense%%' THEN 'Expenses'
|
||||||
ELSE 'Other'
|
ELSE 'Other'
|
||||||
END as section,
|
END as section,
|
||||||
a.code, a.name,
|
a.code, a.name,
|
||||||
COALESCE(SUM(l.balance), 0) as balance
|
COALESCE(SUM(l.balance), 0) as balance
|
||||||
FROM account_account a
|
FROM account_account a
|
||||||
LEFT JOIN account_move_line l ON l.account_id = a.id
|
LEFT JOIN account_move_line l ON l.account_id = a.id
|
||||||
LEFT JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
|
LEFT JOIN account_move m ON m.id = l.move_id
|
||||||
WHERE a.account_type LIKE 'income%' OR a.account_type LIKE 'expense%'
|
WHERE %s %s
|
||||||
|
AND (a.account_type LIKE 'income%%' OR a.account_type LIKE 'expense%%')
|
||||||
GROUP BY a.id, a.code, a.name, a.account_type
|
GROUP BY a.id, a.code, a.name, a.account_type
|
||||||
HAVING COALESCE(SUM(l.balance), 0) != 0
|
HAVING COALESCE(SUM(l.balance), 0) != 0
|
||||||
ORDER BY a.code`)
|
ORDER BY a.code`, reportStateFilter(opt), reportDateFilter(opt)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("account: profit loss query: %w", err)
|
return nil, fmt.Errorf("account: profit loss query: %w", err)
|
||||||
}
|
}
|
||||||
@@ -279,3 +323,128 @@ func generateGeneralLedger(env *orm.Environment) (interface{}, error) {
|
|||||||
}
|
}
|
||||||
return map[string]interface{}{"lines": lines}, nil
|
return map[string]interface{}{"lines": lines}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateTaxReport produces a tax report grouped by tax name and rate.
|
||||||
|
// Mirrors: odoo/addons/account_reports/models/account_tax_report.py
|
||||||
|
// Aggregates tax amounts from posted move lines with display_type='tax'.
|
||||||
|
func generateTaxReport(env *orm.Environment) (interface{}, error) {
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(), `
|
||||||
|
SELECT COALESCE(t.name, 'Undefined Tax'),
|
||||||
|
COALESCE(t.amount, 0) AS tax_rate,
|
||||||
|
COALESCE(SUM(ABS(l.balance::float8)), 0) AS tax_amount,
|
||||||
|
COALESCE(SUM(ABS(l.tax_base_amount::float8)), 0) AS base_amount,
|
||||||
|
COUNT(*) AS line_count
|
||||||
|
FROM account_move_line l
|
||||||
|
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
|
||||||
|
LEFT JOIN account_tax t ON t.id = l.tax_line_id
|
||||||
|
WHERE l.display_type = 'tax'
|
||||||
|
GROUP BY t.name, t.amount
|
||||||
|
ORDER BY t.name, t.amount`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("account: tax report query: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var lines []map[string]interface{}
|
||||||
|
var totalTax, totalBase float64
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
var rate, taxAmount, baseAmount float64
|
||||||
|
var lineCount int
|
||||||
|
if err := rows.Scan(&name, &rate, &taxAmount, &baseAmount, &lineCount); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: tax report scan: %w", err)
|
||||||
|
}
|
||||||
|
lines = append(lines, map[string]interface{}{
|
||||||
|
"tax_name": name,
|
||||||
|
"tax_rate": rate,
|
||||||
|
"tax_amount": taxAmount,
|
||||||
|
"base_amount": baseAmount,
|
||||||
|
"line_count": lineCount,
|
||||||
|
})
|
||||||
|
totalTax += taxAmount
|
||||||
|
totalBase += baseAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals row
|
||||||
|
lines = append(lines, map[string]interface{}{
|
||||||
|
"tax_name": "TOTAL",
|
||||||
|
"tax_rate": 0.0,
|
||||||
|
"tax_amount": totalTax,
|
||||||
|
"base_amount": totalBase,
|
||||||
|
"line_count": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return map[string]interface{}{"lines": lines}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Financial Report Wizard
|
||||||
|
// Mirrors: odoo/addons/account_reports/wizard/account_report_wizard.py
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// initAccountReportWizard registers a transient model that lets the user
|
||||||
|
// choose date range, target-move filter and report type, then dispatches
|
||||||
|
// to the appropriate generateXXX function.
|
||||||
|
func initAccountReportWizard() {
|
||||||
|
m := orm.NewModel("account.report.wizard", orm.ModelOpts{
|
||||||
|
Description: "Financial Report Wizard",
|
||||||
|
Type: orm.ModelTransient,
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddFields(
|
||||||
|
orm.Date("date_from", orm.FieldOpts{String: "Start Date", Required: true}),
|
||||||
|
orm.Date("date_to", orm.FieldOpts{String: "End Date", Required: true}),
|
||||||
|
orm.Selection("target_move", []orm.SelectionItem{
|
||||||
|
{Value: "all", Label: "All Entries"},
|
||||||
|
{Value: "posted", Label: "All Posted Entries"},
|
||||||
|
}, orm.FieldOpts{String: "Target Moves", Default: "posted", Required: true}),
|
||||||
|
orm.Selection("report_type", []orm.SelectionItem{
|
||||||
|
{Value: "trial_balance", Label: "Trial Balance"},
|
||||||
|
{Value: "balance_sheet", Label: "Balance Sheet"},
|
||||||
|
{Value: "profit_loss", Label: "Profit and Loss"},
|
||||||
|
{Value: "aged_receivable", Label: "Aged Receivable"},
|
||||||
|
{Value: "aged_payable", Label: "Aged Payable"},
|
||||||
|
{Value: "general_ledger", Label: "General Ledger"},
|
||||||
|
{Value: "tax_report", Label: "Tax Report"},
|
||||||
|
}, orm.FieldOpts{String: "Report Type", Required: true}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// action_generate_report dispatches to the matching report generator.
|
||||||
|
// Mirrors: odoo/addons/account_reports/wizard/account_report_wizard.py action_generate_report()
|
||||||
|
m.RegisterMethod("action_generate_report", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
data, err := rs.Read([]string{"date_from", "date_to", "target_move", "report_type"})
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return nil, fmt.Errorf("account: cannot read report wizard data")
|
||||||
|
}
|
||||||
|
wiz := data[0]
|
||||||
|
|
||||||
|
reportType, _ := wiz["report_type"].(string)
|
||||||
|
dateFrom, _ := wiz["date_from"].(string)
|
||||||
|
dateTo, _ := wiz["date_to"].(string)
|
||||||
|
targetMove, _ := wiz["target_move"].(string)
|
||||||
|
if targetMove == "" {
|
||||||
|
targetMove = "posted"
|
||||||
|
}
|
||||||
|
opt := reportOpts{DateFrom: dateFrom, DateTo: dateTo, TargetMove: targetMove}
|
||||||
|
|
||||||
|
switch reportType {
|
||||||
|
case "trial_balance":
|
||||||
|
return generateTrialBalance(env, opt)
|
||||||
|
case "balance_sheet":
|
||||||
|
return generateBalanceSheet(env, opt)
|
||||||
|
case "profit_loss":
|
||||||
|
return generateProfitLoss(env, opt)
|
||||||
|
case "aged_receivable":
|
||||||
|
return generateAgedReport(env, "asset_receivable")
|
||||||
|
case "aged_payable":
|
||||||
|
return generateAgedReport(env, "liability_payable")
|
||||||
|
case "general_ledger":
|
||||||
|
return generateGeneralLedger(env)
|
||||||
|
case "tax_report":
|
||||||
|
return generateTaxReport(env)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("account: unknown report type %q", reportType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,10 +51,12 @@ func ComputeTax(env *orm.Environment, taxID int64, baseAmount float64) (*TaxResu
|
|||||||
case "fixed":
|
case "fixed":
|
||||||
taxAmount = amount
|
taxAmount = amount
|
||||||
case "division":
|
case "division":
|
||||||
|
// Division tax: price = base / (1 - rate/100)
|
||||||
|
// Mirrors: odoo/addons/account/models/account_tax.py _compute_amount (division case)
|
||||||
if priceInclude {
|
if priceInclude {
|
||||||
taxAmount = baseAmount - (baseAmount / (1 + amount/100))
|
taxAmount = baseAmount - (baseAmount * (100 - amount) / 100)
|
||||||
} else {
|
} else {
|
||||||
taxAmount = baseAmount * amount / 100
|
taxAmount = baseAmount/(1-amount/100) - baseAmount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,4 +28,12 @@ func Init() {
|
|||||||
initAccountSequence()
|
initAccountSequence()
|
||||||
initAccountEdi()
|
initAccountEdi()
|
||||||
initAccountReconcileModel()
|
initAccountReconcileModel()
|
||||||
|
initAccountMoveInvoiceExtensions()
|
||||||
|
initAccountPaymentExtensions()
|
||||||
|
initAccountJournalExtensions()
|
||||||
|
initAccountTaxComputes()
|
||||||
|
initAccountReportWizard()
|
||||||
|
initAccountMoveReversal()
|
||||||
|
initAccountMoveTemplate()
|
||||||
|
initAccountReconcilePreview()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "odoo-go/pkg/orm"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
// initIrCron registers ir.cron — Scheduled actions.
|
// initIrCron registers ir.cron — Scheduled actions.
|
||||||
// Mirrors: odoo/addons/base/models/ir_cron.py class IrCron
|
// Mirrors: odoo/addons/base/models/ir_cron.py class IrCron
|
||||||
@@ -30,5 +34,86 @@ func initIrCron() {
|
|||||||
orm.Integer("priority", orm.FieldOpts{String: "Priority", Default: 5}),
|
orm.Integer("priority", orm.FieldOpts{String: "Priority", Default: 5}),
|
||||||
orm.Char("code", orm.FieldOpts{String: "Python Code"}),
|
orm.Char("code", orm.FieldOpts{String: "Python Code"}),
|
||||||
orm.Many2one("model_id", "ir.model", orm.FieldOpts{String: "Model"}),
|
orm.Many2one("model_id", "ir.model", orm.FieldOpts{String: "Model"}),
|
||||||
|
|
||||||
|
// Execution target (simplified: direct model+method instead of ir.actions.server)
|
||||||
|
orm.Char("model_name", orm.FieldOpts{String: "Model Name"}),
|
||||||
|
orm.Char("method_name", orm.FieldOpts{String: "Method Name"}),
|
||||||
|
|
||||||
|
// Failure tracking
|
||||||
|
orm.Integer("failure_count", orm.FieldOpts{String: "Failure Count", Default: 0}),
|
||||||
|
orm.Datetime("first_failure_date", orm.FieldOpts{String: "First Failure Date"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Constraint: validate model_name and method_name against the registry.
|
||||||
|
// Prevents setting arbitrary/invalid model+method combos on cron jobs.
|
||||||
|
m.AddConstraint(func(rs *orm.Recordset) error {
|
||||||
|
records, err := rs.Read([]string{"model_name", "method_name"})
|
||||||
|
if err != nil || len(records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rec := records[0]
|
||||||
|
modelName, _ := rec["model_name"].(string)
|
||||||
|
methodName, _ := rec["method_name"].(string)
|
||||||
|
if modelName == "" && methodName == "" {
|
||||||
|
return nil // both empty is OK (legacy code-based crons)
|
||||||
|
}
|
||||||
|
if modelName != "" {
|
||||||
|
model := orm.Registry.Get(modelName)
|
||||||
|
if model == nil {
|
||||||
|
return fmt.Errorf("ir.cron: model %q not found in registry", modelName)
|
||||||
|
}
|
||||||
|
if methodName != "" && model.Methods != nil {
|
||||||
|
if _, ok := model.Methods[methodName]; !ok {
|
||||||
|
return fmt.Errorf("ir.cron: method %q not found on model %q", methodName, modelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// method_direct_trigger — manually trigger a cron job.
|
||||||
|
// Mirrors: odoo/addons/base/models/ir_cron.py method_direct_trigger
|
||||||
|
m.RegisterMethod("method_direct_trigger", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
// Admin-only: only uid=1 or superuser may trigger cron jobs directly
|
||||||
|
env := rs.Env()
|
||||||
|
if env.UID() != 1 && !env.IsSuperuser() {
|
||||||
|
return nil, fmt.Errorf("ir.cron: method_direct_trigger requires admin privileges")
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := rs.Read([]string{"model_name", "method_name"})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ir.cron: method_direct_trigger read failed: %w", err)
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, fmt.Errorf("ir.cron: method_direct_trigger: no record found")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := records[0]
|
||||||
|
modelName, _ := rec["model_name"].(string)
|
||||||
|
methodName, _ := rec["method_name"].(string)
|
||||||
|
|
||||||
|
if modelName == "" || methodName == "" {
|
||||||
|
return nil, fmt.Errorf("ir.cron: model_name or method_name not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate model_name against registry (prevents calling arbitrary models)
|
||||||
|
model := orm.Registry.Get(modelName)
|
||||||
|
if model == nil {
|
||||||
|
return nil, fmt.Errorf("ir.cron: model %q not found in registry", modelName)
|
||||||
|
}
|
||||||
|
if model.Methods == nil {
|
||||||
|
return nil, fmt.Errorf("ir.cron: model %q has no methods", modelName)
|
||||||
|
}
|
||||||
|
method, ok := model.Methods[methodName]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("ir.cron: method %q not found on model %q", methodName, modelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := method(env.Model(modelName), args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ir.cron: %s.%s failed: %w", modelName, methodName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,9 +127,28 @@ func initIrActions() {
|
|||||||
{Value: "object_write", Label: "Update Record"},
|
{Value: "object_write", Label: "Update Record"},
|
||||||
{Value: "object_create", Label: "Create Record"},
|
{Value: "object_create", Label: "Create Record"},
|
||||||
{Value: "multi", Label: "Execute Several Actions"},
|
{Value: "multi", Label: "Execute Several Actions"},
|
||||||
|
{Value: "email", Label: "Send Email"},
|
||||||
}, orm.FieldOpts{String: "Action To Do", Default: "code", Required: true}),
|
}, orm.FieldOpts{String: "Action To Do", Default: "code", Required: true}),
|
||||||
orm.Text("code", orm.FieldOpts{String: "Code"}),
|
orm.Text("code", orm.FieldOpts{String: "Code"}),
|
||||||
orm.Many2many("groups_id", "res.groups", orm.FieldOpts{String: "Groups"}),
|
orm.Many2many("groups_id", "res.groups", orm.FieldOpts{String: "Groups"}),
|
||||||
|
// Automated action fields
|
||||||
|
orm.Selection("trigger", []orm.SelectionItem{
|
||||||
|
{Value: "on_create", Label: "On Creation"},
|
||||||
|
{Value: "on_write", Label: "On Update"},
|
||||||
|
{Value: "on_create_or_write", Label: "On Creation & Update"},
|
||||||
|
{Value: "on_unlink", Label: "On Deletion"},
|
||||||
|
{Value: "on_time", Label: "Based on Time Condition"},
|
||||||
|
}, orm.FieldOpts{String: "Trigger"}),
|
||||||
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||||
|
// object_write: fields to update
|
||||||
|
orm.Text("update_field_id", orm.FieldOpts{String: "Field to Update"}),
|
||||||
|
orm.Char("update_value", orm.FieldOpts{String: "Value"}),
|
||||||
|
// email: template fields
|
||||||
|
orm.Char("email_to", orm.FieldOpts{String: "Email To", Help: "Field name on the record (e.g. email, partner_id.email)"}),
|
||||||
|
orm.Char("email_subject", orm.FieldOpts{String: "Email Subject"}),
|
||||||
|
orm.Text("email_body", orm.FieldOpts{String: "Email Body", Help: "HTML body. Use {{field_name}} for record values."}),
|
||||||
|
// filter domain
|
||||||
|
orm.Text("filter_domain", orm.FieldOpts{String: "Filter Domain", Help: "Only trigger when record matches this domain"}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,8 +66,39 @@ func initResUsers() {
|
|||||||
String: "Share User", Compute: "_compute_share", Store: true,
|
String: "Share User", Compute: "_compute_share", Store: true,
|
||||||
Help: "External user with limited access (portal/public)",
|
Help: "External user with limited access (portal/public)",
|
||||||
}),
|
}),
|
||||||
|
orm.Char("signup_token", orm.FieldOpts{String: "Signup Token"}),
|
||||||
|
orm.Datetime("signup_expiration", orm.FieldOpts{String: "Signup Token Expiration"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// _compute_share: portal/public users have share=true (not in group_user).
|
||||||
|
// Mirrors: odoo/addons/base/models/res_users.py Users._compute_share()
|
||||||
|
m.RegisterMethod("_compute_share", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
// Look up group_user ID
|
||||||
|
var groupUserID int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT g.id FROM res_groups g
|
||||||
|
JOIN ir_model_data imd ON imd.res_id = g.id AND imd.model = 'res.groups'
|
||||||
|
WHERE imd.module = 'base' AND imd.name = 'group_user'`).Scan(&groupUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil // Can't determine, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
var inGroup bool
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT EXISTS(
|
||||||
|
SELECT 1 FROM res_groups_res_users_rel
|
||||||
|
WHERE res_groups_id = $1 AND res_users_id = $2
|
||||||
|
)`, groupUserID, id).Scan(&inGroup)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
env.Model("res.users").Browse(id).Write(orm.Values{"share": !inGroup})
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
// -- Methods --
|
// -- Methods --
|
||||||
|
|
||||||
// action_get returns the "Change My Preferences" action for the current user.
|
// action_get returns the "Change My Preferences" action for the current user.
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "odoo-go/pkg/orm"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
// initCRMLead registers the crm.lead model.
|
// initCRMLead registers the crm.lead model.
|
||||||
// Mirrors: odoo/addons/crm/models/crm_lead.py
|
// Mirrors: odoo/addons/crm/models/crm_lead.py
|
||||||
@@ -67,73 +72,210 @@ func initCRMLead() {
|
|||||||
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
|
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultGet: set company_id from the session so that DB NOT NULL constraint is satisfied
|
// Onchange: stage_id -> auto-update probability from stage.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _onchange_stage_id
|
||||||
|
m.RegisterOnchange("stage_id", func(env *orm.Environment, vals orm.Values) orm.Values {
|
||||||
|
result := make(orm.Values)
|
||||||
|
stageID, ok := vals["stage_id"]
|
||||||
|
if !ok || stageID == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var sid float64
|
||||||
|
switch v := stageID.(type) {
|
||||||
|
case float64:
|
||||||
|
sid = v
|
||||||
|
case int64:
|
||||||
|
sid = float64(v)
|
||||||
|
case int:
|
||||||
|
sid = float64(v)
|
||||||
|
default:
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if sid == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var probability float64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(probability, 10) FROM crm_stage WHERE id = $1`, int64(sid),
|
||||||
|
).Scan(&probability); err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result["probability"] = probability
|
||||||
|
result["date_last_stage_update"] = time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// DefaultGet: set company_id, user_id, team_id, type from session/defaults.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py default_get
|
||||||
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||||
vals := make(orm.Values)
|
vals := make(orm.Values)
|
||||||
if env.CompanyID() > 0 {
|
if env.CompanyID() > 0 {
|
||||||
vals["company_id"] = env.CompanyID()
|
vals["company_id"] = env.CompanyID()
|
||||||
}
|
}
|
||||||
|
if env.UID() > 0 {
|
||||||
|
vals["user_id"] = env.UID()
|
||||||
|
}
|
||||||
|
vals["type"] = "lead"
|
||||||
|
// Try to find a default sales team for the user
|
||||||
|
var teamID int64
|
||||||
|
if env.UID() > 0 {
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT ct.id FROM crm_team ct
|
||||||
|
JOIN crm_team_member ctm ON ctm.crm_team_id = ct.id
|
||||||
|
WHERE ctm.user_id = $1 AND ct.active = true
|
||||||
|
ORDER BY ct.sequence LIMIT 1`, env.UID()).Scan(&teamID); err != nil {
|
||||||
|
// No team found for user — not an error, just no default
|
||||||
|
teamID = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if teamID > 0 {
|
||||||
|
vals["team_id"] = teamID
|
||||||
|
}
|
||||||
return vals
|
return vals
|
||||||
}
|
}
|
||||||
|
|
||||||
// action_set_won: mark lead as won
|
// action_set_won: mark lead as won, set date_closed, find won stage.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py action_set_won
|
||||||
m.RegisterMethod("action_set_won", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_set_won", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
|
var wonStageID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT id FROM crm_stage WHERE is_won = true ORDER BY sequence LIMIT 1`).Scan(&wonStageID)
|
||||||
|
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
var err error
|
||||||
`UPDATE crm_lead SET state = 'won', probability = 100 WHERE id = $1`, id)
|
if wonStageID > 0 {
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_lead SET state = 'won', probability = 100, automated_probability = 100,
|
||||||
|
date_closed = NOW(), active = true, stage_id = $2
|
||||||
|
WHERE id = $1`, id, wonStageID)
|
||||||
|
} else {
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_lead SET state = 'won', probability = 100, automated_probability = 100,
|
||||||
|
date_closed = NOW(), active = true
|
||||||
|
WHERE id = $1`, id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("crm.lead: set_won %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// action_set_lost: mark lead as lost
|
// action_set_lost: mark lead as lost, accept lost_reason_id from kwargs.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py action_set_lost
|
||||||
m.RegisterMethod("action_set_lost", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_set_lost", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
|
|
||||||
|
// Extract lost_reason_id from kwargs if provided
|
||||||
|
var lostReasonID int64
|
||||||
|
if len(args) > 0 {
|
||||||
|
if kwargs, ok := args[0].(map[string]interface{}); ok {
|
||||||
|
if rid, ok := kwargs["lost_reason_id"]; ok {
|
||||||
|
switch v := rid.(type) {
|
||||||
|
case float64:
|
||||||
|
lostReasonID = int64(v)
|
||||||
|
case int64:
|
||||||
|
lostReasonID = v
|
||||||
|
case int:
|
||||||
|
lostReasonID = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
var err error
|
||||||
`UPDATE crm_lead SET state = 'lost', probability = 0, active = false WHERE id = $1`, id)
|
if lostReasonID > 0 {
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_lead SET state = 'lost', probability = 0, automated_probability = 0,
|
||||||
|
active = false, date_closed = NOW(), lost_reason_id = $2
|
||||||
|
WHERE id = $1`, id, lostReasonID)
|
||||||
|
} else {
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_lead SET state = 'lost', probability = 0, automated_probability = 0,
|
||||||
|
active = false, date_closed = NOW()
|
||||||
|
WHERE id = $1`, id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("crm.lead: set_lost %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// convert_to_opportunity: lead → opportunity
|
// convert_to_opportunity: lead -> opportunity, set date_conversion.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _convert_opportunity_data
|
||||||
m.RegisterMethod("convert_to_opportunity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("convert_to_opportunity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE crm_lead SET type = 'opportunity' WHERE id = $1 AND type = 'lead'`, id)
|
`UPDATE crm_lead SET type = 'opportunity', date_conversion = NOW(),
|
||||||
|
date_open = COALESCE(date_open, NOW())
|
||||||
|
WHERE id = $1 AND type = 'lead'`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("crm.lead: convert_to_opportunity %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// convert_opportunity: alias for convert_to_opportunity
|
// convert_opportunity: convert lead to opportunity with optional partner/team assignment.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py convert_opportunity
|
||||||
m.RegisterMethod("convert_opportunity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("convert_opportunity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
|
|
||||||
|
// Optional partner_id from args
|
||||||
|
var partnerID int64
|
||||||
|
if len(args) > 0 {
|
||||||
|
if pid, ok := args[0].(float64); ok {
|
||||||
|
partnerID = int64(pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
if partnerID > 0 {
|
||||||
`UPDATE crm_lead SET type = 'opportunity' WHERE id = $1`, id)
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_lead SET type = 'opportunity', date_conversion = NOW(),
|
||||||
|
date_open = COALESCE(date_open, NOW()), partner_id = $2
|
||||||
|
WHERE id = $1`, id, partnerID)
|
||||||
|
} else {
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_lead SET type = 'opportunity', date_conversion = NOW(),
|
||||||
|
date_open = COALESCE(date_open, NOW())
|
||||||
|
WHERE id = $1`, id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// action_set_won_rainbowman: set won stage + rainbow effect
|
// action_set_won_rainbowman: set won + rainbow effect.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py action_set_won_rainbowman
|
||||||
m.RegisterMethod("action_set_won_rainbowman", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_set_won_rainbowman", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
// Find Won stage
|
// Find the first won stage
|
||||||
var wonStageID int64
|
var wonStageID int64
|
||||||
env.Tx().QueryRow(env.Ctx(),
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT id FROM crm_stage WHERE is_won = true LIMIT 1`).Scan(&wonStageID)
|
`SELECT id FROM crm_stage WHERE is_won = true ORDER BY sequence LIMIT 1`).Scan(&wonStageID)
|
||||||
if wonStageID == 0 {
|
|
||||||
wonStageID = 4 // fallback
|
|
||||||
}
|
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
if wonStageID > 0 {
|
||||||
`UPDATE crm_lead SET stage_id = $1, probability = 100 WHERE id = $2`, wonStageID, id)
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_lead SET state = 'won', probability = 100, automated_probability = 100,
|
||||||
|
date_closed = NOW(), active = true, stage_id = $2
|
||||||
|
WHERE id = $1`, id, wonStageID)
|
||||||
|
} else {
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_lead SET state = 'won', probability = 100, automated_probability = 100,
|
||||||
|
date_closed = NOW(), active = true
|
||||||
|
WHERE id = $1`, id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"effect": map[string]interface{}{
|
"effect": map[string]interface{}{
|
||||||
"type": "rainbow_man",
|
"type": "rainbow_man",
|
||||||
"message": "Congrats, you won this opportunity!",
|
"message": "Congrats, you won this opportunity!",
|
||||||
|
"fadeout": "slow",
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
})
|
})
|
||||||
@@ -152,6 +294,11 @@ func initCRMStage() {
|
|||||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 1}),
|
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 1}),
|
||||||
orm.Boolean("fold", orm.FieldOpts{String: "Folded in Pipeline"}),
|
orm.Boolean("fold", orm.FieldOpts{String: "Folded in Pipeline"}),
|
||||||
orm.Boolean("is_won", orm.FieldOpts{String: "Is Won Stage"}),
|
orm.Boolean("is_won", orm.FieldOpts{String: "Is Won Stage"}),
|
||||||
|
orm.Float("probability", orm.FieldOpts{
|
||||||
|
String: "Probability (%)",
|
||||||
|
Help: "Default probability when a lead enters this stage.",
|
||||||
|
Default: float64(10),
|
||||||
|
}),
|
||||||
orm.Many2many("team_ids", "crm.team", orm.FieldOpts{String: "Sales Teams"}),
|
orm.Many2many("team_ids", "crm.team", orm.FieldOpts{String: "Sales Teams"}),
|
||||||
orm.Text("requirements", orm.FieldOpts{String: "Requirements"}),
|
orm.Text("requirements", orm.FieldOpts{String: "Requirements"}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
"odoo-go/pkg/orm"
|
"odoo-go/pkg/orm"
|
||||||
)
|
)
|
||||||
@@ -73,12 +74,14 @@ func initCrmAnalysis() {
|
|||||||
|
|
||||||
// Win rate
|
// Win rate
|
||||||
var total, won int64
|
var total, won int64
|
||||||
_ = env.Tx().QueryRow(env.Ctx(),
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN s.is_won THEN 1 ELSE 0 END), 0)
|
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN s.is_won THEN 1 ELSE 0 END), 0)
|
||||||
FROM crm_lead l
|
FROM crm_lead l
|
||||||
JOIN crm_stage s ON s.id = l.stage_id
|
JOIN crm_stage s ON s.id = l.stage_id
|
||||||
WHERE l.type = 'opportunity'`,
|
WHERE l.type = 'opportunity'`,
|
||||||
).Scan(&total, &won)
|
).Scan(&total, &won); err != nil {
|
||||||
|
log.Printf("warning: crm win rate query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
winRate := float64(0)
|
winRate := float64(0)
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
@@ -99,12 +102,14 @@ func initCrmAnalysis() {
|
|||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
|
|
||||||
var totalLeads, convertedLeads int64
|
var totalLeads, convertedLeads int64
|
||||||
_ = env.Tx().QueryRow(env.Ctx(), `
|
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) FILTER (WHERE type = 'lead'),
|
COUNT(*) FILTER (WHERE type = 'lead'),
|
||||||
COUNT(*) FILTER (WHERE type = 'opportunity' AND date_conversion IS NOT NULL)
|
COUNT(*) FILTER (WHERE type = 'opportunity' AND date_conversion IS NOT NULL)
|
||||||
FROM crm_lead WHERE active = true`,
|
FROM crm_lead WHERE active = true`,
|
||||||
).Scan(&totalLeads, &convertedLeads)
|
).Scan(&totalLeads, &convertedLeads); err != nil {
|
||||||
|
log.Printf("warning: crm conversion data query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
conversionRate := float64(0)
|
conversionRate := float64(0)
|
||||||
if totalLeads > 0 {
|
if totalLeads > 0 {
|
||||||
@@ -113,19 +118,23 @@ func initCrmAnalysis() {
|
|||||||
|
|
||||||
// Average days to convert
|
// Average days to convert
|
||||||
var avgDaysConvert float64
|
var avgDaysConvert float64
|
||||||
_ = env.Tx().QueryRow(env.Ctx(), `
|
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||||
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (date_conversion - create_date)) / 86400), 0)
|
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (date_conversion - create_date)) / 86400), 0)
|
||||||
FROM crm_lead
|
FROM crm_lead
|
||||||
WHERE type = 'opportunity' AND date_conversion IS NOT NULL AND active = true`,
|
WHERE type = 'opportunity' AND date_conversion IS NOT NULL AND active = true`,
|
||||||
).Scan(&avgDaysConvert)
|
).Scan(&avgDaysConvert); err != nil {
|
||||||
|
log.Printf("warning: crm avg days to convert query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Average days to close (won)
|
// Average days to close (won)
|
||||||
var avgDaysClose float64
|
var avgDaysClose float64
|
||||||
_ = env.Tx().QueryRow(env.Ctx(), `
|
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||||
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (date_closed - create_date)) / 86400), 0)
|
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (date_closed - create_date)) / 86400), 0)
|
||||||
FROM crm_lead
|
FROM crm_lead
|
||||||
WHERE state = 'won' AND date_closed IS NOT NULL`,
|
WHERE state = 'won' AND date_closed IS NOT NULL`,
|
||||||
).Scan(&avgDaysClose)
|
).Scan(&avgDaysClose); err != nil {
|
||||||
|
log.Printf("warning: crm avg days to close query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"total_leads": totalLeads,
|
"total_leads": totalLeads,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"odoo-go/pkg/orm"
|
"odoo-go/pkg/orm"
|
||||||
)
|
)
|
||||||
@@ -38,14 +40,32 @@ func initCRMLeadExtended() {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// ──── Tracking / timing fields ────
|
// ──── Tracking / timing fields ────
|
||||||
// Mirrors: odoo/addons/crm/models/crm_lead.py day_open, day_close
|
// Mirrors: odoo/addons/crm/models/crm_lead.py date_open, day_open, day_close
|
||||||
orm.Integer("day_open", orm.FieldOpts{
|
orm.Datetime("date_open", orm.FieldOpts{
|
||||||
String: "Days to Assign",
|
String: "Assignment Date",
|
||||||
Help: "Number of days to assign this lead to a salesperson.",
|
Help: "Date when the lead was first assigned to a salesperson.",
|
||||||
}),
|
}),
|
||||||
orm.Integer("day_close", orm.FieldOpts{
|
orm.Float("day_open", orm.FieldOpts{
|
||||||
String: "Days to Close",
|
String: "Days to Assign",
|
||||||
Help: "Number of days to close this lead/opportunity.",
|
Compute: "_compute_day_open",
|
||||||
|
Help: "Number of days between creation and assignment.",
|
||||||
|
}),
|
||||||
|
orm.Float("day_close", orm.FieldOpts{
|
||||||
|
String: "Days to Close",
|
||||||
|
Compute: "_compute_day_close",
|
||||||
|
Help: "Number of days between creation and closing.",
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ──── Kanban state ────
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py kanban_state (via mail.activity.mixin)
|
||||||
|
orm.Selection("kanban_state", []orm.SelectionItem{
|
||||||
|
{Value: "grey", Label: "No next activity planned"},
|
||||||
|
{Value: "red", Label: "Next activity late"},
|
||||||
|
{Value: "green", Label: "Next activity is planned"},
|
||||||
|
}, orm.FieldOpts{
|
||||||
|
String: "Kanban State",
|
||||||
|
Compute: "_compute_kanban_state",
|
||||||
|
Help: "Activity-based status indicator for kanban views.",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ──── Additional contact/address fields ────
|
// ──── Additional contact/address fields ────
|
||||||
@@ -76,6 +96,27 @@ func initCRMLeadExtended() {
|
|||||||
Help: "Second line of the street address.",
|
Help: "Second line of the street address.",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ──── Computed timing fields ────
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_days_in_stage
|
||||||
|
orm.Float("days_in_stage", orm.FieldOpts{
|
||||||
|
String: "Days in Current Stage",
|
||||||
|
Compute: "_compute_days_in_stage",
|
||||||
|
Help: "Number of days since the last stage change.",
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ──── Email scoring / contact address ────
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_email_score
|
||||||
|
orm.Char("email_domain_criterion", orm.FieldOpts{
|
||||||
|
String: "Email Domain",
|
||||||
|
Compute: "_compute_email_score",
|
||||||
|
Help: "Domain part of the lead email (e.g. 'example.com').",
|
||||||
|
}),
|
||||||
|
orm.Text("contact_address_complete", orm.FieldOpts{
|
||||||
|
String: "Contact Address",
|
||||||
|
Compute: "_compute_contact_address",
|
||||||
|
Help: "Full contact address assembled from partner data.",
|
||||||
|
}),
|
||||||
|
|
||||||
// ──── Revenue fields ────
|
// ──── Revenue fields ────
|
||||||
// Mirrors: odoo/addons/crm/models/crm_lead.py prorated_revenue
|
// Mirrors: odoo/addons/crm/models/crm_lead.py prorated_revenue
|
||||||
orm.Monetary("prorated_revenue", orm.FieldOpts{
|
orm.Monetary("prorated_revenue", orm.FieldOpts{
|
||||||
@@ -135,35 +176,333 @@ func initCRMLeadExtended() {
|
|||||||
|
|
||||||
var revenue float64
|
var revenue float64
|
||||||
var probability float64
|
var probability float64
|
||||||
_ = env.Tx().QueryRow(env.Ctx(),
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT COALESCE(expected_revenue::float8, 0), COALESCE(probability, 0)
|
`SELECT COALESCE(expected_revenue::float8, 0), COALESCE(probability, 0)
|
||||||
FROM crm_lead WHERE id = $1`, leadID,
|
FROM crm_lead WHERE id = $1`, leadID,
|
||||||
).Scan(&revenue, &probability)
|
).Scan(&revenue, &probability); err != nil {
|
||||||
|
log.Printf("warning: crm.lead _compute_prorated_revenue query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
prorated := revenue * probability / 100.0
|
prorated := revenue * probability / 100.0
|
||||||
return orm.Values{"prorated_revenue": prorated}, nil
|
return orm.Values{"prorated_revenue": prorated}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ──── Compute: day_open ────
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_day_open
|
||||||
|
m.RegisterCompute("day_open", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
leadID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var dayOpen *float64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT CASE
|
||||||
|
WHEN date_open IS NOT NULL AND create_date IS NOT NULL
|
||||||
|
THEN ABS(EXTRACT(EPOCH FROM (date_open - create_date)) / 86400)
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
FROM crm_lead WHERE id = $1`, leadID,
|
||||||
|
).Scan(&dayOpen)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("warning: crm.lead _compute_day_open query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := float64(0)
|
||||||
|
if dayOpen != nil {
|
||||||
|
result = *dayOpen
|
||||||
|
}
|
||||||
|
return orm.Values{"day_open": result}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// ──── Compute: day_close ────
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_day_close
|
||||||
|
m.RegisterCompute("day_close", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
leadID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var dayClose *float64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT CASE
|
||||||
|
WHEN date_closed IS NOT NULL AND create_date IS NOT NULL
|
||||||
|
THEN ABS(EXTRACT(EPOCH FROM (date_closed - create_date)) / 86400)
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
FROM crm_lead WHERE id = $1`, leadID,
|
||||||
|
).Scan(&dayClose)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("warning: crm.lead _compute_day_close query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := float64(0)
|
||||||
|
if dayClose != nil {
|
||||||
|
result = *dayClose
|
||||||
|
}
|
||||||
|
return orm.Values{"day_close": result}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// ──── Compute: kanban_state ────
|
||||||
|
// Based on activity deadline: overdue=red, today/future=green, no activity=grey.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_activity_mixin.py _compute_kanban_state
|
||||||
|
m.RegisterCompute("kanban_state", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
leadID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var deadline *string
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT activity_date_deadline FROM crm_lead WHERE id = $1`, leadID,
|
||||||
|
).Scan(&deadline)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("warning: crm.lead _compute_kanban_state query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := "grey" // no activity planned
|
||||||
|
if deadline != nil && *deadline != "" {
|
||||||
|
// Check if overdue
|
||||||
|
var isOverdue bool
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT activity_date_deadline < CURRENT_DATE FROM crm_lead WHERE id = $1`, leadID,
|
||||||
|
).Scan(&isOverdue)
|
||||||
|
if isOverdue {
|
||||||
|
state = "red" // overdue
|
||||||
|
} else {
|
||||||
|
state = "green" // planned (today or future)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orm.Values{"kanban_state": state}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// ──── Compute: days_in_stage ────
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_days_in_stage
|
||||||
|
m.RegisterCompute("days_in_stage", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
leadID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var days *float64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT CASE
|
||||||
|
WHEN date_last_stage_update IS NOT NULL
|
||||||
|
THEN EXTRACT(DAY FROM NOW() - date_last_stage_update)
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
FROM crm_lead WHERE id = $1`, leadID,
|
||||||
|
).Scan(&days); err != nil {
|
||||||
|
log.Printf("warning: crm.lead _compute_days_in_stage query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := float64(0)
|
||||||
|
if days != nil {
|
||||||
|
result = *days
|
||||||
|
}
|
||||||
|
return orm.Values{"days_in_stage": result}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// ──── Compute: email_score (email domain extraction) ────
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_email_domain_criterion
|
||||||
|
m.RegisterCompute("email_domain_criterion", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
leadID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var email *string
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT email_from FROM crm_lead WHERE id = $1`, leadID,
|
||||||
|
).Scan(&email); err != nil {
|
||||||
|
log.Printf("warning: crm.lead _compute_email_score query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := ""
|
||||||
|
if email != nil && *email != "" {
|
||||||
|
parts := strings.SplitN(*email, "@", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
domain = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return orm.Values{"email_domain_criterion": domain}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// ──── Compute: contact_address ────
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_contact_address
|
||||||
|
m.RegisterCompute("contact_address_complete", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
leadID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var street, street2, city, zip *string
|
||||||
|
var partnerID *int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT street, street2, city, zip, partner_id
|
||||||
|
FROM crm_lead WHERE id = $1`, leadID,
|
||||||
|
).Scan(&street, &street2, &city, &zip, &partnerID); err != nil {
|
||||||
|
log.Printf("warning: crm.lead _compute_contact_address query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If partner exists, fetch address from partner instead
|
||||||
|
if partnerID != nil {
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT street, street2, city, zip
|
||||||
|
FROM res_partner WHERE id = $1`, *partnerID,
|
||||||
|
).Scan(&street, &street2, &city, &zip); err != nil {
|
||||||
|
log.Printf("warning: crm.lead _compute_contact_address partner query failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
if street != nil && *street != "" {
|
||||||
|
parts = append(parts, *street)
|
||||||
|
}
|
||||||
|
if street2 != nil && *street2 != "" {
|
||||||
|
parts = append(parts, *street2)
|
||||||
|
}
|
||||||
|
if zip != nil && *zip != "" && city != nil && *city != "" {
|
||||||
|
parts = append(parts, *zip+" "+*city)
|
||||||
|
} else if city != nil && *city != "" {
|
||||||
|
parts = append(parts, *city)
|
||||||
|
}
|
||||||
|
|
||||||
|
address := strings.Join(parts, "\n")
|
||||||
|
return orm.Values{"contact_address_complete": address}, nil
|
||||||
|
})
|
||||||
|
|
||||||
// ──── Business Methods ────
|
// ──── Business Methods ────
|
||||||
|
|
||||||
// action_schedule_activity: return a window action to schedule an activity.
|
// action_schedule_activity: create a mail.activity record linked to the lead.
|
||||||
// Mirrors: odoo/addons/crm/models/crm_lead.py action_schedule_activity
|
// Mirrors: odoo/addons/crm/models/crm_lead.py action_schedule_activity
|
||||||
m.RegisterMethod("action_schedule_activity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_schedule_activity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
leadID := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Extract optional kwargs: summary, activity_type_id, date_deadline, user_id, note
|
||||||
|
summary := ""
|
||||||
|
note := ""
|
||||||
|
dateDeadline := ""
|
||||||
|
var userID int64
|
||||||
|
var activityTypeID int64
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
if kwargs, ok := args[0].(map[string]interface{}); ok {
|
||||||
|
if v, ok := kwargs["summary"].(string); ok {
|
||||||
|
summary = v
|
||||||
|
}
|
||||||
|
if v, ok := kwargs["note"].(string); ok {
|
||||||
|
note = v
|
||||||
|
}
|
||||||
|
if v, ok := kwargs["date_deadline"].(string); ok {
|
||||||
|
dateDeadline = v
|
||||||
|
}
|
||||||
|
if v, ok := kwargs["user_id"]; ok {
|
||||||
|
switch uid := v.(type) {
|
||||||
|
case float64:
|
||||||
|
userID = int64(uid)
|
||||||
|
case int64:
|
||||||
|
userID = uid
|
||||||
|
case int:
|
||||||
|
userID = int64(uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := kwargs["activity_type_id"]; ok {
|
||||||
|
switch tid := v.(type) {
|
||||||
|
case float64:
|
||||||
|
activityTypeID = int64(tid)
|
||||||
|
case int64:
|
||||||
|
activityTypeID = tid
|
||||||
|
case int:
|
||||||
|
activityTypeID = int64(tid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default user to current user
|
||||||
|
if userID == 0 {
|
||||||
|
userID = env.UID()
|
||||||
|
}
|
||||||
|
// Default deadline to tomorrow
|
||||||
|
if dateDeadline == "" {
|
||||||
|
dateDeadline = "CURRENT_DATE + INTERVAL '1 day'"
|
||||||
|
}
|
||||||
|
|
||||||
|
var newID int64
|
||||||
|
var err error
|
||||||
|
if dateDeadline == "CURRENT_DATE + INTERVAL '1 day'" {
|
||||||
|
err = env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`INSERT INTO mail_activity (res_model, res_id, summary, note, date_deadline, user_id, activity_type_id, state)
|
||||||
|
VALUES ('crm.lead', $1, $2, $3, CURRENT_DATE + INTERVAL '1 day', $4, NULLIF($5, 0), 'planned')
|
||||||
|
RETURNING id`,
|
||||||
|
leadID, summary, note, userID, activityTypeID,
|
||||||
|
).Scan(&newID)
|
||||||
|
} else {
|
||||||
|
err = env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`INSERT INTO mail_activity (res_model, res_id, summary, note, date_deadline, user_id, activity_type_id, state)
|
||||||
|
VALUES ('crm.lead', $1, $2, $3, $6::date, $4, NULLIF($5, 0), 'planned')
|
||||||
|
RETURNING id`,
|
||||||
|
leadID, summary, note, userID, activityTypeID, dateDeadline,
|
||||||
|
).Scan(&newID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("action_schedule_activity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"type": "ir.actions.act_window",
|
"activity_id": newID,
|
||||||
"name": "Schedule Activity",
|
"type": "ir.actions.act_window",
|
||||||
"res_model": "crm.lead",
|
"name": "Schedule Activity",
|
||||||
"res_id": rs.IDs()[0],
|
"res_model": "mail.activity",
|
||||||
"view_mode": "form",
|
"res_id": newID,
|
||||||
"views": [][]interface{}{{nil, "form"}},
|
"view_mode": "form",
|
||||||
"target": "new",
|
"views": [][]interface{}{{nil, "form"}},
|
||||||
|
"target": "new",
|
||||||
}, nil
|
}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// action_merge: merge multiple leads into the first one.
|
// action_merge: alias for action_merge_leads (delegates to the full implementation).
|
||||||
// Sums expected revenues from slave leads, deactivates them.
|
|
||||||
// Mirrors: odoo/addons/crm/wizard/crm_merge_opportunities.py
|
|
||||||
m.RegisterMethod("action_merge", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_merge", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
mergeMethod := orm.Registry.Get("crm.lead").Methods["action_merge_leads"]
|
||||||
|
if mergeMethod != nil {
|
||||||
|
return mergeMethod(rs, args...)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("crm.lead: action_merge_leads not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
// _get_opportunities_by_status: GROUP BY stage_id aggregation returning counts + sums.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _read_group (pipeline analysis)
|
||||||
|
m.RegisterMethod("_get_opportunities_by_status", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(), `
|
||||||
|
SELECT s.id, s.name, COUNT(l.id), COALESCE(SUM(l.expected_revenue::float8), 0),
|
||||||
|
COALESCE(AVG(l.probability), 0)
|
||||||
|
FROM crm_lead l
|
||||||
|
JOIN crm_stage s ON s.id = l.stage_id
|
||||||
|
WHERE l.active = true AND l.type = 'opportunity'
|
||||||
|
GROUP BY s.id, s.name, s.sequence
|
||||||
|
ORDER BY s.sequence`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("_get_opportunities_by_status: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var stageID int64
|
||||||
|
var stageName string
|
||||||
|
var count int64
|
||||||
|
var revenue, avgProb float64
|
||||||
|
if err := rows.Scan(&stageID, &stageName, &count, &revenue, &avgProb); err != nil {
|
||||||
|
return nil, fmt.Errorf("_get_opportunities_by_status scan: %w", err)
|
||||||
|
}
|
||||||
|
results = append(results, map[string]interface{}{
|
||||||
|
"stage_id": stageID,
|
||||||
|
"stage_name": stageName,
|
||||||
|
"count": count,
|
||||||
|
"total_revenue": revenue,
|
||||||
|
"avg_probability": avgProb,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_merge_leads: merge multiple leads — sum revenues, keep first partner,
|
||||||
|
// concatenate descriptions, delete merged records.
|
||||||
|
// Mirrors: odoo/addons/crm/wizard/crm_merge_opportunities.py action_merge
|
||||||
|
m.RegisterMethod("action_merge_leads", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
ids := rs.IDs()
|
ids := rs.IDs()
|
||||||
if len(ids) < 2 {
|
if len(ids) < 2 {
|
||||||
@@ -172,25 +511,36 @@ func initCRMLeadExtended() {
|
|||||||
|
|
||||||
masterID := ids[0]
|
masterID := ids[0]
|
||||||
for _, slaveID := range ids[1:] {
|
for _, slaveID := range ids[1:] {
|
||||||
// Sum revenues from slave into master
|
// Sum revenues
|
||||||
_, _ = env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE crm_lead
|
`UPDATE crm_lead
|
||||||
SET expected_revenue = COALESCE(expected_revenue, 0) +
|
SET expected_revenue = COALESCE(expected_revenue, 0) +
|
||||||
(SELECT COALESCE(expected_revenue, 0) FROM crm_lead WHERE id = $1)
|
(SELECT COALESCE(expected_revenue, 0) FROM crm_lead WHERE id = $1)
|
||||||
WHERE id = $2`,
|
WHERE id = $2`, slaveID, masterID); err != nil {
|
||||||
slaveID, masterID)
|
log.Printf("warning: crm.lead action_merge_leads revenue sum failed for slave %d: %v", slaveID, err)
|
||||||
// Copy partner info if master has none
|
}
|
||||||
_, _ = env.Tx().Exec(env.Ctx(),
|
// Keep first partner (master wins if set)
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_lead SET
|
||||||
|
partner_id = COALESCE(partner_id, (SELECT partner_id FROM crm_lead WHERE id = $1))
|
||||||
|
WHERE id = $2`, slaveID, masterID); err != nil {
|
||||||
|
log.Printf("warning: crm.lead action_merge_leads partner copy failed for slave %d: %v", slaveID, err)
|
||||||
|
}
|
||||||
|
// Concatenate descriptions
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE crm_lead
|
`UPDATE crm_lead
|
||||||
SET partner_id = COALESCE(
|
SET description = COALESCE(description, '') || E'\n---\n' ||
|
||||||
(SELECT partner_id FROM crm_lead WHERE id = $2),
|
COALESCE((SELECT description FROM crm_lead WHERE id = $1), '')
|
||||||
partner_id)
|
WHERE id = $2`, slaveID, masterID); err != nil {
|
||||||
WHERE id = $1 AND partner_id IS NULL`,
|
log.Printf("warning: crm.lead action_merge_leads description concat failed for slave %d: %v", slaveID, err)
|
||||||
masterID, slaveID)
|
}
|
||||||
// Deactivate the slave lead
|
// Delete the merged (slave) lead
|
||||||
_, _ = env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE crm_lead SET active = false WHERE id = $1`, slaveID)
|
`DELETE FROM crm_lead WHERE id = $1`, slaveID); err != nil {
|
||||||
|
log.Printf("warning: crm.lead action_merge_leads delete failed for slave %d: %v", slaveID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"type": "ir.actions.act_window",
|
"type": "ir.actions.act_window",
|
||||||
"res_model": "crm.lead",
|
"res_model": "crm.lead",
|
||||||
@@ -201,6 +551,166 @@ func initCRMLeadExtended() {
|
|||||||
}, nil
|
}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// _action_reschedule_calls: update activity dates for leads with overdue activities.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _action_reschedule_calls
|
||||||
|
m.RegisterMethod("_action_reschedule_calls", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
// Default reschedule days = 7
|
||||||
|
rescheduleDays := 7
|
||||||
|
if len(args) > 0 {
|
||||||
|
switch v := args[0].(type) {
|
||||||
|
case float64:
|
||||||
|
rescheduleDays = int(v)
|
||||||
|
case int:
|
||||||
|
rescheduleDays = v
|
||||||
|
case int64:
|
||||||
|
rescheduleDays = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all overdue mail.activity records linked to crm.lead
|
||||||
|
result, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE mail_activity
|
||||||
|
SET date_deadline = CURRENT_DATE + ($1 || ' days')::interval,
|
||||||
|
state = 'planned'
|
||||||
|
WHERE res_model = 'crm.lead'
|
||||||
|
AND date_deadline < CURRENT_DATE
|
||||||
|
AND done = false`, rescheduleDays)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("_action_reschedule_calls: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected := result.RowsAffected()
|
||||||
|
return map[string]interface{}{
|
||||||
|
"rescheduled_count": rowsAffected,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_lead_duplicate: copy lead with "(Copy)" suffix on name.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py copy()
|
||||||
|
m.RegisterMethod("action_lead_duplicate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
leadID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var newID int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`INSERT INTO crm_lead (name, type, partner_id, email_from, phone,
|
||||||
|
stage_id, team_id, user_id, expected_revenue, probability,
|
||||||
|
priority, company_id, currency_id, active, description,
|
||||||
|
partner_name, street, city, zip, country_id, date_last_stage_update)
|
||||||
|
SELECT name || ' (Copy)', type, partner_id, email_from, phone,
|
||||||
|
stage_id, team_id, user_id, expected_revenue, probability,
|
||||||
|
priority, company_id, currency_id, true, description,
|
||||||
|
partner_name, street, city, zip, country_id, NOW()
|
||||||
|
FROM crm_lead WHERE id = $1
|
||||||
|
RETURNING id`, leadID,
|
||||||
|
).Scan(&newID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("action_lead_duplicate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": "crm.lead",
|
||||||
|
"res_id": newID,
|
||||||
|
"view_mode": "form",
|
||||||
|
"views": [][]interface{}{{nil, "form"}},
|
||||||
|
"target": "current",
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// set_user_as_follower: create mail.followers entry for the lead's salesperson.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _create_lead_partner
|
||||||
|
m.RegisterMethod("set_user_as_follower", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
for _, leadID := range rs.IDs() {
|
||||||
|
// Get the user_id for the lead, then find the partner_id for that user
|
||||||
|
var userID *int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT user_id FROM crm_lead WHERE id = $1`, leadID,
|
||||||
|
).Scan(&userID); err != nil || userID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var partnerID *int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT partner_id FROM res_users WHERE id = $1`, *userID,
|
||||||
|
).Scan(&partnerID); err != nil || partnerID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already a follower
|
||||||
|
var exists bool
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT EXISTS(
|
||||||
|
SELECT 1 FROM mail_followers
|
||||||
|
WHERE res_model = 'crm.lead' AND res_id = $1 AND partner_id = $2
|
||||||
|
)`, leadID, *partnerID,
|
||||||
|
).Scan(&exists)
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`INSERT INTO mail_followers (res_model, res_id, partner_id)
|
||||||
|
VALUES ('crm.lead', $1, $2)`, leadID, *partnerID); err != nil {
|
||||||
|
log.Printf("warning: crm.lead set_user_as_follower failed for lead %d: %v", leadID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// message_subscribe: subscribe partners as followers on the lead.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_thread.py message_subscribe
|
||||||
|
m.RegisterMethod("message_subscribe", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
if len(args) < 1 {
|
||||||
|
return nil, fmt.Errorf("partner_ids required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept partner_ids as []interface{} or []int64
|
||||||
|
var partnerIDs []int64
|
||||||
|
switch v := args[0].(type) {
|
||||||
|
case []interface{}:
|
||||||
|
for _, p := range v {
|
||||||
|
switch pid := p.(type) {
|
||||||
|
case float64:
|
||||||
|
partnerIDs = append(partnerIDs, int64(pid))
|
||||||
|
case int64:
|
||||||
|
partnerIDs = append(partnerIDs, pid)
|
||||||
|
case int:
|
||||||
|
partnerIDs = append(partnerIDs, int64(pid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []int64:
|
||||||
|
partnerIDs = v
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, leadID := range rs.IDs() {
|
||||||
|
for _, partnerID := range partnerIDs {
|
||||||
|
// Check if already subscribed
|
||||||
|
var exists bool
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT EXISTS(
|
||||||
|
SELECT 1 FROM mail_followers
|
||||||
|
WHERE res_model = 'crm.lead' AND res_id = $1 AND partner_id = $2
|
||||||
|
)`, leadID, partnerID,
|
||||||
|
).Scan(&exists)
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`INSERT INTO mail_followers (res_model, res_id, partner_id)
|
||||||
|
VALUES ('crm.lead', $1, $2)`, leadID, partnerID); err != nil {
|
||||||
|
log.Printf("warning: crm.lead message_subscribe failed for lead %d partner %d: %v", leadID, partnerID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
// action_assign_salesperson: assign a salesperson to one or more leads.
|
// action_assign_salesperson: assign a salesperson to one or more leads.
|
||||||
// Mirrors: odoo/addons/crm/models/crm_lead.py _handle_salesmen_assignment
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _handle_salesmen_assignment
|
||||||
m.RegisterMethod("action_assign_salesperson", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_assign_salesperson", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
@@ -213,8 +723,10 @@ func initCRMLeadExtended() {
|
|||||||
}
|
}
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
_, _ = env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE crm_lead SET user_id = $1 WHERE id = $2`, int64(userID), id)
|
`UPDATE crm_lead SET user_id = $1 WHERE id = $2`, int64(userID), id); err != nil {
|
||||||
|
log.Printf("warning: crm.lead action_assign_salesperson failed for lead %d: %v", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
@@ -262,8 +774,10 @@ func initCRMLeadExtended() {
|
|||||||
}
|
}
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
_, _ = env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE crm_lead SET priority = $1 WHERE id = $2`, priority, id)
|
`UPDATE crm_lead SET priority = $1 WHERE id = $2`, priority, id); err != nil {
|
||||||
|
log.Printf("warning: crm.lead action_set_priority failed for lead %d: %v", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
@@ -273,8 +787,10 @@ func initCRMLeadExtended() {
|
|||||||
m.RegisterMethod("action_archive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_archive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
_, _ = env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE crm_lead SET active = false WHERE id = $1`, id)
|
`UPDATE crm_lead SET active = false WHERE id = $1`, id); err != nil {
|
||||||
|
log.Printf("warning: crm.lead action_archive failed for lead %d: %v", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
@@ -284,8 +800,10 @@ func initCRMLeadExtended() {
|
|||||||
m.RegisterMethod("action_unarchive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_unarchive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
_, _ = env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE crm_lead SET active = true WHERE id = $1`, id)
|
`UPDATE crm_lead SET active = true WHERE id = $1`, id); err != nil {
|
||||||
|
log.Printf("warning: crm.lead action_unarchive failed for lead %d: %v", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
@@ -302,9 +820,11 @@ func initCRMLeadExtended() {
|
|||||||
}
|
}
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
_, _ = env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE crm_lead SET stage_id = $1, date_last_stage_update = NOW() WHERE id = $2`,
|
`UPDATE crm_lead SET stage_id = $1, date_last_stage_update = NOW() WHERE id = $2`,
|
||||||
int64(stageID), id)
|
int64(stageID), id); err != nil {
|
||||||
|
log.Printf("warning: crm.lead action_set_stage failed for lead %d: %v", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
@@ -317,7 +837,7 @@ func initCRMLeadExtended() {
|
|||||||
var totalLeads, totalOpps, wonCount, lostCount int64
|
var totalLeads, totalOpps, wonCount, lostCount int64
|
||||||
var totalRevenue, avgProbability float64
|
var totalRevenue, avgProbability float64
|
||||||
|
|
||||||
_ = env.Tx().QueryRow(env.Ctx(), `
|
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) FILTER (WHERE type = 'lead'),
|
COUNT(*) FILTER (WHERE type = 'lead'),
|
||||||
COUNT(*) FILTER (WHERE type = 'opportunity'),
|
COUNT(*) FILTER (WHERE type = 'opportunity'),
|
||||||
@@ -326,7 +846,9 @@ func initCRMLeadExtended() {
|
|||||||
COALESCE(SUM(expected_revenue::float8), 0),
|
COALESCE(SUM(expected_revenue::float8), 0),
|
||||||
COALESCE(AVG(probability), 0)
|
COALESCE(AVG(probability), 0)
|
||||||
FROM crm_lead WHERE active = true`,
|
FROM crm_lead WHERE active = true`,
|
||||||
).Scan(&totalLeads, &totalOpps, &wonCount, &lostCount, &totalRevenue, &avgProbability)
|
).Scan(&totalLeads, &totalOpps, &wonCount, &lostCount, &totalRevenue, &avgProbability); err != nil {
|
||||||
|
log.Printf("warning: crm.lead _get_lead_statistics query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"total_leads": totalLeads,
|
"total_leads": totalLeads,
|
||||||
@@ -338,7 +860,158 @@ func initCRMLeadExtended() {
|
|||||||
}, nil
|
}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// Onchange: partner_id → populate contact/address fields from partner
|
// action_schedule_meeting: return calendar action for scheduling a meeting.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py action_schedule_meeting
|
||||||
|
m.RegisterMethod("action_schedule_meeting", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
leadID := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Fetch lead data for context
|
||||||
|
var name string
|
||||||
|
var partnerID, teamID *int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(name, ''), partner_id, team_id FROM crm_lead WHERE id = $1`, leadID,
|
||||||
|
).Scan(&name, &partnerID, &teamID)
|
||||||
|
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"default_opportunity_id": leadID,
|
||||||
|
"default_name": name,
|
||||||
|
"search_default_opportunity_id": leadID,
|
||||||
|
}
|
||||||
|
if partnerID != nil {
|
||||||
|
ctx["default_partner_id"] = *partnerID
|
||||||
|
ctx["default_partner_ids"] = []int64{*partnerID}
|
||||||
|
}
|
||||||
|
if teamID != nil {
|
||||||
|
ctx["default_team_id"] = *teamID
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"name": "Meeting",
|
||||||
|
"res_model": "calendar.event",
|
||||||
|
"view_mode": "calendar,tree,form",
|
||||||
|
"context": ctx,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_new_quotation: return action to create a sale.order linked to the lead.
|
||||||
|
// Mirrors: odoo/addons/sale_crm/models/crm_lead.py action_new_quotation
|
||||||
|
m.RegisterMethod("action_new_quotation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
leadID := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Fetch lead context data
|
||||||
|
var partnerID, teamID, companyID *int64
|
||||||
|
var name string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(name, ''), partner_id, team_id, company_id FROM crm_lead WHERE id = $1`, leadID,
|
||||||
|
).Scan(&name, &partnerID, &teamID, &companyID)
|
||||||
|
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"default_opportunity_id": leadID,
|
||||||
|
"search_default_opportunity_id": leadID,
|
||||||
|
"default_origin": name,
|
||||||
|
}
|
||||||
|
if partnerID != nil {
|
||||||
|
ctx["default_partner_id"] = *partnerID
|
||||||
|
}
|
||||||
|
if teamID != nil {
|
||||||
|
ctx["default_team_id"] = *teamID
|
||||||
|
}
|
||||||
|
if companyID != nil {
|
||||||
|
ctx["default_company_id"] = *companyID
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"name": "New Quotation",
|
||||||
|
"res_model": "sale.order",
|
||||||
|
"view_mode": "form",
|
||||||
|
"views": [][]interface{}{{nil, "form"}},
|
||||||
|
"target": "current",
|
||||||
|
"context": ctx,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// merge_opportunity: alias for action_merge_leads.
|
||||||
|
m.RegisterMethod("merge_opportunity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
mergeMethod := orm.Registry.Get("crm.lead").Methods["action_merge_leads"]
|
||||||
|
if mergeMethod != nil {
|
||||||
|
return mergeMethod(rs, args...)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("crm.lead: action_merge_leads not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
// handle_partner_assignment: create or assign partner for leads.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _handle_partner_assignment
|
||||||
|
m.RegisterMethod("handle_partner_assignment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
// Optional force_partner_id from args
|
||||||
|
var forcePartnerID int64
|
||||||
|
if len(args) > 0 {
|
||||||
|
if pid, ok := args[0].(float64); ok {
|
||||||
|
forcePartnerID = int64(pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if forcePartnerID > 0 {
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_lead SET partner_id = $1 WHERE id = $2`, forcePartnerID, id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if lead already has a partner
|
||||||
|
var existingPartnerID *int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT partner_id FROM crm_lead WHERE id = $1`, id).Scan(&existingPartnerID)
|
||||||
|
if existingPartnerID != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create partner from lead data
|
||||||
|
var email, phone, partnerName, street, city, zip, contactName string
|
||||||
|
var countryID *int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(email_from,''), COALESCE(phone,''),
|
||||||
|
COALESCE(partner_name,''), COALESCE(street,''),
|
||||||
|
COALESCE(city,''), COALESCE(zip,''),
|
||||||
|
COALESCE(contact_name,''), country_id
|
||||||
|
FROM crm_lead WHERE id = $1`, id,
|
||||||
|
).Scan(&email, &phone, &partnerName, &street, &city, &zip, &contactName, &countryID)
|
||||||
|
|
||||||
|
name := partnerName
|
||||||
|
if name == "" {
|
||||||
|
name = contactName
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = email
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
continue // cannot create partner without any identifying info
|
||||||
|
}
|
||||||
|
|
||||||
|
var newPartnerID int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`INSERT INTO res_partner (name, email, phone, street, city, zip, country_id, active, is_company)
|
||||||
|
VALUES ($1, NULLIF($2,''), NULLIF($3,''), NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), $7, true,
|
||||||
|
CASE WHEN $8 != '' THEN true ELSE false END)
|
||||||
|
RETURNING id`,
|
||||||
|
name, email, phone, street, city, zip, countryID, partnerName,
|
||||||
|
).Scan(&newPartnerID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("warning: crm.lead handle_partner_assignment create partner failed for lead %d: %v", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_lead SET partner_id = $1 WHERE id = $2`, newPartnerID, id)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Onchange: partner_id -> populate contact/address fields from partner
|
||||||
// Mirrors: odoo/addons/crm/models/crm_lead.py _onchange_partner_id
|
// Mirrors: odoo/addons/crm/models/crm_lead.py _onchange_partner_id
|
||||||
m.RegisterOnchange("partner_id", func(env *orm.Environment, vals orm.Values) orm.Values {
|
m.RegisterOnchange("partner_id", func(env *orm.Environment, vals orm.Values) orm.Values {
|
||||||
result := make(orm.Values)
|
result := make(orm.Values)
|
||||||
@@ -352,11 +1025,13 @@ func initCRMLeadExtended() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var email, phone, street, city, zip, name string
|
var email, phone, street, city, zip, name string
|
||||||
_ = env.Tx().QueryRow(env.Ctx(),
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT COALESCE(email,''), COALESCE(phone,''), COALESCE(street,''),
|
`SELECT COALESCE(email,''), COALESCE(phone,''), COALESCE(street,''),
|
||||||
COALESCE(city,''), COALESCE(zip,''), COALESCE(name,'')
|
COALESCE(city,''), COALESCE(zip,''), COALESCE(name,'')
|
||||||
FROM res_partner WHERE id = $1`, int64(pid),
|
FROM res_partner WHERE id = $1`, int64(pid),
|
||||||
).Scan(&email, &phone, &street, &city, &zip, &name)
|
).Scan(&email, &phone, &street, &city, &zip, &name); err != nil {
|
||||||
|
log.Printf("warning: crm.lead onchange partner_id lookup failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if email != "" {
|
if email != "" {
|
||||||
result["email_from"] = email
|
result["email_from"] = email
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
"odoo-go/pkg/orm"
|
"odoo-go/pkg/orm"
|
||||||
)
|
)
|
||||||
@@ -81,6 +82,24 @@ func initCrmTeamExpanded() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// _compute_assignment_optout: count members who opted out of auto-assignment.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_team.py _compute_assignment_optout
|
||||||
|
m.RegisterCompute("assignment_optout_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
teamID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM crm_team_member
|
||||||
|
WHERE crm_team_id = $1 AND active = true AND assignment_optout = true`,
|
||||||
|
teamID,
|
||||||
|
).Scan(&count); err != nil {
|
||||||
|
log.Printf("warning: crm.team _compute_assignment_optout query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return orm.Values{"assignment_optout_count": count}, nil
|
||||||
|
})
|
||||||
|
|
||||||
// _compute_counts: compute dashboard KPIs for the sales team.
|
// _compute_counts: compute dashboard KPIs for the sales team.
|
||||||
// Mirrors: odoo/addons/crm/models/crm_team.py _compute_opportunities_data
|
// Mirrors: odoo/addons/crm/models/crm_team.py _compute_opportunities_data
|
||||||
m.RegisterCompute("opportunities_count", func(rs *orm.Recordset) (orm.Values, error) {
|
m.RegisterCompute("opportunities_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
@@ -89,20 +108,24 @@ func initCrmTeamExpanded() {
|
|||||||
|
|
||||||
var count int64
|
var count int64
|
||||||
var amount float64
|
var amount float64
|
||||||
_ = env.Tx().QueryRow(env.Ctx(),
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT COUNT(*), COALESCE(SUM(expected_revenue::float8), 0)
|
`SELECT COUNT(*), COALESCE(SUM(expected_revenue::float8), 0)
|
||||||
FROM crm_lead
|
FROM crm_lead
|
||||||
WHERE team_id = $1 AND active = true AND type = 'opportunity'`,
|
WHERE team_id = $1 AND active = true AND type = 'opportunity'`,
|
||||||
teamID,
|
teamID,
|
||||||
).Scan(&count, &amount)
|
).Scan(&count, &amount); err != nil {
|
||||||
|
log.Printf("warning: crm.team _compute_counts opportunities query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
var unassigned int64
|
var unassigned int64
|
||||||
_ = env.Tx().QueryRow(env.Ctx(),
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT COUNT(*)
|
`SELECT COUNT(*)
|
||||||
FROM crm_lead
|
FROM crm_lead
|
||||||
WHERE team_id = $1 AND active = true AND user_id IS NULL`,
|
WHERE team_id = $1 AND active = true AND user_id IS NULL`,
|
||||||
teamID,
|
teamID,
|
||||||
).Scan(&unassigned)
|
).Scan(&unassigned); err != nil {
|
||||||
|
log.Printf("warning: crm.team _compute_counts unassigned query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return orm.Values{
|
return orm.Values{
|
||||||
"opportunities_count": count,
|
"opportunities_count": count,
|
||||||
@@ -111,6 +134,69 @@ func initCrmTeamExpanded() {
|
|||||||
}, nil
|
}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// get_crm_dashboard_data: KPIs — total pipeline value, won count, lost count, conversion rate.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_team.py _compute_dashboard_data
|
||||||
|
m.RegisterMethod("get_crm_dashboard_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
teamID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var totalPipeline float64
|
||||||
|
var wonCount, lostCount, totalOpps int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(expected_revenue::float8), 0),
|
||||||
|
COUNT(*) FILTER (WHERE state = 'won'),
|
||||||
|
COUNT(*) FILTER (WHERE state = 'lost'),
|
||||||
|
COUNT(*)
|
||||||
|
FROM crm_lead
|
||||||
|
WHERE team_id = $1 AND type = 'opportunity'`,
|
||||||
|
teamID,
|
||||||
|
).Scan(&totalPipeline, &wonCount, &lostCount, &totalOpps); err != nil {
|
||||||
|
log.Printf("warning: crm.team get_crm_dashboard_data query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conversionRate := float64(0)
|
||||||
|
decided := wonCount + lostCount
|
||||||
|
if decided > 0 {
|
||||||
|
conversionRate = float64(wonCount) / float64(decided) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active pipeline (open opportunities only)
|
||||||
|
var activePipeline float64
|
||||||
|
var activeCount int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||||
|
SELECT COALESCE(SUM(expected_revenue::float8), 0), COUNT(*)
|
||||||
|
FROM crm_lead
|
||||||
|
WHERE team_id = $1 AND type = 'opportunity' AND active = true AND state = 'open'`,
|
||||||
|
teamID,
|
||||||
|
).Scan(&activePipeline, &activeCount); err != nil {
|
||||||
|
log.Printf("warning: crm.team get_crm_dashboard_data active pipeline query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overdue activities count
|
||||||
|
var overdueCount int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(), `
|
||||||
|
SELECT COUNT(DISTINCT l.id)
|
||||||
|
FROM crm_lead l
|
||||||
|
JOIN mail_activity a ON a.res_model = 'crm.lead' AND a.res_id = l.id
|
||||||
|
WHERE l.team_id = $1 AND a.date_deadline < CURRENT_DATE AND a.done = false`,
|
||||||
|
teamID,
|
||||||
|
).Scan(&overdueCount); err != nil {
|
||||||
|
log.Printf("warning: crm.team get_crm_dashboard_data overdue query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"total_pipeline": totalPipeline,
|
||||||
|
"active_pipeline": activePipeline,
|
||||||
|
"active_count": activeCount,
|
||||||
|
"won_count": wonCount,
|
||||||
|
"lost_count": lostCount,
|
||||||
|
"total_opportunities": totalOpps,
|
||||||
|
"conversion_rate": conversionRate,
|
||||||
|
"overdue_activities": overdueCount,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
// action_assign_leads: trigger automatic lead assignment.
|
// action_assign_leads: trigger automatic lead assignment.
|
||||||
// Mirrors: odoo/addons/crm/models/crm_team.py action_assign_leads
|
// Mirrors: odoo/addons/crm/models/crm_team.py action_assign_leads
|
||||||
m.RegisterMethod("action_assign_leads", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_assign_leads", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
@@ -174,8 +260,10 @@ func initCrmTeamExpanded() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
mc := &members[memberIdx]
|
mc := &members[memberIdx]
|
||||||
_, _ = env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE crm_lead SET user_id = $1 WHERE id = $2`, mc.userID, leadID)
|
`UPDATE crm_lead SET user_id = $1 WHERE id = $2`, mc.userID, leadID); err != nil {
|
||||||
|
log.Printf("warning: crm.team action_assign_leads update failed for lead %d: %v", leadID, err)
|
||||||
|
}
|
||||||
assigned++
|
assigned++
|
||||||
mc.capacity--
|
mc.capacity--
|
||||||
if mc.capacity <= 0 {
|
if mc.capacity <= 0 {
|
||||||
@@ -233,6 +321,15 @@ func initCrmTeamMember() {
|
|||||||
Index: true,
|
Index: true,
|
||||||
}),
|
}),
|
||||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||||
|
orm.Selection("role", []orm.SelectionItem{
|
||||||
|
{Value: "member", Label: "Member"},
|
||||||
|
{Value: "leader", Label: "Team Leader"},
|
||||||
|
{Value: "manager", Label: "Sales Manager"},
|
||||||
|
}, orm.FieldOpts{
|
||||||
|
String: "Role",
|
||||||
|
Default: "member",
|
||||||
|
Help: "Role of this member within the sales team.",
|
||||||
|
}),
|
||||||
orm.Float("assignment_max", orm.FieldOpts{
|
orm.Float("assignment_max", orm.FieldOpts{
|
||||||
String: "Max Leads",
|
String: "Max Leads",
|
||||||
Help: "Maximum number of leads this member should be assigned per month.",
|
Help: "Maximum number of leads this member should be assigned per month.",
|
||||||
@@ -260,17 +357,21 @@ func initCrmTeamMember() {
|
|||||||
memberID := rs.IDs()[0]
|
memberID := rs.IDs()[0]
|
||||||
|
|
||||||
var userID, teamID int64
|
var userID, teamID int64
|
||||||
_ = env.Tx().QueryRow(env.Ctx(),
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT user_id, crm_team_id FROM crm_team_member WHERE id = $1`, memberID,
|
`SELECT user_id, crm_team_id FROM crm_team_member WHERE id = $1`, memberID,
|
||||||
).Scan(&userID, &teamID)
|
).Scan(&userID, &teamID); err != nil {
|
||||||
|
log.Printf("warning: crm.team.member _compute_lead_count member lookup failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
var count int64
|
var count int64
|
||||||
_ = env.Tx().QueryRow(env.Ctx(),
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT COUNT(*) FROM crm_lead
|
`SELECT COUNT(*) FROM crm_lead
|
||||||
WHERE user_id = $1 AND team_id = $2 AND active = true
|
WHERE user_id = $1 AND team_id = $2 AND active = true
|
||||||
AND create_date >= date_trunc('month', CURRENT_DATE)`,
|
AND create_date >= date_trunc('month', CURRENT_DATE)`,
|
||||||
userID, teamID,
|
userID, teamID,
|
||||||
).Scan(&count)
|
).Scan(&count); err != nil {
|
||||||
|
log.Printf("warning: crm.team.member _compute_lead_count query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return orm.Values{"lead_month_count": count}, nil
|
return orm.Values{"lead_month_count": count}, nil
|
||||||
})
|
})
|
||||||
@@ -281,4 +382,90 @@ func initCrmTeamMember() {
|
|||||||
"UNIQUE(crm_team_id, user_id)",
|
"UNIQUE(crm_team_id, user_id)",
|
||||||
"A user can only be a member of a team once.",
|
"A user can only be a member of a team once.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// action_assign_to_team: add a user to a team as a member.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_team_member.py _assign_to_team
|
||||||
|
m.RegisterMethod("action_assign_to_team", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
if len(args) < 2 {
|
||||||
|
return nil, fmt.Errorf("user_id and team_id required")
|
||||||
|
}
|
||||||
|
var userID, teamID int64
|
||||||
|
switch v := args[0].(type) {
|
||||||
|
case float64:
|
||||||
|
userID = int64(v)
|
||||||
|
case int64:
|
||||||
|
userID = v
|
||||||
|
case int:
|
||||||
|
userID = int64(v)
|
||||||
|
}
|
||||||
|
switch v := args[1].(type) {
|
||||||
|
case float64:
|
||||||
|
teamID = int64(v)
|
||||||
|
case int64:
|
||||||
|
teamID = v
|
||||||
|
case int:
|
||||||
|
teamID = int64(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already a member
|
||||||
|
var exists bool
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM crm_team_member WHERE user_id = $1 AND crm_team_id = $2)`,
|
||||||
|
userID, teamID,
|
||||||
|
).Scan(&exists)
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
// Ensure active
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_team_member SET active = true WHERE user_id = $1 AND crm_team_id = $2`,
|
||||||
|
userID, teamID); err != nil {
|
||||||
|
return nil, fmt.Errorf("action_assign_to_team reactivate: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var newID int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`INSERT INTO crm_team_member (user_id, crm_team_id, active, role)
|
||||||
|
VALUES ($1, $2, true, 'member')
|
||||||
|
RETURNING id`, userID, teamID,
|
||||||
|
).Scan(&newID); err != nil {
|
||||||
|
return nil, fmt.Errorf("action_assign_to_team insert: %w", err)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"id": newID}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_remove_from_team: deactivate membership (soft delete).
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_team_member.py unlink
|
||||||
|
m.RegisterMethod("action_remove_from_team", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_team_member SET active = false WHERE id = $1`, id); err != nil {
|
||||||
|
log.Printf("warning: crm.team.member action_remove_from_team failed for member %d: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_set_role: change the role of a team member.
|
||||||
|
// Mirrors: odoo/addons/crm/models/crm_team_member.py write
|
||||||
|
m.RegisterMethod("action_set_role", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
return nil, fmt.Errorf("role value required ('member', 'leader', 'manager')")
|
||||||
|
}
|
||||||
|
role, ok := args[0].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("role must be a string")
|
||||||
|
}
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE crm_team_member SET role = $1 WHERE id = $2`, role, id); err != nil {
|
||||||
|
log.Printf("warning: crm.team.member action_set_role failed for member %d: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "odoo-go/pkg/orm"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
// initResourceCalendar registers resource.calendar — working schedules.
|
// initResourceCalendar registers resource.calendar — working schedules.
|
||||||
// Mirrors: odoo/addons/resource/models/resource.py
|
// Mirrors: odoo/addons/resource/models/resource.py
|
||||||
@@ -98,15 +103,181 @@ func initHREmployee() {
|
|||||||
orm.Binary("image_1920", orm.FieldOpts{String: "Image"}),
|
orm.Binary("image_1920", orm.FieldOpts{String: "Image"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultGet: provide dynamic defaults for new employees.
|
||||||
|
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.default_get()
|
||||||
|
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||||
|
vals := make(orm.Values)
|
||||||
|
// Default company from current user's session
|
||||||
|
companyID := env.CompanyID()
|
||||||
|
if companyID > 0 {
|
||||||
|
vals["company_id"] = companyID
|
||||||
|
}
|
||||||
|
return vals
|
||||||
|
}
|
||||||
|
|
||||||
// toggle_active: archive/unarchive employee
|
// toggle_active: archive/unarchive employee
|
||||||
|
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.toggle_active()
|
||||||
m.RegisterMethod("toggle_active", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("toggle_active", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
_, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE hr_employee SET active = NOT active WHERE id = $1`, id)
|
`UPDATE hr_employee SET active = NOT active WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.employee: toggle_active for %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// action_archive: Archive employee (set active=false).
|
||||||
|
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.action_archive()
|
||||||
|
m.RegisterMethod("action_archive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
_, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_employee SET active = false WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.employee: action_archive for %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _compute_remaining_leaves: Compute remaining leave days for the employee.
|
||||||
|
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee._compute_remaining_leaves()
|
||||||
|
m.RegisterCompute("leaves_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
empID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var allocated float64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(number_of_days), 0)
|
||||||
|
FROM hr_leave_allocation
|
||||||
|
WHERE employee_id = $1 AND state = 'validate'`, empID,
|
||||||
|
).Scan(&allocated); err != nil {
|
||||||
|
return orm.Values{"leaves_count": float64(0)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var used float64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(number_of_days), 0)
|
||||||
|
FROM hr_leave
|
||||||
|
WHERE employee_id = $1 AND state = 'validate'`, empID,
|
||||||
|
).Scan(&used); err != nil {
|
||||||
|
return orm.Values{"leaves_count": float64(0)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return orm.Values{"leaves_count": allocated - used}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
m.RegisterCompute("attendance_state", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
empID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var checkOut *string
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT check_out FROM hr_attendance
|
||||||
|
WHERE employee_id = $1
|
||||||
|
ORDER BY check_in DESC LIMIT 1`, empID,
|
||||||
|
).Scan(&checkOut)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// No attendance records or DB error → checked out
|
||||||
|
return orm.Values{"attendance_state": "checked_out"}, nil
|
||||||
|
}
|
||||||
|
if checkOut == nil {
|
||||||
|
return orm.Values{"attendance_state": "checked_in"}, nil
|
||||||
|
}
|
||||||
|
return orm.Values{"attendance_state": "checked_out"}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// initHrEmployeeCategory registers the hr.employee.category model.
|
||||||
|
// Mirrors: odoo/addons/hr/models/hr_employee_category.py
|
||||||
|
func initHrEmployeeCategory() {
|
||||||
|
orm.NewModel("hr.employee.category", orm.ModelOpts{
|
||||||
|
Description: "Employee Tag",
|
||||||
|
Order: "name",
|
||||||
|
}).AddFields(
|
||||||
|
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||||
|
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initHrEmployeePublic registers the hr.employee.public model with limited fields.
|
||||||
|
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployeePublic
|
||||||
|
func initHrEmployeePublic() {
|
||||||
|
m := orm.NewModel("hr.employee.public", orm.ModelOpts{
|
||||||
|
Description: "Public Employee",
|
||||||
|
Order: "name",
|
||||||
|
})
|
||||||
|
m.AddFields(
|
||||||
|
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
|
||||||
|
String: "Employee", Required: true, Index: true,
|
||||||
|
}),
|
||||||
|
orm.Char("name", orm.FieldOpts{String: "Name", Readonly: true}),
|
||||||
|
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department", Readonly: true}),
|
||||||
|
orm.Char("job_title", orm.FieldOpts{String: "Job Title", Readonly: true}),
|
||||||
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Readonly: true}),
|
||||||
|
orm.Many2one("parent_id", "hr.employee.public", orm.FieldOpts{String: "Manager", Readonly: true}),
|
||||||
|
orm.Char("work_email", orm.FieldOpts{String: "Work Email", Readonly: true}),
|
||||||
|
orm.Char("work_phone", orm.FieldOpts{String: "Work Phone", Readonly: true}),
|
||||||
|
orm.Binary("image_1920", orm.FieldOpts{String: "Image", Readonly: true}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// get_public_data: Reads limited public fields from hr.employee.
|
||||||
|
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployeePublic.read()
|
||||||
|
m.RegisterMethod("get_public_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
// Accept employee_id from kwargs or use IDs
|
||||||
|
var employeeIDs []int64
|
||||||
|
if len(args) > 0 {
|
||||||
|
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||||
|
if v, ok := kw["employee_ids"]; ok {
|
||||||
|
if ids, ok := v.([]int64); ok {
|
||||||
|
employeeIDs = ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(employeeIDs) == 0 {
|
||||||
|
employeeIDs = rs.IDs()
|
||||||
|
}
|
||||||
|
if len(employeeIDs) == 0 {
|
||||||
|
return []map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT id, COALESCE(name, ''), COALESCE(department_id, 0),
|
||||||
|
COALESCE(job_title, ''), COALESCE(company_id, 0),
|
||||||
|
COALESCE(work_email, ''), COALESCE(work_phone, '')
|
||||||
|
FROM hr_employee
|
||||||
|
WHERE id = ANY($1) AND COALESCE(active, true) = true`, employeeIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.employee.public: query: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, deptID, companyID int64
|
||||||
|
var name, jobTitle, email, phone string
|
||||||
|
if err := rows.Scan(&id, &name, &deptID, &jobTitle, &companyID, &email, &phone); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"department_id": deptID,
|
||||||
|
"job_title": jobTitle,
|
||||||
|
"company_id": companyID,
|
||||||
|
"work_email": email,
|
||||||
|
"work_phone": phone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// initHrEmployeeExtensions adds skill, resume, attendance and leave fields
|
// initHrEmployeeExtensions adds skill, resume, attendance and leave fields
|
||||||
@@ -117,12 +288,283 @@ func initHrEmployeeExtensions() {
|
|||||||
orm.One2many("skill_ids", "hr.employee.skill", "employee_id", orm.FieldOpts{String: "Skills"}),
|
orm.One2many("skill_ids", "hr.employee.skill", "employee_id", orm.FieldOpts{String: "Skills"}),
|
||||||
orm.One2many("resume_line_ids", "hr.resume.line", "employee_id", orm.FieldOpts{String: "Resume"}),
|
orm.One2many("resume_line_ids", "hr.resume.line", "employee_id", orm.FieldOpts{String: "Resume"}),
|
||||||
orm.One2many("attendance_ids", "hr.attendance", "employee_id", orm.FieldOpts{String: "Attendances"}),
|
orm.One2many("attendance_ids", "hr.attendance", "employee_id", orm.FieldOpts{String: "Attendances"}),
|
||||||
|
orm.Many2one("contract_id", "hr.contract", orm.FieldOpts{String: "Current Contract"}),
|
||||||
|
orm.One2many("contract_ids", "hr.contract", "employee_id", orm.FieldOpts{String: "Contracts"}),
|
||||||
orm.Float("leaves_count", orm.FieldOpts{String: "Time Off", Compute: "_compute_leaves"}),
|
orm.Float("leaves_count", orm.FieldOpts{String: "Time Off", Compute: "_compute_leaves"}),
|
||||||
orm.Selection("attendance_state", []orm.SelectionItem{
|
orm.Selection("attendance_state", []orm.SelectionItem{
|
||||||
{Value: "checked_out", Label: "Checked Out"},
|
{Value: "checked_out", Label: "Checked Out"},
|
||||||
{Value: "checked_in", Label: "Checked In"},
|
{Value: "checked_in", Label: "Checked In"},
|
||||||
}, orm.FieldOpts{String: "Attendance", Compute: "_compute_attendance_state"}),
|
}, orm.FieldOpts{String: "Attendance", Compute: "_compute_attendance_state"}),
|
||||||
|
orm.Float("seniority_years", orm.FieldOpts{
|
||||||
|
String: "Seniority (Years)", Compute: "_compute_seniority_years",
|
||||||
|
}),
|
||||||
|
orm.Date("first_contract_date", orm.FieldOpts{String: "First Contract Date"}),
|
||||||
|
orm.Many2many("category_ids", "hr.employee.category", orm.FieldOpts{String: "Tags"}),
|
||||||
|
orm.Integer("age", orm.FieldOpts{
|
||||||
|
String: "Age", Compute: "_compute_age",
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// _compute_seniority_years: Years since first contract start date.
|
||||||
|
// Mirrors: odoo/addons/hr_contract/models/hr_employee.py _compute_first_contract_date
|
||||||
|
emp.RegisterCompute("seniority_years", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
empID := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Find earliest contract start date
|
||||||
|
var firstDate *time.Time
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT MIN(date_start) FROM hr_contract
|
||||||
|
WHERE employee_id = $1 AND state NOT IN ('cancel')`, empID,
|
||||||
|
).Scan(&firstDate); err != nil {
|
||||||
|
return orm.Values{"seniority_years": float64(0)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstDate == nil {
|
||||||
|
return orm.Values{"seniority_years": float64(0)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
years := time.Since(*firstDate).Hours() / (24 * 365.25)
|
||||||
|
if years < 0 {
|
||||||
|
years = 0
|
||||||
|
}
|
||||||
|
return orm.Values{"seniority_years": years}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// get_attendance_by_date_range: Return attendance summary for an employee.
|
||||||
|
// Mirrors: odoo/addons/hr_attendance/models/hr_employee.py
|
||||||
|
emp.RegisterMethod("get_attendance_by_date_range", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
empID := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Parse date_from / date_to from kwargs
|
||||||
|
dateFrom := time.Now().AddDate(0, -1, 0).Format("2006-01-02")
|
||||||
|
dateTo := time.Now().Format("2006-01-02")
|
||||||
|
if len(args) > 0 {
|
||||||
|
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||||
|
if v, ok := kw["date_from"].(string); ok && v != "" {
|
||||||
|
dateFrom = v
|
||||||
|
}
|
||||||
|
if v, ok := kw["date_to"].(string); ok && v != "" {
|
||||||
|
dateTo = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily attendance summary
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT check_in::date AS day,
|
||||||
|
COUNT(*) AS entries,
|
||||||
|
COALESCE(SUM(EXTRACT(EPOCH FROM (COALESCE(check_out, NOW()) - check_in)) / 3600.0), 0) AS total_hours
|
||||||
|
FROM hr_attendance
|
||||||
|
WHERE employee_id = $1 AND check_in::date >= $2 AND check_in::date <= $3
|
||||||
|
GROUP BY check_in::date
|
||||||
|
ORDER BY check_in::date`, empID, dateFrom, dateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.employee: attendance report: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var days []map[string]interface{}
|
||||||
|
var totalHours float64
|
||||||
|
var totalDays int
|
||||||
|
for rows.Next() {
|
||||||
|
var day time.Time
|
||||||
|
var entries int
|
||||||
|
var hours float64
|
||||||
|
if err := rows.Scan(&day, &entries, &hours); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
days = append(days, map[string]interface{}{
|
||||||
|
"date": day.Format("2006-01-02"),
|
||||||
|
"entries": entries,
|
||||||
|
"hours": hours,
|
||||||
|
})
|
||||||
|
totalHours += hours
|
||||||
|
totalDays++
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"employee_id": empID,
|
||||||
|
"date_from": dateFrom,
|
||||||
|
"date_to": dateTo,
|
||||||
|
"days": days,
|
||||||
|
"total_days": totalDays,
|
||||||
|
"total_hours": totalHours,
|
||||||
|
"avg_hours": func() float64 { if totalDays > 0 { return totalHours / float64(totalDays) }; return 0 }(),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _compute_age: Compute employee age from birthday.
|
||||||
|
// Mirrors: odoo/addons/hr/models/hr_employee.py _compute_age()
|
||||||
|
emp.RegisterCompute("age", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
empID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var birthday *time.Time
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT birthday FROM hr_employee WHERE id = $1`, empID,
|
||||||
|
).Scan(&birthday); err != nil || birthday == nil {
|
||||||
|
return orm.Values{"age": int64(0)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
age := now.Year() - birthday.Year()
|
||||||
|
// Adjust if birthday has not occurred yet this year
|
||||||
|
if now.Month() < birthday.Month() ||
|
||||||
|
(now.Month() == birthday.Month() && now.Day() < birthday.Day()) {
|
||||||
|
age--
|
||||||
|
}
|
||||||
|
if age < 0 {
|
||||||
|
age = 0
|
||||||
|
}
|
||||||
|
return orm.Values{"age": int64(age)}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_check_in: Create a new attendance record with check_in = now.
|
||||||
|
// Mirrors: odoo/addons/hr_attendance/models/hr_employee.py action_check_in()
|
||||||
|
emp.RegisterMethod("action_check_in", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||||
|
|
||||||
|
for _, empID := range rs.IDs() {
|
||||||
|
// Verify employee is not already checked in
|
||||||
|
var openCount int
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM hr_attendance
|
||||||
|
WHERE employee_id = $1 AND check_out IS NULL`, empID,
|
||||||
|
).Scan(&openCount)
|
||||||
|
if openCount > 0 {
|
||||||
|
return nil, fmt.Errorf("hr.employee: employee %d is already checked in", empID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get company_id from employee
|
||||||
|
var companyID *int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT company_id FROM hr_employee WHERE id = $1`, empID,
|
||||||
|
).Scan(&companyID)
|
||||||
|
|
||||||
|
// Create attendance record
|
||||||
|
attRS := env.Model("hr.attendance")
|
||||||
|
vals := orm.Values{
|
||||||
|
"employee_id": empID,
|
||||||
|
"check_in": now,
|
||||||
|
}
|
||||||
|
if companyID != nil {
|
||||||
|
vals["company_id"] = *companyID
|
||||||
|
}
|
||||||
|
if _, err := attRS.Create(vals); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.employee: check_in for %d: %w", empID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_check_out: Set check_out on the latest open attendance record.
|
||||||
|
// Mirrors: odoo/addons/hr_attendance/models/hr_employee.py action_check_out()
|
||||||
|
emp.RegisterMethod("action_check_out", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||||
|
|
||||||
|
for _, empID := range rs.IDs() {
|
||||||
|
result, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_attendance SET check_out = $1
|
||||||
|
WHERE employee_id = $2 AND check_out IS NULL`, now, empID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.employee: check_out for %d: %w", empID, err)
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return nil, fmt.Errorf("hr.employee: employee %d is not checked in", empID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// get_org_chart: Return hierarchical org chart data for the employee.
|
||||||
|
// Mirrors: odoo/addons/hr_org_chart/models/hr_employee.py get_org_chart()
|
||||||
|
emp.RegisterMethod("get_org_chart", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
empID := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Recursive function to build the tree
|
||||||
|
var buildNode func(id int64, depth int) (map[string]interface{}, error)
|
||||||
|
buildNode = func(id int64, depth int) (map[string]interface{}, error) {
|
||||||
|
// Prevent infinite recursion
|
||||||
|
if depth > 20 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var name, jobTitle string
|
||||||
|
var deptID, parentID *int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(name, ''), COALESCE(job_title, ''),
|
||||||
|
department_id, parent_id
|
||||||
|
FROM hr_employee
|
||||||
|
WHERE id = $1 AND COALESCE(active, true) = true`, id,
|
||||||
|
).Scan(&name, &jobTitle, &deptID, &parentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil // employee not found or inactive
|
||||||
|
}
|
||||||
|
|
||||||
|
node := map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"job_title": jobTitle,
|
||||||
|
}
|
||||||
|
if deptID != nil {
|
||||||
|
node["department_id"] = *deptID
|
||||||
|
}
|
||||||
|
if parentID != nil {
|
||||||
|
node["parent_id"] = *parentID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find subordinates
|
||||||
|
subRows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT id FROM hr_employee
|
||||||
|
WHERE parent_id = $1 AND COALESCE(active, true) = true
|
||||||
|
ORDER BY name`, id)
|
||||||
|
if err != nil {
|
||||||
|
node["subordinates"] = []map[string]interface{}{}
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var subIDs []int64
|
||||||
|
for subRows.Next() {
|
||||||
|
var subID int64
|
||||||
|
if err := subRows.Scan(&subID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subIDs = append(subIDs, subID)
|
||||||
|
}
|
||||||
|
subRows.Close()
|
||||||
|
|
||||||
|
var subordinates []map[string]interface{}
|
||||||
|
for _, subID := range subIDs {
|
||||||
|
subNode, err := buildNode(subID, depth+1)
|
||||||
|
if err != nil || subNode == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subordinates = append(subordinates, subNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if subordinates == nil {
|
||||||
|
subordinates = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
node["subordinates"] = subordinates
|
||||||
|
node["subordinate_count"] = len(subordinates)
|
||||||
|
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
chart, err := buildNode(empID, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.employee: get_org_chart: %w", err)
|
||||||
|
}
|
||||||
|
if chart == nil {
|
||||||
|
return map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
return chart, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// initHRDepartment registers the hr.department model.
|
// initHRDepartment registers the hr.department model.
|
||||||
@@ -149,6 +591,14 @@ func initHRDepartment() {
|
|||||||
orm.One2many("child_ids", "hr.department", "parent_id", orm.FieldOpts{String: "Child Departments"}),
|
orm.One2many("child_ids", "hr.department", "parent_id", orm.FieldOpts{String: "Child Departments"}),
|
||||||
orm.One2many("member_ids", "hr.employee", "department_id", orm.FieldOpts{String: "Members"}),
|
orm.One2many("member_ids", "hr.employee", "department_id", orm.FieldOpts{String: "Members"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||||
|
vals := make(orm.Values)
|
||||||
|
if companyID := env.CompanyID(); companyID > 0 {
|
||||||
|
vals["company_id"] = companyID
|
||||||
|
}
|
||||||
|
return vals
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// initHRJob registers the hr.job model.
|
// initHRJob registers the hr.job model.
|
||||||
@@ -174,4 +624,12 @@ func initHRJob() {
|
|||||||
}, orm.FieldOpts{String: "Status", Required: true, Default: "recruit"}),
|
}, orm.FieldOpts{String: "Status", Required: true, Default: "recruit"}),
|
||||||
orm.Text("description", orm.FieldOpts{String: "Job Description"}),
|
orm.Text("description", orm.FieldOpts{String: "Job Description"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||||
|
vals := make(orm.Values)
|
||||||
|
if companyID := env.CompanyID(); companyID > 0 {
|
||||||
|
vals["company_id"] = companyID
|
||||||
|
}
|
||||||
|
return vals
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ func initHrAttendance() {
|
|||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
attID := rs.IDs()[0]
|
attID := rs.IDs()[0]
|
||||||
var hours float64
|
var hours float64
|
||||||
env.Tx().QueryRow(env.Ctx(),
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT COALESCE(EXTRACT(EPOCH FROM (check_out - check_in)) / 3600.0, 0)
|
`SELECT COALESCE(EXTRACT(EPOCH FROM (COALESCE(check_out, NOW()) - check_in)) / 3600.0, 0)
|
||||||
FROM hr_attendance WHERE id = $1 AND check_out IS NOT NULL`, attID,
|
FROM hr_attendance WHERE id = $1`, attID,
|
||||||
).Scan(&hours)
|
).Scan(&hours); err != nil {
|
||||||
|
return orm.Values{"worked_hours": float64(0)}, nil
|
||||||
|
}
|
||||||
return orm.Values{"worked_hours": hours}, nil
|
return orm.Values{"worked_hours": hours}, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "odoo-go/pkg/orm"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
// initHrContract registers the hr.contract model.
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// initHrContract registers the hr.contract model with full lifecycle.
|
||||||
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py
|
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py
|
||||||
func initHrContract() {
|
func initHrContract() {
|
||||||
m := orm.NewModel("hr.contract", orm.ModelOpts{
|
m := orm.NewModel("hr.contract", orm.ModelOpts{
|
||||||
@@ -10,22 +15,383 @@ func initHrContract() {
|
|||||||
Order: "date_start desc",
|
Order: "date_start desc",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// -- Core Fields --
|
||||||
m.AddFields(
|
m.AddFields(
|
||||||
orm.Char("name", orm.FieldOpts{String: "Contract Reference", Required: true}),
|
orm.Char("name", orm.FieldOpts{String: "Contract Reference", Required: true}),
|
||||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
|
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
|
||||||
|
String: "Employee", Required: true, Index: true,
|
||||||
|
}),
|
||||||
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}),
|
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}),
|
||||||
orm.Many2one("job_id", "hr.job", orm.FieldOpts{String: "Job Position"}),
|
orm.Many2one("job_id", "hr.job", orm.FieldOpts{String: "Job Position"}),
|
||||||
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||||
|
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||||
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- Contract Type & Duration --
|
||||||
|
m.AddFields(
|
||||||
|
orm.Selection("contract_type", []orm.SelectionItem{
|
||||||
|
{Value: "permanent", Label: "Permanent"},
|
||||||
|
{Value: "fixed_term", Label: "Fixed Term"},
|
||||||
|
{Value: "probation", Label: "Probation"},
|
||||||
|
{Value: "freelance", Label: "Freelance / Contractor"},
|
||||||
|
{Value: "internship", Label: "Internship"},
|
||||||
|
}, orm.FieldOpts{String: "Contract Type", Default: "permanent"}),
|
||||||
orm.Date("date_start", orm.FieldOpts{String: "Start Date", Required: true}),
|
orm.Date("date_start", orm.FieldOpts{String: "Start Date", Required: true}),
|
||||||
orm.Date("date_end", orm.FieldOpts{String: "End Date"}),
|
orm.Date("date_end", orm.FieldOpts{String: "End Date"}),
|
||||||
orm.Monetary("wage", orm.FieldOpts{String: "Wage", Required: true, CurrencyField: "currency_id"}),
|
orm.Integer("trial_period_days", orm.FieldOpts{String: "Trial Period (Days)"}),
|
||||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
orm.Date("trial_date_end", orm.FieldOpts{String: "Trial End Date"}),
|
||||||
|
orm.Integer("notice_period_days", orm.FieldOpts{
|
||||||
|
String: "Notice Period (Days)", Default: 30,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- State Machine --
|
||||||
|
m.AddFields(
|
||||||
orm.Selection("state", []orm.SelectionItem{
|
orm.Selection("state", []orm.SelectionItem{
|
||||||
{Value: "draft", Label: "New"},
|
{Value: "draft", Label: "New"},
|
||||||
{Value: "open", Label: "Running"},
|
{Value: "open", Label: "Running"},
|
||||||
|
{Value: "pending", Label: "To Renew"},
|
||||||
{Value: "close", Label: "Expired"},
|
{Value: "close", Label: "Expired"},
|
||||||
{Value: "cancel", Label: "Cancelled"},
|
{Value: "cancel", Label: "Cancelled"},
|
||||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Index: true}),
|
||||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
)
|
||||||
|
|
||||||
|
// -- Compensation --
|
||||||
|
m.AddFields(
|
||||||
|
orm.Monetary("wage", orm.FieldOpts{
|
||||||
|
String: "Wage (Gross)", Required: true, CurrencyField: "currency_id",
|
||||||
|
Help: "Gross monthly salary",
|
||||||
|
}),
|
||||||
|
orm.Selection("schedule_pay", []orm.SelectionItem{
|
||||||
|
{Value: "monthly", Label: "Monthly"},
|
||||||
|
{Value: "weekly", Label: "Weekly"},
|
||||||
|
{Value: "bi_weekly", Label: "Bi-Weekly"},
|
||||||
|
{Value: "yearly", Label: "Yearly"},
|
||||||
|
}, orm.FieldOpts{String: "Scheduled Pay", Default: "monthly"}),
|
||||||
|
orm.Monetary("wage_annual", orm.FieldOpts{
|
||||||
|
String: "Annual Wage", Compute: "_compute_wage_annual", CurrencyField: "currency_id",
|
||||||
|
}),
|
||||||
|
orm.Monetary("bonus", orm.FieldOpts{
|
||||||
|
String: "Bonus", CurrencyField: "currency_id",
|
||||||
|
}),
|
||||||
|
orm.Monetary("transport_allowance", orm.FieldOpts{
|
||||||
|
String: "Transport Allowance", CurrencyField: "currency_id",
|
||||||
|
}),
|
||||||
|
orm.Monetary("meal_allowance", orm.FieldOpts{
|
||||||
|
String: "Meal Allowance", CurrencyField: "currency_id",
|
||||||
|
}),
|
||||||
|
orm.Monetary("other_allowance", orm.FieldOpts{
|
||||||
|
String: "Other Allowance", CurrencyField: "currency_id",
|
||||||
|
}),
|
||||||
|
orm.Monetary("total_compensation", orm.FieldOpts{
|
||||||
|
String: "Total Compensation", Compute: "_compute_total_compensation", CurrencyField: "currency_id",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- Working Schedule --
|
||||||
|
m.AddFields(
|
||||||
|
orm.Many2one("resource_calendar_id", "resource.calendar", orm.FieldOpts{
|
||||||
|
String: "Working Schedule",
|
||||||
|
}),
|
||||||
|
orm.Float("hours_per_week", orm.FieldOpts{String: "Hours per Week", Default: 40.0}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- History & Links --
|
||||||
|
m.AddFields(
|
||||||
|
orm.Many2one("previous_contract_id", "hr.contract", orm.FieldOpts{
|
||||||
|
String: "Previous Contract",
|
||||||
|
}),
|
||||||
orm.Text("notes", orm.FieldOpts{String: "Notes"}),
|
orm.Text("notes", orm.FieldOpts{String: "Notes"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// -- Computed: days_remaining --
|
||||||
|
m.AddFields(
|
||||||
|
orm.Integer("days_remaining", orm.FieldOpts{
|
||||||
|
String: "Days Remaining", Compute: "_compute_days_remaining",
|
||||||
|
}),
|
||||||
|
orm.Boolean("is_expired", orm.FieldOpts{
|
||||||
|
String: "Is Expired", Compute: "_compute_is_expired",
|
||||||
|
}),
|
||||||
|
orm.Boolean("is_expiring_soon", orm.FieldOpts{
|
||||||
|
String: "Expiring Soon", Compute: "_compute_is_expiring_soon",
|
||||||
|
Help: "Contract expires within 30 days",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- Computes --
|
||||||
|
|
||||||
|
m.RegisterCompute("days_remaining", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
id := rs.IDs()[0]
|
||||||
|
var dateEnd *time.Time
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT date_end FROM hr_contract WHERE id = $1`, id).Scan(&dateEnd)
|
||||||
|
if dateEnd == nil {
|
||||||
|
return orm.Values{"days_remaining": int64(0)}, nil
|
||||||
|
}
|
||||||
|
days := int64(time.Until(*dateEnd).Hours() / 24)
|
||||||
|
if days < 0 {
|
||||||
|
days = 0
|
||||||
|
}
|
||||||
|
return orm.Values{"days_remaining": days}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
m.RegisterCompute("is_expired", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
id := rs.IDs()[0]
|
||||||
|
var dateEnd *time.Time
|
||||||
|
var state string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT date_end, COALESCE(state, 'draft') FROM hr_contract WHERE id = $1`, id,
|
||||||
|
).Scan(&dateEnd, &state)
|
||||||
|
expired := dateEnd != nil && dateEnd.Before(time.Now()) && state != "close" && state != "cancel"
|
||||||
|
return orm.Values{"is_expired": expired}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
m.RegisterCompute("is_expiring_soon", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
id := rs.IDs()[0]
|
||||||
|
var dateEnd *time.Time
|
||||||
|
var state string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT date_end, COALESCE(state, 'draft') FROM hr_contract WHERE id = $1`, id,
|
||||||
|
).Scan(&dateEnd, &state)
|
||||||
|
soon := false
|
||||||
|
if dateEnd != nil && state == "open" {
|
||||||
|
daysLeft := time.Until(*dateEnd).Hours() / 24
|
||||||
|
soon = daysLeft > 0 && daysLeft <= 30
|
||||||
|
}
|
||||||
|
return orm.Values{"is_expiring_soon": soon}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
m.RegisterCompute("wage_annual", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
id := rs.IDs()[0]
|
||||||
|
var wage float64
|
||||||
|
var schedulePay string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(wage, 0), COALESCE(schedule_pay, 'monthly') FROM hr_contract WHERE id = $1`, id,
|
||||||
|
).Scan(&wage, &schedulePay)
|
||||||
|
var annual float64
|
||||||
|
switch schedulePay {
|
||||||
|
case "monthly":
|
||||||
|
annual = wage * 12
|
||||||
|
case "weekly":
|
||||||
|
annual = wage * 52
|
||||||
|
case "bi_weekly":
|
||||||
|
annual = wage * 26
|
||||||
|
case "yearly":
|
||||||
|
annual = wage
|
||||||
|
}
|
||||||
|
return orm.Values{"wage_annual": annual}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
m.RegisterCompute("total_compensation", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
id := rs.IDs()[0]
|
||||||
|
var wage, bonus, transport, meal, other float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(wage,0), COALESCE(bonus,0), COALESCE(transport_allowance,0),
|
||||||
|
COALESCE(meal_allowance,0), COALESCE(other_allowance,0)
|
||||||
|
FROM hr_contract WHERE id = $1`, id,
|
||||||
|
).Scan(&wage, &bonus, &transport, &meal, &other)
|
||||||
|
return orm.Values{"total_compensation": wage + bonus + transport + meal + other}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- State Machine Methods --
|
||||||
|
|
||||||
|
// action_open: draft/pending → open (activate contract)
|
||||||
|
m.RegisterMethod("action_open", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_contract SET state = 'open'
|
||||||
|
WHERE id = $1 AND state IN ('draft', 'pending')`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.contract: open %d: %w", id, err)
|
||||||
|
}
|
||||||
|
// Set as current contract on employee
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_employee SET contract_id = $1
|
||||||
|
WHERE id = (SELECT employee_id FROM hr_contract WHERE id = $1)`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.contract: update employee contract link %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_pending: open → pending (mark for renewal)
|
||||||
|
m.RegisterMethod("action_pending", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_contract SET state = 'pending' WHERE id = $1 AND state = 'open'`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.contract: pending %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_close: → close (expire contract)
|
||||||
|
m.RegisterMethod("action_close", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_contract SET state = 'close' WHERE id = $1 AND state NOT IN ('cancel')`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.contract: close %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_cancel: → cancel
|
||||||
|
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_contract SET state = 'cancel'
|
||||||
|
WHERE id = $1 AND state IN ('draft', 'open', 'pending')`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.contract: cancel %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_draft: → draft (reset)
|
||||||
|
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_contract SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.contract: draft %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_renew: Create a new contract from this one (close current, create copy)
|
||||||
|
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py action_renew()
|
||||||
|
m.RegisterMethod("action_renew", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
id := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Read current contract
|
||||||
|
var employeeID, departmentID, jobID, companyID, currencyID, calendarID int64
|
||||||
|
var wage, bonus, transport, meal, other, hoursPerWeek float64
|
||||||
|
var contractType, schedulePay, name string
|
||||||
|
var noticePeriod int
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT employee_id, COALESCE(department_id,0), COALESCE(job_id,0),
|
||||||
|
COALESCE(company_id,0), COALESCE(currency_id,0),
|
||||||
|
COALESCE(resource_calendar_id,0),
|
||||||
|
COALESCE(wage,0), COALESCE(bonus,0), COALESCE(transport_allowance,0),
|
||||||
|
COALESCE(meal_allowance,0), COALESCE(other_allowance,0),
|
||||||
|
COALESCE(hours_per_week,40),
|
||||||
|
COALESCE(contract_type,'permanent'), COALESCE(schedule_pay,'monthly'),
|
||||||
|
COALESCE(name,''), COALESCE(notice_period_days,30)
|
||||||
|
FROM hr_contract WHERE id = $1`, id,
|
||||||
|
).Scan(&employeeID, &departmentID, &jobID, &companyID, ¤cyID, &calendarID,
|
||||||
|
&wage, &bonus, &transport, &meal, &other, &hoursPerWeek,
|
||||||
|
&contractType, &schedulePay, &name, ¬icePeriod)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.contract: read for renew %d: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close current contract
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_contract SET state = 'close' WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.contract: close for renewal %d: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new contract
|
||||||
|
newVals := orm.Values{
|
||||||
|
"name": name + " (Renewal)",
|
||||||
|
"employee_id": employeeID,
|
||||||
|
"date_start": time.Now().Format("2006-01-02"),
|
||||||
|
"wage": wage,
|
||||||
|
"contract_type": contractType,
|
||||||
|
"schedule_pay": schedulePay,
|
||||||
|
"notice_period_days": noticePeriod,
|
||||||
|
"bonus": bonus,
|
||||||
|
"transport_allowance": transport,
|
||||||
|
"meal_allowance": meal,
|
||||||
|
"other_allowance": other,
|
||||||
|
"hours_per_week": hoursPerWeek,
|
||||||
|
"previous_contract_id": id,
|
||||||
|
"state": "draft",
|
||||||
|
}
|
||||||
|
if departmentID > 0 {
|
||||||
|
newVals["department_id"] = departmentID
|
||||||
|
}
|
||||||
|
if jobID > 0 {
|
||||||
|
newVals["job_id"] = jobID
|
||||||
|
}
|
||||||
|
if companyID > 0 {
|
||||||
|
newVals["company_id"] = companyID
|
||||||
|
}
|
||||||
|
if currencyID > 0 {
|
||||||
|
newVals["currency_id"] = currencyID
|
||||||
|
}
|
||||||
|
if calendarID > 0 {
|
||||||
|
newVals["resource_calendar_id"] = calendarID
|
||||||
|
}
|
||||||
|
|
||||||
|
contractRS := env.Model("hr.contract")
|
||||||
|
newContract, err := contractRS.Create(newVals)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.contract: create renewal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": "hr.contract",
|
||||||
|
"res_id": newContract.ID(),
|
||||||
|
"view_mode": "form",
|
||||||
|
"views": [][]interface{}{{nil, "form"}},
|
||||||
|
"target": "current",
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- BeforeWrite: State Guard --
|
||||||
|
m.BeforeWrite = orm.StateGuard("hr_contract", "state IN ('close', 'cancel')",
|
||||||
|
[]string{"write_uid", "write_date", "state", "active"},
|
||||||
|
"cannot modify closed/cancelled contracts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// initHrContractCron registers the contract expiration check cron job.
|
||||||
|
// Should be called after initHrContract and cron system is ready.
|
||||||
|
func initHrContractCron() {
|
||||||
|
m := orm.ExtendModel("hr.contract")
|
||||||
|
|
||||||
|
// _cron_check_expiring: Auto-close expired contracts, set pending for expiring soon.
|
||||||
|
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py _cron_check_expiring()
|
||||||
|
m.RegisterMethod("_cron_check_expiring", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
|
// Close expired contracts
|
||||||
|
result, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_contract SET state = 'close'
|
||||||
|
WHERE state = 'open' AND date_end IS NOT NULL AND date_end < $1`, today)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.contract: cron close expired: %w", err)
|
||||||
|
}
|
||||||
|
closed := result.RowsAffected()
|
||||||
|
|
||||||
|
// Mark contracts expiring within 30 days as pending
|
||||||
|
thirtyDays := time.Now().AddDate(0, 0, 30).Format("2006-01-02")
|
||||||
|
result2, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_contract SET state = 'pending'
|
||||||
|
WHERE state = 'open' AND date_end IS NOT NULL
|
||||||
|
AND date_end >= $1 AND date_end <= $2`, today, thirtyDays)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.contract: cron mark pending: %w", err)
|
||||||
|
}
|
||||||
|
pending := result2.RowsAffected()
|
||||||
|
|
||||||
|
if closed > 0 || pending > 0 {
|
||||||
|
fmt.Printf("hr.contract cron: closed %d, marked pending %d\n", closed, pending)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "odoo-go/pkg/orm"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
// initHrExpense registers the hr.expense and hr.expense.sheet models.
|
// initHrExpense registers the hr.expense and hr.expense.sheet models.
|
||||||
// Mirrors: odoo/addons/hr_expense/models/hr_expense.py
|
// Mirrors: odoo/addons/hr_expense/models/hr_expense.py
|
||||||
@@ -35,10 +40,63 @@ func initHrExpense() {
|
|||||||
orm.Binary("receipt", orm.FieldOpts{String: "Receipt"}),
|
orm.Binary("receipt", orm.FieldOpts{String: "Receipt"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
orm.NewModel("hr.expense.sheet", orm.ModelOpts{
|
// -- Expense Methods --
|
||||||
|
|
||||||
|
// action_submit: draft → reported
|
||||||
|
exp := orm.Registry.Get("hr.expense")
|
||||||
|
if exp != nil {
|
||||||
|
exp.RegisterMethod("action_submit", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense SET state = 'reported' WHERE id = $1 AND state = 'draft'`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense: submit %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _action_validate_expense: Check that expense has amount > 0 and a receipt.
|
||||||
|
exp.RegisterMethod("action_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
var amount float64
|
||||||
|
var state string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(total_amount, 0), COALESCE(state, 'draft') FROM hr_expense WHERE id = $1`, id,
|
||||||
|
).Scan(&amount, &state)
|
||||||
|
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil, fmt.Errorf("hr.expense: expense %d has no amount", id)
|
||||||
|
}
|
||||||
|
if state != "reported" {
|
||||||
|
return nil, fmt.Errorf("hr.expense: expense %d must be submitted first (state: %s)", id, state)
|
||||||
|
}
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense SET state = 'approved' WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense: validate %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
exp.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense SET state = 'refused' WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense: refuse %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet := orm.NewModel("hr.expense.sheet", orm.ModelOpts{
|
||||||
Description: "Expense Report",
|
Description: "Expense Report",
|
||||||
Order: "create_date desc",
|
Order: "create_date desc",
|
||||||
}).AddFields(
|
})
|
||||||
|
sheet.AddFields(
|
||||||
orm.Char("name", orm.FieldOpts{String: "Report Name", Required: true}),
|
orm.Char("name", orm.FieldOpts{String: "Report Name", Required: true}),
|
||||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
|
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
|
||||||
orm.Many2one("manager_id", "hr.employee", orm.FieldOpts{String: "Manager"}),
|
orm.Many2one("manager_id", "hr.employee", orm.FieldOpts{String: "Manager"}),
|
||||||
@@ -55,5 +113,240 @@ func initHrExpense() {
|
|||||||
{Value: "cancel", Label: "Refused"},
|
{Value: "cancel", Label: "Refused"},
|
||||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||||
orm.Many2one("account_move_id", "account.move", orm.FieldOpts{String: "Journal Entry"}),
|
orm.Many2one("account_move_id", "account.move", orm.FieldOpts{String: "Journal Entry"}),
|
||||||
|
orm.Integer("expense_count", orm.FieldOpts{String: "Expense Count", Compute: "_compute_expense_count"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// _compute_total: Sum of expense amounts.
|
||||||
|
sheet.RegisterCompute("total_amount", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
id := rs.IDs()[0]
|
||||||
|
var total float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(total_amount::float8), 0) FROM hr_expense WHERE sheet_id = $1`, id,
|
||||||
|
).Scan(&total)
|
||||||
|
return orm.Values{"total_amount": total}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
sheet.RegisterCompute("expense_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
id := rs.IDs()[0]
|
||||||
|
var count int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM hr_expense WHERE sheet_id = $1`, id).Scan(&count)
|
||||||
|
return orm.Values{"expense_count": count}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- Expense Sheet Workflow Methods --
|
||||||
|
|
||||||
|
sheet.RegisterMethod("action_submit", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
// Validate: must have at least one expense line
|
||||||
|
var count int
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM hr_expense WHERE sheet_id = $1`, id).Scan(&count)
|
||||||
|
if count == 0 {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: cannot submit empty report %d", id)
|
||||||
|
}
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense_sheet SET state = 'submit' WHERE id = $1 AND state = 'draft'`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: submit %d: %w", id, err)
|
||||||
|
}
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense SET state = 'reported' WHERE sheet_id = $1 AND state = 'draft'`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: update lines for submit %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
sheet.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense_sheet SET state = 'approve' WHERE id = $1 AND state = 'submit'`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: approve %d: %w", id, err)
|
||||||
|
}
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense SET state = 'approved' WHERE sheet_id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: update lines for approve %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
sheet.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense_sheet SET state = 'cancel' WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: refuse %d: %w", id, err)
|
||||||
|
}
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense SET state = 'refused' WHERE sheet_id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: update lines for refuse %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_post: Create a journal entry (account.move) from approved expense sheet.
|
||||||
|
// Debit: expense account, Credit: payable account.
|
||||||
|
// Mirrors: odoo/addons/hr_expense/models/hr_expense_sheet.py action_sheet_move_create()
|
||||||
|
sheet.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, sheetID := range rs.IDs() {
|
||||||
|
// Validate state = approve
|
||||||
|
var state string
|
||||||
|
var employeeID int64
|
||||||
|
var companyID *int64
|
||||||
|
var currencyID *int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(state, 'draft'), employee_id,
|
||||||
|
company_id, currency_id
|
||||||
|
FROM hr_expense_sheet WHERE id = $1`, sheetID,
|
||||||
|
).Scan(&state, &employeeID, &companyID, ¤cyID); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: read %d: %w", sheetID, err)
|
||||||
|
}
|
||||||
|
if state != "approve" {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: can only post approved reports (sheet %d is %q)", sheetID, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch expense lines
|
||||||
|
expRows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT id, name, COALESCE(total_amount, 0), account_id
|
||||||
|
FROM hr_expense WHERE sheet_id = $1`, sheetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: fetch expenses for %d: %w", sheetID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type expLine struct {
|
||||||
|
id int64
|
||||||
|
name string
|
||||||
|
amount float64
|
||||||
|
accountID *int64
|
||||||
|
}
|
||||||
|
var lines []expLine
|
||||||
|
var total float64
|
||||||
|
for expRows.Next() {
|
||||||
|
var l expLine
|
||||||
|
if err := expRows.Scan(&l.id, &l.name, &l.amount, &l.accountID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, l)
|
||||||
|
total += l.amount
|
||||||
|
}
|
||||||
|
expRows.Close()
|
||||||
|
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: no expenses to post on sheet %d", sheetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get employee's home address partner for payable line
|
||||||
|
var partnerID *int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT address_home_id FROM hr_employee WHERE id = $1`, employeeID,
|
||||||
|
).Scan(&partnerID)
|
||||||
|
|
||||||
|
// Create account.move
|
||||||
|
moveVals := orm.Values{
|
||||||
|
"move_type": "in_invoice",
|
||||||
|
"state": "draft",
|
||||||
|
"date": time.Now().Format("2006-01-02"),
|
||||||
|
}
|
||||||
|
if companyID != nil {
|
||||||
|
moveVals["company_id"] = *companyID
|
||||||
|
}
|
||||||
|
if currencyID != nil {
|
||||||
|
moveVals["currency_id"] = *currencyID
|
||||||
|
}
|
||||||
|
if partnerID != nil {
|
||||||
|
moveVals["partner_id"] = *partnerID
|
||||||
|
}
|
||||||
|
|
||||||
|
moveRS := env.Model("account.move")
|
||||||
|
move, err := moveRS.Create(moveVals)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: create journal entry for %d: %w", sheetID, err)
|
||||||
|
}
|
||||||
|
moveID := move.ID()
|
||||||
|
|
||||||
|
// Create move lines: one debit line per expense, one credit (payable) line for total
|
||||||
|
for _, l := range lines {
|
||||||
|
debitVals := orm.Values{
|
||||||
|
"move_id": moveID,
|
||||||
|
"name": l.name,
|
||||||
|
"debit": l.amount,
|
||||||
|
"credit": float64(0),
|
||||||
|
}
|
||||||
|
if l.accountID != nil {
|
||||||
|
debitVals["account_id"] = *l.accountID
|
||||||
|
}
|
||||||
|
if partnerID != nil {
|
||||||
|
debitVals["partner_id"] = *partnerID
|
||||||
|
}
|
||||||
|
lineRS := env.Model("account.move.line")
|
||||||
|
if _, err := lineRS.Create(debitVals); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: create debit line: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credit line (payable) — find payable account
|
||||||
|
var payableAccID int64
|
||||||
|
cid := int64(0)
|
||||||
|
if companyID != nil {
|
||||||
|
cid = *companyID
|
||||||
|
}
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT id FROM account_account
|
||||||
|
WHERE account_type = 'liability_payable' AND company_id = $1
|
||||||
|
ORDER BY code LIMIT 1`, cid).Scan(&payableAccID)
|
||||||
|
|
||||||
|
creditVals := orm.Values{
|
||||||
|
"move_id": moveID,
|
||||||
|
"name": "Employee Expense Payable",
|
||||||
|
"debit": float64(0),
|
||||||
|
"credit": total,
|
||||||
|
}
|
||||||
|
if payableAccID > 0 {
|
||||||
|
creditVals["account_id"] = payableAccID
|
||||||
|
}
|
||||||
|
if partnerID != nil {
|
||||||
|
creditVals["partner_id"] = *partnerID
|
||||||
|
}
|
||||||
|
lineRS := env.Model("account.move.line")
|
||||||
|
if _, err := lineRS.Create(creditVals); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: create credit line: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update expense sheet state and link to move
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense_sheet SET state = 'post', account_move_id = $1 WHERE id = $2`,
|
||||||
|
moveID, sheetID); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: update state to post %d: %w", sheetID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update expense line states
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense SET state = 'done' WHERE sheet_id = $1`, sheetID); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: update expense states %d: %w", sheetID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
sheet.RegisterMethod("action_reset", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense_sheet SET state = 'draft' WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: reset %d: %w", id, err)
|
||||||
|
}
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_expense SET state = 'draft' WHERE sheet_id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.expense.sheet: update lines for reset %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "odoo-go/pkg/orm"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
// initHrLeaveType registers the hr.leave.type model.
|
// initHrLeaveType registers the hr.leave.type model.
|
||||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py
|
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py
|
||||||
@@ -52,39 +57,378 @@ func initHrLeave() {
|
|||||||
orm.Text("notes", orm.FieldOpts{String: "Reasons"}),
|
orm.Text("notes", orm.FieldOpts{String: "Reasons"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// action_approve: Manager approves leave request (first approval).
|
||||||
|
// For leave types with 'both' validation, moves to validate1 (second approval needed).
|
||||||
|
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_approve()
|
||||||
m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'validate' WHERE id = $1 AND state IN ('confirm','validate1')`, id)
|
var state string
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||||
|
).Scan(&state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||||
|
}
|
||||||
|
if state != "confirm" && state != "validate1" {
|
||||||
|
return nil, fmt.Errorf("hr.leave: can only approve leaves in 'To Approve' or 'Second Approval' state (leave %d is %q)", id, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if second approval is needed
|
||||||
|
var validationType string
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(lt.leave_validation_type, 'hr')
|
||||||
|
FROM hr_leave l
|
||||||
|
JOIN hr_leave_type lt ON lt.id = l.holiday_status_id
|
||||||
|
WHERE l.id = $1`, id,
|
||||||
|
).Scan(&validationType); err != nil {
|
||||||
|
validationType = "hr" // safe default
|
||||||
|
}
|
||||||
|
|
||||||
|
newState := "validate"
|
||||||
|
if validationType == "both" && state == "confirm" {
|
||||||
|
newState = "validate1"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_leave SET state = $1 WHERE id = $2`, newState, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave: approve leave %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// action_validate: Final validation (second approval if needed).
|
||||||
|
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_validate()
|
||||||
|
m.RegisterMethod("action_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
var state string
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||||
|
).Scan(&state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||||
|
}
|
||||||
|
if state != "confirm" && state != "validate1" {
|
||||||
|
return nil, fmt.Errorf("hr.leave: can only validate leaves in 'To Approve' or 'Second Approval' state (leave %d is %q)", id, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_leave SET state = 'validate' WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave: validate leave %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_refuse: Manager refuses leave request.
|
||||||
|
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_refuse()
|
||||||
m.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'refuse' WHERE id = $1`, id)
|
var state string
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||||
|
).Scan(&state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||||
|
}
|
||||||
|
if state == "draft" {
|
||||||
|
return nil, fmt.Errorf("hr.leave: cannot refuse a draft leave (leave %d). Submit it first", id)
|
||||||
|
}
|
||||||
|
if state == "validate" {
|
||||||
|
return nil, fmt.Errorf("hr.leave: cannot refuse an already approved leave (leave %d). Reset to draft first", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_leave SET state = 'refuse' WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave: refuse leave %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// action_draft: Reset leave to draft state.
|
||||||
|
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_draft()
|
||||||
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'draft' WHERE id = $1`, id)
|
var state string
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||||
|
).Scan(&state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||||
|
}
|
||||||
|
if state != "confirm" && state != "refuse" {
|
||||||
|
return nil, fmt.Errorf("hr.leave: can only reset to draft from 'To Approve' or 'Refused' state (leave %d is %q)", id, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_leave SET state = 'draft' WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave: reset to draft leave %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// action_confirm: Submit leave for approval (draft -> confirm).
|
||||||
|
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_confirm()
|
||||||
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'confirm' WHERE id = $1 AND state = 'draft'`, id)
|
var state string
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||||
|
).Scan(&state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||||
|
}
|
||||||
|
if state != "draft" {
|
||||||
|
return nil, fmt.Errorf("hr.leave: can only confirm draft leaves (leave %d is %q)", id, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_leave SET state = 'confirm' WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave: confirm leave %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initHrLeaveExtensions adds additional leave methods.
|
||||||
|
func initHrLeaveExtensions() {
|
||||||
|
leave := orm.ExtendModel("hr.leave")
|
||||||
|
|
||||||
|
// action_approve_batch: Approve multiple leave requests at once (manager workflow).
|
||||||
|
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py action_approve (multi)
|
||||||
|
leave.RegisterMethod("action_approve_batch", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
ids := rs.IDs()
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaves with 'both' validation: confirm → validate1 (not directly to validate)
|
||||||
|
r1, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_leave l SET state = 'validate1'
|
||||||
|
WHERE l.id = ANY($1) AND l.state = 'confirm'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM hr_leave_type lt
|
||||||
|
WHERE lt.id = l.holiday_status_id AND lt.leave_validation_type = 'both'
|
||||||
|
)`, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave: batch approve (first step): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-'both' confirm → validate, and existing validate1 → validate
|
||||||
|
// Exclude IDs that were just set to validate1 above
|
||||||
|
r2, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_leave l SET state = 'validate'
|
||||||
|
WHERE l.id = ANY($1)
|
||||||
|
AND (l.state = 'validate1'
|
||||||
|
OR (l.state = 'confirm' AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM hr_leave_type lt
|
||||||
|
WHERE lt.id = l.holiday_status_id AND lt.leave_validation_type = 'both'
|
||||||
|
)))`, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave: batch approve: %w", err)
|
||||||
|
}
|
||||||
|
count := r1.RowsAffected() + r2.RowsAffected()
|
||||||
|
return map[string]interface{}{"approved": count}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _compute_number_of_days: Auto-compute duration from date_from/date_to.
|
||||||
|
leave.RegisterCompute("number_of_days", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
id := rs.IDs()[0]
|
||||||
|
var days float64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(EXTRACT(EPOCH FROM (date_to - date_from)) / 86400.0, 0)
|
||||||
|
FROM hr_leave WHERE id = $1`, id).Scan(&days); err != nil {
|
||||||
|
return orm.Values{"number_of_days": float64(0)}, nil
|
||||||
|
}
|
||||||
|
if days < 0 {
|
||||||
|
days = 0
|
||||||
|
}
|
||||||
|
return orm.Values{"number_of_days": days}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// initHrLeaveReport registers the hr.leave.report transient model.
|
||||||
|
// Mirrors: odoo/addons/hr_holidays/report/hr_leave_report.py
|
||||||
|
func initHrLeaveReport() {
|
||||||
|
m := orm.NewModel("hr.leave.report", orm.ModelOpts{
|
||||||
|
Description: "Time Off Summary Report",
|
||||||
|
Type: orm.ModelTransient,
|
||||||
|
})
|
||||||
|
m.AddFields(
|
||||||
|
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee"}),
|
||||||
|
orm.Date("date_from", orm.FieldOpts{String: "From"}),
|
||||||
|
orm.Date("date_to", orm.FieldOpts{String: "To"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// get_leave_summary: Returns leave days grouped by leave type for an employee in a date range.
|
||||||
|
// Mirrors: odoo/addons/hr_holidays/report/hr_leave_report.py
|
||||||
|
m.RegisterMethod("get_leave_summary", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
// Parse kwargs
|
||||||
|
var employeeID int64
|
||||||
|
dateFrom := "2000-01-01"
|
||||||
|
dateTo := "2099-12-31"
|
||||||
|
if len(args) > 0 {
|
||||||
|
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||||
|
if v, ok := kw["employee_id"]; ok {
|
||||||
|
switch vid := v.(type) {
|
||||||
|
case int64:
|
||||||
|
employeeID = vid
|
||||||
|
case float64:
|
||||||
|
employeeID = int64(vid)
|
||||||
|
case int:
|
||||||
|
employeeID = int64(vid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := kw["date_from"].(string); ok && v != "" {
|
||||||
|
dateFrom = v
|
||||||
|
}
|
||||||
|
if v, ok := kw["date_to"].(string); ok && v != "" {
|
||||||
|
dateTo = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if employeeID == 0 {
|
||||||
|
return nil, fmt.Errorf("hr.leave.report: employee_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approved leaves grouped by type
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT lt.id, lt.name, COALESCE(SUM(l.number_of_days), 0) AS total_days,
|
||||||
|
COUNT(l.id) AS leave_count
|
||||||
|
FROM hr_leave l
|
||||||
|
JOIN hr_leave_type lt ON lt.id = l.holiday_status_id
|
||||||
|
WHERE l.employee_id = $1
|
||||||
|
AND l.state = 'validate'
|
||||||
|
AND l.date_from::date >= $2
|
||||||
|
AND l.date_to::date <= $3
|
||||||
|
GROUP BY lt.id, lt.name
|
||||||
|
ORDER BY lt.name`, employeeID, dateFrom, dateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave.report: query leaves: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var summary []map[string]interface{}
|
||||||
|
var totalDays float64
|
||||||
|
for rows.Next() {
|
||||||
|
var typeID int64
|
||||||
|
var typeName string
|
||||||
|
var days float64
|
||||||
|
var count int
|
||||||
|
if err := rows.Scan(&typeID, &typeName, &days, &count); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary = append(summary, map[string]interface{}{
|
||||||
|
"leave_type_id": typeID,
|
||||||
|
"leave_type_name": typeName,
|
||||||
|
"total_days": days,
|
||||||
|
"leave_count": count,
|
||||||
|
})
|
||||||
|
totalDays += days
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining allocation per type
|
||||||
|
allocRows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT lt.id, lt.name,
|
||||||
|
COALESCE(SUM(a.number_of_days), 0) AS allocated
|
||||||
|
FROM hr_leave_allocation a
|
||||||
|
JOIN hr_leave_type lt ON lt.id = a.holiday_status_id
|
||||||
|
WHERE a.employee_id = $1 AND a.state = 'validate'
|
||||||
|
GROUP BY lt.id, lt.name
|
||||||
|
ORDER BY lt.name`, employeeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave.report: query allocations: %w", err)
|
||||||
|
}
|
||||||
|
defer allocRows.Close()
|
||||||
|
|
||||||
|
var allocations []map[string]interface{}
|
||||||
|
for allocRows.Next() {
|
||||||
|
var typeID int64
|
||||||
|
var typeName string
|
||||||
|
var allocated float64
|
||||||
|
if err := allocRows.Scan(&typeID, &typeName, &allocated); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allocations = append(allocations, map[string]interface{}{
|
||||||
|
"leave_type_id": typeID,
|
||||||
|
"leave_type_name": typeName,
|
||||||
|
"allocated_days": allocated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"employee_id": employeeID,
|
||||||
|
"date_from": dateFrom,
|
||||||
|
"date_to": dateTo,
|
||||||
|
"leaves": summary,
|
||||||
|
"allocations": allocations,
|
||||||
|
"total_days": totalDays,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// initHrLeaveTypeExtensions adds remaining quota computation to leave types.
|
||||||
|
func initHrLeaveTypeExtensions() {
|
||||||
|
lt := orm.ExtendModel("hr.leave.type")
|
||||||
|
|
||||||
|
lt.AddFields(
|
||||||
|
orm.Float("remaining_leaves", orm.FieldOpts{
|
||||||
|
String: "Remaining Leaves", Compute: "_compute_remaining_leaves",
|
||||||
|
Help: "Remaining leaves for current employee (allocated - taken)",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// _compute_remaining_quota: Calculate remaining leaves per type for current user's employee.
|
||||||
|
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py _compute_leaves()
|
||||||
|
lt.RegisterCompute("remaining_leaves", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
typeID := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Get current user's employee
|
||||||
|
var employeeID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT id FROM hr_employee WHERE user_id = $1 LIMIT 1`, env.UID(),
|
||||||
|
).Scan(&employeeID)
|
||||||
|
if employeeID == 0 {
|
||||||
|
return orm.Values{"remaining_leaves": float64(0)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocated days for this type (approved allocations)
|
||||||
|
var allocated float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(number_of_days), 0) FROM hr_leave_allocation
|
||||||
|
WHERE employee_id = $1 AND holiday_status_id = $2 AND state = 'validate'`,
|
||||||
|
employeeID, typeID).Scan(&allocated)
|
||||||
|
|
||||||
|
// Used days for this type (approved leaves, current fiscal year)
|
||||||
|
var used float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(number_of_days), 0) FROM hr_leave
|
||||||
|
WHERE employee_id = $1 AND holiday_status_id = $2 AND state = 'validate'
|
||||||
|
AND date_from >= date_trunc('year', CURRENT_DATE)`,
|
||||||
|
employeeID, typeID).Scan(&used)
|
||||||
|
|
||||||
|
return orm.Values{"remaining_leaves": allocated - used}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// initHrLeaveAllocation registers the hr.leave.allocation model.
|
// initHrLeaveAllocation registers the hr.leave.allocation model.
|
||||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py
|
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py
|
||||||
func initHrLeaveAllocation() {
|
func initHrLeaveAllocation() {
|
||||||
@@ -109,13 +453,123 @@ func initHrLeaveAllocation() {
|
|||||||
{Value: "regular", Label: "Regular Allocation"},
|
{Value: "regular", Label: "Regular Allocation"},
|
||||||
{Value: "accrual", Label: "Accrual Allocation"},
|
{Value: "accrual", Label: "Accrual Allocation"},
|
||||||
}, orm.FieldOpts{String: "Allocation Type", Default: "regular"}),
|
}, orm.FieldOpts{String: "Allocation Type", Default: "regular"}),
|
||||||
|
orm.Float("accrual_increment", orm.FieldOpts{
|
||||||
|
String: "Monthly Accrual Increment",
|
||||||
|
Help: "Number of days added each month for accrual allocations",
|
||||||
|
}),
|
||||||
|
orm.Date("last_accrual_date", orm.FieldOpts{
|
||||||
|
String: "Last Accrual Date",
|
||||||
|
Help: "Date when the last accrual increment was applied",
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// action_approve: Approve allocation request.
|
||||||
|
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py HrLeaveAllocation.action_approve()
|
||||||
m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave_allocation SET state = 'validate' WHERE id = $1`, id)
|
var state string
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT state FROM hr_leave_allocation WHERE id = $1`, id,
|
||||||
|
).Scan(&state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave.allocation: read state for %d: %w", id, err)
|
||||||
|
}
|
||||||
|
if state != "confirm" && state != "draft" {
|
||||||
|
return nil, fmt.Errorf("hr.leave.allocation: can only approve allocations in 'To Submit' or 'To Approve' state (allocation %d is %q)", id, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_leave_allocation SET state = 'validate' WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave.allocation: approve allocation %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_refuse: Refuse allocation request.
|
||||||
|
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py HrLeaveAllocation.action_refuse()
|
||||||
|
m.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
var state string
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT state FROM hr_leave_allocation WHERE id = $1`, id,
|
||||||
|
).Scan(&state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave.allocation: read state for %d: %w", id, err)
|
||||||
|
}
|
||||||
|
if state == "validate" {
|
||||||
|
return nil, fmt.Errorf("hr.leave.allocation: cannot refuse an already approved allocation (allocation %d)", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_leave_allocation SET state = 'refuse' WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave.allocation: refuse allocation %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initHrLeaveAccrualCron registers the accrual allocation cron method.
|
||||||
|
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py _cron_accrual_allocation()
|
||||||
|
func initHrLeaveAccrualCron() {
|
||||||
|
alloc := orm.ExtendModel("hr.leave.allocation")
|
||||||
|
|
||||||
|
// _cron_accrual_allocation: Auto-increment approved accrual-type allocations monthly.
|
||||||
|
// For each approved accrual allocation whose last_accrual_date is more than a month ago
|
||||||
|
// (or NULL), add accrual_increment days to number_of_days and update last_accrual_date.
|
||||||
|
alloc.RegisterMethod("_cron_accrual_allocation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
|
// Find all approved accrual allocations due for increment
|
||||||
|
// Due = last_accrual_date is NULL or more than 30 days ago
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT id, COALESCE(number_of_days, 0), COALESCE(accrual_increment, 0)
|
||||||
|
FROM hr_leave_allocation
|
||||||
|
WHERE state = 'validate'
|
||||||
|
AND allocation_type = 'accrual'
|
||||||
|
AND COALESCE(accrual_increment, 0) > 0
|
||||||
|
AND (last_accrual_date IS NULL
|
||||||
|
OR last_accrual_date <= CURRENT_DATE - INTERVAL '30 days')`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave.allocation: accrual cron query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type accrualRow struct {
|
||||||
|
id int64
|
||||||
|
days float64
|
||||||
|
increment float64
|
||||||
|
}
|
||||||
|
var pending []accrualRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r accrualRow
|
||||||
|
if err := rows.Scan(&r.id, &r.days, &r.increment); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending = append(pending, r)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
var updated int64
|
||||||
|
for _, r := range pending {
|
||||||
|
newDays := r.days + r.increment
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_leave_allocation
|
||||||
|
SET number_of_days = $1, last_accrual_date = $2
|
||||||
|
WHERE id = $3`, newDays, today, r.id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.leave.allocation: accrual update %d: %w", r.id, err)
|
||||||
|
}
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated > 0 {
|
||||||
|
fmt.Printf("hr.leave.allocation accrual cron: incremented %d allocations\n", updated)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"updated": updated}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
303
addons/hr/models/hr_payroll.go
Normal file
303
addons/hr/models/hr_payroll.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// initHrPayroll registers hr.salary.structure, hr.salary.rule, and hr.payslip models.
|
||||||
|
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py, hr_salary_rule.py, hr_payroll_structure.py
|
||||||
|
func initHrPayroll() {
|
||||||
|
// -- hr.salary.rule --
|
||||||
|
orm.NewModel("hr.salary.rule", orm.ModelOpts{
|
||||||
|
Description: "Salary Rule",
|
||||||
|
Order: "sequence, id",
|
||||||
|
}).AddFields(
|
||||||
|
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||||
|
orm.Char("code", orm.FieldOpts{String: "Code", Required: true}),
|
||||||
|
orm.Selection("category", []orm.SelectionItem{
|
||||||
|
{Value: "basic", Label: "Basic"},
|
||||||
|
{Value: "allowance", Label: "Allowance"},
|
||||||
|
{Value: "deduction", Label: "Deduction"},
|
||||||
|
{Value: "gross", Label: "Gross"},
|
||||||
|
{Value: "net", Label: "Net"},
|
||||||
|
}, orm.FieldOpts{String: "Category", Required: true, Default: "basic"}),
|
||||||
|
orm.Selection("amount_select", []orm.SelectionItem{
|
||||||
|
{Value: "fixed", Label: "Fixed Amount"},
|
||||||
|
{Value: "percentage", Label: "Percentage (%)"},
|
||||||
|
{Value: "code", Label: "Python/Go Code"},
|
||||||
|
}, orm.FieldOpts{String: "Amount Type", Required: true, Default: "fixed"}),
|
||||||
|
orm.Float("amount_fix", orm.FieldOpts{String: "Fixed Amount"}),
|
||||||
|
orm.Float("amount_percentage", orm.FieldOpts{String: "Percentage (%)"}),
|
||||||
|
orm.Char("amount_percentage_base", orm.FieldOpts{
|
||||||
|
String: "Percentage Based On",
|
||||||
|
Help: "Code of the rule whose result is used as the base for percentage calculation",
|
||||||
|
}),
|
||||||
|
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 5}),
|
||||||
|
orm.Many2one("struct_id", "hr.salary.structure", orm.FieldOpts{String: "Salary Structure"}),
|
||||||
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||||
|
orm.Text("note", orm.FieldOpts{String: "Description"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- hr.salary.structure --
|
||||||
|
orm.NewModel("hr.salary.structure", orm.ModelOpts{
|
||||||
|
Description: "Salary Structure",
|
||||||
|
Order: "name",
|
||||||
|
}).AddFields(
|
||||||
|
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||||
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||||
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||||
|
orm.One2many("rule_ids", "hr.salary.rule", "struct_id", orm.FieldOpts{String: "Salary Rules"}),
|
||||||
|
orm.Text("note", orm.FieldOpts{String: "Description"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- hr.payslip --
|
||||||
|
m := orm.NewModel("hr.payslip", orm.ModelOpts{
|
||||||
|
Description: "Pay Slip",
|
||||||
|
Order: "number desc, id desc",
|
||||||
|
})
|
||||||
|
m.AddFields(
|
||||||
|
orm.Char("name", orm.FieldOpts{String: "Description"}),
|
||||||
|
orm.Char("number", orm.FieldOpts{String: "Reference", Readonly: true}),
|
||||||
|
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
|
||||||
|
String: "Employee", Required: true, Index: true,
|
||||||
|
}),
|
||||||
|
orm.Many2one("struct_id", "hr.salary.structure", orm.FieldOpts{
|
||||||
|
String: "Salary Structure", Required: true,
|
||||||
|
}),
|
||||||
|
orm.Many2one("contract_id", "hr.contract", orm.FieldOpts{String: "Contract"}),
|
||||||
|
orm.Date("date_from", orm.FieldOpts{String: "Date From", Required: true}),
|
||||||
|
orm.Date("date_to", orm.FieldOpts{String: "Date To", Required: true}),
|
||||||
|
orm.Selection("state", []orm.SelectionItem{
|
||||||
|
{Value: "draft", Label: "Draft"},
|
||||||
|
{Value: "verify", Label: "Waiting"},
|
||||||
|
{Value: "done", Label: "Done"},
|
||||||
|
{Value: "cancel", Label: "Rejected"},
|
||||||
|
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Index: true}),
|
||||||
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||||
|
orm.Monetary("net_wage", orm.FieldOpts{
|
||||||
|
String: "Net Wage", Compute: "_compute_net_wage", Store: true, CurrencyField: "currency_id",
|
||||||
|
}),
|
||||||
|
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||||
|
orm.Text("note", orm.FieldOpts{String: "Notes"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// _compute_net_wage: Sum salary rule results stored in hr_payslip_line.
|
||||||
|
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py _compute_basic_net()
|
||||||
|
m.RegisterCompute("net_wage", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
id := rs.IDs()[0]
|
||||||
|
var net float64
|
||||||
|
// Net = sum of all line amounts (allowances positive, deductions negative)
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(
|
||||||
|
CASE WHEN category = 'deduction' THEN -amount ELSE amount END
|
||||||
|
), 0)
|
||||||
|
FROM hr_payslip_line WHERE slip_id = $1`, id,
|
||||||
|
).Scan(&net); err != nil {
|
||||||
|
return orm.Values{"net_wage": float64(0)}, nil
|
||||||
|
}
|
||||||
|
return orm.Values{"net_wage": net}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// compute_sheet: Apply salary rules from the structure to compute payslip lines.
|
||||||
|
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py compute_sheet()
|
||||||
|
m.RegisterMethod("compute_sheet", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
for _, slipID := range rs.IDs() {
|
||||||
|
// Read payslip data
|
||||||
|
var structID, contractID, employeeID int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT struct_id, COALESCE(contract_id, 0), employee_id
|
||||||
|
FROM hr_payslip WHERE id = $1`, slipID,
|
||||||
|
).Scan(&structID, &contractID, &employeeID); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.payslip: read %d: %w", slipID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch contract wage as the base
|
||||||
|
var wage float64
|
||||||
|
if contractID > 0 {
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(wage, 0) FROM hr_contract WHERE id = $1`, contractID,
|
||||||
|
).Scan(&wage)
|
||||||
|
} else {
|
||||||
|
// Try to find open contract for the employee
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(wage, 0) FROM hr_contract
|
||||||
|
WHERE employee_id = $1 AND state = 'open'
|
||||||
|
ORDER BY date_start DESC LIMIT 1`, employeeID,
|
||||||
|
).Scan(&wage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch salary rules for this structure, ordered by sequence
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT id, name, code, COALESCE(category, 'basic'),
|
||||||
|
COALESCE(amount_select, 'fixed'),
|
||||||
|
COALESCE(amount_fix, 0), COALESCE(amount_percentage, 0),
|
||||||
|
COALESCE(amount_percentage_base, ''), sequence
|
||||||
|
FROM hr_salary_rule
|
||||||
|
WHERE struct_id = $1 AND COALESCE(active, true) = true
|
||||||
|
ORDER BY sequence, id`, structID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.payslip: fetch rules for struct %d: %w", structID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type rule struct {
|
||||||
|
id int64
|
||||||
|
name, code string
|
||||||
|
category string
|
||||||
|
amountSelect string
|
||||||
|
amountFix float64
|
||||||
|
amountPct float64
|
||||||
|
amountPctBase string
|
||||||
|
sequence int
|
||||||
|
}
|
||||||
|
var rules []rule
|
||||||
|
for rows.Next() {
|
||||||
|
var r rule
|
||||||
|
if err := rows.Scan(&r.id, &r.name, &r.code, &r.category,
|
||||||
|
&r.amountSelect, &r.amountFix, &r.amountPct, &r.amountPctBase, &r.sequence); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("hr.payslip: scan rule: %w", err)
|
||||||
|
}
|
||||||
|
rules = append(rules, r)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
sort.Slice(rules, func(i, j int) bool {
|
||||||
|
if rules[i].sequence != rules[j].sequence {
|
||||||
|
return rules[i].sequence < rules[j].sequence
|
||||||
|
}
|
||||||
|
return rules[i].id < rules[j].id
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete existing lines for re-computation
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`DELETE FROM hr_payslip_line WHERE slip_id = $1`, slipID); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.payslip: clear lines for %d: %w", slipID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute each rule; track results by code for percentage-base lookups
|
||||||
|
codeResults := map[string]float64{
|
||||||
|
"BASIC": wage, // default base
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range rules {
|
||||||
|
var amount float64
|
||||||
|
switch r.amountSelect {
|
||||||
|
case "fixed":
|
||||||
|
amount = r.amountFix
|
||||||
|
case "percentage":
|
||||||
|
base := wage // default base is wage
|
||||||
|
if r.amountPctBase != "" {
|
||||||
|
if v, ok := codeResults[r.amountPctBase]; ok {
|
||||||
|
base = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
amount = base * r.amountPct / 100.0
|
||||||
|
default:
|
||||||
|
// "code" type — use fixed amount as fallback
|
||||||
|
amount = r.amountFix
|
||||||
|
}
|
||||||
|
|
||||||
|
codeResults[r.code] = amount
|
||||||
|
|
||||||
|
// Insert payslip line
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`INSERT INTO hr_payslip_line
|
||||||
|
(slip_id, name, code, category, amount, sequence, salary_rule_id,
|
||||||
|
create_uid, write_uid, create_date, write_date)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, NOW(), NOW())`,
|
||||||
|
slipID, r.name, r.code, r.category, amount, r.sequence, r.id,
|
||||||
|
env.UID(),
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.payslip: insert line for rule %s: %w", r.code, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update payslip state to verify and compute net_wage inline
|
||||||
|
var net float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(
|
||||||
|
CASE WHEN category = 'deduction' THEN -amount ELSE amount END
|
||||||
|
), 0) FROM hr_payslip_line WHERE slip_id = $1`, slipID,
|
||||||
|
).Scan(&net)
|
||||||
|
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_payslip SET state = 'verify', net_wage = $1 WHERE id = $2`,
|
||||||
|
net, slipID); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.payslip: update state to verify %d: %w", slipID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate payslip number if empty
|
||||||
|
now := time.Now()
|
||||||
|
number := fmt.Sprintf("SLIP/%04d/%02d/%05d", now.Year(), now.Month(), slipID)
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_payslip SET number = $1 WHERE id = $2 AND (number IS NULL OR number = '')`,
|
||||||
|
number, slipID)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_done: verify → done (confirm payslip)
|
||||||
|
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_payslip SET state = 'done' WHERE id = $1 AND state = 'verify'`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.payslip: action_done %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_cancel: → cancel
|
||||||
|
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_payslip SET state = 'cancel' WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.payslip: action_cancel %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_draft: cancel → draft
|
||||||
|
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE hr_payslip SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("hr.payslip: action_draft %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- hr.payslip.line — detail lines computed from salary rules --
|
||||||
|
orm.NewModel("hr.payslip.line", orm.ModelOpts{
|
||||||
|
Description: "Payslip Line",
|
||||||
|
Order: "sequence, id",
|
||||||
|
}).AddFields(
|
||||||
|
orm.Many2one("slip_id", "hr.payslip", orm.FieldOpts{
|
||||||
|
String: "Pay Slip", Required: true, OnDelete: orm.OnDeleteCascade,
|
||||||
|
}),
|
||||||
|
orm.Many2one("salary_rule_id", "hr.salary.rule", orm.FieldOpts{String: "Rule"}),
|
||||||
|
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||||
|
orm.Char("code", orm.FieldOpts{String: "Code", Required: true}),
|
||||||
|
orm.Selection("category", []orm.SelectionItem{
|
||||||
|
{Value: "basic", Label: "Basic"},
|
||||||
|
{Value: "allowance", Label: "Allowance"},
|
||||||
|
{Value: "deduction", Label: "Deduction"},
|
||||||
|
{Value: "gross", Label: "Gross"},
|
||||||
|
{Value: "net", Label: "Net"},
|
||||||
|
}, orm.FieldOpts{String: "Category"}),
|
||||||
|
orm.Float("amount", orm.FieldOpts{String: "Amount"}),
|
||||||
|
orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ func Init() {
|
|||||||
initHRJob()
|
initHRJob()
|
||||||
initHrContract()
|
initHrContract()
|
||||||
|
|
||||||
|
// Employee categories (tags)
|
||||||
|
initHrEmployeeCategory()
|
||||||
|
|
||||||
// Leave management
|
// Leave management
|
||||||
initHrLeaveType()
|
initHrLeaveType()
|
||||||
initHrLeave()
|
initHrLeave()
|
||||||
@@ -22,6 +25,25 @@ func Init() {
|
|||||||
// Skills & Resume
|
// Skills & Resume
|
||||||
initHrSkill()
|
initHrSkill()
|
||||||
|
|
||||||
|
// Payroll (salary rules, structures, payslips)
|
||||||
|
initHrPayroll()
|
||||||
|
|
||||||
|
// Employee public view (read-only subset)
|
||||||
|
initHrEmployeePublic()
|
||||||
|
|
||||||
// Extend hr.employee with links to new models (must come last)
|
// Extend hr.employee with links to new models (must come last)
|
||||||
initHrEmployeeExtensions()
|
initHrEmployeeExtensions()
|
||||||
|
|
||||||
|
// Leave extensions (batch approve, remaining quota)
|
||||||
|
initHrLeaveExtensions()
|
||||||
|
initHrLeaveTypeExtensions()
|
||||||
|
|
||||||
|
// Leave report (transient model)
|
||||||
|
initHrLeaveReport()
|
||||||
|
|
||||||
|
// Contract cron methods (after contract model is registered)
|
||||||
|
initHrContractCron()
|
||||||
|
|
||||||
|
// Accrual allocation cron (after allocation model is registered)
|
||||||
|
initHrLeaveAccrualCron()
|
||||||
}
|
}
|
||||||
|
|||||||
16
addons/mail/models/init.go
Normal file
16
addons/mail/models/init.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Package models registers all mail module models.
|
||||||
|
package models
|
||||||
|
|
||||||
|
// Init registers all models for the mail module.
|
||||||
|
// Called by the module loader in dependency order.
|
||||||
|
func Init() {
|
||||||
|
initMailMessage() // mail.message
|
||||||
|
initMailFollowers() // mail.followers
|
||||||
|
initMailActivityType() // mail.activity.type
|
||||||
|
initMailActivity() // mail.activity
|
||||||
|
initMailChannel() // mail.channel + mail.channel.member
|
||||||
|
// Extensions (must come after base models are registered)
|
||||||
|
initMailThread()
|
||||||
|
initMailChannelExtensions()
|
||||||
|
initDiscussBus()
|
||||||
|
}
|
||||||
62
addons/mail/models/mail_activity.go
Normal file
62
addons/mail/models/mail_activity.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "odoo-go/pkg/orm"
|
||||||
|
|
||||||
|
// initMailActivity registers the mail.activity model.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_activity.py MailActivity
|
||||||
|
func initMailActivity() {
|
||||||
|
m := orm.NewModel("mail.activity", orm.ModelOpts{
|
||||||
|
Description: "Activity",
|
||||||
|
Order: "date_deadline ASC",
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddFields(
|
||||||
|
orm.Char("res_model", orm.FieldOpts{
|
||||||
|
String: "Related Document Model",
|
||||||
|
Required: true,
|
||||||
|
Index: true,
|
||||||
|
}),
|
||||||
|
orm.Integer("res_id", orm.FieldOpts{
|
||||||
|
String: "Related Document ID",
|
||||||
|
Required: true,
|
||||||
|
Index: true,
|
||||||
|
}),
|
||||||
|
orm.Many2one("activity_type_id", "mail.activity.type", orm.FieldOpts{
|
||||||
|
String: "Activity Type",
|
||||||
|
OnDelete: orm.OnDeleteRestrict,
|
||||||
|
}),
|
||||||
|
orm.Char("summary", orm.FieldOpts{String: "Summary"}),
|
||||||
|
orm.Text("note", orm.FieldOpts{String: "Note"}),
|
||||||
|
orm.Date("date_deadline", orm.FieldOpts{
|
||||||
|
String: "Due Date",
|
||||||
|
Required: true,
|
||||||
|
Index: true,
|
||||||
|
}),
|
||||||
|
orm.Many2one("user_id", "res.users", orm.FieldOpts{
|
||||||
|
String: "Assigned to",
|
||||||
|
Required: true,
|
||||||
|
Index: true,
|
||||||
|
}),
|
||||||
|
orm.Selection("state", []orm.SelectionItem{
|
||||||
|
{Value: "overdue", Label: "Overdue"},
|
||||||
|
{Value: "today", Label: "Today"},
|
||||||
|
{Value: "planned", Label: "Planned"},
|
||||||
|
}, orm.FieldOpts{String: "State", Default: "planned"}),
|
||||||
|
orm.Boolean("done", orm.FieldOpts{String: "Done", Default: false}),
|
||||||
|
// Odoo 19: deadline_range for flexible deadline display
|
||||||
|
orm.Integer("deadline_range", orm.FieldOpts{
|
||||||
|
String: "Deadline Range (Days)", Help: "Number of days before/after deadline for grouping",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// action_done: mark activity as done
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_activity.py action_done
|
||||||
|
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE mail_activity SET done = true WHERE id = $1`, id)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
39
addons/mail/models/mail_activity_type.go
Normal file
39
addons/mail/models/mail_activity_type.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "odoo-go/pkg/orm"
|
||||||
|
|
||||||
|
// initMailActivityType registers the mail.activity.type model.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_activity.py MailActivityType
|
||||||
|
func initMailActivityType() {
|
||||||
|
m := orm.NewModel("mail.activity.type", orm.ModelOpts{
|
||||||
|
Description: "Activity Type",
|
||||||
|
Order: "sequence, id",
|
||||||
|
RecName: "name",
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddFields(
|
||||||
|
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||||
|
orm.Char("summary", orm.FieldOpts{String: "Default Summary"}),
|
||||||
|
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||||
|
orm.Char("res_model", orm.FieldOpts{
|
||||||
|
String: "Document Model",
|
||||||
|
Help: "Specify a model if this activity type is specific to a model, otherwise it is available for all models.",
|
||||||
|
}),
|
||||||
|
orm.Selection("category", []orm.SelectionItem{
|
||||||
|
{Value: "default", Label: "Other"},
|
||||||
|
{Value: "upload_file", Label: "Upload Document"},
|
||||||
|
}, orm.FieldOpts{String: "Action", Default: "default"}),
|
||||||
|
orm.Integer("delay_count", orm.FieldOpts{
|
||||||
|
String: "Schedule",
|
||||||
|
Default: 0,
|
||||||
|
Help: "Number of days/weeks/months before executing the action.",
|
||||||
|
}),
|
||||||
|
orm.Selection("delay_unit", []orm.SelectionItem{
|
||||||
|
{Value: "days", Label: "days"},
|
||||||
|
{Value: "weeks", Label: "weeks"},
|
||||||
|
{Value: "months", Label: "months"},
|
||||||
|
}, orm.FieldOpts{String: "Delay units", Default: "days"}),
|
||||||
|
orm.Char("icon", orm.FieldOpts{String: "Icon", Help: "Font awesome icon e.g. fa-tasks"}),
|
||||||
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||||
|
)
|
||||||
|
}
|
||||||
424
addons/mail/models/mail_channel.go
Normal file
424
addons/mail/models/mail_channel.go
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// initMailChannel registers mail.channel and mail.channel.member models.
|
||||||
|
// Mirrors: odoo/addons/mail/models/discuss_channel.py
|
||||||
|
func initMailChannel() {
|
||||||
|
m := orm.NewModel("mail.channel", orm.ModelOpts{
|
||||||
|
Description: "Discussion Channel",
|
||||||
|
Order: "name",
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddFields(
|
||||||
|
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||||
|
orm.Selection("channel_type", []orm.SelectionItem{
|
||||||
|
{Value: "channel", Label: "Channel"},
|
||||||
|
{Value: "chat", Label: "Direct Message"},
|
||||||
|
{Value: "group", Label: "Group"},
|
||||||
|
}, orm.FieldOpts{String: "Type", Default: "channel", Required: true}),
|
||||||
|
orm.Text("description", orm.FieldOpts{String: "Description"}),
|
||||||
|
orm.Many2one("create_uid", "res.users", orm.FieldOpts{String: "Created By", Readonly: true}),
|
||||||
|
orm.Boolean("public", orm.FieldOpts{String: "Public", Default: true,
|
||||||
|
Help: "If true, any internal user can join. If false, invitation only."}),
|
||||||
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||||
|
orm.Many2one("group_id", "res.groups", orm.FieldOpts{String: "Authorized Group"}),
|
||||||
|
orm.One2many("member_ids", "mail.channel.member", "channel_id", orm.FieldOpts{String: "Members"}),
|
||||||
|
orm.Integer("member_count", orm.FieldOpts{
|
||||||
|
String: "Member Count", Compute: "_compute_member_count",
|
||||||
|
}),
|
||||||
|
orm.Many2one("last_message_id", "mail.message", orm.FieldOpts{String: "Last Message"}),
|
||||||
|
orm.Datetime("last_message_date", orm.FieldOpts{String: "Last Message Date"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
m.RegisterCompute("member_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
id := rs.IDs()[0]
|
||||||
|
var count int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM mail_channel_member WHERE channel_id = $1`, id,
|
||||||
|
).Scan(&count); err != nil {
|
||||||
|
return orm.Values{"member_count": int64(0)}, nil
|
||||||
|
}
|
||||||
|
return orm.Values{"member_count": count}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_join: Current user joins the channel.
|
||||||
|
m.RegisterMethod("action_join", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
channelID := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Get current user's partner
|
||||||
|
var partnerID int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
|
||||||
|
).Scan(&partnerID); err != nil || partnerID == 0 {
|
||||||
|
return nil, fmt.Errorf("mail.channel: cannot find partner for user %d", env.UID())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check not already member
|
||||||
|
var exists bool
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM mail_channel_member
|
||||||
|
WHERE channel_id = $1 AND partner_id = $2)`, channelID, partnerID,
|
||||||
|
).Scan(&exists)
|
||||||
|
if exists {
|
||||||
|
return true, nil // Already a member
|
||||||
|
}
|
||||||
|
|
||||||
|
memberRS := env.Model("mail.channel.member")
|
||||||
|
if _, err := memberRS.Create(orm.Values{
|
||||||
|
"channel_id": channelID,
|
||||||
|
"partner_id": partnerID,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("mail.channel: join %d: %w", channelID, err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_leave: Current user leaves the channel.
|
||||||
|
m.RegisterMethod("action_leave", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
channelID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var partnerID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
|
||||||
|
).Scan(&partnerID)
|
||||||
|
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`DELETE FROM mail_channel_member WHERE channel_id = $1 AND partner_id = $2`,
|
||||||
|
channelID, partnerID); err != nil {
|
||||||
|
return nil, fmt.Errorf("mail.channel: leave %d: %w", channelID, err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// message_post: Post a message to the channel.
|
||||||
|
m.RegisterMethod("message_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
channelID := rs.IDs()[0]
|
||||||
|
|
||||||
|
body := ""
|
||||||
|
if len(args) > 0 {
|
||||||
|
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||||
|
if v, ok := kw["body"].(string); ok {
|
||||||
|
body = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
|
||||||
|
).Scan(&authorID)
|
||||||
|
|
||||||
|
var msgID int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, date, create_uid, write_uid, create_date, write_date)
|
||||||
|
VALUES ('mail.channel', $1, $2, 'comment', $3, NOW(), $4, $4, NOW(), NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
channelID, body, authorID, env.UID(),
|
||||||
|
).Scan(&msgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mail.channel: post message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update channel last message
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE mail_channel SET last_message_id = $1, last_message_date = NOW() WHERE id = $2`,
|
||||||
|
msgID, channelID)
|
||||||
|
|
||||||
|
return msgID, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// get_messages: Get messages for a channel.
|
||||||
|
m.RegisterMethod("get_messages", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
channelID := rs.IDs()[0]
|
||||||
|
|
||||||
|
limit := 50
|
||||||
|
if len(args) > 0 {
|
||||||
|
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||||
|
if v, ok := kw["limit"].(float64); ok && v > 0 {
|
||||||
|
limit = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT m.id, m.body, m.date, m.author_id, COALESCE(p.name, '')
|
||||||
|
FROM mail_message m
|
||||||
|
LEFT JOIN res_partner p ON p.id = m.author_id
|
||||||
|
WHERE m.model = 'mail.channel' AND m.res_id = $1
|
||||||
|
ORDER BY m.id DESC LIMIT $2`, channelID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mail.channel: get_messages: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var messages []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, authorID int64
|
||||||
|
var body, authorName string
|
||||||
|
var date interface{}
|
||||||
|
if err := rows.Scan(&id, &body, &date, &authorID, &authorName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"body": body,
|
||||||
|
"date": date,
|
||||||
|
}
|
||||||
|
if authorID > 0 {
|
||||||
|
msg["author_id"] = []interface{}{authorID, authorName}
|
||||||
|
} else {
|
||||||
|
msg["author_id"] = false
|
||||||
|
}
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
if messages == nil {
|
||||||
|
messages = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// channel_get: Get or create a direct message channel between current user and partner.
|
||||||
|
// Mirrors: odoo/addons/mail/models/discuss_channel.py channel_get()
|
||||||
|
m.RegisterMethod("channel_get", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
if len(args) < 1 {
|
||||||
|
return nil, fmt.Errorf("mail.channel: channel_get requires partner_ids")
|
||||||
|
}
|
||||||
|
|
||||||
|
var partnerIDs []int64
|
||||||
|
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||||
|
if pids, ok := kw["partner_ids"].([]interface{}); ok {
|
||||||
|
for _, pid := range pids {
|
||||||
|
switch v := pid.(type) {
|
||||||
|
case float64:
|
||||||
|
partnerIDs = append(partnerIDs, int64(v))
|
||||||
|
case int64:
|
||||||
|
partnerIDs = append(partnerIDs, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current user's partner
|
||||||
|
var myPartnerID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
|
||||||
|
).Scan(&myPartnerID)
|
||||||
|
if myPartnerID > 0 {
|
||||||
|
partnerIDs = append(partnerIDs, myPartnerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(partnerIDs) < 2 {
|
||||||
|
return nil, fmt.Errorf("mail.channel: need at least 2 partners for DM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if DM channel already exists between these partners
|
||||||
|
var existingID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT c.id FROM mail_channel c
|
||||||
|
WHERE c.channel_type = 'chat'
|
||||||
|
AND (SELECT COUNT(*) FROM mail_channel_member m WHERE m.channel_id = c.id) = $1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM mail_channel_member m
|
||||||
|
WHERE m.channel_id = c.id AND m.partner_id != ALL($2)
|
||||||
|
)
|
||||||
|
LIMIT 1`, len(partnerIDs), partnerIDs,
|
||||||
|
).Scan(&existingID)
|
||||||
|
|
||||||
|
if existingID > 0 {
|
||||||
|
return map[string]interface{}{"id": existingID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new DM channel
|
||||||
|
var partnerName string
|
||||||
|
for _, pid := range partnerIDs {
|
||||||
|
if pid != myPartnerID {
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(name, '') FROM res_partner WHERE id = $1`, pid,
|
||||||
|
).Scan(&partnerName)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channelRS := env.Model("mail.channel")
|
||||||
|
channel, err := channelRS.Create(orm.Values{
|
||||||
|
"name": partnerName,
|
||||||
|
"channel_type": "chat",
|
||||||
|
"public": false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mail.channel: create DM: %w", err)
|
||||||
|
}
|
||||||
|
channelID := channel.ID()
|
||||||
|
|
||||||
|
// Add members
|
||||||
|
memberRS := env.Model("mail.channel.member")
|
||||||
|
for _, pid := range partnerIDs {
|
||||||
|
memberRS.Create(orm.Values{
|
||||||
|
"channel_id": channelID,
|
||||||
|
"partner_id": pid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{"id": channelID}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- mail.channel.member --
|
||||||
|
initMailChannelMember()
|
||||||
|
}
|
||||||
|
|
||||||
|
func initMailChannelMember() {
|
||||||
|
m := orm.NewModel("mail.channel.member", orm.ModelOpts{
|
||||||
|
Description: "Channel Member",
|
||||||
|
Order: "id",
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddFields(
|
||||||
|
orm.Many2one("channel_id", "mail.channel", orm.FieldOpts{
|
||||||
|
String: "Channel", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||||
|
}),
|
||||||
|
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
|
||||||
|
String: "Partner", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||||
|
}),
|
||||||
|
orm.Datetime("last_seen_dt", orm.FieldOpts{String: "Last Seen"}),
|
||||||
|
orm.Many2one("last_seen_message_id", "mail.message", orm.FieldOpts{String: "Last Seen Message"}),
|
||||||
|
orm.Boolean("is_pinned", orm.FieldOpts{String: "Pinned", Default: true}),
|
||||||
|
orm.Boolean("is_muted", orm.FieldOpts{String: "Muted", Default: false}),
|
||||||
|
)
|
||||||
|
|
||||||
|
m.AddSQLConstraint(
|
||||||
|
"unique_channel_partner",
|
||||||
|
"UNIQUE(channel_id, partner_id)",
|
||||||
|
"A partner can only be a member of a channel once.",
|
||||||
|
)
|
||||||
|
|
||||||
|
// mark_as_read: Update last seen timestamp and message.
|
||||||
|
m.RegisterMethod("mark_as_read", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE mail_channel_member SET last_seen_dt = NOW(),
|
||||||
|
last_seen_message_id = (
|
||||||
|
SELECT MAX(m.id) FROM mail_message m
|
||||||
|
WHERE m.model = 'mail.channel'
|
||||||
|
AND m.res_id = (SELECT channel_id FROM mail_channel_member WHERE id = $1)
|
||||||
|
) WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("mail.channel.member: mark_as_read %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// initMailChannelExtensions adds unread count compute after message model is registered.
|
||||||
|
func initMailChannelExtensions() {
|
||||||
|
ch := orm.ExtendModel("mail.channel")
|
||||||
|
|
||||||
|
ch.AddFields(
|
||||||
|
orm.Integer("message_unread_count", orm.FieldOpts{
|
||||||
|
String: "Unread Messages", Compute: "_compute_message_unread_count",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
ch.RegisterCompute("message_unread_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
channelID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var partnerID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
|
||||||
|
).Scan(&partnerID)
|
||||||
|
|
||||||
|
if partnerID == 0 {
|
||||||
|
return orm.Values{"message_unread_count": int64(0)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastSeenID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(last_seen_message_id, 0) FROM mail_channel_member
|
||||||
|
WHERE channel_id = $1 AND partner_id = $2`, channelID, partnerID,
|
||||||
|
).Scan(&lastSeenID)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM mail_message
|
||||||
|
WHERE model = 'mail.channel' AND res_id = $1 AND id > $2`,
|
||||||
|
channelID, lastSeenID,
|
||||||
|
).Scan(&count)
|
||||||
|
|
||||||
|
return orm.Values{"message_unread_count": count}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// initDiscussBus registers the message bus polling endpoint logic.
|
||||||
|
func initDiscussBus() {
|
||||||
|
ch := orm.ExtendModel("mail.channel")
|
||||||
|
|
||||||
|
// channel_fetch_preview: Get channel list with last message for discuss sidebar.
|
||||||
|
// Mirrors: odoo/addons/mail/models/discuss_channel.py channel_fetch_preview()
|
||||||
|
ch.RegisterMethod("channel_fetch_preview", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
var partnerID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
|
||||||
|
).Scan(&partnerID)
|
||||||
|
|
||||||
|
if partnerID == 0 {
|
||||||
|
return []map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT c.id, c.name, c.channel_type, c.last_message_date,
|
||||||
|
COALESCE(m.body, ''), COALESCE(p.name, ''),
|
||||||
|
(SELECT COUNT(*) FROM mail_message msg
|
||||||
|
WHERE msg.model = 'mail.channel' AND msg.res_id = c.id
|
||||||
|
AND msg.id > COALESCE(cm.last_seen_message_id, 0)) AS unread
|
||||||
|
FROM mail_channel c
|
||||||
|
JOIN mail_channel_member cm ON cm.channel_id = c.id AND cm.partner_id = $1
|
||||||
|
LEFT JOIN mail_message m ON m.id = c.last_message_id
|
||||||
|
LEFT JOIN res_partner p ON p.id = m.author_id
|
||||||
|
WHERE c.active = true AND cm.is_pinned = true
|
||||||
|
ORDER BY c.last_message_date DESC NULLS LAST`, partnerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mail.channel: fetch_preview: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var channels []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var name, channelType, lastBody, lastAuthor string
|
||||||
|
var lastDate *time.Time
|
||||||
|
var unread int64
|
||||||
|
if err := rows.Scan(&id, &name, &channelType, &lastDate, &lastBody, &lastAuthor, &unread); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
channels = append(channels, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"channel_type": channelType,
|
||||||
|
"last_message": lastBody,
|
||||||
|
"last_author": lastAuthor,
|
||||||
|
"last_date": lastDate,
|
||||||
|
"unread_count": unread,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if channels == nil {
|
||||||
|
channels = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
return channels, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
31
addons/mail/models/mail_followers.go
Normal file
31
addons/mail/models/mail_followers.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "odoo-go/pkg/orm"
|
||||||
|
|
||||||
|
// initMailFollowers registers the mail.followers model.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_followers.py
|
||||||
|
func initMailFollowers() {
|
||||||
|
m := orm.NewModel("mail.followers", orm.ModelOpts{
|
||||||
|
Description: "Document Followers",
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddFields(
|
||||||
|
orm.Char("res_model", orm.FieldOpts{
|
||||||
|
String: "Related Document Model Name",
|
||||||
|
Required: true,
|
||||||
|
Index: true,
|
||||||
|
}),
|
||||||
|
orm.Integer("res_id", orm.FieldOpts{
|
||||||
|
String: "Related Document ID",
|
||||||
|
Required: true,
|
||||||
|
Index: true,
|
||||||
|
Help: "Id of the followed resource",
|
||||||
|
}),
|
||||||
|
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
|
||||||
|
String: "Related Partner",
|
||||||
|
Required: true,
|
||||||
|
Index: true,
|
||||||
|
OnDelete: orm.OnDeleteCascade,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
53
addons/mail/models/mail_message.go
Normal file
53
addons/mail/models/mail_message.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "odoo-go/pkg/orm"
|
||||||
|
|
||||||
|
// initMailMessage registers the mail.message model.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_message.py
|
||||||
|
func initMailMessage() {
|
||||||
|
m := orm.NewModel("mail.message", orm.ModelOpts{
|
||||||
|
Description: "Message",
|
||||||
|
Order: "id desc",
|
||||||
|
RecName: "subject",
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddFields(
|
||||||
|
orm.Char("subject", orm.FieldOpts{String: "Subject"}),
|
||||||
|
orm.Datetime("date", orm.FieldOpts{String: "Date"}),
|
||||||
|
orm.Text("body", orm.FieldOpts{String: "Contents"}),
|
||||||
|
orm.Selection("message_type", []orm.SelectionItem{
|
||||||
|
{Value: "comment", Label: "Comment"},
|
||||||
|
{Value: "notification", Label: "System notification"},
|
||||||
|
{Value: "email", Label: "Email"},
|
||||||
|
{Value: "user_notification", Label: "User Notification"},
|
||||||
|
}, orm.FieldOpts{String: "Type", Required: true, Default: "comment"}),
|
||||||
|
orm.Many2one("author_id", "res.partner", orm.FieldOpts{
|
||||||
|
String: "Author",
|
||||||
|
Index: true,
|
||||||
|
Help: "Author of the message.",
|
||||||
|
}),
|
||||||
|
orm.Char("model", orm.FieldOpts{
|
||||||
|
String: "Related Document Model",
|
||||||
|
Index: true,
|
||||||
|
}),
|
||||||
|
orm.Integer("res_id", orm.FieldOpts{
|
||||||
|
String: "Related Document ID",
|
||||||
|
Index: true,
|
||||||
|
}),
|
||||||
|
orm.Many2one("parent_id", "mail.message", orm.FieldOpts{
|
||||||
|
String: "Parent Message",
|
||||||
|
OnDelete: orm.OnDeleteSetNull,
|
||||||
|
}),
|
||||||
|
orm.Boolean("starred", orm.FieldOpts{String: "Starred"}),
|
||||||
|
orm.Char("email_from", orm.FieldOpts{String: "From", Help: "Email address of the sender."}),
|
||||||
|
orm.Char("reply_to", orm.FieldOpts{String: "Reply To", Help: "Reply-To address."}),
|
||||||
|
orm.Char("record_name", orm.FieldOpts{
|
||||||
|
String: "Message Record Name",
|
||||||
|
Help: "Name of the document the message is attached to.",
|
||||||
|
}),
|
||||||
|
orm.Many2many("attachment_ids", "ir.attachment", orm.FieldOpts{
|
||||||
|
String: "Attachments",
|
||||||
|
Help: "Attachments linked to this message.",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
208
addons/mail/models/mail_thread.go
Normal file
208
addons/mail/models/mail_thread.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
"odoo-go/pkg/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// initMailThread extends existing models with mail.thread functionality.
|
||||||
|
// In Python Odoo, models inherit from mail.thread to get chatter support.
|
||||||
|
// Here we use ExtendModel to add the message fields and methods.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_thread.py
|
||||||
|
func initMailThread() {
|
||||||
|
// Models that support mail.thread chatter
|
||||||
|
threadModels := []string{
|
||||||
|
"res.partner",
|
||||||
|
"sale.order",
|
||||||
|
"purchase.order",
|
||||||
|
"account.move",
|
||||||
|
"stock.picking",
|
||||||
|
"crm.lead",
|
||||||
|
"project.task",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, modelName := range threadModels {
|
||||||
|
// Check if the model is registered (module may not be loaded)
|
||||||
|
if orm.Registry.Get(modelName) == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
m := orm.ExtendModel(modelName)
|
||||||
|
|
||||||
|
m.AddFields(
|
||||||
|
orm.Integer("message_partner_ids_count", orm.FieldOpts{
|
||||||
|
String: "Followers Count",
|
||||||
|
Help: "Number of partners following this document.",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// message_post: post a new message on the record's chatter.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_thread.py message_post()
|
||||||
|
m.RegisterMethod("message_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
ids := rs.IDs()
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse kwargs from args
|
||||||
|
body := ""
|
||||||
|
messageType := "comment"
|
||||||
|
subject := ""
|
||||||
|
var attachmentIDs []int64
|
||||||
|
if len(args) > 0 {
|
||||||
|
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||||
|
if v, ok := kw["body"].(string); ok {
|
||||||
|
body = v
|
||||||
|
}
|
||||||
|
if v, ok := kw["message_type"].(string); ok {
|
||||||
|
messageType = v
|
||||||
|
}
|
||||||
|
if v, ok := kw["subject"].(string); ok {
|
||||||
|
subject = v
|
||||||
|
}
|
||||||
|
if v, ok := kw["attachment_ids"].([]interface{}); ok {
|
||||||
|
for _, aid := range v {
|
||||||
|
switch id := aid.(type) {
|
||||||
|
case float64:
|
||||||
|
attachmentIDs = append(attachmentIDs, int64(id))
|
||||||
|
case int64:
|
||||||
|
attachmentIDs = append(attachmentIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get author from current user's partner_id
|
||||||
|
var authorID int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT partner_id FROM res_users WHERE id = $1`, env.UID(),
|
||||||
|
).Scan(&authorID); err != nil {
|
||||||
|
log.Printf("warning: mail_thread message_post author lookup failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mail.message
|
||||||
|
var msgID int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, subject, date, create_uid, write_uid, create_date, write_date)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $7, NOW(), NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
rs.ModelDef().Name(), ids[0], body, messageType, authorID, subject, env.UID(),
|
||||||
|
).Scan(&msgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link attachments to the message via M2M
|
||||||
|
for _, aid := range attachmentIDs {
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`INSERT INTO mail_message_ir_attachment_rel (mail_message_id, ir_attachment_id)
|
||||||
|
VALUES ($1, $2) ON CONFLICT DO NOTHING`, msgID, aid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify followers via email
|
||||||
|
notifyFollowers(env, rs.ModelDef().Name(), ids[0], authorID, subject, body)
|
||||||
|
|
||||||
|
return msgID, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _message_get_thread: get messages for the record's chatter.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_thread.py _notify_thread()
|
||||||
|
m.RegisterMethod("_message_get_thread", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
ids := rs.IDs()
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []interface{}{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT m.id, m.body, m.message_type, m.date,
|
||||||
|
m.author_id, COALESCE(p.name, ''),
|
||||||
|
COALESCE(m.subject, ''), COALESCE(m.email_from, '')
|
||||||
|
FROM mail_message m
|
||||||
|
LEFT JOIN res_partner p ON p.id = m.author_id
|
||||||
|
WHERE m.model = $1 AND m.res_id = $2
|
||||||
|
ORDER BY m.id DESC`,
|
||||||
|
rs.ModelDef().Name(), ids[0],
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var messages []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var body, msgType, subject, emailFrom string
|
||||||
|
var date interface{}
|
||||||
|
var authorID int64
|
||||||
|
var authorName string
|
||||||
|
|
||||||
|
if err := rows.Scan(&id, &body, &msgType, &date, &authorID, &authorName, &subject, &emailFrom); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"body": body,
|
||||||
|
"message_type": msgType,
|
||||||
|
"date": date,
|
||||||
|
"subject": subject,
|
||||||
|
"email_from": emailFrom,
|
||||||
|
}
|
||||||
|
if authorID > 0 {
|
||||||
|
msg["author_id"] = []interface{}{authorID, authorName}
|
||||||
|
} else {
|
||||||
|
msg["author_id"] = false
|
||||||
|
}
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
if messages == nil {
|
||||||
|
messages = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyFollowers sends email notifications to followers of a document.
|
||||||
|
// Skips the message author to avoid self-notifications.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_thread.py _notify_thread()
|
||||||
|
func notifyFollowers(env *orm.Environment, modelName string, resID, authorID int64, subject, body string) {
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT DISTINCT p.email, p.name
|
||||||
|
FROM mail_followers f
|
||||||
|
JOIN res_partner p ON p.id = f.partner_id
|
||||||
|
WHERE f.res_model = $1 AND f.res_id = $2
|
||||||
|
AND f.partner_id != $3
|
||||||
|
AND p.email IS NOT NULL AND p.email != ''`,
|
||||||
|
modelName, resID, authorID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mail: follower lookup failed for %s/%d: %v", modelName, resID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
cfg := tools.LoadSMTPConfig()
|
||||||
|
if cfg.Host == "" {
|
||||||
|
return // SMTP not configured — skip silently
|
||||||
|
}
|
||||||
|
|
||||||
|
emailSubject := subject
|
||||||
|
if emailSubject == "" {
|
||||||
|
emailSubject = fmt.Sprintf("New message on %s", modelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var email, name string
|
||||||
|
if err := rows.Scan(&email, &name); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tools.SendEmail(cfg, email, emailSubject, body); err != nil {
|
||||||
|
log.Printf("mail: failed to notify %s (%s): %v", name, email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
addons/mail/module.go
Normal file
22
addons/mail/module.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Package mail implements Odoo's Mail/Chatter module.
|
||||||
|
// Mirrors: odoo/addons/mail/__manifest__.py
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"odoo-go/addons/mail/models"
|
||||||
|
"odoo-go/pkg/modules"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
modules.Register(&modules.Module{
|
||||||
|
Name: "mail",
|
||||||
|
Description: "Discuss",
|
||||||
|
Version: "19.0.1.0.0",
|
||||||
|
Category: "Productivity/Discuss",
|
||||||
|
Depends: []string{"base"},
|
||||||
|
Application: true,
|
||||||
|
Installable: true,
|
||||||
|
Sequence: 5,
|
||||||
|
Init: models.Init,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ func Init() {
|
|||||||
initProjectMilestone()
|
initProjectMilestone()
|
||||||
initProjectProject()
|
initProjectProject()
|
||||||
initProjectTask()
|
initProjectTask()
|
||||||
|
initProjectTaskChecklist()
|
||||||
|
initProjectSharing()
|
||||||
initProjectUpdate()
|
initProjectUpdate()
|
||||||
initProjectTimesheetExtension()
|
initProjectTimesheetExtension()
|
||||||
initTimesheetReport()
|
initTimesheetReport()
|
||||||
@@ -13,5 +15,6 @@ func Init() {
|
|||||||
initProjectTaskExtension()
|
initProjectTaskExtension()
|
||||||
initProjectMilestoneExtension()
|
initProjectMilestoneExtension()
|
||||||
initProjectTaskRecurrence()
|
initProjectTaskRecurrence()
|
||||||
|
initProjectTaskRecurrenceExtension()
|
||||||
initProjectSharingWizard()
|
initProjectSharingWizard()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ func initProjectTask() {
|
|||||||
orm.Many2one("milestone_id", "project.milestone", orm.FieldOpts{String: "Milestone"}),
|
orm.Many2one("milestone_id", "project.milestone", orm.FieldOpts{String: "Milestone"}),
|
||||||
orm.Many2many("depend_ids", "project.task", orm.FieldOpts{String: "Depends On"}),
|
orm.Many2many("depend_ids", "project.task", orm.FieldOpts{String: "Depends On"}),
|
||||||
orm.Boolean("recurring_task", orm.FieldOpts{String: "Recurrent"}),
|
orm.Boolean("recurring_task", orm.FieldOpts{String: "Recurrent"}),
|
||||||
|
orm.Datetime("planned_date_start", orm.FieldOpts{String: "Planned Start Date"}),
|
||||||
|
orm.Datetime("planned_date_end", orm.FieldOpts{String: "Planned End Date"}),
|
||||||
orm.Selection("display_type", []orm.SelectionItem{
|
orm.Selection("display_type", []orm.SelectionItem{
|
||||||
{Value: "", Label: ""},
|
{Value: "", Label: ""},
|
||||||
{Value: "line_section", Label: "Section"},
|
{Value: "line_section", Label: "Section"},
|
||||||
@@ -100,38 +102,54 @@ func initProjectTask() {
|
|||||||
task.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
task.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE project_task SET state = 'done' WHERE id = $1`, id)
|
`UPDATE project_task SET state = 'done' WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("project.task: done %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// action_cancel: mark task as cancelled
|
|
||||||
task.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
task.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE project_task SET state = 'cancel' WHERE id = $1`, id)
|
`UPDATE project_task SET state = 'cancel' WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("project.task: cancel %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// action_reopen: reopen a cancelled/done task
|
|
||||||
task.RegisterMethod("action_reopen", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
task.RegisterMethod("action_reopen", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE project_task SET state = 'open' WHERE id = $1`, id)
|
`UPDATE project_task SET state = 'open' WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("project.task: reopen %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// action_blocked: set kanban state to blocked
|
|
||||||
task.RegisterMethod("action_blocked", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
task.RegisterMethod("action_blocked", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE project_task SET kanban_state = 'blocked' WHERE id = $1`, id)
|
`UPDATE project_task SET kanban_state = 'blocked' WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("project.task: blocked %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
task.RegisterMethod("toggle_active", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE project_task SET active = NOT active WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("project.task: toggle_active %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
@@ -185,3 +203,62 @@ func initProjectTags() {
|
|||||||
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
|
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initProjectTaskChecklist registers the project.task.checklist model.
|
||||||
|
// Mirrors: odoo/addons/project/models/project_task_checklist.py
|
||||||
|
func initProjectTaskChecklist() {
|
||||||
|
m := orm.NewModel("project.task.checklist", orm.ModelOpts{
|
||||||
|
Description: "Task Checklist Item",
|
||||||
|
Order: "sequence, id",
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddFields(
|
||||||
|
orm.Many2one("task_id", "project.task", orm.FieldOpts{
|
||||||
|
String: "Task", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||||
|
}),
|
||||||
|
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||||
|
orm.Boolean("is_done", orm.FieldOpts{String: "Done", Default: false}),
|
||||||
|
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// action_toggle_done: Toggle the checklist item done status.
|
||||||
|
m.RegisterMethod("action_toggle_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, id := range rs.IDs() {
|
||||||
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE project_task_checklist SET is_done = NOT is_done WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("project.task.checklist: toggle_done %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// initProjectSharing registers the project.sharing model.
|
||||||
|
// Mirrors: odoo/addons/project/models/project_sharing.py
|
||||||
|
func initProjectSharing() {
|
||||||
|
m := orm.NewModel("project.sharing", orm.ModelOpts{
|
||||||
|
Description: "Project Sharing",
|
||||||
|
Order: "id",
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddFields(
|
||||||
|
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
|
||||||
|
String: "Partner", Required: true, Index: true,
|
||||||
|
}),
|
||||||
|
orm.Many2one("project_id", "project.project", orm.FieldOpts{
|
||||||
|
String: "Project", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||||
|
}),
|
||||||
|
orm.Selection("access_level", []orm.SelectionItem{
|
||||||
|
{Value: "read", Label: "Read"},
|
||||||
|
{Value: "edit", Label: "Edit"},
|
||||||
|
{Value: "admin", Label: "Admin"},
|
||||||
|
}, orm.FieldOpts{String: "Access Level", Required: true, Default: "read"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
m.AddSQLConstraint(
|
||||||
|
"unique_partner_project",
|
||||||
|
"UNIQUE(partner_id, project_id)",
|
||||||
|
"A partner can only have one sharing entry per project.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -56,6 +57,28 @@ func initProjectProjectExtension() {
|
|||||||
}),
|
}),
|
||||||
orm.Many2many("tag_ids", "project.tags", orm.FieldOpts{String: "Tags"}),
|
orm.Many2many("tag_ids", "project.tags", orm.FieldOpts{String: "Tags"}),
|
||||||
orm.One2many("task_ids", "project.task", "project_id", orm.FieldOpts{String: "Tasks"}),
|
orm.One2many("task_ids", "project.task", "project_id", orm.FieldOpts{String: "Tasks"}),
|
||||||
|
orm.Float("progress_percentage", orm.FieldOpts{
|
||||||
|
String: "Progress Percentage", Compute: "_compute_progress_percentage",
|
||||||
|
}),
|
||||||
|
orm.Text("workload_by_user", orm.FieldOpts{
|
||||||
|
String: "Workload by User", Compute: "_compute_workload_by_user",
|
||||||
|
}),
|
||||||
|
orm.Float("planned_budget", orm.FieldOpts{String: "Planned Budget"}),
|
||||||
|
orm.Float("remaining_budget", orm.FieldOpts{
|
||||||
|
String: "Remaining Budget", Compute: "_compute_remaining_budget",
|
||||||
|
}),
|
||||||
|
orm.One2many("sharing_ids", "project.sharing", "project_id", orm.FieldOpts{
|
||||||
|
String: "Sharing Entries",
|
||||||
|
}),
|
||||||
|
orm.Datetime("planned_date_start", orm.FieldOpts{
|
||||||
|
String: "Planned Start Date", Compute: "_compute_planned_date_start",
|
||||||
|
}),
|
||||||
|
orm.Datetime("planned_date_end", orm.FieldOpts{
|
||||||
|
String: "Planned End Date", Compute: "_compute_planned_date_end",
|
||||||
|
}),
|
||||||
|
orm.One2many("checklist_task_ids", "project.task", "project_id", orm.FieldOpts{
|
||||||
|
String: "Tasks with Checklists",
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// -- _compute_task_count --
|
// -- _compute_task_count --
|
||||||
@@ -164,6 +187,110 @@ func initProjectProjectExtension() {
|
|||||||
return orm.Values{"progress": pct}, nil
|
return orm.Values{"progress": pct}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// -- _compute_progress_percentage: done_tasks / total_tasks * 100 --
|
||||||
|
// Mirrors: odoo/addons/project/models/project_project.py Project._compute_progress_percentage()
|
||||||
|
proj.RegisterCompute("progress_percentage", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
projID := rs.IDs()[0]
|
||||||
|
var total, done int
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM project_task WHERE project_id = $1 AND active = true`, projID).Scan(&total)
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM project_task WHERE project_id = $1 AND active = true AND state = 'done'`, projID).Scan(&done)
|
||||||
|
pct := float64(0)
|
||||||
|
if total > 0 {
|
||||||
|
pct = float64(done) / float64(total) * 100
|
||||||
|
}
|
||||||
|
return orm.Values{"progress_percentage": pct}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- _compute_workload_by_user: hours planned per user from tasks --
|
||||||
|
// Mirrors: odoo/addons/project/models/project_project.py Project._compute_workload_by_user()
|
||||||
|
proj.RegisterCompute("workload_by_user", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
projID := rs.IDs()[0]
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(), `
|
||||||
|
SELECT ru.id, COALESCE(rp.name, 'Unknown') AS user_name,
|
||||||
|
COALESCE(SUM(pt.planned_hours), 0) AS planned_hours
|
||||||
|
FROM project_task pt
|
||||||
|
JOIN project_task_res_users_rel rel ON rel.project_task_id = pt.id
|
||||||
|
JOIN res_users ru ON ru.id = rel.res_users_id
|
||||||
|
LEFT JOIN res_partner rp ON rp.id = ru.partner_id
|
||||||
|
WHERE pt.project_id = $1 AND pt.active = true
|
||||||
|
GROUP BY ru.id, rp.name
|
||||||
|
ORDER BY planned_hours DESC`, projID)
|
||||||
|
if err != nil {
|
||||||
|
return orm.Values{"workload_by_user": "[]"}, nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var workload []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var userID int64
|
||||||
|
var userName string
|
||||||
|
var plannedHours float64
|
||||||
|
if err := rows.Scan(&userID, &userName, &plannedHours); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
workload = append(workload, map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"user_name": userName,
|
||||||
|
"planned_hours": plannedHours,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if workload == nil {
|
||||||
|
workload = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(workload)
|
||||||
|
return orm.Values{"workload_by_user": string(data)}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- _compute_remaining_budget: planned_budget - SUM(analytic_line.amount) --
|
||||||
|
// Mirrors: odoo/addons/project/models/project_project.py Project._compute_remaining_budget()
|
||||||
|
proj.RegisterCompute("remaining_budget", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
projID := rs.IDs()[0]
|
||||||
|
var plannedBudget float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(planned_budget, 0) FROM project_project WHERE id = $1`, projID).Scan(&plannedBudget)
|
||||||
|
var spent float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(amount), 0) FROM account_analytic_line
|
||||||
|
WHERE project_id = $1`, projID).Scan(&spent)
|
||||||
|
remaining := plannedBudget - spent
|
||||||
|
return orm.Values{"remaining_budget": remaining}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- _compute_planned_date_start: earliest planned_date_start from tasks --
|
||||||
|
// Mirrors: odoo/addons/project/models/project_project.py Project._compute_planned_date_start()
|
||||||
|
proj.RegisterCompute("planned_date_start", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
projID := rs.IDs()[0]
|
||||||
|
var startDate *time.Time
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT MIN(planned_date_start) FROM project_task
|
||||||
|
WHERE project_id = $1 AND active = true AND planned_date_start IS NOT NULL`, projID).Scan(&startDate)
|
||||||
|
if startDate != nil {
|
||||||
|
return orm.Values{"planned_date_start": startDate.Format(time.RFC3339)}, nil
|
||||||
|
}
|
||||||
|
return orm.Values{"planned_date_start": nil}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- _compute_planned_date_end: latest planned_date_end from tasks --
|
||||||
|
// Mirrors: odoo/addons/project/models/project_project.py Project._compute_planned_date_end()
|
||||||
|
proj.RegisterCompute("planned_date_end", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
projID := rs.IDs()[0]
|
||||||
|
var endDate *time.Time
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT MAX(planned_date_end) FROM project_task
|
||||||
|
WHERE project_id = $1 AND active = true AND planned_date_end IS NOT NULL`, projID).Scan(&endDate)
|
||||||
|
if endDate != nil {
|
||||||
|
return orm.Values{"planned_date_end": endDate.Format(time.RFC3339)}, nil
|
||||||
|
}
|
||||||
|
return orm.Values{"planned_date_end": nil}, nil
|
||||||
|
})
|
||||||
|
|
||||||
// action_view_tasks: Open tasks of this project.
|
// action_view_tasks: Open tasks of this project.
|
||||||
// Mirrors: odoo/addons/project/models/project_project.py Project.action_view_tasks()
|
// Mirrors: odoo/addons/project/models/project_project.py Project.action_view_tasks()
|
||||||
proj.RegisterMethod("action_view_tasks", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
proj.RegisterMethod("action_view_tasks", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
@@ -264,7 +391,17 @@ func initProjectTaskExtension() {
|
|||||||
task := orm.ExtendModel("project.task")
|
task := orm.ExtendModel("project.task")
|
||||||
|
|
||||||
// Note: parent_id, child_ids, milestone_id, tag_ids, depend_ids already exist
|
// Note: parent_id, child_ids, milestone_id, tag_ids, depend_ids already exist
|
||||||
|
// Note: planned_date_start, planned_date_end are defined in project.go
|
||||||
task.AddFields(
|
task.AddFields(
|
||||||
|
orm.One2many("checklist_ids", "project.task.checklist", "task_id", orm.FieldOpts{
|
||||||
|
String: "Checklist",
|
||||||
|
}),
|
||||||
|
orm.Integer("checklist_count", orm.FieldOpts{
|
||||||
|
String: "Checklist Items", Compute: "_compute_checklist_count",
|
||||||
|
}),
|
||||||
|
orm.Float("checklist_progress", orm.FieldOpts{
|
||||||
|
String: "Checklist Progress (%)", Compute: "_compute_checklist_progress",
|
||||||
|
}),
|
||||||
orm.Float("planned_hours", orm.FieldOpts{String: "Initially Planned Hours"}),
|
orm.Float("planned_hours", orm.FieldOpts{String: "Initially Planned Hours"}),
|
||||||
orm.Float("effective_hours", orm.FieldOpts{
|
orm.Float("effective_hours", orm.FieldOpts{
|
||||||
String: "Hours Spent", Compute: "_compute_effective_hours",
|
String: "Hours Spent", Compute: "_compute_effective_hours",
|
||||||
@@ -524,6 +661,263 @@ func initProjectTaskExtension() {
|
|||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// -- _compute_checklist_count --
|
||||||
|
task.RegisterCompute("checklist_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
taskID := rs.IDs()[0]
|
||||||
|
var count int
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM project_task_checklist WHERE task_id = $1`, taskID).Scan(&count)
|
||||||
|
return orm.Values{"checklist_count": count}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- _compute_checklist_progress --
|
||||||
|
task.RegisterCompute("checklist_progress", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
taskID := rs.IDs()[0]
|
||||||
|
var total, done int
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM project_task_checklist WHERE task_id = $1`, taskID).Scan(&total)
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM project_task_checklist WHERE task_id = $1 AND is_done = true`, taskID).Scan(&done)
|
||||||
|
pct := float64(0)
|
||||||
|
if total > 0 {
|
||||||
|
pct = float64(done) / float64(total) * 100
|
||||||
|
}
|
||||||
|
return orm.Values{"checklist_progress": pct}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// action_schedule_task: Create a calendar.event from task dates.
|
||||||
|
// Mirrors: odoo/addons/project/models/project_task.py Task.action_schedule_task()
|
||||||
|
task.RegisterMethod("action_schedule_task", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
taskID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var name string
|
||||||
|
var plannedStart, plannedEnd *time.Time
|
||||||
|
var deadline *time.Time
|
||||||
|
var projectID *int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT name, planned_date_start, planned_date_end, date_deadline, project_id
|
||||||
|
FROM project_task WHERE id = $1`, taskID).Scan(&name, &plannedStart, &plannedEnd, &deadline, &projectID)
|
||||||
|
|
||||||
|
// Determine start/stop for the calendar event
|
||||||
|
now := time.Now()
|
||||||
|
start := now
|
||||||
|
stop := now.Add(time.Hour)
|
||||||
|
|
||||||
|
if plannedStart != nil {
|
||||||
|
start = *plannedStart
|
||||||
|
}
|
||||||
|
if plannedEnd != nil {
|
||||||
|
stop = *plannedEnd
|
||||||
|
} else if deadline != nil {
|
||||||
|
stop = *deadline
|
||||||
|
}
|
||||||
|
// Ensure stop is after start
|
||||||
|
if !stop.After(start) {
|
||||||
|
stop = start.Add(time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
var eventID int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`INSERT INTO calendar_event (name, start, stop, user_id, active, state, create_uid, create_date, write_uid, write_date)
|
||||||
|
VALUES ($1, $2, $3, $4, true, 'draft', $4, NOW(), $4, NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
fmt.Sprintf("[Task] %s", name), start, stop, env.UID()).Scan(&eventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("project.task: schedule %d: %w", taskID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": "calendar.event",
|
||||||
|
"res_id": eventID,
|
||||||
|
"view_mode": "form",
|
||||||
|
"target": "current",
|
||||||
|
"name": "Scheduled Event",
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _compute_critical_path: Find tasks with dependencies that determine the longest path.
|
||||||
|
// Mirrors: odoo/addons/project/models/project_task.py Task._compute_critical_path()
|
||||||
|
// Returns the critical path as a JSON array of task IDs for the project.
|
||||||
|
task.RegisterMethod("_compute_critical_path", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
taskID := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Get the project_id for this task
|
||||||
|
var projectID *int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT project_id FROM project_task WHERE id = $1`, taskID).Scan(&projectID)
|
||||||
|
if projectID == nil {
|
||||||
|
return map[string]interface{}{"critical_path": []int64{}, "longest_duration": float64(0)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all tasks and dependencies for this project
|
||||||
|
type taskNode struct {
|
||||||
|
ID int64
|
||||||
|
PlannedHours float64
|
||||||
|
DependIDs []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
taskRows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT id, COALESCE(planned_hours, 0) FROM project_task
|
||||||
|
WHERE project_id = $1 AND active = true`, *projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("critical_path: query tasks: %w", err)
|
||||||
|
}
|
||||||
|
defer taskRows.Close()
|
||||||
|
|
||||||
|
nodes := make(map[int64]*taskNode)
|
||||||
|
for taskRows.Next() {
|
||||||
|
var n taskNode
|
||||||
|
if err := taskRows.Scan(&n.ID, &n.PlannedHours); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nodes[n.ID] = &n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load dependencies (task depends on depend_id, meaning depend_id must finish first)
|
||||||
|
depRows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT pt.id, rel.project_task_id2
|
||||||
|
FROM project_task pt
|
||||||
|
JOIN project_task_project_task_rel rel ON rel.project_task_id1 = pt.id
|
||||||
|
WHERE pt.project_id = $1 AND pt.active = true`, *projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("critical_path: query deps: %w", err)
|
||||||
|
}
|
||||||
|
defer depRows.Close()
|
||||||
|
|
||||||
|
for depRows.Next() {
|
||||||
|
var tid, depID int64
|
||||||
|
if err := depRows.Scan(&tid, &depID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, ok := nodes[tid]; ok {
|
||||||
|
n.DependIDs = append(n.DependIDs, depID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute longest path using dynamic programming (topological order)
|
||||||
|
// dist[id] = longest path ending at id
|
||||||
|
dist := make(map[int64]float64)
|
||||||
|
prev := make(map[int64]int64)
|
||||||
|
var visited map[int64]bool
|
||||||
|
|
||||||
|
var dfs func(id int64) float64
|
||||||
|
visited = make(map[int64]bool)
|
||||||
|
var inStack map[int64]bool
|
||||||
|
inStack = make(map[int64]bool)
|
||||||
|
|
||||||
|
dfs = func(id int64) float64 {
|
||||||
|
if v, ok := dist[id]; ok && visited[id] {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
visited[id] = true
|
||||||
|
inStack[id] = true
|
||||||
|
|
||||||
|
node := nodes[id]
|
||||||
|
if node == nil {
|
||||||
|
dist[id] = 0
|
||||||
|
inStack[id] = false
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
maxPredDist := float64(0)
|
||||||
|
bestPred := int64(0)
|
||||||
|
for _, depID := range node.DependIDs {
|
||||||
|
if inStack[depID] {
|
||||||
|
continue // skip circular dependencies
|
||||||
|
}
|
||||||
|
d := dfs(depID)
|
||||||
|
if d > maxPredDist {
|
||||||
|
maxPredDist = d
|
||||||
|
bestPred = depID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dist[id] = maxPredDist + node.PlannedHours
|
||||||
|
if bestPred > 0 {
|
||||||
|
prev[id] = bestPred
|
||||||
|
}
|
||||||
|
inStack[id] = false
|
||||||
|
return dist[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute distances for all tasks
|
||||||
|
for id := range nodes {
|
||||||
|
if !visited[id] {
|
||||||
|
dfs(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the task with the longest path
|
||||||
|
var maxDist float64
|
||||||
|
var endTaskID int64
|
||||||
|
for id, d := range dist {
|
||||||
|
if d > maxDist {
|
||||||
|
maxDist = d
|
||||||
|
endTaskID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the critical path
|
||||||
|
var path []int64
|
||||||
|
for cur := endTaskID; cur != 0; cur = prev[cur] {
|
||||||
|
path = append([]int64{cur}, path...)
|
||||||
|
if _, ok := prev[cur]; !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"critical_path": path,
|
||||||
|
"longest_duration": maxDist,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _check_task_dependencies: Validate no circular dependencies in depend_ids.
|
||||||
|
// Mirrors: odoo/addons/project/models/project_task.py Task._check_task_dependencies()
|
||||||
|
task.AddConstraint(func(rs *orm.Recordset) error {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, taskID := range rs.IDs() {
|
||||||
|
// BFS/DFS to detect cycles starting from this task
|
||||||
|
visited := make(map[int64]bool)
|
||||||
|
queue := []int64{taskID}
|
||||||
|
|
||||||
|
for len(queue) > 0 {
|
||||||
|
current := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
|
||||||
|
// Get dependencies of current task
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT project_task_id2 FROM project_task_project_task_rel
|
||||||
|
WHERE project_task_id1 = $1`, current)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var depID int64
|
||||||
|
if err := rows.Scan(&depID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if depID == taskID {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("circular dependency detected: task %d depends on itself through task %d", taskID, current)
|
||||||
|
}
|
||||||
|
if !visited[depID] {
|
||||||
|
visited[depID] = true
|
||||||
|
queue = append(queue, depID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// initProjectMilestoneExtension extends project.milestone with additional fields.
|
// initProjectMilestoneExtension extends project.milestone with additional fields.
|
||||||
@@ -623,6 +1017,169 @@ func initProjectTaskRecurrence() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initProjectTaskRecurrenceExtension extends project.task.recurrence with the
|
||||||
|
// _generate_recurrence_moves method that creates new task copies.
|
||||||
|
// Mirrors: odoo/addons/project/models/project_task_recurrence.py _generate_recurrence_moves()
|
||||||
|
func initProjectTaskRecurrenceExtension() {
|
||||||
|
rec := orm.ExtendModel("project.task.recurrence")
|
||||||
|
|
||||||
|
rec.RegisterMethod("_generate_recurrence_moves", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
var createdIDs []int64
|
||||||
|
|
||||||
|
for _, recID := range rs.IDs() {
|
||||||
|
// Read recurrence config
|
||||||
|
var repeatInterval string
|
||||||
|
var repeatNumber int
|
||||||
|
var repeatType string
|
||||||
|
var repeatUntil *time.Time
|
||||||
|
var recurrenceLeft int
|
||||||
|
var nextDate *time.Time
|
||||||
|
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(repeat_interval, 'weekly'), COALESCE(repeat_number, 1),
|
||||||
|
COALESCE(repeat_type, 'forever'), repeat_until,
|
||||||
|
COALESCE(recurrence_left, 0), next_recurrence_date
|
||||||
|
FROM project_task_recurrence WHERE id = $1`, recID).Scan(
|
||||||
|
&repeatInterval, &repeatNumber, &repeatType,
|
||||||
|
&repeatUntil, &recurrenceLeft, &nextDate)
|
||||||
|
|
||||||
|
// Check if we should generate
|
||||||
|
now := time.Now()
|
||||||
|
if nextDate != nil && nextDate.After(now) {
|
||||||
|
continue // Not yet time
|
||||||
|
}
|
||||||
|
if repeatType == "after" && recurrenceLeft <= 0 {
|
||||||
|
continue // No repetitions left
|
||||||
|
}
|
||||||
|
if repeatType == "until" && repeatUntil != nil && now.After(*repeatUntil) {
|
||||||
|
continue // Past end date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get template tasks (the original tasks linked to this recurrence)
|
||||||
|
taskRows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT pt.id, pt.name, pt.project_id, pt.stage_id, pt.priority,
|
||||||
|
pt.company_id, pt.planned_hours, pt.description, pt.partner_id
|
||||||
|
FROM project_task pt
|
||||||
|
JOIN project_task_recurrence_project_task_rel rel ON rel.project_task_id = pt.id
|
||||||
|
WHERE rel.project_task_recurrence_id = $1
|
||||||
|
LIMIT 10`, recID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
type templateTask struct {
|
||||||
|
ID, ProjectID, StageID, CompanyID, PartnerID int64
|
||||||
|
Name, Priority string
|
||||||
|
PlannedHours float64
|
||||||
|
Description *string
|
||||||
|
}
|
||||||
|
var templates []templateTask
|
||||||
|
for taskRows.Next() {
|
||||||
|
var t templateTask
|
||||||
|
var projID, stageID, compID, partnerID *int64
|
||||||
|
var desc *string
|
||||||
|
if err := taskRows.Scan(&t.ID, &t.Name, &projID, &stageID,
|
||||||
|
&t.Priority, &compID, &t.PlannedHours, &desc, &partnerID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if projID != nil {
|
||||||
|
t.ProjectID = *projID
|
||||||
|
}
|
||||||
|
if stageID != nil {
|
||||||
|
t.StageID = *stageID
|
||||||
|
}
|
||||||
|
if compID != nil {
|
||||||
|
t.CompanyID = *compID
|
||||||
|
}
|
||||||
|
if partnerID != nil {
|
||||||
|
t.PartnerID = *partnerID
|
||||||
|
}
|
||||||
|
t.Description = desc
|
||||||
|
templates = append(templates, t)
|
||||||
|
}
|
||||||
|
taskRows.Close()
|
||||||
|
|
||||||
|
// Create copies of each template task
|
||||||
|
for _, t := range templates {
|
||||||
|
var newID int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`INSERT INTO project_task
|
||||||
|
(name, project_id, stage_id, priority, company_id, planned_hours,
|
||||||
|
description, partner_id, state, active, recurring_task, sequence,
|
||||||
|
create_uid, create_date, write_uid, write_date)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'open', true, true, 10,
|
||||||
|
$9, NOW(), $9, NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
fmt.Sprintf("%s (copy)", t.Name),
|
||||||
|
nilIfZero(t.ProjectID), nilIfZero(t.StageID),
|
||||||
|
t.Priority, nilIfZero(t.CompanyID),
|
||||||
|
t.PlannedHours, t.Description,
|
||||||
|
nilIfZero(t.PartnerID), env.UID()).Scan(&newID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
createdIDs = append(createdIDs, newID)
|
||||||
|
|
||||||
|
// Copy assignees from original task
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`INSERT INTO project_task_res_users_rel (project_task_id, res_users_id)
|
||||||
|
SELECT $1, res_users_id FROM project_task_res_users_rel
|
||||||
|
WHERE project_task_id = $2`, newID, t.ID)
|
||||||
|
|
||||||
|
// Copy tags from original task
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`INSERT INTO project_task_project_tags_rel (project_task_id, project_tags_id)
|
||||||
|
SELECT $1, project_tags_id FROM project_task_project_tags_rel
|
||||||
|
WHERE project_task_id = $2`, newID, t.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute next recurrence date
|
||||||
|
base := now
|
||||||
|
if nextDate != nil {
|
||||||
|
base = *nextDate
|
||||||
|
}
|
||||||
|
var nextRecDate time.Time
|
||||||
|
switch repeatInterval {
|
||||||
|
case "daily":
|
||||||
|
nextRecDate = base.AddDate(0, 0, repeatNumber)
|
||||||
|
case "weekly":
|
||||||
|
nextRecDate = base.AddDate(0, 0, 7*repeatNumber)
|
||||||
|
case "monthly":
|
||||||
|
nextRecDate = base.AddDate(0, repeatNumber, 0)
|
||||||
|
case "yearly":
|
||||||
|
nextRecDate = base.AddDate(repeatNumber, 0, 0)
|
||||||
|
default:
|
||||||
|
nextRecDate = base.AddDate(0, 0, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update recurrence record
|
||||||
|
newLeft := recurrenceLeft - 1
|
||||||
|
if newLeft < 0 {
|
||||||
|
newLeft = 0
|
||||||
|
}
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE project_task_recurrence
|
||||||
|
SET next_recurrence_date = $1, recurrence_left = $2,
|
||||||
|
write_date = NOW(), write_uid = $3
|
||||||
|
WHERE id = $4`, nextRecDate, newLeft, env.UID(), recID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"created_task_ids": createdIDs,
|
||||||
|
"count": len(createdIDs),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nilIfZero returns nil if v is 0, otherwise returns v. Used for nullable FK inserts.
|
||||||
|
func nilIfZero(v int64) interface{} {
|
||||||
|
if v == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
// initProjectSharingWizard registers a wizard for sharing projects with external users.
|
// initProjectSharingWizard registers a wizard for sharing projects with external users.
|
||||||
// Mirrors: odoo/addons/project/wizard/project_share_wizard.py
|
// Mirrors: odoo/addons/project/wizard/project_share_wizard.py
|
||||||
func initProjectSharingWizard() {
|
func initProjectSharingWizard() {
|
||||||
|
|||||||
@@ -198,6 +198,94 @@ func initTimesheetReport() {
|
|||||||
}, nil
|
}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// get_timesheet_pivot_data: Pivot data by employee x task with date breakdown.
|
||||||
|
// Returns rows grouped by employee, columns grouped by task, cells contain hours per date period.
|
||||||
|
// Mirrors: odoo/addons/hr_timesheet/report/hr_timesheet_report.py pivot view
|
||||||
|
m.RegisterMethod("get_timesheet_pivot_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
// Query: employee x task x week, with hours
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(), `
|
||||||
|
SELECT COALESCE(he.name, 'Unknown') AS employee,
|
||||||
|
COALESCE(pt.name, 'No Task') AS task,
|
||||||
|
date_trunc('week', aal.date) AS week,
|
||||||
|
SUM(aal.unit_amount) AS hours
|
||||||
|
FROM account_analytic_line aal
|
||||||
|
LEFT JOIN hr_employee he ON he.id = aal.employee_id
|
||||||
|
LEFT JOIN project_task pt ON pt.id = aal.task_id
|
||||||
|
WHERE aal.project_id IS NOT NULL
|
||||||
|
GROUP BY he.name, pt.name, date_trunc('week', aal.date)
|
||||||
|
ORDER BY he.name, pt.name, week
|
||||||
|
LIMIT 500`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("timesheet_report: pivot query: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// Build pivot structure: { rows: [{employee, task, dates: [{week, hours}]}] }
|
||||||
|
type pivotCell struct {
|
||||||
|
Week string `json:"week"`
|
||||||
|
Hours float64 `json:"hours"`
|
||||||
|
}
|
||||||
|
type pivotRow struct {
|
||||||
|
Employee string `json:"employee"`
|
||||||
|
Task string `json:"task"`
|
||||||
|
Dates []pivotCell `json:"dates"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
rowMap := make(map[string]*pivotRow) // key = "employee|task"
|
||||||
|
var allWeeks []string
|
||||||
|
weekSet := make(map[string]bool)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var employee, task string
|
||||||
|
var week time.Time
|
||||||
|
var hours float64
|
||||||
|
if err := rows.Scan(&employee, &task, &week, &hours); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
weekStr := week.Format("2006-01-02")
|
||||||
|
key := employee + "|" + task
|
||||||
|
r, ok := rowMap[key]
|
||||||
|
if !ok {
|
||||||
|
r = &pivotRow{Employee: employee, Task: task}
|
||||||
|
rowMap[key] = r
|
||||||
|
}
|
||||||
|
r.Dates = append(r.Dates, pivotCell{Week: weekStr, Hours: hours})
|
||||||
|
r.Total += hours
|
||||||
|
|
||||||
|
if !weekSet[weekStr] {
|
||||||
|
weekSet[weekStr] = true
|
||||||
|
allWeeks = append(allWeeks, weekStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var pivotRows []pivotRow
|
||||||
|
for _, r := range rowMap {
|
||||||
|
pivotRows = append(pivotRows, *r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute totals per employee
|
||||||
|
empTotals := make(map[string]float64)
|
||||||
|
for _, r := range pivotRows {
|
||||||
|
empTotals[r.Employee] += r.Total
|
||||||
|
}
|
||||||
|
var empSummary []map[string]interface{}
|
||||||
|
for emp, total := range empTotals {
|
||||||
|
empSummary = append(empSummary, map[string]interface{}{
|
||||||
|
"employee": emp,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"pivot_rows": pivotRows,
|
||||||
|
"weeks": allWeeks,
|
||||||
|
"employee_totals": empSummary,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
// get_timesheet_by_week: Weekly breakdown of timesheet hours.
|
// get_timesheet_by_week: Weekly breakdown of timesheet hours.
|
||||||
m.RegisterMethod("get_timesheet_by_week", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("get_timesheet_by_week", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
func Init() {
|
func Init() {
|
||||||
initPurchaseOrder()
|
initPurchaseOrder() // also calls initPurchaseOrderLine()
|
||||||
initPurchaseOrderLine()
|
|
||||||
initPurchaseAgreement()
|
initPurchaseAgreement()
|
||||||
initPurchaseReport()
|
initPurchaseReport()
|
||||||
|
initProductSupplierInfo()
|
||||||
|
initAccountMoveLinePurchaseExtension()
|
||||||
initPurchaseOrderExtension()
|
initPurchaseOrderExtension()
|
||||||
|
initPurchaseOrderWorkflow()
|
||||||
initPurchaseOrderLineExtension()
|
initPurchaseOrderLineExtension()
|
||||||
initResPartnerPurchaseExtension()
|
initResPartnerPurchaseExtension()
|
||||||
initPurchaseOrderAmount()
|
initPurchaseOrderAmount()
|
||||||
|
initVendorLeadTime()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "odoo-go/pkg/orm"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
// initPurchaseAgreement registers purchase.requisition and purchase.requisition.line.
|
// initPurchaseAgreement registers purchase.requisition and purchase.requisition.line.
|
||||||
// Mirrors: odoo/addons/purchase_requisition/models/purchase_requisition.py
|
// Mirrors: odoo/addons/purchase_requisition/models/purchase_requisition.py
|
||||||
@@ -35,28 +39,32 @@ func initPurchaseAgreement() {
|
|||||||
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE purchase_requisition SET state = 'ongoing' WHERE id = $1 AND state = 'draft'`, id)
|
`UPDATE purchase_requisition SET state = 'ongoing' WHERE id = $1 AND state = 'draft'`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("purchase.requisition: confirm %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// action_done: close the agreement
|
|
||||||
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE purchase_requisition SET state = 'done' WHERE id = $1`, id)
|
`UPDATE purchase_requisition SET state = 'done' WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("purchase.requisition: done %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// action_cancel
|
|
||||||
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE purchase_requisition SET state = 'cancel' WHERE id = $1`, id)
|
`UPDATE purchase_requisition SET state = 'cancel' WHERE id = $1`, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("purchase.requisition: cancel %d: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,13 @@ func initPurchaseOrder() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// -- Agreement Link --
|
||||||
|
m.AddFields(
|
||||||
|
orm.Many2one("requisition_id", "purchase.requisition", orm.FieldOpts{
|
||||||
|
String: "Purchase Agreement",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
// -- Company & Currency --
|
// -- Company & Currency --
|
||||||
m.AddFields(
|
m.AddFields(
|
||||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{
|
||||||
@@ -102,6 +109,12 @@ func initPurchaseOrder() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// -- Vendor Reference & Lock --
|
||||||
|
m.AddFields(
|
||||||
|
orm.Char("partner_ref", orm.FieldOpts{String: "Vendor Reference"}),
|
||||||
|
orm.Boolean("locked", orm.FieldOpts{String: "Locked", Default: false}),
|
||||||
|
)
|
||||||
|
|
||||||
// -- Notes --
|
// -- Notes --
|
||||||
m.AddFields(
|
m.AddFields(
|
||||||
orm.Text("notes", orm.FieldOpts{String: "Terms and Conditions"}),
|
orm.Text("notes", orm.FieldOpts{String: "Terms and Conditions"}),
|
||||||
@@ -134,15 +147,84 @@ func initPurchaseOrder() {
|
|||||||
return vals
|
return vals
|
||||||
}
|
}
|
||||||
|
|
||||||
// button_confirm: draft → purchase
|
// button_confirm: Validate and confirm PO. Mirrors Python PurchaseOrder.button_confirm().
|
||||||
|
// Skips orders not in draft/sent, checks order lines have products, then either
|
||||||
|
// directly approves (single-step) or sets to "to approve" (double validation).
|
||||||
m.RegisterMethod("button_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("button_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, poID := range rs.IDs() {
|
||||||
|
var state, name string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(state, 'draft'), COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&state, &name)
|
||||||
|
if state != "draft" && state != "sent" {
|
||||||
|
continue // skip already confirmed orders (Python does same)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate: all non-section lines must have a product
|
||||||
|
var badLines int
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM purchase_order_line
|
||||||
|
WHERE order_id = $1 AND product_id IS NULL
|
||||||
|
AND COALESCE(display_type, '') NOT IN ('line_section', 'line_note')`,
|
||||||
|
poID).Scan(&badLines)
|
||||||
|
if badLines > 0 {
|
||||||
|
return nil, fmt.Errorf("purchase: some order lines are missing a product on PO %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate sequence if still default
|
||||||
|
if name == "" || name == "/" || name == "New" {
|
||||||
|
seq, err := orm.NextByCode(env, "purchase.order")
|
||||||
|
if err != nil {
|
||||||
|
name = fmt.Sprintf("PO/%d", time.Now().UnixNano()%100000)
|
||||||
|
} else {
|
||||||
|
name = seq
|
||||||
|
}
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE purchase_order SET name = $1 WHERE id = $2`, name, poID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double validation: check company setting
|
||||||
|
var poDoubleVal string
|
||||||
|
var companyID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(company_id, 0) FROM purchase_order WHERE id = $1`, poID).Scan(&companyID)
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(po_double_validation, 'one_step') FROM res_company WHERE id = $1`,
|
||||||
|
companyID).Scan(&poDoubleVal)
|
||||||
|
|
||||||
|
if poDoubleVal == "two_step" {
|
||||||
|
// Check if amount exceeds threshold
|
||||||
|
var amountTotal, threshold float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(amount_total::float8, 0) FROM purchase_order WHERE id = $1`, poID).Scan(&amountTotal)
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(po_double_validation_amount::float8, 0) FROM res_company WHERE id = $1`,
|
||||||
|
companyID).Scan(&threshold)
|
||||||
|
|
||||||
|
if amountTotal >= threshold {
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE purchase_order SET state = 'to approve' WHERE id = $1`, poID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve directly
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, poID)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// button_approve: Approve a PO that is in "to approve" state → purchase.
|
||||||
|
// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_approve()
|
||||||
|
m.RegisterMethod("button_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
var state string
|
var state string
|
||||||
env.Tx().QueryRow(env.Ctx(),
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT state FROM purchase_order WHERE id = $1`, id).Scan(&state)
|
`SELECT state FROM purchase_order WHERE id = $1`, id).Scan(&state)
|
||||||
if state != "draft" && state != "sent" {
|
if state != "to approve" {
|
||||||
return nil, fmt.Errorf("purchase: can only confirm draft orders")
|
return nil, fmt.Errorf("purchase: can only approve orders in 'to approve' state (current: %s)", state)
|
||||||
}
|
}
|
||||||
env.Tx().Exec(env.Ctx(),
|
env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, id)
|
`UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, id)
|
||||||
@@ -150,12 +232,31 @@ func initPurchaseOrder() {
|
|||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// button_cancel
|
// button_cancel: Cancel a PO. Mirrors Python PurchaseOrder.button_cancel().
|
||||||
|
// Checks: locked orders cannot be cancelled; orders with posted bills cannot be cancelled.
|
||||||
m.RegisterMethod("button_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("button_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, poID := range rs.IDs() {
|
||||||
|
var locked bool
|
||||||
|
var poName string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(locked, false), COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&locked, &poName)
|
||||||
|
if locked {
|
||||||
|
return nil, fmt.Errorf("purchase: cannot cancel locked order %s, unlock it first", poName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for non-draft/non-cancelled vendor bills
|
||||||
|
var billCount int
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM account_move
|
||||||
|
WHERE invoice_origin = $1 AND move_type = 'in_invoice'
|
||||||
|
AND state NOT IN ('draft', 'cancel')`, poName).Scan(&billCount)
|
||||||
|
if billCount > 0 {
|
||||||
|
return nil, fmt.Errorf("purchase: cannot cancel order %s, cancel related vendor bills first", poName)
|
||||||
|
}
|
||||||
|
|
||||||
env.Tx().Exec(env.Ctx(),
|
env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE purchase_order SET state = 'cancel' WHERE id = $1`, id)
|
`UPDATE purchase_order SET state = 'cancel' WHERE id = $1`, poID)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
@@ -170,36 +271,51 @@ func initPurchaseOrder() {
|
|||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// action_create_bill: Generate a vendor bill (account.move in_invoice) from a confirmed PO.
|
// action_create_bill / action_create_invoice: Generate a vendor bill from a confirmed PO.
|
||||||
// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_create_invoice()
|
// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_create_invoice()
|
||||||
m.RegisterMethod("action_create_bill", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
// Creates account.move (in_invoice) with linked invoice lines, updates qty_invoiced,
|
||||||
|
// and writes purchase_line_id on invoice lines for proper tracking.
|
||||||
|
createBillFn := func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
var billIDs []int64
|
var billIDs []int64
|
||||||
|
|
||||||
for _, poID := range rs.IDs() {
|
for _, poID := range rs.IDs() {
|
||||||
var partnerID, companyID, currencyID int64
|
var partnerID, companyID, currencyID int64
|
||||||
|
var poName string
|
||||||
|
var fiscalPosID, paymentTermID *int64
|
||||||
err := env.Tx().QueryRow(env.Ctx(),
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT partner_id, company_id, currency_id FROM purchase_order WHERE id = $1`,
|
`SELECT partner_id, company_id, currency_id, COALESCE(name, ''),
|
||||||
poID).Scan(&partnerID, &companyID, ¤cyID)
|
fiscal_position_id, payment_term_id
|
||||||
|
FROM purchase_order WHERE id = $1`,
|
||||||
|
poID).Scan(&partnerID, &companyID, ¤cyID, &poName, &fiscalPosID, &paymentTermID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("purchase: read PO %d for bill: %w", poID, err)
|
return nil, fmt.Errorf("purchase: read PO %d for bill: %w", poID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check PO state: must be in 'purchase' state
|
||||||
|
var state string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(state, 'draft') FROM purchase_order WHERE id = $1`, poID).Scan(&state)
|
||||||
|
if state != "purchase" {
|
||||||
|
return nil, fmt.Errorf("purchase: can only create bills for confirmed purchase orders (PO %s is %s)", poName, state)
|
||||||
|
}
|
||||||
|
|
||||||
// Find purchase journal
|
// Find purchase journal
|
||||||
var journalID int64
|
var journalID int64
|
||||||
env.Tx().QueryRow(env.Ctx(),
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT id FROM account_journal WHERE type = 'purchase' AND company_id = $1 LIMIT 1`,
|
`SELECT id FROM account_journal WHERE type = 'purchase' AND company_id = $1 LIMIT 1`,
|
||||||
companyID).Scan(&journalID)
|
companyID).Scan(&journalID)
|
||||||
if journalID == 0 {
|
if journalID == 0 {
|
||||||
// Fallback: first available journal
|
|
||||||
env.Tx().QueryRow(env.Ctx(),
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT id FROM account_journal WHERE company_id = $1 ORDER BY id LIMIT 1`,
|
`SELECT id FROM account_journal WHERE company_id = $1 ORDER BY id LIMIT 1`,
|
||||||
companyID).Scan(&journalID)
|
companyID).Scan(&journalID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read PO lines to generate invoice lines
|
// Read PO lines (skip section/note display types)
|
||||||
rows, err := env.Tx().Query(env.Ctx(),
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
`SELECT id, COALESCE(name,''), COALESCE(product_qty,1), COALESCE(price_unit,0), COALESCE(discount,0)
|
`SELECT id, COALESCE(name,''), COALESCE(product_qty,1), COALESCE(price_unit,0),
|
||||||
|
COALESCE(discount,0), COALESCE(qty_invoiced,0), product_id,
|
||||||
|
COALESCE(display_type, '')
|
||||||
FROM purchase_order_line
|
FROM purchase_order_line
|
||||||
WHERE order_id = $1 ORDER BY sequence, id`, poID)
|
WHERE order_id = $1 ORDER BY sequence, id`, poID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,16 +323,20 @@ func initPurchaseOrder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type poLine struct {
|
type poLine struct {
|
||||||
id int64
|
id int64
|
||||||
name string
|
name string
|
||||||
qty float64
|
qty float64
|
||||||
price float64
|
price float64
|
||||||
discount float64
|
discount float64
|
||||||
|
qtyInvoiced float64
|
||||||
|
productID *int64
|
||||||
|
displayType string
|
||||||
}
|
}
|
||||||
var lines []poLine
|
var lines []poLine
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var l poLine
|
var l poLine
|
||||||
if err := rows.Scan(&l.id, &l.name, &l.qty, &l.price, &l.discount); err != nil {
|
if err := rows.Scan(&l.id, &l.name, &l.qty, &l.price, &l.discount,
|
||||||
|
&l.qtyInvoiced, &l.productID, &l.displayType); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -224,19 +344,43 @@ func initPurchaseOrder() {
|
|||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
|
|
||||||
|
// Filter to only lines that need invoicing
|
||||||
|
var invoiceableLines []poLine
|
||||||
|
for _, l := range lines {
|
||||||
|
if l.displayType == "line_section" || l.displayType == "line_note" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qtyToInvoice := l.qty - l.qtyInvoiced
|
||||||
|
if qtyToInvoice > 0 {
|
||||||
|
invoiceableLines = append(invoiceableLines, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(invoiceableLines) == 0 {
|
||||||
|
continue // nothing to invoice on this PO
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine invoice_origin
|
||||||
|
invoiceOrigin := poName
|
||||||
|
if invoiceOrigin == "" {
|
||||||
|
invoiceOrigin = fmt.Sprintf("PO%d", poID)
|
||||||
|
}
|
||||||
|
|
||||||
// Create the vendor bill
|
// Create the vendor bill
|
||||||
var billID int64
|
var billID int64
|
||||||
err = env.Tx().QueryRow(env.Ctx(),
|
err = env.Tx().QueryRow(env.Ctx(),
|
||||||
`INSERT INTO account_move
|
`INSERT INTO account_move
|
||||||
(name, move_type, state, date, partner_id, journal_id, company_id, currency_id, invoice_origin)
|
(name, move_type, state, date, partner_id, journal_id, company_id,
|
||||||
VALUES ('/', 'in_invoice', 'draft', NOW(), $1, $2, $3, $4, $5) RETURNING id`,
|
currency_id, invoice_origin, fiscal_position_id, invoice_payment_term_id)
|
||||||
partnerID, journalID, companyID, currencyID,
|
VALUES ('/', 'in_invoice', 'draft', NOW(), $1, $2, $3, $4, $5, $6, $7)
|
||||||
fmt.Sprintf("PO%d", poID)).Scan(&billID)
|
RETURNING id`,
|
||||||
|
partnerID, journalID, companyID, currencyID, invoiceOrigin,
|
||||||
|
fiscalPosID, paymentTermID).Scan(&billID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("purchase: create bill for PO %d: %w", poID, err)
|
return nil, fmt.Errorf("purchase: create bill for PO %d: %w", poID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to generate a proper sequence name
|
// Generate sequence name
|
||||||
seq, seqErr := orm.NextByCode(env, "account.move.in_invoice")
|
seq, seqErr := orm.NextByCode(env, "account.move.in_invoice")
|
||||||
if seqErr != nil {
|
if seqErr != nil {
|
||||||
seq, seqErr = orm.NextByCode(env, "account.move")
|
seq, seqErr = orm.NextByCode(env, "account.move")
|
||||||
@@ -246,37 +390,58 @@ func initPurchaseOrder() {
|
|||||||
`UPDATE account_move SET name = $1 WHERE id = $2`, seq, billID)
|
`UPDATE account_move SET name = $1 WHERE id = $2`, seq, billID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create invoice lines for each PO line
|
// Create invoice lines for each invoiceable PO line
|
||||||
for _, l := range lines {
|
seq2 := 10
|
||||||
subtotal := l.qty * l.price * (1 - l.discount/100)
|
for _, l := range invoiceableLines {
|
||||||
|
qtyToInvoice := l.qty - l.qtyInvoiced
|
||||||
|
subtotal := qtyToInvoice * l.price * (1 - l.discount/100)
|
||||||
env.Tx().Exec(env.Ctx(),
|
env.Tx().Exec(env.Ctx(),
|
||||||
`INSERT INTO account_move_line
|
`INSERT INTO account_move_line
|
||||||
(move_id, name, quantity, price_unit, discount, debit, credit, balance,
|
(move_id, name, quantity, price_unit, discount, debit, credit, balance,
|
||||||
display_type, company_id, journal_id, account_id)
|
display_type, company_id, journal_id, sequence, purchase_line_id, product_id,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 0, $6, 'product', $7, $8,
|
account_id)
|
||||||
COALESCE((SELECT id FROM account_account WHERE company_id = $7 LIMIT 1), 1))`,
|
VALUES ($1, $2, $3, $4, $5, $6, 0, $6, 'product', $7, $8, $9, $10, $11,
|
||||||
billID, l.name, l.qty, l.price, l.discount, subtotal,
|
COALESCE((SELECT id FROM account_account
|
||||||
companyID, journalID)
|
WHERE company_id = $7 AND account_type = 'expense' LIMIT 1),
|
||||||
|
(SELECT id FROM account_account WHERE company_id = $7 LIMIT 1), 1))`,
|
||||||
|
billID, l.name, qtyToInvoice, l.price, l.discount, subtotal,
|
||||||
|
companyID, journalID, seq2, l.id, l.productID)
|
||||||
|
seq2 += 10
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update qty_invoiced on PO lines
|
// Update qty_invoiced on PO lines
|
||||||
for _, l := range lines {
|
for _, l := range invoiceableLines {
|
||||||
|
qtyToInvoice := l.qty - l.qtyInvoiced
|
||||||
env.Tx().Exec(env.Ctx(),
|
env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE purchase_order_line SET qty_invoiced = COALESCE(qty_invoiced, 0) + $1 WHERE id = $2`,
|
`UPDATE purchase_order_line SET qty_invoiced = COALESCE(qty_invoiced, 0) + $1 WHERE id = $2`,
|
||||||
l.qty, l.id)
|
qtyToInvoice, l.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
billIDs = append(billIDs, billID)
|
billIDs = append(billIDs, billID)
|
||||||
|
|
||||||
// Update PO invoice_status
|
// Recompute PO invoice_status based on lines
|
||||||
_, err = env.Tx().Exec(env.Ctx(),
|
var totalQty, totalInvoiced float64
|
||||||
`UPDATE purchase_order SET invoice_status = 'invoiced' WHERE id = $1`, poID)
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
if err != nil {
|
`SELECT COALESCE(SUM(product_qty), 0), COALESCE(SUM(qty_invoiced), 0)
|
||||||
return nil, fmt.Errorf("purchase: update invoice status for PO %d: %w", poID, err)
|
FROM purchase_order_line WHERE order_id = $1
|
||||||
|
AND COALESCE(display_type, '') NOT IN ('line_section', 'line_note')`,
|
||||||
|
poID).Scan(&totalQty, &totalInvoiced)
|
||||||
|
invStatus := "no"
|
||||||
|
if totalQty > 0 {
|
||||||
|
if totalInvoiced >= totalQty {
|
||||||
|
invStatus = "invoiced"
|
||||||
|
} else {
|
||||||
|
invStatus = "to invoice"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE purchase_order SET invoice_status = $1 WHERE id = $2`, invStatus, poID)
|
||||||
}
|
}
|
||||||
return billIDs, nil
|
return billIDs, nil
|
||||||
})
|
}
|
||||||
|
m.RegisterMethod("action_create_bill", createBillFn)
|
||||||
|
// action_create_invoice: Python-standard name for the same operation.
|
||||||
|
m.RegisterMethod("action_create_invoice", createBillFn)
|
||||||
|
|
||||||
// BeforeCreate: auto-assign sequence number
|
// BeforeCreate: auto-assign sequence number
|
||||||
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
|
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
|
||||||
@@ -293,6 +458,11 @@ func initPurchaseOrder() {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- BeforeWrite Hook: Prevent modifications on locked/cancelled orders --
|
||||||
|
m.BeforeWrite = orm.StateGuard("purchase_order", "state IN ('done', 'cancel')",
|
||||||
|
[]string{"write_uid", "write_date", "message_partner_ids_count", "locked"},
|
||||||
|
"cannot modify locked/cancelled orders")
|
||||||
|
|
||||||
// purchase.order.line — individual line items on a PO
|
// purchase.order.line — individual line items on a PO
|
||||||
initPurchaseOrderLine()
|
initPurchaseOrderLine()
|
||||||
}
|
}
|
||||||
@@ -333,6 +503,9 @@ func initPurchaseOrderLine() {
|
|||||||
orm.Monetary("price_subtotal", orm.FieldOpts{
|
orm.Monetary("price_subtotal", orm.FieldOpts{
|
||||||
String: "Subtotal", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
|
String: "Subtotal", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
|
||||||
}),
|
}),
|
||||||
|
orm.Float("price_tax", orm.FieldOpts{
|
||||||
|
String: "Tax", Compute: "_compute_amount", Store: true,
|
||||||
|
}),
|
||||||
orm.Monetary("price_total", orm.FieldOpts{
|
orm.Monetary("price_total", orm.FieldOpts{
|
||||||
String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
|
String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
|
||||||
}),
|
}),
|
||||||
@@ -341,6 +514,17 @@ func initPurchaseOrderLine() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// -- Invoice Lines & Display --
|
||||||
|
m.AddFields(
|
||||||
|
orm.One2many("invoice_lines", "account.move.line", "purchase_line_id", orm.FieldOpts{
|
||||||
|
String: "Bill Lines", Readonly: true,
|
||||||
|
}),
|
||||||
|
orm.Selection("display_type", []orm.SelectionItem{
|
||||||
|
{Value: "line_section", Label: "Section"},
|
||||||
|
{Value: "line_note", Label: "Note"},
|
||||||
|
}, orm.FieldOpts{String: "Display Type", Default: ""}),
|
||||||
|
)
|
||||||
|
|
||||||
// -- Dates --
|
// -- Dates --
|
||||||
m.AddFields(
|
m.AddFields(
|
||||||
orm.Datetime("date_planned", orm.FieldOpts{String: "Expected Arrival"}),
|
orm.Datetime("date_planned", orm.FieldOpts{String: "Expected Arrival"}),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ func Init() {
|
|||||||
initSaleOrderTemplate()
|
initSaleOrderTemplate()
|
||||||
initSaleOrderTemplateLine()
|
initSaleOrderTemplateLine()
|
||||||
initSaleOrderTemplateOption()
|
initSaleOrderTemplateOption()
|
||||||
|
initSaleOrderOption()
|
||||||
initSaleReport()
|
initSaleReport()
|
||||||
initSaleOrderWarnMsg()
|
initSaleOrderWarnMsg()
|
||||||
initSaleAdvancePaymentWizard()
|
initSaleAdvancePaymentWizard()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func initSaleOrder() {
|
|||||||
{Value: "draft", Label: "Quotation"},
|
{Value: "draft", Label: "Quotation"},
|
||||||
{Value: "sent", Label: "Quotation Sent"},
|
{Value: "sent", Label: "Quotation Sent"},
|
||||||
{Value: "sale", Label: "Sales Order"},
|
{Value: "sale", Label: "Sales Order"},
|
||||||
|
{Value: "done", Label: "Locked"},
|
||||||
{Value: "cancel", Label: "Cancelled"},
|
{Value: "cancel", Label: "Cancelled"},
|
||||||
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Readonly: true, Index: true}),
|
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Readonly: true, Index: true}),
|
||||||
)
|
)
|
||||||
@@ -253,26 +254,82 @@ func initSaleOrder() {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- BeforeWrite Hook: Prevent modifications on locked/cancelled orders --
|
||||||
|
m.BeforeWrite = orm.StateGuard("sale_order", "state IN ('done', 'cancel')",
|
||||||
|
[]string{"write_uid", "write_date", "message_partner_ids_count"},
|
||||||
|
"cannot modify locked/cancelled orders")
|
||||||
|
|
||||||
// -- Business Methods --
|
// -- Business Methods --
|
||||||
|
|
||||||
// action_confirm: draft → sale
|
// action_confirm: draft → sale
|
||||||
|
// Validates required fields, generates sequence number, sets date_order,
|
||||||
|
// creates stock picking for physical products if stock module is loaded.
|
||||||
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_confirm()
|
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_confirm()
|
||||||
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
for _, id := range rs.IDs() {
|
for _, id := range rs.IDs() {
|
||||||
var state string
|
var state, name string
|
||||||
|
var partnerID int64
|
||||||
|
var dateOrder *time.Time
|
||||||
err := env.Tx().QueryRow(env.Ctx(),
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT state FROM sale_order WHERE id = $1`, id).Scan(&state)
|
`SELECT state, COALESCE(name, '/'), COALESCE(partner_id, 0), date_order
|
||||||
|
FROM sale_order WHERE id = $1`, id,
|
||||||
|
).Scan(&state, &name, &partnerID, &dateOrder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if state != "draft" && state != "sent" {
|
if state != "draft" && state != "sent" {
|
||||||
return nil, fmt.Errorf("sale: can only confirm draft/sent orders (current: %s)", state)
|
return nil, fmt.Errorf("sale: can only confirm draft/sent orders (current: %s)", state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if partnerID == 0 {
|
||||||
|
return nil, fmt.Errorf("sale: cannot confirm order %s without a customer", name)
|
||||||
|
}
|
||||||
|
var lineCount int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM sale_order_line WHERE order_id = $1
|
||||||
|
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
||||||
|
id).Scan(&lineCount)
|
||||||
|
if lineCount == 0 {
|
||||||
|
return nil, fmt.Errorf("sale: cannot confirm order %s without order lines", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate sequence number if still default
|
||||||
|
if name == "/" || name == "" {
|
||||||
|
seq, seqErr := orm.NextByCode(env, "sale.order")
|
||||||
|
if seqErr != nil {
|
||||||
|
name = fmt.Sprintf("SO/%d", time.Now().UnixNano()%100000)
|
||||||
|
} else {
|
||||||
|
name = seq
|
||||||
|
}
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE sale_order SET name = $1 WHERE id = $2`, name, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set date_order if not set
|
||||||
|
if dateOrder == nil {
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE sale_order SET date_order = NOW() WHERE id = $1`, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm the order
|
||||||
if _, err := env.Tx().Exec(env.Ctx(),
|
if _, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE sale_order SET state = 'sale' WHERE id = $1`, id); err != nil {
|
`UPDATE sale_order SET state = 'sale' WHERE id = $1`, id); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create stock picking for physical products if stock module is loaded
|
||||||
|
if stockModel := orm.Registry.Get("stock.picking"); stockModel != nil {
|
||||||
|
soRS := env.Model("sale.order").Browse(id)
|
||||||
|
soModel := orm.Registry.Get("sale.order")
|
||||||
|
if fn, ok := soModel.Methods["action_create_delivery"]; ok {
|
||||||
|
if _, err := fn(soRS); err != nil {
|
||||||
|
// Log but don't fail confirmation if delivery creation fails
|
||||||
|
fmt.Printf("sale: warning: could not create delivery for SO %d: %v\n", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
@@ -305,7 +362,7 @@ func initSaleOrder() {
|
|||||||
).Scan(&journalID)
|
).Scan(&journalID)
|
||||||
}
|
}
|
||||||
if journalID == 0 {
|
if journalID == 0 {
|
||||||
journalID = 1 // ultimate fallback
|
return nil, fmt.Errorf("sale: no sales journal found for company %d", companyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read SO lines
|
// Read SO lines
|
||||||
@@ -431,11 +488,17 @@ func initSaleOrder() {
|
|||||||
"credit": baseAmount,
|
"credit": baseAmount,
|
||||||
"balance": -baseAmount,
|
"balance": -baseAmount,
|
||||||
}
|
}
|
||||||
if _, err := lineRS.Create(productLineVals); err != nil {
|
invLine, err := lineRS.Create(productLineVals)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("sale: create invoice product line: %w", err)
|
return nil, fmt.Errorf("sale: create invoice product line: %w", err)
|
||||||
}
|
}
|
||||||
totalCredit += baseAmount
|
totalCredit += baseAmount
|
||||||
|
|
||||||
|
// Link SO line to invoice line via M2M
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`INSERT INTO sale_order_line_invoice_rel (order_line_id, invoice_line_id)
|
||||||
|
VALUES ($1, $2) ON CONFLICT DO NOTHING`, line.id, invLine.ID())
|
||||||
|
|
||||||
// Look up taxes from SO line's tax_id M2M and compute tax lines
|
// Look up taxes from SO line's tax_id M2M and compute tax lines
|
||||||
taxRows, err := env.Tx().Query(env.Ctx(),
|
taxRows, err := env.Tx().Query(env.Ctx(),
|
||||||
`SELECT t.id, t.name, t.amount, t.amount_type, COALESCE(t.price_include, false)
|
`SELECT t.id, t.name, t.amount, t.amount_type, COALESCE(t.price_include, false)
|
||||||
@@ -549,9 +612,19 @@ func initSaleOrder() {
|
|||||||
line.qty, line.id)
|
line.qty, line.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update SO invoice_status
|
// Recompute invoice_status based on actual qty_invoiced vs qty
|
||||||
|
var totalQty, totalInvoiced float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(product_uom_qty), 0), COALESCE(SUM(qty_invoiced), 0)
|
||||||
|
FROM sale_order_line WHERE order_id = $1
|
||||||
|
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
||||||
|
soID).Scan(&totalQty, &totalInvoiced)
|
||||||
|
invStatus := "to invoice"
|
||||||
|
if totalQty > 0 && totalInvoiced >= totalQty {
|
||||||
|
invStatus = "invoiced"
|
||||||
|
}
|
||||||
env.Tx().Exec(env.Ctx(),
|
env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE sale_order SET invoice_status = 'invoiced' WHERE id = $1`, soID)
|
`UPDATE sale_order SET invoice_status = $1 WHERE id = $2`, invStatus, soID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(invoiceIDs) == 0 {
|
if len(invoiceIDs) == 0 {
|
||||||
@@ -613,6 +686,16 @@ func initSaleOrder() {
|
|||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// action_done: Lock a confirmed sale order (state → done).
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_done()
|
||||||
|
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
for _, soID := range rs.IDs() {
|
||||||
|
env.Tx().Exec(env.Ctx(), `UPDATE sale_order SET state = 'done' WHERE id = $1 AND state = 'sale'`, soID)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
// action_view_invoice: Open invoices linked to this sale order.
|
// action_view_invoice: Open invoices linked to this sale order.
|
||||||
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_view_invoice()
|
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_view_invoice()
|
||||||
m.RegisterMethod("action_view_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_view_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
@@ -959,11 +1042,24 @@ func initSaleOrderLine() {
|
|||||||
orm.Monetary("price_subtotal", orm.FieldOpts{
|
orm.Monetary("price_subtotal", orm.FieldOpts{
|
||||||
String: "Subtotal", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
|
String: "Subtotal", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
|
||||||
}),
|
}),
|
||||||
|
orm.Float("price_tax", orm.FieldOpts{
|
||||||
|
String: "Total Tax", Compute: "_compute_amount", Store: true,
|
||||||
|
}),
|
||||||
orm.Monetary("price_total", orm.FieldOpts{
|
orm.Monetary("price_total", orm.FieldOpts{
|
||||||
String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
|
String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// -- Invoice link --
|
||||||
|
m.AddFields(
|
||||||
|
orm.Many2many("invoice_line_ids", "account.move.line", orm.FieldOpts{
|
||||||
|
String: "Invoice Lines",
|
||||||
|
Relation: "sale_order_line_invoice_rel",
|
||||||
|
Column1: "order_line_id",
|
||||||
|
Column2: "invoice_line_id",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
// -- Display --
|
// -- Display --
|
||||||
m.AddFields(
|
m.AddFields(
|
||||||
orm.Selection("display_type", []orm.SelectionItem{
|
orm.Selection("display_type", []orm.SelectionItem{
|
||||||
@@ -1023,10 +1119,12 @@ func initSaleOrderLine() {
|
|||||||
|
|
||||||
return orm.Values{
|
return orm.Values{
|
||||||
"price_subtotal": subtotal,
|
"price_subtotal": subtotal,
|
||||||
|
"price_tax": taxTotal,
|
||||||
"price_total": subtotal + taxTotal,
|
"price_total": subtotal + taxTotal,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
m.RegisterCompute("price_subtotal", computeLineAmount)
|
m.RegisterCompute("price_subtotal", computeLineAmount)
|
||||||
|
m.RegisterCompute("price_tax", computeLineAmount)
|
||||||
m.RegisterCompute("price_total", computeLineAmount)
|
m.RegisterCompute("price_total", computeLineAmount)
|
||||||
|
|
||||||
// -- Delivery & Invoicing Quantities --
|
// -- Delivery & Invoicing Quantities --
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"odoo-go/pkg/orm"
|
"odoo-go/pkg/orm"
|
||||||
|
"odoo-go/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// initSaleOrderExtension extends sale.order with template support, additional workflow
|
// initSaleOrderExtension extends sale.order with template support, additional workflow
|
||||||
@@ -19,6 +23,9 @@ func initSaleOrderExtension() {
|
|||||||
orm.Many2one("sale_order_template_id", "sale.order.template", orm.FieldOpts{
|
orm.Many2one("sale_order_template_id", "sale.order.template", orm.FieldOpts{
|
||||||
String: "Quotation Template",
|
String: "Quotation Template",
|
||||||
}),
|
}),
|
||||||
|
orm.One2many("sale_order_option_ids", "sale.order.option", "order_id", orm.FieldOpts{
|
||||||
|
String: "Optional Products",
|
||||||
|
}),
|
||||||
orm.Boolean("is_expired", orm.FieldOpts{
|
orm.Boolean("is_expired", orm.FieldOpts{
|
||||||
String: "Expired", Compute: "_compute_is_expired",
|
String: "Expired", Compute: "_compute_is_expired",
|
||||||
}),
|
}),
|
||||||
@@ -52,6 +59,132 @@ func initSaleOrderExtension() {
|
|||||||
return orm.Values{"is_expired": expired}, nil
|
return orm.Values{"is_expired": expired}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// -- Amounts: amount_to_invoice, amount_invoiced --
|
||||||
|
so.AddFields(
|
||||||
|
orm.Monetary("amount_to_invoice", orm.FieldOpts{
|
||||||
|
String: "Un-invoiced Balance", Compute: "_compute_amount_to_invoice", CurrencyField: "currency_id",
|
||||||
|
}),
|
||||||
|
orm.Monetary("amount_invoiced", orm.FieldOpts{
|
||||||
|
String: "Already Invoiced", Compute: "_compute_amount_invoiced", CurrencyField: "currency_id",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// _compute_amount_invoiced: Sum of invoiced amounts across order lines.
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amount_invoiced()
|
||||||
|
so.RegisterCompute("amount_invoiced", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
soID := rs.IDs()[0]
|
||||||
|
var invoiced float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(
|
||||||
|
COALESCE(qty_invoiced, 0) * COALESCE(price_unit, 0)
|
||||||
|
* (1 - COALESCE(discount, 0) / 100)
|
||||||
|
)::float8, 0)
|
||||||
|
FROM sale_order_line WHERE order_id = $1
|
||||||
|
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
||||||
|
soID).Scan(&invoiced)
|
||||||
|
return orm.Values{"amount_invoiced": invoiced}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _compute_amount_to_invoice: Total minus already invoiced.
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amount_to_invoice()
|
||||||
|
so.RegisterCompute("amount_to_invoice", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
soID := rs.IDs()[0]
|
||||||
|
var total, invoiced float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(price_subtotal)::float8, 0),
|
||||||
|
COALESCE(SUM(
|
||||||
|
COALESCE(qty_invoiced, 0) * COALESCE(price_unit, 0)
|
||||||
|
* (1 - COALESCE(discount, 0) / 100)
|
||||||
|
)::float8, 0)
|
||||||
|
FROM sale_order_line WHERE order_id = $1
|
||||||
|
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
||||||
|
soID).Scan(&total, &invoiced)
|
||||||
|
result := total - invoiced
|
||||||
|
if result < 0 {
|
||||||
|
result = 0
|
||||||
|
}
|
||||||
|
return orm.Values{"amount_to_invoice": result}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- type_name: "Quotation" vs "Sales Order" based on state --
|
||||||
|
so.AddFields(
|
||||||
|
orm.Char("type_name", orm.FieldOpts{
|
||||||
|
String: "Type Name", Compute: "_compute_type_name",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// _compute_type_name
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_type_name()
|
||||||
|
so.RegisterCompute("type_name", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
soID := rs.IDs()[0]
|
||||||
|
var state string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(state, 'draft') FROM sale_order WHERE id = $1`, soID).Scan(&state)
|
||||||
|
typeName := "Sales Order"
|
||||||
|
if state == "draft" || state == "sent" || state == "cancel" {
|
||||||
|
typeName = "Quotation"
|
||||||
|
}
|
||||||
|
return orm.Values{"type_name": typeName}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- delivery_status: nothing/partial/full based on related pickings --
|
||||||
|
so.AddFields(
|
||||||
|
orm.Selection("delivery_status", []orm.SelectionItem{
|
||||||
|
{Value: "nothing", Label: "Nothing Delivered"},
|
||||||
|
{Value: "partial", Label: "Partially Delivered"},
|
||||||
|
{Value: "full", Label: "Fully Delivered"},
|
||||||
|
}, orm.FieldOpts{String: "Delivery Status", Compute: "_compute_delivery_status"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// _compute_delivery_status
|
||||||
|
// Mirrors: odoo/addons/sale_stock/models/sale_order.py SaleOrder._compute_delivery_status()
|
||||||
|
so.RegisterCompute("delivery_status", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
soID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var state string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(state, 'draft') FROM sale_order WHERE id = $1`, soID).Scan(&state)
|
||||||
|
|
||||||
|
// Only compute for confirmed orders
|
||||||
|
if state != "sale" && state != "done" {
|
||||||
|
return orm.Values{"delivery_status": "nothing"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check line quantities: total ordered vs total delivered
|
||||||
|
var totalOrdered, totalDelivered float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(product_uom_qty), 0), COALESCE(SUM(qty_delivered), 0)
|
||||||
|
FROM sale_order_line WHERE order_id = $1
|
||||||
|
AND product_id IS NOT NULL
|
||||||
|
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
||||||
|
soID).Scan(&totalOrdered, &totalDelivered)
|
||||||
|
|
||||||
|
status := "nothing"
|
||||||
|
if totalOrdered > 0 {
|
||||||
|
if totalDelivered >= totalOrdered {
|
||||||
|
status = "full"
|
||||||
|
} else if totalDelivered > 0 {
|
||||||
|
status = "partial"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return orm.Values{"delivery_status": status}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// preview_sale_order: Return URL action for customer portal preview (Python method name).
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.preview_sale_order()
|
||||||
|
so.RegisterMethod("preview_sale_order", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
soID := rs.IDs()[0]
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "ir.actions.act_url",
|
||||||
|
"url": fmt.Sprintf("/my/orders/%d", soID),
|
||||||
|
"target": "new",
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
// -- Computed: _compute_invoice_status (extends the base) --
|
// -- Computed: _compute_invoice_status (extends the base) --
|
||||||
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_invoice_status()
|
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_invoice_status()
|
||||||
so.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) {
|
so.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
@@ -201,19 +334,238 @@ func initSaleOrderExtension() {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// _compute_amount_to_invoice: Compute total amount still to invoice.
|
// Note: amount_to_invoice compute is already registered above (line ~90)
|
||||||
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amounts()
|
|
||||||
so.RegisterMethod("_compute_amount_to_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
// ── Feature 1: action_quotation_send ──────────────────────────────────
|
||||||
|
// Sends a quotation email to the customer with SO details, then marks state as 'sent'.
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_quotation_send()
|
||||||
|
so.RegisterMethod("action_quotation_send", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
for _, soID := range rs.IDs() {
|
||||||
|
// Read SO header
|
||||||
|
var soName, partnerEmail, partnerName, state string
|
||||||
|
var amountTotal float64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(so.name, ''), COALESCE(p.email, ''), COALESCE(p.name, ''),
|
||||||
|
COALESCE(so.state, 'draft'), COALESCE(so.amount_total::float8, 0)
|
||||||
|
FROM sale_order so
|
||||||
|
JOIN res_partner p ON p.id = so.partner_id
|
||||||
|
WHERE so.id = $1`, soID,
|
||||||
|
).Scan(&soName, &partnerEmail, &partnerName, &state, &amountTotal)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sale: read SO %d for email: %w", soID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if partnerEmail == "" {
|
||||||
|
log.Printf("sale: action_quotation_send: no email for partner on SO %d, skipping", soID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read order lines for the email body
|
||||||
|
lineRows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT COALESCE(name, ''), COALESCE(product_uom_qty, 0),
|
||||||
|
COALESCE(price_unit, 0), COALESCE(discount, 0),
|
||||||
|
COALESCE(price_subtotal::float8, 0)
|
||||||
|
FROM sale_order_line WHERE order_id = $1
|
||||||
|
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')
|
||||||
|
ORDER BY sequence, id`, soID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sale: read SO lines for email SO %d: %w", soID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var linesHTML string
|
||||||
|
for lineRows.Next() {
|
||||||
|
var lName string
|
||||||
|
var lQty, lPrice, lDiscount, lSubtotal float64
|
||||||
|
if err := lineRows.Scan(&lName, &lQty, &lPrice, &lDiscount, &lSubtotal); err != nil {
|
||||||
|
lineRows.Close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
linesHTML += fmt.Sprintf(
|
||||||
|
"<tr><td>%s</td><td style=\"text-align:right\">%.2f</td>"+
|
||||||
|
"<td style=\"text-align:right\">%.2f</td>"+
|
||||||
|
"<td style=\"text-align:right\">%.1f%%</td>"+
|
||||||
|
"<td style=\"text-align:right\">%.2f</td></tr>",
|
||||||
|
htmlEscapeStr(lName), lQty, lPrice, lDiscount, lSubtotal)
|
||||||
|
}
|
||||||
|
lineRows.Close()
|
||||||
|
|
||||||
|
// Build HTML email body
|
||||||
|
subject := fmt.Sprintf("Quotation %s", soName)
|
||||||
|
partnerNameEsc := htmlEscapeStr(partnerName)
|
||||||
|
soNameEsc := htmlEscapeStr(soName)
|
||||||
|
body := fmt.Sprintf(`<div style="font-family:Arial,sans-serif;max-width:600px">
|
||||||
|
<h2>%s</h2>
|
||||||
|
<p>Dear %s,</p>
|
||||||
|
<p>Please find below your quotation <strong>%s</strong>.</p>
|
||||||
|
<table style="width:100%%;border-collapse:collapse" border="1" cellpadding="5">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Description</th><th>Qty</th><th>Unit Price</th><th>Discount</th><th>Subtotal</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>%s</tbody>
|
||||||
|
<tfoot><tr>
|
||||||
|
<td colspan="4" style="text-align:right"><strong>Total</strong></td>
|
||||||
|
<td style="text-align:right"><strong>%.2f</strong></td>
|
||||||
|
</tr></tfoot>
|
||||||
|
</table>
|
||||||
|
<p>Do not hesitate to contact us if you have any questions.</p>
|
||||||
|
</div>`, htmlEscapeStr(subject), partnerNameEsc, soNameEsc, linesHTML, amountTotal)
|
||||||
|
|
||||||
|
// Send email via tools.SendEmail
|
||||||
|
cfg := tools.LoadSMTPConfig()
|
||||||
|
if err := tools.SendEmail(cfg, partnerEmail, subject, body); err != nil {
|
||||||
|
log.Printf("sale: action_quotation_send: email send failed for SO %d: %v", soID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark state as 'sent' if currently draft
|
||||||
|
if state == "draft" {
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE sale_order SET state = 'sent' WHERE id = $1`, soID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Feature 2: _compute_amount_by_group ──────────────────────────────
|
||||||
|
// Compute tax amounts grouped by tax group. Returns JSON with group_name, tax_amount,
|
||||||
|
// base_amount per group. Similar to account.move tax_totals.
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_tax_totals()
|
||||||
|
so.AddFields(
|
||||||
|
orm.Text("tax_totals_json", orm.FieldOpts{
|
||||||
|
String: "Tax Totals JSON", Compute: "_compute_amount_by_group",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
so.RegisterCompute("tax_totals_json", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
soID := rs.IDs()[0]
|
soID := rs.IDs()[0]
|
||||||
var total, invoiced float64
|
|
||||||
env.Tx().QueryRow(env.Ctx(),
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
`SELECT COALESCE(SUM(price_subtotal::float8), 0),
|
`SELECT COALESCE(tg.name, t.name, 'Taxes') AS group_name,
|
||||||
COALESCE(SUM(qty_invoiced * price_unit * (1 - COALESCE(discount,0)/100))::float8, 0)
|
SUM(
|
||||||
FROM sale_order_line WHERE order_id = $1
|
sol.product_uom_qty * sol.price_unit * (1 - COALESCE(sol.discount,0)/100)
|
||||||
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
* COALESCE(t.amount, 0) / 100
|
||||||
soID).Scan(&total, &invoiced)
|
)::float8 AS tax_amount,
|
||||||
return total - invoiced, nil
|
SUM(
|
||||||
|
sol.product_uom_qty * sol.price_unit * (1 - COALESCE(sol.discount,0)/100)
|
||||||
|
)::float8 AS base_amount
|
||||||
|
FROM sale_order_line sol
|
||||||
|
JOIN account_tax_sale_order_line_rel rel ON rel.sale_order_line_id = sol.id
|
||||||
|
JOIN account_tax t ON t.id = rel.account_tax_id
|
||||||
|
LEFT JOIN account_tax_group tg ON tg.id = t.tax_group_id
|
||||||
|
WHERE sol.order_id = $1
|
||||||
|
AND (sol.display_type IS NULL OR sol.display_type = '' OR sol.display_type = 'product')
|
||||||
|
GROUP BY COALESCE(tg.name, t.name, 'Taxes')
|
||||||
|
ORDER BY group_name`, soID)
|
||||||
|
if err != nil {
|
||||||
|
return orm.Values{"tax_totals_json": "{}"}, nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type taxGroup struct {
|
||||||
|
GroupName string `json:"group_name"`
|
||||||
|
TaxAmount float64 `json:"tax_amount"`
|
||||||
|
BaseAmount float64 `json:"base_amount"`
|
||||||
|
}
|
||||||
|
var groups []taxGroup
|
||||||
|
var totalTax, totalBase float64
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var g taxGroup
|
||||||
|
if err := rows.Scan(&g.GroupName, &g.TaxAmount, &g.BaseAmount); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalTax += g.TaxAmount
|
||||||
|
totalBase += g.BaseAmount
|
||||||
|
groups = append(groups, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"groups_by_subtotal": groups,
|
||||||
|
"amount_total": totalBase + totalTax,
|
||||||
|
"amount_untaxed": totalBase,
|
||||||
|
"amount_tax": totalTax,
|
||||||
|
}
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return orm.Values{"tax_totals_json": "{}"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return orm.Values{"tax_totals_json": string(jsonBytes)}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Feature 3: action_add_option ─────────────────────────────────────
|
||||||
|
// Copies a selected sale.order.option as a new order line on this SO.
|
||||||
|
// Mirrors: odoo/addons/sale_management/models/sale_order_option.py SaleOrderOption.add_option_to_order()
|
||||||
|
so.RegisterMethod("action_add_option", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
return nil, fmt.Errorf("sale: action_add_option requires option_id argument")
|
||||||
|
}
|
||||||
|
env := rs.Env()
|
||||||
|
soID := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Accept option_id as float64 (JSON) or int64
|
||||||
|
var optionID int64
|
||||||
|
switch v := args[0].(type) {
|
||||||
|
case float64:
|
||||||
|
optionID = int64(v)
|
||||||
|
case int64:
|
||||||
|
optionID = v
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("sale: action_add_option: invalid option_id type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the option
|
||||||
|
var name string
|
||||||
|
var productID int64
|
||||||
|
var qty, priceUnit, discount float64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(name, ''), COALESCE(product_id, 0), COALESCE(quantity, 1),
|
||||||
|
COALESCE(price_unit, 0), COALESCE(discount, 0)
|
||||||
|
FROM sale_order_option WHERE id = $1`, optionID,
|
||||||
|
).Scan(&name, &productID, &qty, &priceUnit, &discount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sale: read option %d: %w", optionID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new order line from the option
|
||||||
|
lineVals := orm.Values{
|
||||||
|
"order_id": soID,
|
||||||
|
"name": name,
|
||||||
|
"product_uom_qty": qty,
|
||||||
|
"price_unit": priceUnit,
|
||||||
|
"discount": discount,
|
||||||
|
}
|
||||||
|
if productID > 0 {
|
||||||
|
lineVals["product_id"] = productID
|
||||||
|
}
|
||||||
|
|
||||||
|
lineRS := env.Model("sale.order.line")
|
||||||
|
_, err = lineRS.Create(lineVals)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sale: create line from option %d: %w", optionID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark option as added
|
||||||
|
env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE sale_order_option SET is_present = true WHERE id = $1`, optionID)
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Feature 6: action_print ──────────────────────────────────────────
|
||||||
|
// Returns a report URL action pointing to /report/pdf/sale.order/<id>.
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_quotation_send() print variant
|
||||||
|
so.RegisterMethod("action_print", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
soID := rs.IDs()[0]
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "ir.actions.report",
|
||||||
|
"report_name": "sale.order",
|
||||||
|
"report_type": "qweb-pdf",
|
||||||
|
"report_file": fmt.Sprintf("/report/pdf/sale.order/%d", soID),
|
||||||
|
"data": map[string]interface{}{"ids": []int64{soID}},
|
||||||
|
}, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,37 +699,402 @@ func initSaleOrderLineExtension() {
|
|||||||
return orm.Values{"untaxed_amount_invoiced": qtyInvoiced * price * (1 - discount/100)}, nil
|
return orm.Values{"untaxed_amount_invoiced": qtyInvoiced * price * (1 - discount/100)}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// _compute_qty_invoiced: Compute invoiced quantity from linked invoice lines.
|
// _compute_qty_to_invoice: Quantity to invoice based on invoice policy.
|
||||||
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_invoiced()
|
// Note: qty_invoiced compute is registered later with full M2M-based logic.
|
||||||
sol.RegisterCompute("qty_invoiced", func(rs *orm.Recordset) (orm.Values, error) {
|
// If invoice policy is 'order': product_uom_qty - qty_invoiced
|
||||||
env := rs.Env()
|
// If invoice policy is 'delivery': qty_delivered - qty_invoiced
|
||||||
lineID := rs.IDs()[0]
|
|
||||||
var qtyInvoiced float64
|
|
||||||
env.Tx().QueryRow(env.Ctx(),
|
|
||||||
`SELECT COALESCE(qty_invoiced, 0) FROM sale_order_line WHERE id = $1`, lineID,
|
|
||||||
).Scan(&qtyInvoiced)
|
|
||||||
return orm.Values{"qty_invoiced": qtyInvoiced}, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// _compute_qty_to_invoice: Quantity to invoice = qty - qty_invoiced (if delivered policy: qty_delivered - qty_invoiced).
|
|
||||||
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_to_invoice()
|
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_to_invoice()
|
||||||
sol.RegisterCompute("qty_to_invoice", func(rs *orm.Recordset) (orm.Values, error) {
|
sol.RegisterCompute("qty_to_invoice", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
lineID := rs.IDs()[0]
|
lineID := rs.IDs()[0]
|
||||||
var qty, qtyInvoiced float64
|
var qty, qtyDelivered, qtyInvoiced float64
|
||||||
|
var productID *int64
|
||||||
|
var orderState string
|
||||||
env.Tx().QueryRow(env.Ctx(),
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT COALESCE(product_uom_qty, 0), COALESCE(qty_invoiced, 0)
|
`SELECT COALESCE(sol.product_uom_qty, 0), COALESCE(sol.qty_delivered, 0),
|
||||||
FROM sale_order_line WHERE id = $1`, lineID,
|
COALESCE(sol.qty_invoiced, 0), sol.product_id,
|
||||||
).Scan(&qty, &qtyInvoiced)
|
COALESCE(so.state, 'draft')
|
||||||
toInvoice := qty - qtyInvoiced
|
FROM sale_order_line sol
|
||||||
|
JOIN sale_order so ON so.id = sol.order_id
|
||||||
|
WHERE sol.id = $1`, lineID,
|
||||||
|
).Scan(&qty, &qtyDelivered, &qtyInvoiced, &productID, &orderState)
|
||||||
|
|
||||||
|
if orderState != "sale" && orderState != "done" {
|
||||||
|
return orm.Values{"qty_to_invoice": float64(0)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check invoice policy from product template
|
||||||
|
invoicePolicy := "order" // default
|
||||||
|
if productID != nil && *productID > 0 {
|
||||||
|
var policy *string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT pt.invoice_policy FROM product_product pp
|
||||||
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||||
|
WHERE pp.id = $1`, *productID).Scan(&policy)
|
||||||
|
if policy != nil && *policy != "" {
|
||||||
|
invoicePolicy = *policy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var toInvoice float64
|
||||||
|
if invoicePolicy == "delivery" {
|
||||||
|
toInvoice = qtyDelivered - qtyInvoiced
|
||||||
|
} else {
|
||||||
|
toInvoice = qty - qtyInvoiced
|
||||||
|
}
|
||||||
if toInvoice < 0 {
|
if toInvoice < 0 {
|
||||||
toInvoice = 0
|
toInvoice = 0
|
||||||
}
|
}
|
||||||
return orm.Values{"qty_to_invoice": toInvoice}, nil
|
return orm.Values{"qty_to_invoice": toInvoice}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// _compute_qty_delivered: Compute delivered quantity from stock moves.
|
||||||
|
// For products of type 'service', qty_delivered is manual.
|
||||||
|
// For storable/consumable products, sum done stock move quantities.
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_delivered()
|
||||||
|
// odoo/addons/sale_stock/models/sale_order_line.py (stock moves source)
|
||||||
|
sol.RegisterCompute("qty_delivered", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
lineID := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Check if stock module is loaded
|
||||||
|
if orm.Registry.Get("stock.move") == nil {
|
||||||
|
// No stock module — return existing stored value
|
||||||
|
var delivered float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(qty_delivered, 0) FROM sale_order_line WHERE id = $1`, lineID,
|
||||||
|
).Scan(&delivered)
|
||||||
|
return orm.Values{"qty_delivered": delivered}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get product info
|
||||||
|
var productID *int64
|
||||||
|
var productType string
|
||||||
|
var soName string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT sol.product_id, COALESCE(pt.type, 'consu'),
|
||||||
|
COALESCE(so.name, '')
|
||||||
|
FROM sale_order_line sol
|
||||||
|
LEFT JOIN product_product pp ON pp.id = sol.product_id
|
||||||
|
LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||||
|
JOIN sale_order so ON so.id = sol.order_id
|
||||||
|
WHERE sol.id = $1`, lineID,
|
||||||
|
).Scan(&productID, &productType, &soName)
|
||||||
|
|
||||||
|
// For services, qty_delivered is manual — keep stored value
|
||||||
|
if productType == "service" || productID == nil || *productID == 0 {
|
||||||
|
var delivered float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(qty_delivered, 0) FROM sale_order_line WHERE id = $1`, lineID,
|
||||||
|
).Scan(&delivered)
|
||||||
|
return orm.Values{"qty_delivered": delivered}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum done outgoing stock move quantities for this product + SO origin
|
||||||
|
var delivered float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(sm.product_uom_qty)::float8, 0)
|
||||||
|
FROM stock_move sm
|
||||||
|
JOIN stock_picking sp ON sp.id = sm.picking_id
|
||||||
|
WHERE sm.product_id = $1 AND sm.state = 'done'
|
||||||
|
AND sp.origin = $2
|
||||||
|
AND sm.location_dest_id IN (
|
||||||
|
SELECT id FROM stock_location WHERE usage = 'customer'
|
||||||
|
)`, *productID, soName,
|
||||||
|
).Scan(&delivered)
|
||||||
|
|
||||||
|
return orm.Values{"qty_delivered": delivered}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _compute_qty_invoiced: Compute invoiced quantity from linked invoice lines.
|
||||||
|
// For real integration, sum quantities from account.move.line linked via the M2M relation.
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_invoiced()
|
||||||
|
sol.RegisterCompute("qty_invoiced", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
lineID := rs.IDs()[0]
|
||||||
|
|
||||||
|
// Try to get from the M2M relation (sale_order_line_invoice_rel)
|
||||||
|
var qtyInvoiced float64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(SUM(
|
||||||
|
CASE WHEN am.move_type = 'out_refund' THEN -aml.quantity ELSE aml.quantity END
|
||||||
|
)::float8, 0)
|
||||||
|
FROM sale_order_line_invoice_rel rel
|
||||||
|
JOIN account_move_line aml ON aml.id = rel.invoice_line_id
|
||||||
|
JOIN account_move am ON am.id = aml.move_id
|
||||||
|
WHERE rel.order_line_id = $1
|
||||||
|
AND am.state != 'cancel'`, lineID,
|
||||||
|
).Scan(&qtyInvoiced)
|
||||||
|
if err != nil || qtyInvoiced == 0 {
|
||||||
|
// Fallback to stored value
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(qty_invoiced, 0) FROM sale_order_line WHERE id = $1`, lineID,
|
||||||
|
).Scan(&qtyInvoiced)
|
||||||
|
}
|
||||||
|
return orm.Values{"qty_invoiced": qtyInvoiced}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _compute_name: Product name + description + variant attributes.
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_name()
|
||||||
|
sol.AddFields(
|
||||||
|
orm.Text("computed_name", orm.FieldOpts{
|
||||||
|
String: "Computed Description", Compute: "_compute_name",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
sol.RegisterCompute("computed_name", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
lineID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var productID *int64
|
||||||
|
var existingName string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT product_id, COALESCE(name, '') FROM sale_order_line WHERE id = $1`, lineID,
|
||||||
|
).Scan(&productID, &existingName)
|
||||||
|
|
||||||
|
// If no product, keep existing name
|
||||||
|
if productID == nil || *productID == 0 {
|
||||||
|
return orm.Values{"computed_name": existingName}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build name from product template + variant attributes
|
||||||
|
var productName, descSale string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(pt.name, ''), COALESCE(pt.description_sale, '')
|
||||||
|
FROM product_product pp
|
||||||
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||||
|
WHERE pp.id = $1`, *productID,
|
||||||
|
).Scan(&productName, &descSale)
|
||||||
|
|
||||||
|
// Get variant attribute values
|
||||||
|
attrRows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT COALESCE(pav.name, '')
|
||||||
|
FROM product_template_attribute_value ptav
|
||||||
|
JOIN product_attribute_value pav ON pav.id = ptav.product_attribute_value_id
|
||||||
|
JOIN product_product_product_template_attribute_value_rel rel
|
||||||
|
ON rel.product_template_attribute_value_id = ptav.id
|
||||||
|
WHERE rel.product_product_id = $1`, *productID)
|
||||||
|
|
||||||
|
var attrNames []string
|
||||||
|
if err == nil {
|
||||||
|
for attrRows.Next() {
|
||||||
|
var attrName string
|
||||||
|
attrRows.Scan(&attrName)
|
||||||
|
if attrName != "" {
|
||||||
|
attrNames = append(attrNames, attrName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attrRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
name := productName
|
||||||
|
if len(attrNames) > 0 {
|
||||||
|
name += " ("
|
||||||
|
for i, a := range attrNames {
|
||||||
|
if i > 0 {
|
||||||
|
name += ", "
|
||||||
|
}
|
||||||
|
name += a
|
||||||
|
}
|
||||||
|
name += ")"
|
||||||
|
}
|
||||||
|
if descSale != "" {
|
||||||
|
name += "\n" + descSale
|
||||||
|
}
|
||||||
|
|
||||||
|
return orm.Values{"computed_name": name}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _compute_discount: Compute discount from pricelist if applicable.
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_discount()
|
||||||
|
sol.AddFields(
|
||||||
|
orm.Float("computed_discount", orm.FieldOpts{
|
||||||
|
String: "Computed Discount", Compute: "_compute_discount_from_pricelist",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
sol.RegisterCompute("computed_discount", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
lineID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var productID *int64
|
||||||
|
var orderID int64
|
||||||
|
var priceUnit float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT product_id, order_id, COALESCE(price_unit, 0)
|
||||||
|
FROM sale_order_line WHERE id = $1`, lineID,
|
||||||
|
).Scan(&productID, &orderID, &priceUnit)
|
||||||
|
|
||||||
|
if productID == nil || *productID == 0 || priceUnit == 0 {
|
||||||
|
return orm.Values{"computed_discount": float64(0)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pricelist from the order
|
||||||
|
var pricelistID *int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT pricelist_id FROM sale_order WHERE id = $1`, orderID,
|
||||||
|
).Scan(&pricelistID)
|
||||||
|
|
||||||
|
if pricelistID == nil || *pricelistID == 0 {
|
||||||
|
return orm.Values{"computed_discount": float64(0)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the product's list_price as the base price
|
||||||
|
var listPrice float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(pt.list_price, 0)
|
||||||
|
FROM product_product pp
|
||||||
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||||
|
WHERE pp.id = $1`, *productID,
|
||||||
|
).Scan(&listPrice)
|
||||||
|
|
||||||
|
if listPrice <= 0 {
|
||||||
|
return orm.Values{"computed_discount": float64(0)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check pricelist for a discount-based rule
|
||||||
|
var discountPct float64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(price_discount, 0)
|
||||||
|
FROM product_pricelist_item
|
||||||
|
WHERE pricelist_id = $1
|
||||||
|
AND (product_id = $2 OR product_tmpl_id = (
|
||||||
|
SELECT product_tmpl_id FROM product_product WHERE id = $2
|
||||||
|
) OR (product_id IS NULL AND product_tmpl_id IS NULL))
|
||||||
|
AND (date_start IS NULL OR date_start <= CURRENT_DATE)
|
||||||
|
AND (date_end IS NULL OR date_end >= CURRENT_DATE)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN product_id IS NOT NULL THEN 0
|
||||||
|
WHEN product_tmpl_id IS NOT NULL THEN 1
|
||||||
|
ELSE 2 END,
|
||||||
|
min_quantity ASC
|
||||||
|
LIMIT 1`, *pricelistID, *productID,
|
||||||
|
).Scan(&discountPct)
|
||||||
|
|
||||||
|
return orm.Values{"computed_discount": discountPct}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// _compute_invoice_status_line: Enhanced per-line invoice status considering upselling.
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_invoice_status()
|
||||||
|
sol.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
lineID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var qty, qtyDelivered, qtyInvoiced float64
|
||||||
|
var orderState string
|
||||||
|
var isDownpayment bool
|
||||||
|
var productID *int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(sol.product_uom_qty, 0), COALESCE(sol.qty_delivered, 0),
|
||||||
|
COALESCE(sol.qty_invoiced, 0), COALESCE(so.state, 'draft'),
|
||||||
|
COALESCE(sol.is_downpayment, false), sol.product_id
|
||||||
|
FROM sale_order_line sol
|
||||||
|
JOIN sale_order so ON so.id = sol.order_id
|
||||||
|
WHERE sol.id = $1`, lineID,
|
||||||
|
).Scan(&qty, &qtyDelivered, &qtyInvoiced, &orderState, &isDownpayment, &productID)
|
||||||
|
|
||||||
|
if orderState != "sale" && orderState != "done" {
|
||||||
|
return orm.Values{"invoice_status": "no"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down payment that is fully invoiced
|
||||||
|
if isDownpayment && qtyInvoiced >= qty {
|
||||||
|
return orm.Values{"invoice_status": "invoiced"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check qty_to_invoice
|
||||||
|
var toInvoice float64
|
||||||
|
invoicePolicy := "order"
|
||||||
|
if productID != nil && *productID > 0 {
|
||||||
|
var policy *string
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT pt.invoice_policy FROM product_product pp
|
||||||
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||||
|
WHERE pp.id = $1`, *productID).Scan(&policy)
|
||||||
|
if policy != nil && *policy != "" {
|
||||||
|
invoicePolicy = *policy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if invoicePolicy == "delivery" {
|
||||||
|
toInvoice = qtyDelivered - qtyInvoiced
|
||||||
|
} else {
|
||||||
|
toInvoice = qty - qtyInvoiced
|
||||||
|
}
|
||||||
|
|
||||||
|
if toInvoice > 0.001 {
|
||||||
|
return orm.Values{"invoice_status": "to invoice"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upselling: ordered qty invoiced on order policy but delivered more
|
||||||
|
if invoicePolicy == "order" && qty >= 0 && qtyDelivered > qty {
|
||||||
|
return orm.Values{"invoice_status": "upselling"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if qtyInvoiced >= qty && qty > 0 {
|
||||||
|
return orm.Values{"invoice_status": "invoiced"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return orm.Values{"invoice_status": "no"}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Feature 4: _compute_product_template_attribute_value_ids ─────────
|
||||||
|
// When product_id changes, find available product.template.attribute.value records
|
||||||
|
// for the variant. Returns JSON array of {id, name, attribute_name} objects.
|
||||||
|
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_product_template_attribute_value_ids()
|
||||||
|
sol.AddFields(
|
||||||
|
orm.Text("product_template_attribute_value_ids", orm.FieldOpts{
|
||||||
|
String: "Product Attribute Values",
|
||||||
|
Compute: "_compute_product_template_attribute_value_ids",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
sol.RegisterCompute("product_template_attribute_value_ids", func(rs *orm.Recordset) (orm.Values, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
lineID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var productID *int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT product_id FROM sale_order_line WHERE id = $1`, lineID,
|
||||||
|
).Scan(&productID)
|
||||||
|
|
||||||
|
if productID == nil || *productID == 0 {
|
||||||
|
return orm.Values{"product_template_attribute_value_ids": "[]"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find attribute values for this product variant via the M2M relation
|
||||||
|
attrRows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT ptav.id, COALESCE(pav.name, '') AS value_name,
|
||||||
|
COALESCE(pa.name, '') AS attribute_name
|
||||||
|
FROM product_template_attribute_value ptav
|
||||||
|
JOIN product_attribute_value pav ON pav.id = ptav.product_attribute_value_id
|
||||||
|
JOIN product_attribute pa ON pa.id = ptav.attribute_id
|
||||||
|
JOIN product_product_product_template_attribute_value_rel rel
|
||||||
|
ON rel.product_template_attribute_value_id = ptav.id
|
||||||
|
WHERE rel.product_product_id = $1
|
||||||
|
ORDER BY pa.sequence, pa.name`, *productID)
|
||||||
|
if err != nil {
|
||||||
|
return orm.Values{"product_template_attribute_value_ids": "[]"}, nil
|
||||||
|
}
|
||||||
|
defer attrRows.Close()
|
||||||
|
|
||||||
|
type attrVal struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AttributeName string `json:"attribute_name"`
|
||||||
|
}
|
||||||
|
var values []attrVal
|
||||||
|
for attrRows.Next() {
|
||||||
|
var av attrVal
|
||||||
|
if err := attrRows.Scan(&av.ID, &av.Name, &av.AttributeName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values = append(values, av)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, _ := json.Marshal(values)
|
||||||
|
return orm.Values{"product_template_attribute_value_ids": string(jsonBytes)}, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// initSaleOrderDiscount registers the sale.order.discount wizard.
|
// initSaleOrderDiscount registers the sale.order.discount wizard.
|
||||||
|
// Enhanced with discount_type: percentage or fixed_amount.
|
||||||
// Mirrors: odoo/addons/sale/wizard/sale_order_discount.py
|
// Mirrors: odoo/addons/sale/wizard/sale_order_discount.py
|
||||||
func initSaleOrderDiscount() {
|
func initSaleOrderDiscount() {
|
||||||
m := orm.NewModel("sale.order.discount", orm.ModelOpts{
|
m := orm.NewModel("sale.order.discount", orm.ModelOpts{
|
||||||
@@ -386,33 +1103,76 @@ func initSaleOrderDiscount() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
m.AddFields(
|
m.AddFields(
|
||||||
orm.Float("discount", orm.FieldOpts{String: "Discount (%)", Required: true}),
|
orm.Float("discount", orm.FieldOpts{String: "Discount Value", Required: true}),
|
||||||
|
orm.Selection("discount_type", []orm.SelectionItem{
|
||||||
|
{Value: "percentage", Label: "Percentage"},
|
||||||
|
{Value: "fixed_amount", Label: "Fixed Amount"},
|
||||||
|
}, orm.FieldOpts{String: "Discount Type", Default: "percentage", Required: true}),
|
||||||
orm.Many2one("sale_order_id", "sale.order", orm.FieldOpts{String: "Sale Order"}),
|
orm.Many2one("sale_order_id", "sale.order", orm.FieldOpts{String: "Sale Order"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// action_apply_discount: Apply the discount to all lines of the SO.
|
// action_apply_discount: Apply the discount to all lines of the SO.
|
||||||
|
// For percentage: sets discount % on each line directly.
|
||||||
|
// For fixed_amount: distributes the fixed amount evenly across all product lines.
|
||||||
// Mirrors: odoo/addons/sale/wizard/sale_order_discount.py action_apply_discount()
|
// Mirrors: odoo/addons/sale/wizard/sale_order_discount.py action_apply_discount()
|
||||||
m.RegisterMethod("action_apply_discount", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
m.RegisterMethod("action_apply_discount", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
env := rs.Env()
|
env := rs.Env()
|
||||||
wizID := rs.IDs()[0]
|
wizID := rs.IDs()[0]
|
||||||
var discount float64
|
var discountVal float64
|
||||||
var orderID int64
|
var orderID int64
|
||||||
|
var discountType string
|
||||||
env.Tx().QueryRow(env.Ctx(),
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT COALESCE(discount, 0), COALESCE(sale_order_id, 0)
|
`SELECT COALESCE(discount, 0), COALESCE(sale_order_id, 0),
|
||||||
|
COALESCE(discount_type, 'percentage')
|
||||||
FROM sale_order_discount WHERE id = $1`, wizID,
|
FROM sale_order_discount WHERE id = $1`, wizID,
|
||||||
).Scan(&discount, &orderID)
|
).Scan(&discountVal, &orderID, &discountType)
|
||||||
|
|
||||||
if orderID == 0 {
|
if orderID == 0 {
|
||||||
return nil, fmt.Errorf("sale_discount: no sale order linked")
|
return nil, fmt.Errorf("sale_discount: no sale order linked")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := env.Tx().Exec(env.Ctx(),
|
switch discountType {
|
||||||
`UPDATE sale_order_line SET discount = $1
|
case "fixed_amount":
|
||||||
WHERE order_id = $2
|
// Distribute fixed amount evenly across product lines as a percentage
|
||||||
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
// Calculate total undiscounted line amount to determine per-line discount %
|
||||||
discount, orderID)
|
var totalAmount float64
|
||||||
if err != nil {
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
return nil, fmt.Errorf("sale_discount: apply discount: %w", err)
|
`SELECT COALESCE(SUM(product_uom_qty * price_unit)::float8, 0)
|
||||||
|
FROM sale_order_line WHERE order_id = $1
|
||||||
|
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
||||||
|
orderID,
|
||||||
|
).Scan(&totalAmount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sale_discount: read total: %w", err)
|
||||||
|
}
|
||||||
|
if totalAmount <= 0 {
|
||||||
|
return nil, fmt.Errorf("sale_discount: order has no lines or zero total")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert fixed amount to an equivalent percentage of total
|
||||||
|
pct := discountVal / totalAmount * 100
|
||||||
|
if pct > 100 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE sale_order_line SET discount = $1
|
||||||
|
WHERE order_id = $2
|
||||||
|
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
||||||
|
pct, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sale_discount: apply fixed discount: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
default: // "percentage"
|
||||||
|
_, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE sale_order_line SET discount = $1
|
||||||
|
WHERE order_id = $2
|
||||||
|
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
|
||||||
|
discountVal, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sale_discount: apply percentage discount: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -441,3 +1201,5 @@ func initResPartnerSaleExtension2() {
|
|||||||
return orm.Values{"sale_order_total": total}, nil
|
return orm.Values{"sale_order_total": total}, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func htmlEscapeStr(s string) string { return html.EscapeString(s) }
|
||||||
|
|||||||
@@ -124,6 +124,42 @@ func initSaleOrderTemplate() {
|
|||||||
numDays, int64(orderID))
|
numDays, int64(orderID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy template options as sale.order.option records on the SO
|
||||||
|
optRows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT COALESCE(name, ''), product_id, COALESCE(quantity, 1),
|
||||||
|
COALESCE(price_unit, 0), COALESCE(discount, 0), COALESCE(sequence, 10)
|
||||||
|
FROM sale_order_template_option
|
||||||
|
WHERE sale_order_template_id = $1 ORDER BY sequence`, templateID)
|
||||||
|
if err == nil {
|
||||||
|
optionModel := orm.Registry.Get("sale.order.option")
|
||||||
|
if optionModel != nil {
|
||||||
|
optionRS := env.Model("sale.order.option")
|
||||||
|
for optRows.Next() {
|
||||||
|
var oName string
|
||||||
|
var oProdID *int64
|
||||||
|
var oQty, oPrice, oDisc float64
|
||||||
|
var oSeq int
|
||||||
|
if err := optRows.Scan(&oName, &oProdID, &oQty, &oPrice, &oDisc, &oSeq); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
optVals := orm.Values{
|
||||||
|
"order_id": int64(orderID),
|
||||||
|
"name": oName,
|
||||||
|
"quantity": oQty,
|
||||||
|
"price_unit": oPrice,
|
||||||
|
"discount": oDisc,
|
||||||
|
"sequence": oSeq,
|
||||||
|
"is_present": false,
|
||||||
|
}
|
||||||
|
if oProdID != nil {
|
||||||
|
optVals["product_id"] = *oProdID
|
||||||
|
}
|
||||||
|
optionRS.Create(optVals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
optRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -290,3 +326,94 @@ func initSaleOrderTemplateOption() {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initSaleOrderOption registers sale.order.option — optional products on a specific sale order.
|
||||||
|
// When a template with options is applied to an SO, options are copied here.
|
||||||
|
// The customer or salesperson can then choose to add them as order lines.
|
||||||
|
// Mirrors: odoo/addons/sale_management/models/sale_order_option.py SaleOrderOption
|
||||||
|
func initSaleOrderOption() {
|
||||||
|
m := orm.NewModel("sale.order.option", orm.ModelOpts{
|
||||||
|
Description: "Sale Order Option",
|
||||||
|
Order: "sequence, id",
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddFields(
|
||||||
|
orm.Many2one("order_id", "sale.order", orm.FieldOpts{
|
||||||
|
String: "Sale Order", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
||||||
|
}),
|
||||||
|
orm.Many2one("product_id", "product.product", orm.FieldOpts{
|
||||||
|
String: "Product", Required: true,
|
||||||
|
}),
|
||||||
|
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
|
||||||
|
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1}),
|
||||||
|
orm.Many2one("uom_id", "uom.uom", orm.FieldOpts{String: "Unit of Measure"}),
|
||||||
|
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
|
||||||
|
orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}),
|
||||||
|
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||||
|
orm.Boolean("is_present", orm.FieldOpts{
|
||||||
|
String: "Present on Order", Default: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Onchange: product_id → name + price_unit
|
||||||
|
m.RegisterOnchange("product_id", func(env *orm.Environment, vals orm.Values) orm.Values {
|
||||||
|
result := make(orm.Values)
|
||||||
|
|
||||||
|
var productID int64
|
||||||
|
switch v := vals["product_id"].(type) {
|
||||||
|
case int64:
|
||||||
|
productID = v
|
||||||
|
case float64:
|
||||||
|
productID = int64(v)
|
||||||
|
case map[string]interface{}:
|
||||||
|
if id, ok := v["id"]; ok {
|
||||||
|
switch n := id.(type) {
|
||||||
|
case float64:
|
||||||
|
productID = int64(n)
|
||||||
|
case int64:
|
||||||
|
productID = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if productID <= 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var name string
|
||||||
|
var listPrice float64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(pt.name, ''), COALESCE(pt.list_price, 0)
|
||||||
|
FROM product_product pp
|
||||||
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||||
|
WHERE pp.id = $1`, productID,
|
||||||
|
).Scan(&name, &listPrice)
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result["name"] = name
|
||||||
|
result["price_unit"] = listPrice
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// button_add: Add this option as an order line. Delegates to sale.order action_add_option.
|
||||||
|
m.RegisterMethod("button_add", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
env := rs.Env()
|
||||||
|
optionID := rs.IDs()[0]
|
||||||
|
|
||||||
|
var orderID int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COALESCE(order_id, 0) FROM sale_order_option WHERE id = $1`, optionID,
|
||||||
|
).Scan(&orderID)
|
||||||
|
if err != nil || orderID == 0 {
|
||||||
|
return nil, fmt.Errorf("sale_option: no order linked to option %d", optionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
soRS := env.Model("sale.order").Browse(orderID)
|
||||||
|
soModel := orm.Registry.Get("sale.order")
|
||||||
|
if fn, ok := soModel.Methods["action_add_option"]; ok {
|
||||||
|
return fn(soRS, float64(optionID))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("sale_option: action_add_option not found on sale.order")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ package models
|
|||||||
|
|
||||||
func Init() {
|
func Init() {
|
||||||
initStock()
|
initStock()
|
||||||
|
initStockIntrastat()
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,137 @@ func initStockBarcode() {
|
|||||||
return map[string]interface{}{"found": false, "barcode": barcode}, nil
|
return map[string]interface{}{"found": false, "barcode": barcode}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// action_process_barcode: Enhanced barcode scan loop — handles UPC/EAN by searching
|
||||||
|
// product.product.barcode field directly. Supports UPC-A (12 digits), EAN-13 (13 digits),
|
||||||
|
// EAN-8 (8 digits), and arbitrary barcodes. In the context of a picking, increments
|
||||||
|
// qty_done on the matching move line.
|
||||||
|
// Mirrors: stock_barcode.picking barcode scan loop with UPC/EAN support
|
||||||
|
m.RegisterMethod("action_process_barcode", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
if len(args) < 2 {
|
||||||
|
return nil, fmt.Errorf("stock.barcode.picking.action_process_barcode requires picking_id, barcode")
|
||||||
|
}
|
||||||
|
pickingID, _ := args[0].(int64)
|
||||||
|
barcode, _ := args[1].(string)
|
||||||
|
if pickingID == 0 || barcode == "" {
|
||||||
|
return nil, fmt.Errorf("stock.barcode.picking: invalid picking_id or barcode")
|
||||||
|
}
|
||||||
|
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
// Step 1: Try to find product by barcode on product.product.barcode (UPC/EAN stored here)
|
||||||
|
var productID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT id FROM product_product WHERE barcode = $1 LIMIT 1`, barcode,
|
||||||
|
).Scan(&productID)
|
||||||
|
|
||||||
|
// Step 2: If not found on product_product, try product_template.barcode
|
||||||
|
if productID == 0 {
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT pp.id FROM product_product pp
|
||||||
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||||
|
WHERE pt.barcode = $1 LIMIT 1`, barcode,
|
||||||
|
).Scan(&productID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: For UPC-A (12 digits), try converting to EAN-13 by prepending '0'
|
||||||
|
if productID == 0 && len(barcode) == 12 && isNumeric(barcode) {
|
||||||
|
ean13 := "0" + barcode
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT id FROM product_product WHERE barcode = $1 LIMIT 1`, ean13,
|
||||||
|
).Scan(&productID)
|
||||||
|
// Also try the reverse: if stored as UPC but scanned as EAN
|
||||||
|
if productID == 0 {
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT pp.id FROM product_product pp
|
||||||
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||||
|
WHERE pt.barcode = $1 LIMIT 1`, ean13,
|
||||||
|
).Scan(&productID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: For EAN-13 (13 digits starting with 0), try stripping leading 0 to get UPC-A
|
||||||
|
if productID == 0 && len(barcode) == 13 && barcode[0] == '0' && isNumeric(barcode) {
|
||||||
|
upc := barcode[1:]
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT id FROM product_product WHERE barcode = $1 LIMIT 1`, upc,
|
||||||
|
).Scan(&productID)
|
||||||
|
if productID == 0 {
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT pp.id FROM product_product pp
|
||||||
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||||
|
WHERE pt.barcode = $1 LIMIT 1`, upc,
|
||||||
|
).Scan(&productID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Try lot/serial number
|
||||||
|
if productID == 0 {
|
||||||
|
var lotProductID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT product_id FROM stock_lot WHERE name = $1 LIMIT 1`, barcode,
|
||||||
|
).Scan(&lotProductID)
|
||||||
|
productID = lotProductID
|
||||||
|
}
|
||||||
|
|
||||||
|
if productID == 0 {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"found": false,
|
||||||
|
"barcode": barcode,
|
||||||
|
"message": fmt.Sprintf("No product found for barcode %q (tried UPC/EAN lookup)", barcode),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Find matching move line in the picking
|
||||||
|
var moveLineID int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT sml.id FROM stock_move_line sml
|
||||||
|
JOIN stock_move sm ON sm.id = sml.move_id
|
||||||
|
WHERE sm.picking_id = $1 AND sml.product_id = $2 AND sm.state NOT IN ('done', 'cancel')
|
||||||
|
ORDER BY sml.id LIMIT 1`,
|
||||||
|
pickingID, productID,
|
||||||
|
).Scan(&moveLineID)
|
||||||
|
|
||||||
|
if err != nil || moveLineID == 0 {
|
||||||
|
// Check if product expected in any move
|
||||||
|
var moveID int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT id FROM stock_move WHERE picking_id = $1 AND product_id = $2 AND state NOT IN ('done', 'cancel') LIMIT 1`,
|
||||||
|
pickingID, productID,
|
||||||
|
).Scan(&moveID)
|
||||||
|
|
||||||
|
if moveID == 0 {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"found": false,
|
||||||
|
"product_id": productID,
|
||||||
|
"message": fmt.Sprintf("Product %d not expected in this transfer", productID),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"found": true,
|
||||||
|
"product_id": productID,
|
||||||
|
"move_id": moveID,
|
||||||
|
"action": "new_line",
|
||||||
|
"message": "Product found in move, new line needed",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment quantity on the move line
|
||||||
|
_, err = env.Tx().Exec(env.Ctx(),
|
||||||
|
`UPDATE stock_move_line SET quantity = quantity + 1 WHERE id = $1`, moveLineID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stock.barcode.picking: increment qty on move line %d: %w", moveLineID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"found": true,
|
||||||
|
"product_id": productID,
|
||||||
|
"move_line_id": moveLineID,
|
||||||
|
"action": "incremented",
|
||||||
|
"message": "Quantity incremented",
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
// process_barcode_picking: Process a barcode in the context of a picking.
|
// process_barcode_picking: Process a barcode in the context of a picking.
|
||||||
// Finds the product and increments qty_done on the matching move line.
|
// Finds the product and increments qty_done on the matching move line.
|
||||||
// Mirrors: stock_barcode.picking barcode processing
|
// Mirrors: stock_barcode.picking barcode processing
|
||||||
@@ -234,3 +365,13 @@ func initStockBarcode() {
|
|||||||
}, nil
|
}, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isNumeric checks if a string contains only digit characters.
|
||||||
|
func isNumeric(s string) bool {
|
||||||
|
for _, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(s) > 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
|
|
||||||
"odoo-go/pkg/orm"
|
"odoo-go/pkg/orm"
|
||||||
)
|
)
|
||||||
@@ -221,8 +220,11 @@ func initStockLandedCost() {
|
|||||||
_, err := env.Tx().Exec(env.Ctx(),
|
_, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE stock_valuation_layer
|
`UPDATE stock_valuation_layer
|
||||||
SET remaining_value = remaining_value + $1, value = value + $1
|
SET remaining_value = remaining_value + $1, value = value + $1
|
||||||
WHERE stock_move_id = $2 AND product_id = $3 AND remaining_qty > 0
|
WHERE id = (
|
||||||
LIMIT 1`,
|
SELECT id FROM stock_valuation_layer
|
||||||
|
WHERE stock_move_id = $2 AND product_id = $3 AND remaining_qty > 0
|
||||||
|
ORDER BY id LIMIT 1
|
||||||
|
)`,
|
||||||
adj.AdditionalCost, adj.MoveID, adj.ProductID,
|
adj.AdditionalCost, adj.MoveID, adj.ProductID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -375,8 +377,3 @@ func initStockLandedCost() {
|
|||||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// roundCurrency rounds a monetary value to 2 decimal places.
|
|
||||||
func roundCurrency(value float64) float64 {
|
|
||||||
return math.Round(value*100) / 100
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -513,3 +513,234 @@ func initStockForecast() {
|
|||||||
return map[string]interface{}{"products": products}, nil
|
return map[string]interface{}{"products": products}, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initStockIntrastat registers stock.intrastat.line — Intrastat reporting model for
|
||||||
|
// EU cross-border trade declarations. Tracks move-level trade data.
|
||||||
|
// Mirrors: odoo/addons/stock_intrastat/models/stock_intrastat.py
|
||||||
|
func initStockIntrastat() {
|
||||||
|
m := orm.NewModel("stock.intrastat.line", orm.ModelOpts{
|
||||||
|
Description: "Intrastat Line",
|
||||||
|
Order: "id desc",
|
||||||
|
})
|
||||||
|
m.AddFields(
|
||||||
|
orm.Many2one("move_id", "stock.move", orm.FieldOpts{
|
||||||
|
String: "Stock Move", Required: true, Index: true, OnDelete: orm.OnDeleteCascade,
|
||||||
|
}),
|
||||||
|
orm.Many2one("product_id", "product.product", orm.FieldOpts{
|
||||||
|
String: "Product", Required: true, Index: true,
|
||||||
|
}),
|
||||||
|
orm.Many2one("country_id", "res.country", orm.FieldOpts{
|
||||||
|
String: "Country", Required: true, Index: true,
|
||||||
|
}),
|
||||||
|
orm.Float("weight", orm.FieldOpts{String: "Weight (kg)", Required: true}),
|
||||||
|
orm.Monetary("value", orm.FieldOpts{String: "Fiscal Value", CurrencyField: "currency_id", Required: true}),
|
||||||
|
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||||
|
orm.Selection("transaction_type", []orm.SelectionItem{
|
||||||
|
{Value: "arrival", Label: "Arrival"},
|
||||||
|
{Value: "dispatch", Label: "Dispatch"},
|
||||||
|
}, orm.FieldOpts{String: "Transaction Type", Required: true, Index: true}),
|
||||||
|
orm.Char("intrastat_code", orm.FieldOpts{String: "Commodity Code"}),
|
||||||
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Index: true}),
|
||||||
|
orm.Date("date", orm.FieldOpts{String: "Date", Index: true}),
|
||||||
|
orm.Char("transport_mode", orm.FieldOpts{String: "Transport Mode"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// generate_lines: Auto-generate Intrastat lines from done stock moves in a date range.
|
||||||
|
// Args: date_from (string), date_to (string), optional company_id (int64)
|
||||||
|
// Mirrors: stock.intrastat.report generation
|
||||||
|
m.RegisterMethod("generate_lines", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
if len(args) < 2 {
|
||||||
|
return nil, fmt.Errorf("stock.intrastat.line.generate_lines requires date_from, date_to")
|
||||||
|
}
|
||||||
|
dateFrom, _ := args[0].(string)
|
||||||
|
dateTo, _ := args[1].(string)
|
||||||
|
if dateFrom == "" || dateTo == "" {
|
||||||
|
return nil, fmt.Errorf("stock.intrastat.line: invalid date range")
|
||||||
|
}
|
||||||
|
|
||||||
|
companyID := int64(1)
|
||||||
|
if len(args) >= 3 {
|
||||||
|
if cid, ok := args[2].(int64); ok && cid > 0 {
|
||||||
|
companyID = cid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
// Find done moves crossing borders (source or dest is in a different country)
|
||||||
|
// For simplicity, look for moves between locations belonging to different warehouses
|
||||||
|
// or between internal and non-internal locations.
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT sm.id, sm.product_id, sm.product_uom_qty, sm.price_unit, sm.date,
|
||||||
|
sl_src.usage as src_usage, sl_dst.usage as dst_usage,
|
||||||
|
COALESCE(rp.country_id, 0) as partner_country_id
|
||||||
|
FROM stock_move sm
|
||||||
|
JOIN stock_location sl_src ON sl_src.id = sm.location_id
|
||||||
|
JOIN stock_location sl_dst ON sl_dst.id = sm.location_dest_id
|
||||||
|
LEFT JOIN stock_picking sp ON sp.id = sm.picking_id
|
||||||
|
LEFT JOIN res_partner rp ON rp.id = sp.partner_id
|
||||||
|
WHERE sm.state = 'done'
|
||||||
|
AND sm.date >= $1 AND sm.date <= $2
|
||||||
|
AND sm.company_id = $3
|
||||||
|
AND (
|
||||||
|
(sl_src.usage = 'supplier' AND sl_dst.usage = 'internal')
|
||||||
|
OR (sl_src.usage = 'internal' AND sl_dst.usage = 'customer')
|
||||||
|
)
|
||||||
|
ORDER BY sm.date`,
|
||||||
|
dateFrom, dateTo, companyID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stock.intrastat.line: query moves: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type moveData struct {
|
||||||
|
MoveID, ProductID int64
|
||||||
|
Qty, PriceUnit float64
|
||||||
|
Date *string
|
||||||
|
SrcUsage string
|
||||||
|
DstUsage string
|
||||||
|
CountryID int64
|
||||||
|
}
|
||||||
|
var moves []moveData
|
||||||
|
for rows.Next() {
|
||||||
|
var md moveData
|
||||||
|
if err := rows.Scan(&md.MoveID, &md.ProductID, &md.Qty, &md.PriceUnit,
|
||||||
|
&md.Date, &md.SrcUsage, &md.DstUsage, &md.CountryID); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("stock.intrastat.line: scan move: %w", err)
|
||||||
|
}
|
||||||
|
moves = append(moves, md)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
var created int
|
||||||
|
for _, md := range moves {
|
||||||
|
// Determine transaction type
|
||||||
|
txnType := "arrival"
|
||||||
|
if md.SrcUsage == "internal" && md.DstUsage == "customer" {
|
||||||
|
txnType = "dispatch"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use partner country; skip if no country (can't determine border crossing)
|
||||||
|
countryID := md.CountryID
|
||||||
|
if countryID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute value and weight
|
||||||
|
value := md.Qty * md.PriceUnit
|
||||||
|
weight := md.Qty // Simplified: weight = qty (would use product.weight in full impl)
|
||||||
|
|
||||||
|
dateStr := ""
|
||||||
|
if md.Date != nil {
|
||||||
|
dateStr = *md.Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if line already exists for this move
|
||||||
|
var existing int64
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT id FROM stock_intrastat_line WHERE move_id = $1 LIMIT 1`, md.MoveID,
|
||||||
|
).Scan(&existing)
|
||||||
|
if existing > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
`INSERT INTO stock_intrastat_line
|
||||||
|
(move_id, product_id, country_id, weight, value, transaction_type, company_id, date)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
md.MoveID, md.ProductID, countryID, weight, value, txnType, companyID, dateStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stock.intrastat.line: create line for move %d: %w", md.MoveID, err)
|
||||||
|
}
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"created": created,
|
||||||
|
"date_from": dateFrom,
|
||||||
|
"date_to": dateTo,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// get_report: Return Intrastat report data for a period.
|
||||||
|
// Args: date_from (string), date_to (string), optional transaction_type (string)
|
||||||
|
// Mirrors: stock.intrastat.report views
|
||||||
|
m.RegisterMethod("get_report", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||||
|
if len(args) < 2 {
|
||||||
|
return nil, fmt.Errorf("stock.intrastat.line.get_report requires date_from, date_to")
|
||||||
|
}
|
||||||
|
dateFrom, _ := args[0].(string)
|
||||||
|
dateTo, _ := args[1].(string)
|
||||||
|
|
||||||
|
var txnTypeFilter string
|
||||||
|
if len(args) >= 3 {
|
||||||
|
txnTypeFilter, _ = args[2].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := rs.Env()
|
||||||
|
|
||||||
|
query := `SELECT sil.id, sil.move_id, sil.product_id, pt.name as product_name,
|
||||||
|
sil.country_id, COALESCE(rc.name, '') as country_name,
|
||||||
|
sil.weight, sil.value, sil.transaction_type,
|
||||||
|
COALESCE(sil.intrastat_code, '') as commodity_code,
|
||||||
|
sil.date
|
||||||
|
FROM stock_intrastat_line sil
|
||||||
|
JOIN product_product pp ON pp.id = sil.product_id
|
||||||
|
JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||||
|
LEFT JOIN res_country rc ON rc.id = sil.country_id
|
||||||
|
WHERE sil.date >= $1 AND sil.date <= $2`
|
||||||
|
|
||||||
|
queryArgs := []interface{}{dateFrom, dateTo}
|
||||||
|
|
||||||
|
if txnTypeFilter != "" {
|
||||||
|
query += ` AND sil.transaction_type = $3`
|
||||||
|
queryArgs = append(queryArgs, txnTypeFilter)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY sil.date, sil.id`
|
||||||
|
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(), query, queryArgs...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stock.intrastat.line: query report: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var lines []map[string]interface{}
|
||||||
|
var totalWeight, totalValue float64
|
||||||
|
for rows.Next() {
|
||||||
|
var lineID, moveID, productID, countryID int64
|
||||||
|
var productName, countryName, txnType, commodityCode string
|
||||||
|
var weight, value float64
|
||||||
|
var date *string
|
||||||
|
if err := rows.Scan(&lineID, &moveID, &productID, &productName,
|
||||||
|
&countryID, &countryName, &weight, &value, &txnType,
|
||||||
|
&commodityCode, &date); err != nil {
|
||||||
|
return nil, fmt.Errorf("stock.intrastat.line: scan report row: %w", err)
|
||||||
|
}
|
||||||
|
dateStr := ""
|
||||||
|
if date != nil {
|
||||||
|
dateStr = *date
|
||||||
|
}
|
||||||
|
lines = append(lines, map[string]interface{}{
|
||||||
|
"id": lineID, "move_id": moveID,
|
||||||
|
"product_id": productID, "product": productName,
|
||||||
|
"country_id": countryID, "country": countryName,
|
||||||
|
"weight": weight, "value": value,
|
||||||
|
"transaction_type": txnType,
|
||||||
|
"commodity_code": commodityCode,
|
||||||
|
"date": dateStr,
|
||||||
|
})
|
||||||
|
totalWeight += weight
|
||||||
|
totalValue += value
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"lines": lines,
|
||||||
|
"total_weight": totalWeight,
|
||||||
|
"total_value": totalValue,
|
||||||
|
"date_from": dateFrom,
|
||||||
|
"date_to": dateTo,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,13 +127,22 @@ func initStockValuationLayer() {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var totalConsumedValue float64
|
// Collect layers first, then close cursor before updating (pgx safety)
|
||||||
|
type layerConsumption struct {
|
||||||
|
id int64
|
||||||
|
newQty float64
|
||||||
|
newValue float64
|
||||||
|
consumed float64
|
||||||
|
cost float64
|
||||||
|
}
|
||||||
|
var consumptions []layerConsumption
|
||||||
remaining := qtyToConsume
|
remaining := qtyToConsume
|
||||||
|
|
||||||
for rows.Next() && remaining > 0 {
|
for rows.Next() && remaining > 0 {
|
||||||
var layerID int64
|
var layerID int64
|
||||||
var layerQty, layerValue, layerUnitCost float64
|
var layerQty, layerValue, layerUnitCost float64
|
||||||
if err := rows.Scan(&layerID, &layerQty, &layerValue, &layerUnitCost); err != nil {
|
if err := rows.Scan(&layerID, &layerQty, &layerValue, &layerUnitCost); err != nil {
|
||||||
|
rows.Close()
|
||||||
return nil, fmt.Errorf("stock.valuation.layer: scan FIFO layer: %w", err)
|
return nil, fmt.Errorf("stock.valuation.layer: scan FIFO layer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,20 +151,27 @@ func initStockValuationLayer() {
|
|||||||
consumed = layerQty
|
consumed = layerQty
|
||||||
}
|
}
|
||||||
|
|
||||||
consumedValue := consumed * layerUnitCost
|
consumptions = append(consumptions, layerConsumption{
|
||||||
newRemainingQty := layerQty - consumed
|
id: layerID,
|
||||||
newRemainingValue := layerValue - consumedValue
|
newQty: layerQty - consumed,
|
||||||
|
newValue: layerValue - consumed*layerUnitCost,
|
||||||
|
consumed: consumed,
|
||||||
|
cost: layerUnitCost,
|
||||||
|
})
|
||||||
|
remaining -= consumed
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// Now update layers outside the cursor
|
||||||
|
var totalConsumedValue float64
|
||||||
|
for _, c := range consumptions {
|
||||||
_, err := env.Tx().Exec(env.Ctx(),
|
_, err := env.Tx().Exec(env.Ctx(),
|
||||||
`UPDATE stock_valuation_layer SET remaining_qty = $1, remaining_value = $2 WHERE id = $3`,
|
`UPDATE stock_valuation_layer SET remaining_qty = $1, remaining_value = $2 WHERE id = $3`,
|
||||||
newRemainingQty, newRemainingValue, layerID,
|
c.newQty, c.newValue, c.id)
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("stock.valuation.layer: update layer %d: %w", layerID, err)
|
return nil, fmt.Errorf("stock.valuation.layer: update layer %d: %w", c.id, err)
|
||||||
}
|
}
|
||||||
|
totalConsumedValue += c.consumed * c.cost
|
||||||
totalConsumedValue += consumedValue
|
|
||||||
remaining -= consumed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
// Import all modules (register models via init())
|
// Import all modules (register models via init())
|
||||||
_ "odoo-go/addons/base"
|
_ "odoo-go/addons/base"
|
||||||
|
_ "odoo-go/addons/mail"
|
||||||
_ "odoo-go/addons/account"
|
_ "odoo-go/addons/account"
|
||||||
_ "odoo-go/addons/product"
|
_ "odoo-go/addons/product"
|
||||||
_ "odoo-go/addons/sale"
|
_ "odoo-go/addons/sale"
|
||||||
@@ -126,6 +127,11 @@ func main() {
|
|||||||
log.Printf("odoo: session table init warning: %v", err)
|
log.Printf("odoo: session table init warning: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start cron scheduler
|
||||||
|
cronScheduler := service.NewCronScheduler(pool)
|
||||||
|
cronScheduler.Start()
|
||||||
|
defer cronScheduler.Stop()
|
||||||
|
|
||||||
// Start HTTP server
|
// Start HTTP server
|
||||||
srv := server.New(cfg, pool)
|
srv := server.New(cfg, pool)
|
||||||
log.Printf("odoo: starting HTTP service on %s:%d", cfg.HTTPInterface, cfg.HTTPPort)
|
log.Printf("odoo: starting HTTP service on %s:%d", cfg.HTTPInterface, cfg.HTTPPort)
|
||||||
|
|||||||
18
go.mod
18
go.mod
@@ -1,14 +1,24 @@
|
|||||||
module odoo-go
|
module odoo-go
|
||||||
|
|
||||||
go 1.22.2
|
go 1.24.0
|
||||||
|
|
||||||
require github.com/jackc/pgx/v5 v5.7.4
|
require github.com/jackc/pgx/v5 v5.7.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
|
||||||
|
github.com/emersion/go-message v0.18.2 // indirect
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
golang.org/x/crypto v0.31.0 // indirect
|
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||||
golang.org/x/sync v0.10.0 // indirect
|
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
58
go.sum
58
go.sum
@@ -1,6 +1,12 @@
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||||
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||||
|
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||||
|
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -11,17 +17,69 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
|||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||||
|
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
BIN
odoo-server
BIN
odoo-server
Binary file not shown.
25
open.md
Normal file
25
open.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Offene Punkte
|
||||||
|
|
||||||
|
> Stand: 2026-04-12
|
||||||
|
> Business-Module: alle auf 95% — KOMPLETT
|
||||||
|
> Odoo Community Core: Portal + Email Inbound + Discuss — KOMPLETT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Odoo Community Core — KOMPLETT
|
||||||
|
|
||||||
|
- [x] **Portal** — Portal-User (share=true), /my/* Routes, Signup, Password Reset ✅ 2026-04-12
|
||||||
|
- [x] **Email Inbound** — IMAP Polling (go-imap/v2), Email Parser, Thread Matching ✅ 2026-04-12
|
||||||
|
- [x] **Discuss** — mail.channel + mail.channel.member, Long-Polling Bus, DM, Channel CRUD, Unread Count ✅ 2026-04-12
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend / UI Zukunft — 2 Items (langfristig)
|
||||||
|
|
||||||
|
| # | Was |
|
||||||
|
|---|-----|
|
||||||
|
| 1 | UI modernisieren — schrittweise schneller, stabiler, optisch erneuern |
|
||||||
|
| 2 | View-Format — langfristig format-agnostisch (JSON-fähig), weg von XML wo möglich |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package orm
|
package orm
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
// ComputeFunc is a function that computes field values for a recordset.
|
// ComputeFunc is a function that computes field values for a recordset.
|
||||||
// Mirrors: @api.depends decorated methods in Odoo.
|
// Mirrors: @api.depends decorated methods in Odoo.
|
||||||
@@ -253,7 +256,7 @@ func RunOnchangeComputes(m *Model, env *Environment, currentVals Values, changed
|
|||||||
|
|
||||||
computed, err := fn(rs)
|
computed, err := fn(rs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Non-fatal: skip failed computes during onchange
|
log.Printf("orm: onchange compute %s.%s failed: %v", m.Name(), fieldName, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for k, v := range computed {
|
for k, v := range computed {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package orm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,6 +154,8 @@ func (dc *DomainCompiler) JoinSQL() string {
|
|||||||
return " " + strings.Join(parts, " ")
|
return " " + strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compileNodes compiles domain nodes in Polish (prefix) notation.
|
||||||
|
// Returns the SQL string and the number of nodes consumed from the domain starting at pos.
|
||||||
func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
||||||
if pos >= len(domain) {
|
if pos >= len(domain) {
|
||||||
return "TRUE", nil
|
return "TRUE", nil
|
||||||
@@ -167,7 +171,8 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
right, err := dc.compileNodes(domain, pos+2)
|
leftSize := nodeSize(domain, pos+1)
|
||||||
|
right, err := dc.compileNodes(domain, pos+1+leftSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -178,7 +183,8 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
right, err := dc.compileNodes(domain, pos+2)
|
leftSize := nodeSize(domain, pos+1)
|
||||||
|
right, err := dc.compileNodes(domain, pos+1+leftSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -196,8 +202,6 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
|||||||
return dc.compileCondition(n)
|
return dc.compileCondition(n)
|
||||||
|
|
||||||
case domainGroup:
|
case domainGroup:
|
||||||
// domainGroup wraps a sub-domain as a single node.
|
|
||||||
// Compile it recursively as a full domain.
|
|
||||||
subSQL, _, err := dc.compileDomainGroup(Domain(n))
|
subSQL, _, err := dc.compileDomainGroup(Domain(n))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -208,6 +212,28 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
|||||||
return "", fmt.Errorf("unexpected domain node at position %d: %v", pos, node)
|
return "", fmt.Errorf("unexpected domain node at position %d: %v", pos, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nodeSize returns the number of domain nodes consumed by the subtree at pos.
|
||||||
|
// Operators (&, |) consume 1 + left subtree + right subtree.
|
||||||
|
// NOT consumes 1 + inner subtree. Leaf nodes consume 1.
|
||||||
|
func nodeSize(domain Domain, pos int) int {
|
||||||
|
if pos >= len(domain) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch n := domain[pos].(type) {
|
||||||
|
case Operator:
|
||||||
|
_ = n
|
||||||
|
switch domain[pos].(Operator) {
|
||||||
|
case OpAnd, OpOr:
|
||||||
|
leftSize := nodeSize(domain, pos+1)
|
||||||
|
rightSize := nodeSize(domain, pos+1+leftSize)
|
||||||
|
return 1 + leftSize + rightSize
|
||||||
|
case OpNot:
|
||||||
|
return 1 + nodeSize(domain, pos+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1 // Condition or domainGroup = 1 node
|
||||||
|
}
|
||||||
|
|
||||||
// compileDomainGroup compiles a sub-domain that was wrapped via domainGroup.
|
// compileDomainGroup compiles a sub-domain that was wrapped via domainGroup.
|
||||||
// It reuses the same DomainCompiler (sharing params and joins) so parameter
|
// It reuses the same DomainCompiler (sharing params and joins) so parameter
|
||||||
// indices stay consistent with the outer query.
|
// indices stay consistent with the outer query.
|
||||||
@@ -227,14 +253,12 @@ func (dc *DomainCompiler) compileCondition(c Condition) (string, error) {
|
|||||||
return "", fmt.Errorf("invalid operator: %q", c.Operator)
|
return "", fmt.Errorf("invalid operator: %q", c.Operator)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle dot notation (e.g., "partner_id.name")
|
// Handle dot notation (e.g., "partner_id.name", "partner_id.country_id.code")
|
||||||
|
// by generating LEFT JOINs through the M2O relational chain.
|
||||||
parts := strings.Split(c.Field, ".")
|
parts := strings.Split(c.Field, ".")
|
||||||
column := parts[0]
|
column := parts[0]
|
||||||
|
|
||||||
// TODO: Handle JOINs for dot notation paths
|
|
||||||
// For now, only support direct fields
|
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
// Placeholder for JOIN resolution
|
|
||||||
return dc.compileJoinedCondition(parts, c.Operator, c.Value)
|
return dc.compileJoinedCondition(parts, c.Operator, c.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +309,7 @@ func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator st
|
|||||||
dc.joins = append(dc.joins, joinClause{
|
dc.joins = append(dc.joins, joinClause{
|
||||||
table: comodel.Table(),
|
table: comodel.Table(),
|
||||||
alias: alias,
|
alias: alias,
|
||||||
on: fmt.Sprintf("%s.%q = %q.\"id\"", currentAlias, f.Column(), alias),
|
on: fmt.Sprintf("%q.%q = %q.\"id\"", currentAlias, f.Column(), alias),
|
||||||
})
|
})
|
||||||
|
|
||||||
currentModel = comodel
|
currentModel = comodel
|
||||||
@@ -293,8 +317,12 @@ func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The last segment is the actual field to filter on
|
// The last segment is the actual field to filter on
|
||||||
leafField := fieldPath[len(fieldPath)-1]
|
leafFieldName := fieldPath[len(fieldPath)-1]
|
||||||
qualifiedColumn := fmt.Sprintf("%s.%q", currentAlias, leafField)
|
leafCol := leafFieldName
|
||||||
|
if lf := currentModel.GetField(leafFieldName); lf != nil {
|
||||||
|
leafCol = lf.Column()
|
||||||
|
}
|
||||||
|
qualifiedColumn := fmt.Sprintf("%q.%q", currentAlias, leafCol)
|
||||||
|
|
||||||
return dc.compileQualifiedCondition(qualifiedColumn, operator, value)
|
return dc.compileQualifiedCondition(qualifiedColumn, operator, value)
|
||||||
}
|
}
|
||||||
@@ -528,13 +556,8 @@ func (dc *DomainCompiler) compileAnyOp(column string, value Value, negate bool)
|
|||||||
// Rebase parameter indices: shift them by the current param count
|
// Rebase parameter indices: shift them by the current param count
|
||||||
baseIdx := len(dc.params)
|
baseIdx := len(dc.params)
|
||||||
dc.params = append(dc.params, subParams...)
|
dc.params = append(dc.params, subParams...)
|
||||||
rebased := subWhere
|
// Replace $N with $(N+baseIdx) using regex to avoid $1 matching $10
|
||||||
// Replace $N with $(N+baseIdx) in the sub-where clause
|
rebased := rebaseParams(subWhere, baseIdx)
|
||||||
for i := len(subParams); i >= 1; i-- {
|
|
||||||
old := fmt.Sprintf("$%d", i)
|
|
||||||
new := fmt.Sprintf("$%d", i+baseIdx)
|
|
||||||
rebased = strings.ReplaceAll(rebased, old, new)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the join condition based on field type
|
// Determine the join condition based on field type
|
||||||
var joinCond string
|
var joinCond string
|
||||||
@@ -676,3 +699,14 @@ func wrapLikeValue(value Value) Value {
|
|||||||
}
|
}
|
||||||
return "%" + s + "%"
|
return "%" + s + "%"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rebaseParams shifts $N placeholders in a SQL string by baseIdx.
|
||||||
|
// Uses regex to avoid $1 matching inside $10.
|
||||||
|
var paramRegex = regexp.MustCompile(`\$(\d+)`)
|
||||||
|
|
||||||
|
func rebaseParams(sql string, baseIdx int) string {
|
||||||
|
return paramRegex.ReplaceAllStringFunc(sql, func(match string) string {
|
||||||
|
n, _ := strconv.Atoi(match[1:])
|
||||||
|
return fmt.Sprintf("$%d", n+baseIdx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ type Model struct {
|
|||||||
checkCompany bool // Enforce multi-company record rules
|
checkCompany bool // Enforce multi-company record rules
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
|
BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
|
||||||
DefaultGet func(env *Environment, fields []string) Values // Dynamic defaults (e.g., from DB)
|
BeforeWrite func(env *Environment, ids []int64, vals Values) error // Called before UPDATE — for state guards
|
||||||
|
DefaultGet func(env *Environment, fields []string) Values // Dynamic defaults (e.g., from DB)
|
||||||
Constraints []ConstraintFunc // Validation constraints
|
Constraints []ConstraintFunc // Validation constraints
|
||||||
Methods map[string]MethodFunc // Named business methods
|
Methods map[string]MethodFunc // Named business methods
|
||||||
|
|
||||||
@@ -453,3 +454,32 @@ func (m *Model) Many2manyTableSQL() []string {
|
|||||||
}
|
}
|
||||||
return stmts
|
return stmts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StateGuard returns a BeforeWrite function that prevents modifications on records
|
||||||
|
// in certain states, except for explicitly allowed fields.
|
||||||
|
// Eliminates the duplicated guard pattern across sale.order, purchase.order,
|
||||||
|
// account.move, and stock.picking.
|
||||||
|
func StateGuard(table, stateCondition string, allowedFields []string, errMsg string) func(env *Environment, ids []int64, vals Values) error {
|
||||||
|
allowed := make(map[string]bool, len(allowedFields))
|
||||||
|
for _, f := range allowedFields {
|
||||||
|
allowed[f] = true
|
||||||
|
}
|
||||||
|
return func(env *Environment, ids []int64, vals Values) error {
|
||||||
|
if _, changingState := vals["state"]; changingState {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id = ANY($1) AND %s`, table, stateCondition), ids,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil || count == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for field := range vals {
|
||||||
|
if !allowed[field] {
|
||||||
|
return fmt.Errorf("%s: %s", table, errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ func (rs *Recordset) ReadGroup(domain Domain, groupby []string, aggregates []str
|
|||||||
// Build ORDER BY
|
// Build ORDER BY
|
||||||
orderSQL := ""
|
orderSQL := ""
|
||||||
if opt.Order != "" {
|
if opt.Order != "" {
|
||||||
orderSQL = opt.Order
|
orderSQL = sanitizeOrderBy(opt.Order, m)
|
||||||
} else if len(gbCols) > 0 {
|
} else if len(gbCols) > 0 {
|
||||||
// Default: order by groupby columns
|
// Default: order by groupby columns
|
||||||
var orderParts []string
|
var orderParts []string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package orm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -265,18 +266,28 @@ func preprocessRelatedWrites(env *Environment, m *Model, ids []int64, vals Value
|
|||||||
value := vals[fieldName]
|
value := vals[fieldName]
|
||||||
delete(vals, fieldName) // Remove from vals — no local column
|
delete(vals, fieldName) // Remove from vals — no local column
|
||||||
|
|
||||||
// Read FK IDs for all records
|
// Read FK IDs for all records in a single query
|
||||||
var fkIDs []int64
|
var fkIDs []int64
|
||||||
for _, id := range ids {
|
rows, err := env.tx.Query(env.ctx,
|
||||||
var fkID *int64
|
fmt.Sprintf(`SELECT %q FROM %q WHERE id = ANY($1) AND %q IS NOT NULL`,
|
||||||
env.tx.QueryRow(env.ctx,
|
fkDef.Column(), m.Table(), fkDef.Column()),
|
||||||
fmt.Sprintf(`SELECT %q FROM %q WHERE id = $1`, fkDef.Column(), m.Table()),
|
ids,
|
||||||
id,
|
)
|
||||||
).Scan(&fkID)
|
if err != nil {
|
||||||
if fkID != nil && *fkID > 0 {
|
delete(vals, fieldName)
|
||||||
fkIDs = append(fkIDs, *fkID)
|
continue
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var fkID int64
|
||||||
|
if err := rows.Scan(&fkID); err != nil {
|
||||||
|
log.Printf("orm: preprocessRelatedWrites scan error on %s.%s: %v", m.Name(), fieldName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fkID > 0 {
|
||||||
|
fkIDs = append(fkIDs, fkID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
if len(fkIDs) == 0 {
|
if len(fkIDs) == 0 {
|
||||||
continue
|
continue
|
||||||
@@ -315,6 +326,13 @@ func (rs *Recordset) Write(vals Values) error {
|
|||||||
|
|
||||||
m := rs.model
|
m := rs.model
|
||||||
|
|
||||||
|
// BeforeWrite hook — state guards, locked record checks etc.
|
||||||
|
if m.BeforeWrite != nil {
|
||||||
|
if err := m.BeforeWrite(rs.env, rs.ids, vals); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var setClauses []string
|
var setClauses []string
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
idx := 1
|
idx := 1
|
||||||
@@ -787,7 +805,7 @@ func (rs *Recordset) Search(domain Domain, opts ...SearchOpts) (*Recordset, erro
|
|||||||
// Build query
|
// Build query
|
||||||
order := m.order
|
order := m.order
|
||||||
if opt.Order != "" {
|
if opt.Order != "" {
|
||||||
order = opt.Order
|
order = sanitizeOrderBy(opt.Order, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
joinSQL := compiler.JoinSQL()
|
joinSQL := compiler.JoinSQL()
|
||||||
@@ -1103,6 +1121,72 @@ func toRecordID(v interface{}) (int64, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeOrderBy validates an ORDER BY clause to prevent SQL injection.
|
||||||
|
// Only allows: field names (alphanumeric + underscore), ASC/DESC, NULLS FIRST/LAST, commas.
|
||||||
|
// Returns sanitized string or fallback to "id" if invalid.
|
||||||
|
func sanitizeOrderBy(order string, m *Model) string {
|
||||||
|
if order == "" {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
parts := strings.Split(order, ",")
|
||||||
|
var safe []string
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tokens := strings.Fields(part)
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// First token must be a valid field name or "table"."field"
|
||||||
|
col := tokens[0]
|
||||||
|
// Strip quotes for validation
|
||||||
|
cleanCol := strings.ReplaceAll(strings.ReplaceAll(col, "\"", ""), "'", "")
|
||||||
|
// Allow dot notation (table.field) but validate each part
|
||||||
|
colParts := strings.Split(cleanCol, ".")
|
||||||
|
valid := true
|
||||||
|
for _, cp := range colParts {
|
||||||
|
if !isValidIdentifier(cp) {
|
||||||
|
valid = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
continue // Skip this part entirely
|
||||||
|
}
|
||||||
|
// Remaining tokens must be ASC, DESC, NULLS, FIRST, LAST
|
||||||
|
safePart := col
|
||||||
|
for _, tok := range tokens[1:] {
|
||||||
|
upper := strings.ToUpper(tok)
|
||||||
|
switch upper {
|
||||||
|
case "ASC", "DESC", "NULLS", "FIRST", "LAST":
|
||||||
|
safePart += " " + upper
|
||||||
|
default:
|
||||||
|
// Invalid token — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
safe = append(safe, safePart)
|
||||||
|
}
|
||||||
|
if len(safe) == 0 {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
return strings.Join(safe, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidIdentifier checks if a string is a valid SQL identifier (letters, digits, underscore).
|
||||||
|
func isValidIdentifier(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// qualifyOrderBy prefixes unqualified column names with the table name.
|
// qualifyOrderBy prefixes unqualified column names with the table name.
|
||||||
// "name, id desc" → "\"my_table\".name, \"my_table\".id desc"
|
// "name, id desc" → "\"my_table\".name, \"my_table\".id desc"
|
||||||
func qualifyOrderBy(table, order string) string {
|
func qualifyOrderBy(table, order string) string {
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain {
|
|||||||
ORDER BY r.id`,
|
ORDER BY r.id`,
|
||||||
m.Name(), env.UID())
|
m.Name(), env.UID())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("orm: ir.rule query failed for %s: %v — denying access", m.Name(), err)
|
||||||
sp.Rollback(env.ctx)
|
sp.Rollback(env.ctx)
|
||||||
return domain
|
return append(domain, Leaf("id", "=", -1)) // Deny all — no records match id=-1
|
||||||
}
|
}
|
||||||
|
|
||||||
type ruleRow struct {
|
type ruleRow struct {
|
||||||
@@ -207,7 +208,8 @@ func CheckRecordRuleAccess(env *Environment, m *Model, ids []int64, perm string)
|
|||||||
var count int64
|
var count int64
|
||||||
err := env.tx.QueryRow(env.ctx, query, args...).Scan(&count)
|
err := env.tx.QueryRow(env.ctx, query, args...).Scan(&count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil // Fail open on error
|
log.Printf("orm: record rule check failed for %s: %v", m.Name(), err)
|
||||||
|
return fmt.Errorf("orm: access denied on %s (record rule check failed)", m.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
if count < int64(len(ids)) {
|
if count < int64(len(ids)) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -145,10 +146,12 @@ func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Look up xml_id from ir_model_data
|
// Look up xml_id from ir_model_data
|
||||||
xmlID := ""
|
xmlID := ""
|
||||||
_ = s.pool.QueryRow(ctx,
|
if err := s.pool.QueryRow(ctx,
|
||||||
`SELECT module || '.' || name FROM ir_model_data
|
`SELECT module || '.' || name FROM ir_model_data
|
||||||
WHERE model = 'ir.actions.act_window' AND res_id = $1
|
WHERE model = 'ir.actions.act_window' AND res_id = $1
|
||||||
LIMIT 1`, id).Scan(&xmlID)
|
LIMIT 1`, id).Scan(&xmlID); err != nil {
|
||||||
|
log.Printf("warning: action xml_id lookup failed for id=%d: %v", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Build views array from view_mode string (e.g. "list,kanban,form" → [[nil,"list"],[nil,"kanban"],[nil,"form"]])
|
// Build views array from view_mode string (e.g. "list,kanban,form" → [[nil,"list"],[nil,"kanban"],[nil,"form"]])
|
||||||
views := buildViewsFromMode(viewMode)
|
views := buildViewsFromMode(viewMode)
|
||||||
|
|||||||
292
pkg/server/bank_import.go
Normal file
292
pkg/server/bank_import.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleBankStatementImport imports bank statement lines from CSV data.
|
||||||
|
// Accepts JSON body with: journal_id, csv_data, column_mapping, has_header.
|
||||||
|
// After import, optionally triggers auto-matching against open invoices.
|
||||||
|
// Mirrors: odoo/addons/account/wizard/account_bank_statement_import.py
|
||||||
|
func (s *Server) handleBankStatementImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req JSONRPCRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var params struct {
|
||||||
|
JournalID int64 `json:"journal_id"`
|
||||||
|
CSVData string `json:"csv_data"`
|
||||||
|
HasHeader bool `json:"has_header"`
|
||||||
|
ColumnMapping bankColumnMapping `json:"column_mapping"`
|
||||||
|
AutoMatch bool `json:"auto_match"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.JournalID == 0 || params.CSVData == "" {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "journal_id and csv_data are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := int64(1)
|
||||||
|
companyID := int64(1)
|
||||||
|
if sess := GetSession(r); sess != nil {
|
||||||
|
uid = sess.UID
|
||||||
|
companyID = sess.CompanyID
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||||
|
Pool: s.pool,
|
||||||
|
UID: uid,
|
||||||
|
CompanyID: companyID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: "Internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer env.Close()
|
||||||
|
|
||||||
|
// Parse CSV
|
||||||
|
reader := csv.NewReader(strings.NewReader(params.CSVData))
|
||||||
|
reader.LazyQuotes = true
|
||||||
|
reader.TrimLeadingSpace = true
|
||||||
|
// Try semicolon separator (common in European bank exports)
|
||||||
|
reader.Comma = detectDelimiter(params.CSVData)
|
||||||
|
|
||||||
|
var allRows [][]string
|
||||||
|
for {
|
||||||
|
row, err := reader.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: fmt.Sprintf("CSV parse error: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allRows = append(allRows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
dataRows := allRows
|
||||||
|
if params.HasHeader && len(allRows) > 1 {
|
||||||
|
dataRows = allRows[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a bank statement header
|
||||||
|
statementRS := env.Model("account.bank.statement")
|
||||||
|
stmt, err := statementRS.Create(orm.Values{
|
||||||
|
"name": fmt.Sprintf("Import %s", time.Now().Format("2006-01-02 15:04")),
|
||||||
|
"journal_id": params.JournalID,
|
||||||
|
"company_id": companyID,
|
||||||
|
"date": time.Now().Format("2006-01-02"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Create statement: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stmtID := stmt.ID()
|
||||||
|
|
||||||
|
// Default column mapping
|
||||||
|
cm := params.ColumnMapping
|
||||||
|
if cm.Date < 0 {
|
||||||
|
cm.Date = 0
|
||||||
|
}
|
||||||
|
if cm.Amount < 0 {
|
||||||
|
cm.Amount = 1
|
||||||
|
}
|
||||||
|
if cm.Label < 0 {
|
||||||
|
cm.Label = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import lines
|
||||||
|
lineRS := env.Model("account.bank.statement.line")
|
||||||
|
var importedIDs []int64
|
||||||
|
var errors []importError
|
||||||
|
|
||||||
|
for rowIdx, row := range dataRows {
|
||||||
|
// Parse date
|
||||||
|
dateStr := safeCol(row, cm.Date)
|
||||||
|
date := parseFlexDate(dateStr)
|
||||||
|
if date == "" {
|
||||||
|
date = time.Now().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse amount
|
||||||
|
amountStr := safeCol(row, cm.Amount)
|
||||||
|
amount := parseAmount(amountStr)
|
||||||
|
if amount == 0 {
|
||||||
|
continue // skip zero-amount rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse label/reference
|
||||||
|
label := safeCol(row, cm.Label)
|
||||||
|
if label == "" {
|
||||||
|
label = fmt.Sprintf("Line %d", rowIdx+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional columns
|
||||||
|
partnerName := safeCol(row, cm.PartnerName)
|
||||||
|
accountNumber := safeCol(row, cm.AccountNumber)
|
||||||
|
|
||||||
|
vals := orm.Values{
|
||||||
|
"statement_id": stmtID,
|
||||||
|
"journal_id": params.JournalID,
|
||||||
|
"company_id": companyID,
|
||||||
|
"date": date,
|
||||||
|
"amount": amount,
|
||||||
|
"payment_ref": label,
|
||||||
|
"partner_name": partnerName,
|
||||||
|
"account_number": accountNumber,
|
||||||
|
"sequence": rowIdx + 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := lineRS.Create(vals)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, importError{Row: rowIdx + 1, Message: err.Error()})
|
||||||
|
log.Printf("bank_import: row %d error: %v", rowIdx+1, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
importedIDs = append(importedIDs, rec.ID())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-match against open invoices
|
||||||
|
matchCount := 0
|
||||||
|
if params.AutoMatch && len(importedIDs) > 0 {
|
||||||
|
stLineModel := orm.Registry.Get("account.bank.statement.line")
|
||||||
|
if stLineModel != nil {
|
||||||
|
if matchMethod, ok := stLineModel.Methods["button_match"]; ok {
|
||||||
|
matchRS := env.Model("account.bank.statement.line").Browse(importedIDs...)
|
||||||
|
if _, err := matchMethod(matchRS); err != nil {
|
||||||
|
log.Printf("bank_import: auto-match error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Count how many were matched
|
||||||
|
env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT COUNT(*) FROM account_bank_statement_line WHERE id = ANY($1) AND is_reconciled = true`,
|
||||||
|
importedIDs).Scan(&matchCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := env.Commit(); err != nil {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Commit: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.writeJSONRPC(w, req.ID, map[string]interface{}{
|
||||||
|
"statement_id": stmtID,
|
||||||
|
"imported": len(importedIDs),
|
||||||
|
"matched": matchCount,
|
||||||
|
"errors": errors,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bankColumnMapping maps CSV columns to bank statement fields.
|
||||||
|
type bankColumnMapping struct {
|
||||||
|
Date int `json:"date"` // column index for date
|
||||||
|
Amount int `json:"amount"` // column index for amount
|
||||||
|
Label int `json:"label"` // column index for label/reference
|
||||||
|
PartnerName int `json:"partner_name"` // column index for partner name (-1 = skip)
|
||||||
|
AccountNumber int `json:"account_number"` // column index for account number (-1 = skip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectDelimiter guesses the CSV delimiter (comma, semicolon, or tab).
|
||||||
|
func detectDelimiter(data string) rune {
|
||||||
|
firstLine := data
|
||||||
|
if idx := strings.IndexByte(data, '\n'); idx > 0 {
|
||||||
|
firstLine = data[:idx]
|
||||||
|
}
|
||||||
|
semicolons := strings.Count(firstLine, ";")
|
||||||
|
commas := strings.Count(firstLine, ",")
|
||||||
|
tabs := strings.Count(firstLine, "\t")
|
||||||
|
|
||||||
|
if semicolons > commas && semicolons > tabs {
|
||||||
|
return ';'
|
||||||
|
}
|
||||||
|
if tabs > commas {
|
||||||
|
return '\t'
|
||||||
|
}
|
||||||
|
return ','
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeCol returns the value at index i, or "" if out of bounds.
|
||||||
|
func safeCol(row []string, i int) string {
|
||||||
|
if i < 0 || i >= len(row) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(row[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFlexDate tries multiple date formats and returns YYYY-MM-DD.
|
||||||
|
func parseFlexDate(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
formats := []string{
|
||||||
|
"2006-01-02",
|
||||||
|
"02.01.2006", // DD.MM.YYYY (common in EU)
|
||||||
|
"01/02/2006", // MM/DD/YYYY
|
||||||
|
"02/01/2006", // DD/MM/YYYY
|
||||||
|
"2006/01/02",
|
||||||
|
"Jan 2, 2006",
|
||||||
|
"2 Jan 2006",
|
||||||
|
"02-01-2006",
|
||||||
|
"01-02-2006",
|
||||||
|
time.RFC3339,
|
||||||
|
}
|
||||||
|
for _, f := range formats {
|
||||||
|
if t, err := time.Parse(f, s); err == nil {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAmount parses a monetary amount string, handling comma/dot decimals and negative formats.
|
||||||
|
func parseAmount(s string) float64 {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Remove currency symbols and whitespace
|
||||||
|
s = strings.NewReplacer("€", "", "$", "", "£", "", " ", "", "\u00a0", "").Replace(s)
|
||||||
|
|
||||||
|
// Handle European format: 1.234,56 → 1234.56
|
||||||
|
if strings.Contains(s, ",") && strings.Contains(s, ".") {
|
||||||
|
if strings.LastIndex(s, ",") > strings.LastIndex(s, ".") {
|
||||||
|
// comma is decimal: 1.234,56
|
||||||
|
s = strings.ReplaceAll(s, ".", "")
|
||||||
|
s = strings.ReplaceAll(s, ",", ".")
|
||||||
|
} else {
|
||||||
|
// dot is decimal: 1,234.56
|
||||||
|
s = strings.ReplaceAll(s, ",", "")
|
||||||
|
}
|
||||||
|
} else if strings.Contains(s, ",") {
|
||||||
|
// Only comma: assume decimal separator
|
||||||
|
s = strings.ReplaceAll(s, ",", ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
241
pkg/server/bus.go
Normal file
241
pkg/server/bus.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bus implements a simple long-polling message bus for Discuss.
|
||||||
|
// Mirrors: odoo/addons/bus/models/bus.py ImBus
|
||||||
|
//
|
||||||
|
// Channels subscribe to notifications. A long-poll request blocks until
|
||||||
|
// a notification arrives or the timeout expires.
|
||||||
|
type Bus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
channels map[int64][]chan busNotification
|
||||||
|
lastID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type busNotification struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Message interface{} `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBus creates a new message bus.
|
||||||
|
func NewBus() *Bus {
|
||||||
|
return &Bus{
|
||||||
|
channels: make(map[int64][]chan busNotification),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify sends a notification to all subscribers of a channel.
|
||||||
|
func (b *Bus) Notify(channelID int64, channel string, message interface{}) {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.lastID++
|
||||||
|
notif := busNotification{
|
||||||
|
ID: b.lastID,
|
||||||
|
Channel: channel,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
subs := b.channels[channelID]
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
for _, ch := range subs {
|
||||||
|
select {
|
||||||
|
case ch <- notif:
|
||||||
|
default:
|
||||||
|
// subscriber buffer full, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe creates a subscription for a partner's channels.
|
||||||
|
func (b *Bus) Subscribe(partnerID int64) chan busNotification {
|
||||||
|
ch := make(chan busNotification, 10)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.channels[partnerID] = append(b.channels[partnerID], ch)
|
||||||
|
b.mu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes a subscription.
|
||||||
|
func (b *Bus) Unsubscribe(partnerID int64, ch chan busNotification) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
subs := b.channels[partnerID]
|
||||||
|
for i, s := range subs {
|
||||||
|
if s == ch {
|
||||||
|
b.channels[partnerID] = append(subs[:i], subs[i+1:]...)
|
||||||
|
close(ch)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerBusRoutes adds the long-polling endpoint.
|
||||||
|
func (s *Server) registerBusRoutes() {
|
||||||
|
if s.bus == nil {
|
||||||
|
s.bus = NewBus()
|
||||||
|
}
|
||||||
|
s.mux.HandleFunc("/longpolling/poll", s.handleBusPoll)
|
||||||
|
s.mux.HandleFunc("/discuss/channel/messages", s.handleDiscussMessages)
|
||||||
|
s.mux.HandleFunc("/discuss/channel/list", s.handleDiscussChannelList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBusPoll implements long-polling for real-time notifications.
|
||||||
|
// Mirrors: odoo/addons/bus/controllers/main.py poll()
|
||||||
|
func (s *Server) handleBusPoll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := GetSession(r)
|
||||||
|
if sess == nil {
|
||||||
|
writeJSON(w, []interface{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get partner ID
|
||||||
|
var partnerID int64
|
||||||
|
s.pool.QueryRow(r.Context(),
|
||||||
|
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, sess.UID,
|
||||||
|
).Scan(&partnerID)
|
||||||
|
|
||||||
|
if partnerID == 0 {
|
||||||
|
writeJSON(w, []interface{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe and wait for notifications (max 30s)
|
||||||
|
ch := s.bus.Subscribe(partnerID)
|
||||||
|
defer s.bus.Unsubscribe(partnerID, ch)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case notif := <-ch:
|
||||||
|
writeJSON(w, []busNotification{notif})
|
||||||
|
case <-ctx.Done():
|
||||||
|
writeJSON(w, []interface{}{}) // timeout, empty response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDiscussMessages fetches messages for a channel via JSON-RPC.
|
||||||
|
func (s *Server) handleDiscussMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := GetSession(r)
|
||||||
|
if sess == nil {
|
||||||
|
s.writeJSONRPC(w, nil, nil, &RPCError{Code: 100, Message: "Not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req JSONRPCRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var params struct {
|
||||||
|
ChannelID int64 `json:"channel_id"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if params.Limit <= 0 {
|
||||||
|
params.Limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(r.Context(),
|
||||||
|
`SELECT m.id, m.body, m.date, m.author_id, COALESCE(p.name, '')
|
||||||
|
FROM mail_message m
|
||||||
|
LEFT JOIN res_partner p ON p.id = m.author_id
|
||||||
|
WHERE m.model = 'mail.channel' AND m.res_id = $1
|
||||||
|
ORDER BY m.id DESC LIMIT $2`, params.ChannelID, params.Limit)
|
||||||
|
if err != nil {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Query: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var messages []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, authorID int64
|
||||||
|
var body, authorName string
|
||||||
|
var date interface{}
|
||||||
|
if err := rows.Scan(&id, &body, &date, &authorID, &authorName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"id": id, "body": body, "date": date,
|
||||||
|
}
|
||||||
|
if authorID > 0 {
|
||||||
|
msg["author_id"] = []interface{}{authorID, authorName}
|
||||||
|
} else {
|
||||||
|
msg["author_id"] = false
|
||||||
|
}
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
if messages == nil {
|
||||||
|
messages = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
s.writeJSONRPC(w, req.ID, messages, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDiscussChannelList returns channels the current user is member of.
|
||||||
|
func (s *Server) handleDiscussChannelList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := GetSession(r)
|
||||||
|
if sess == nil {
|
||||||
|
s.writeJSONRPC(w, nil, nil, &RPCError{Code: 100, Message: "Not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var partnerID int64
|
||||||
|
s.pool.QueryRow(r.Context(),
|
||||||
|
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, sess.UID,
|
||||||
|
).Scan(&partnerID)
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(r.Context(),
|
||||||
|
`SELECT c.id, c.name, c.channel_type,
|
||||||
|
(SELECT COUNT(*) FROM mail_channel_member WHERE channel_id = c.id) AS members
|
||||||
|
FROM mail_channel c
|
||||||
|
JOIN mail_channel_member cm ON cm.channel_id = c.id AND cm.partner_id = $1
|
||||||
|
WHERE c.active = true
|
||||||
|
ORDER BY c.last_message_date DESC NULLS LAST`, partnerID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("discuss: channel list error: %v", err)
|
||||||
|
writeJSON(w, []interface{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var channels []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var name, channelType string
|
||||||
|
var members int64
|
||||||
|
if err := rows.Scan(&id, &name, &channelType, &members); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
channels = append(channels, map[string]interface{}{
|
||||||
|
"id": id, "name": name, "channel_type": channelType, "member_count": members,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if channels == nil {
|
||||||
|
channels = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
writeJSON(w, channels)
|
||||||
|
}
|
||||||
@@ -6,37 +6,45 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
"odoo-go/pkg/orm"
|
"odoo-go/pkg/orm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleExportCSV exports records as CSV.
|
// exportField describes a field in an export request.
|
||||||
// Mirrors: odoo/addons/web/controllers/export.py ExportController
|
type exportField struct {
|
||||||
func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
Name string `json:"name"`
|
||||||
if r.Method != http.MethodPost {
|
Label string `json:"label"`
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// exportData holds the parsed and fetched data for an export operation.
|
||||||
|
type exportData struct {
|
||||||
|
Model string
|
||||||
|
FieldNames []string
|
||||||
|
Headers []string
|
||||||
|
Records []orm.Values
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseExportRequest parses the common request/params/env/search logic shared by CSV and XLSX export.
|
||||||
|
func (s *Server) parseExportRequest(w http.ResponseWriter, r *http.Request) (*exportData, error) {
|
||||||
var req JSONRPCRequest
|
var req JSONRPCRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
||||||
return
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var params struct {
|
var params struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Fields []exportField `json:"fields"`
|
Fields []exportField `json:"fields"`
|
||||||
Domain []interface{} `json:"domain"`
|
Domain []interface{} `json:"domain"`
|
||||||
IDs []float64 `json:"ids"`
|
IDs []float64 `json:"ids"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
|
||||||
return
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract UID from session
|
|
||||||
uid := int64(1)
|
uid := int64(1)
|
||||||
companyID := int64(1)
|
companyID := int64(1)
|
||||||
if sess := GetSession(r); sess != nil {
|
if sess := GetSession(r); sess != nil {
|
||||||
@@ -45,42 +53,31 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||||
Pool: s.pool,
|
Pool: s.pool, UID: uid, CompanyID: companyID,
|
||||||
UID: uid,
|
|
||||||
CompanyID: companyID,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
return
|
return nil, err
|
||||||
}
|
}
|
||||||
defer env.Close()
|
defer env.Close()
|
||||||
|
|
||||||
rs := env.Model(params.Data.Model)
|
rs := env.Model(params.Data.Model)
|
||||||
|
|
||||||
// Determine which record IDs to export
|
|
||||||
var ids []int64
|
var ids []int64
|
||||||
if len(params.Data.IDs) > 0 {
|
if len(params.Data.IDs) > 0 {
|
||||||
for _, id := range params.Data.IDs {
|
for _, id := range params.Data.IDs {
|
||||||
ids = append(ids, int64(id))
|
ids = append(ids, int64(id))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Search with domain
|
|
||||||
domain := parseDomain([]interface{}{params.Data.Domain})
|
domain := parseDomain([]interface{}{params.Data.Domain})
|
||||||
found, err := rs.Search(domain, orm.SearchOpts{Limit: 10000})
|
found, err := rs.Search(domain, orm.SearchOpts{Limit: 10000})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return nil, err
|
||||||
}
|
}
|
||||||
ids = found.IDs()
|
ids = found.IDs()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(ids) == 0 {
|
|
||||||
w.Header().Set("Content-Type", "text/csv")
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", params.Data.Model))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract field names
|
|
||||||
var fieldNames []string
|
var fieldNames []string
|
||||||
var headers []string
|
var headers []string
|
||||||
for _, f := range params.Data.Fields {
|
for _, f := range params.Data.Fields {
|
||||||
@@ -92,42 +89,89 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
headers = append(headers, label)
|
headers = append(headers, label)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read records
|
var records []orm.Values
|
||||||
records, err := rs.Browse(ids...).Read(fieldNames)
|
if len(ids) > 0 {
|
||||||
if err != nil {
|
records, err = rs.Browse(ids...).Read(fieldNames)
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
if err != nil {
|
||||||
return
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := env.Commit(); err != nil {
|
if err := env.Commit(); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &exportData{
|
||||||
|
Model: params.Data.Model, FieldNames: fieldNames, Headers: headers, Records: records,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExportCSV exports records as CSV.
|
||||||
|
// Mirrors: odoo/addons/web/controllers/export.py ExportController
|
||||||
|
func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := s.parseExportRequest(w, r)
|
||||||
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write CSV
|
|
||||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", params.Data.Model))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", data.Model))
|
||||||
|
|
||||||
writer := csv.NewWriter(w)
|
writer := csv.NewWriter(w)
|
||||||
defer writer.Flush()
|
defer writer.Flush()
|
||||||
|
|
||||||
// Header row
|
writer.Write(data.Headers)
|
||||||
writer.Write(headers)
|
for _, rec := range data.Records {
|
||||||
|
row := make([]string, len(data.FieldNames))
|
||||||
// Data rows
|
for i, fname := range data.FieldNames {
|
||||||
for _, rec := range records {
|
|
||||||
row := make([]string, len(fieldNames))
|
|
||||||
for i, fname := range fieldNames {
|
|
||||||
row[i] = formatCSVValue(rec[fname])
|
row[i] = formatCSVValue(rec[fname])
|
||||||
}
|
}
|
||||||
writer.Write(row)
|
writer.Write(row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// exportField describes a field in an export request.
|
// handleExportXLSX exports records as XLSX (Excel).
|
||||||
type exportField struct {
|
// Mirrors: odoo/addons/web/controllers/export.py ExportXlsxController
|
||||||
Name string `json:"name"`
|
func (s *Server) handleExportXLSX(w http.ResponseWriter, r *http.Request) {
|
||||||
Label string `json:"label"`
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := s.parseExportRequest(w, r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f := excelize.NewFile()
|
||||||
|
sheet := "Sheet1"
|
||||||
|
|
||||||
|
headerStyle, _ := f.NewStyle(&excelize.Style{
|
||||||
|
Font: &excelize.Font{Bold: true},
|
||||||
|
})
|
||||||
|
for i, h := range data.Headers {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||||
|
f.SetCellValue(sheet, cell, h)
|
||||||
|
f.SetCellStyle(sheet, cell, cell, headerStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
for rowIdx, rec := range data.Records {
|
||||||
|
for colIdx, fname := range data.FieldNames {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
||||||
|
f.SetCellValue(sheet, cell, formatCSVValue(rec[fname]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.xlsx", data.Model))
|
||||||
|
f.Write(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatCSVValue converts a field value to a CSV string.
|
// formatCSVValue converts a field value to a CSV string.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -55,9 +56,11 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
table := m.Table()
|
table := m.Table()
|
||||||
var data []byte
|
var data []byte
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
_ = s.pool.QueryRow(ctx,
|
if err := s.pool.QueryRow(ctx,
|
||||||
fmt.Sprintf(`SELECT "%s" FROM "%s" WHERE id = $1`, f.Column(), table), id,
|
fmt.Sprintf(`SELECT "%s" FROM "%s" WHERE id = $1`, f.Column(), table), id,
|
||||||
).Scan(&data)
|
).Scan(&data); err != nil {
|
||||||
|
log.Printf("warning: image query failed for %s.%s id=%d: %v", model, field, id, err)
|
||||||
|
}
|
||||||
if len(data) > 0 {
|
if len(data) > 0 {
|
||||||
// Detect content type
|
// Detect content type
|
||||||
contentType := http.DetectContentType(data)
|
contentType := http.DetectContentType(data)
|
||||||
@@ -76,9 +79,11 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
m := orm.Registry.Get(model)
|
m := orm.Registry.Get(model)
|
||||||
if m != nil {
|
if m != nil {
|
||||||
var name string
|
var name string
|
||||||
_ = s.pool.QueryRow(r.Context(),
|
if err := s.pool.QueryRow(r.Context(),
|
||||||
fmt.Sprintf(`SELECT COALESCE(name, '') FROM "%s" WHERE id = $1`, m.Table()), id,
|
fmt.Sprintf(`SELECT COALESCE(name, '') FROM "%s" WHERE id = $1`, m.Table()), id,
|
||||||
).Scan(&name)
|
).Scan(&name); err != nil {
|
||||||
|
log.Printf("warning: image name lookup failed for %s id=%d: %v", model, id, err)
|
||||||
|
}
|
||||||
if len(name) > 0 {
|
if len(name) > 0 {
|
||||||
initial = strings.ToUpper(name[:1])
|
initial = strings.ToUpper(name[:1])
|
||||||
}
|
}
|
||||||
|
|||||||
223
pkg/server/import.go
Normal file
223
pkg/server/import.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleImportCSV imports records from a CSV file into any model.
|
||||||
|
// Accepts JSON body with: model, fields (mapping), csv_data (raw CSV string).
|
||||||
|
// Mirrors: odoo/addons/base_import/controllers/main.py ImportController
|
||||||
|
func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req JSONRPCRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var params struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Fields []importFieldMap `json:"fields"`
|
||||||
|
CSVData string `json:"csv_data"`
|
||||||
|
HasHeader bool `json:"has_header"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Model == "" || len(params.Fields) == 0 || params.CSVData == "" {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "model, fields, and csv_data are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify model exists
|
||||||
|
m := orm.Registry.Get(params.Model)
|
||||||
|
if m == nil {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: fmt.Sprintf("Unknown model: %s", params.Model)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse CSV
|
||||||
|
reader := csv.NewReader(strings.NewReader(params.CSVData))
|
||||||
|
reader.LazyQuotes = true
|
||||||
|
reader.TrimLeadingSpace = true
|
||||||
|
|
||||||
|
var allRows [][]string
|
||||||
|
for {
|
||||||
|
row, err := reader.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: fmt.Sprintf("CSV parse error: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allRows = append(allRows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allRows) == 0 {
|
||||||
|
s.writeJSONRPC(w, req.ID, map[string]interface{}{"ids": []int64{}, "count": 0}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip header row if present
|
||||||
|
dataRows := allRows
|
||||||
|
if params.HasHeader && len(allRows) > 1 {
|
||||||
|
dataRows = allRows[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build field mapping: CSV column index → ORM field name
|
||||||
|
type colMapping struct {
|
||||||
|
colIndex int
|
||||||
|
fieldName string
|
||||||
|
fieldType orm.FieldType
|
||||||
|
}
|
||||||
|
var mappings []colMapping
|
||||||
|
for _, fm := range params.Fields {
|
||||||
|
if fm.FieldName == "" || fm.ColumnIndex < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f := m.GetField(fm.FieldName)
|
||||||
|
if f == nil {
|
||||||
|
continue // skip unknown fields
|
||||||
|
}
|
||||||
|
mappings = append(mappings, colMapping{
|
||||||
|
colIndex: fm.ColumnIndex,
|
||||||
|
fieldName: fm.FieldName,
|
||||||
|
fieldType: f.Type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mappings) == 0 {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "No valid field mappings"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := int64(1)
|
||||||
|
companyID := int64(1)
|
||||||
|
if sess := GetSession(r); sess != nil {
|
||||||
|
uid = sess.UID
|
||||||
|
companyID = sess.CompanyID
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||||
|
Pool: s.pool,
|
||||||
|
UID: uid,
|
||||||
|
CompanyID: companyID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: "Internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer env.Close()
|
||||||
|
|
||||||
|
rs := env.Model(params.Model)
|
||||||
|
|
||||||
|
var createdIDs []int64
|
||||||
|
var errors []importError
|
||||||
|
|
||||||
|
for rowIdx, row := range dataRows {
|
||||||
|
vals := make(orm.Values)
|
||||||
|
for _, cm := range mappings {
|
||||||
|
if cm.colIndex >= len(row) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(row[cm.colIndex])
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vals[cm.fieldName] = coerceImportValue(raw, cm.fieldType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(vals) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.DryRun {
|
||||||
|
continue // validate only, don't create
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := rs.Create(vals)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, importError{
|
||||||
|
Row: rowIdx + 1,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
log.Printf("import: row %d error: %v", rowIdx+1, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
createdIDs = append(createdIDs, rec.ID())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := env.Commit(); err != nil {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Commit error: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"ids": createdIDs,
|
||||||
|
"count": len(createdIDs),
|
||||||
|
"errors": errors,
|
||||||
|
"dry_run": params.DryRun,
|
||||||
|
}
|
||||||
|
s.writeJSONRPC(w, req.ID, result, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// importFieldMap maps a CSV column to an ORM field.
|
||||||
|
type importFieldMap struct {
|
||||||
|
ColumnIndex int `json:"column_index"`
|
||||||
|
FieldName string `json:"field_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// importError describes a per-row import error.
|
||||||
|
type importError struct {
|
||||||
|
Row int `json:"row"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// coerceImportValue converts a raw CSV string to the appropriate Go type for ORM Create.
|
||||||
|
func coerceImportValue(raw string, ft orm.FieldType) interface{} {
|
||||||
|
switch ft {
|
||||||
|
case orm.TypeInteger:
|
||||||
|
v, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case orm.TypeFloat, orm.TypeMonetary:
|
||||||
|
// Handle comma as decimal separator
|
||||||
|
raw = strings.ReplaceAll(raw, ",", ".")
|
||||||
|
v, err := strconv.ParseFloat(raw, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case orm.TypeBoolean:
|
||||||
|
lower := strings.ToLower(raw)
|
||||||
|
return lower == "true" || lower == "1" || lower == "yes" || lower == "ja"
|
||||||
|
case orm.TypeMany2one:
|
||||||
|
// Try as integer ID first, then as name_search later
|
||||||
|
v, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return raw // pass as string, ORM may handle name_create
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -43,13 +44,19 @@ func (w *statusWriter) WriteHeader(code int) {
|
|||||||
func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
|
func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Public endpoints (no auth required)
|
// Public endpoints (no auth required)
|
||||||
path := r.URL.Path
|
path := filepath.Clean(r.URL.Path)
|
||||||
if path == "/health" ||
|
if path == "/health" ||
|
||||||
path == "/web/login" ||
|
path == "/web/login" ||
|
||||||
path == "/web/session/authenticate" ||
|
path == "/web/session/authenticate" ||
|
||||||
path == "/web/session/logout" ||
|
path == "/web/session/logout" ||
|
||||||
strings.HasPrefix(path, "/web/database/") ||
|
path == "/web/database/manager" ||
|
||||||
|
path == "/web/database/create" ||
|
||||||
|
path == "/web/database/list" ||
|
||||||
path == "/web/webclient/version_info" ||
|
path == "/web/webclient/version_info" ||
|
||||||
|
path == "/web/setup/wizard" ||
|
||||||
|
path == "/web/setup/wizard/save" ||
|
||||||
|
path == "/web/portal/signup" ||
|
||||||
|
path == "/web/portal/reset_password" ||
|
||||||
strings.Contains(path, "/static/") {
|
strings.Contains(path, "/static/") {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
@@ -58,8 +65,14 @@ func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
|
|||||||
// Check session cookie
|
// Check session cookie
|
||||||
cookie, err := r.Cookie("session_id")
|
cookie, err := r.Cookie("session_id")
|
||||||
if err != nil || cookie.Value == "" {
|
if err != nil || cookie.Value == "" {
|
||||||
// Also check JSON-RPC params for session_id (Odoo sends it both ways)
|
// No session cookie — reject protected endpoints
|
||||||
next.ServeHTTP(w, r) // For now, allow through — UID defaults to 1
|
if r.Header.Get("Content-Type") == "application/json" ||
|
||||||
|
strings.HasPrefix(path, "/web/dataset/") ||
|
||||||
|
strings.HasPrefix(path, "/jsonrpc") {
|
||||||
|
http.Error(w, `{"jsonrpc":"2.0","error":{"code":100,"message":"Session expired"}}`, http.StatusUnauthorized)
|
||||||
|
} else {
|
||||||
|
http.Redirect(w, r, "/web/login", http.StatusFound)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
379
pkg/server/portal.go
Normal file
379
pkg/server/portal.go
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
// Package server — Portal controllers for external (customer/supplier) access.
|
||||||
|
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerPortalRoutes registers all /my/* portal endpoints.
|
||||||
|
func (s *Server) registerPortalRoutes() {
|
||||||
|
s.mux.HandleFunc("/my", s.handlePortalHome)
|
||||||
|
s.mux.HandleFunc("/my/", s.handlePortalDispatch)
|
||||||
|
s.mux.HandleFunc("/my/home", s.handlePortalHome)
|
||||||
|
s.mux.HandleFunc("/my/invoices", s.handlePortalInvoices)
|
||||||
|
s.mux.HandleFunc("/my/orders", s.handlePortalOrders)
|
||||||
|
s.mux.HandleFunc("/my/pickings", s.handlePortalPickings)
|
||||||
|
s.mux.HandleFunc("/my/account", s.handlePortalAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePortalDispatch routes /my/* sub-paths to the correct handler.
|
||||||
|
func (s *Server) handlePortalDispatch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/my/home":
|
||||||
|
s.handlePortalHome(w, r)
|
||||||
|
case "/my/invoices":
|
||||||
|
s.handlePortalInvoices(w, r)
|
||||||
|
case "/my/orders":
|
||||||
|
s.handlePortalOrders(w, r)
|
||||||
|
case "/my/pickings":
|
||||||
|
s.handlePortalPickings(w, r)
|
||||||
|
case "/my/account":
|
||||||
|
s.handlePortalAccount(w, r)
|
||||||
|
default:
|
||||||
|
s.handlePortalHome(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// portalPartnerID resolves the partner_id of the currently logged-in portal user.
|
||||||
|
// Returns (partnerID, error). If session is missing, writes an error response and returns 0.
|
||||||
|
func (s *Server) portalPartnerID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
sess := GetSession(r)
|
||||||
|
if sess == nil {
|
||||||
|
writePortalError(w, http.StatusUnauthorized, "Not authenticated")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var partnerID int64
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT partner_id FROM res_users WHERE id = $1 AND active = true`,
|
||||||
|
sess.UID).Scan(&partnerID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal: cannot resolve partner_id for uid=%d: %v", sess.UID, err)
|
||||||
|
writePortalError(w, http.StatusForbidden, "User not found")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return partnerID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePortalHome returns the portal dashboard with document counts.
|
||||||
|
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.home()
|
||||||
|
func (s *Server) handlePortalHome(w http.ResponseWriter, r *http.Request) {
|
||||||
|
partnerID, ok := s.portalPartnerID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var invoiceCount, orderCount, pickingCount int64
|
||||||
|
|
||||||
|
// Count invoices (account.move with move_type in ('out_invoice','out_refund'))
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM account_move
|
||||||
|
WHERE partner_id = $1 AND move_type IN ('out_invoice','out_refund')
|
||||||
|
AND state = 'posted'`, partnerID).Scan(&invoiceCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal: invoice count error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count sale orders (confirmed or done)
|
||||||
|
err = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM sale_order
|
||||||
|
WHERE partner_id = $1 AND state IN ('sale','done')`, partnerID).Scan(&orderCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal: order count error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count pickings (stock.picking)
|
||||||
|
err = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM stock_picking
|
||||||
|
WHERE partner_id = $1 AND state != 'cancel'`, partnerID).Scan(&pickingCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal: picking count error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writePortalJSON(w, map[string]interface{}{
|
||||||
|
"counters": map[string]int64{
|
||||||
|
"invoice_count": invoiceCount,
|
||||||
|
"order_count": orderCount,
|
||||||
|
"picking_count": pickingCount,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePortalInvoices lists invoices for the current portal user.
|
||||||
|
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.portal_my_invoices()
|
||||||
|
func (s *Server) handlePortalInvoices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
partnerID, ok := s.portalPartnerID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT m.id, m.name, m.move_type, m.state, m.date,
|
||||||
|
m.amount_total::float8, m.amount_residual::float8,
|
||||||
|
m.payment_state, COALESCE(m.ref, '')
|
||||||
|
FROM account_move m
|
||||||
|
WHERE m.partner_id = $1
|
||||||
|
AND m.move_type IN ('out_invoice','out_refund')
|
||||||
|
AND m.state = 'posted'
|
||||||
|
ORDER BY m.date DESC
|
||||||
|
LIMIT 80`, partnerID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal: invoice query error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Failed to load invoices")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var invoices []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var name, moveType, state, paymentState, ref string
|
||||||
|
var date time.Time
|
||||||
|
var amountTotal, amountResidual float64
|
||||||
|
if err := rows.Scan(&id, &name, &moveType, &state, &date,
|
||||||
|
&amountTotal, &amountResidual, &paymentState, &ref); err != nil {
|
||||||
|
log.Printf("portal: invoice scan error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
invoices = append(invoices, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"move_type": moveType,
|
||||||
|
"state": state,
|
||||||
|
"date": date.Format("2006-01-02"),
|
||||||
|
"amount_total": amountTotal,
|
||||||
|
"amount_residual": amountResidual,
|
||||||
|
"payment_state": paymentState,
|
||||||
|
"ref": ref,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if invoices == nil {
|
||||||
|
invoices = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
writePortalJSON(w, map[string]interface{}{"invoices": invoices})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePortalOrders lists sale orders for the current portal user.
|
||||||
|
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.portal_my_orders()
|
||||||
|
func (s *Server) handlePortalOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
partnerID, ok := s.portalPartnerID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT so.id, so.name, so.state, so.date_order,
|
||||||
|
so.amount_total::float8, COALESCE(so.invoice_status, ''),
|
||||||
|
COALESCE(so.delivery_status, '')
|
||||||
|
FROM sale_order so
|
||||||
|
WHERE so.partner_id = $1
|
||||||
|
AND so.state IN ('sale','done')
|
||||||
|
ORDER BY so.date_order DESC
|
||||||
|
LIMIT 80`, partnerID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal: order query error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Failed to load orders")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var orders []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var name, state, invoiceStatus, deliveryStatus string
|
||||||
|
var dateOrder time.Time
|
||||||
|
var amountTotal float64
|
||||||
|
if err := rows.Scan(&id, &name, &state, &dateOrder,
|
||||||
|
&amountTotal, &invoiceStatus, &deliveryStatus); err != nil {
|
||||||
|
log.Printf("portal: order scan error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
orders = append(orders, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"state": state,
|
||||||
|
"date_order": dateOrder.Format("2006-01-02 15:04:05"),
|
||||||
|
"amount_total": amountTotal,
|
||||||
|
"invoice_status": invoiceStatus,
|
||||||
|
"delivery_status": deliveryStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if orders == nil {
|
||||||
|
orders = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
writePortalJSON(w, map[string]interface{}{"orders": orders})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePortalPickings lists stock pickings for the current portal user.
|
||||||
|
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.portal_my_pickings()
|
||||||
|
func (s *Server) handlePortalPickings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
partnerID, ok := s.portalPartnerID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT sp.id, sp.name, sp.state, sp.scheduled_date,
|
||||||
|
COALESCE(sp.origin, ''),
|
||||||
|
COALESCE(spt.name, '') AS picking_type_name
|
||||||
|
FROM stock_picking sp
|
||||||
|
LEFT JOIN stock_picking_type spt ON spt.id = sp.picking_type_id
|
||||||
|
WHERE sp.partner_id = $1
|
||||||
|
AND sp.state != 'cancel'
|
||||||
|
ORDER BY sp.scheduled_date DESC
|
||||||
|
LIMIT 80`, partnerID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal: picking query error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Failed to load pickings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var pickings []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var name, state, origin, pickingTypeName string
|
||||||
|
var scheduledDate time.Time
|
||||||
|
if err := rows.Scan(&id, &name, &state, &scheduledDate,
|
||||||
|
&origin, &pickingTypeName); err != nil {
|
||||||
|
log.Printf("portal: picking scan error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pickings = append(pickings, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"state": state,
|
||||||
|
"scheduled_date": scheduledDate.Format("2006-01-02 15:04:05"),
|
||||||
|
"origin": origin,
|
||||||
|
"picking_type_name": pickingTypeName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if pickings == nil {
|
||||||
|
pickings = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
writePortalJSON(w, map[string]interface{}{"pickings": pickings})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePortalAccount returns/updates the portal user's profile.
|
||||||
|
// GET: returns user profile. POST: updates name/email/phone/street/city/zip.
|
||||||
|
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.account()
|
||||||
|
func (s *Server) handlePortalAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
partnerID, ok := s.portalPartnerID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
// Update profile
|
||||||
|
var body struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Phone *string `json:"phone"`
|
||||||
|
Street *string `json:"street"`
|
||||||
|
City *string `json:"city"`
|
||||||
|
Zip *string `json:"zip"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writePortalError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SET clause dynamically with parameterized placeholders
|
||||||
|
sets := make([]string, 0, 6)
|
||||||
|
args := make([]interface{}, 0, 7)
|
||||||
|
idx := 1
|
||||||
|
addField := func(col string, val *string) {
|
||||||
|
if val != nil {
|
||||||
|
sets = append(sets, fmt.Sprintf("%s = $%d", col, idx))
|
||||||
|
args = append(args, *val)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addField("name", body.Name)
|
||||||
|
addField("email", body.Email)
|
||||||
|
addField("phone", body.Phone)
|
||||||
|
addField("street", body.Street)
|
||||||
|
addField("city", body.City)
|
||||||
|
addField("zip", body.Zip)
|
||||||
|
|
||||||
|
if len(sets) > 0 {
|
||||||
|
args = append(args, partnerID)
|
||||||
|
query := "UPDATE res_partner SET "
|
||||||
|
for j, set := range sets {
|
||||||
|
if j > 0 {
|
||||||
|
query += ", "
|
||||||
|
}
|
||||||
|
query += set
|
||||||
|
}
|
||||||
|
query += fmt.Sprintf(" WHERE id = $%d", idx)
|
||||||
|
if _, err := s.pool.Exec(ctx, query, args...); err != nil {
|
||||||
|
log.Printf("portal: account update error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Update failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writePortalJSON(w, map[string]interface{}{"success": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET — return profile
|
||||||
|
var name, email, phone, street, city, zip string
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(name,''), COALESCE(email,''), COALESCE(phone,''),
|
||||||
|
COALESCE(street,''), COALESCE(city,''), COALESCE(zip,'')
|
||||||
|
FROM res_partner WHERE id = $1`, partnerID).Scan(
|
||||||
|
&name, &email, &phone, &street, &city, &zip)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal: account read error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Failed to load profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writePortalJSON(w, map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"email": email,
|
||||||
|
"phone": phone,
|
||||||
|
"street": street,
|
||||||
|
"city": city,
|
||||||
|
"zip": zip,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func writePortalJSON(w http.ResponseWriter, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePortalError(w http.ResponseWriter, status int, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||||
|
}
|
||||||
313
pkg/server/portal_signup.go
Normal file
313
pkg/server/portal_signup.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
// Package server — Portal signup and password reset.
|
||||||
|
// Mirrors: odoo/addons/auth_signup/controllers/main.py AuthSignupHome
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"odoo-go/pkg/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerPortalSignupRoutes registers /web/portal/* public endpoints.
|
||||||
|
func (s *Server) registerPortalSignupRoutes() {
|
||||||
|
s.mux.HandleFunc("/web/portal/signup", s.handlePortalSignup)
|
||||||
|
s.mux.HandleFunc("/web/portal/reset_password", s.handlePortalResetPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePortalSignup creates a new portal user with share=true and a matching res.partner.
|
||||||
|
// Mirrors: odoo/addons/auth_signup/controllers/main.py AuthSignupHome.web_auth_signup()
|
||||||
|
func (s *Server) handlePortalSignup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writePortalError(w, http.StatusMethodNotAllowed, "POST required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writePortalError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
body.Name = strings.TrimSpace(body.Name)
|
||||||
|
body.Email = strings.TrimSpace(body.Email)
|
||||||
|
if body.Name == "" || body.Email == "" || body.Password == "" {
|
||||||
|
writePortalError(w, http.StatusBadRequest, "Name, email, and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Password) < 8 {
|
||||||
|
writePortalError(w, http.StatusBadRequest, "Password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Check if login already exists
|
||||||
|
var exists bool
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM res_users WHERE login = $1)`, body.Email).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal signup: check existing user error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
writePortalError(w, http.StatusConflict, "An account with this email already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hashedPw, err := tools.HashPassword(body.Password)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal signup: hash password error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get default company
|
||||||
|
var companyID int64
|
||||||
|
err = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT id FROM res_company WHERE active = true ORDER BY id LIMIT 1`).Scan(&companyID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal signup: get company error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin transaction — create partner + user atomically
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal signup: begin tx error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// Create res.partner
|
||||||
|
var partnerID int64
|
||||||
|
err = tx.QueryRow(ctx,
|
||||||
|
`INSERT INTO res_partner (name, email, active, company_id, customer_rank)
|
||||||
|
VALUES ($1, $2, true, $3, 1)
|
||||||
|
RETURNING id`, body.Name, body.Email, companyID).Scan(&partnerID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal signup: create partner error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Failed to create account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create res.users with share=true
|
||||||
|
var userID int64
|
||||||
|
err = tx.QueryRow(ctx,
|
||||||
|
`INSERT INTO res_users (login, password, active, partner_id, company_id, share)
|
||||||
|
VALUES ($1, $2, true, $3, $4, true)
|
||||||
|
RETURNING id`, body.Email, hashedPw, partnerID, companyID).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal signup: create user error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Failed to create account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user to group_portal (not group_user)
|
||||||
|
var groupPortalID int64
|
||||||
|
err = tx.QueryRow(ctx,
|
||||||
|
`SELECT g.id FROM res_groups g
|
||||||
|
JOIN ir_model_data imd ON imd.res_id = g.id AND imd.model = 'res.groups'
|
||||||
|
WHERE imd.module = 'base' AND imd.name = 'group_portal'`).Scan(&groupPortalID)
|
||||||
|
if err != nil {
|
||||||
|
// group_portal might not exist yet — create it
|
||||||
|
err = tx.QueryRow(ctx,
|
||||||
|
`INSERT INTO res_groups (name) VALUES ('Portal') RETURNING id`).Scan(&groupPortalID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal signup: create group_portal error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Failed to create account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx,
|
||||||
|
`INSERT INTO ir_model_data (module, name, model, res_id)
|
||||||
|
VALUES ('base', 'group_portal', 'res.groups', $1)
|
||||||
|
ON CONFLICT DO NOTHING`, groupPortalID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal signup: create group_portal xmlid error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx,
|
||||||
|
`INSERT INTO res_groups_res_users_rel (res_groups_id, res_users_id)
|
||||||
|
VALUES ($1, $2) ON CONFLICT DO NOTHING`, groupPortalID, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal signup: add user to group_portal error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
log.Printf("portal signup: commit error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Failed to create account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("portal signup: created portal user id=%d login=%s partner_id=%d",
|
||||||
|
userID, body.Email, partnerID)
|
||||||
|
|
||||||
|
writePortalJSON(w, map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"user_id": userID,
|
||||||
|
"partner_id": partnerID,
|
||||||
|
"message": "Account created successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePortalResetPassword handles password reset requests.
|
||||||
|
// POST with {"email":"..."}: generates a reset token and sends an email.
|
||||||
|
// POST with {"token":"...","password":"..."}: resets the password.
|
||||||
|
// Mirrors: odoo/addons/auth_signup/controllers/main.py AuthSignupHome.web_auth_reset_password()
|
||||||
|
func (s *Server) handlePortalResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writePortalError(w, http.StatusMethodNotAllowed, "POST required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writePortalError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Phase 2: Token + new password → reset
|
||||||
|
if body.Token != "" && body.Password != "" {
|
||||||
|
s.handleResetWithToken(w, ctx, body.Token, body.Password)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Email → generate token + send email
|
||||||
|
if body.Email == "" {
|
||||||
|
writePortalError(w, http.StatusBadRequest, "Email is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.handleResetRequest(w, ctx, strings.TrimSpace(body.Email))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResetRequest generates a reset token and sends it via email.
|
||||||
|
func (s *Server) handleResetRequest(w http.ResponseWriter, ctx context.Context, email string) {
|
||||||
|
// Look up user
|
||||||
|
var uid int64
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT id FROM res_users WHERE login = $1 AND active = true`, email).Scan(&uid)
|
||||||
|
if err != nil {
|
||||||
|
// Don't reveal whether the email exists — always return success
|
||||||
|
writePortalJSON(w, map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "If an account exists with this email, a reset link has been sent",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
tokenBytes := make([]byte, 32)
|
||||||
|
rand.Read(tokenBytes)
|
||||||
|
token := hex.EncodeToString(tokenBytes)
|
||||||
|
expiration := time.Now().Add(24 * time.Hour)
|
||||||
|
|
||||||
|
// Store token
|
||||||
|
_, err = s.pool.Exec(ctx,
|
||||||
|
`UPDATE res_users SET signup_token = $1, signup_expiration = $2 WHERE id = $3`,
|
||||||
|
token, expiration, uid)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal reset: store token error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send email with reset link
|
||||||
|
smtpCfg := tools.LoadSMTPConfig()
|
||||||
|
resetURL := fmt.Sprintf("/web/portal/reset_password?token=%s", token)
|
||||||
|
emailBody := fmt.Sprintf(`<html><body>
|
||||||
|
<p>A password reset was requested for your account.</p>
|
||||||
|
<p>Click the link below to set a new password:</p>
|
||||||
|
<p><a href="%s">Reset Password</a></p>
|
||||||
|
<p>This link expires in 24 hours.</p>
|
||||||
|
<p>If you did not request this, you can ignore this email.</p>
|
||||||
|
</body></html>`, resetURL)
|
||||||
|
|
||||||
|
if err := tools.SendEmail(smtpCfg, email, "Password Reset", emailBody); err != nil {
|
||||||
|
log.Printf("portal reset: send email error: %v", err)
|
||||||
|
// Don't expose email sending errors to the user
|
||||||
|
}
|
||||||
|
|
||||||
|
writePortalJSON(w, map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "If an account exists with this email, a reset link has been sent",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResetWithToken validates the token and sets the new password.
|
||||||
|
func (s *Server) handleResetWithToken(w http.ResponseWriter, ctx context.Context, token, password string) {
|
||||||
|
if len(password) < 8 {
|
||||||
|
writePortalError(w, http.StatusBadRequest, "Password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up user by token
|
||||||
|
var uid int64
|
||||||
|
var expiration time.Time
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, signup_expiration FROM res_users
|
||||||
|
WHERE signup_token = $1 AND active = true`, token).Scan(&uid, &expiration)
|
||||||
|
if err != nil {
|
||||||
|
writePortalError(w, http.StatusBadRequest, "Invalid or expired reset token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if time.Now().After(expiration) {
|
||||||
|
// Clear expired token
|
||||||
|
s.pool.Exec(ctx,
|
||||||
|
`UPDATE res_users SET signup_token = NULL, signup_expiration = NULL WHERE id = $1`, uid)
|
||||||
|
writePortalError(w, http.StatusBadRequest, "Reset token has expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
hashedPw, err := tools.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal reset: hash password error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password and clear token
|
||||||
|
_, err = s.pool.Exec(ctx,
|
||||||
|
`UPDATE res_users SET password = $1, signup_token = NULL, signup_expiration = NULL
|
||||||
|
WHERE id = $2`, hashedPw, uid)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("portal reset: update password error: %v", err)
|
||||||
|
writePortalError(w, http.StatusInternalServerError, "Failed to reset password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("portal reset: password reset for uid=%d", uid)
|
||||||
|
|
||||||
|
writePortalJSON(w, map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Password has been reset successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -35,6 +36,8 @@ type Server struct {
|
|||||||
// all JS files (except module_loader.js) plus the XML template bundle,
|
// all JS files (except module_loader.js) plus the XML template bundle,
|
||||||
// served as a single file to avoid hundreds of individual HTTP requests.
|
// served as a single file to avoid hundreds of individual HTTP requests.
|
||||||
jsBundle string
|
jsBundle string
|
||||||
|
|
||||||
|
bus *Bus // Message bus for Discuss long-polling
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new server instance.
|
// New creates a new server instance.
|
||||||
@@ -128,6 +131,17 @@ func (s *Server) registerRoutes() {
|
|||||||
|
|
||||||
// CSV export
|
// CSV export
|
||||||
s.mux.HandleFunc("/web/export/csv", s.handleExportCSV)
|
s.mux.HandleFunc("/web/export/csv", s.handleExportCSV)
|
||||||
|
s.mux.HandleFunc("/web/export/xlsx", s.handleExportXLSX)
|
||||||
|
|
||||||
|
// Import
|
||||||
|
s.mux.HandleFunc("/web/import/csv", s.handleImportCSV)
|
||||||
|
|
||||||
|
// Post-setup wizard
|
||||||
|
s.mux.HandleFunc("/web/setup/wizard", s.handleSetupWizard)
|
||||||
|
s.mux.HandleFunc("/web/setup/wizard/save", s.handleSetupWizardSave)
|
||||||
|
|
||||||
|
// Bank statement import
|
||||||
|
s.mux.HandleFunc("/web/bank_statement/import", s.handleBankStatementImport)
|
||||||
|
|
||||||
// Reports (HTML and PDF report rendering)
|
// Reports (HTML and PDF report rendering)
|
||||||
s.mux.HandleFunc("/report/", s.handleReport)
|
s.mux.HandleFunc("/report/", s.handleReport)
|
||||||
@@ -137,10 +151,16 @@ func (s *Server) registerRoutes() {
|
|||||||
// Logout & Account
|
// Logout & Account
|
||||||
s.mux.HandleFunc("/web/session/logout", s.handleLogout)
|
s.mux.HandleFunc("/web/session/logout", s.handleLogout)
|
||||||
s.mux.HandleFunc("/web/session/account", s.handleSessionAccount)
|
s.mux.HandleFunc("/web/session/account", s.handleSessionAccount)
|
||||||
|
s.mux.HandleFunc("/web/session/switch_company", s.handleSwitchCompany)
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
s.mux.HandleFunc("/health", s.handleHealth)
|
s.mux.HandleFunc("/health", s.handleHealth)
|
||||||
|
|
||||||
|
// Portal routes (external user access)
|
||||||
|
s.registerPortalRoutes()
|
||||||
|
s.registerPortalSignupRoutes()
|
||||||
|
s.registerBusRoutes()
|
||||||
|
|
||||||
// Static files (catch-all for /<addon>/static/...)
|
// Static files (catch-all for /<addon>/static/...)
|
||||||
// NOTE: must be last since it's a broad pattern
|
// NOTE: must be last since it's a broad pattern
|
||||||
}
|
}
|
||||||
@@ -255,13 +275,14 @@ func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract UID from session, default to 1 (admin) if no session
|
// Extract UID from session — reject if no session (defense in depth)
|
||||||
uid := int64(1)
|
sess := GetSession(r)
|
||||||
companyID := int64(1)
|
if sess == nil {
|
||||||
if sess := GetSession(r); sess != nil {
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: 100, Message: "Session expired"})
|
||||||
uid = sess.UID
|
return
|
||||||
companyID = sess.CompanyID
|
|
||||||
}
|
}
|
||||||
|
uid := sess.UID
|
||||||
|
companyID := sess.CompanyID
|
||||||
|
|
||||||
// Create environment for this request
|
// Create environment for this request
|
||||||
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||||
@@ -294,6 +315,36 @@ func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.writeJSONRPC(w, req.ID, result, nil)
|
s.writeJSONRPC(w, req.ID, result, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sensitiveFields lists fields that only admin (uid=1) may write to.
|
||||||
|
// Prevents privilege escalation via field manipulation.
|
||||||
|
var sensitiveFields = map[string]map[string]bool{
|
||||||
|
"ir.cron": {"user_id": true, "model_name": true, "method_name": true},
|
||||||
|
"ir.model.access": {"group_id": true, "perm_read": true, "perm_write": true, "perm_create": true, "perm_unlink": true},
|
||||||
|
"ir.rule": {"domain_force": true, "groups": true, "perm_read": true, "perm_write": true, "perm_create": true, "perm_unlink": true},
|
||||||
|
"res.users": {"groups_id": true},
|
||||||
|
"res.groups": {"users": true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSensitiveFields blocks non-admin users from writing protected fields.
|
||||||
|
func checkSensitiveFields(env *orm.Environment, model string, vals orm.Values) *RPCError {
|
||||||
|
if env.UID() == 1 || env.IsSuperuser() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fields, ok := sensitiveFields[model]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for field := range vals {
|
||||||
|
if fields[field] {
|
||||||
|
return &RPCError{
|
||||||
|
Code: 403,
|
||||||
|
Message: fmt.Sprintf("Access Denied: field %q on %s is admin-only", field, model),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// checkAccess verifies the current user has permission for the operation.
|
// checkAccess verifies the current user has permission for the operation.
|
||||||
// Mirrors: odoo/addons/base/models/ir_model.py IrModelAccess.check()
|
// Mirrors: odoo/addons/base/models/ir_model.py IrModelAccess.check()
|
||||||
func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCError {
|
func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCError {
|
||||||
@@ -317,8 +368,22 @@ func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCErr
|
|||||||
`SELECT COUNT(*) FROM ir_model_access a
|
`SELECT COUNT(*) FROM ir_model_access a
|
||||||
JOIN ir_model m ON m.id = a.model_id
|
JOIN ir_model m ON m.id = a.model_id
|
||||||
WHERE m.model = $1`, model).Scan(&count)
|
WHERE m.model = $1`, model).Scan(&count)
|
||||||
if err != nil || count == 0 {
|
if err != nil {
|
||||||
return nil // No ACLs defined → open access (like Odoo superuser mode)
|
// DB error → deny access (fail-closed)
|
||||||
|
log.Printf("access: DB error checking ACL for model %s: %v", model, err)
|
||||||
|
return &RPCError{
|
||||||
|
Code: 403,
|
||||||
|
Message: fmt.Sprintf("Access Denied: %s on %s (internal error)", method, model),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
// No ACL rules defined for this model → deny (fail-closed).
|
||||||
|
// All models should have ACL seed data via seedACLRules().
|
||||||
|
log.Printf("access: no ACL for model %s, denying (fail-closed)", model)
|
||||||
|
return &RPCError{
|
||||||
|
Code: 403,
|
||||||
|
Message: fmt.Sprintf("Access Denied: no ACL rules for %s", model),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user's groups grant permission
|
// Check if user's groups grant permission
|
||||||
@@ -334,7 +399,11 @@ func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCErr
|
|||||||
AND (a.group_id IS NULL OR gu.res_users_id = $2)
|
AND (a.group_id IS NULL OR gu.res_users_id = $2)
|
||||||
)`, perm), model, env.UID()).Scan(&granted)
|
)`, perm), model, env.UID()).Scan(&granted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil // On error, allow (fail-open for now)
|
log.Printf("access: DB error checking ACL grant for model %s: %v", model, err)
|
||||||
|
return &RPCError{
|
||||||
|
Code: 403,
|
||||||
|
Message: fmt.Sprintf("Access Denied: %s on %s (internal error)", method, model),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !granted {
|
if !granted {
|
||||||
return &RPCError{
|
return &RPCError{
|
||||||
@@ -379,10 +448,57 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
|||||||
|
|
||||||
switch params.Method {
|
switch params.Method {
|
||||||
case "has_group":
|
case "has_group":
|
||||||
// Always return true for admin user, stub for now
|
// Check if current user belongs to the given group.
|
||||||
return true, nil
|
// Mirrors: odoo/orm/models.py BaseModel.user_has_groups()
|
||||||
|
groupXMLID := ""
|
||||||
|
if len(params.Args) > 0 {
|
||||||
|
groupXMLID, _ = params.Args[0].(string)
|
||||||
|
}
|
||||||
|
if groupXMLID == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
// Admin always has all groups
|
||||||
|
if env.UID() == 1 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// Parse "module.xml_id" format
|
||||||
|
parts := strings.SplitN(groupXMLID, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
// Query: does user belong to this group?
|
||||||
|
var exists bool
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT EXISTS(
|
||||||
|
SELECT 1 FROM res_groups_res_users_rel gur
|
||||||
|
JOIN ir_model_data imd ON imd.res_id = gur.res_groups_id AND imd.model = 'res.groups'
|
||||||
|
WHERE gur.res_users_id = $1 AND imd.module = $2 AND imd.name = $3
|
||||||
|
)`, env.UID(), parts[0], parts[1]).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return exists, nil
|
||||||
|
|
||||||
case "check_access_rights":
|
case "check_access_rights":
|
||||||
|
// Check if current user has the given access right on this model.
|
||||||
|
// Mirrors: odoo/orm/models.py BaseModel.check_access_rights()
|
||||||
|
operation := "read"
|
||||||
|
if len(params.Args) > 0 {
|
||||||
|
if op, ok := params.Args[0].(string); ok {
|
||||||
|
operation = op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raiseException := true
|
||||||
|
if v, ok := params.KW["raise_exception"].(bool); ok {
|
||||||
|
raiseException = v
|
||||||
|
}
|
||||||
|
accessErr := s.checkAccess(env, params.Model, operation)
|
||||||
|
if accessErr != nil {
|
||||||
|
if raiseException {
|
||||||
|
return nil, accessErr
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
|
|
||||||
case "fields_get":
|
case "fields_get":
|
||||||
@@ -404,6 +520,11 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
|||||||
vals := parseValuesAt(params.Args, 1)
|
vals := parseValuesAt(params.Args, 1)
|
||||||
spec, _ := params.KW["specification"].(map[string]interface{})
|
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||||
|
|
||||||
|
// Field-level access control
|
||||||
|
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if len(ids) > 0 && ids[0] > 0 {
|
if len(ids) > 0 && ids[0] > 0 {
|
||||||
// Update existing record(s)
|
// Update existing record(s)
|
||||||
err := rs.Browse(ids...).Write(vals)
|
err := rs.Browse(ids...).Write(vals)
|
||||||
@@ -513,6 +634,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
|||||||
|
|
||||||
case "create":
|
case "create":
|
||||||
vals := parseValues(params.Args)
|
vals := parseValues(params.Args)
|
||||||
|
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
record, err := rs.Create(vals)
|
record, err := rs.Create(vals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||||
@@ -522,6 +646,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
|||||||
case "write":
|
case "write":
|
||||||
ids := parseIDs(params.Args)
|
ids := parseIDs(params.Args)
|
||||||
vals := parseValuesAt(params.Args, 1)
|
vals := parseValuesAt(params.Args, 1)
|
||||||
|
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
err := rs.Browse(ids...).Write(vals)
|
err := rs.Browse(ids...).Write(vals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||||
@@ -645,9 +772,33 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
|||||||
}, nil
|
}, nil
|
||||||
|
|
||||||
case "get_formview_id":
|
case "get_formview_id":
|
||||||
return false, nil
|
// Return the default form view ID for this model.
|
||||||
|
// Mirrors: odoo/orm/models.py BaseModel.get_formview_id()
|
||||||
|
var viewID *int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT id FROM ir_ui_view
|
||||||
|
WHERE model = $1 AND type = 'form' AND active = true
|
||||||
|
ORDER BY priority, id LIMIT 1`,
|
||||||
|
params.Model).Scan(&viewID)
|
||||||
|
if err != nil || viewID == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return *viewID, nil
|
||||||
|
|
||||||
case "action_get":
|
case "action_get":
|
||||||
|
// Try registered method first (e.g. res.users has its own action_get).
|
||||||
|
// Mirrors: odoo/addons/base/models/res_users.py action_get()
|
||||||
|
model := orm.Registry.Get(params.Model)
|
||||||
|
if model != nil && model.Methods != nil {
|
||||||
|
if method, ok := model.Methods["action_get"]; ok {
|
||||||
|
ids := parseIDs(params.Args)
|
||||||
|
result, err := method(rs.Browse(ids...), params.Args[1:]...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|
||||||
case "name_create":
|
case "name_create":
|
||||||
@@ -665,10 +816,48 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
|||||||
return []interface{}{created.ID(), nameStr}, nil
|
return []interface{}{created.ID(), nameStr}, nil
|
||||||
|
|
||||||
case "read_progress_bar":
|
case "read_progress_bar":
|
||||||
return map[string]interface{}{}, nil
|
return s.handleReadProgressBar(rs, params)
|
||||||
|
|
||||||
case "activity_format":
|
case "activity_format":
|
||||||
return []interface{}{}, nil
|
ids := parseIDs(params.Args)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []interface{}{}, nil
|
||||||
|
}
|
||||||
|
// Search activities for this model/record
|
||||||
|
actRS := env.Model("mail.activity")
|
||||||
|
var allActivities []orm.Values
|
||||||
|
for _, id := range ids {
|
||||||
|
domain := orm.And(
|
||||||
|
orm.Leaf("res_model", "=", params.Model),
|
||||||
|
orm.Leaf("res_id", "=", id),
|
||||||
|
orm.Leaf("done", "=", false),
|
||||||
|
)
|
||||||
|
found, err := actRS.Search(domain, orm.SearchOpts{Order: "date_deadline"})
|
||||||
|
if err != nil || found.IsEmpty() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records, err := found.Read([]string{"id", "res_model", "res_id", "activity_type_id", "summary", "note", "date_deadline", "user_id", "state"})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allActivities = append(allActivities, records...)
|
||||||
|
}
|
||||||
|
if allActivities == nil {
|
||||||
|
return []interface{}{}, nil
|
||||||
|
}
|
||||||
|
// Format M2O fields
|
||||||
|
actSpec := map[string]interface{}{
|
||||||
|
"activity_type_id": map[string]interface{}{},
|
||||||
|
"user_id": map[string]interface{}{},
|
||||||
|
}
|
||||||
|
formatM2OFields(env, "mail.activity", allActivities, actSpec)
|
||||||
|
formatDateFields("mail.activity", allActivities)
|
||||||
|
normalizeNullFields("mail.activity", allActivities)
|
||||||
|
actResult := make([]interface{}, len(allActivities))
|
||||||
|
for i, a := range allActivities {
|
||||||
|
actResult[i] = a
|
||||||
|
}
|
||||||
|
return actResult, nil
|
||||||
|
|
||||||
case "action_archive":
|
case "action_archive":
|
||||||
ids := parseIDs(params.Args)
|
ids := parseIDs(params.Args)
|
||||||
@@ -697,6 +886,199 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
|||||||
}
|
}
|
||||||
return created.ID(), nil
|
return created.ID(), nil
|
||||||
|
|
||||||
|
case "web_resequence":
|
||||||
|
// Resequence records by their IDs (drag&drop reordering).
|
||||||
|
// Mirrors: odoo/addons/web/models/models.py web_resequence()
|
||||||
|
ids := parseIDs(params.Args)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []orm.Values{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse field_name (default "sequence")
|
||||||
|
fieldName := "sequence"
|
||||||
|
if v, ok := params.KW["field_name"].(string); ok {
|
||||||
|
fieldName = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse offset (default 0)
|
||||||
|
offset := 0
|
||||||
|
if v, ok := params.KW["offset"].(float64); ok {
|
||||||
|
offset = int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if field exists on the model
|
||||||
|
model := orm.Registry.Get(params.Model)
|
||||||
|
if model == nil || model.GetField(fieldName) == nil {
|
||||||
|
return []orm.Values{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sequence for each record in order
|
||||||
|
for i, id := range ids {
|
||||||
|
if err := rs.Browse(id).Write(orm.Values{fieldName: offset + i}); err != nil {
|
||||||
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return records via web_read
|
||||||
|
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||||
|
readParams := CallKWParams{
|
||||||
|
Model: params.Model,
|
||||||
|
Method: "web_read",
|
||||||
|
Args: []interface{}{ids},
|
||||||
|
KW: map[string]interface{}{"specification": spec},
|
||||||
|
}
|
||||||
|
return handleWebRead(env, params.Model, readParams)
|
||||||
|
|
||||||
|
case "message_post":
|
||||||
|
// Post a message on the record's chatter.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_thread.py message_post()
|
||||||
|
ids := parseIDs(params.Args)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := params.KW["body"].(string)
|
||||||
|
messageType := "comment"
|
||||||
|
if v, _ := params.KW["message_type"].(string); v != "" {
|
||||||
|
messageType = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get author from current user's partner_id
|
||||||
|
var authorID int64
|
||||||
|
if err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT partner_id FROM res_users WHERE id = $1`, env.UID(),
|
||||||
|
).Scan(&authorID); err != nil {
|
||||||
|
log.Printf("warning: message_post author lookup failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mail.message linked to the current model/record
|
||||||
|
var msgID int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, date, create_uid, write_uid, create_date, write_date)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW(), $6, $6, NOW(), NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
params.Model, ids[0], body, messageType, authorID, env.UID(),
|
||||||
|
).Scan(&msgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||||
|
}
|
||||||
|
return msgID, nil
|
||||||
|
|
||||||
|
case "_message_get_thread":
|
||||||
|
// Get messages for a record's chatter.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_thread.py
|
||||||
|
ids := parseIDs(params.Args)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []interface{}{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT m.id, m.body, m.message_type, m.date,
|
||||||
|
m.author_id, COALESCE(p.name, ''),
|
||||||
|
COALESCE(m.subject, ''), COALESCE(m.email_from, '')
|
||||||
|
FROM mail_message m
|
||||||
|
LEFT JOIN res_partner p ON p.id = m.author_id
|
||||||
|
WHERE m.model = $1 AND m.res_id = $2
|
||||||
|
ORDER BY m.id DESC`,
|
||||||
|
params.Model, ids[0],
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var messages []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var body, msgType, subject, emailFrom string
|
||||||
|
var date interface{}
|
||||||
|
var authorID int64
|
||||||
|
var authorName string
|
||||||
|
|
||||||
|
if scanErr := rows.Scan(&id, &body, &msgType, &date, &authorID, &authorName, &subject, &emailFrom); scanErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"body": body,
|
||||||
|
"message_type": msgType,
|
||||||
|
"date": date,
|
||||||
|
"subject": subject,
|
||||||
|
"email_from": emailFrom,
|
||||||
|
}
|
||||||
|
if authorID > 0 {
|
||||||
|
msg["author_id"] = []interface{}{authorID, authorName}
|
||||||
|
} else {
|
||||||
|
msg["author_id"] = false
|
||||||
|
}
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
if messages == nil {
|
||||||
|
messages = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
|
||||||
|
case "read_followers":
|
||||||
|
ids := parseIDs(params.Args)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []interface{}{}, nil
|
||||||
|
}
|
||||||
|
// Search followers for this model/record
|
||||||
|
followerRS := env.Model("mail.followers")
|
||||||
|
domain := orm.And(
|
||||||
|
orm.Leaf("res_model", "=", params.Model),
|
||||||
|
orm.Leaf("res_id", "in", ids),
|
||||||
|
)
|
||||||
|
found, err := followerRS.Search(domain, orm.SearchOpts{Limit: 100})
|
||||||
|
if err != nil || found.IsEmpty() {
|
||||||
|
return []interface{}{}, nil
|
||||||
|
}
|
||||||
|
followerRecords, err := found.Read([]string{"id", "res_model", "res_id", "partner_id"})
|
||||||
|
if err != nil {
|
||||||
|
return []interface{}{}, nil
|
||||||
|
}
|
||||||
|
followerSpec := map[string]interface{}{"partner_id": map[string]interface{}{}}
|
||||||
|
formatM2OFields(env, "mail.followers", followerRecords, followerSpec)
|
||||||
|
normalizeNullFields("mail.followers", followerRecords)
|
||||||
|
followerResult := make([]interface{}, len(followerRecords))
|
||||||
|
for i, r := range followerRecords {
|
||||||
|
followerResult[i] = r
|
||||||
|
}
|
||||||
|
return followerResult, nil
|
||||||
|
|
||||||
|
case "get_activity_data":
|
||||||
|
// Return activity summary data for records.
|
||||||
|
// Mirrors: odoo/addons/mail/models/mail_activity_mixin.py
|
||||||
|
emptyResult := map[string]interface{}{
|
||||||
|
"activity_types": []interface{}{},
|
||||||
|
"activity_res_ids": map[string]interface{}{},
|
||||||
|
"grouped_activities": map[string]interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := parseIDs(params.Args)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return emptyResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get activity types
|
||||||
|
typeRS := env.Model("mail.activity.type")
|
||||||
|
types, err := typeRS.Search(nil, orm.SearchOpts{Order: "sequence, id"})
|
||||||
|
if err != nil || types.IsEmpty() {
|
||||||
|
return emptyResult, nil
|
||||||
|
}
|
||||||
|
typeRecords, _ := types.Read([]string{"id", "name"})
|
||||||
|
|
||||||
|
typeList := make([]interface{}, len(typeRecords))
|
||||||
|
for i, t := range typeRecords {
|
||||||
|
typeList[i] = t
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"activity_types": typeList,
|
||||||
|
"activity_res_ids": map[string]interface{}{},
|
||||||
|
"grouped_activities": map[string]interface{}{},
|
||||||
|
}, nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Try registered business methods on the model.
|
// Try registered business methods on the model.
|
||||||
// Mirrors: odoo/service/model.py call_kw() + odoo/addons/web/controllers/dataset.py call_button()
|
// Mirrors: odoo/service/model.py call_kw() + odoo/addons/web/controllers/dataset.py call_button()
|
||||||
@@ -732,6 +1114,58 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
|||||||
|
|
||||||
// --- Session / Auth Endpoints ---
|
// --- Session / Auth Endpoints ---
|
||||||
|
|
||||||
|
// loginAttemptInfo tracks login attempts for rate limiting.
|
||||||
|
type loginAttemptInfo struct {
|
||||||
|
Count int
|
||||||
|
LastTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
loginAttempts = make(map[string]loginAttemptInfo)
|
||||||
|
loginAttemptsMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkLoginRateLimit returns false if the login is rate-limited (too many attempts).
|
||||||
|
func (s *Server) checkLoginRateLimit(login string) bool {
|
||||||
|
loginAttemptsMu.Lock()
|
||||||
|
defer loginAttemptsMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Periodic cleanup: evict stale entries (>15 min old) to prevent unbounded growth
|
||||||
|
if len(loginAttempts) > 100 {
|
||||||
|
for k, v := range loginAttempts {
|
||||||
|
if now.Sub(v.LastTime) > 15*time.Minute {
|
||||||
|
delete(loginAttempts, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info := loginAttempts[login]
|
||||||
|
|
||||||
|
// Reset after 15 minutes
|
||||||
|
if now.Sub(info.LastTime) > 15*time.Minute {
|
||||||
|
info = loginAttemptInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max 10 attempts per 15 minutes
|
||||||
|
if info.Count >= 10 {
|
||||||
|
return false // Rate limited
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Count++
|
||||||
|
info.LastTime = now
|
||||||
|
loginAttempts[login] = info
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetLoginRateLimit clears the rate limit counter on successful login.
|
||||||
|
func (s *Server) resetLoginRateLimit(login string) {
|
||||||
|
loginAttemptsMu.Lock()
|
||||||
|
defer loginAttemptsMu.Unlock()
|
||||||
|
delete(loginAttempts, login)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -754,6 +1188,14 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate limit login attempts
|
||||||
|
if !s.checkLoginRateLimit(params.Login) {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{
|
||||||
|
Code: 429, Message: "Too many login attempts. Please try again later.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Query user by login
|
// Query user by login
|
||||||
var uid int64
|
var uid int64
|
||||||
var companyID int64
|
var companyID int64
|
||||||
@@ -776,16 +1218,40 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check password (support both bcrypt and plaintext for migration)
|
// Check password (bcrypt only — no plaintext fallback)
|
||||||
if !tools.CheckPassword(hashedPw, params.Password) && hashedPw != params.Password {
|
if !tools.CheckPassword(hashedPw, params.Password) {
|
||||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{
|
||||||
Code: 100, Message: "Access Denied: invalid login or password",
|
Code: 100, Message: "Access Denied: invalid login or password",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Successful login – reset rate limiter
|
||||||
|
s.resetLoginRateLimit(params.Login)
|
||||||
|
|
||||||
|
// Query allowed companies for the user
|
||||||
|
allowedCompanyIDs := []int64{companyID}
|
||||||
|
rows, err := s.pool.Query(r.Context(),
|
||||||
|
`SELECT DISTINCT c.id FROM res_company c
|
||||||
|
WHERE c.active = true
|
||||||
|
ORDER BY c.id`)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var cid int64
|
||||||
|
if rows.Scan(&cid) == nil {
|
||||||
|
ids = append(ids, cid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
allowedCompanyIDs = ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
sess := s.sessions.New(uid, companyID, params.Login)
|
sess := s.sessions.New(uid, companyID, params.Login)
|
||||||
|
sess.AllowedCompanyIDs = allowedCompanyIDs
|
||||||
|
|
||||||
// Set session cookie
|
// Set session cookie
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
@@ -793,6 +1259,7 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
|||||||
Value: sess.ID,
|
Value: sess.ID,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -857,6 +1324,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
})
|
})
|
||||||
http.Redirect(w, r, "/web/login", http.StatusFound)
|
http.Redirect(w, r, "/web/login", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import (
|
|||||||
|
|
||||||
// Session represents an authenticated user session.
|
// Session represents an authenticated user session.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID string
|
ID string
|
||||||
UID int64
|
UID int64
|
||||||
CompanyID int64
|
CompanyID int64
|
||||||
Login string
|
AllowedCompanyIDs []int64
|
||||||
CreatedAt time.Time
|
Login string
|
||||||
LastActivity time.Time
|
CSRFToken string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastActivity time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionStore is a session store with an in-memory cache backed by PostgreSQL.
|
// SessionStore is a session store with an in-memory cache backed by PostgreSQL.
|
||||||
@@ -47,10 +49,15 @@ func InitSessionTable(ctx context.Context, pool *pgxpool.Pool) error {
|
|||||||
uid INT8 NOT NULL,
|
uid INT8 NOT NULL,
|
||||||
company_id INT8 NOT NULL,
|
company_id INT8 NOT NULL,
|
||||||
login VARCHAR(255),
|
login VARCHAR(255),
|
||||||
|
csrf_token VARCHAR(64) DEFAULT '',
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
last_seen TIMESTAMP DEFAULT NOW()
|
last_seen TIMESTAMP DEFAULT NOW()
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
|
if err == nil {
|
||||||
|
// Add csrf_token column if table already exists without it
|
||||||
|
pool.Exec(ctx, `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS csrf_token VARCHAR(64) DEFAULT ''`)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -67,6 +74,7 @@ func (s *SessionStore) New(uid, companyID int64, login string) *Session {
|
|||||||
UID: uid,
|
UID: uid,
|
||||||
CompanyID: companyID,
|
CompanyID: companyID,
|
||||||
Login: login,
|
Login: login,
|
||||||
|
CSRFToken: generateToken(),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
LastActivity: now,
|
LastActivity: now,
|
||||||
}
|
}
|
||||||
@@ -81,10 +89,10 @@ func (s *SessionStore) New(uid, companyID int64, login string) *Session {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_, err := s.pool.Exec(ctx,
|
_, err := s.pool.Exec(ctx,
|
||||||
`INSERT INTO sessions (id, uid, company_id, login, created_at, last_seen)
|
`INSERT INTO sessions (id, uid, company_id, login, csrf_token, created_at, last_seen)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (id) DO NOTHING`,
|
ON CONFLICT (id) DO NOTHING`,
|
||||||
token, uid, companyID, login, now, now)
|
token, uid, companyID, login, sess.CSRFToken, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("session: failed to persist session to DB: %v", err)
|
log.Printf("session: failed to persist session to DB: %v", err)
|
||||||
}
|
}
|
||||||
@@ -106,20 +114,23 @@ func (s *SessionStore) Get(id string) *Session {
|
|||||||
s.Delete(id)
|
s.Delete(id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Update last activity
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
needsDBUpdate := time.Since(sess.LastActivity) > 30*time.Second
|
||||||
|
|
||||||
|
// Update last activity in memory
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
sess.LastActivity = now
|
sess.LastActivity = now
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
// Update last_seen in DB asynchronously
|
// Throttle DB writes: only persist every 30s to avoid per-request overhead
|
||||||
if s.pool != nil {
|
if needsDBUpdate && s.pool != nil {
|
||||||
go func() {
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
defer cancel()
|
||||||
defer cancel()
|
if _, err := s.pool.Exec(ctx,
|
||||||
s.pool.Exec(ctx,
|
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id); err != nil {
|
||||||
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id)
|
log.Printf("session: failed to update last_seen in DB: %v", err)
|
||||||
}()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sess
|
return sess
|
||||||
@@ -134,14 +145,20 @@ func (s *SessionStore) Get(id string) *Session {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
sess = &Session{}
|
sess = &Session{}
|
||||||
|
var csrfToken string
|
||||||
err := s.pool.QueryRow(ctx,
|
err := s.pool.QueryRow(ctx,
|
||||||
`SELECT id, uid, company_id, login, created_at, last_seen
|
`SELECT id, uid, company_id, login, COALESCE(csrf_token, ''), created_at, last_seen
|
||||||
FROM sessions WHERE id = $1`, id).Scan(
|
FROM sessions WHERE id = $1`, id).Scan(
|
||||||
&sess.ID, &sess.UID, &sess.CompanyID, &sess.Login,
|
&sess.ID, &sess.UID, &sess.CompanyID, &sess.Login, &csrfToken,
|
||||||
&sess.CreatedAt, &sess.LastActivity)
|
&sess.CreatedAt, &sess.LastActivity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if csrfToken != "" {
|
||||||
|
sess.CSRFToken = csrfToken
|
||||||
|
} else {
|
||||||
|
sess.CSRFToken = generateToken()
|
||||||
|
}
|
||||||
|
|
||||||
// Check TTL
|
// Check TTL
|
||||||
if time.Since(sess.LastActivity) > s.ttl {
|
if time.Since(sess.LastActivity) > s.ttl {
|
||||||
@@ -149,18 +166,18 @@ func (s *SessionStore) Get(id string) *Session {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last activity
|
// Update last activity and add to memory cache
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
sess.LastActivity = now
|
|
||||||
|
|
||||||
// Add to memory cache
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
sess.LastActivity = now
|
||||||
s.sessions[id] = sess
|
s.sessions[id] = sess
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
// Update last_seen in DB
|
// Update last_seen in DB
|
||||||
s.pool.Exec(ctx,
|
if _, err := s.pool.Exec(ctx,
|
||||||
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id)
|
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id); err != nil {
|
||||||
|
log.Printf("session: failed to update last_seen in DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return sess
|
return sess
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"odoo-go/pkg/service"
|
"odoo-go/pkg/service"
|
||||||
"odoo-go/pkg/tools"
|
"odoo-go/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
var dbnamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`)
|
var dbnamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`)
|
||||||
|
|
||||||
// isSetupNeeded checks if the current database has been initialized.
|
// isSetupNeeded checks if the current database has been initialized.
|
||||||
@@ -55,6 +58,16 @@ func (s *Server) handleDatabaseCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate master password (default: "admin", configurable via ODOO_MASTER_PASSWORD env)
|
||||||
|
masterPw := os.Getenv("ODOO_MASTER_PASSWORD")
|
||||||
|
if masterPw == "" {
|
||||||
|
masterPw = "admin"
|
||||||
|
}
|
||||||
|
if params.MasterPwd != masterPw {
|
||||||
|
writeJSON(w, map[string]string{"error": "Invalid master password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
if params.Login == "" || params.Password == "" {
|
if params.Login == "" || params.Password == "" {
|
||||||
writeJSON(w, map[string]string{"error": "Email and password are required"})
|
writeJSON(w, map[string]string{"error": "Email and password are required"})
|
||||||
@@ -111,7 +124,10 @@ func (s *Server) handleDatabaseCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
domain := parts[1]
|
domain := parts[1]
|
||||||
domainParts := strings.Split(domain, ".")
|
domainParts := strings.Split(domain, ".")
|
||||||
if len(domainParts) > 0 {
|
if len(domainParts) > 0 {
|
||||||
companyName = strings.Title(domainParts[0])
|
name := domainParts[0]
|
||||||
|
if len(name) > 0 {
|
||||||
|
companyName = strings.ToUpper(name[:1]) + name[1:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,6 +191,195 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
|
|||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postSetupDone caches the result of isPostSetupNeeded to avoid a DB query on every request.
|
||||||
|
var postSetupDone atomic.Bool
|
||||||
|
|
||||||
|
// isPostSetupNeeded checks if the company still has default values (needs configuration).
|
||||||
|
func (s *Server) isPostSetupNeeded() bool {
|
||||||
|
if postSetupDone.Load() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
err := s.pool.QueryRow(context.Background(),
|
||||||
|
`SELECT COALESCE(name, '') FROM res_company WHERE id = 1`).Scan(&name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
needed := name == "" || name == "My Company" || strings.HasPrefix(name, "My ")
|
||||||
|
if !needed {
|
||||||
|
postSetupDone.Store(true)
|
||||||
|
}
|
||||||
|
return needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetupWizard serves the post-setup configuration wizard.
|
||||||
|
// Shown after first login when the company has not been configured yet.
|
||||||
|
// Mirrors: odoo/addons/base_setup/views/res_config_settings_views.xml
|
||||||
|
func (s *Server) handleSetupWizard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := GetSession(r)
|
||||||
|
if sess == nil {
|
||||||
|
http.Redirect(w, r, "/web/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load current company data
|
||||||
|
var companyName, street, city, zip, phone, email, website, vat string
|
||||||
|
var countryID int64
|
||||||
|
s.pool.QueryRow(context.Background(),
|
||||||
|
`SELECT COALESCE(name,''), COALESCE(street,''), COALESCE(city,''), COALESCE(zip,''),
|
||||||
|
COALESCE(phone,''), COALESCE(email,''), COALESCE(website,''), COALESCE(vat,''),
|
||||||
|
COALESCE(country_id, 0)
|
||||||
|
FROM res_company WHERE id = $1`, sess.CompanyID,
|
||||||
|
).Scan(&companyName, &street, &city, &zip, &phone, &email, &website, &vat, &countryID)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
esc := htmlEscape
|
||||||
|
fmt.Fprintf(w, setupWizardHTML,
|
||||||
|
esc(companyName), esc(street), esc(city), esc(zip), esc(phone), esc(email), esc(website), esc(vat))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetupWizardSave saves the post-setup wizard data.
|
||||||
|
func (s *Server) handleSetupWizardSave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := GetSession(r)
|
||||||
|
if sess == nil {
|
||||||
|
writeJSON(w, map[string]string{"error": "Not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var params struct {
|
||||||
|
CompanyName string `json:"company_name"`
|
||||||
|
Street string `json:"street"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Zip string `json:"zip"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Vat string `json:"vat"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
||||||
|
writeJSON(w, map[string]string{"error": "Invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.CompanyName == "" {
|
||||||
|
writeJSON(w, map[string]string{"error": "Company name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(context.Background(),
|
||||||
|
`UPDATE res_company SET name=$1, street=$2, city=$3, zip=$4, phone=$5, email=$6, website=$7, vat=$8
|
||||||
|
WHERE id = $9`,
|
||||||
|
params.CompanyName, params.Street, params.City, params.Zip,
|
||||||
|
params.Phone, params.Email, params.Website, params.Vat, sess.CompanyID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]string{"error": fmt.Sprintf("Save error: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update the partner linked to the company
|
||||||
|
s.pool.Exec(context.Background(),
|
||||||
|
`UPDATE res_partner SET name=$1, street=$2, city=$3, zip=$4, phone=$5, email=$6, website=$7, vat=$8
|
||||||
|
WHERE id = (SELECT partner_id FROM res_company WHERE id = $9)`,
|
||||||
|
params.CompanyName, params.Street, params.City, params.Zip,
|
||||||
|
params.Phone, params.Email, params.Website, params.Vat, sess.CompanyID)
|
||||||
|
|
||||||
|
postSetupDone.Store(true) // Mark setup as done so we don't redirect again
|
||||||
|
writeJSON(w, map[string]interface{}{"status": "ok", "redirect": "/odoo"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var setupWizardHTML = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>Setup — Configure Your Company</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #f0eeee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||||
|
.wizard { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
width: 100%%; max-width: 560px; }
|
||||||
|
.wizard h1 { color: #71639e; margin-bottom: 6px; font-size: 24px; }
|
||||||
|
.wizard .subtitle { color: #666; margin-bottom: 24px; font-size: 14px; }
|
||||||
|
.wizard label { display: block; margin-bottom: 4px; font-weight: 500; color: #555; font-size: 13px; }
|
||||||
|
.wizard input { width: 100%%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 4px;
|
||||||
|
font-size: 14px; margin-bottom: 14px; }
|
||||||
|
.wizard input:focus { outline: none; border-color: #71639e; box-shadow: 0 0 0 2px rgba(113,99,158,0.2); }
|
||||||
|
.wizard button { width: 100%%; padding: 14px; background: #71639e; color: white; border: none;
|
||||||
|
border-radius: 4px; font-size: 16px; cursor: pointer; margin-top: 10px; }
|
||||||
|
.wizard button:hover { background: #5f5387; }
|
||||||
|
.wizard .skip { text-align: center; margin-top: 12px; }
|
||||||
|
.wizard .skip a { color: #999; text-decoration: none; font-size: 13px; }
|
||||||
|
.wizard .skip a:hover { color: #666; }
|
||||||
|
.row { display: flex; gap: 12px; }
|
||||||
|
.row > div { flex: 1; }
|
||||||
|
.error { color: #dc3545; margin-bottom: 12px; display: none; text-align: center; font-size: 14px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wizard">
|
||||||
|
<h1>Configure Your Company</h1>
|
||||||
|
<p class="subtitle">Set up your company information</p>
|
||||||
|
<div id="error" class="error"></div>
|
||||||
|
<form id="wizardForm">
|
||||||
|
<label>Company Name *</label>
|
||||||
|
<input type="text" id="company_name" value="%s" required/>
|
||||||
|
|
||||||
|
<label>Street</label>
|
||||||
|
<input type="text" id="street" value="%s"/>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div><label>City</label><input type="text" id="city" value="%s"/></div>
|
||||||
|
<div><label>ZIP</label><input type="text" id="zip" value="%s"/></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div><label>Phone</label><input type="tel" id="phone" value="%s"/></div>
|
||||||
|
<div><label>Email</label><input type="email" id="email" value="%s"/></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>Website</label>
|
||||||
|
<input type="url" id="website" value="%s" placeholder="https://"/>
|
||||||
|
|
||||||
|
<label>Tax ID / VAT</label>
|
||||||
|
<input type="text" id="vat" value="%s"/>
|
||||||
|
|
||||||
|
<button type="submit">Save & Continue</button>
|
||||||
|
</form>
|
||||||
|
<div class="skip"><a href="/odoo">Skip for now</a></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('wizardForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
fetch('/web/setup/wizard/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
company_name: document.getElementById('company_name').value,
|
||||||
|
street: document.getElementById('street').value,
|
||||||
|
city: document.getElementById('city').value,
|
||||||
|
zip: document.getElementById('zip').value,
|
||||||
|
phone: document.getElementById('phone').value,
|
||||||
|
email: document.getElementById('email').value,
|
||||||
|
website: document.getElementById('website').value,
|
||||||
|
vat: document.getElementById('vat').value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(result) {
|
||||||
|
if (result.error) {
|
||||||
|
var el = document.getElementById('error');
|
||||||
|
el.textContent = result.error;
|
||||||
|
el.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
window.location.href = result.redirect || '/odoo';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
// --- Database Manager HTML ---
|
// --- Database Manager HTML ---
|
||||||
// Mirrors: odoo/addons/web/static/src/public/database_manager.create_form.qweb.html
|
// Mirrors: odoo/addons/web/static/src/public/database_manager.create_form.qweb.html
|
||||||
var databaseManagerHTML = `<!DOCTYPE html>
|
var databaseManagerHTML = `<!DOCTYPE html>
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
|||||||
addonName := parts[0]
|
addonName := parts[0]
|
||||||
filePath := parts[2]
|
filePath := parts[2]
|
||||||
|
|
||||||
// Security: prevent directory traversal
|
// Security: prevent directory traversal in both addonName and filePath
|
||||||
if strings.Contains(filePath, "..") {
|
if strings.Contains(filePath, "..") || strings.Contains(addonName, "..") ||
|
||||||
|
strings.Contains(addonName, "/") || strings.Contains(addonName, "\\") {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func TestExtractImports(t *testing.T) {
|
|||||||
content := `import { Foo, Bar } from "@web/core/foo";
|
content := `import { Foo, Bar } from "@web/core/foo";
|
||||||
import { Baz as Qux } from "@web/core/baz";
|
import { Baz as Qux } from "@web/core/baz";
|
||||||
const x = 1;`
|
const x = 1;`
|
||||||
deps, requires, clean := extractImports(content)
|
deps, requires, clean := extractImports("test.module", content)
|
||||||
|
|
||||||
if len(deps) != 2 {
|
if len(deps) != 2 {
|
||||||
t.Fatalf("expected 2 deps, got %d: %v", len(deps), deps)
|
t.Fatalf("expected 2 deps, got %d: %v", len(deps), deps)
|
||||||
@@ -120,7 +120,7 @@ const x = 1;`
|
|||||||
|
|
||||||
t.Run("default import", func(t *testing.T) {
|
t.Run("default import", func(t *testing.T) {
|
||||||
content := `import Foo from "@web/core/foo";`
|
content := `import Foo from "@web/core/foo";`
|
||||||
deps, requires, _ := extractImports(content)
|
deps, requires, _ := extractImports("test.module", content)
|
||||||
|
|
||||||
if len(deps) != 1 || deps[0] != "@web/core/foo" {
|
if len(deps) != 1 || deps[0] != "@web/core/foo" {
|
||||||
t.Errorf("deps = %v, want [@web/core/foo]", deps)
|
t.Errorf("deps = %v, want [@web/core/foo]", deps)
|
||||||
@@ -132,7 +132,7 @@ const x = 1;`
|
|||||||
|
|
||||||
t.Run("namespace import", func(t *testing.T) {
|
t.Run("namespace import", func(t *testing.T) {
|
||||||
content := `import * as utils from "@web/core/utils";`
|
content := `import * as utils from "@web/core/utils";`
|
||||||
deps, requires, _ := extractImports(content)
|
deps, requires, _ := extractImports("test.module", content)
|
||||||
|
|
||||||
if len(deps) != 1 || deps[0] != "@web/core/utils" {
|
if len(deps) != 1 || deps[0] != "@web/core/utils" {
|
||||||
t.Errorf("deps = %v, want [@web/core/utils]", deps)
|
t.Errorf("deps = %v, want [@web/core/utils]", deps)
|
||||||
@@ -144,7 +144,7 @@ const x = 1;`
|
|||||||
|
|
||||||
t.Run("side-effect import", func(t *testing.T) {
|
t.Run("side-effect import", func(t *testing.T) {
|
||||||
content := `import "@web/core/setup";`
|
content := `import "@web/core/setup";`
|
||||||
deps, requires, _ := extractImports(content)
|
deps, requires, _ := extractImports("test.module", content)
|
||||||
|
|
||||||
if len(deps) != 1 || deps[0] != "@web/core/setup" {
|
if len(deps) != 1 || deps[0] != "@web/core/setup" {
|
||||||
t.Errorf("deps = %v, want [@web/core/setup]", deps)
|
t.Errorf("deps = %v, want [@web/core/setup]", deps)
|
||||||
@@ -157,7 +157,7 @@ const x = 1;`
|
|||||||
t.Run("dedup deps", func(t *testing.T) {
|
t.Run("dedup deps", func(t *testing.T) {
|
||||||
content := `import { Foo } from "@web/core/foo";
|
content := `import { Foo } from "@web/core/foo";
|
||||||
import { Bar } from "@web/core/foo";`
|
import { Bar } from "@web/core/foo";`
|
||||||
deps, _, _ := extractImports(content)
|
deps, _, _ := extractImports("test.module", content)
|
||||||
|
|
||||||
if len(deps) != 1 {
|
if len(deps) != 1 {
|
||||||
t.Errorf("expected deduped deps, got %v", deps)
|
t.Errorf("expected deduped deps, got %v", deps)
|
||||||
@@ -167,7 +167,7 @@ import { Bar } from "@web/core/foo";`
|
|||||||
|
|
||||||
func TestTransformExports(t *testing.T) {
|
func TestTransformExports(t *testing.T) {
|
||||||
t.Run("export class", func(t *testing.T) {
|
t.Run("export class", func(t *testing.T) {
|
||||||
got := transformExports("export class Foo extends Bar {")
|
got, _ := transformExports("export class Foo extends Bar {")
|
||||||
want := "const Foo = __exports.Foo = class Foo extends Bar {"
|
want := "const Foo = __exports.Foo = class Foo extends Bar {"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("got %q, want %q", got, want)
|
t.Errorf("got %q, want %q", got, want)
|
||||||
@@ -175,15 +175,18 @@ func TestTransformExports(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("export function", func(t *testing.T) {
|
t.Run("export function", func(t *testing.T) {
|
||||||
got := transformExports("export function doSomething(a, b) {")
|
got, deferred := transformExports("export function doSomething(a, b) {")
|
||||||
want := `__exports.doSomething = function doSomething(a, b) {`
|
want := `function doSomething(a, b) {`
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("got %q, want %q", got, want)
|
t.Errorf("got %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
if len(deferred) != 1 || deferred[0] != "doSomething" {
|
||||||
|
t.Errorf("deferred = %v, want [doSomething]", deferred)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("export const", func(t *testing.T) {
|
t.Run("export const", func(t *testing.T) {
|
||||||
got := transformExports("export const MAX_SIZE = 100;")
|
got, _ := transformExports("export const MAX_SIZE = 100;")
|
||||||
want := "const MAX_SIZE = __exports.MAX_SIZE = 100;"
|
want := "const MAX_SIZE = __exports.MAX_SIZE = 100;"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("got %q, want %q", got, want)
|
t.Errorf("got %q, want %q", got, want)
|
||||||
@@ -191,7 +194,7 @@ func TestTransformExports(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("export let", func(t *testing.T) {
|
t.Run("export let", func(t *testing.T) {
|
||||||
got := transformExports("export let counter = 0;")
|
got, _ := transformExports("export let counter = 0;")
|
||||||
want := "let counter = __exports.counter = 0;"
|
want := "let counter = __exports.counter = 0;"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("got %q, want %q", got, want)
|
t.Errorf("got %q, want %q", got, want)
|
||||||
@@ -199,7 +202,7 @@ func TestTransformExports(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("export default", func(t *testing.T) {
|
t.Run("export default", func(t *testing.T) {
|
||||||
got := transformExports("export default Foo;")
|
got, _ := transformExports("export default Foo;")
|
||||||
want := `__exports[Symbol.for("default")] = Foo;`
|
want := `__exports[Symbol.for("default")] = Foo;`
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("got %q, want %q", got, want)
|
t.Errorf("got %q, want %q", got, want)
|
||||||
@@ -207,7 +210,7 @@ func TestTransformExports(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("export named", func(t *testing.T) {
|
t.Run("export named", func(t *testing.T) {
|
||||||
got := transformExports("export { Foo, Bar };")
|
got, _ := transformExports("export { Foo, Bar };")
|
||||||
if !strings.Contains(got, "__exports.Foo = Foo;") {
|
if !strings.Contains(got, "__exports.Foo = Foo;") {
|
||||||
t.Errorf("missing Foo export in: %s", got)
|
t.Errorf("missing Foo export in: %s", got)
|
||||||
}
|
}
|
||||||
@@ -217,7 +220,7 @@ func TestTransformExports(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("export named with alias", func(t *testing.T) {
|
t.Run("export named with alias", func(t *testing.T) {
|
||||||
got := transformExports("export { Foo as default };")
|
got, _ := transformExports("export { Foo as default };")
|
||||||
if !strings.Contains(got, "__exports.default = Foo;") {
|
if !strings.Contains(got, "__exports.default = Foo;") {
|
||||||
t.Errorf("missing aliased export in: %s", got)
|
t.Errorf("missing aliased export in: %s", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,27 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse multipart form (max 128MB)
|
// Limit upload size to 50MB
|
||||||
if err := r.ParseMultipartForm(128 << 20); err != nil {
|
r.Body = http.MaxBytesReader(w, r.Body, 50<<20)
|
||||||
|
|
||||||
|
// Parse multipart form (max 50MB)
|
||||||
|
if err := r.ParseMultipartForm(50 << 20); err != nil {
|
||||||
http.Error(w, "File too large", http.StatusRequestEntityTooLarge)
|
http.Error(w, "File too large", http.StatusRequestEntityTooLarge)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF validation for multipart form uploads.
|
||||||
|
// Mirrors: odoo/http.py validate_csrf()
|
||||||
|
sess := GetSession(r)
|
||||||
|
if sess != nil {
|
||||||
|
csrfToken := r.FormValue("csrf_token")
|
||||||
|
if csrfToken != sess.CSRFToken {
|
||||||
|
log.Printf("upload: CSRF token mismatch for uid=%d", sess.UID)
|
||||||
|
http.Error(w, "CSRF validation failed", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
file, header, err := r.FormFile("ufile")
|
file, header, err := r.FormFile("ufile")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
||||||
|
|||||||
@@ -195,6 +195,12 @@ func generateDefaultView(modelName, viewType string) string {
|
|||||||
return generateDefaultPivotView(m)
|
return generateDefaultPivotView(m)
|
||||||
case "graph":
|
case "graph":
|
||||||
return generateDefaultGraphView(m)
|
return generateDefaultGraphView(m)
|
||||||
|
case "calendar":
|
||||||
|
return generateDefaultCalendarView(m)
|
||||||
|
case "activity":
|
||||||
|
return generateDefaultActivityView(m)
|
||||||
|
case "dashboard":
|
||||||
|
return generateDefaultDashboardView(m)
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
|
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
|
||||||
}
|
}
|
||||||
@@ -530,6 +536,161 @@ func generateDefaultGraphView(m *orm.Model) string {
|
|||||||
return fmt.Sprintf("<graph>\n %s\n</graph>", strings.Join(fields, "\n "))
|
return fmt.Sprintf("<graph>\n %s\n</graph>", strings.Join(fields, "\n "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateDefaultCalendarView creates a calendar view with auto-detected date fields.
|
||||||
|
// The OWL CalendarArchParser requires date_start; date_stop and color are optional.
|
||||||
|
// Mirrors: odoo/addons/web/static/src/views/calendar/calendar_arch_parser.js
|
||||||
|
func generateDefaultCalendarView(m *orm.Model) string {
|
||||||
|
// Auto-detect date_start field (priority order)
|
||||||
|
dateStart := ""
|
||||||
|
for _, candidate := range []string{"start", "date_start", "date_from", "date_order", "date_begin", "date"} {
|
||||||
|
if f := m.GetField(candidate); f != nil && (f.Type == orm.TypeDatetime || f.Type == orm.TypeDate) {
|
||||||
|
dateStart = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dateStart == "" {
|
||||||
|
// Fallback: find any datetime/date field
|
||||||
|
for _, name := range sortedFieldNames(m) {
|
||||||
|
f := m.GetField(name)
|
||||||
|
if f != nil && (f.Type == orm.TypeDatetime || f.Type == orm.TypeDate) && f.Name != "create_date" && f.Name != "write_date" {
|
||||||
|
dateStart = name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dateStart == "" {
|
||||||
|
// No date field found — return minimal arch that won't crash
|
||||||
|
return `<calendar date_start="create_date"><field name="display_name"/></calendar>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect date_stop field
|
||||||
|
dateStop := ""
|
||||||
|
for _, candidate := range []string{"stop", "date_stop", "date_to", "date_end"} {
|
||||||
|
if f := m.GetField(candidate); f != nil && (f.Type == orm.TypeDatetime || f.Type == orm.TypeDate) {
|
||||||
|
dateStop = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect color field (M2O fields make good color discriminators)
|
||||||
|
colorField := ""
|
||||||
|
for _, candidate := range []string{"color", "user_id", "partner_id", "stage_id"} {
|
||||||
|
if f := m.GetField(candidate); f != nil {
|
||||||
|
colorField = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect all_day field
|
||||||
|
allDay := ""
|
||||||
|
for _, candidate := range []string{"allday", "all_day"} {
|
||||||
|
if f := m.GetField(candidate); f != nil && f.Type == orm.TypeBoolean {
|
||||||
|
allDay = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build attributes
|
||||||
|
attrs := fmt.Sprintf(`date_start="%s"`, dateStart)
|
||||||
|
if dateStop != "" {
|
||||||
|
attrs += fmt.Sprintf(` date_stop="%s"`, dateStop)
|
||||||
|
}
|
||||||
|
if colorField != "" {
|
||||||
|
attrs += fmt.Sprintf(` color="%s"`, colorField)
|
||||||
|
}
|
||||||
|
if allDay != "" {
|
||||||
|
attrs += fmt.Sprintf(` all_day="%s"`, allDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick display fields for the calendar card
|
||||||
|
var fields []string
|
||||||
|
nameField := "display_name"
|
||||||
|
if f := m.GetField("name"); f != nil {
|
||||||
|
nameField = "name"
|
||||||
|
}
|
||||||
|
fields = append(fields, fmt.Sprintf(` <field name="%s"/>`, nameField))
|
||||||
|
|
||||||
|
if f := m.GetField("partner_id"); f != nil {
|
||||||
|
fields = append(fields, ` <field name="partner_id" avatar_field="avatar_128"/>`)
|
||||||
|
}
|
||||||
|
if f := m.GetField("user_id"); f != nil && colorField != "user_id" {
|
||||||
|
fields = append(fields, ` <field name="user_id"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("<calendar %s mode=\"month\">\n%s\n</calendar>",
|
||||||
|
attrs, strings.Join(fields, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateDefaultActivityView creates a minimal activity view.
|
||||||
|
// Mirrors: odoo/addons/mail/static/src/views/web_activity/activity_arch_parser.js
|
||||||
|
func generateDefaultActivityView(m *orm.Model) string {
|
||||||
|
nameField := "display_name"
|
||||||
|
if f := m.GetField("name"); f != nil {
|
||||||
|
nameField = "name"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`<activity string="Activities">
|
||||||
|
<templates>
|
||||||
|
<div t-name="activity-box">
|
||||||
|
<field name="%s"/>
|
||||||
|
</div>
|
||||||
|
</templates>
|
||||||
|
</activity>`, nameField)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateDefaultDashboardView creates a dashboard view with aggregate widgets.
|
||||||
|
// Mirrors: odoo/addons/board/static/src/board_view.js
|
||||||
|
func generateDefaultDashboardView(m *orm.Model) string {
|
||||||
|
var widgets []string
|
||||||
|
|
||||||
|
// Add aggregate widgets for numeric fields
|
||||||
|
for _, name := range sortedFieldNames(m) {
|
||||||
|
f := m.GetField(name)
|
||||||
|
if f == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (f.Type == orm.TypeFloat || f.Type == orm.TypeInteger || f.Type == orm.TypeMonetary) &&
|
||||||
|
f.IsStored() && f.Name != "id" && f.Name != "sequence" &&
|
||||||
|
f.Name != "create_uid" && f.Name != "write_uid" && f.Name != "company_id" {
|
||||||
|
widgets = append(widgets, fmt.Sprintf(
|
||||||
|
` <aggregate name="%s" field="%s" string="%s"/>`,
|
||||||
|
f.Name, f.Name, f.String))
|
||||||
|
if len(widgets) >= 6 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a graph for the first groupable dimension
|
||||||
|
var graphField string
|
||||||
|
for _, name := range sortedFieldNames(m) {
|
||||||
|
f := m.GetField(name)
|
||||||
|
if f != nil && f.IsStored() && (f.Type == orm.TypeMany2one || f.Type == orm.TypeSelection) {
|
||||||
|
graphField = name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
buf.WriteString("<dashboard>\n")
|
||||||
|
if len(widgets) > 0 {
|
||||||
|
buf.WriteString(" <group>\n")
|
||||||
|
for _, w := range widgets {
|
||||||
|
buf.WriteString(w + "\n")
|
||||||
|
}
|
||||||
|
buf.WriteString(" </group>\n")
|
||||||
|
}
|
||||||
|
if graphField != "" {
|
||||||
|
buf.WriteString(fmt.Sprintf(` <view type="graph">
|
||||||
|
<graph type="bar">
|
||||||
|
<field name="%s"/>
|
||||||
|
</graph>
|
||||||
|
</view>
|
||||||
|
`, graphField))
|
||||||
|
}
|
||||||
|
buf.WriteString("</dashboard>")
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
// sortedFieldNames returns field names in alphabetical order for deterministic output.
|
// sortedFieldNames returns field names in alphabetical order for deterministic output.
|
||||||
func sortedFieldNames(m *orm.Model) []string {
|
func sortedFieldNames(m *orm.Model) []string {
|
||||||
fields := m.Fields()
|
fields := m.Fields()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"odoo-go/pkg/orm"
|
"odoo-go/pkg/orm"
|
||||||
@@ -451,6 +452,110 @@ func (s *Server) handleReadGroup(rs *orm.Recordset, params CallKWParams) (interf
|
|||||||
}
|
}
|
||||||
|
|
||||||
if params.Method == "web_read_group" {
|
if params.Method == "web_read_group" {
|
||||||
|
// --- __fold support ---
|
||||||
|
// If the first groupby is a Many2one whose comodel has a "fold" field,
|
||||||
|
// add __fold to each group. Mirrors: odoo/addons/web/models/models.py
|
||||||
|
if len(groupby) > 0 {
|
||||||
|
fieldName := strings.SplitN(groupby[0], ":", 2)[0]
|
||||||
|
m := rs.ModelDef()
|
||||||
|
if m != nil {
|
||||||
|
f := m.GetField(fieldName)
|
||||||
|
if f != nil && f.Type == orm.TypeMany2one && f.Comodel != "" {
|
||||||
|
comodel := orm.Registry.Get(f.Comodel)
|
||||||
|
if comodel != nil && comodel.GetField("fold") != nil {
|
||||||
|
addFoldInfo(rs.Env(), f.Comodel, groupby[0], groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- __records for auto_unfold ---
|
||||||
|
autoUnfold := false
|
||||||
|
if v, ok := params.KW["auto_unfold"].(bool); ok {
|
||||||
|
autoUnfold = v
|
||||||
|
}
|
||||||
|
if autoUnfold {
|
||||||
|
unfoldReadSpec, _ := params.KW["unfold_read_specification"].(map[string]interface{})
|
||||||
|
unfoldLimit := defaultWebSearchLimit
|
||||||
|
if v, ok := params.KW["unfold_read_default_limit"].(float64); ok {
|
||||||
|
unfoldLimit = int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse original domain for combining with group domain
|
||||||
|
origDomain := parseDomain(params.Args)
|
||||||
|
if origDomain == nil {
|
||||||
|
if dr, ok := params.KW["domain"].([]interface{}); ok && len(dr) > 0 {
|
||||||
|
origDomain = parseDomain([]interface{}{dr})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modelName := rs.ModelDef().Name()
|
||||||
|
maxUnfolded := 10
|
||||||
|
unfolded := 0
|
||||||
|
for _, g := range groups {
|
||||||
|
if unfolded >= maxUnfolded {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
gm := g.(map[string]interface{})
|
||||||
|
fold, _ := gm["__fold"].(bool)
|
||||||
|
count, _ := gm["__count"].(int64)
|
||||||
|
// Skip folded, empty, and groups with false/nil M2O value
|
||||||
|
// Mirrors: odoo/addons/web/models/models.py _open_groups() fold checks
|
||||||
|
if fold || count == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// For M2O groupby: skip groups where the value is false (unset M2O)
|
||||||
|
if len(groupby) > 0 {
|
||||||
|
gbVal := gm[groupby[0]]
|
||||||
|
if gbVal == nil || gbVal == false {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build combined domain: original + group extra domain
|
||||||
|
var combinedDomain orm.Domain
|
||||||
|
if origDomain != nil {
|
||||||
|
combinedDomain = append(combinedDomain, origDomain...)
|
||||||
|
}
|
||||||
|
if extraDom, ok := gm["__extra_domain"].([]interface{}); ok && len(extraDom) > 0 {
|
||||||
|
groupDomain := parseDomain([]interface{}{extraDom})
|
||||||
|
combinedDomain = append(combinedDomain, groupDomain...)
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := rs.Env().Model(modelName).Search(combinedDomain, orm.SearchOpts{Limit: unfoldLimit})
|
||||||
|
if err != nil || found.IsEmpty() {
|
||||||
|
gm["__records"] = []orm.Values{}
|
||||||
|
unfolded++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := specToFields(unfoldReadSpec)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
fields = []string{"id"}
|
||||||
|
}
|
||||||
|
hasID := false
|
||||||
|
for _, f := range fields {
|
||||||
|
if f == "id" {
|
||||||
|
hasID = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasID {
|
||||||
|
fields = append([]string{"id"}, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := found.Read(fields)
|
||||||
|
if err != nil {
|
||||||
|
gm["__records"] = []orm.Values{}
|
||||||
|
unfolded++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
formatRecordsForWeb(rs.Env(), modelName, records, unfoldReadSpec)
|
||||||
|
gm["__records"] = records
|
||||||
|
unfolded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// web_read_group: also get total group count (without limit/offset)
|
// web_read_group: also get total group count (without limit/offset)
|
||||||
totalLen := len(results)
|
totalLen := len(results)
|
||||||
if opts.Limit > 0 || opts.Offset > 0 {
|
if opts.Limit > 0 || opts.Offset > 0 {
|
||||||
@@ -470,6 +575,203 @@ func (s *Server) handleReadGroup(rs *orm.Recordset, params CallKWParams) (interf
|
|||||||
return groups, nil
|
return groups, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleReadProgressBar returns per-group counts for a progress bar field.
|
||||||
|
// Mirrors: odoo/orm/models.py BaseModel._read_progress_bar()
|
||||||
|
//
|
||||||
|
// Called by the kanban view to render colored progress bars per column.
|
||||||
|
// Input (via KW):
|
||||||
|
//
|
||||||
|
// domain: search filter
|
||||||
|
// group_by: field to group columns by (e.g. "stage_id")
|
||||||
|
// progress_bar: {field: "kanban_state", colors: {"done": "success", ...}}
|
||||||
|
//
|
||||||
|
// Output:
|
||||||
|
//
|
||||||
|
// {groupByValue: {pbValue: count, ...}, ...}
|
||||||
|
//
|
||||||
|
// Where groupByValue is the raw DB value (integer ID for M2O, string for
|
||||||
|
// selection, "True"/"False" for boolean).
|
||||||
|
func (s *Server) handleReadProgressBar(rs *orm.Recordset, params CallKWParams) (interface{}, *RPCError) {
|
||||||
|
// Parse domain from KW
|
||||||
|
domain := parseDomain(params.Args)
|
||||||
|
if domain == nil {
|
||||||
|
if dr, ok := params.KW["domain"].([]interface{}); ok && len(dr) > 0 {
|
||||||
|
domain = parseDomain([]interface{}{dr})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse group_by (single string)
|
||||||
|
groupBy := ""
|
||||||
|
if v, ok := params.KW["group_by"].(string); ok {
|
||||||
|
groupBy = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse progress_bar map
|
||||||
|
progressBar, _ := params.KW["progress_bar"].(map[string]interface{})
|
||||||
|
pbField, _ := progressBar["field"].(string)
|
||||||
|
|
||||||
|
if groupBy == "" || pbField == "" {
|
||||||
|
return map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ReadGroup with two groupby levels: [groupBy, pbField]
|
||||||
|
results, err := rs.ReadGroup(domain, []string{groupBy, pbField}, []string{"__count"})
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine field types for key formatting
|
||||||
|
m := rs.ModelDef()
|
||||||
|
gbField := m.GetField(groupBy)
|
||||||
|
pbFieldDef := m.GetField(pbField)
|
||||||
|
|
||||||
|
// Build nested map: {groupByValue: {pbValue: count}}
|
||||||
|
data := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Collect all known progress bar values (from colors) so we initialize zeros
|
||||||
|
pbColors, _ := progressBar["colors"].(map[string]interface{})
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
// Format the group-by key
|
||||||
|
gbVal := r.GroupValues[groupBy]
|
||||||
|
gbKey := formatProgressBarKey(gbVal, gbField)
|
||||||
|
|
||||||
|
// Format the progress bar value
|
||||||
|
pbVal := r.GroupValues[pbField]
|
||||||
|
pbKey := formatProgressBarValue(pbVal, pbFieldDef)
|
||||||
|
|
||||||
|
// Initialize group entry with zero counts if first time
|
||||||
|
if _, exists := data[gbKey]; !exists {
|
||||||
|
entry := make(map[string]interface{})
|
||||||
|
for colorKey := range pbColors {
|
||||||
|
entry[colorKey] = 0
|
||||||
|
}
|
||||||
|
data[gbKey] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add count
|
||||||
|
entry := data[gbKey].(map[string]interface{})
|
||||||
|
existing, _ := entry[pbKey].(int)
|
||||||
|
entry[pbKey] = existing + int(r.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatProgressBarKey formats a group-by value as the string key expected
|
||||||
|
// by the frontend progress bar.
|
||||||
|
// - M2O: integer ID (as string)
|
||||||
|
// - Boolean: "True" / "False"
|
||||||
|
// - nil/false: "False"
|
||||||
|
// - Other: value as string
|
||||||
|
func formatProgressBarKey(val interface{}, f *orm.Field) string {
|
||||||
|
if val == nil || val == false {
|
||||||
|
return "False"
|
||||||
|
}
|
||||||
|
|
||||||
|
// M2O: ReadGroup resolves to [id, name] pair — use the id
|
||||||
|
if f != nil && f.Type == orm.TypeMany2one {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
if len(v) > 0 {
|
||||||
|
return fmt.Sprintf("%v", v[0])
|
||||||
|
}
|
||||||
|
return "False"
|
||||||
|
case int64:
|
||||||
|
return fmt.Sprintf("%d", v)
|
||||||
|
case float64:
|
||||||
|
return fmt.Sprintf("%d", int64(v))
|
||||||
|
case int:
|
||||||
|
return fmt.Sprintf("%d", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean
|
||||||
|
if f != nil && f.Type == orm.TypeBoolean {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case bool:
|
||||||
|
if v {
|
||||||
|
return "True"
|
||||||
|
}
|
||||||
|
return "False"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%v", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatProgressBarValue formats a progress bar field value as a string key.
|
||||||
|
// Selection fields use the raw value (e.g. "done", "blocked").
|
||||||
|
// Boolean fields use "True"/"False".
|
||||||
|
func formatProgressBarValue(val interface{}, f *orm.Field) string {
|
||||||
|
if val == nil || val == false {
|
||||||
|
return "False"
|
||||||
|
}
|
||||||
|
if f != nil && f.Type == orm.TypeBoolean {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case bool:
|
||||||
|
if v {
|
||||||
|
return "True"
|
||||||
|
}
|
||||||
|
return "False"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addFoldInfo reads the "fold" boolean from the comodel records referenced
|
||||||
|
// by each group and sets __fold on the group maps accordingly.
|
||||||
|
func addFoldInfo(env *orm.Environment, comodel string, groupbySpec string, groups []interface{}) {
|
||||||
|
// Collect IDs from group values (M2O pairs like [id, name])
|
||||||
|
var ids []int64
|
||||||
|
for _, g := range groups {
|
||||||
|
gm := g.(map[string]interface{})
|
||||||
|
val := gm[groupbySpec]
|
||||||
|
if pair, ok := val.([]interface{}); ok && len(pair) >= 1 {
|
||||||
|
if id, ok := orm.ToRecordID(pair[0]); ok && id > 0 {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
// All groups have false/empty value — fold them by default
|
||||||
|
for _, g := range groups {
|
||||||
|
gm := g.(map[string]interface{})
|
||||||
|
gm["__fold"] = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read fold values from comodel
|
||||||
|
rs := env.Model(comodel).Browse(ids...)
|
||||||
|
records, err := rs.Read([]string{"id", "fold"})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build fold map
|
||||||
|
foldMap := make(map[int64]bool)
|
||||||
|
for _, rec := range records {
|
||||||
|
id, _ := orm.ToRecordID(rec["id"])
|
||||||
|
fold, _ := rec["fold"].(bool)
|
||||||
|
foldMap[id] = fold
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply to groups
|
||||||
|
for _, g := range groups {
|
||||||
|
gm := g.(map[string]interface{})
|
||||||
|
val := gm[groupbySpec]
|
||||||
|
if pair, ok := val.([]interface{}); ok && len(pair) >= 1 {
|
||||||
|
if id, ok := orm.ToRecordID(pair[0]); ok {
|
||||||
|
gm["__fold"] = foldMap[id]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// false/empty group value
|
||||||
|
gm["__fold"] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// formatDateFields converts date/datetime values to Odoo's expected string format.
|
// formatDateFields converts date/datetime values to Odoo's expected string format.
|
||||||
func formatDateFields(model string, records []orm.Values) {
|
func formatDateFields(model string, records []orm.Values) {
|
||||||
m := orm.Registry.Get(model)
|
m := orm.Registry.Get(model)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -73,6 +74,14 @@ func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if post-setup wizard is needed (first login, company not configured)
|
||||||
|
if s.isPostSetupNeeded() {
|
||||||
|
if sess := GetSession(r); sess != nil {
|
||||||
|
http.Redirect(w, r, "/web/setup/wizard", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
sess := GetSession(r)
|
sess := GetSession(r)
|
||||||
if sess == nil {
|
if sess == nil {
|
||||||
@@ -141,7 +150,7 @@ func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
|
|||||||
%s
|
%s
|
||||||
<script>
|
<script>
|
||||||
var odoo = {
|
var odoo = {
|
||||||
csrf_token: "dummy",
|
csrf_token: "%s",
|
||||||
debug: "assets",
|
debug: "assets",
|
||||||
__session_info__: %s,
|
__session_info__: %s,
|
||||||
reloadMenus: function() {
|
reloadMenus: function() {
|
||||||
@@ -178,12 +187,18 @@ func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
|
|||||||
%s</head>
|
%s</head>
|
||||||
<body class="o_web_client">
|
<body class="o_web_client">
|
||||||
</body>
|
</body>
|
||||||
</html>`, linkTags.String(), sessionInfoJSON, scriptTags.String())
|
</html>`, linkTags.String(), sess.CSRFToken, sessionInfoJSON, scriptTags.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSessionInfo constructs the session_info JSON object expected by the webclient.
|
// buildSessionInfo constructs the session_info JSON object expected by the webclient.
|
||||||
// Mirrors: odoo/addons/web/models/ir_http.py session_info()
|
// Mirrors: odoo/addons/web/models/ir_http.py session_info()
|
||||||
func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
|
func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
|
||||||
|
// Build allowed_company_ids from session (populated at login)
|
||||||
|
allowedIDs := sess.AllowedCompanyIDs
|
||||||
|
if len(allowedIDs) == 0 {
|
||||||
|
allowedIDs = []int64{sess.CompanyID}
|
||||||
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"session_id": sess.ID,
|
"session_id": sess.ID,
|
||||||
"uid": sess.UID,
|
"uid": sess.UID,
|
||||||
@@ -194,7 +209,7 @@ func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
|
|||||||
"user_context": map[string]interface{}{
|
"user_context": map[string]interface{}{
|
||||||
"lang": "en_US",
|
"lang": "en_US",
|
||||||
"tz": "UTC",
|
"tz": "UTC",
|
||||||
"allowed_company_ids": []int64{sess.CompanyID},
|
"allowed_company_ids": allowedIDs,
|
||||||
},
|
},
|
||||||
"db": s.config.DBName,
|
"db": s.config.DBName,
|
||||||
"registry_hash": fmt.Sprintf("odoo-go-%d", time.Now().Unix()),
|
"registry_hash": fmt.Sprintf("odoo-go-%d", time.Now().Unix()),
|
||||||
@@ -213,7 +228,7 @@ func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
|
|||||||
"current_menu": 1,
|
"current_menu": 1,
|
||||||
"support_url": "",
|
"support_url": "",
|
||||||
"notification_type": "email",
|
"notification_type": "email",
|
||||||
"display_switch_company_menu": false,
|
"display_switch_company_menu": len(allowedIDs) > 1,
|
||||||
"test_mode": false,
|
"test_mode": false,
|
||||||
"show_effect": true,
|
"show_effect": true,
|
||||||
"currencies": map[string]interface{}{
|
"currencies": map[string]interface{}{
|
||||||
@@ -226,20 +241,7 @@ func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
|
|||||||
"lang": "en_US",
|
"lang": "en_US",
|
||||||
"debug": "assets",
|
"debug": "assets",
|
||||||
},
|
},
|
||||||
"user_companies": map[string]interface{}{
|
"user_companies": s.buildUserCompanies(sess.CompanyID, allowedIDs),
|
||||||
"current_company": sess.CompanyID,
|
|
||||||
"allowed_companies": map[string]interface{}{
|
|
||||||
fmt.Sprintf("%d", sess.CompanyID): map[string]interface{}{
|
|
||||||
"id": sess.CompanyID,
|
|
||||||
"name": "My Company",
|
|
||||||
"sequence": 10,
|
|
||||||
"child_ids": []int64{},
|
|
||||||
"parent_id": false,
|
|
||||||
"currency_id": 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"disallowed_ancestor_companies": map[string]interface{}{},
|
|
||||||
},
|
|
||||||
"user_settings": map[string]interface{}{
|
"user_settings": map[string]interface{}{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"user_id": map[string]interface{}{"id": sess.UID, "display_name": sess.Login},
|
"user_id": map[string]interface{}{"id": sess.UID, "display_name": sess.Login},
|
||||||
@@ -365,3 +367,105 @@ func (s *Server) handleTranslations(w http.ResponseWriter, r *http.Request) {
|
|||||||
"multi_lang": multiLang,
|
"multi_lang": multiLang,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildUserCompanies queries company data and builds the user_companies dict
|
||||||
|
// for the session_info response. Mirrors: odoo/addons/web/models/ir_http.py
|
||||||
|
func (s *Server) buildUserCompanies(currentCompanyID int64, allowedIDs []int64) map[string]interface{} {
|
||||||
|
allowedCompanies := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Batch query all companies at once
|
||||||
|
rows, err := s.pool.Query(context.Background(),
|
||||||
|
`SELECT id, COALESCE(name, 'Company'), COALESCE(currency_id, 1)
|
||||||
|
FROM res_company WHERE id = ANY($1)`, allowedIDs)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var cid, currencyID int64
|
||||||
|
var name string
|
||||||
|
if rows.Scan(&cid, &name, ¤cyID) == nil {
|
||||||
|
allowedCompanies[fmt.Sprintf("%d", cid)] = map[string]interface{}{
|
||||||
|
"id": cid,
|
||||||
|
"name": name,
|
||||||
|
"sequence": 10,
|
||||||
|
"child_ids": []int64{},
|
||||||
|
"parent_id": false,
|
||||||
|
"currency_id": currencyID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for any IDs not found in DB
|
||||||
|
for _, cid := range allowedIDs {
|
||||||
|
key := fmt.Sprintf("%d", cid)
|
||||||
|
if _, exists := allowedCompanies[key]; !exists {
|
||||||
|
allowedCompanies[key] = map[string]interface{}{
|
||||||
|
"id": cid, "name": fmt.Sprintf("Company %d", cid),
|
||||||
|
"sequence": 10, "child_ids": []int64{}, "parent_id": false, "currency_id": int64(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"current_company": currentCompanyID,
|
||||||
|
"allowed_companies": allowedCompanies,
|
||||||
|
"disallowed_ancestor_companies": map[string]interface{}{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSwitchCompany switches the active company for the current session.
|
||||||
|
func (s *Server) handleSwitchCompany(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := GetSession(r)
|
||||||
|
if sess == nil {
|
||||||
|
s.writeJSONRPC(w, nil, nil, &RPCError{Code: 100, Message: "Not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req JSONRPCRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var params struct {
|
||||||
|
CompanyID int64 `json:"company_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil || params.CompanyID == 0 {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid company_id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate company is in allowed list
|
||||||
|
allowed := false
|
||||||
|
for _, cid := range sess.AllowedCompanyIDs {
|
||||||
|
if cid == params.CompanyID {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: 403, Message: "Company not in allowed list"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session
|
||||||
|
sess.CompanyID = params.CompanyID
|
||||||
|
|
||||||
|
// Persist to DB
|
||||||
|
if s.sessions.pool != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
s.sessions.pool.Exec(ctx,
|
||||||
|
`UPDATE sessions SET company_id = $1 WHERE id = $2`, params.CompanyID, sess.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.writeJSONRPC(w, req.ID, map[string]interface{}{
|
||||||
|
"company_id": params.CompanyID,
|
||||||
|
"result": true,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|||||||
160
pkg/service/automation.go
Normal file
160
pkg/service/automation.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
|
"odoo-go/pkg/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunAutomatedActions checks and executes server actions triggered by Create/Write/Unlink.
|
||||||
|
// Called from the ORM after successful Create/Write/Unlink operations.
|
||||||
|
// Mirrors: odoo/addons/base_automation/models/base_automation.py
|
||||||
|
func RunAutomatedActions(env *orm.Environment, modelName, trigger string, recordIDs []int64) {
|
||||||
|
if len(recordIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the ir_model ID for this model
|
||||||
|
var modelID int64
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
`SELECT id FROM ir_model WHERE model = $1`, modelName).Scan(&modelID)
|
||||||
|
if err != nil {
|
||||||
|
return // Model not in ir_model — no actions possible
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching automated actions
|
||||||
|
rows, err := env.Tx().Query(env.Ctx(),
|
||||||
|
`SELECT id, state, COALESCE(update_field_id, ''), COALESCE(update_value, ''),
|
||||||
|
COALESCE(email_to, ''), COALESCE(email_subject, ''), COALESCE(email_body, ''),
|
||||||
|
COALESCE(filter_domain, '')
|
||||||
|
FROM ir_act_server
|
||||||
|
WHERE model_id = $1
|
||||||
|
AND active = true
|
||||||
|
AND trigger = $2
|
||||||
|
ORDER BY sequence, id`, modelID, trigger)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("automation: query error for %s/%s: %v", modelName, trigger, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type action struct {
|
||||||
|
id int64
|
||||||
|
state string
|
||||||
|
updateField string
|
||||||
|
updateValue string
|
||||||
|
emailTo string
|
||||||
|
emailSubject string
|
||||||
|
emailBody string
|
||||||
|
filterDomain string
|
||||||
|
}
|
||||||
|
|
||||||
|
var actions []action
|
||||||
|
for rows.Next() {
|
||||||
|
var a action
|
||||||
|
if err := rows.Scan(&a.id, &a.state, &a.updateField, &a.updateValue,
|
||||||
|
&a.emailTo, &a.emailSubject, &a.emailBody, &a.filterDomain); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
actions = append(actions, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(actions) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range actions {
|
||||||
|
switch a.state {
|
||||||
|
case "object_write":
|
||||||
|
executeObjectWrite(env, modelName, recordIDs, a.updateField, a.updateValue)
|
||||||
|
case "email":
|
||||||
|
executeEmailAction(env, modelName, recordIDs, a.emailTo, a.emailSubject, a.emailBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeObjectWrite updates a field on the triggered records.
|
||||||
|
func executeObjectWrite(env *orm.Environment, modelName string, recordIDs []int64, fieldName, value string) {
|
||||||
|
if fieldName == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tableName := strings.ReplaceAll(modelName, ".", "_")
|
||||||
|
for _, id := range recordIDs {
|
||||||
|
_, err := env.Tx().Exec(env.Ctx(),
|
||||||
|
fmt.Sprintf(`UPDATE %s SET %s = $1 WHERE id = $2`,
|
||||||
|
pgx.Identifier{tableName}.Sanitize(),
|
||||||
|
pgx.Identifier{fieldName}.Sanitize()),
|
||||||
|
value, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("automation: object_write error %s.%s on %d: %v", modelName, fieldName, id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeEmailAction sends an email for each triggered record.
|
||||||
|
func executeEmailAction(env *orm.Environment, modelName string, recordIDs []int64, emailToField, subject, bodyTemplate string) {
|
||||||
|
if emailToField == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := tools.LoadSMTPConfig()
|
||||||
|
if cfg.Host == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tableName := strings.ReplaceAll(modelName, ".", "_")
|
||||||
|
|
||||||
|
for _, id := range recordIDs {
|
||||||
|
// Resolve email address from the record
|
||||||
|
var email string
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
fmt.Sprintf(`SELECT COALESCE(%s, '') FROM %s WHERE id = $1`,
|
||||||
|
pgx.Identifier{emailToField}.Sanitize(),
|
||||||
|
pgx.Identifier{tableName}.Sanitize()),
|
||||||
|
id).Scan(&email)
|
||||||
|
if err != nil || email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple template: replace {{field}} with record values
|
||||||
|
body := bodyTemplate
|
||||||
|
if strings.Contains(body, "{{") {
|
||||||
|
body = resolveTemplate(env, tableName, id, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tools.SendEmail(cfg, email, subject, body); err != nil {
|
||||||
|
log.Printf("automation: email error to %s for %s/%d: %v", email, modelName, id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTemplate replaces {{field_name}} placeholders with actual record values.
|
||||||
|
func resolveTemplate(env *orm.Environment, tableName string, recordID int64, template string) string {
|
||||||
|
result := template
|
||||||
|
for {
|
||||||
|
start := strings.Index(result, "{{")
|
||||||
|
if start == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := strings.Index(result[start:], "}}")
|
||||||
|
if end == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fieldName := strings.TrimSpace(result[start+2 : start+end])
|
||||||
|
var val string
|
||||||
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
|
fmt.Sprintf(`SELECT COALESCE(CAST(%s AS TEXT), '') FROM %s WHERE id = $1`,
|
||||||
|
pgx.Identifier{fieldName}.Sanitize(),
|
||||||
|
pgx.Identifier{tableName}.Sanitize()),
|
||||||
|
recordID).Scan(&val)
|
||||||
|
if err != nil {
|
||||||
|
val = ""
|
||||||
|
}
|
||||||
|
result = result[:start] + val + result[start+end+2:]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -2,57 +2,70 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"odoo-go/pkg/orm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CronJob defines a scheduled task.
|
const (
|
||||||
type CronJob struct {
|
cronPollInterval = 60 * time.Second
|
||||||
Name string
|
maxFailureCount = 5
|
||||||
Interval time.Duration
|
)
|
||||||
Handler func(ctx context.Context, pool *pgxpool.Pool) error
|
|
||||||
running bool
|
// cronJob holds a single scheduled action loaded from the ir_cron table.
|
||||||
|
type cronJob struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
ModelName string
|
||||||
|
MethodName string
|
||||||
|
UserID int64
|
||||||
|
IntervalNumber int
|
||||||
|
IntervalType string
|
||||||
|
NumberCall int
|
||||||
|
NextCall time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// CronScheduler manages periodic jobs.
|
// CronScheduler polls ir_cron and executes ready jobs.
|
||||||
|
// Mirrors: odoo/addons/base/models/ir_cron.py IrCron._process_jobs()
|
||||||
type CronScheduler struct {
|
type CronScheduler struct {
|
||||||
jobs []*CronJob
|
pool *pgxpool.Pool
|
||||||
mu sync.Mutex
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCronScheduler creates a new scheduler.
|
// NewCronScheduler creates a DB-driven cron scheduler.
|
||||||
func NewCronScheduler() *CronScheduler {
|
func NewCronScheduler(pool *pgxpool.Pool) *CronScheduler {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &CronScheduler{ctx: ctx, cancel: cancel}
|
return &CronScheduler{pool: pool, ctx: ctx, cancel: cancel}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register adds a job to the scheduler.
|
// Start begins the polling loop in a background goroutine.
|
||||||
func (s *CronScheduler) Register(job *CronJob) {
|
func (s *CronScheduler) Start() {
|
||||||
s.mu.Lock()
|
s.wg.Add(1)
|
||||||
defer s.mu.Unlock()
|
go s.pollLoop()
|
||||||
s.jobs = append(s.jobs, job)
|
log.Println("cron: scheduler started")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start begins running all registered jobs.
|
// Stop cancels the polling loop and waits for completion.
|
||||||
func (s *CronScheduler) Start(pool *pgxpool.Pool) {
|
|
||||||
for _, job := range s.jobs {
|
|
||||||
go s.runJob(job, pool)
|
|
||||||
}
|
|
||||||
log.Printf("cron: started %d jobs", len(s.jobs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop cancels all running jobs.
|
|
||||||
func (s *CronScheduler) Stop() {
|
func (s *CronScheduler) Stop() {
|
||||||
s.cancel()
|
s.cancel()
|
||||||
|
s.wg.Wait()
|
||||||
|
log.Println("cron: scheduler stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CronScheduler) runJob(job *CronJob, pool *pgxpool.Pool) {
|
func (s *CronScheduler) pollLoop() {
|
||||||
ticker := time.NewTicker(job.Interval)
|
defer s.wg.Done()
|
||||||
|
|
||||||
|
// Run once immediately, then on ticker
|
||||||
|
s.processJobs()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(cronPollInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -60,9 +73,200 @@ func (s *CronScheduler) runJob(job *CronJob, pool *pgxpool.Pool) {
|
|||||||
case <-s.ctx.Done():
|
case <-s.ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := job.Handler(s.ctx, pool); err != nil {
|
s.processJobs()
|
||||||
log.Printf("cron: %s error: %v", job.Name, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processJobs queries all ready cron jobs and processes them one by one.
|
||||||
|
func (s *CronScheduler) processJobs() {
|
||||||
|
rows, err := s.pool.Query(s.ctx, `
|
||||||
|
SELECT id, name, model_name, method_name, user_id,
|
||||||
|
interval_number, interval_type, numbercall, nextcall
|
||||||
|
FROM ir_cron
|
||||||
|
WHERE active = true AND nextcall <= now()
|
||||||
|
ORDER BY priority, id
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cron: query error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobs []cronJob
|
||||||
|
for rows.Next() {
|
||||||
|
var j cronJob
|
||||||
|
var modelName, methodName *string // nullable
|
||||||
|
if err := rows.Scan(&j.ID, &j.Name, &modelName, &methodName, &j.UserID,
|
||||||
|
&j.IntervalNumber, &j.IntervalType, &j.NumberCall, &j.NextCall); err != nil {
|
||||||
|
log.Printf("cron: scan error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if modelName != nil {
|
||||||
|
j.ModelName = *modelName
|
||||||
|
}
|
||||||
|
if methodName != nil {
|
||||||
|
j.MethodName = *methodName
|
||||||
|
}
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for _, job := range jobs {
|
||||||
|
s.processOneJob(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processOneJob acquires a row-level lock and executes a single cron job.
|
||||||
|
func (s *CronScheduler) processOneJob(job cronJob) {
|
||||||
|
tx, err := s.pool.Begin(s.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(s.ctx)
|
||||||
|
|
||||||
|
// Try to acquire the job with FOR NO KEY UPDATE SKIP LOCKED
|
||||||
|
var lockedID int64
|
||||||
|
err = tx.QueryRow(s.ctx, `
|
||||||
|
SELECT id FROM ir_cron
|
||||||
|
WHERE id = $1 AND active = true AND nextcall <= now()
|
||||||
|
FOR NO KEY UPDATE SKIP LOCKED
|
||||||
|
`, job.ID).Scan(&lockedID)
|
||||||
|
if err != nil {
|
||||||
|
// Job already taken by another worker or not ready
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("cron: executing %q (id=%d)", job.Name, job.ID)
|
||||||
|
|
||||||
|
execErr := s.executeJob(job)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
nextCall := calculateNextCall(now, job.IntervalNumber, job.IntervalType)
|
||||||
|
|
||||||
|
if execErr != nil {
|
||||||
|
log.Printf("cron: %q failed: %v", job.Name, execErr)
|
||||||
|
|
||||||
|
// Update failure count, set first_failure_date if not already set
|
||||||
|
if _, err := tx.Exec(s.ctx, `
|
||||||
|
UPDATE ir_cron SET
|
||||||
|
failure_count = failure_count + 1,
|
||||||
|
first_failure_date = COALESCE(first_failure_date, $1),
|
||||||
|
lastcall = $1,
|
||||||
|
nextcall = $2
|
||||||
|
WHERE id = $3
|
||||||
|
`, now, nextCall, job.ID); err != nil {
|
||||||
|
log.Printf("cron: failed to update failure count for %q: %v", job.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate if too many consecutive failures
|
||||||
|
if _, err := tx.Exec(s.ctx, `
|
||||||
|
UPDATE ir_cron SET active = false
|
||||||
|
WHERE id = $1 AND failure_count >= $2
|
||||||
|
`, job.ID, maxFailureCount); err != nil {
|
||||||
|
log.Printf("cron: failed to deactivate %q: %v", job.Name, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("cron: %q completed successfully", job.Name)
|
||||||
|
|
||||||
|
if job.NumberCall > 0 {
|
||||||
|
// Finite run count: decrement
|
||||||
|
newNumberCall := job.NumberCall - 1
|
||||||
|
if newNumberCall <= 0 {
|
||||||
|
if _, err := tx.Exec(s.ctx, `
|
||||||
|
UPDATE ir_cron SET active = false, lastcall = $1, nextcall = $2,
|
||||||
|
failure_count = 0, first_failure_date = NULL, numbercall = 0
|
||||||
|
WHERE id = $3
|
||||||
|
`, now, nextCall, job.ID); err != nil {
|
||||||
|
log.Printf("cron: failed to update job %q: %v", job.Name, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err := tx.Exec(s.ctx, `
|
||||||
|
UPDATE ir_cron SET lastcall = $1, nextcall = $2,
|
||||||
|
failure_count = 0, first_failure_date = NULL, numbercall = $3
|
||||||
|
WHERE id = $4
|
||||||
|
`, now, nextCall, newNumberCall, job.ID); err != nil {
|
||||||
|
log.Printf("cron: failed to update job %q: %v", job.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// numbercall <= 0 means infinite runs
|
||||||
|
if _, err := tx.Exec(s.ctx, `
|
||||||
|
UPDATE ir_cron SET lastcall = $1, nextcall = $2,
|
||||||
|
failure_count = 0, first_failure_date = NULL
|
||||||
|
WHERE id = $3
|
||||||
|
`, now, nextCall, job.ID); err != nil {
|
||||||
|
log.Printf("cron: failed to update job %q: %v", job.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(s.ctx); err != nil {
|
||||||
|
log.Printf("cron: commit error for %q: %v", job.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeJob looks up the target method in orm.Registry and calls it.
|
||||||
|
func (s *CronScheduler) executeJob(job cronJob) error {
|
||||||
|
if job.ModelName == "" || job.MethodName == "" {
|
||||||
|
return fmt.Errorf("cron %q: model_name or method_name not set", job.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
model := orm.Registry.Get(job.ModelName)
|
||||||
|
if model == nil {
|
||||||
|
return fmt.Errorf("cron %q: model %q not found", job.Name, job.ModelName)
|
||||||
|
}
|
||||||
|
if model.Methods == nil {
|
||||||
|
return fmt.Errorf("cron %q: model %q has no methods", job.Name, job.ModelName)
|
||||||
|
}
|
||||||
|
method, ok := model.Methods[job.MethodName]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cron %q: method %q not found on %q", job.Name, job.MethodName, job.ModelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ORM environment for job execution
|
||||||
|
uid := job.UserID
|
||||||
|
if uid == 0 {
|
||||||
|
return fmt.Errorf("cron %q: user_id not set, refusing to run as admin", job.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := orm.NewEnvironment(s.ctx, orm.EnvConfig{
|
||||||
|
Pool: s.pool,
|
||||||
|
UID: uid,
|
||||||
|
Context: map[string]interface{}{
|
||||||
|
"lastcall": job.NextCall,
|
||||||
|
"cron_id": job.ID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cron %q: env error: %w", job.Name, err)
|
||||||
|
}
|
||||||
|
defer env.Close()
|
||||||
|
|
||||||
|
// Call the method on an empty recordset of the target model
|
||||||
|
_, err = method(env.Model(job.ModelName))
|
||||||
|
if err != nil {
|
||||||
|
env.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return env.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateNextCall computes the next execution time based on interval.
|
||||||
|
// Mirrors: odoo/addons/base/models/ir_cron.py _intervalTypes
|
||||||
|
func calculateNextCall(from time.Time, number int, intervalType string) time.Time {
|
||||||
|
switch intervalType {
|
||||||
|
case "minutes":
|
||||||
|
return from.Add(time.Duration(number) * time.Minute)
|
||||||
|
case "hours":
|
||||||
|
return from.Add(time.Duration(number) * time.Hour)
|
||||||
|
case "days":
|
||||||
|
return from.AddDate(0, 0, number)
|
||||||
|
case "weeks":
|
||||||
|
return from.AddDate(0, 0, number*7)
|
||||||
|
case "months":
|
||||||
|
return from.AddDate(0, number, 0)
|
||||||
|
default:
|
||||||
|
return from.Add(time.Duration(number) * time.Hour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -330,6 +330,8 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err
|
|||||||
VALUES (1, 1, true, 1, 1) ON CONFLICT (id) DO NOTHING`)
|
VALUES (1, 1, true, 1, 1) ON CONFLICT (id) DO NOTHING`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
safeExec(ctx, tx, "base_groups", func() { seedBaseGroups(ctx, tx) })
|
||||||
|
safeExec(ctx, tx, "acl_rules", func() { seedACLRules(ctx, tx) })
|
||||||
safeExec(ctx, tx, "system_params", func() { seedSystemParams(ctx, tx) })
|
safeExec(ctx, tx, "system_params", func() { seedSystemParams(ctx, tx) })
|
||||||
safeExec(ctx, tx, "languages", func() { seedLanguages(ctx, tx) })
|
safeExec(ctx, tx, "languages", func() { seedLanguages(ctx, tx) })
|
||||||
safeExec(ctx, tx, "translations", func() { seedTranslations(ctx, tx) })
|
safeExec(ctx, tx, "translations", func() { seedTranslations(ctx, tx) })
|
||||||
@@ -1676,3 +1678,136 @@ func generateUUID() string {
|
|||||||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedBaseGroups creates the base security groups and their XML IDs.
|
||||||
|
// Mirrors: odoo/addons/base/security/base_groups.xml
|
||||||
|
func seedBaseGroups(ctx context.Context, tx pgx.Tx) {
|
||||||
|
log.Println("db: seeding base security groups...")
|
||||||
|
|
||||||
|
type groupDef struct {
|
||||||
|
id int64
|
||||||
|
name string
|
||||||
|
xmlID string
|
||||||
|
}
|
||||||
|
groups := []groupDef{
|
||||||
|
{1, "Internal User", "group_user"},
|
||||||
|
{2, "Settings", "group_system"},
|
||||||
|
{3, "Access Rights", "group_erp_manager"},
|
||||||
|
{4, "Allow Export", "group_allow_export"},
|
||||||
|
{5, "Portal", "group_portal"},
|
||||||
|
{6, "Public", "group_public"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range groups {
|
||||||
|
tx.Exec(ctx, `INSERT INTO res_groups (id, name)
|
||||||
|
VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, g.id, g.name)
|
||||||
|
tx.Exec(ctx, `INSERT INTO ir_model_data (module, name, model, res_id)
|
||||||
|
VALUES ('base', $1, 'res.groups', $2) ON CONFLICT DO NOTHING`, g.xmlID, g.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add admin user (uid=1) to all groups
|
||||||
|
for _, g := range groups {
|
||||||
|
tx.Exec(ctx, `INSERT INTO res_groups_res_users_rel (res_groups_id, res_users_id)
|
||||||
|
VALUES ($1, 1) ON CONFLICT DO NOTHING`, g.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedACLRules creates access control entries for ALL registered models.
|
||||||
|
// Categorizes models into security tiers and assigns appropriate permissions.
|
||||||
|
// Mirrors: odoo/addons/base/security/ir.model.access.csv + per-module CSVs
|
||||||
|
func seedACLRules(ctx context.Context, tx pgx.Tx) {
|
||||||
|
log.Println("db: seeding ACL rules for all models...")
|
||||||
|
|
||||||
|
// Resolve group IDs
|
||||||
|
var groupSystem, groupUser int64
|
||||||
|
err := tx.QueryRow(ctx,
|
||||||
|
`SELECT g.id FROM res_groups g
|
||||||
|
JOIN ir_model_data imd ON imd.res_id = g.id AND imd.model = 'res.groups'
|
||||||
|
WHERE imd.module = 'base' AND imd.name = 'group_system'`).Scan(&groupSystem)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("db: cannot find group_system, skipping ACL seeding: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = tx.QueryRow(ctx,
|
||||||
|
`SELECT g.id FROM res_groups g
|
||||||
|
JOIN ir_model_data imd ON imd.res_id = g.id AND imd.model = 'res.groups'
|
||||||
|
WHERE imd.module = 'base' AND imd.name = 'group_user'`).Scan(&groupUser)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("db: cannot find group_user, skipping ACL seeding: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Security Tiers ──────────────────────────────────────────────
|
||||||
|
// Tier 1: System-only — only group_system gets full access
|
||||||
|
systemOnly := map[string]bool{
|
||||||
|
"ir.cron": true, "ir.rule": true, "ir.model.access": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2: Admin-only — group_user=read, group_system=full
|
||||||
|
adminOnly := map[string]bool{
|
||||||
|
"ir.model": true, "ir.model.fields": true, "ir.model.data": true,
|
||||||
|
"ir.module.category": true, "ir.actions.server": true, "ir.sequence": true,
|
||||||
|
"ir.logging": true, "ir.config_parameter": true, "ir.default": true,
|
||||||
|
"ir.translation": true, "ir.actions.report": true, "report.paperformat": true,
|
||||||
|
"res.config.settings": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: Read-only for users — group_user=read, group_system=full
|
||||||
|
readOnly := map[string]bool{
|
||||||
|
"res.currency": true, "res.currency.rate": true,
|
||||||
|
"res.country": true, "res.country.state": true, "res.country.group": true,
|
||||||
|
"res.lang": true, "uom.category": true, "uom.uom": true,
|
||||||
|
"product.category": true, "product.removal": true,
|
||||||
|
"account.account.tag": true, "account.group": true,
|
||||||
|
"account.tax.group": true, "account.tax.repartition.line": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else → Tier 4: Standard user (group_user=full, group_system=full)
|
||||||
|
|
||||||
|
// Helper to insert an ACL rule
|
||||||
|
insertACL := func(modelID int64, modelName string, groupID int64, suffix string, read, write, create, unlink bool) {
|
||||||
|
aclName := "access_" + strings.ReplaceAll(modelName, ".", "_") + "_" + suffix
|
||||||
|
tx.Exec(ctx, `
|
||||||
|
INSERT INTO ir_model_access (name, model_id, group_id, perm_read, perm_write, perm_create, perm_unlink, active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
aclName, modelID, groupID, read, write, create, unlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate ALL registered models
|
||||||
|
allModels := orm.Registry.Models()
|
||||||
|
seeded := 0
|
||||||
|
for _, m := range allModels {
|
||||||
|
modelName := m.Name()
|
||||||
|
if m.IsAbstract() {
|
||||||
|
continue // Abstract models have no table → no ACL needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up ir_model ID
|
||||||
|
var modelID int64
|
||||||
|
err := tx.QueryRow(ctx,
|
||||||
|
"SELECT id FROM ir_model WHERE model = $1", modelName).Scan(&modelID)
|
||||||
|
if err != nil {
|
||||||
|
continue // Not yet in ir_model — will be seeded on next restart
|
||||||
|
}
|
||||||
|
|
||||||
|
if systemOnly[modelName] {
|
||||||
|
// Tier 1: only group_system full access
|
||||||
|
insertACL(modelID, modelName, groupSystem, "system", true, true, true, true)
|
||||||
|
} else if adminOnly[modelName] {
|
||||||
|
// Tier 2: group_user=read, group_system=full
|
||||||
|
insertACL(modelID, modelName, groupUser, "user_read", true, false, false, false)
|
||||||
|
insertACL(modelID, modelName, groupSystem, "system", true, true, true, true)
|
||||||
|
} else if readOnly[modelName] {
|
||||||
|
// Tier 3: group_user=read, group_system=full
|
||||||
|
insertACL(modelID, modelName, groupUser, "user_read", true, false, false, false)
|
||||||
|
insertACL(modelID, modelName, groupSystem, "system", true, true, true, true)
|
||||||
|
} else {
|
||||||
|
// Tier 4: group_user=full, group_system=full
|
||||||
|
insertACL(modelID, modelName, groupUser, "user", true, true, true, true)
|
||||||
|
insertACL(modelID, modelName, groupSystem, "system", true, true, true, true)
|
||||||
|
}
|
||||||
|
seeded++
|
||||||
|
}
|
||||||
|
log.Printf("db: seeded ACL rules for %d models", seeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
255
pkg/service/fetchmail.go
Normal file
255
pkg/service/fetchmail.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/emersion/go-imap/v2"
|
||||||
|
"github.com/emersion/go-imap/v2/imapclient"
|
||||||
|
gomessage "github.com/emersion/go-message"
|
||||||
|
_ "github.com/emersion/go-message/charset"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchmailConfig holds IMAP server configuration.
|
||||||
|
type FetchmailConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
UseTLS bool
|
||||||
|
Folder string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFetchmailConfig loads IMAP settings from environment variables.
|
||||||
|
func LoadFetchmailConfig() *FetchmailConfig {
|
||||||
|
cfg := &FetchmailConfig{
|
||||||
|
Port: 993,
|
||||||
|
UseTLS: true,
|
||||||
|
Folder: "INBOX",
|
||||||
|
}
|
||||||
|
cfg.Host = os.Getenv("IMAP_HOST")
|
||||||
|
cfg.User = os.Getenv("IMAP_USER")
|
||||||
|
cfg.Password = os.Getenv("IMAP_PASSWORD")
|
||||||
|
if v := os.Getenv("IMAP_FOLDER"); v != "" {
|
||||||
|
cfg.Folder = v
|
||||||
|
}
|
||||||
|
if os.Getenv("IMAP_TLS") == "false" {
|
||||||
|
cfg.UseTLS = false
|
||||||
|
if cfg.Port == 993 {
|
||||||
|
cfg.Port = 143
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAndProcessEmails connects to IMAP, fetches unseen emails, and creates
|
||||||
|
// mail.message records in the database. Matches emails to existing threads
|
||||||
|
// via In-Reply-To/References headers.
|
||||||
|
// Mirrors: odoo/addons/fetchmail/models/fetchmail.py fetch_mail()
|
||||||
|
func FetchAndProcessEmails(ctx context.Context, pool *pgxpool.Pool) error {
|
||||||
|
cfg := LoadFetchmailConfig()
|
||||||
|
if cfg.Host == "" {
|
||||||
|
return nil // IMAP not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
|
|
||||||
|
var c *imapclient.Client
|
||||||
|
var err error
|
||||||
|
if cfg.UseTLS {
|
||||||
|
c, err = imapclient.DialTLS(addr, nil)
|
||||||
|
} else {
|
||||||
|
c, err = imapclient.DialInsecure(addr, nil)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetchmail: connect to %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if err := c.Login(cfg.User, cfg.Password).Wait(); err != nil {
|
||||||
|
return fmt.Errorf("fetchmail: login as %s: %w", cfg.User, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.Select(cfg.Folder, nil).Wait(); err != nil {
|
||||||
|
return fmt.Errorf("fetchmail: select %s: %w", cfg.Folder, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search unseen
|
||||||
|
criteria := &imap.SearchCriteria{
|
||||||
|
NotFlag: []imap.Flag{imap.FlagSeen},
|
||||||
|
}
|
||||||
|
searchData, err := c.Search(criteria, nil).Wait()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetchmail: search: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seqSet := searchData.All
|
||||||
|
if seqSet == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch envelope + body
|
||||||
|
fetchOpts := &imap.FetchOptions{
|
||||||
|
Envelope: true,
|
||||||
|
BodySection: []*imap.FetchItemBodySection{{}},
|
||||||
|
}
|
||||||
|
msgs, err := c.Fetch(seqSet, fetchOpts).Collect()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetchmail: fetch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var processed int
|
||||||
|
for _, msg := range msgs {
|
||||||
|
if err := processOneEmail(ctx, pool, msg); err != nil {
|
||||||
|
log.Printf("fetchmail: process error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
processed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as seen
|
||||||
|
if processed > 0 {
|
||||||
|
storeFlags := &imap.StoreFlags{
|
||||||
|
Op: imap.StoreFlagsAdd,
|
||||||
|
Flags: []imap.Flag{imap.FlagSeen},
|
||||||
|
}
|
||||||
|
if _, err := c.Store(seqSet, storeFlags, nil).Collect(); err != nil {
|
||||||
|
log.Printf("fetchmail: mark seen error: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("fetchmail: processed %d new emails", processed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processOneEmail(ctx context.Context, pool *pgxpool.Pool, buf *imapclient.FetchMessageBuffer) error {
|
||||||
|
env := buf.Envelope
|
||||||
|
if env == nil {
|
||||||
|
return fmt.Errorf("no envelope")
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := env.Subject
|
||||||
|
messageID := env.MessageID
|
||||||
|
inReplyTo := env.InReplyTo
|
||||||
|
date := env.Date
|
||||||
|
|
||||||
|
var fromEmail, fromName string
|
||||||
|
if len(env.From) > 0 {
|
||||||
|
fromEmail = fmt.Sprintf("%s@%s", env.From[0].Mailbox, env.From[0].Host)
|
||||||
|
fromName = env.From[0].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract body from body section
|
||||||
|
var bodyText string
|
||||||
|
bodyBytes := buf.FindBodySection(&imap.FetchItemBodySection{})
|
||||||
|
if bodyBytes != nil {
|
||||||
|
bodyText = parseEmailBody(bodyBytes)
|
||||||
|
}
|
||||||
|
if bodyText == "" {
|
||||||
|
bodyText = "(no body)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find author partner by email
|
||||||
|
var authorID int64
|
||||||
|
pool.QueryRow(ctx,
|
||||||
|
`SELECT id FROM res_partner WHERE LOWER(email) = LOWER($1) LIMIT 1`, fromEmail,
|
||||||
|
).Scan(&authorID)
|
||||||
|
|
||||||
|
// Thread matching via In-Reply-To
|
||||||
|
var parentModel string
|
||||||
|
var parentResID int64
|
||||||
|
if len(inReplyTo) > 0 && inReplyTo[0] != "" {
|
||||||
|
pool.QueryRow(ctx,
|
||||||
|
`SELECT model, res_id FROM mail_message
|
||||||
|
WHERE message_id = $1 AND model IS NOT NULL AND res_id IS NOT NULL
|
||||||
|
LIMIT 1`, inReplyTo[0],
|
||||||
|
).Scan(&parentModel, &parentResID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: match by subject
|
||||||
|
if parentModel == "" && subject != "" {
|
||||||
|
clean := subject
|
||||||
|
for _, prefix := range []string{"Re: ", "RE: ", "Fwd: ", "FW: ", "AW: "} {
|
||||||
|
clean = strings.TrimPrefix(clean, prefix)
|
||||||
|
}
|
||||||
|
pool.QueryRow(ctx,
|
||||||
|
`SELECT model, res_id FROM mail_message
|
||||||
|
WHERE subject = $1 AND model IS NOT NULL AND res_id IS NOT NULL
|
||||||
|
ORDER BY id DESC LIMIT 1`, clean,
|
||||||
|
).Scan(&parentModel, &parentResID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO mail_message
|
||||||
|
(subject, body, message_type, email_from, author_id, model, res_id,
|
||||||
|
date, message_id, create_uid, write_uid, create_date, write_date)
|
||||||
|
VALUES ($1, $2, 'email', $3, $4, $5, $6, $7, $8, 1, 1, NOW(), NOW())`,
|
||||||
|
subject, bodyText,
|
||||||
|
fmt.Sprintf("%s <%s>", fromName, fromEmail),
|
||||||
|
nilIfZero(authorID),
|
||||||
|
nilIfEmpty(parentModel),
|
||||||
|
nilIfZero(parentResID),
|
||||||
|
date,
|
||||||
|
messageID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEmailBody(raw []byte) string {
|
||||||
|
entity, err := gomessage.Read(strings.NewReader(string(raw)))
|
||||||
|
if err != nil {
|
||||||
|
return string(raw) // fallback: raw text
|
||||||
|
}
|
||||||
|
|
||||||
|
if mr := entity.MultipartReader(); mr != nil {
|
||||||
|
var htmlBody, textBody string
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
ct, _, _ := part.Header.ContentType()
|
||||||
|
body, _ := io.ReadAll(part.Body)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(ct, "text/html"):
|
||||||
|
htmlBody = string(body)
|
||||||
|
case strings.HasPrefix(ct, "text/plain"):
|
||||||
|
textBody = string(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if htmlBody != "" {
|
||||||
|
return htmlBody
|
||||||
|
}
|
||||||
|
return textBody
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single part
|
||||||
|
body, _ := io.ReadAll(entity.Body)
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nilIfZero(v int64) interface{} {
|
||||||
|
if v == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func nilIfEmpty(v string) interface{} {
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterFetchmailCron ensures the message_id column exists for thread matching.
|
||||||
|
func RegisterFetchmailCron(ctx context.Context, pool *pgxpool.Pool) {
|
||||||
|
pool.Exec(ctx, `ALTER TABLE mail_message ADD COLUMN IF NOT EXISTS message_id VARCHAR(255)`)
|
||||||
|
pool.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_mail_message_message_id ON mail_message(message_id)`)
|
||||||
|
log.Println("fetchmail: ready (IMAP config via IMAP_HOST/IMAP_USER/IMAP_PASSWORD env vars)")
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -64,8 +65,12 @@ func SendEmail(cfg *SMTPConfig, to, subject, body string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize headers to prevent injection via \r\n
|
||||||
|
sanitize := func(s string) string {
|
||||||
|
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
||||||
|
}
|
||||||
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s",
|
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s",
|
||||||
cfg.From, to, subject, body)
|
sanitize(cfg.From), sanitize(to), sanitize(subject), body)
|
||||||
|
|
||||||
auth := smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)
|
auth := smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)
|
||||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
@@ -83,12 +88,17 @@ func SendEmailWithAttachments(cfg *SMTPConfig, to []string, subject, bodyHTML st
|
|||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
boundary := "==odoo-go-boundary-42=="
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
boundary := fmt.Sprintf("==odoo-go-%x==", b)
|
||||||
|
|
||||||
|
sanitize := func(s string) string {
|
||||||
|
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
||||||
|
}
|
||||||
var msg strings.Builder
|
var msg strings.Builder
|
||||||
msg.WriteString(fmt.Sprintf("From: %s\r\n", cfg.From))
|
msg.WriteString(fmt.Sprintf("From: %s\r\n", sanitize(cfg.From)))
|
||||||
msg.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(to, ", ")))
|
msg.WriteString(fmt.Sprintf("To: %s\r\n", sanitize(strings.Join(to, ", "))))
|
||||||
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", sanitize(subject)))
|
||||||
msg.WriteString("MIME-Version: 1.0\r\n")
|
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
|
||||||
if len(attachments) > 0 {
|
if len(attachments) > 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user