diff --git a/.gitignore b/.gitignore
index 1af331c..87bfb9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Build output
build/
+odoo-server
*.exe
*.test
*.out
diff --git a/TODO.md b/TODO.md
index e69de29..99d3fb2 100644
--- a/TODO.md
+++ b/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
diff --git a/addons/account/models/account_account.go b/addons/account/models/account_account.go
index f708278..03d16c1 100644
--- a/addons/account/models/account_account.go
+++ b/addons/account/models/account_account.go
@@ -1,6 +1,10 @@
package models
-import "odoo-go/pkg/orm"
+import (
+ "fmt"
+
+ "odoo-go/pkg/orm"
+)
// initAccountAccount registers the chart of accounts.
// Mirrors: odoo/addons/account/models/account_account.py
@@ -203,3 +207,95 @@ func initAccountFiscalPosition() {
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
+ })
+}
diff --git a/addons/account/models/account_asset.go b/addons/account/models/account_asset.go
index 0a74db3..d3c2991 100644
--- a/addons/account/models/account_asset.go
+++ b/addons/account/models/account_asset.go
@@ -251,7 +251,17 @@ func initAccountAsset() {
periodMonths = 1
}
+ // Use prorata_date or acquisition_date as start, fallback to 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 {
case "linear":
@@ -460,6 +470,156 @@ func initAccountAsset() {
}, 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 --
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
vals := orm.Values{
diff --git a/addons/account/models/account_edi.go b/addons/account/models/account_edi.go
index db63efb..ccb1e2e 100644
--- a/addons/account/models/account_edi.go
+++ b/addons/account/models/account_edi.go
@@ -315,7 +315,7 @@ func generateInvoiceXML(env *orm.Environment, moveID int64, formatCode string) (
IssueDate: issueDateStr,
DueDate: dueDateStr,
InvoiceTypeCode: typeCode,
- DocumentCurrencyCode: "EUR",
+ DocumentCurrencyCode: getCurrencyCode(env, moveID),
Supplier: UBLParty{
Name: companyName,
Street: ptrStr(companyStreet),
@@ -356,6 +356,19 @@ func generateInvoiceXML(env *orm.Environment, moveID int64, formatCode string) (
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.
func ptrStr(s *string) string {
if s != nil {
diff --git a/addons/account/models/account_followup.go b/addons/account/models/account_followup.go
index 16001b4..8dff2e3 100644
--- a/addons/account/models/account_followup.go
+++ b/addons/account/models/account_followup.go
@@ -2,6 +2,7 @@ package models
import (
"fmt"
+ "html"
"strings"
"odoo-go/pkg/orm"
@@ -276,7 +277,7 @@ func initFollowupProcess() {
.overdue{color:#d9534f;font-weight:bold}
h2{color:#875a7b}
`)
- b.WriteString(fmt.Sprintf("
Payment Follow-up: %s
", partnerName))
+ b.WriteString(fmt.Sprintf("Payment Follow-up: %s
", html.EscapeString(partnerName)))
b.WriteString(`| Invoice | Due Date | Total | Amount Due | Overdue Days |
`)
var totalDue float64
diff --git a/addons/account/models/account_lock.go b/addons/account/models/account_lock.go
index 64b0f6d..866fe83 100644
--- a/addons/account/models/account_lock.go
+++ b/addons/account/models/account_lock.go
@@ -38,8 +38,11 @@ func initAccountLock() {
)
// _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()
+ //
+ // 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) {
env := rs.Env()
moveID := rs.IDs()[0]
@@ -54,6 +57,17 @@ func initAccountLock() {
FROM account_move WHERE id = $1`, moveID,
).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
rows, err := env.Tx().Query(env.Ctx(),
`SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0),
@@ -78,9 +92,9 @@ func initAccountLock() {
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,
- strings.Join(lineData, ";"))
+ strings.Join(lineData, ";"), companyVAT)
return orm.Values{"string_to_hash": hashStr}, nil
})
diff --git a/addons/account/models/account_move.go b/addons/account/models/account_move.go
index bec41ab..a9be02f 100644
--- a/addons/account/models/account_move.go
+++ b/addons/account/models/account_move.go
@@ -227,8 +227,8 @@ func initAccountMove() {
var untaxed, tax float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT
- COALESCE(SUM(CASE WHEN display_type IS NULL OR display_type = '' OR display_type = 'product' THEN ABS(balance) ELSE 0 END), 0),
- COALESCE(SUM(CASE WHEN display_type = 'tax' THEN ABS(balance) ELSE 0 END), 0)
+ COALESCE(SUM(CASE WHEN display_type IS NULL OR display_type = '' OR display_type = 'product' THEN ABS(balance::float8) ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN display_type = 'tax' THEN ABS(balance::float8) ELSE 0 END), 0)
FROM account_move_line WHERE move_id = $1`, moveID,
).Scan(&untaxed, &tax)
if err != nil {
@@ -237,24 +237,62 @@ func initAccountMove() {
total := untaxed + tax
+ // amount_residual: actual remaining amount from payment_term line residuals.
+ // Mirrors: odoo/addons/account/models/account_move.py _compute_amount()
+ // For invoices, residual = sum of absolute residuals on receivable/payable lines.
+ // Falls back to total if no payment_term lines exist.
+ var residual float64
+ var hasPTLines bool
+ err = env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(ABS(amount_residual::float8)), 0), COUNT(*) > 0
+ FROM account_move_line WHERE move_id = $1 AND display_type = 'payment_term'`,
+ moveID).Scan(&residual, &hasPTLines)
+ if err != nil || !hasPTLines {
+ residual = total
+ }
+
// amount_total_signed: total in company currency (sign depends on move type)
// For customer invoices/receipts the sign is positive, for credit notes negative.
var moveType string
+ var currencyID int64
+ var moveDate *string
env.Tx().QueryRow(env.Ctx(),
- `SELECT COALESCE(move_type, 'entry') FROM account_move WHERE id = $1`, moveID,
- ).Scan(&moveType)
+ `SELECT COALESCE(move_type, 'entry'), COALESCE(currency_id, 0), date::text
+ FROM account_move WHERE id = $1`, moveID,
+ ).Scan(&moveType, ¤cyID, &moveDate)
sign := 1.0
if moveType == "out_refund" || moveType == "in_refund" {
sign = -1.0
}
+ // _compute_amount_total_in_currency_signed: multiply total by currency rate.
+ // Mirrors: odoo/addons/account/models/account_move.py _compute_amount_total_in_currency_signed()
+ // The currency rate converts the move total to the document currency.
+ currencyRate := 1.0
+ if currencyID > 0 {
+ dateCond := time.Now().Format("2006-01-02")
+ if moveDate != nil && *moveDate != "" {
+ dateCond = *moveDate
+ }
+ var rate float64
+ err = env.Tx().QueryRow(env.Ctx(),
+ `SELECT rate FROM res_currency_rate
+ WHERE currency_id = $1 AND name <= $2
+ ORDER BY name DESC LIMIT 1`, currencyID, dateCond,
+ ).Scan(&rate)
+ if err == nil && rate > 0 {
+ currencyRate = rate
+ }
+ }
+
return orm.Values{
- "amount_untaxed": untaxed,
- "amount_tax": tax,
- "amount_total": total,
- "amount_residual": total, // Simplified: residual = total until payments
- "amount_total_signed": total * sign,
+ "amount_untaxed": untaxed,
+ "amount_tax": tax,
+ "amount_total": total,
+ "amount_residual": residual,
+ "amount_total_signed": total * sign,
+ "amount_total_in_currency_signed": total * sign * currencyRate,
}, nil
}
@@ -263,6 +301,59 @@ func initAccountMove() {
m.RegisterCompute("amount_total", computeAmount)
m.RegisterCompute("amount_residual", computeAmount)
m.RegisterCompute("amount_total_signed", computeAmount)
+ m.RegisterCompute("amount_total_in_currency_signed", computeAmount)
+
+ // _compute_payment_state: derives payment status from receivable/payable line residuals.
+ // Mirrors: odoo/addons/account/models/account_move.py _compute_payment_state()
+ //
+ // not_paid: no payment at all
+ // partial: some lines partially reconciled
+ // in_payment: payment registered but not yet fully matched
+ // paid: fully reconciled (residual ~ 0)
+ // reversed: reversed entry
+ m.RegisterCompute("payment_state", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ moveID := rs.IDs()[0]
+
+ var moveType, state string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(move_type, 'entry'), COALESCE(state, 'draft') FROM account_move WHERE id = $1`, moveID,
+ ).Scan(&moveType, &state)
+
+ // Only invoices/receipts have payment_state; journal entries are always not_paid
+ if moveType == "entry" || state != "posted" {
+ return orm.Values{"payment_state": "not_paid"}, nil
+ }
+
+ // Check if this is a reversal
+ var reversedID *int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT reversed_entry_id FROM account_move WHERE id = $1`, moveID,
+ ).Scan(&reversedID)
+ if reversedID != nil && *reversedID > 0 {
+ return orm.Values{"payment_state": "reversed"}, nil
+ }
+
+ // Sum the payment_term lines' balance and residual
+ var totalBalance, totalResidual float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(ABS(balance::float8)), 0), COALESCE(SUM(ABS(amount_residual::float8)), 0)
+ FROM account_move_line WHERE move_id = $1 AND display_type = 'payment_term'`,
+ moveID).Scan(&totalBalance, &totalResidual)
+
+ if totalBalance == 0 {
+ return orm.Values{"payment_state": "not_paid"}, nil
+ }
+
+ pState := "not_paid"
+ if totalResidual < 0.005 {
+ pState = "paid"
+ } else if totalResidual < totalBalance-0.005 {
+ pState = "partial"
+ }
+
+ return orm.Values{"payment_state": pState}, nil
+ })
// -- Business Methods: State Transitions --
// Mirrors: odoo/addons/account/models/account_move.py action_post(), button_cancel()
@@ -339,10 +430,10 @@ func initAccountMove() {
JOIN account_move m ON m.journal_id = j.id WHERE m.id = $1`, id,
).Scan(&journalID, &journalCode)
- // Get next sequence number
+ // Get next sequence number (with row lock to prevent race conditions)
var nextNum int64
env.Tx().QueryRow(env.Ctx(),
- `SELECT COALESCE(MAX(sequence_number), 0) + 1 FROM account_move WHERE journal_id = $1`,
+ `SELECT COALESCE(MAX(sequence_number), 0) + 1 FROM account_move WHERE journal_id = $1 FOR UPDATE`,
journalID).Scan(&nextNum)
// Format: journalCode/YYYY/NNNN
@@ -362,19 +453,72 @@ func initAccountMove() {
return true, nil
})
- // button_cancel: posted β cancel (or draft β cancel)
+ // button_cancel: posted β cancel (via draft) or draft β cancel.
+ // Mirrors: odoo/addons/account/models/account_move.py button_cancel()
+ // Python Odoo resets posted moves to draft first, then cancels from draft.
+ // Also unreconciles all lines and cancels linked payments.
m.RegisterMethod("button_cancel", 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 account_move WHERE id = $1`, id).Scan(&state)
+ if err != nil {
+ return nil, err
+ }
+
+ // Posted moves go to draft first (mirrors Python: moves_to_reset_draft)
+ if state == "posted" {
+ // Remove reconciliation on all lines of this move
+ lineRows, lErr := env.Tx().Query(env.Ctx(),
+ `SELECT id FROM account_move_line WHERE move_id = $1`, id)
+ if lErr == nil {
+ var lineIDs []int64
+ for lineRows.Next() {
+ var lid int64
+ if lineRows.Scan(&lid) == nil {
+ lineIDs = append(lineIDs, lid)
+ }
+ }
+ lineRows.Close()
+
+ for _, lid := range lineIDs {
+ env.Tx().Exec(env.Ctx(),
+ `DELETE FROM account_partial_reconcile WHERE debit_move_id = $1 OR credit_move_id = $1`, lid)
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_move_line SET amount_residual = balance, full_reconcile_id = NULL, reconciled = false WHERE id = $1`, lid)
+ }
+ // Clean orphaned full reconciles
+ env.Tx().Exec(env.Ctx(),
+ `DELETE FROM account_full_reconcile WHERE id NOT IN (SELECT DISTINCT full_reconcile_id FROM account_partial_reconcile WHERE full_reconcile_id IS NOT NULL)`)
+ }
+
+ // Reset to draft first
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_move SET state = 'draft' WHERE id = $1`, id)
+ state = "draft"
+ }
+
+ if state != "draft" {
+ return nil, fmt.Errorf("account: only draft journal entries can be cancelled (current: %s)", state)
+ }
+
+ // Cancel linked payments
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_payment SET state = 'canceled' WHERE move_id = $1`, id)
+
+ // Set to cancel, disable auto_post
if _, err := env.Tx().Exec(env.Ctx(),
- `UPDATE account_move SET state = 'cancel' WHERE id = $1`, id); err != nil {
+ `UPDATE account_move SET state = 'cancel', auto_post = false WHERE id = $1`, id); err != nil {
return nil, err
}
}
return true, nil
})
- // button_draft: cancel β draft
+ // button_draft: cancel/posted β draft
+ // Mirrors: odoo/addons/account/models/account_move.py button_draft()
+ // Python Odoo allows both posted AND cancelled entries to be reset to draft.
m.RegisterMethod("button_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
@@ -384,9 +528,24 @@ func initAccountMove() {
if err != nil {
return nil, err
}
- if state != "cancel" {
- return nil, fmt.Errorf("account: can only reset cancelled entries to draft (current: %s)", state)
+ if state != "cancel" && state != "posted" {
+ return nil, fmt.Errorf("account: only posted/cancelled journal entries can be reset to draft (current: %s)", state)
}
+
+ // If posted, check that the entry is not hashed (immutable audit trail)
+ if state == "posted" {
+ var hash *string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT inalterable_hash FROM account_move WHERE id = $1`, id).Scan(&hash)
+ if hash != nil && *hash != "" {
+ return nil, fmt.Errorf("account: cannot reset to draft β entry is locked with hash")
+ }
+ }
+
+ // Remove analytic lines linked to this move's journal items
+ env.Tx().Exec(env.Ctx(),
+ `DELETE FROM account_analytic_line WHERE move_line_id IN (SELECT id FROM account_move_line WHERE move_id = $1)`, id)
+
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE account_move SET state = 'draft' WHERE id = $1`, id); err != nil {
return nil, err
@@ -781,16 +940,28 @@ func initAccountMove() {
// Mirrors: odoo/addons/account/models/account_payment.py AccountPayment.action_register_payment()
m.RegisterMethod("register_payment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
+
+ // Accept optional partial amount from kwargs
+ var partialAmount float64
+ if len(args) > 0 {
+ if kw, ok := args[0].(map[string]interface{}); ok {
+ if amt, ok := kw["amount"].(float64); ok && amt > 0 {
+ partialAmount = amt
+ }
+ }
+ }
+
for _, moveID := range rs.IDs() {
// Read invoice info
var partnerID, journalID, companyID, currencyID int64
- var amountTotal float64
+ var amountTotal, amountResidual float64
var moveType string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id,0), COALESCE(journal_id,0), COALESCE(company_id,0),
- COALESCE(currency_id,0), COALESCE(amount_total,0), COALESCE(move_type,'entry')
+ COALESCE(currency_id,0), COALESCE(amount_total,0), COALESCE(move_type,'entry'),
+ COALESCE(amount_residual,0)
FROM account_move WHERE id = $1`, moveID,
- ).Scan(&partnerID, &journalID, &companyID, ¤cyID, &amountTotal, &moveType)
+ ).Scan(&partnerID, &journalID, &companyID, ¤cyID, &amountTotal, &moveType, &amountResidual)
if err != nil {
return nil, fmt.Errorf("account: read invoice %d for payment: %w", moveID, err)
}
@@ -803,6 +974,15 @@ func initAccountMove() {
partnerType = "supplier"
}
+ // Determine payment amount: partial if specified, else full residual
+ paymentAmount := amountTotal
+ if amountResidual > 0 {
+ paymentAmount = amountResidual
+ }
+ if partialAmount > 0 && partialAmount < paymentAmount {
+ paymentAmount = partialAmount
+ }
+
// Find bank journal
var bankJournalID int64
env.Tx().QueryRow(env.Ctx(),
@@ -812,16 +992,21 @@ func initAccountMove() {
bankJournalID = journalID
}
- // Create a journal entry for the payment
- var payMoveID int64
- err = env.Tx().QueryRow(env.Ctx(),
- `INSERT INTO account_move (name, move_type, state, date, partner_id, journal_id, company_id, currency_id)
- VALUES ($1, 'entry', 'posted', NOW(), $2, $3, $4, $5) RETURNING id`,
- fmt.Sprintf("PAY/%d", moveID), partnerID, bankJournalID, companyID, currencyID,
- ).Scan(&payMoveID)
+ // Create a journal entry for the payment (draft, then post via action_post)
+ payMoveRS := env.Model("account.move")
+ payMove, err := payMoveRS.Create(orm.Values{
+ "name": fmt.Sprintf("PAY/%d", moveID),
+ "move_type": "entry",
+ "date": time.Now().Format("2006-01-02"),
+ "partner_id": partnerID,
+ "journal_id": bankJournalID,
+ "company_id": companyID,
+ "currency_id": currencyID,
+ })
if err != nil {
return nil, fmt.Errorf("account: create payment move for invoice %d: %w", moveID, err)
}
+ payMoveID := payMove.ID()
// Create payment record linked to the journal entry
_, err = env.Tx().Exec(env.Ctx(),
@@ -829,7 +1014,7 @@ func initAccountMove() {
(name, payment_type, partner_type, state, date, amount,
currency_id, journal_id, partner_id, company_id, move_id, is_reconciled)
VALUES ($1, $2, $3, 'paid', NOW(), $4, $5, $6, $7, $8, $9, true)`,
- fmt.Sprintf("PAY/%d", moveID), paymentType, partnerType, amountTotal,
+ fmt.Sprintf("PAY/%d", moveID), paymentType, partnerType, paymentAmount,
currencyID, bankJournalID, partnerID, companyID, payMoveID)
if err != nil {
return nil, fmt.Errorf("account: create payment for invoice %d: %w", moveID, err)
@@ -871,9 +1056,9 @@ func initAccountMove() {
// Bank line (debit for inbound, credit for outbound)
var bankDebit, bankCredit float64
if paymentType == "inbound" {
- bankDebit = amountTotal
+ bankDebit = paymentAmount
} else {
- bankCredit = amountTotal
+ bankCredit = paymentAmount
}
_, err = env.Tx().Exec(env.Ctx(),
`INSERT INTO account_move_line
@@ -891,11 +1076,11 @@ func initAccountMove() {
var cpDebit, cpCredit float64
var cpResidual float64
if paymentType == "inbound" {
- cpCredit = amountTotal
- cpResidual = -amountTotal // Negative residual for credit line
+ cpCredit = paymentAmount
+ cpResidual = -paymentAmount
} else {
- cpDebit = amountTotal
- cpResidual = amountTotal
+ cpDebit = paymentAmount
+ cpResidual = paymentAmount
}
var paymentLineID int64
err = env.Tx().QueryRow(env.Ctx(),
@@ -911,6 +1096,10 @@ func initAccountMove() {
return nil, fmt.Errorf("account: create counterpart line for payment %d: %w", moveID, err)
}
+ // Post the payment move via action_post (validates balance, generates hash)
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_move SET state = 'posted' WHERE id = $1`, payMoveID)
+
// Find the invoice's receivable/payable line and reconcile
var invoiceLineID int64
env.Tx().QueryRow(env.Ctx(),
@@ -918,32 +1107,41 @@ func initAccountMove() {
WHERE move_id = $1 AND display_type = 'payment_term'
ORDER BY id LIMIT 1`, moveID).Scan(&invoiceLineID)
+ // Determine payment state: partial or paid
+ payState := "paid"
+ if paymentAmount < amountResidual-0.005 {
+ payState = "partial"
+ }
+
if invoiceLineID > 0 && paymentLineID > 0 {
lineModel := orm.Registry.Get("account.move.line")
if lineModel != nil {
if reconcileMethod, ok := lineModel.Methods["reconcile"]; ok {
lineRS := env.Model("account.move.line").Browse(invoiceLineID, paymentLineID)
- if _, err := reconcileMethod(lineRS); err != nil {
- // Non-fatal: fall back to direct update
+ if _, rErr := reconcileMethod(lineRS); rErr != nil {
env.Tx().Exec(env.Ctx(),
- `UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID)
+ `UPDATE account_move SET payment_state = $1 WHERE id = $2`, payState, moveID)
}
} else {
env.Tx().Exec(env.Ctx(),
- `UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID)
+ `UPDATE account_move SET payment_state = $1 WHERE id = $2`, payState, moveID)
}
} else {
env.Tx().Exec(env.Ctx(),
- `UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID)
+ `UPDATE account_move SET payment_state = $1 WHERE id = $2`, payState, moveID)
}
} else {
env.Tx().Exec(env.Ctx(),
- `UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID)
+ `UPDATE account_move SET payment_state = $1 WHERE id = $2`, payState, moveID)
}
- } else {
- // Fallback: direct payment state update (no reconciliation possible)
+
+ // Update amount_residual on invoice
env.Tx().Exec(env.Ctx(),
- `UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID)
+ `UPDATE account_move SET amount_residual = GREATEST(COALESCE(amount_residual,0) - $1, 0) WHERE id = $2`,
+ paymentAmount, moveID)
+ } else {
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_move SET payment_state = 'paid', amount_residual = 0 WHERE id = $1`, moveID)
}
}
return true, nil
@@ -984,6 +1182,11 @@ func initAccountMove() {
}
return nil
}
+
+ // -- BeforeWrite Hook: Prevent modifications on posted entries --
+ m.BeforeWrite = orm.StateGuard("account_move", "state = 'posted'",
+ []string{"write_uid", "write_date", "payment_state", "amount_residual"},
+ "cannot modify posted entries β reset to draft first")
}
// initAccountMoveLine registers account.move.line β journal items / invoice lines.
@@ -1086,6 +1289,81 @@ func initAccountMoveLine() {
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
+ // -- Compute: balance = debit - credit --
+ // Mirrors: odoo/addons/account/models/account_move_line.py _compute_balance()
+ m.RegisterCompute("balance", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ lineID := rs.IDs()[0]
+
+ var debit, credit float64
+ var displayType *string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(debit::float8, 0), COALESCE(credit::float8, 0), display_type
+ FROM account_move_line WHERE id = $1`, lineID,
+ ).Scan(&debit, &credit, &displayType)
+
+ // Section/note lines have no balance
+ if displayType != nil && (*displayType == "line_section" || *displayType == "line_note") {
+ return orm.Values{"balance": 0.0}, nil
+ }
+
+ return orm.Values{"balance": debit - credit}, nil
+ })
+
+ // -- Compute: price_subtotal and price_total --
+ // Mirrors: odoo/addons/account/models/account_move_line.py _compute_totals()
+ // price_subtotal = quantity * price_unit * (1 - discount/100)
+ // price_total = price_subtotal + tax amounts
+ computeTotals := func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ lineID := rs.IDs()[0]
+
+ var quantity, priceUnit, discount float64
+ var displayType *string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(quantity, 1), COALESCE(price_unit::float8, 0),
+ COALESCE(discount, 0), display_type
+ FROM account_move_line WHERE id = $1`, lineID,
+ ).Scan(&quantity, &priceUnit, &discount, &displayType)
+
+ // Only product lines have price_subtotal/price_total
+ if displayType != nil && *displayType != "product" && *displayType != "" {
+ return orm.Values{"price_subtotal": 0.0, "price_total": 0.0}, nil
+ }
+
+ subtotal := quantity * priceUnit * (1 - discount/100)
+
+ // Compute tax amount from tax_ids
+ total := subtotal
+ taxRows, err := env.Tx().Query(env.Ctx(),
+ `SELECT t.account_tax_id FROM account_move_line_account_tax_rel t
+ WHERE t.account_move_line_id = $1`, lineID)
+ if err == nil {
+ var taxIDs []int64
+ for taxRows.Next() {
+ var tid int64
+ if taxRows.Scan(&tid) == nil {
+ taxIDs = append(taxIDs, tid)
+ }
+ }
+ taxRows.Close()
+
+ for _, taxID := range taxIDs {
+ taxResult, tErr := ComputeTax(env, taxID, subtotal)
+ if tErr == nil {
+ total += taxResult.Amount
+ }
+ }
+ }
+
+ return orm.Values{
+ "price_subtotal": subtotal,
+ "price_total": total,
+ }, nil
+ }
+ m.RegisterCompute("price_subtotal", computeTotals)
+ m.RegisterCompute("price_total", computeTotals)
+
// -- Reconciliation --
m.AddFields(
orm.Boolean("reconciled", orm.FieldOpts{String: "Reconciled"}),
@@ -1340,11 +1618,92 @@ func initAccountPayment() {
// action_post: confirm and post the payment.
// Mirrors: odoo/addons/account/models/account_payment.py action_post()
+ // Posts the payment AND its linked journal entry (account.move).
m.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
+ var state string
+ var moveID int64
+ err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(state, 'draft'), COALESCE(move_id, 0) FROM account_payment WHERE id = $1`, id,
+ ).Scan(&state, &moveID)
+ if err != nil {
+ return nil, fmt.Errorf("account: read payment %d: %w", id, err)
+ }
+
+ if state != "draft" && state != "in_process" {
+ continue // Already posted or in non-postable state
+ }
+
+ // Post the linked journal entry if it exists and is in draft
+ if moveID > 0 {
+ var moveState string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(state, 'draft') FROM account_move WHERE id = $1`, moveID,
+ ).Scan(&moveState)
+
+ if moveState == "draft" {
+ // Post the move via its registered method
+ moveModel := orm.Registry.Get("account.move")
+ if moveModel != nil {
+ if postMethod, ok := moveModel.Methods["action_post"]; ok {
+ moveRS := env.Model("account.move").Browse(moveID)
+ if _, pErr := postMethod(moveRS); pErr != nil {
+ return nil, fmt.Errorf("account: post payment journal entry: %w", pErr)
+ }
+ }
+ }
+ }
+ }
+
+ // Check if the outstanding account is a cash account β paid directly
+ // Otherwise β in_process (mirrors Python: outstanding_account_id.account_type == 'asset_cash')
+ newState := "in_process"
+ if moveID > 0 {
+ var accountType *string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT a.account_type FROM account_move_line l
+ JOIN account_account a ON a.id = l.account_id
+ WHERE l.move_id = $1 AND a.account_type = 'asset_cash'
+ LIMIT 1`, moveID,
+ ).Scan(&accountType)
+ if accountType != nil && *accountType == "asset_cash" {
+ newState = "paid"
+ }
+ }
+
if _, err := env.Tx().Exec(env.Ctx(),
- `UPDATE account_payment SET state = 'paid' WHERE id = $1 AND state = 'draft'`, id); err != nil {
+ `UPDATE account_payment SET state = $1 WHERE id = $2`, newState, id); err != nil {
+ return nil, err
+ }
+ }
+ return true, nil
+ })
+
+ // action_draft: reset payment to draft.
+ // Mirrors: odoo/addons/account/models/account_payment.py action_draft()
+ // Also resets the linked journal entry to draft.
+ m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, id := range rs.IDs() {
+ var moveID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(move_id, 0) FROM account_payment WHERE id = $1`, id,
+ ).Scan(&moveID)
+
+ // Reset the linked journal entry to draft
+ if moveID > 0 {
+ moveModel := orm.Registry.Get("account.move")
+ if moveModel != nil {
+ if draftMethod, ok := moveModel.Methods["button_draft"]; ok {
+ moveRS := env.Model("account.move").Browse(moveID)
+ draftMethod(moveRS) // best effort
+ }
+ }
+ }
+
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `UPDATE account_payment SET state = 'draft' WHERE id = $1`, id); err != nil {
return nil, err
}
}
@@ -1352,9 +1711,28 @@ func initAccountPayment() {
})
// action_cancel: cancel the payment.
+ // Mirrors: odoo/addons/account/models/account_payment.py action_cancel()
+ // Also cancels the linked journal entry.
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
+ var state string
+ var moveID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(state, 'draft'), COALESCE(move_id, 0) FROM account_payment WHERE id = $1`, id,
+ ).Scan(&state, &moveID)
+
+ // Cancel the linked journal entry
+ if moveID > 0 {
+ moveModel := orm.Registry.Get("account.move")
+ if moveModel != nil {
+ if cancelMethod, ok := moveModel.Methods["button_cancel"]; ok {
+ moveRS := env.Model("account.move").Browse(moveID)
+ cancelMethod(moveRS) // best effort
+ }
+ }
+ }
+
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE account_payment SET state = 'canceled' WHERE id = $1`, id); err != nil {
return nil, err
@@ -1508,7 +1886,7 @@ func initAccountPaymentRegister() {
env.Tx().Exec(env.Ctx(),
`INSERT INTO account_partial_reconcile (debit_move_id, credit_move_id, amount)
VALUES ($1, $2, $3)`,
- invoiceLineID, invoiceLineID, matchAmount)
+ paymentLineID, invoiceLineID, matchAmount)
env.Tx().Exec(env.Ctx(),
`UPDATE account_move_line SET amount_residual = amount_residual - $1 WHERE id = $2`,
matchAmount, invoiceLineID)
@@ -1688,6 +2066,25 @@ func initAccountBankStatement() {
orm.One2many("line_ids", "account.bank.statement.line", "statement_id", orm.FieldOpts{String: "Statement Lines"}),
)
+ // _compute_balance_end: balance_start + sum of line amounts
+ // Mirrors: odoo/addons/account/models/account_bank_statement.py _compute_balance_end()
+ m.RegisterCompute("balance_end", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ stID := rs.IDs()[0]
+
+ var balanceStart float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(balance_start::float8, 0) FROM account_bank_statement WHERE id = $1`, stID,
+ ).Scan(&balanceStart)
+
+ var lineSum float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(amount::float8), 0) FROM account_bank_statement_line WHERE statement_id = $1`, stID,
+ ).Scan(&lineSum)
+
+ return orm.Values{"balance_end": balanceStart + lineSum}, nil
+ })
+
// Bank statement line
stLine := orm.NewModel("account.bank.statement.line", orm.ModelOpts{
Description: "Bank Statement Line",
@@ -1750,6 +2147,47 @@ func initAccountBankStatement() {
env.Tx().Exec(env.Ctx(),
`UPDATE account_bank_statement_line SET move_line_id = $1, is_reconciled = true WHERE id = $2`,
matchLineID, lineID)
+ } else {
+ // No match found β create a journal entry for the statement line
+ var journalID, companyID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(journal_id, 0), COALESCE(company_id, 0)
+ FROM account_bank_statement_line WHERE id = $1`, lineID,
+ ).Scan(&journalID, &companyID)
+
+ if journalID > 0 {
+ // Get journal default + suspense accounts
+ var defaultAccID, suspenseAccID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(default_account_id, 0), COALESCE(suspense_account_id, 0)
+ FROM account_journal WHERE id = $1`, journalID).Scan(&defaultAccID, &suspenseAccID)
+ if suspenseAccID == 0 {
+ suspenseAccID = defaultAccID
+ }
+
+ if defaultAccID > 0 {
+ moveRS := env.Model("account.move")
+ move, mErr := moveRS.Create(orm.Values{
+ "move_type": "entry",
+ "journal_id": journalID,
+ "company_id": companyID,
+ "date": time.Now().Format("2006-01-02"),
+ })
+ if mErr == nil {
+ mvID := move.ID()
+ lineRS := env.Model("account.move.line")
+ if amount > 0 {
+ lineRS.Create(orm.Values{"move_id": mvID, "account_id": defaultAccID, "debit": amount, "credit": 0.0, "balance": amount, "company_id": companyID, "journal_id": journalID, "display_type": "product", "name": "Bank Statement"})
+ lineRS.Create(orm.Values{"move_id": mvID, "account_id": suspenseAccID, "debit": 0.0, "credit": amount, "balance": -amount, "company_id": companyID, "journal_id": journalID, "display_type": "product", "name": "Suspense"})
+ } else {
+ lineRS.Create(orm.Values{"move_id": mvID, "account_id": suspenseAccID, "debit": -amount, "credit": 0.0, "balance": -amount, "company_id": companyID, "journal_id": journalID, "display_type": "product", "name": "Suspense"})
+ lineRS.Create(orm.Values{"move_id": mvID, "account_id": defaultAccID, "debit": 0.0, "credit": -amount, "balance": amount, "company_id": companyID, "journal_id": journalID, "display_type": "product", "name": "Bank Statement"})
+ }
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_bank_statement_line SET is_reconciled = true WHERE id = $1`, lineID)
+ }
+ }
+ }
}
}
return true, nil
@@ -1843,3 +2281,972 @@ func updatePaymentState(env *orm.Environment, moveID int64) {
env.Tx().Exec(env.Ctx(),
`UPDATE account_move SET payment_state = $1 WHERE id = $2`, state, moveID)
}
+
+// ---------------------------------------------------------------------------
+// Extensions: Invoice workflow, amounts, payment matching
+// Mirrors: odoo/addons/account/models/account_move.py (various methods)
+// ---------------------------------------------------------------------------
+
+// initAccountMoveInvoiceExtensions adds invoice_sent, tax_totals,
+// amount_residual_signed fields and several workflow / payment-matching
+// methods to account.move.
+func initAccountMoveInvoiceExtensions() {
+ ext := orm.ExtendModel("account.move")
+
+ // -- Additional fields --
+ ext.AddFields(
+ orm.Boolean("invoice_sent", orm.FieldOpts{
+ String: "Invoice Sent",
+ Help: "Set to true when the invoice has been sent to the partner",
+ }),
+ orm.Text("tax_totals", orm.FieldOpts{
+ String: "Tax Totals JSON",
+ Compute: "_compute_tax_totals",
+ Help: "Structured tax breakdown data for the tax summary widget (JSON)",
+ }),
+ orm.Monetary("amount_residual_signed", orm.FieldOpts{
+ String: "Amount Due (Signed)",
+ Compute: "_compute_amount_residual_signed",
+ Store: true,
+ CurrencyField: "company_currency_id",
+ Help: "Residual amount with sign based on move type, for reporting",
+ }),
+ )
+
+ // _compute_tax_totals: compute structured tax breakdown grouped by tax group.
+ // Mirrors: odoo/addons/account/models/account_move.py _compute_tax_totals()
+ // Produces a JSON string with tax groups and their base/tax amounts for the
+ // frontend tax summary widget.
+ ext.RegisterCompute("tax_totals", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ moveID := rs.IDs()[0]
+
+ var moveType string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(move_type, 'entry') FROM account_move WHERE id = $1`, moveID,
+ ).Scan(&moveType)
+
+ // Only invoices/receipts get tax_totals
+ if moveType == "entry" {
+ return orm.Values{"tax_totals": ""}, nil
+ }
+
+ // Read tax lines grouped by tax group
+ rows, err := env.Tx().Query(env.Ctx(),
+ `SELECT COALESCE(tg.name, 'Taxes'), COALESCE(tg.id, 0),
+ COALESCE(SUM(ABS(l.balance::float8)), 0) AS tax_amount
+ FROM account_move_line l
+ LEFT JOIN account_tax t ON t.id = l.tax_line_id
+ LEFT JOIN account_tax_group tg ON tg.id = t.tax_group_id
+ WHERE l.move_id = $1 AND l.display_type = 'tax'
+ GROUP BY tg.id, tg.name
+ ORDER BY tg.id`, moveID)
+ if err != nil {
+ return orm.Values{"tax_totals": ""}, nil
+ }
+ defer rows.Close()
+
+ type taxGroupEntry struct {
+ Name string
+ GroupID int64
+ TaxAmount float64
+ }
+ var groups []taxGroupEntry
+ var totalTax float64
+ for rows.Next() {
+ var g taxGroupEntry
+ if err := rows.Scan(&g.Name, &g.GroupID, &g.TaxAmount); err != nil {
+ continue
+ }
+ groups = append(groups, g)
+ totalTax += g.TaxAmount
+ }
+
+ // Read base amounts (product lines)
+ var amountUntaxed float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(ABS(balance::float8)), 0)
+ FROM account_move_line WHERE move_id = $1
+ AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
+ moveID).Scan(&amountUntaxed)
+
+ // Build JSON manually (avoids encoding/json import)
+ result := fmt.Sprintf(
+ `{"amount_untaxed":%.2f,"amount_total":%.2f,"groups_by_subtotal":{"Untaxed Amount":[`,
+ amountUntaxed, amountUntaxed+totalTax)
+ for i, g := range groups {
+ if i > 0 {
+ result += ","
+ }
+ result += fmt.Sprintf(
+ `{"tax_group_name":"%s","tax_group_id":%d,"tax_group_amount":%.2f,"tax_group_base_amount":%.2f}`,
+ g.Name, g.GroupID, g.TaxAmount, amountUntaxed)
+ }
+ result += fmt.Sprintf(`]},"has_tax_groups":%t}`, len(groups) > 0)
+
+ return orm.Values{"tax_totals": result}, nil
+ })
+
+ // _compute_amount_residual_signed: amount_residual with sign based on move type.
+ // Mirrors: odoo/addons/account/models/account_move.py amount_residual_signed
+ // Positive for receivables (customer invoices), negative for payables (vendor bills).
+ ext.RegisterCompute("amount_residual_signed", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ moveID := rs.IDs()[0]
+
+ var residual float64
+ var moveType string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(amount_residual::float8, 0), COALESCE(move_type, 'entry')
+ FROM account_move WHERE id = $1`, moveID,
+ ).Scan(&residual, &moveType)
+
+ sign := 1.0
+ switch moveType {
+ case "in_invoice", "in_receipt":
+ sign = -1.0
+ case "out_refund":
+ sign = -1.0
+ }
+
+ return orm.Values{"amount_residual_signed": residual * sign}, nil
+ })
+
+ // action_invoice_sent: mark invoice as sent and return email compose wizard action.
+ // Mirrors: odoo/addons/account/models/account_move.py action_invoice_sent()
+ ext.RegisterMethod("action_invoice_sent", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ moveID := rs.IDs()[0]
+
+ // Mark the invoice as sent
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_move SET invoice_sent = true WHERE id = $1`, moveID)
+
+ return map[string]interface{}{
+ "type": "ir.actions.act_window",
+ "name": "Send Invoice",
+ "res_model": "account.invoice.send",
+ "view_mode": "form",
+ "views": [][]interface{}{{nil, "form"}},
+ "target": "new",
+ "context": map[string]interface{}{
+ "default_invoice_ids": []int64{moveID},
+ "active_ids": []int64{moveID},
+ },
+ }, nil
+ })
+
+ // action_switch_move_type: stub returning action to switch between invoice/bill types.
+ // Mirrors: odoo/addons/account/models/account_move.py action_switch_move_type()
+ // In Python Odoo this redirects to the same form with a different default move_type.
+ ext.RegisterMethod("action_switch_move_type", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ moveID := rs.IDs()[0]
+
+ var moveType, state string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(move_type, 'entry'), COALESCE(state, 'draft')
+ FROM account_move WHERE id = $1`, moveID,
+ ).Scan(&moveType, &state)
+
+ if state != "draft" {
+ return nil, fmt.Errorf("account: can only switch move type on draft entries")
+ }
+
+ // Determine the opposite type
+ newType := moveType
+ switch moveType {
+ case "out_invoice":
+ newType = "in_invoice"
+ case "in_invoice":
+ newType = "out_invoice"
+ case "out_refund":
+ newType = "in_refund"
+ case "in_refund":
+ newType = "out_refund"
+ case "out_receipt":
+ newType = "in_receipt"
+ case "in_receipt":
+ newType = "out_receipt"
+ }
+
+ return map[string]interface{}{
+ "type": "ir.actions.act_window",
+ "res_model": "account.move",
+ "res_id": moveID,
+ "view_mode": "form",
+ "views": [][]interface{}{{nil, "form"}},
+ "target": "current",
+ "context": map[string]interface{}{
+ "default_move_type": newType,
+ },
+ }, nil
+ })
+
+ // js_assign_outstanding_line: reconcile an outstanding payment line with this invoice.
+ // Called by the payment widget on the invoice form.
+ // Mirrors: odoo/addons/account/models/account_move.py js_assign_outstanding_line()
+ ext.RegisterMethod("js_assign_outstanding_line", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ if len(args) < 1 {
+ return nil, fmt.Errorf("account: js_assign_outstanding_line requires a line_id argument")
+ }
+ env := rs.Env()
+ moveID := rs.IDs()[0]
+
+ lineID, ok := toInt64Arg(args[0])
+ if !ok || lineID == 0 {
+ return nil, fmt.Errorf("account: invalid line_id for js_assign_outstanding_line")
+ }
+
+ // Find the outstanding line's account to match against
+ var outstandingAccountID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(account_id, 0) FROM account_move_line WHERE id = $1`, lineID,
+ ).Scan(&outstandingAccountID)
+
+ if outstandingAccountID == 0 {
+ return nil, fmt.Errorf("account: outstanding line %d has no account", lineID)
+ }
+
+ // Find unreconciled invoice lines on the same account
+ var invoiceLineID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT id FROM account_move_line
+ WHERE move_id = $1 AND account_id = $2 AND COALESCE(reconciled, false) = false
+ ORDER BY id LIMIT 1`, moveID, outstandingAccountID,
+ ).Scan(&invoiceLineID)
+
+ if invoiceLineID == 0 {
+ return nil, fmt.Errorf("account: no unreconciled line on account %d for move %d", outstandingAccountID, moveID)
+ }
+
+ // Reconcile the two lines via the ORM method
+ lineModel := orm.Registry.Get("account.move.line")
+ if lineModel != nil {
+ if reconcileMethod, mOk := lineModel.Methods["reconcile"]; mOk {
+ lineRS := env.Model("account.move.line").Browse(invoiceLineID, lineID)
+ return reconcileMethod(lineRS)
+ }
+ }
+
+ return nil, fmt.Errorf("account: reconcile method not available")
+ })
+
+ // js_remove_outstanding_partial: remove a partial reconciliation from this invoice.
+ // Called by the payment widget to undo a reconciliation.
+ // Mirrors: odoo/addons/account/models/account_move.py js_remove_outstanding_partial()
+ ext.RegisterMethod("js_remove_outstanding_partial", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ if len(args) < 1 {
+ return nil, fmt.Errorf("account: js_remove_outstanding_partial requires a partial_id argument")
+ }
+ env := rs.Env()
+
+ partialID, ok := toInt64Arg(args[0])
+ if !ok || partialID == 0 {
+ return nil, fmt.Errorf("account: invalid partial_id for js_remove_outstanding_partial")
+ }
+
+ // Read the partial reconcile to get linked lines
+ var debitLineID, creditLineID int64
+ err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(debit_move_id, 0), COALESCE(credit_move_id, 0)
+ FROM account_partial_reconcile WHERE id = $1`, partialID,
+ ).Scan(&debitLineID, &creditLineID)
+ if err != nil {
+ return nil, fmt.Errorf("account: read partial reconcile %d: %w", partialID, err)
+ }
+
+ // Read match amount to restore residuals
+ var matchAmount float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(amount::float8, 0) FROM account_partial_reconcile WHERE id = $1`, partialID,
+ ).Scan(&matchAmount)
+
+ // Delete the partial reconcile
+ env.Tx().Exec(env.Ctx(),
+ `DELETE FROM account_partial_reconcile WHERE id = $1`, partialID)
+
+ // Restore residual amounts on the affected lines
+ if debitLineID > 0 {
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_move_line SET amount_residual = amount_residual + $1,
+ reconciled = false, full_reconcile_id = NULL WHERE id = $2`,
+ matchAmount, debitLineID)
+ }
+ if creditLineID > 0 {
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_move_line SET amount_residual = amount_residual - $1,
+ reconciled = false, full_reconcile_id = NULL WHERE id = $2`,
+ matchAmount, creditLineID)
+ }
+
+ // Clean up orphaned full reconciles
+ env.Tx().Exec(env.Ctx(),
+ `DELETE FROM account_full_reconcile WHERE id NOT IN
+ (SELECT DISTINCT full_reconcile_id FROM account_partial_reconcile WHERE full_reconcile_id IS NOT NULL)`)
+
+ // Update payment state on linked moves
+ for _, lid := range []int64{debitLineID, creditLineID} {
+ if lid > 0 {
+ var moveID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(move_id, 0) FROM account_move_line WHERE id = $1`, lid,
+ ).Scan(&moveID)
+ if moveID > 0 {
+ updatePaymentState(env, moveID)
+ }
+ }
+ }
+
+ return true, nil
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Extensions: account.payment β destination/outstanding accounts, improved post
+// Mirrors: odoo/addons/account/models/account_payment.py
+// ---------------------------------------------------------------------------
+
+// initAccountPaymentExtensions adds outstanding_account_id field and compute
+// methods for destination_account_id and outstanding_account_id on account.payment.
+func initAccountPaymentExtensions() {
+ ext := orm.ExtendModel("account.payment")
+
+ ext.AddFields(
+ orm.Many2one("outstanding_account_id", "account.account", orm.FieldOpts{
+ String: "Outstanding Account",
+ Compute: "_compute_outstanding_account_id",
+ Store: true,
+ Help: "The outstanding receipts/payments account used for this payment",
+ }),
+ )
+
+ // _compute_outstanding_account_id: determine the outstanding account from the
+ // payment method line's configured payment_account_id.
+ // Mirrors: odoo/addons/account/models/account_payment.py _compute_outstanding_account_id()
+ ext.RegisterCompute("outstanding_account_id", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ paymentID := rs.IDs()[0]
+
+ // Try to get from payment_method_line β payment_account_id
+ var accountID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(pml.payment_account_id, 0)
+ FROM account_payment p
+ LEFT JOIN account_payment_method_line pml ON pml.id = (
+ SELECT pml2.id FROM account_payment_method_line pml2
+ WHERE pml2.journal_id = p.journal_id
+ AND pml2.code = COALESCE(p.payment_method_code, 'manual')
+ LIMIT 1
+ )
+ WHERE p.id = $1`, paymentID,
+ ).Scan(&accountID)
+
+ // Fallback: use journal's default account
+ if accountID == 0 {
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(j.default_account_id, 0)
+ FROM account_payment p
+ JOIN account_journal j ON j.id = p.journal_id
+ WHERE p.id = $1`, paymentID,
+ ).Scan(&accountID)
+ }
+
+ return orm.Values{"outstanding_account_id": accountID}, nil
+ })
+
+ // _compute_destination_account_id: determine the destination account based on
+ // payment type (customer β receivable, supplier β payable).
+ // Mirrors: odoo/addons/account/models/account_payment.py _compute_destination_account_id()
+ ext.RegisterCompute("destination_account_id", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ paymentID := rs.IDs()[0]
+
+ var partnerType string
+ var partnerID, companyID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(partner_type, 'customer'), COALESCE(partner_id, 0), COALESCE(company_id, 0)
+ FROM account_payment WHERE id = $1`, paymentID,
+ ).Scan(&partnerType, &partnerID, &companyID)
+
+ var accountID int64
+
+ if partnerType == "customer" {
+ // Look for partner's property_account_receivable_id
+ if partnerID > 0 {
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(property_account_receivable_id, 0) FROM res_partner WHERE id = $1`, partnerID,
+ ).Scan(&accountID)
+ }
+ // Fallback to first receivable account for the company
+ if accountID == 0 {
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT id FROM account_account
+ WHERE account_type = 'asset_receivable' AND company_id = $1
+ ORDER BY code LIMIT 1`, companyID,
+ ).Scan(&accountID)
+ }
+ } else if partnerType == "supplier" {
+ // Look for partner's property_account_payable_id
+ if partnerID > 0 {
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(property_account_payable_id, 0) FROM res_partner WHERE id = $1`, partnerID,
+ ).Scan(&accountID)
+ }
+ // Fallback to first payable account for the company
+ if accountID == 0 {
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT id FROM account_account
+ WHERE account_type = 'liability_payable' AND company_id = $1
+ ORDER BY code LIMIT 1`, companyID,
+ ).Scan(&accountID)
+ }
+ }
+
+ return orm.Values{"destination_account_id": accountID}, nil
+ })
+
+ // Improve action_post: validate amount > 0 and generate payment name/sequence.
+ // Mirrors: odoo/addons/account/models/account_payment.py action_post() validation
+ ext.RegisterMethod("action_post_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, id := range rs.IDs() {
+ // Validate amount > 0
+ var amount float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(amount::float8, 0) FROM account_payment WHERE id = $1`, id,
+ ).Scan(&amount)
+ if amount <= 0 {
+ return nil, fmt.Errorf("account: payment amount must be strictly positive (got %.2f)", amount)
+ }
+
+ // Generate payment name/sequence if not set
+ var name *string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT name FROM account_payment WHERE id = $1`, id,
+ ).Scan(&name)
+
+ if name == nil || *name == "" || *name == "/" {
+ var journalCode string
+ var companyID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(j.code, 'BNK'), COALESCE(p.company_id, 0)
+ FROM account_payment p
+ LEFT JOIN account_journal j ON j.id = p.journal_id
+ WHERE p.id = $1`, id,
+ ).Scan(&journalCode, &companyID)
+
+ var nextNum int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(MAX(CAST(
+ CASE WHEN name ~ '[0-9]+$'
+ THEN regexp_replace(name, '.*/', '')
+ ELSE '0' END AS INTEGER)), 0) + 1
+ FROM account_payment
+ WHERE journal_id = (SELECT journal_id FROM account_payment WHERE id = $1)`, id,
+ ).Scan(&nextNum)
+
+ year := time.Now().Format("2006")
+ newName := fmt.Sprintf("%s/%s/%04d", journalCode, year, nextNum)
+
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_payment SET name = $1 WHERE id = $2`, newName, id)
+ }
+ }
+ return true, nil
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Extensions: account.journal β current statement balance, last statement
+// Mirrors: odoo/addons/account/models/account_journal_dashboard.py
+// ---------------------------------------------------------------------------
+
+// initAccountJournalExtensions adds bank statement related computed fields
+// to account.journal.
+func initAccountJournalExtensions() {
+ ext := orm.ExtendModel("account.journal")
+
+ ext.AddFields(
+ orm.Monetary("current_statement_balance", orm.FieldOpts{
+ String: "Current Statement Balance",
+ Compute: "_compute_current_statement",
+ Help: "Current running balance for bank/cash journals",
+ }),
+ orm.Boolean("has_statement_lines", orm.FieldOpts{
+ String: "Has Statement Lines",
+ Compute: "_compute_current_statement",
+ }),
+ orm.Many2one("last_statement_id", "account.bank.statement", orm.FieldOpts{
+ String: "Last Statement",
+ Compute: "_compute_current_statement",
+ Help: "Last bank statement for this journal",
+ }),
+ )
+
+ // _compute_current_statement: get current bank statement balance and last statement.
+ // Mirrors: odoo/addons/account/models/account_journal_dashboard.py
+ // _compute_current_statement_balance() + _compute_last_bank_statement()
+ computeStatement := func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ journalID := rs.IDs()[0]
+
+ // Check if this is a bank/cash journal
+ var journalType string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(type, '') FROM account_journal WHERE id = $1`, journalID,
+ ).Scan(&journalType)
+
+ if journalType != "bank" && journalType != "cash" {
+ return orm.Values{
+ "current_statement_balance": 0.0,
+ "has_statement_lines": false,
+ "last_statement_id": int64(0),
+ }, nil
+ }
+
+ // Running balance = sum of all posted move lines on the journal's default account
+ var balance float64
+ var hasLines bool
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(l.balance::float8), 0), COUNT(*) > 0
+ FROM account_bank_statement_line sl
+ JOIN account_move m ON m.id = sl.move_id AND m.state = 'posted'
+ JOIN account_move_line l ON l.move_id = m.id
+ JOIN account_journal j ON j.id = sl.journal_id
+ JOIN account_account a ON a.id = l.account_id AND a.id = j.default_account_id
+ WHERE sl.journal_id = $1`, journalID,
+ ).Scan(&balance, &hasLines)
+
+ // Last statement
+ var lastStatementID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(id, 0) FROM account_bank_statement
+ WHERE journal_id = $1
+ ORDER BY date DESC, id DESC LIMIT 1`, journalID,
+ ).Scan(&lastStatementID)
+
+ return orm.Values{
+ "current_statement_balance": balance,
+ "has_statement_lines": hasLines,
+ "last_statement_id": lastStatementID,
+ }, nil
+ }
+
+ ext.RegisterCompute("current_statement_balance", computeStatement)
+ ext.RegisterCompute("has_statement_lines", computeStatement)
+ ext.RegisterCompute("last_statement_id", computeStatement)
+}
+
+// ---------------------------------------------------------------------------
+// Invoice Refund / Reversal Wizard
+// Mirrors: odoo/addons/account/wizard/account_move_reversal.py
+// ---------------------------------------------------------------------------
+
+// initAccountMoveReversal registers a transient model for creating
+// credit notes (refunds) or full reversals of posted journal entries.
+func initAccountMoveReversal() {
+ m := orm.NewModel("account.move.reversal", orm.ModelOpts{
+ Description: "Account Move Reversal",
+ Type: orm.ModelTransient,
+ })
+
+ m.AddFields(
+ orm.Many2many("move_ids", "account.move", orm.FieldOpts{
+ String: "Journal Entries",
+ Relation: "account_move_reversal_move_rel",
+ Column1: "reversal_id",
+ Column2: "move_id",
+ }),
+ orm.Char("reason", orm.FieldOpts{String: "Reason"}),
+ orm.Date("date", orm.FieldOpts{String: "Reversal Date", Required: true}),
+ orm.Selection("refund_method", []orm.SelectionItem{
+ {Value: "refund", Label: "Partial Refund"},
+ {Value: "cancel", Label: "Full Refund"},
+ {Value: "modify", Label: "Full Refund and New Draft Invoice"},
+ }, orm.FieldOpts{String: "Credit Method", Default: "refund", Required: true}),
+ )
+
+ // reverse_moves creates reversed journal entries for each selected move.
+ // Mirrors: odoo/addons/account/wizard/account_move_reversal.py reverse_moves()
+ m.RegisterMethod("reverse_moves", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ data, err := rs.Read([]string{"reason", "date", "refund_method"})
+ if err != nil || len(data) == 0 {
+ return nil, fmt.Errorf("account: cannot read reversal wizard data")
+ }
+ wiz := data[0]
+
+ reason, _ := wiz["reason"].(string)
+ reversalDate, _ := wiz["date"].(string)
+ if reversalDate == "" {
+ reversalDate = time.Now().Format("2006-01-02")
+ }
+ refundMethod, _ := wiz["refund_method"].(string)
+
+ // Get move IDs from context or from M2M field
+ var moveIDs []int64
+ if ctx := env.Context(); ctx != nil {
+ if ids, ok := ctx["active_ids"].([]interface{}); ok {
+ for _, raw := range ids {
+ if id, ok := toInt64Arg(raw); ok && id > 0 {
+ moveIDs = append(moveIDs, id)
+ }
+ }
+ }
+ }
+ if len(moveIDs) == 0 {
+ // Try reading from the wizard's move_ids M2M
+ rows, qerr := env.Tx().Query(env.Ctx(),
+ `SELECT move_id FROM account_move_reversal_move_rel WHERE reversal_id = $1`,
+ rs.IDs()[0])
+ if qerr == nil {
+ defer rows.Close()
+ for rows.Next() {
+ var id int64
+ rows.Scan(&id)
+ moveIDs = append(moveIDs, id)
+ }
+ }
+ }
+ if len(moveIDs) == 0 {
+ return nil, fmt.Errorf("account: no moves to reverse")
+ }
+
+ moveRS := env.Model("account.move")
+ lineRS := env.Model("account.move.line")
+ var reversalIDs []int64
+
+ for _, moveID := range moveIDs {
+ // Read original move header
+ var journalID, companyID int64
+ var curID *int64
+ var moveState string
+ err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(journal_id, 0), COALESCE(company_id, 0),
+ currency_id, COALESCE(state, 'draft')
+ FROM account_move WHERE id = $1`, moveID,
+ ).Scan(&journalID, &companyID, &curID, &moveState)
+ if err != nil {
+ return nil, fmt.Errorf("account: read move %d: %w", moveID, err)
+ }
+ if moveState != "posted" {
+ continue // skip non-posted moves
+ }
+
+ var currencyID int64
+ if curID != nil {
+ currencyID = *curID
+ }
+
+ ref := fmt.Sprintf("Reversal of move %d", moveID)
+ if reason != "" {
+ ref = fmt.Sprintf("%s: %s", ref, reason)
+ }
+
+ // Create reversed move
+ revMove, err := moveRS.Create(orm.Values{
+ "move_type": "entry",
+ "ref": ref,
+ "date": reversalDate,
+ "journal_id": journalID,
+ "company_id": companyID,
+ "currency_id": currencyID,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("account: create reversal move: %w", err)
+ }
+ reversalIDs = append(reversalIDs, revMove.ID())
+
+ // Read original lines and create reversed copies (swap debit/credit)
+ origLines, err := env.Tx().Query(env.Ctx(),
+ `SELECT account_id, name, debit, credit, balance,
+ COALESCE(partner_id, 0), display_type,
+ COALESCE(tax_base_amount, 0), COALESCE(amount_currency, 0)
+ FROM account_move_line WHERE move_id = $1`, moveID)
+ if err != nil {
+ return nil, fmt.Errorf("account: read original lines: %w", err)
+ }
+
+ type lineData struct {
+ accountID int64
+ name string
+ debit, credit float64
+ balance float64
+ partnerID int64
+ displayType string
+ taxBase float64
+ amountCur float64
+ }
+ var lines []lineData
+ for origLines.Next() {
+ var ld lineData
+ origLines.Scan(&ld.accountID, &ld.name, &ld.debit, &ld.credit,
+ &ld.balance, &ld.partnerID, &ld.displayType, &ld.taxBase, &ld.amountCur)
+ lines = append(lines, ld)
+ }
+ origLines.Close()
+
+ for _, ld := range lines {
+ vals := orm.Values{
+ "move_id": revMove.ID(),
+ "account_id": ld.accountID,
+ "name": ld.name,
+ "debit": ld.credit, // swapped
+ "credit": ld.debit, // swapped
+ "balance": -ld.balance, // negated
+ "company_id": companyID,
+ "journal_id": journalID,
+ "currency_id": currencyID,
+ "display_type": ld.displayType,
+ "tax_base_amount": -ld.taxBase,
+ "amount_currency": -ld.amountCur,
+ }
+ if ld.partnerID > 0 {
+ vals["partner_id"] = ld.partnerID
+ }
+ if _, err := lineRS.Create(vals); err != nil {
+ return nil, fmt.Errorf("account: create reversal line: %w", err)
+ }
+ }
+
+ // For "cancel" method: auto-post the reversal and reconcile
+ if refundMethod == "cancel" || refundMethod == "modify" {
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_move SET state = 'posted' WHERE id = $1`, revMove.ID())
+
+ // Mark original as reversed / payment_state reconciled
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE account_move SET payment_state = 'reversed' WHERE id = $1`, moveID)
+
+ // Create partial reconcile entries between matching receivable/payable lines
+ origRecLines, _ := env.Tx().Query(env.Ctx(),
+ `SELECT id, account_id, COALESCE(ABS(balance::float8), 0)
+ FROM account_move_line
+ WHERE move_id = $1 AND display_type = 'payment_term'`, moveID)
+ if origRecLines != nil {
+ var recPairs []struct {
+ origLineID int64
+ accountID int64
+ amount float64
+ }
+ for origRecLines.Next() {
+ var olID, aID int64
+ var amt float64
+ origRecLines.Scan(&olID, &aID, &amt)
+ recPairs = append(recPairs, struct {
+ origLineID int64
+ accountID int64
+ amount float64
+ }{olID, aID, amt})
+ }
+ origRecLines.Close()
+
+ for _, pair := range recPairs {
+ var revLineID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT id FROM account_move_line
+ WHERE move_id = $1 AND account_id = $2
+ ORDER BY id LIMIT 1`, revMove.ID(), pair.accountID,
+ ).Scan(&revLineID)
+
+ if revLineID > 0 {
+ reconcileRS := env.Model("account.partial.reconcile")
+ reconcileRS.Create(orm.Values{
+ "debit_move_id": revLineID,
+ "credit_move_id": pair.origLineID,
+ "amount": pair.amount,
+ "company_id": companyID,
+ })
+ }
+ }
+ }
+ }
+ }
+
+ if len(reversalIDs) == 1 {
+ return map[string]interface{}{
+ "type": "ir.actions.act_window",
+ "res_model": "account.move",
+ "res_id": reversalIDs[0],
+ "view_mode": "form",
+ "views": [][]interface{}{{nil, "form"}},
+ "target": "current",
+ }, nil
+ }
+ return map[string]interface{}{
+ "type": "ir.actions.act_window",
+ "res_model": "account.move",
+ "view_mode": "list,form",
+ "domain": fmt.Sprintf("[['id', 'in', %v]]", reversalIDs),
+ "target": "current",
+ }, nil
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Move Templates
+// Mirrors: odoo/addons/account/models/account_move_template.py
+// ---------------------------------------------------------------------------
+
+// initAccountMoveTemplate registers account.move.template and
+// account.move.template.line β reusable journal entry templates.
+func initAccountMoveTemplate() {
+ // -- Template header --
+ tmpl := orm.NewModel("account.move.template", orm.ModelOpts{
+ Description: "Journal Entry Template",
+ Order: "name",
+ })
+ tmpl.AddFields(
+ orm.Char("name", orm.FieldOpts{String: "Template Name", Required: true}),
+ orm.Many2one("journal_id", "account.journal", orm.FieldOpts{
+ String: "Journal", Required: true,
+ }),
+ orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
+ orm.One2many("line_ids", "account.move.template.line", "template_id", orm.FieldOpts{
+ String: "Template Lines",
+ }),
+ )
+
+ // action_create_move: create an account.move from this template.
+ // Mirrors: odoo/addons/account/models/account_move_template.py action_create_move()
+ //
+ // For "percentage" lines the caller must supply a total amount via
+ // args[0] (float64). For "fixed" lines the amount is taken as-is.
+ tmpl.RegisterMethod("action_create_move", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ templateID := rs.IDs()[0]
+
+ // Read template header
+ var name string
+ var journalID, companyID int64
+ err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(name, ''), COALESCE(journal_id, 0), COALESCE(company_id, 0)
+ FROM account_move_template WHERE id = $1`, templateID,
+ ).Scan(&name, &journalID, &companyID)
+ if err != nil {
+ return nil, fmt.Errorf("account: read template %d: %w", templateID, err)
+ }
+ if journalID == 0 {
+ return nil, fmt.Errorf("account: template %d has no journal", templateID)
+ }
+
+ // Optional total amount for percentage lines
+ var totalAmount float64
+ if len(args) > 0 {
+ if v, ok := toFloat(args[0]); ok {
+ totalAmount = v
+ }
+ }
+
+ // Read template lines
+ rows, err := env.Tx().Query(env.Ctx(),
+ `SELECT id, COALESCE(name, ''), COALESCE(account_id, 0),
+ COALESCE(amount_type, 'fixed'), COALESCE(amount::float8, 0)
+ FROM account_move_template_line
+ WHERE template_id = $1
+ ORDER BY id`, templateID)
+ if err != nil {
+ return nil, fmt.Errorf("account: read template lines: %w", err)
+ }
+
+ type tplLine struct {
+ name string
+ accountID int64
+ amountType string
+ amount float64
+ }
+ var tplLines []tplLine
+ for rows.Next() {
+ var tl tplLine
+ var lineID int64
+ rows.Scan(&lineID, &tl.name, &tl.accountID, &tl.amountType, &tl.amount)
+ tplLines = append(tplLines, tl)
+ }
+ rows.Close()
+
+ if len(tplLines) == 0 {
+ return nil, fmt.Errorf("account: template %d has no lines", templateID)
+ }
+
+ // Resolve currency from company
+ var currencyID int64
+ if companyID > 0 {
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(currency_id, 0) FROM res_company WHERE id = $1`, companyID,
+ ).Scan(¤cyID)
+ }
+
+ // Create the move
+ moveRS := env.Model("account.move")
+ move, err := moveRS.Create(orm.Values{
+ "move_type": "entry",
+ "ref": fmt.Sprintf("From template: %s", name),
+ "date": time.Now().Format("2006-01-02"),
+ "journal_id": journalID,
+ "company_id": companyID,
+ "currency_id": currencyID,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("account: create move from template: %w", err)
+ }
+
+ lineRS := env.Model("account.move.line")
+ for _, tl := range tplLines {
+ amount := tl.amount
+ if tl.amountType == "percentage" && totalAmount != 0 {
+ amount = totalAmount * tl.amount / 100.0
+ }
+
+ var debit, credit float64
+ if amount >= 0 {
+ debit = amount
+ } else {
+ credit = -amount
+ }
+
+ if _, err := lineRS.Create(orm.Values{
+ "move_id": move.ID(),
+ "account_id": tl.accountID,
+ "name": tl.name,
+ "debit": debit,
+ "credit": credit,
+ "balance": amount,
+ "company_id": companyID,
+ "journal_id": journalID,
+ "currency_id": currencyID,
+ "display_type": "product",
+ }); err != nil {
+ return nil, fmt.Errorf("account: create template 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
+ })
+
+ // -- Template lines --
+ line := orm.NewModel("account.move.template.line", orm.ModelOpts{
+ Description: "Journal Entry Template Line",
+ })
+ line.AddFields(
+ orm.Many2one("template_id", "account.move.template", orm.FieldOpts{
+ String: "Template", Required: true, OnDelete: orm.OnDeleteCascade,
+ }),
+ orm.Char("name", orm.FieldOpts{String: "Label"}),
+ orm.Many2one("account_id", "account.account", orm.FieldOpts{
+ String: "Account", Required: true,
+ }),
+ orm.Selection("amount_type", []orm.SelectionItem{
+ {Value: "fixed", Label: "Fixed Amount"},
+ {Value: "percentage", Label: "Percentage of Total"},
+ }, orm.FieldOpts{String: "Amount Type", Default: "fixed", Required: true}),
+ orm.Float("amount", orm.FieldOpts{String: "Amount"}),
+ )
+}
diff --git a/addons/account/models/account_reconcile_model.go b/addons/account/models/account_reconcile_model.go
index 0880df4..c35aec0 100644
--- a/addons/account/models/account_reconcile_model.go
+++ b/addons/account/models/account_reconcile_model.go
@@ -1,6 +1,7 @@
package models
import (
+ "encoding/json"
"fmt"
"strings"
@@ -296,3 +297,73 @@ func applyWriteoffSuggestion(env *orm.Environment, modelID, stLineID int64, amou
"suggestions": suggestions,
}, 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
+ })
+}
diff --git a/addons/account/models/account_recurring.go b/addons/account/models/account_recurring.go
index abfaf9f..bffe2e2 100644
--- a/addons/account/models/account_recurring.go
+++ b/addons/account/models/account_recurring.go
@@ -1,6 +1,10 @@
package models
-import "odoo-go/pkg/orm"
+import (
+ "log"
+
+ "odoo-go/pkg/orm"
+)
// initAccountRecurring registers account.move.recurring β recurring entry templates.
// Mirrors: odoo/addons/account/models/account_move.py (recurring entries feature)
@@ -113,4 +117,79 @@ func initAccountRecurring() {
}
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
+ })
}
diff --git a/addons/account/models/account_reports.go b/addons/account/models/account_reports.go
index 7711f1a..d022672 100644
--- a/addons/account/models/account_reports.go
+++ b/addons/account/models/account_reports.go
@@ -56,6 +56,8 @@ func initAccountTaxReport() {
return generateAgedReport(env, "liability_payable")
case "general_ledger":
return generateGeneralLedger(env)
+ case "tax_report":
+ return generateTaxReport(env)
default:
return map[string]interface{}{"lines": []interface{}{}}, nil
}
@@ -81,20 +83,52 @@ func initAccountReportLine() {
// -- 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.
// Mirrors: odoo/addons/account_reports/models/account_trial_balance_report.py
-func generateTrialBalance(env *orm.Environment) (interface{}, error) {
- rows, err := env.Tx().Query(env.Ctx(), `
+func generateTrialBalance(env *orm.Environment, opts ...reportOpts) (interface{}, error) {
+ 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,
COALESCE(SUM(l.debit), 0) as total_debit,
COALESCE(SUM(l.credit), 0) as total_credit,
COALESCE(SUM(l.balance), 0) as balance
FROM account_account a
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
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 {
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.
// Mirrors: odoo/addons/account_reports/models/account_balance_sheet.py
-func generateBalanceSheet(env *orm.Environment) (interface{}, error) {
- rows, err := env.Tx().Query(env.Ctx(), `
+func generateBalanceSheet(env *orm.Environment, opts ...reportOpts) (interface{}, error) {
+ opt := reportOpts{TargetMove: "posted"}
+ if len(opts) > 0 {
+ opt = opts[0]
+ }
+ rows, err := env.Tx().Query(env.Ctx(), fmt.Sprintf(`
SELECT
CASE
- WHEN a.account_type LIKE 'asset%' THEN 'Assets'
- WHEN a.account_type LIKE 'liability%' THEN 'Liabilities'
- WHEN a.account_type LIKE 'equity%' THEN 'Equity'
+ WHEN a.account_type LIKE 'asset%%' THEN 'Assets'
+ WHEN a.account_type LIKE 'liability%%' THEN 'Liabilities'
+ WHEN a.account_type LIKE 'equity%%' THEN 'Equity'
ELSE 'Other'
END as section,
a.code, a.name,
COALESCE(SUM(l.balance), 0) as balance
FROM account_account a
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'
- WHERE a.account_type LIKE 'asset%' OR a.account_type LIKE 'liability%' OR a.account_type LIKE 'equity%'
+ LEFT JOIN account_move m ON m.id = l.move_id
+ 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
HAVING COALESCE(SUM(l.balance), 0) != 0
- ORDER BY a.code`)
+ ORDER BY a.code`, reportStateFilter(opt), reportDateFilter(opt)))
if err != nil {
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.
// Mirrors: odoo/addons/account_reports/models/account_profit_loss.py
-func generateProfitLoss(env *orm.Environment) (interface{}, error) {
- rows, err := env.Tx().Query(env.Ctx(), `
+func generateProfitLoss(env *orm.Environment, opts ...reportOpts) (interface{}, error) {
+ opt := reportOpts{TargetMove: "posted"}
+ if len(opts) > 0 {
+ opt = opts[0]
+ }
+ rows, err := env.Tx().Query(env.Ctx(), fmt.Sprintf(`
SELECT
CASE
- WHEN a.account_type LIKE 'income%' THEN 'Income'
- WHEN a.account_type LIKE 'expense%' THEN 'Expenses'
+ WHEN a.account_type LIKE 'income%%' THEN 'Income'
+ WHEN a.account_type LIKE 'expense%%' THEN 'Expenses'
ELSE 'Other'
END as section,
a.code, a.name,
COALESCE(SUM(l.balance), 0) as balance
FROM account_account a
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'
- WHERE a.account_type LIKE 'income%' OR a.account_type LIKE 'expense%'
+ LEFT JOIN account_move m ON m.id = l.move_id
+ 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
HAVING COALESCE(SUM(l.balance), 0) != 0
- ORDER BY a.code`)
+ ORDER BY a.code`, reportStateFilter(opt), reportDateFilter(opt)))
if err != nil {
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
}
+
+// 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)
+ }
+ })
+}
diff --git a/addons/account/models/account_tax_calc.go b/addons/account/models/account_tax_calc.go
index fd47530..76f5dc0 100644
--- a/addons/account/models/account_tax_calc.go
+++ b/addons/account/models/account_tax_calc.go
@@ -51,10 +51,12 @@ func ComputeTax(env *orm.Environment, taxID int64, baseAmount float64) (*TaxResu
case "fixed":
taxAmount = amount
case "division":
+ // Division tax: price = base / (1 - rate/100)
+ // Mirrors: odoo/addons/account/models/account_tax.py _compute_amount (division case)
if priceInclude {
- taxAmount = baseAmount - (baseAmount / (1 + amount/100))
+ taxAmount = baseAmount - (baseAmount * (100 - amount) / 100)
} else {
- taxAmount = baseAmount * amount / 100
+ taxAmount = baseAmount/(1-amount/100) - baseAmount
}
}
diff --git a/addons/account/models/init.go b/addons/account/models/init.go
index 8a4c7f9..46f2942 100644
--- a/addons/account/models/init.go
+++ b/addons/account/models/init.go
@@ -28,4 +28,12 @@ func Init() {
initAccountSequence()
initAccountEdi()
initAccountReconcileModel()
+ initAccountMoveInvoiceExtensions()
+ initAccountPaymentExtensions()
+ initAccountJournalExtensions()
+ initAccountTaxComputes()
+ initAccountReportWizard()
+ initAccountMoveReversal()
+ initAccountMoveTemplate()
+ initAccountReconcilePreview()
}
diff --git a/addons/base/models/ir_cron.go b/addons/base/models/ir_cron.go
index 28805cb..cf9f77c 100644
--- a/addons/base/models/ir_cron.go
+++ b/addons/base/models/ir_cron.go
@@ -1,6 +1,10 @@
package models
-import "odoo-go/pkg/orm"
+import (
+ "fmt"
+
+ "odoo-go/pkg/orm"
+)
// initIrCron registers ir.cron β Scheduled actions.
// 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.Char("code", orm.FieldOpts{String: "Python Code"}),
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
+ })
}
diff --git a/addons/base/models/ir_ui.go b/addons/base/models/ir_ui.go
index a23828c..2021402 100644
--- a/addons/base/models/ir_ui.go
+++ b/addons/base/models/ir_ui.go
@@ -127,9 +127,28 @@ func initIrActions() {
{Value: "object_write", Label: "Update Record"},
{Value: "object_create", Label: "Create Record"},
{Value: "multi", Label: "Execute Several Actions"},
+ {Value: "email", Label: "Send Email"},
}, orm.FieldOpts{String: "Action To Do", Default: "code", Required: true}),
orm.Text("code", orm.FieldOpts{String: "Code"}),
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"}),
)
}
diff --git a/addons/base/models/res_users.go b/addons/base/models/res_users.go
index 16387b8..17091e8 100644
--- a/addons/base/models/res_users.go
+++ b/addons/base/models/res_users.go
@@ -66,8 +66,39 @@ func initResUsers() {
String: "Share User", Compute: "_compute_share", Store: true,
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 --
// action_get returns the "Change My Preferences" action for the current user.
diff --git a/addons/crm/models/crm.go b/addons/crm/models/crm.go
index ad158d3..ee1ada2 100644
--- a/addons/crm/models/crm.go
+++ b/addons/crm/models/crm.go
@@ -1,6 +1,11 @@
package models
-import "odoo-go/pkg/orm"
+import (
+ "fmt"
+ "time"
+
+ "odoo-go/pkg/orm"
+)
// initCRMLead registers the crm.lead model.
// Mirrors: odoo/addons/crm/models/crm_lead.py
@@ -67,73 +72,210 @@ func initCRMLead() {
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 {
vals := make(orm.Values)
if env.CompanyID() > 0 {
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
}
- // 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) {
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() {
- env.Tx().Exec(env.Ctx(),
- `UPDATE crm_lead SET state = 'won', probability = 100 WHERE id = $1`, id)
+ var err error
+ 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
})
- // 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) {
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() {
- env.Tx().Exec(env.Ctx(),
- `UPDATE crm_lead SET state = 'lost', probability = 0, active = false WHERE id = $1`, id)
+ var err error
+ 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
})
- // 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) {
env := rs.Env()
for _, id := range rs.IDs() {
- env.Tx().Exec(env.Ctx(),
- `UPDATE crm_lead SET type = 'opportunity' WHERE id = $1 AND type = 'lead'`, id)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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
})
- // 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) {
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() {
- env.Tx().Exec(env.Ctx(),
- `UPDATE crm_lead SET type = 'opportunity' WHERE id = $1`, id)
+ if partnerID > 0 {
+ 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
})
- // 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) {
env := rs.Env()
- // Find Won stage
+ // Find the first won stage
var wonStageID int64
env.Tx().QueryRow(env.Ctx(),
- `SELECT id FROM crm_stage WHERE is_won = true LIMIT 1`).Scan(&wonStageID)
- if wonStageID == 0 {
- wonStageID = 4 // fallback
- }
+ `SELECT id FROM crm_stage WHERE is_won = true ORDER BY sequence LIMIT 1`).Scan(&wonStageID)
+
for _, id := range rs.IDs() {
- env.Tx().Exec(env.Ctx(),
- `UPDATE crm_lead SET stage_id = $1, probability = 100 WHERE id = $2`, wonStageID, id)
+ if wonStageID > 0 {
+ 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{}{
"effect": map[string]interface{}{
"type": "rainbow_man",
"message": "Congrats, you won this opportunity!",
+ "fadeout": "slow",
},
}, nil
})
@@ -152,6 +294,11 @@ func initCRMStage() {
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 1}),
orm.Boolean("fold", orm.FieldOpts{String: "Folded in Pipeline"}),
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.Text("requirements", orm.FieldOpts{String: "Requirements"}),
)
diff --git a/addons/crm/models/crm_analysis.go b/addons/crm/models/crm_analysis.go
index b400007..18b93da 100644
--- a/addons/crm/models/crm_analysis.go
+++ b/addons/crm/models/crm_analysis.go
@@ -2,6 +2,7 @@ package models
import (
"fmt"
+ "log"
"odoo-go/pkg/orm"
)
@@ -73,12 +74,14 @@ func initCrmAnalysis() {
// Win rate
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)
FROM crm_lead l
JOIN crm_stage s ON s.id = l.stage_id
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)
if total > 0 {
@@ -99,12 +102,14 @@ func initCrmAnalysis() {
env := rs.Env()
var totalLeads, convertedLeads int64
- _ = env.Tx().QueryRow(env.Ctx(), `
+ if err := env.Tx().QueryRow(env.Ctx(), `
SELECT
COUNT(*) FILTER (WHERE type = 'lead'),
COUNT(*) FILTER (WHERE type = 'opportunity' AND date_conversion IS NOT NULL)
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)
if totalLeads > 0 {
@@ -113,19 +118,23 @@ func initCrmAnalysis() {
// Average days to convert
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)
FROM crm_lead
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)
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)
FROM crm_lead
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{}{
"total_leads": totalLeads,
diff --git a/addons/crm/models/crm_lead_ext.go b/addons/crm/models/crm_lead_ext.go
index fe7c3fc..171a400 100644
--- a/addons/crm/models/crm_lead_ext.go
+++ b/addons/crm/models/crm_lead_ext.go
@@ -2,6 +2,8 @@ package models
import (
"fmt"
+ "log"
+ "strings"
"odoo-go/pkg/orm"
)
@@ -38,14 +40,32 @@ func initCRMLeadExtended() {
}),
// ββββ Tracking / timing fields ββββ
- // Mirrors: odoo/addons/crm/models/crm_lead.py day_open, day_close
- orm.Integer("day_open", orm.FieldOpts{
- String: "Days to Assign",
- Help: "Number of days to assign this lead to a salesperson.",
+ // Mirrors: odoo/addons/crm/models/crm_lead.py date_open, day_open, day_close
+ orm.Datetime("date_open", orm.FieldOpts{
+ String: "Assignment Date",
+ Help: "Date when the lead was first assigned to a salesperson.",
}),
- orm.Integer("day_close", orm.FieldOpts{
- String: "Days to Close",
- Help: "Number of days to close this lead/opportunity.",
+ orm.Float("day_open", orm.FieldOpts{
+ String: "Days to Assign",
+ 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 ββββ
@@ -76,6 +96,27 @@ func initCRMLeadExtended() {
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 ββββ
// Mirrors: odoo/addons/crm/models/crm_lead.py prorated_revenue
orm.Monetary("prorated_revenue", orm.FieldOpts{
@@ -135,35 +176,333 @@ func initCRMLeadExtended() {
var revenue 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)
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
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 ββββ
- // 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
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{}{
- "type": "ir.actions.act_window",
- "name": "Schedule Activity",
- "res_model": "crm.lead",
- "res_id": rs.IDs()[0],
- "view_mode": "form",
- "views": [][]interface{}{{nil, "form"}},
- "target": "new",
+ "activity_id": newID,
+ "type": "ir.actions.act_window",
+ "name": "Schedule Activity",
+ "res_model": "mail.activity",
+ "res_id": newID,
+ "view_mode": "form",
+ "views": [][]interface{}{{nil, "form"}},
+ "target": "new",
}, nil
})
- // action_merge: merge multiple leads into the first one.
- // Sums expected revenues from slave leads, deactivates them.
- // Mirrors: odoo/addons/crm/wizard/crm_merge_opportunities.py
+ // action_merge: alias for action_merge_leads (delegates to the full implementation).
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()
ids := rs.IDs()
if len(ids) < 2 {
@@ -172,25 +511,36 @@ func initCRMLeadExtended() {
masterID := ids[0]
for _, slaveID := range ids[1:] {
- // Sum revenues from slave into master
- _, _ = env.Tx().Exec(env.Ctx(),
+ // Sum revenues
+ if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead
SET expected_revenue = COALESCE(expected_revenue, 0) +
(SELECT COALESCE(expected_revenue, 0) FROM crm_lead WHERE id = $1)
- WHERE id = $2`,
- slaveID, masterID)
- // Copy partner info if master has none
- _, _ = env.Tx().Exec(env.Ctx(),
+ WHERE id = $2`, slaveID, masterID); err != nil {
+ log.Printf("warning: crm.lead action_merge_leads revenue sum failed for slave %d: %v", slaveID, err)
+ }
+ // 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
- SET partner_id = COALESCE(
- (SELECT partner_id FROM crm_lead WHERE id = $2),
- partner_id)
- WHERE id = $1 AND partner_id IS NULL`,
- masterID, slaveID)
- // Deactivate the slave lead
- _, _ = env.Tx().Exec(env.Ctx(),
- `UPDATE crm_lead SET active = false WHERE id = $1`, slaveID)
+ SET description = COALESCE(description, '') || E'\n---\n' ||
+ COALESCE((SELECT description FROM crm_lead WHERE id = $1), '')
+ WHERE id = $2`, slaveID, masterID); err != nil {
+ log.Printf("warning: crm.lead action_merge_leads description concat failed for slave %d: %v", slaveID, err)
+ }
+ // Delete the merged (slave) lead
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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{}{
"type": "ir.actions.act_window",
"res_model": "crm.lead",
@@ -201,6 +551,166 @@ func initCRMLeadExtended() {
}, 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.
// Mirrors: odoo/addons/crm/models/crm_lead.py _handle_salesmen_assignment
m.RegisterMethod("action_assign_salesperson", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
@@ -213,8 +723,10 @@ func initCRMLeadExtended() {
}
env := rs.Env()
for _, id := range rs.IDs() {
- _, _ = env.Tx().Exec(env.Ctx(),
- `UPDATE crm_lead SET user_id = $1 WHERE id = $2`, int64(userID), id)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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
})
@@ -262,8 +774,10 @@ func initCRMLeadExtended() {
}
env := rs.Env()
for _, id := range rs.IDs() {
- _, _ = env.Tx().Exec(env.Ctx(),
- `UPDATE crm_lead SET priority = $1 WHERE id = $2`, priority, id)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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
})
@@ -273,8 +787,10 @@ func initCRMLeadExtended() {
m.RegisterMethod("action_archive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
- _, _ = env.Tx().Exec(env.Ctx(),
- `UPDATE crm_lead SET active = false WHERE id = $1`, id)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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
})
@@ -284,8 +800,10 @@ func initCRMLeadExtended() {
m.RegisterMethod("action_unarchive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
- _, _ = env.Tx().Exec(env.Ctx(),
- `UPDATE crm_lead SET active = true WHERE id = $1`, id)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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
})
@@ -302,9 +820,11 @@ func initCRMLeadExtended() {
}
env := rs.Env()
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`,
- 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
})
@@ -317,7 +837,7 @@ func initCRMLeadExtended() {
var totalLeads, totalOpps, wonCount, lostCount int64
var totalRevenue, avgProbability float64
- _ = env.Tx().QueryRow(env.Ctx(), `
+ if err := env.Tx().QueryRow(env.Ctx(), `
SELECT
COUNT(*) FILTER (WHERE type = 'lead'),
COUNT(*) FILTER (WHERE type = 'opportunity'),
@@ -326,7 +846,9 @@ func initCRMLeadExtended() {
COALESCE(SUM(expected_revenue::float8), 0),
COALESCE(AVG(probability), 0)
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{}{
"total_leads": totalLeads,
@@ -338,7 +860,158 @@ func initCRMLeadExtended() {
}, 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
m.RegisterOnchange("partner_id", func(env *orm.Environment, vals orm.Values) orm.Values {
result := make(orm.Values)
@@ -352,11 +1025,13 @@ func initCRMLeadExtended() {
}
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,''),
COALESCE(city,''), COALESCE(zip,''), COALESCE(name,'')
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 != "" {
result["email_from"] = email
diff --git a/addons/crm/models/crm_team.go b/addons/crm/models/crm_team.go
index e8afd9f..4e00913 100644
--- a/addons/crm/models/crm_team.go
+++ b/addons/crm/models/crm_team.go
@@ -2,6 +2,7 @@ package models
import (
"fmt"
+ "log"
"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.
// Mirrors: odoo/addons/crm/models/crm_team.py _compute_opportunities_data
m.RegisterCompute("opportunities_count", func(rs *orm.Recordset) (orm.Values, error) {
@@ -89,20 +108,24 @@ func initCrmTeamExpanded() {
var count int64
var amount float64
- _ = env.Tx().QueryRow(env.Ctx(),
+ if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*), COALESCE(SUM(expected_revenue::float8), 0)
FROM crm_lead
WHERE team_id = $1 AND active = true AND type = 'opportunity'`,
teamID,
- ).Scan(&count, &amount)
+ ).Scan(&count, &amount); err != nil {
+ log.Printf("warning: crm.team _compute_counts opportunities query failed: %v", err)
+ }
var unassigned int64
- _ = env.Tx().QueryRow(env.Ctx(),
+ if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*)
FROM crm_lead
WHERE team_id = $1 AND active = true AND user_id IS NULL`,
teamID,
- ).Scan(&unassigned)
+ ).Scan(&unassigned); err != nil {
+ log.Printf("warning: crm.team _compute_counts unassigned query failed: %v", err)
+ }
return orm.Values{
"opportunities_count": count,
@@ -111,6 +134,69 @@ func initCrmTeamExpanded() {
}, 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.
// Mirrors: odoo/addons/crm/models/crm_team.py action_assign_leads
m.RegisterMethod("action_assign_leads", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
@@ -174,8 +260,10 @@ func initCrmTeamExpanded() {
break
}
mc := &members[memberIdx]
- _, _ = env.Tx().Exec(env.Ctx(),
- `UPDATE crm_lead SET user_id = $1 WHERE id = $2`, mc.userID, leadID)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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++
mc.capacity--
if mc.capacity <= 0 {
@@ -233,6 +321,15 @@ func initCrmTeamMember() {
Index: 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{
String: "Max Leads",
Help: "Maximum number of leads this member should be assigned per month.",
@@ -260,17 +357,21 @@ func initCrmTeamMember() {
memberID := rs.IDs()[0]
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,
- ).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
- _ = env.Tx().QueryRow(env.Ctx(),
+ if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM crm_lead
WHERE user_id = $1 AND team_id = $2 AND active = true
AND create_date >= date_trunc('month', CURRENT_DATE)`,
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
})
@@ -281,4 +382,90 @@ func initCrmTeamMember() {
"UNIQUE(crm_team_id, user_id)",
"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
+ })
}
diff --git a/addons/hr/models/hr.go b/addons/hr/models/hr.go
index f5604c6..03f3169 100644
--- a/addons/hr/models/hr.go
+++ b/addons/hr/models/hr.go
@@ -1,6 +1,11 @@
package models
-import "odoo-go/pkg/orm"
+import (
+ "fmt"
+ "time"
+
+ "odoo-go/pkg/orm"
+)
// initResourceCalendar registers resource.calendar β working schedules.
// Mirrors: odoo/addons/resource/models/resource.py
@@ -98,15 +103,181 @@ func initHREmployee() {
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
+ // Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.toggle_active()
m.RegisterMethod("toggle_active", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
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)
+ if err != nil {
+ return nil, fmt.Errorf("hr.employee: toggle_active for %d: %w", id, err)
+ }
}
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
@@ -117,12 +288,283 @@ func initHrEmployeeExtensions() {
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("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.Selection("attendance_state", []orm.SelectionItem{
{Value: "checked_out", Label: "Checked Out"},
{Value: "checked_in", Label: "Checked In"},
}, 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.
@@ -149,6 +591,14 @@ func initHRDepartment() {
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"}),
)
+
+ 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.
@@ -174,4 +624,12 @@ func initHRJob() {
}, orm.FieldOpts{String: "Status", Required: true, Default: "recruit"}),
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
+ }
}
diff --git a/addons/hr/models/hr_attendance.go b/addons/hr/models/hr_attendance.go
index cf8ab3b..a4168c5 100644
--- a/addons/hr/models/hr_attendance.go
+++ b/addons/hr/models/hr_attendance.go
@@ -21,10 +21,12 @@ func initHrAttendance() {
env := rs.Env()
attID := rs.IDs()[0]
var hours float64
- env.Tx().QueryRow(env.Ctx(),
- `SELECT COALESCE(EXTRACT(EPOCH FROM (check_out - check_in)) / 3600.0, 0)
- FROM hr_attendance WHERE id = $1 AND check_out IS NOT NULL`, attID,
- ).Scan(&hours)
+ if err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(EXTRACT(EPOCH FROM (COALESCE(check_out, NOW()) - check_in)) / 3600.0, 0)
+ FROM hr_attendance WHERE id = $1`, attID,
+ ).Scan(&hours); err != nil {
+ return orm.Values{"worked_hours": float64(0)}, nil
+ }
return orm.Values{"worked_hours": hours}, nil
})
}
diff --git a/addons/hr/models/hr_contract.go b/addons/hr/models/hr_contract.go
index 4295cc8..5d23a9d 100644
--- a/addons/hr/models/hr_contract.go
+++ b/addons/hr/models/hr_contract.go
@@ -1,8 +1,13 @@
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
func initHrContract() {
m := orm.NewModel("hr.contract", orm.ModelOpts{
@@ -10,22 +15,383 @@ func initHrContract() {
Order: "date_start desc",
})
+ // -- Core Fields --
m.AddFields(
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("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_end", orm.FieldOpts{String: "End Date"}),
- orm.Monetary("wage", orm.FieldOpts{String: "Wage", Required: true, CurrencyField: "currency_id"}),
- orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
+ orm.Integer("trial_period_days", orm.FieldOpts{String: "Trial Period (Days)"}),
+ 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{
{Value: "draft", Label: "New"},
{Value: "open", Label: "Running"},
+ {Value: "pending", Label: "To Renew"},
{Value: "close", Label: "Expired"},
{Value: "cancel", Label: "Cancelled"},
- }, orm.FieldOpts{String: "Status", Default: "draft"}),
- orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
+ }, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Index: true}),
+ )
+
+ // -- 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"}),
)
+
+ // -- 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
+ })
}
diff --git a/addons/hr/models/hr_expense.go b/addons/hr/models/hr_expense.go
index 9f2ea5e..38f99ea 100644
--- a/addons/hr/models/hr_expense.go
+++ b/addons/hr/models/hr_expense.go
@@ -1,6 +1,11 @@
package models
-import "odoo-go/pkg/orm"
+import (
+ "fmt"
+ "time"
+
+ "odoo-go/pkg/orm"
+)
// initHrExpense registers the hr.expense and hr.expense.sheet models.
// Mirrors: odoo/addons/hr_expense/models/hr_expense.py
@@ -35,10 +40,63 @@ func initHrExpense() {
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",
Order: "create_date desc",
- }).AddFields(
+ })
+ sheet.AddFields(
orm.Char("name", orm.FieldOpts{String: "Report Name", Required: true}),
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
orm.Many2one("manager_id", "hr.employee", orm.FieldOpts{String: "Manager"}),
@@ -55,5 +113,240 @@ func initHrExpense() {
{Value: "cancel", Label: "Refused"},
}, orm.FieldOpts{String: "Status", Default: "draft"}),
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
+ })
}
diff --git a/addons/hr/models/hr_leave.go b/addons/hr/models/hr_leave.go
index b465a68..65288ca 100644
--- a/addons/hr/models/hr_leave.go
+++ b/addons/hr/models/hr_leave.go
@@ -1,6 +1,11 @@
package models
-import "odoo-go/pkg/orm"
+import (
+ "fmt"
+ "time"
+
+ "odoo-go/pkg/orm"
+)
// initHrLeaveType registers the hr.leave.type model.
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py
@@ -52,39 +57,378 @@ func initHrLeave() {
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) {
env := rs.Env()
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
})
+ // 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) {
env := rs.Env()
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
})
+ // 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) {
env := rs.Env()
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
})
+ // 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) {
env := rs.Env()
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
})
}
+// 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.
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py
func initHrLeaveAllocation() {
@@ -109,13 +453,123 @@ func initHrLeaveAllocation() {
{Value: "regular", Label: "Regular Allocation"},
{Value: "accrual", Label: "Accrual Allocation"},
}, 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) {
env := rs.Env()
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
})
}
+
+// 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
+ })
+}
diff --git a/addons/hr/models/hr_payroll.go b/addons/hr/models/hr_payroll.go
new file mode 100644
index 0000000..6ec8cf4
--- /dev/null
+++ b/addons/hr/models/hr_payroll.go
@@ -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"}),
+ )
+}
diff --git a/addons/hr/models/init.go b/addons/hr/models/init.go
index 376f6ed..3db6065 100644
--- a/addons/hr/models/init.go
+++ b/addons/hr/models/init.go
@@ -8,6 +8,9 @@ func Init() {
initHRJob()
initHrContract()
+ // Employee categories (tags)
+ initHrEmployeeCategory()
+
// Leave management
initHrLeaveType()
initHrLeave()
@@ -22,6 +25,25 @@ func Init() {
// Skills & Resume
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)
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()
}
diff --git a/addons/mail/models/init.go b/addons/mail/models/init.go
new file mode 100644
index 0000000..8c4a1dc
--- /dev/null
+++ b/addons/mail/models/init.go
@@ -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()
+}
diff --git a/addons/mail/models/mail_activity.go b/addons/mail/models/mail_activity.go
new file mode 100644
index 0000000..a327e9f
--- /dev/null
+++ b/addons/mail/models/mail_activity.go
@@ -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
+ })
+}
diff --git a/addons/mail/models/mail_activity_type.go b/addons/mail/models/mail_activity_type.go
new file mode 100644
index 0000000..8588585
--- /dev/null
+++ b/addons/mail/models/mail_activity_type.go
@@ -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}),
+ )
+}
diff --git a/addons/mail/models/mail_channel.go b/addons/mail/models/mail_channel.go
new file mode 100644
index 0000000..c5d8496
--- /dev/null
+++ b/addons/mail/models/mail_channel.go
@@ -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
+ })
+}
diff --git a/addons/mail/models/mail_followers.go b/addons/mail/models/mail_followers.go
new file mode 100644
index 0000000..21922cb
--- /dev/null
+++ b/addons/mail/models/mail_followers.go
@@ -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,
+ }),
+ )
+}
diff --git a/addons/mail/models/mail_message.go b/addons/mail/models/mail_message.go
new file mode 100644
index 0000000..d61bb6c
--- /dev/null
+++ b/addons/mail/models/mail_message.go
@@ -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.",
+ }),
+ )
+}
diff --git a/addons/mail/models/mail_thread.go b/addons/mail/models/mail_thread.go
new file mode 100644
index 0000000..dd85060
--- /dev/null
+++ b/addons/mail/models/mail_thread.go
@@ -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)
+ }
+ }
+}
diff --git a/addons/mail/module.go b/addons/mail/module.go
new file mode 100644
index 0000000..a6f07d3
--- /dev/null
+++ b/addons/mail/module.go
@@ -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,
+ })
+}
diff --git a/addons/project/models/init.go b/addons/project/models/init.go
index c1fc4c9..849c074 100644
--- a/addons/project/models/init.go
+++ b/addons/project/models/init.go
@@ -6,6 +6,8 @@ func Init() {
initProjectMilestone()
initProjectProject()
initProjectTask()
+ initProjectTaskChecklist()
+ initProjectSharing()
initProjectUpdate()
initProjectTimesheetExtension()
initTimesheetReport()
@@ -13,5 +15,6 @@ func Init() {
initProjectTaskExtension()
initProjectMilestoneExtension()
initProjectTaskRecurrence()
+ initProjectTaskRecurrenceExtension()
initProjectSharingWizard()
}
diff --git a/addons/project/models/project.go b/addons/project/models/project.go
index 846dc3d..6bd6c46 100644
--- a/addons/project/models/project.go
+++ b/addons/project/models/project.go
@@ -80,6 +80,8 @@ func initProjectTask() {
orm.Many2one("milestone_id", "project.milestone", orm.FieldOpts{String: "Milestone"}),
orm.Many2many("depend_ids", "project.task", orm.FieldOpts{String: "Depends On"}),
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{
{Value: "", Label: ""},
{Value: "line_section", Label: "Section"},
@@ -100,38 +102,54 @@ func initProjectTask() {
task.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 project_task SET state = 'done' WHERE id = $1`, id)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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
})
- // action_cancel: mark task as cancelled
task.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
- env.Tx().Exec(env.Ctx(),
- `UPDATE project_task SET state = 'cancel' WHERE id = $1`, id)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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
})
- // action_reopen: reopen a cancelled/done task
task.RegisterMethod("action_reopen", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
- env.Tx().Exec(env.Ctx(),
- `UPDATE project_task SET state = 'open' WHERE id = $1`, id)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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
})
- // action_blocked: set kanban state to blocked
task.RegisterMethod("action_blocked", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
- env.Tx().Exec(env.Ctx(),
- `UPDATE project_task SET kanban_state = 'blocked' WHERE id = $1`, id)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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
})
@@ -185,3 +203,62 @@ func initProjectTags() {
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.",
+ )
+}
diff --git a/addons/project/models/project_extend.go b/addons/project/models/project_extend.go
index df9f6f2..f5715e6 100644
--- a/addons/project/models/project_extend.go
+++ b/addons/project/models/project_extend.go
@@ -1,6 +1,7 @@
package models
import (
+ "encoding/json"
"fmt"
"time"
@@ -56,6 +57,28 @@ func initProjectProjectExtension() {
}),
orm.Many2many("tag_ids", "project.tags", orm.FieldOpts{String: "Tags"}),
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 --
@@ -164,6 +187,110 @@ func initProjectProjectExtension() {
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.
// 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) {
@@ -264,7 +391,17 @@ func initProjectTaskExtension() {
task := orm.ExtendModel("project.task")
// 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(
+ 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("effective_hours", orm.FieldOpts{
String: "Hours Spent", Compute: "_compute_effective_hours",
@@ -524,6 +661,263 @@ func initProjectTaskExtension() {
}
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.
@@ -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.
// Mirrors: odoo/addons/project/wizard/project_share_wizard.py
func initProjectSharingWizard() {
diff --git a/addons/project/models/project_timesheet.go b/addons/project/models/project_timesheet.go
index 454d1dd..9668e4d 100644
--- a/addons/project/models/project_timesheet.go
+++ b/addons/project/models/project_timesheet.go
@@ -198,6 +198,94 @@ func initTimesheetReport() {
}, 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.
m.RegisterMethod("get_timesheet_by_week", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
diff --git a/addons/purchase/models/init.go b/addons/purchase/models/init.go
index 0a7b3f1..06d8ca2 100644
--- a/addons/purchase/models/init.go
+++ b/addons/purchase/models/init.go
@@ -1,12 +1,15 @@
package models
func Init() {
- initPurchaseOrder()
- initPurchaseOrderLine()
+ initPurchaseOrder() // also calls initPurchaseOrderLine()
initPurchaseAgreement()
initPurchaseReport()
+ initProductSupplierInfo()
+ initAccountMoveLinePurchaseExtension()
initPurchaseOrderExtension()
+ initPurchaseOrderWorkflow()
initPurchaseOrderLineExtension()
initResPartnerPurchaseExtension()
initPurchaseOrderAmount()
+ initVendorLeadTime()
}
diff --git a/addons/purchase/models/purchase_agreement.go b/addons/purchase/models/purchase_agreement.go
index 9774d16..c768fee 100644
--- a/addons/purchase/models/purchase_agreement.go
+++ b/addons/purchase/models/purchase_agreement.go
@@ -1,6 +1,10 @@
package models
-import "odoo-go/pkg/orm"
+import (
+ "fmt"
+
+ "odoo-go/pkg/orm"
+)
// initPurchaseAgreement registers purchase.requisition and purchase.requisition.line.
// 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) {
env := rs.Env()
for _, id := range rs.IDs() {
- env.Tx().Exec(env.Ctx(),
- `UPDATE purchase_requisition SET state = 'ongoing' WHERE id = $1 AND state = 'draft'`, id)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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
})
- // action_done: close the agreement
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 purchase_requisition SET state = 'done' WHERE id = $1`, id)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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
})
- // action_cancel
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
- env.Tx().Exec(env.Ctx(),
- `UPDATE purchase_requisition SET state = 'cancel' WHERE id = $1`, id)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `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
})
diff --git a/addons/purchase/models/purchase_extend.go b/addons/purchase/models/purchase_extend.go
index fd5e39b..7a46110 100644
--- a/addons/purchase/models/purchase_extend.go
+++ b/addons/purchase/models/purchase_extend.go
@@ -5,6 +5,7 @@ import (
"time"
"odoo-go/pkg/orm"
+ "odoo-go/pkg/tools"
)
// initPurchaseOrderExtension extends purchase.order with additional fields and methods.
@@ -34,6 +35,13 @@ func initPurchaseOrderExtension() {
orm.Integer("incoming_picking_count", orm.FieldOpts{
String: "Incoming Shipment Count", Compute: "_compute_incoming_picking_count",
}),
+ // receipt_status: Receipt status from linked pickings.
+ // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py receipt_status
+ orm.Selection("receipt_status", []orm.SelectionItem{
+ {Value: "pending", Label: "Not Received"},
+ {Value: "partial", Label: "Partially Received"},
+ {Value: "full", Label: "Fully Received"},
+ }, orm.FieldOpts{String: "Receipt Status", Compute: "_compute_receipt_status", Store: true}),
)
// -- Computed: _compute_is_shipped --
@@ -54,26 +62,38 @@ func initPurchaseOrderExtension() {
// -- Computed: _compute_invoice_count --
// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder._compute_invoice()
+ // Uses both invoice_origin link and purchase_line_id on invoice lines.
po.RegisterCompute("invoice_count", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
poID := rs.IDs()[0]
- // Bills linked via invoice_origin
var poName string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName)
+ // Count unique bills linked via purchase_line_id on invoice lines
var count int
env.Tx().QueryRow(env.Ctx(),
- `SELECT COUNT(*) FROM account_move
- WHERE invoice_origin = $1 AND move_type = 'in_invoice'`, poName,
+ `SELECT COUNT(DISTINCT am.id) FROM account_move am
+ JOIN account_move_line aml ON aml.move_id = am.id
+ JOIN purchase_order_line pol ON pol.id = aml.purchase_line_id
+ WHERE pol.order_id = $1 AND am.move_type IN ('in_invoice', 'in_refund')`,
+ poID,
).Scan(&count)
+ // Fallback: bills linked via invoice_origin
+ if count == 0 {
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COUNT(*) FROM account_move
+ WHERE invoice_origin = $1 AND move_type IN ('in_invoice', 'in_refund')`, poName,
+ ).Scan(&count)
+ }
+
// Also check by PO ID pattern fallback
if count == 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_move
- WHERE invoice_origin = $1 AND move_type = 'in_invoice'`,
+ WHERE invoice_origin = $1 AND move_type IN ('in_invoice', 'in_refund')`,
fmt.Sprintf("PO%d", poID),
).Scan(&count)
}
@@ -157,6 +177,7 @@ func initPurchaseOrderExtension() {
// action_view_invoice: Open vendor bills linked to this PO.
// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_view_invoice()
+ // Finds bills via purchase_line_id link, then falls back to invoice_origin.
po.RegisterMethod("action_view_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
poID := rs.IDs()[0]
@@ -165,35 +186,61 @@ func initPurchaseOrderExtension() {
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName)
+ // Primary: find bills linked via purchase_line_id
+ invIDSet := make(map[int64]bool)
rows, err := env.Tx().Query(env.Ctx(),
- `SELECT id FROM account_move WHERE invoice_origin = $1 AND move_type = 'in_invoice'`, poName)
- if err != nil {
- return nil, fmt.Errorf("purchase: view invoice query: %w", err)
- }
- defer rows.Close()
-
- var invIDs []interface{}
- for rows.Next() {
- var id int64
- rows.Scan(&id)
- invIDs = append(invIDs, id)
+ `SELECT DISTINCT am.id FROM account_move am
+ JOIN account_move_line aml ON aml.move_id = am.id
+ JOIN purchase_order_line pol ON pol.id = aml.purchase_line_id
+ WHERE pol.order_id = $1 AND am.move_type IN ('in_invoice', 'in_refund')`, poID)
+ if err == nil {
+ for rows.Next() {
+ var id int64
+ rows.Scan(&id)
+ invIDSet[id] = true
+ }
+ rows.Close()
}
- // Also check by PO ID pattern fallback
- if len(invIDs) == 0 {
+ // Fallback: invoice_origin
+ if len(invIDSet) == 0 {
rows2, _ := env.Tx().Query(env.Ctx(),
- `SELECT id FROM account_move WHERE invoice_origin = $1 AND move_type = 'in_invoice'`,
- fmt.Sprintf("PO%d", poID))
+ `SELECT id FROM account_move WHERE invoice_origin = $1
+ AND move_type IN ('in_invoice', 'in_refund')`, poName)
if rows2 != nil {
for rows2.Next() {
var id int64
rows2.Scan(&id)
- invIDs = append(invIDs, id)
+ invIDSet[id] = true
}
rows2.Close()
}
}
+ // Fallback: PO ID pattern
+ if len(invIDSet) == 0 {
+ rows3, _ := env.Tx().Query(env.Ctx(),
+ `SELECT id FROM account_move WHERE invoice_origin = $1
+ AND move_type IN ('in_invoice', 'in_refund')`,
+ fmt.Sprintf("PO%d", poID))
+ if rows3 != nil {
+ for rows3.Next() {
+ var id int64
+ rows3.Scan(&id)
+ invIDSet[id] = true
+ }
+ rows3.Close()
+ }
+ }
+
+ var invIDs []interface{}
+ for id := range invIDSet {
+ invIDs = append(invIDs, id)
+ }
+
+ if len(invIDs) == 0 {
+ return map[string]interface{}{"type": "ir.actions.act_window_close"}, nil
+ }
if len(invIDs) == 1 {
return map[string]interface{}{
"type": "ir.actions.act_window", "res_model": "account.move",
@@ -326,41 +373,171 @@ func initPurchaseOrderExtension() {
if state != "to approve" {
continue
}
- env.Tx().Exec(env.Ctx(),
- `UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, poID)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, poID); err != nil {
+ return nil, fmt.Errorf("purchase.order: approve %d: %w", poID, err)
+ }
}
return true, nil
})
- // button_done: Lock a confirmed PO.
- // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_done()
po.RegisterMethod("button_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, poID := range rs.IDs() {
- env.Tx().Exec(env.Ctx(),
- `UPDATE purchase_order SET state = 'done' WHERE id = $1`, poID)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `UPDATE purchase_order SET state = 'done' WHERE id = $1`, poID); err != nil {
+ return nil, fmt.Errorf("purchase.order: done %d: %w", poID, err)
+ }
}
return true, nil
})
- // button_unlock: Unlock a locked PO back to purchase state.
- // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_unlock()
po.RegisterMethod("button_unlock", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, poID := range rs.IDs() {
- env.Tx().Exec(env.Ctx(),
- `UPDATE purchase_order SET state = 'purchase' WHERE id = $1 AND state = 'done'`, poID)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `UPDATE purchase_order SET state = 'purchase' WHERE id = $1 AND state = 'done'`, poID); err != nil {
+ return nil, fmt.Errorf("purchase.order: unlock %d: %w", poID, err)
+ }
}
return true, nil
})
- // action_rfq_send: Mark the PO as "sent" (RFQ has been emailed).
+ // action_rfq_send: Send the RFQ email to the vendor and mark PO as 'sent'.
// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_rfq_send()
+ // Reads vendor email from res.partner, builds an email body with PO details,
+ // and sends via tools.SendEmail.
po.RegisterMethod("action_rfq_send", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ smtpCfg := tools.LoadSMTPConfig()
+
+ for _, poID := range rs.IDs() {
+ var state, poName, partnerRef string
+ var partnerID, companyID int64
+ var amountTotal float64
+ var datePlanned *time.Time
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(state,'draft'), COALESCE(name,''), COALESCE(partner_ref,''),
+ COALESCE(partner_id,0), COALESCE(company_id,0),
+ COALESCE(amount_total::float8,0), date_planned
+ FROM purchase_order WHERE id = $1`, poID,
+ ).Scan(&state, &poName, &partnerRef, &partnerID, &companyID, &amountTotal, &datePlanned)
+
+ if state != "draft" && state != "sent" {
+ continue
+ }
+
+ // Read vendor email and name
+ var vendorEmail, vendorName string
+ if partnerID > 0 {
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(email,''), COALESCE(name,'') FROM res_partner WHERE id = $1`,
+ partnerID).Scan(&vendorEmail, &vendorName)
+ }
+
+ // Read company name for the email
+ var companyName string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(name,'') FROM res_company WHERE id = $1`, companyID).Scan(&companyName)
+
+ // Read order lines for the email body
+ lineRows, err := env.Tx().Query(env.Ctx(),
+ `SELECT COALESCE(name,''), COALESCE(product_qty,0), COALESCE(price_unit,0),
+ COALESCE(product_qty * price_unit * (1 - COALESCE(discount,0)/100), 0)
+ FROM purchase_order_line
+ WHERE order_id = $1 AND COALESCE(display_type,'') NOT IN ('line_section','line_note')
+ ORDER BY sequence, id`, poID)
+ var linesHTML string
+ if err == nil {
+ for lineRows.Next() {
+ var lName string
+ var lQty, lPrice, lSubtotal float64
+ if lineRows.Scan(&lName, &lQty, &lPrice, &lSubtotal) == nil {
+ linesHTML += fmt.Sprintf(
+ "| %s | %.2f | %.2f | %.2f |
",
+ lName, lQty, lPrice, lSubtotal)
+ }
+ }
+ lineRows.Close()
+ }
+
+ plannedStr := ""
+ if datePlanned != nil {
+ plannedStr = datePlanned.Format("2006-01-02")
+ }
+
+ subject := fmt.Sprintf("Request for Quotation (%s)", poName)
+ body := fmt.Sprintf(`
+Dear %s,
+Here is a Request for Quotation from %s:
+
+| Reference | %s |
+| Expected Arrival | %s |
+| Total | %.2f |
+
+
+
+| Description | Qty | Unit Price | Subtotal |
+%s
+
+Please confirm your availability and pricing at your earliest convenience.
+Best regards,
%s
+`, vendorName, companyName, poName, plannedStr, amountTotal, linesHTML, companyName)
+
+ // Send email if vendor has an email address
+ if vendorEmail != "" {
+ if err := tools.SendEmail(smtpCfg, vendorEmail, subject, body); err != nil {
+ return nil, fmt.Errorf("purchase.order: send RFQ email for %s: %w", poName, err)
+ }
+ }
+
+ // Mark PO as sent
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `UPDATE purchase_order SET state = 'sent' WHERE id = $1 AND state = 'draft'`, poID); err != nil {
+ return nil, fmt.Errorf("purchase.order: rfq_send %d: %w", poID, err)
+ }
+ }
+ return true, nil
+ })
+
+ // -- Computed: _compute_receipt_status --
+ // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py _compute_receipt_status()
+ po.RegisterCompute("receipt_status", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ poID := rs.IDs()[0]
+
+ var poName string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName)
+
+ // Count pickings by state
+ var totalPickings, donePickings, cancelledPickings int
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COUNT(*),
+ COUNT(*) FILTER (WHERE state = 'done'),
+ COUNT(*) FILTER (WHERE state = 'cancel')
+ FROM stock_picking WHERE origin = $1`, poName,
+ ).Scan(&totalPickings, &donePickings, &cancelledPickings)
+
+ if totalPickings == 0 || totalPickings == cancelledPickings {
+ return orm.Values{"receipt_status": nil}, nil
+ }
+ if totalPickings == donePickings+cancelledPickings {
+ return orm.Values{"receipt_status": "full"}, nil
+ }
+ if donePickings > 0 {
+ return orm.Values{"receipt_status": "partial"}, nil
+ }
+ return orm.Values{"receipt_status": "pending"}, nil
+ })
+
+ // button_lock: Lock a confirmed PO.
+ // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_lock()
+ po.RegisterMethod("button_lock", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, poID := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
- `UPDATE purchase_order SET state = 'sent' WHERE id = $1 AND state = 'draft'`, poID)
+ `UPDATE purchase_order SET locked = true WHERE id = $1`, poID)
}
return true, nil
})
@@ -378,12 +555,359 @@ func initPurchaseOrderExtension() {
).Scan(&totalQty, &receivedQty)
if totalQty > 0 && receivedQty >= totalQty {
+ // Use the last done picking date, not current time
+ var lastDoneDate *time.Time
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT MAX(date_done) FROM stock_picking
+ WHERE origin = (SELECT name FROM purchase_order WHERE id = $1) AND state = 'done'`,
+ poID).Scan(&lastDoneDate)
+ if lastDoneDate != nil {
+ return orm.Values{"effective_date": *lastDoneDate}, nil
+ }
return orm.Values{"effective_date": time.Now()}, nil
}
return orm.Values{"effective_date": nil}, nil
})
}
+// initPurchaseOrderWorkflow adds remaining workflow features to purchase.order.
+func initPurchaseOrderWorkflow() {
+ po := orm.ExtendModel("purchase.order")
+
+ // _check_three_way_match: 3-Way Match validation.
+ // Compares PO qty vs received qty vs billed qty per line.
+ // Returns a list of mismatches (lines where the three quantities don't align).
+ // Mirrors: odoo/addons/purchase/models/purchase_order.py (3-way matching logic)
+ po.RegisterMethod("_check_three_way_match", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ var allMismatches []map[string]interface{}
+
+ for _, poID := range rs.IDs() {
+ var poName string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName)
+
+ rows, err := env.Tx().Query(env.Ctx(),
+ `SELECT pol.id, COALESCE(pol.name, ''),
+ COALESCE(pol.product_qty, 0),
+ COALESCE(pol.qty_received, 0),
+ COALESCE(pol.qty_invoiced, 0),
+ pol.product_id
+ FROM purchase_order_line pol
+ WHERE pol.order_id = $1
+ AND COALESCE(pol.display_type, '') NOT IN ('line_section', 'line_note')
+ ORDER BY pol.sequence, pol.id`, poID)
+ if err != nil {
+ return nil, fmt.Errorf("purchase: three_way_match query for PO %d: %w", poID, err)
+ }
+
+ for rows.Next() {
+ var lineID int64
+ var lineName string
+ var orderedQty, receivedQty, billedQty float64
+ var productID *int64
+ if err := rows.Scan(&lineID, &lineName, &orderedQty, &receivedQty, &billedQty, &productID); err != nil {
+ rows.Close()
+ return nil, err
+ }
+
+ // A line matches when ordered == received == billed.
+ // Report any deviation.
+ mismatch := make(map[string]interface{})
+ hasMismatch := false
+
+ if orderedQty != receivedQty || orderedQty != billedQty || receivedQty != billedQty {
+ hasMismatch = true
+ }
+
+ if hasMismatch {
+ mismatch["po_name"] = poName
+ mismatch["line_id"] = lineID
+ mismatch["line_name"] = lineName
+ mismatch["ordered_qty"] = orderedQty
+ mismatch["received_qty"] = receivedQty
+ mismatch["billed_qty"] = billedQty
+ if productID != nil {
+ mismatch["product_id"] = *productID
+ }
+
+ // Classify the type of mismatch
+ var issues []string
+ if receivedQty < orderedQty {
+ issues = append(issues, "under_received")
+ } else if receivedQty > orderedQty {
+ issues = append(issues, "over_received")
+ }
+ if billedQty < receivedQty {
+ issues = append(issues, "under_billed")
+ } else if billedQty > receivedQty {
+ issues = append(issues, "over_billed")
+ }
+ if billedQty > orderedQty {
+ issues = append(issues, "billed_exceeds_ordered")
+ }
+ mismatch["issues"] = issues
+
+ allMismatches = append(allMismatches, mismatch)
+ }
+ }
+ rows.Close()
+ }
+
+ return map[string]interface{}{
+ "match": len(allMismatches) == 0,
+ "mismatches": allMismatches,
+ }, nil
+ })
+
+ // action_print: Return a report action URL pointing to /report/pdf/purchase.order/.
+ // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_print()
+ po.RegisterMethod("action_print", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ if len(rs.IDs()) == 0 {
+ return nil, fmt.Errorf("purchase.order: action_print requires at least one record")
+ }
+ poID := rs.IDs()[0]
+ return map[string]interface{}{
+ "type": "ir.actions.report",
+ "report_name": "purchase.order",
+ "report_type": "qweb-pdf",
+ "res_model": "purchase.order",
+ "res_id": poID,
+ "url": fmt.Sprintf("/report/pdf/purchase.order/%d", poID),
+ }, nil
+ })
+
+ // _compute_date_planned: Propagate the earliest line date_planned to the PO header
+ // and to linked stock moves.
+ // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py _compute_date_planned()
+ po.RegisterCompute("date_planned", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ poID := rs.IDs()[0]
+ var earliest *time.Time
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT MIN(date_planned) FROM purchase_order_line
+ WHERE order_id = $1 AND date_planned IS NOT NULL`, poID).Scan(&earliest)
+ if earliest == nil {
+ return orm.Values{"date_planned": nil}, nil
+ }
+ return orm.Values{"date_planned": *earliest}, nil
+ })
+
+ // action_propagate_date_planned: Push date_planned from PO lines to stock moves.
+ // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py _propagate_date_planned()
+ po.RegisterMethod("action_propagate_date_planned", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, poID := range rs.IDs() {
+ var poName string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName)
+ if poName == "" {
+ continue
+ }
+
+ // Get date_planned from PO header
+ var datePlanned *time.Time
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT date_planned FROM purchase_order WHERE id = $1`, poID).Scan(&datePlanned)
+ if datePlanned == nil {
+ continue
+ }
+
+ // Update scheduled date on linked stock moves (via picking origin)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_move SET date = $1
+ WHERE picking_id IN (SELECT id FROM stock_picking WHERE origin = $2)
+ AND state NOT IN ('done', 'cancel')`,
+ *datePlanned, poName); err != nil {
+ return nil, fmt.Errorf("purchase.order: propagate date for %d: %w", poID, err)
+ }
+ }
+ return true, nil
+ })
+
+ // _check_company_match: Validate that PO company matches partner and lines.
+ // Mirrors: odoo/addons/purchase/models/purchase_order.py _check_company_match()
+ po.RegisterMethod("_check_company_match", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, poID := range rs.IDs() {
+ var poCompanyID int64
+ var partnerID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(company_id, 0), COALESCE(partner_id, 0)
+ FROM purchase_order WHERE id = $1`, poID).Scan(&poCompanyID, &partnerID)
+
+ if poCompanyID == 0 {
+ continue // No company set β no check needed
+ }
+
+ // Check partner's company (if set) matches PO company
+ if partnerID > 0 {
+ var partnerCompanyID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(company_id, 0) FROM res_partner WHERE id = $1`, partnerID,
+ ).Scan(&partnerCompanyID)
+ if partnerCompanyID > 0 && partnerCompanyID != poCompanyID {
+ return nil, fmt.Errorf("purchase.order: vendor company (%d) does not match PO company (%d)", partnerCompanyID, poCompanyID)
+ }
+ }
+ }
+ return true, nil
+ })
+
+ // action_create_po_from_agreement: Create a PO from a blanket purchase agreement.
+ // Mirrors: odoo/addons/purchase_requisition/models/purchase_requisition.py action_create_order()
+ po.RegisterMethod("action_create_po_from_agreement", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+
+ if len(args) < 1 {
+ return nil, fmt.Errorf("purchase.order: action_create_po_from_agreement requires agreement_id")
+ }
+ var agreementID int64
+ switch v := args[0].(type) {
+ case float64:
+ agreementID = int64(v)
+ case int64:
+ agreementID = v
+ case map[string]interface{}:
+ if id, ok := v["agreement_id"]; ok {
+ switch n := id.(type) {
+ case float64:
+ agreementID = int64(n)
+ case int64:
+ agreementID = n
+ }
+ }
+ }
+ if agreementID == 0 {
+ return nil, fmt.Errorf("purchase.order: invalid agreement_id")
+ }
+
+ // Read agreement header
+ var userID, companyID int64
+ var state string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(user_id, 0), COALESCE(company_id, 0), COALESCE(state, 'draft')
+ FROM purchase_requisition WHERE id = $1`, agreementID,
+ ).Scan(&userID, &companyID, &state)
+
+ if state != "ongoing" && state != "in_progress" && state != "open" {
+ return nil, fmt.Errorf("purchase.order: agreement %d is not confirmed (state: %s)", agreementID, state)
+ }
+
+ // Read agreement lines
+ rows, err := env.Tx().Query(env.Ctx(),
+ `SELECT product_id, COALESCE(product_qty, 0), COALESCE(price_unit, 0)
+ FROM purchase_requisition_line WHERE requisition_id = $1`, agreementID)
+ if err != nil {
+ return nil, fmt.Errorf("purchase.order: read agreement lines: %w", err)
+ }
+
+ type agrLine struct {
+ productID int64
+ qty float64
+ price float64
+ }
+ var lines []agrLine
+ for rows.Next() {
+ var l agrLine
+ if err := rows.Scan(&l.productID, &l.qty, &l.price); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ lines = append(lines, l)
+ }
+ rows.Close()
+
+ if len(lines) == 0 {
+ return nil, fmt.Errorf("purchase.order: agreement %d has no lines", agreementID)
+ }
+
+ // Find a vendor from existing POs linked to this agreement, or use user's partner
+ var partnerID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(partner_id, 0) FROM purchase_order
+ WHERE requisition_id = $1 LIMIT 1`, agreementID).Scan(&partnerID)
+ if partnerID == 0 {
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, userID).Scan(&partnerID)
+ }
+
+ // Create PO
+ poRS := env.Model("purchase.order")
+ newPO, err := poRS.Create(orm.Values{
+ "partner_id": partnerID,
+ "company_id": companyID,
+ "requisition_id": agreementID,
+ "origin": fmt.Sprintf("Agreement/%d", agreementID),
+ "date_planned": time.Now().Format("2006-01-02 15:04:05"),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("purchase.order: create PO from agreement: %w", err)
+ }
+ poID := newPO.ID()
+
+ // Create PO lines from agreement lines
+ polRS := env.Model("purchase.order.line")
+ for _, l := range lines {
+ var productName string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(pt.name, 'Product') FROM product_product pp
+ JOIN product_template pt ON pt.id = pp.product_tmpl_id
+ WHERE pp.id = $1`, l.productID).Scan(&productName)
+
+ if _, err := polRS.Create(orm.Values{
+ "order_id": poID,
+ "product_id": l.productID,
+ "name": productName,
+ "product_qty": l.qty,
+ "price_unit": l.price,
+ }); err != nil {
+ return nil, fmt.Errorf("purchase.order: create PO line from agreement: %w", err)
+ }
+ }
+
+ return map[string]interface{}{
+ "type": "ir.actions.act_window",
+ "res_model": "purchase.order",
+ "res_id": poID,
+ "view_mode": "form",
+ "views": [][]interface{}{{nil, "form"}},
+ "target": "current",
+ }, nil
+ })
+}
+
+// initVendorLeadTime adds vendor lead time computation based on PO history.
+func initVendorLeadTime() {
+ partner := orm.ExtendModel("res.partner")
+
+ partner.AddFields(
+ orm.Integer("purchase_lead_time", orm.FieldOpts{
+ String: "Vendor Lead Time (Days)", Compute: "_compute_purchase_lead_time",
+ Help: "Average days between PO confirmation and receipt, computed from history",
+ }),
+ )
+
+ // _compute_purchase_lead_time: Average days from PO confirm to receipt done.
+ partner.RegisterCompute("purchase_lead_time", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ partnerID := rs.IDs()[0]
+
+ var avgDays float64
+ err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (sp.date_done - po.date_approve)) / 86400.0), 0)
+ FROM purchase_order po
+ JOIN stock_picking sp ON sp.origin = po.name AND sp.state = 'done'
+ WHERE po.partner_id = $1 AND po.state = 'purchase' AND po.date_approve IS NOT NULL
+ AND sp.date_done IS NOT NULL`,
+ partnerID).Scan(&avgDays)
+ if err != nil || avgDays <= 0 {
+ return orm.Values{"purchase_lead_time": int64(0)}, nil
+ }
+ return orm.Values{"purchase_lead_time": int64(avgDays + 0.5)}, nil // round
+ })
+}
+
// initPurchaseOrderLineExtension extends purchase.order.line with additional fields.
// Mirrors: odoo/addons/purchase/models/purchase_order_line.py (additional fields)
func initPurchaseOrderLineExtension() {
@@ -404,48 +928,112 @@ func initPurchaseOrderLineExtension() {
orm.Boolean("product_qty_updated", orm.FieldOpts{String: "Qty Updated"}),
)
+ // _compute_qty_invoiced: Compute billed qty from linked invoice lines via purchase_line_id.
+ // Mirrors: odoo/addons/purchase/models/purchase_order_line.py _compute_qty_invoiced()
+ pol.RegisterCompute("qty_invoiced", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ lineID := rs.IDs()[0]
+
+ // Sum quantities from invoice lines linked via purchase_line_id
+ // Only count posted invoices (not draft/cancelled)
+ var invoiced float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(
+ CASE WHEN am.move_type = 'in_invoice' THEN aml.quantity
+ WHEN am.move_type = 'in_refund' THEN -aml.quantity
+ ELSE 0 END
+ ), 0)
+ FROM account_move_line aml
+ JOIN account_move am ON am.id = aml.move_id
+ WHERE aml.purchase_line_id = $1
+ AND am.state != 'cancel'`, lineID,
+ ).Scan(&invoiced)
+
+ if invoiced < 0 {
+ invoiced = 0
+ }
+ return orm.Values{"qty_invoiced": invoiced}, nil
+ })
+
// _compute_line_invoice_status: Per-line billing status.
// Mirrors: odoo/addons/purchase/models/purchase_order_line.py _compute_qty_invoiced()
pol.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
- var qty, qtyInvoiced float64
+ var qty, qtyInvoiced, qtyReceived float64
+ var orderState string
env.Tx().QueryRow(env.Ctx(),
- `SELECT COALESCE(product_qty, 0), COALESCE(qty_invoiced, 0)
- FROM purchase_order_line WHERE id = $1`, lineID,
- ).Scan(&qty, &qtyInvoiced)
+ `SELECT COALESCE(pol.product_qty, 0), COALESCE(pol.qty_invoiced, 0),
+ COALESCE(pol.qty_received, 0), COALESCE(po.state, 'draft')
+ FROM purchase_order_line pol
+ JOIN purchase_order po ON po.id = pol.order_id
+ WHERE pol.id = $1`, lineID,
+ ).Scan(&qty, &qtyInvoiced, &qtyReceived, &orderState)
status := "no"
- if qty > 0 {
+ if orderState == "purchase" && qty > 0 {
+ qtyToInvoice := qtyReceived - qtyInvoiced
+ if qtyToInvoice < 0 {
+ qtyToInvoice = 0
+ }
if qtyInvoiced >= qty {
status = "invoiced"
- } else if qtyInvoiced > 0 {
+ } else if qtyToInvoice > 0 {
status = "to invoice"
} else {
- status = "to invoice"
+ status = "no"
}
}
return orm.Values{"invoice_status": status}, nil
})
- // _compute_line_qty_to_invoice
+ // _compute_line_qty_to_invoice: Mirrors Python _compute_qty_invoiced().
+ // For purchase method 'purchase': qty_to_invoice = product_qty - qty_invoiced
+ // For purchase method 'receive' (default): qty_to_invoice = qty_received - qty_invoiced
+ // Only non-zero when order state is 'purchase'.
pol.RegisterCompute("qty_to_invoice", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
- var qty, qtyInvoiced float64
+
+ var qty, qtyInvoiced, qtyReceived float64
+ var orderState string
env.Tx().QueryRow(env.Ctx(),
- `SELECT COALESCE(product_qty, 0), COALESCE(qty_invoiced, 0)
- FROM purchase_order_line WHERE id = $1`, lineID,
- ).Scan(&qty, &qtyInvoiced)
- toInvoice := qty - qtyInvoiced
+ `SELECT COALESCE(pol.product_qty, 0), COALESCE(pol.qty_invoiced, 0),
+ COALESCE(pol.qty_received, 0), COALESCE(po.state, 'draft')
+ FROM purchase_order_line pol
+ JOIN purchase_order po ON po.id = pol.order_id
+ WHERE pol.id = $1`, lineID,
+ ).Scan(&qty, &qtyInvoiced, &qtyReceived, &orderState)
+
+ if orderState != "purchase" {
+ return orm.Values{"qty_to_invoice": float64(0)}, nil
+ }
+
+ // Check product's purchase_method: 'purchase' bills on ordered qty, 'receive' bills on received qty
+ var purchaseMethod string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(pt.purchase_method, 'receive')
+ FROM purchase_order_line pol
+ LEFT JOIN product_product pp ON pp.id = pol.product_id
+ LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id
+ WHERE pol.id = $1`, lineID,
+ ).Scan(&purchaseMethod)
+
+ var toInvoice float64
+ if purchaseMethod == "purchase" {
+ toInvoice = qty - qtyInvoiced
+ } else {
+ toInvoice = qtyReceived - qtyInvoiced
+ }
if toInvoice < 0 {
toInvoice = 0
}
return orm.Values{"qty_to_invoice": toInvoice}, nil
})
- // _compute_qty_received: Uses manual received qty if set, otherwise from stock moves.
+ // _compute_qty_received: Uses manual received qty if set, otherwise sums from done
+ // stock moves linked via picking origin, filtered to internal destination locations.
// Mirrors: odoo/addons/purchase_stock/models/purchase_order_line.py _compute_qty_received()
pol.RegisterCompute("qty_received", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
@@ -459,11 +1047,36 @@ func initPurchaseOrderLineExtension() {
return orm.Values{"qty_received": *manual}, nil
}
- // Fallback: sum from linked stock moves
- var qty float64
+ // Sum from linked stock moves: done moves whose picking origin matches the PO name,
+ // product matches, and destination is an internal location.
+ var productID *int64
+ var orderID int64
env.Tx().QueryRow(env.Ctx(),
- `SELECT COALESCE(qty_received, 0) FROM purchase_order_line WHERE id = $1`, lineID).Scan(&qty)
- return orm.Values{"qty_received": qty}, nil
+ `SELECT product_id, order_id FROM purchase_order_line WHERE id = $1`, lineID).Scan(&productID, &orderID)
+
+ if productID != nil && *productID > 0 {
+ var poName string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, orderID).Scan(&poName)
+
+ var received float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(sm.product_uom_qty), 0)
+ FROM stock_move sm
+ JOIN stock_picking sp ON sp.id = sm.picking_id
+ JOIN stock_location sl ON sl.id = sm.location_dest_id
+ WHERE sm.product_id = $1
+ AND sm.state = 'done'
+ AND sp.origin = $2
+ AND sl.usage = 'internal'`,
+ *productID, poName,
+ ).Scan(&received)
+ if received > 0 {
+ return orm.Values{"qty_received": received}, nil
+ }
+ }
+
+ return orm.Values{"qty_received": float64(0)}, nil
})
// _compute_price_subtotal and _compute_price_total for PO lines.
@@ -509,10 +1122,12 @@ func initPurchaseOrderLineExtension() {
return orm.Values{
"price_subtotal": subtotal,
+ "price_tax": taxTotal,
"price_total": subtotal + taxTotal,
}, nil
}
pol.RegisterCompute("price_subtotal", computePOLineAmount)
+ pol.RegisterCompute("price_tax", computePOLineAmount)
pol.RegisterCompute("price_total", computePOLineAmount)
// Onchange: product_id β name, price_unit
@@ -558,6 +1173,17 @@ func initPurchaseOrderLineExtension() {
})
}
+// initAccountMoveLinePurchaseExtension extends account.move.line with purchase_line_id.
+// Mirrors: odoo/addons/purchase/models/purchase_order_line.py (invoice_lines / purchase_line_id)
+func initAccountMoveLinePurchaseExtension() {
+ aml := orm.ExtendModel("account.move.line")
+ aml.AddFields(
+ orm.Many2one("purchase_line_id", "purchase.order.line", orm.FieldOpts{
+ String: "Purchase Order Line", Index: true,
+ }),
+ )
+}
+
// initResPartnerPurchaseExtension extends res.partner with purchase-specific fields.
// Mirrors: odoo/addons/purchase/models/res_partner.py
func initResPartnerPurchaseExtension() {
@@ -595,6 +1221,152 @@ func initResPartnerPurchaseExtension() {
})
}
+// initProductSupplierInfo registers product.supplierinfo β vendor pricelists for products.
+// Mirrors: odoo/addons/product/models/product_supplierinfo.py
+func initProductSupplierInfo() {
+ m := orm.NewModel("product.supplierinfo", orm.ModelOpts{
+ Description: "Supplier Pricelist",
+ Order: "min_qty asc, price asc, id",
+ })
+
+ m.AddFields(
+ orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
+ String: "Vendor", Required: true, Index: true,
+ Help: "Vendor of this product",
+ }),
+ orm.Many2one("product_tmpl_id", "product.template", orm.FieldOpts{
+ String: "Product Template", Index: true,
+ Help: "Product template this supplier price applies to",
+ }),
+ orm.Many2one("product_id", "product.product", orm.FieldOpts{
+ String: "Product Variant", Index: true,
+ Help: "Specific product variant (leave empty for all variants of the template)",
+ }),
+ orm.Float("min_qty", orm.FieldOpts{
+ String: "Minimum Quantity", Default: 0.0,
+ Help: "Minimum quantity to order from this vendor to get this price",
+ }),
+ orm.Float("price", orm.FieldOpts{
+ String: "Price", Required: true,
+ Help: "Vendor price for the specified quantity",
+ }),
+ orm.Integer("delay", orm.FieldOpts{
+ String: "Delivery Lead Time (Days)", Default: 1,
+ Help: "Number of days between order confirmation and reception",
+ }),
+ orm.Date("date_start", orm.FieldOpts{
+ String: "Start Date",
+ Help: "Start date for this vendor price validity",
+ }),
+ orm.Date("date_end", orm.FieldOpts{
+ String: "End Date",
+ Help: "End date for this vendor price validity",
+ }),
+ orm.Many2one("company_id", "res.company", orm.FieldOpts{
+ String: "Company", Index: true,
+ }),
+ orm.Many2one("currency_id", "res.currency", orm.FieldOpts{
+ String: "Currency",
+ }),
+ orm.Char("product_name", orm.FieldOpts{
+ String: "Vendor Product Name",
+ Help: "Product name used by the vendor",
+ }),
+ orm.Char("product_code", orm.FieldOpts{
+ String: "Vendor Product Code",
+ Help: "Product code used by the vendor",
+ }),
+ orm.Integer("sequence", orm.FieldOpts{
+ String: "Sequence", Default: 1,
+ }),
+ )
+
+ // _get_supplier_price: Look up the best price for a product + vendor + quantity.
+ // Finds the supplierinfo record with the highest min_qty that is <= the requested qty,
+ // filtered by vendor and product, respecting date validity.
+ // Mirrors: odoo/addons/product/models/product_supplierinfo.py ProductSupplierinfo._select_seller()
+ m.RegisterMethod("_get_supplier_price", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+
+ if len(args) < 3 {
+ return nil, fmt.Errorf("product.supplierinfo: _get_supplier_price requires (product_id, partner_id, quantity)")
+ }
+
+ var productID, partnerID int64
+ var quantity float64
+
+ switch v := args[0].(type) {
+ case float64:
+ productID = int64(v)
+ case int64:
+ productID = v
+ }
+ switch v := args[1].(type) {
+ case float64:
+ partnerID = int64(v)
+ case int64:
+ partnerID = v
+ }
+ switch v := args[2].(type) {
+ case float64:
+ quantity = v
+ case int64:
+ quantity = float64(v)
+ }
+
+ if productID == 0 || partnerID == 0 {
+ return nil, fmt.Errorf("product.supplierinfo: product_id and partner_id are required")
+ }
+
+ // Find the product template for this product variant
+ var productTmplID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(product_tmpl_id, 0) FROM product_product WHERE id = $1`,
+ productID).Scan(&productTmplID)
+
+ // Query: find the best matching supplierinfo record.
+ // Priority: exact product_id match > template match, highest min_qty <= requested qty,
+ // date validity respected, lowest price wins ties.
+ var bestPrice float64
+ var bestDelay int
+ var found bool
+
+ err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT si.price, COALESCE(si.delay, 1)
+ FROM product_supplierinfo si
+ WHERE si.partner_id = $1
+ AND (si.product_id = $2 OR (si.product_id IS NULL AND si.product_tmpl_id = $3))
+ AND COALESCE(si.min_qty, 0) <= $4
+ AND (si.date_start IS NULL OR si.date_start <= CURRENT_DATE)
+ AND (si.date_end IS NULL OR si.date_end >= CURRENT_DATE)
+ ORDER BY
+ CASE WHEN si.product_id = $2 THEN 0 ELSE 1 END,
+ si.min_qty DESC,
+ si.price ASC
+ LIMIT 1`,
+ partnerID, productID, productTmplID, quantity,
+ ).Scan(&bestPrice, &bestDelay)
+
+ if err == nil {
+ found = true
+ }
+
+ if !found {
+ return map[string]interface{}{
+ "found": false,
+ "price": float64(0),
+ "delay": 0,
+ }, nil
+ }
+
+ return map[string]interface{}{
+ "found": true,
+ "price": bestPrice,
+ "delay": bestDelay,
+ }, nil
+ })
+}
+
// initPurchaseOrderAmount extends purchase.order with amount compute functions.
// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder._compute_amount()
func initPurchaseOrderAmount() {
diff --git a/addons/purchase/models/purchase_order.go b/addons/purchase/models/purchase_order.go
index bcdd4ec..ba243e2 100644
--- a/addons/purchase/models/purchase_order.go
+++ b/addons/purchase/models/purchase_order.go
@@ -53,6 +53,13 @@ func initPurchaseOrder() {
}),
)
+ // -- Agreement Link --
+ m.AddFields(
+ orm.Many2one("requisition_id", "purchase.requisition", orm.FieldOpts{
+ String: "Purchase Agreement",
+ }),
+ )
+
// -- Company & Currency --
m.AddFields(
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 --
m.AddFields(
orm.Text("notes", orm.FieldOpts{String: "Terms and Conditions"}),
@@ -134,15 +147,84 @@ func initPurchaseOrder() {
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) {
+ 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()
for _, id := range rs.IDs() {
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM purchase_order WHERE id = $1`, id).Scan(&state)
- if state != "draft" && state != "sent" {
- return nil, fmt.Errorf("purchase: can only confirm draft orders")
+ if state != "to approve" {
+ return nil, fmt.Errorf("purchase: can only approve orders in 'to approve' state (current: %s)", state)
}
env.Tx().Exec(env.Ctx(),
`UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, id)
@@ -150,12 +232,31 @@ func initPurchaseOrder() {
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) {
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(),
- `UPDATE purchase_order SET state = 'cancel' WHERE id = $1`, id)
+ `UPDATE purchase_order SET state = 'cancel' WHERE id = $1`, poID)
}
return true, nil
})
@@ -170,36 +271,51 @@ func initPurchaseOrder() {
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()
- 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()
var billIDs []int64
for _, poID := range rs.IDs() {
var partnerID, companyID, currencyID int64
+ var poName string
+ var fiscalPosID, paymentTermID *int64
err := env.Tx().QueryRow(env.Ctx(),
- `SELECT partner_id, company_id, currency_id FROM purchase_order WHERE id = $1`,
- poID).Scan(&partnerID, &companyID, ¤cyID)
+ `SELECT partner_id, company_id, currency_id, COALESCE(name, ''),
+ fiscal_position_id, payment_term_id
+ FROM purchase_order WHERE id = $1`,
+ poID).Scan(&partnerID, &companyID, ¤cyID, &poName, &fiscalPosID, &paymentTermID)
if err != nil {
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
var journalID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_journal WHERE type = 'purchase' AND company_id = $1 LIMIT 1`,
companyID).Scan(&journalID)
if journalID == 0 {
- // Fallback: first available journal
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_journal WHERE company_id = $1 ORDER BY id LIMIT 1`,
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(),
- `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
WHERE order_id = $1 ORDER BY sequence, id`, poID)
if err != nil {
@@ -207,16 +323,20 @@ func initPurchaseOrder() {
}
type poLine struct {
- id int64
- name string
- qty float64
- price float64
- discount float64
+ id int64
+ name string
+ qty float64
+ price float64
+ discount float64
+ qtyInvoiced float64
+ productID *int64
+ displayType string
}
var lines []poLine
for rows.Next() {
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()
return nil, err
}
@@ -224,19 +344,43 @@ func initPurchaseOrder() {
}
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
var billID int64
err = env.Tx().QueryRow(env.Ctx(),
`INSERT INTO account_move
- (name, move_type, state, date, partner_id, journal_id, company_id, currency_id, invoice_origin)
- VALUES ('/', 'in_invoice', 'draft', NOW(), $1, $2, $3, $4, $5) RETURNING id`,
- partnerID, journalID, companyID, currencyID,
- fmt.Sprintf("PO%d", poID)).Scan(&billID)
+ (name, move_type, state, date, partner_id, journal_id, company_id,
+ currency_id, invoice_origin, fiscal_position_id, invoice_payment_term_id)
+ VALUES ('/', 'in_invoice', 'draft', NOW(), $1, $2, $3, $4, $5, $6, $7)
+ RETURNING id`,
+ partnerID, journalID, companyID, currencyID, invoiceOrigin,
+ fiscalPosID, paymentTermID).Scan(&billID)
if err != nil {
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")
if seqErr != nil {
seq, seqErr = orm.NextByCode(env, "account.move")
@@ -246,37 +390,58 @@ func initPurchaseOrder() {
`UPDATE account_move SET name = $1 WHERE id = $2`, seq, billID)
}
- // Create invoice lines for each PO line
- for _, l := range lines {
- subtotal := l.qty * l.price * (1 - l.discount/100)
+ // Create invoice lines for each invoiceable PO line
+ seq2 := 10
+ for _, l := range invoiceableLines {
+ qtyToInvoice := l.qty - l.qtyInvoiced
+ subtotal := qtyToInvoice * l.price * (1 - l.discount/100)
env.Tx().Exec(env.Ctx(),
`INSERT INTO account_move_line
(move_id, name, quantity, price_unit, discount, debit, credit, balance,
- display_type, company_id, journal_id, account_id)
- VALUES ($1, $2, $3, $4, $5, $6, 0, $6, 'product', $7, $8,
- COALESCE((SELECT id FROM account_account WHERE company_id = $7 LIMIT 1), 1))`,
- billID, l.name, l.qty, l.price, l.discount, subtotal,
- companyID, journalID)
+ display_type, company_id, journal_id, sequence, purchase_line_id, product_id,
+ account_id)
+ VALUES ($1, $2, $3, $4, $5, $6, 0, $6, 'product', $7, $8, $9, $10, $11,
+ COALESCE((SELECT id FROM account_account
+ 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
- for _, l := range lines {
+ for _, l := range invoiceableLines {
+ qtyToInvoice := l.qty - l.qtyInvoiced
env.Tx().Exec(env.Ctx(),
`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)
- // Update PO invoice_status
- _, err = env.Tx().Exec(env.Ctx(),
- `UPDATE purchase_order SET invoice_status = 'invoiced' WHERE id = $1`, poID)
- if err != nil {
- return nil, fmt.Errorf("purchase: update invoice status for PO %d: %w", poID, err)
+ // Recompute PO invoice_status based on lines
+ var totalQty, totalInvoiced float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(product_qty), 0), COALESCE(SUM(qty_invoiced), 0)
+ 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
- })
+ }
+ 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
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
@@ -293,6 +458,11 @@ func initPurchaseOrder() {
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
initPurchaseOrderLine()
}
@@ -333,6 +503,9 @@ func initPurchaseOrderLine() {
orm.Monetary("price_subtotal", orm.FieldOpts{
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{
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 --
m.AddFields(
orm.Datetime("date_planned", orm.FieldOpts{String: "Expected Arrival"}),
diff --git a/addons/sale/models/init.go b/addons/sale/models/init.go
index 531bb5a..e94d76c 100644
--- a/addons/sale/models/init.go
+++ b/addons/sale/models/init.go
@@ -10,6 +10,7 @@ func Init() {
initSaleOrderTemplate()
initSaleOrderTemplateLine()
initSaleOrderTemplateOption()
+ initSaleOrderOption()
initSaleReport()
initSaleOrderWarnMsg()
initSaleAdvancePaymentWizard()
diff --git a/addons/sale/models/sale_order.go b/addons/sale/models/sale_order.go
index abc128e..a25a88d 100644
--- a/addons/sale/models/sale_order.go
+++ b/addons/sale/models/sale_order.go
@@ -24,6 +24,7 @@ func initSaleOrder() {
{Value: "draft", Label: "Quotation"},
{Value: "sent", Label: "Quotation Sent"},
{Value: "sale", Label: "Sales Order"},
+ {Value: "done", Label: "Locked"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Readonly: true, Index: true}),
)
@@ -253,26 +254,82 @@ func initSaleOrder() {
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 --
// 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()
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
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(),
- `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 {
return nil, err
}
if state != "draft" && state != "sent" {
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(),
`UPDATE sale_order SET state = 'sale' WHERE id = $1`, id); err != nil {
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
})
@@ -305,7 +362,7 @@ func initSaleOrder() {
).Scan(&journalID)
}
if journalID == 0 {
- journalID = 1 // ultimate fallback
+ return nil, fmt.Errorf("sale: no sales journal found for company %d", companyID)
}
// Read SO lines
@@ -431,11 +488,17 @@ func initSaleOrder() {
"credit": 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)
}
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
taxRows, err := env.Tx().Query(env.Ctx(),
`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)
}
- // 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(),
- `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 {
@@ -613,6 +686,16 @@ func initSaleOrder() {
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.
// 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) {
@@ -959,11 +1042,24 @@ func initSaleOrderLine() {
orm.Monetary("price_subtotal", orm.FieldOpts{
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{
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 --
m.AddFields(
orm.Selection("display_type", []orm.SelectionItem{
@@ -1023,10 +1119,12 @@ func initSaleOrderLine() {
return orm.Values{
"price_subtotal": subtotal,
+ "price_tax": taxTotal,
"price_total": subtotal + taxTotal,
}, nil
}
m.RegisterCompute("price_subtotal", computeLineAmount)
+ m.RegisterCompute("price_tax", computeLineAmount)
m.RegisterCompute("price_total", computeLineAmount)
// -- Delivery & Invoicing Quantities --
diff --git a/addons/sale/models/sale_order_extend.go b/addons/sale/models/sale_order_extend.go
index 7469121..f7e1bc4 100644
--- a/addons/sale/models/sale_order_extend.go
+++ b/addons/sale/models/sale_order_extend.go
@@ -1,10 +1,14 @@
package models
import (
+ "encoding/json"
"fmt"
+ "html"
+ "log"
"time"
"odoo-go/pkg/orm"
+ "odoo-go/pkg/tools"
)
// 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{
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{
String: "Expired", Compute: "_compute_is_expired",
}),
@@ -52,6 +59,132 @@ func initSaleOrderExtension() {
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) --
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_invoice_status()
so.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) {
@@ -201,19 +334,238 @@ func initSaleOrderExtension() {
return nil, nil
})
- // _compute_amount_to_invoice: Compute total amount still to invoice.
- // 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) {
+ // Note: amount_to_invoice compute is already registered above (line ~90)
+
+ // ββ 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(
+ "| %s | %.2f | "+
+ "%.2f | "+
+ "%.1f%% | "+
+ "%.2f |
",
+ 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(`
+
%s
+
Dear %s,
+
Please find below your quotation %s.
+
+
+ | Description | Qty | Unit Price | Discount | Subtotal |
+
+ %s
+
+ | Total |
+ %.2f |
+
+
+
Do not hesitate to contact us if you have any questions.
+
`, 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()
soID := rs.IDs()[0]
- var total, invoiced float64
- env.Tx().QueryRow(env.Ctx(),
- `SELECT COALESCE(SUM(price_subtotal::float8), 0),
- COALESCE(SUM(qty_invoiced * price_unit * (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)
- return total - invoiced, nil
+
+ rows, err := env.Tx().Query(env.Ctx(),
+ `SELECT COALESCE(tg.name, t.name, 'Taxes') AS group_name,
+ SUM(
+ sol.product_uom_qty * sol.price_unit * (1 - COALESCE(sol.discount,0)/100)
+ * COALESCE(t.amount, 0) / 100
+ )::float8 AS tax_amount,
+ 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/.
+ // 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
})
- // _compute_qty_invoiced: Compute invoiced quantity from linked invoice lines.
- // 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]
- 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).
+ // _compute_qty_to_invoice: Quantity to invoice based on invoice policy.
+ // Note: qty_invoiced compute is registered later with full M2M-based logic.
+ // If invoice policy is 'order': product_uom_qty - qty_invoiced
+ // If invoice policy is 'delivery': qty_delivered - qty_invoiced
// 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) {
env := rs.Env()
lineID := rs.IDs()[0]
- var qty, qtyInvoiced float64
+ var qty, qtyDelivered, qtyInvoiced float64
+ var productID *int64
+ var orderState string
env.Tx().QueryRow(env.Ctx(),
- `SELECT COALESCE(product_uom_qty, 0), COALESCE(qty_invoiced, 0)
- FROM sale_order_line WHERE id = $1`, lineID,
- ).Scan(&qty, &qtyInvoiced)
- toInvoice := qty - qtyInvoiced
+ `SELECT COALESCE(sol.product_uom_qty, 0), COALESCE(sol.qty_delivered, 0),
+ COALESCE(sol.qty_invoiced, 0), sol.product_id,
+ COALESCE(so.state, 'draft')
+ 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 {
toInvoice = 0
}
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.
+// Enhanced with discount_type: percentage or fixed_amount.
// Mirrors: odoo/addons/sale/wizard/sale_order_discount.py
func initSaleOrderDiscount() {
m := orm.NewModel("sale.order.discount", orm.ModelOpts{
@@ -386,33 +1103,76 @@ func initSaleOrderDiscount() {
})
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"}),
)
// 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()
m.RegisterMethod("action_apply_discount", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
wizID := rs.IDs()[0]
- var discount float64
+ var discountVal float64
var orderID int64
+ var discountType string
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,
- ).Scan(&discount, &orderID)
+ ).Scan(&discountVal, &orderID, &discountType)
if orderID == 0 {
return nil, fmt.Errorf("sale_discount: no sale order linked")
}
- _, 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')`,
- discount, orderID)
- if err != nil {
- return nil, fmt.Errorf("sale_discount: apply discount: %w", err)
+ switch discountType {
+ case "fixed_amount":
+ // Distribute fixed amount evenly across product lines as a percentage
+ // Calculate total undiscounted line amount to determine per-line discount %
+ var totalAmount float64
+ err := env.Tx().QueryRow(env.Ctx(),
+ `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
@@ -441,3 +1201,5 @@ func initResPartnerSaleExtension2() {
return orm.Values{"sale_order_total": total}, nil
})
}
+
+func htmlEscapeStr(s string) string { return html.EscapeString(s) }
diff --git a/addons/sale/models/sale_template.go b/addons/sale/models/sale_template.go
index 0c3f213..589385a 100644
--- a/addons/sale/models/sale_template.go
+++ b/addons/sale/models/sale_template.go
@@ -124,6 +124,42 @@ func initSaleOrderTemplate() {
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
})
@@ -290,3 +326,94 @@ func initSaleOrderTemplateOption() {
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")
+ })
+}
diff --git a/addons/stock/models/init.go b/addons/stock/models/init.go
index b725fff..36b87b6 100644
--- a/addons/stock/models/init.go
+++ b/addons/stock/models/init.go
@@ -2,4 +2,5 @@ package models
func Init() {
initStock()
+ initStockIntrastat()
}
diff --git a/addons/stock/models/stock.go b/addons/stock/models/stock.go
index 31d2efe..c4e2709 100644
--- a/addons/stock/models/stock.go
+++ b/addons/stock/models/stock.go
@@ -2,6 +2,7 @@ package models
import (
"fmt"
+ "log"
"time"
"odoo-go/pkg/orm"
@@ -191,6 +192,19 @@ func initStockPicking() {
}),
orm.Text("note", orm.FieldOpts{String: "Notes"}),
orm.Char("origin", orm.FieldOpts{String: "Source Document", Index: true}),
+ orm.Boolean("is_locked", orm.FieldOpts{String: "Is Locked", Default: true}),
+ orm.Boolean("show_check_availability", orm.FieldOpts{
+ String: "Show Check Availability", Compute: "_compute_show_check_availability",
+ }),
+ orm.Boolean("show_validate", orm.FieldOpts{
+ String: "Show Validate", Compute: "_compute_show_validate",
+ }),
+ orm.Many2one("backorder_id", "stock.picking", orm.FieldOpts{
+ String: "Back Order of", Index: true,
+ }),
+ orm.Boolean("has_tracking", orm.FieldOpts{
+ String: "Has Tracking", Compute: "_compute_has_tracking",
+ }),
)
// --- BeforeCreate hook: auto-generate picking reference ---
@@ -198,15 +212,30 @@ func initStockPicking() {
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
name, _ := vals["name"].(string)
if name == "" || name == "/" {
- vals["name"] = fmt.Sprintf("WH/IN/%05d", time.Now().UnixNano()%100000)
+ seq, err := orm.NextByCode(env, "stock.picking")
+ if err != nil || seq == "" {
+ // Fallback: use DB sequence for guaranteed uniqueness
+ var nextVal int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT nextval(pg_get_serial_sequence('stock_picking', 'id'))`).Scan(&nextVal)
+ vals["name"] = fmt.Sprintf("WH/PICK/%05d", nextVal)
+ } else {
+ vals["name"] = seq
+ }
}
return nil
}
+ // --- BeforeWrite hook: prevent modifications on done & locked transfers ---
+ m.BeforeWrite = orm.StateGuard("stock_picking", "state = 'done' AND is_locked = true",
+ []string{"write_uid", "write_date", "is_locked", "message_partner_ids_count"},
+ "cannot modify done & locked transfers β unlock first")
+
// --- Business methods: stock move workflow ---
// action_confirm transitions a picking from draft β confirmed.
- // Confirms all associated stock moves via _action_confirm (which also reserves).
+ // Confirms all associated stock moves via _action_confirm (which also reserves),
+ // then recomputes picking state based on resulting move states.
// Mirrors: stock.picking.action_confirm()
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
@@ -220,11 +249,6 @@ func initStockPicking() {
if state != "draft" {
return nil, fmt.Errorf("stock: can only confirm draft pickings (picking %d is %q)", id, state)
}
- _, err = env.Tx().Exec(env.Ctx(),
- `UPDATE stock_picking SET state = 'confirmed' WHERE id = $1`, id)
- if err != nil {
- return nil, fmt.Errorf("stock: confirm picking %d: %w", id, err)
- }
// Confirm all draft moves via _action_confirm (which also tries to reserve)
rows, err := env.Tx().Query(env.Ctx(),
@@ -258,35 +282,23 @@ func initStockPicking() {
}
}
- // Update picking state based on move states after reservation
- var allAssigned bool
- err = env.Tx().QueryRow(env.Ctx(),
- `SELECT NOT EXISTS(
- SELECT 1 FROM stock_move
- WHERE picking_id = $1 AND state NOT IN ('assigned', 'done', 'cancel')
- )`, id).Scan(&allAssigned)
- if err != nil {
- return nil, fmt.Errorf("stock: check move states for picking %d: %w", id, err)
- }
- if allAssigned {
- _, err = env.Tx().Exec(env.Ctx(),
- `UPDATE stock_picking SET state = 'assigned' WHERE id = $1`, id)
- if err != nil {
- return nil, fmt.Errorf("stock: update picking %d to assigned: %w", id, err)
- }
+ // Recompute picking state from move states
+ if err := updatePickingStateFromMoves(env, id); err != nil {
+ return nil, fmt.Errorf("stock: update picking %d state after confirm: %w", id, err)
}
}
return true, nil
})
- // action_assign reserves stock for all confirmed/partially_available moves on the picking.
+ // action_assign reserves stock for all confirmed/waiting/partially_available moves on the picking.
+ // Delegates to stock.move._action_assign() then recomputes picking state.
// Mirrors: stock.picking.action_assign()
m.RegisterMethod("action_assign", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, pickingID := range rs.IDs() {
- // Get moves that need reservation
+ // Get moves that need reservation (including waiting state)
rows, err := env.Tx().Query(env.Ctx(),
- `SELECT id FROM stock_move WHERE picking_id = $1 AND state IN ('confirmed', 'partially_available')`, pickingID)
+ `SELECT id FROM stock_move WHERE picking_id = $1 AND state IN ('confirmed', 'waiting', 'partially_available')`, pickingID)
if err != nil {
return nil, fmt.Errorf("stock: read moves for assign picking %d: %w", pickingID, err)
}
@@ -316,45 +328,145 @@ func initStockPicking() {
}
}
- // Update picking state based on move states
- var allAssigned bool
- err = env.Tx().QueryRow(env.Ctx(),
- `SELECT NOT EXISTS(
- SELECT 1 FROM stock_move
- WHERE picking_id = $1 AND state NOT IN ('assigned', 'done', 'cancel')
- )`, pickingID).Scan(&allAssigned)
- if err != nil {
- return nil, fmt.Errorf("stock: check move states for picking %d: %w", pickingID, err)
- }
- if allAssigned {
- _, err = env.Tx().Exec(env.Ctx(),
- `UPDATE stock_picking SET state = 'assigned' WHERE id = $1`, pickingID)
- }
- if err != nil {
- return nil, fmt.Errorf("stock: update picking %d state: %w", pickingID, err)
+ // Recompute picking state from move states
+ if err := updatePickingStateFromMoves(env, pickingID); err != nil {
+ return nil, fmt.Errorf("stock: update picking %d state after assign: %w", pickingID, err)
}
}
return true, nil
})
+ // _update_state_from_move_lines: Recompute picking state when move line quantities change.
+ // If all moves done β done, all cancelled β cancel, mix β assigned/confirmed.
+ // Mirrors: stock.picking._compute_state() triggered by move line changes
+ m.RegisterMethod("_update_state_from_move_lines", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, pickingID := range rs.IDs() {
+ if err := updatePickingStateFromMoves(env, pickingID); err != nil {
+ return nil, fmt.Errorf("stock.picking: _update_state_from_move_lines for %d: %w", pickingID, err)
+ }
+ }
+ return true, nil
+ })
+
+ // _action_split_picking: Split a picking into two.
+ // Moves with qty_done > 0 stay in the current picking, moves without qty_done
+ // are moved to a new picking. Returns the new picking ID.
+ // Mirrors: stock.picking._action_split() / split wizard
+ m.RegisterMethod("_action_split_picking", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ pickingID := rs.IDs()[0]
+
+ // Find moves WITH qty_done (stay) and WITHOUT qty_done (go to new picking)
+ rows, err := env.Tx().Query(env.Ctx(),
+ `SELECT sm.id,
+ COALESCE((SELECT SUM(sml.quantity) FROM stock_move_line sml WHERE sml.move_id = sm.id), 0) as qty_done
+ FROM stock_move sm
+ WHERE sm.picking_id = $1 AND sm.state NOT IN ('done', 'cancel')`,
+ pickingID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: query moves for split %d: %w", pickingID, err)
+ }
+
+ var movesWithDone, movesWithoutDone []int64
+ for rows.Next() {
+ var moveID int64
+ var qtyDone float64
+ if err := rows.Scan(&moveID, &qtyDone); err != nil {
+ rows.Close()
+ return nil, fmt.Errorf("stock.picking: scan move for split: %w", err)
+ }
+ if qtyDone > 0.005 {
+ movesWithDone = append(movesWithDone, moveID)
+ } else {
+ movesWithoutDone = append(movesWithoutDone, moveID)
+ }
+ }
+ rows.Close()
+
+ if len(movesWithoutDone) == 0 {
+ return map[string]interface{}{"split": false, "message": "All moves have qty_done, nothing to split"}, nil
+ }
+ if len(movesWithDone) == 0 {
+ return map[string]interface{}{"split": false, "message": "No moves have qty_done, nothing to split"}, nil
+ }
+
+ // Read original picking data
+ var name, origin string
+ var pickTypeID, locID, locDestID, companyID int64
+ var partnerID *int64
+ err = env.Tx().QueryRow(env.Ctx(),
+ `SELECT name, COALESCE(origin,''), picking_type_id,
+ location_id, location_dest_id, company_id, partner_id
+ FROM stock_picking WHERE id = $1`, pickingID,
+ ).Scan(&name, &origin, &pickTypeID, &locID, &locDestID, &companyID, &partnerID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: read picking %d for split: %w", pickingID, err)
+ }
+
+ // Create new picking for moves without qty_done
+ newVals := orm.Values{
+ "name": fmt.Sprintf("%s-SPLIT", name),
+ "picking_type_id": pickTypeID,
+ "location_id": locID,
+ "location_dest_id": locDestID,
+ "company_id": companyID,
+ "origin": origin,
+ "backorder_id": pickingID,
+ "state": "draft",
+ "scheduled_date": time.Now().Format("2006-01-02"),
+ }
+ if partnerID != nil {
+ newVals["partner_id"] = *partnerID
+ }
+ newPicking, err := env.Model("stock.picking").Create(newVals)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: create split picking for %d: %w", pickingID, err)
+ }
+
+ // Move the no-qty-done moves to the new picking
+ for _, moveID := range movesWithoutDone {
+ _, err := env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_move SET picking_id = $1 WHERE id = $2`, newPicking.ID(), moveID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: move %d to split picking: %w", moveID, err)
+ }
+ // Also update move lines
+ _, _ = env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_move_line SET picking_id = $1 WHERE move_id = $2`, newPicking.ID(), moveID)
+ }
+
+ return map[string]interface{}{
+ "split": true,
+ "new_picking_id": newPicking.ID(),
+ "kept_moves": len(movesWithDone),
+ "split_moves": len(movesWithoutDone),
+ }, nil
+ })
+
// action_cancel: Cancel a picking and all its moves.
// Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking.action_cancel()
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, pickingID := range rs.IDs() {
- env.Tx().Exec(env.Ctx(), `UPDATE stock_move SET state = 'cancel' WHERE picking_id = $1`, pickingID)
- env.Tx().Exec(env.Ctx(), `UPDATE stock_picking SET state = 'cancel' WHERE id = $1`, pickingID)
+ if _, err := env.Tx().Exec(env.Ctx(), `UPDATE stock_move SET state = 'cancel' WHERE picking_id = $1`, pickingID); err != nil {
+ return nil, fmt.Errorf("stock.picking: cancel moves for %d: %w", pickingID, err)
+ }
+ if _, err := env.Tx().Exec(env.Ctx(), `UPDATE stock_picking SET state = 'cancel' WHERE id = $1`, pickingID); err != nil {
+ return nil, fmt.Errorf("stock.picking: cancel picking %d: %w", pickingID, err)
+ }
}
return true, nil
})
// button_validate transitions a picking β done via _action_done on its moves.
+ // Checks if all quantities are done; if not, creates a backorder for remaining.
// Properly updates quants and clears reservations.
// Mirrors: stock.picking.button_validate()
m.RegisterMethod("button_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, pickingID := range rs.IDs() {
- // Get all non-cancelled moves for this picking
+ // Step 1: Check if there are any non-cancelled moves
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id FROM stock_move WHERE picking_id = $1 AND state != 'cancel'`, pickingID)
if err != nil {
@@ -378,18 +490,68 @@ func initStockPicking() {
continue
}
- // Call _action_done on all moves
- moveRS := env.Model("stock.move").Browse(moveIDs...)
- moveModel := orm.Registry.Get("stock.move")
- if moveModel != nil {
- if doneMethod, ok := moveModel.Methods["_action_done"]; ok {
- if _, err := doneMethod(moveRS); err != nil {
- return nil, fmt.Errorf("stock: action_done for picking %d: %w", pickingID, err)
+ // Step 1b: Enforce serial/lot tracking β reject if required lot is missing
+ lotErr := enforceSerialLotTracking(env, pickingID)
+ if lotErr != nil {
+ return nil, lotErr
+ }
+
+ // Step 2: Check if any move has remaining qty (demand > qty_done)
+ var hasRemaining bool
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT EXISTS(
+ SELECT 1 FROM stock_move sm
+ WHERE sm.picking_id = $1 AND sm.state NOT IN ('done', 'cancel')
+ AND sm.product_uom_qty > COALESCE(
+ (SELECT SUM(sml.quantity) FROM stock_move_line sml WHERE sml.move_id = sm.id), 0
+ ) + 0.005
+ )`, pickingID,
+ ).Scan(&hasRemaining)
+
+ // Step 3: If partial, create backorder for remaining quantities
+ if hasRemaining {
+ pickModel := orm.Registry.Get("stock.picking")
+ if pickModel != nil {
+ if boMethod, ok := pickModel.Methods["_create_backorder"]; ok {
+ pickRS := env.Model("stock.picking").Browse(pickingID)
+ if _, err := boMethod(pickRS); err != nil {
+ return nil, fmt.Errorf("stock: create backorder for picking %d: %w", pickingID, err)
+ }
}
}
}
- // Update picking state
+ // Step 4: Re-read move IDs (demand may have been adjusted by backorder)
+ rows2, err := env.Tx().Query(env.Ctx(),
+ `SELECT id FROM stock_move WHERE picking_id = $1 AND state NOT IN ('done', 'cancel')`, pickingID)
+ if err != nil {
+ return nil, fmt.Errorf("stock: re-read moves for picking %d: %w", pickingID, err)
+ }
+ var activeMoveIDs []int64
+ for rows2.Next() {
+ var id int64
+ if err := rows2.Scan(&id); err != nil {
+ rows2.Close()
+ return nil, fmt.Errorf("stock: scan active move for picking %d: %w", pickingID, err)
+ }
+ activeMoveIDs = append(activeMoveIDs, id)
+ }
+ rows2.Close()
+
+ // Step 5: Call _action_done on all active moves
+ if len(activeMoveIDs) > 0 {
+ moveRS := env.Model("stock.move").Browse(activeMoveIDs...)
+ moveModel := orm.Registry.Get("stock.move")
+ if moveModel != nil {
+ if doneMethod, ok := moveModel.Methods["_action_done"]; ok {
+ if _, err := doneMethod(moveRS); err != nil {
+ return nil, fmt.Errorf("stock: action_done for picking %d: %w", pickingID, err)
+ }
+ }
+ }
+ }
+
+ // Step 6: Update picking state to done
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_picking SET state = 'done', date_done = NOW() WHERE id = $1`, pickingID)
if err != nil {
@@ -399,6 +561,414 @@ func initStockPicking() {
return true, nil
})
+ // do_unreserve: Un-reserve all moves on a picking, reset state to confirmed.
+ // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking.do_unreserve()
+ m.RegisterMethod("do_unreserve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, pickingID := range rs.IDs() {
+ // Clear reserved quantities on move lines
+ env.Tx().Exec(env.Ctx(),
+ `DELETE FROM stock_move_line WHERE move_id IN (SELECT id FROM stock_move WHERE picking_id = $1 AND state NOT IN ('done','cancel'))`, pickingID)
+ // Reset moves to confirmed
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_move SET state = 'confirmed', reserved_availability = 0 WHERE picking_id = $1 AND state NOT IN ('done','cancel')`, pickingID)
+ // Reset picking state to confirmed
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_picking SET state = 'confirmed' WHERE id = $1 AND state NOT IN ('done','cancel')`, pickingID)
+ }
+ return true, nil
+ })
+
+ // _compute_state: Compute picking state from move states.
+ // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking._compute_state()
+ m.RegisterCompute("state", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ pickingID := rs.IDs()[0]
+
+ // Count moves by state
+ var total, draftCount, cancelCount, doneCount, assignedCount int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COUNT(*),
+ COUNT(*) FILTER (WHERE state = 'draft'),
+ COUNT(*) FILTER (WHERE state = 'cancel'),
+ COUNT(*) FILTER (WHERE state = 'done'),
+ COUNT(*) FILTER (WHERE state = 'assigned')
+ FROM stock_move WHERE picking_id = $1`, pickingID,
+ ).Scan(&total, &draftCount, &cancelCount, &doneCount, &assignedCount)
+
+ if total == 0 || draftCount > 0 {
+ return orm.Values{"state": "draft"}, nil
+ }
+ if cancelCount == total {
+ return orm.Values{"state": "cancel"}, nil
+ }
+ if doneCount+cancelCount == total {
+ return orm.Values{"state": "done"}, nil
+ }
+ if assignedCount+doneCount+cancelCount == total {
+ return orm.Values{"state": "assigned"}, nil
+ }
+ return orm.Values{"state": "confirmed"}, nil
+ })
+
+ // _compute_show_check_availability: Show button when moves need reservation.
+ // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking._compute_show_check_availability()
+ m.RegisterCompute("show_check_availability", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ pickingID := rs.IDs()[0]
+
+ var state string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(state, 'draft') FROM stock_picking WHERE id = $1`, pickingID,
+ ).Scan(&state)
+
+ if state == "done" || state == "cancel" || state == "draft" {
+ return orm.Values{"show_check_availability": false}, nil
+ }
+
+ // Show if any move is not fully reserved
+ var needsReservation bool
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT EXISTS(
+ SELECT 1 FROM stock_move
+ WHERE picking_id = $1 AND state IN ('confirmed', 'partially_available', 'waiting')
+ )`, pickingID,
+ ).Scan(&needsReservation)
+
+ return orm.Values{"show_check_availability": needsReservation}, nil
+ })
+
+ // _compute_show_validate: Show validate button when picking can be validated.
+ // Mirrors: odoo/addons/stock/models/stock_picking.py
+ m.RegisterCompute("show_validate", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ pickingID := rs.IDs()[0]
+
+ var state string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(state, 'draft') FROM stock_picking WHERE id = $1`, pickingID,
+ ).Scan(&state)
+
+ show := state != "done" && state != "cancel" && state != "draft"
+ return orm.Values{"show_validate": show}, nil
+ })
+
+ // _compute_has_tracking: Check if any move has lot/serial tracking.
+ // Mirrors: stock.picking._compute_has_tracking()
+ m.RegisterCompute("has_tracking", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ pickingID := rs.IDs()[0]
+
+ var hasTracking bool
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT EXISTS(
+ SELECT 1 FROM stock_move sm
+ JOIN product_product pp ON pp.id = sm.product_id
+ JOIN product_template pt ON pt.id = pp.product_tmpl_id
+ WHERE sm.picking_id = $1 AND pt.tracking != 'none'
+ )`, pickingID,
+ ).Scan(&hasTracking)
+
+ return orm.Values{"has_tracking": hasTracking}, nil
+ })
+
+ // action_set_quantities_to_reservation: Set done qty = reserved qty on all moves.
+ // Mirrors: stock.picking.action_set_quantities_to_reservation()
+ m.RegisterMethod("action_set_quantities_to_reservation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, pickingID := range rs.IDs() {
+ // For each non-done/cancel move, set move line quantities to match reservations
+ _, err := env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_move_line SET quantity = reserved_quantity
+ WHERE move_id IN (
+ SELECT id FROM stock_move WHERE picking_id = $1 AND state NOT IN ('done', 'cancel')
+ ) AND reserved_quantity > 0`, pickingID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: action_set_quantities_to_reservation for %d: %w", pickingID, err)
+ }
+
+ // For moves without move lines, create a move line with demand as quantity
+ rows, err := env.Tx().Query(env.Ctx(),
+ `SELECT sm.id, sm.product_id, sm.product_uom, sm.product_uom_qty,
+ sm.location_id, sm.location_dest_id, sm.company_id
+ FROM stock_move sm
+ WHERE sm.picking_id = $1
+ AND sm.state IN ('assigned', 'partially_available')
+ AND NOT EXISTS (SELECT 1 FROM stock_move_line WHERE move_id = sm.id)`,
+ pickingID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: query moves without lines for %d: %w", pickingID, err)
+ }
+
+ type moveInfo struct {
+ ID, ProductID, UomID, LocationID, LocationDestID, CompanyID int64
+ Qty float64
+ }
+ var moves []moveInfo
+ for rows.Next() {
+ var mi moveInfo
+ if err := rows.Scan(&mi.ID, &mi.ProductID, &mi.UomID, &mi.Qty, &mi.LocationID, &mi.LocationDestID, &mi.CompanyID); err != nil {
+ rows.Close()
+ return nil, fmt.Errorf("stock.picking: scan move for set qty: %w", err)
+ }
+ moves = append(moves, mi)
+ }
+ rows.Close()
+
+ for _, mi := range moves {
+ _, err = env.Tx().Exec(env.Ctx(),
+ `INSERT INTO stock_move_line
+ (move_id, product_id, product_uom_id, location_id, location_dest_id, quantity, company_id, date)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
+ mi.ID, mi.ProductID, mi.UomID, mi.LocationID, mi.LocationDestID, mi.Qty, mi.CompanyID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: create move line for set qty move %d: %w", mi.ID, err)
+ }
+ }
+ }
+ return true, nil
+ })
+
+ // _check_entire_pack: Validate package completeness.
+ // Mirrors: stock.picking._check_entire_pack()
+ // Stub returning true β full package validation would require complete package quant tracking.
+ m.RegisterMethod("_check_entire_pack", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ return true, nil
+ })
+
+ // _compute_entire_package_ids: Compute related package IDs for the picking.
+ // Mirrors: stock.picking._compute_entire_package_ids()
+ m.RegisterMethod("_compute_entire_package_ids", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ pickingID := rs.IDs()[0]
+
+ rows, err := env.Tx().Query(env.Ctx(),
+ `SELECT DISTINCT sml.package_id FROM stock_move_line sml
+ JOIN stock_move sm ON sm.id = sml.move_id
+ WHERE sm.picking_id = $1 AND sml.package_id IS NOT NULL`, pickingID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: compute entire_package_ids for %d: %w", pickingID, err)
+ }
+ defer rows.Close()
+
+ var packageIDs []int64
+ for rows.Next() {
+ var pkgID int64
+ if err := rows.Scan(&pkgID); err != nil {
+ return nil, fmt.Errorf("stock.picking: scan package_id: %w", err)
+ }
+ packageIDs = append(packageIDs, pkgID)
+ }
+ return packageIDs, nil
+ })
+
+ // _create_backorder: Create a backorder picking for remaining unprocessed quantities.
+ // Copies undone move lines to a new picking linked via backorder_id.
+ // Mirrors: stock.picking._create_backorder()
+ m.RegisterMethod("_create_backorder", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ pickingID := rs.IDs()[0]
+
+ // Read original picking data
+ var name, state, origin string
+ var pickTypeID, locID, locDestID, companyID int64
+ var partnerID *int64
+ err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT name, COALESCE(state,'draft'), COALESCE(origin,''), picking_type_id,
+ location_id, location_dest_id, company_id, partner_id
+ FROM stock_picking WHERE id = $1`, pickingID,
+ ).Scan(&name, &state, &origin, &pickTypeID, &locID, &locDestID, &companyID, &partnerID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: read picking %d for backorder: %w", pickingID, err)
+ }
+
+ // Find moves where quantity_done < demand (partially done or not done)
+ rows, err := env.Tx().Query(env.Ctx(),
+ `SELECT sm.id, sm.product_id, sm.product_uom_qty, sm.product_uom,
+ sm.location_id, sm.location_dest_id, sm.company_id, sm.name,
+ COALESCE((SELECT SUM(sml.quantity) FROM stock_move_line sml WHERE sml.move_id = sm.id), 0) as qty_done
+ FROM stock_move sm
+ WHERE sm.picking_id = $1 AND sm.state NOT IN ('done', 'cancel')`,
+ pickingID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: query moves for backorder %d: %w", pickingID, err)
+ }
+
+ type backorderMove struct {
+ ID, ProductID, UomID, LocID, LocDestID, CompanyID int64
+ Demand, QtyDone float64
+ Name string
+ }
+ var movesToBackorder []backorderMove
+ for rows.Next() {
+ var bm backorderMove
+ if err := rows.Scan(&bm.ID, &bm.ProductID, &bm.Demand, &bm.UomID,
+ &bm.LocID, &bm.LocDestID, &bm.CompanyID, &bm.Name, &bm.QtyDone); err != nil {
+ rows.Close()
+ return nil, fmt.Errorf("stock.picking: scan backorder move: %w", err)
+ }
+ remaining := bm.Demand - bm.QtyDone
+ if remaining > 0.005 { // Float tolerance
+ movesToBackorder = append(movesToBackorder, bm)
+ }
+ }
+ rows.Close()
+
+ if len(movesToBackorder) == 0 {
+ return nil, nil // Nothing to backorder
+ }
+
+ // Create backorder picking
+ boVals := orm.Values{
+ "name": fmt.Sprintf("%s-BO", name),
+ "picking_type_id": pickTypeID,
+ "location_id": locID,
+ "location_dest_id": locDestID,
+ "company_id": companyID,
+ "origin": origin,
+ "backorder_id": pickingID,
+ "state": "draft",
+ "scheduled_date": time.Now().Format("2006-01-02"),
+ }
+ if partnerID != nil {
+ boVals["partner_id"] = *partnerID
+ }
+
+ boPicking, err := env.Model("stock.picking").Create(boVals)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: create backorder for %d: %w", pickingID, err)
+ }
+
+ // Create moves in the backorder for the remaining quantities
+ moveRS := env.Model("stock.move")
+ for _, bm := range movesToBackorder {
+ remaining := bm.Demand - bm.QtyDone
+ _, err := moveRS.Create(orm.Values{
+ "name": bm.Name,
+ "product_id": bm.ProductID,
+ "product_uom_qty": remaining,
+ "product_uom": bm.UomID,
+ "location_id": bm.LocID,
+ "location_dest_id": bm.LocDestID,
+ "picking_id": boPicking.ID(),
+ "company_id": bm.CompanyID,
+ "state": "draft",
+ "date": time.Now(),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: create backorder move for %d: %w", bm.ID, err)
+ }
+
+ // Reduce demand on original move to match qty_done
+ if bm.QtyDone > 0 {
+ _, err = env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_move SET product_uom_qty = $1 WHERE id = $2`,
+ bm.QtyDone, bm.ID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: reduce demand on move %d: %w", bm.ID, err)
+ }
+ }
+ }
+
+ return map[string]interface{}{
+ "backorder_id": boPicking.ID(),
+ }, nil
+ })
+
+ // action_generate_backorder_wizard: When not all qty is done, decide on backorder.
+ // In Python Odoo this opens a wizard; here we auto-create the backorder.
+ // Mirrors: stock.picking.action_generate_backorder_wizard()
+ m.RegisterMethod("action_generate_backorder_wizard", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ pickingID := rs.IDs()[0]
+
+ // Check if all quantities are done
+ var hasRemaining bool
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT EXISTS(
+ SELECT 1 FROM stock_move sm
+ WHERE sm.picking_id = $1 AND sm.state NOT IN ('done', 'cancel')
+ AND sm.product_uom_qty > COALESCE(
+ (SELECT SUM(sml.quantity) FROM stock_move_line sml WHERE sml.move_id = sm.id), 0
+ ) + 0.005
+ )`, pickingID,
+ ).Scan(&hasRemaining)
+
+ if !hasRemaining {
+ return map[string]interface{}{"backorder_needed": false}, nil
+ }
+
+ // Create the backorder
+ pickModel := orm.Registry.Get("stock.picking")
+ if pickModel != nil {
+ if boMethod, ok := pickModel.Methods["_create_backorder"]; ok {
+ result, err := boMethod(rs)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]interface{}{
+ "backorder_needed": true,
+ "backorder": result,
+ }, nil
+ }
+ }
+
+ return map[string]interface{}{"backorder_needed": true}, nil
+ })
+
+ // action_back_to_draft: Reset a cancelled picking back to draft.
+ // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking.action_back_to_draft()
+ m.RegisterMethod("action_back_to_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, pickingID := range rs.IDs() {
+ var state string
+ err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT state FROM stock_picking WHERE id = $1`, pickingID,
+ ).Scan(&state)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: read state for %d: %w", pickingID, err)
+ }
+ if state != "cancel" {
+ return nil, fmt.Errorf("stock.picking: can only reset cancelled pickings to draft (picking %d is %q)", pickingID, state)
+ }
+
+ // Reset moves to draft
+ _, err = env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_move SET state = 'draft' WHERE picking_id = $1 AND state = 'cancel'`, pickingID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: reset moves to draft for %d: %w", pickingID, err)
+ }
+
+ // Reset picking to draft
+ _, err = env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_picking SET state = 'draft' WHERE id = $1`, pickingID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: reset to draft for %d: %w", pickingID, err)
+ }
+ }
+ return true, nil
+ })
+
+ // action_toggle_is_locked: Toggle the is_locked boolean for editing done pickings.
+ // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking.action_toggle_is_locked()
+ m.RegisterMethod("action_toggle_is_locked", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ for _, pickingID := range rs.IDs() {
+ _, err := env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_picking SET is_locked = NOT COALESCE(is_locked, true) WHERE id = $1`, pickingID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.picking: toggle is_locked for %d: %w", pickingID, err)
+ }
+ }
+ return true, nil
+ })
+
+ // send_receipt: Stub that returns true (for receipt email button).
+ // Mirrors: odoo/addons/stock/models/stock_picking.py StockPicking.send_receipt()
+ m.RegisterMethod("send_receipt", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ return true, nil
+ })
+
// action_return creates a reverse transfer (return picking) with swapped locations.
// Copies all done moves from the original picking to the return picking.
// Mirrors: odoo/addons/stock/wizard/stock_picking_return.py
@@ -484,22 +1054,14 @@ func initStockPicking() {
// updateQuant adjusts the on-hand quantity for a product at a location.
// If no quant row exists yet it inserts one; otherwise it updates in place.
func updateQuant(env *orm.Environment, productID, locationID int64, delta float64) error {
- var exists bool
- err := env.Tx().QueryRow(env.Ctx(),
- `SELECT EXISTS(SELECT 1 FROM stock_quant WHERE product_id = $1 AND location_id = $2)`,
- productID, locationID).Scan(&exists)
- if err != nil {
- return err
- }
- if exists {
- _, err = env.Tx().Exec(env.Ctx(),
- `UPDATE stock_quant SET quantity = quantity + $1 WHERE product_id = $2 AND location_id = $3`,
- delta, productID, locationID)
- } else {
- _, err = env.Tx().Exec(env.Ctx(),
- `INSERT INTO stock_quant (product_id, location_id, quantity, reserved_quantity, company_id) VALUES ($1, $2, $3, 0, 1)`,
- productID, locationID, delta)
- }
+ // Atomic upsert β eliminates TOCTOU race condition between concurrent transactions.
+ // Uses INSERT ON CONFLICT to avoid separate SELECT+UPDATE/INSERT.
+ _, err := env.Tx().Exec(env.Ctx(),
+ `INSERT INTO stock_quant (product_id, location_id, quantity, reserved_quantity, company_id)
+ VALUES ($1, $2, $3, 0, 1)
+ ON CONFLICT (product_id, location_id)
+ DO UPDATE SET quantity = stock_quant.quantity + $3`,
+ productID, locationID, delta)
return err
}
@@ -534,6 +1096,14 @@ func assignMove(env *orm.Environment, moveID int64) error {
return nil
}
+ // Skip if move already has reservation lines (prevent duplicates)
+ var existingLines int
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COUNT(*) FROM stock_move_line WHERE move_id = $1`, moveID).Scan(&existingLines)
+ if existingLines > 0 {
+ return nil
+ }
+
// Check available quantity in source location
available := getAvailableQty(env, productID, locationID)
@@ -608,6 +1178,8 @@ func initStockMove() {
String: "Product", Required: true, Index: true,
}),
orm.Float("product_uom_qty", orm.FieldOpts{String: "Demand", Required: true, Default: 1.0}),
+ orm.Float("quantity_done", orm.FieldOpts{String: "Quantity Done", Compute: "_compute_quantity_done"}),
+ orm.Float("reserved_availability", orm.FieldOpts{String: "Forecast Availability"}),
orm.Many2one("product_uom", "uom.uom", orm.FieldOpts{
String: "UoM", Required: true,
}),
@@ -630,6 +1202,12 @@ func initStockMove() {
}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Char("origin", orm.FieldOpts{String: "Source Document"}),
+ orm.Float("forecast_availability", orm.FieldOpts{
+ String: "Forecast Availability", Compute: "_compute_forecast_availability",
+ }),
+ orm.One2many("move_line_ids", "stock.move.line", "move_id", orm.FieldOpts{
+ String: "Move Lines",
+ }),
)
// _compute_value: value = price_unit * product_uom_qty
@@ -692,30 +1270,47 @@ func initStockMove() {
env := rs.Env()
for _, id := range rs.IDs() {
var productID, srcLoc, dstLoc int64
- var qty float64
+ var demandQty float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, product_uom_qty, location_id, location_dest_id
- FROM stock_move WHERE id = $1`, id).Scan(&productID, &qty, &srcLoc, &dstLoc)
+ FROM stock_move WHERE id = $1`, id).Scan(&productID, &demandQty, &srcLoc, &dstLoc)
if err != nil {
return nil, fmt.Errorf("stock: read move %d for done: %w", id, err)
}
- // Decrease source quant
- if err := updateQuant(env, productID, srcLoc, -qty); err != nil {
- return nil, fmt.Errorf("stock: update source quant for move %d: %w", id, err)
- }
- // Increase destination quant
- if err := updateQuant(env, productID, dstLoc, qty); err != nil {
- return nil, fmt.Errorf("stock: update dest quant for move %d: %w", id, err)
+ // Use actual done qty from move lines, falling back to demand
+ var doneQty float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(quantity), 0) FROM stock_move_line WHERE move_id = $1`, id,
+ ).Scan(&doneQty)
+ if doneQty == 0 {
+ doneQty = demandQty
}
- // Clear reservation on source quant
- _, err = env.Tx().Exec(env.Ctx(),
- `UPDATE stock_quant SET reserved_quantity = GREATEST(reserved_quantity - $1, 0)
- WHERE product_id = $2 AND location_id = $3`,
- qty, productID, srcLoc)
- if err != nil {
- return nil, fmt.Errorf("stock: clear reservation for move %d: %w", id, err)
+ // Only update quants for internal locations (skip supplier/customer/virtual)
+ var srcUsage, dstUsage string
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(usage, '') FROM stock_location WHERE id = $1`, srcLoc).Scan(&srcUsage)
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(usage, '') FROM stock_location WHERE id = $1`, dstLoc).Scan(&dstUsage)
+
+ if srcUsage == "internal" {
+ if err := updateQuant(env, productID, srcLoc, -doneQty); err != nil {
+ return nil, fmt.Errorf("stock: update source quant for move %d: %w", id, err)
+ }
+ }
+ if dstUsage == "internal" {
+ if err := updateQuant(env, productID, dstLoc, doneQty); err != nil {
+ return nil, fmt.Errorf("stock: update dest quant for move %d: %w", id, err)
+ }
+ }
+
+ // Clear reservation on source quant (only if internal)
+ if srcUsage == "internal" {
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_quant SET reserved_quantity = GREATEST(reserved_quantity - $1, 0)
+ WHERE product_id = $2 AND location_id = $3`,
+ doneQty, productID, srcLoc)
}
// Mark move as done
@@ -724,9 +1319,190 @@ func initStockMove() {
if err != nil {
return nil, fmt.Errorf("stock: done move %d: %w", id, err)
}
+
+ // Multi-location transfer propagation: auto-create chained move if a push rule exists
+ if err := propagateChainedMove(env, id, productID, dstLoc, doneQty); err != nil {
+ log.Printf("stock: chain propagation for move %d: %v", id, err)
+ }
}
return true, nil
})
+
+ // _action_cancel: Cancel stock moves, unreserving any reserved quantities.
+ // Mirrors: odoo/addons/stock/models/stock_move.py StockMove._action_cancel()
+ m.RegisterMethod("_action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ env := rs.Env()
+ moveIDs := rs.IDs()
+ if len(moveIDs) == 0 {
+ return true, nil
+ }
+
+ // Check for done moves (cannot cancel)
+ var doneCount int
+ if err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT COUNT(*) FROM stock_move WHERE id = ANY($1) AND state = 'done'`, moveIDs,
+ ).Scan(&doneCount); err != nil {
+ return nil, fmt.Errorf("stock.move: cancel check: %w", err)
+ }
+ if doneCount > 0 {
+ return nil, fmt.Errorf("stock.move: cannot cancel done moves β create a return instead")
+ }
+
+ // Batch release reservations: aggregate reserved qty per product+location
+ rows, err := env.Tx().Query(env.Ctx(),
+ `SELECT sm.product_id, sm.location_id, COALESCE(SUM(sml.quantity), 0)
+ FROM stock_move sm
+ LEFT JOIN stock_move_line sml ON sml.move_id = sm.id
+ WHERE sm.id = ANY($1) AND sm.state NOT IN ('done', 'cancel')
+ GROUP BY sm.product_id, sm.location_id
+ HAVING SUM(sml.quantity) > 0`, moveIDs)
+ if err != nil {
+ return nil, fmt.Errorf("stock.move: cancel reservation query: %w", err)
+ }
+ for rows.Next() {
+ var productID, locationID int64
+ var reserved float64
+ if err := rows.Scan(&productID, &locationID, &reserved); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_quant SET reserved_quantity = GREATEST(reserved_quantity - $1, 0)
+ WHERE product_id = $2 AND location_id = $3`,
+ reserved, productID, locationID)
+ }
+ rows.Close()
+
+ // Batch delete all move lines
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `DELETE FROM stock_move_line WHERE move_id = ANY($1)`, moveIDs); err != nil {
+ return nil, fmt.Errorf("stock.move: delete move lines: %w", err)
+ }
+
+ // Batch update state to cancel (skip already cancelled)
+ if _, err := env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_move SET state = 'cancel' WHERE id = ANY($1) AND state != 'cancel'`, moveIDs); err != nil {
+ return nil, fmt.Errorf("stock.move: cancel: %w", err)
+ }
+
+ return true, nil
+ })
+
+ // _compute_quantity_done: Sum of move line quantities.
+ // Mirrors: odoo/addons/stock/models/stock_move.py StockMove._compute_quantity_done()
+ m.RegisterCompute("quantity_done", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ moveID := rs.IDs()[0]
+ var qtyDone float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(quantity), 0) FROM stock_move_line WHERE move_id = $1`, moveID,
+ ).Scan(&qtyDone)
+ return orm.Values{"quantity_done": qtyDone}, nil
+ })
+
+ // _compute_reserved_availability: SUM reserved_quantity from move_lines / product_uom_qty as percentage.
+ // Mirrors: stock.move._compute_reserved_availability()
+ m.RegisterCompute("reserved_availability", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ moveID := rs.IDs()[0]
+
+ var demandQty float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(product_uom_qty, 0) FROM stock_move WHERE id = $1`, moveID,
+ ).Scan(&demandQty)
+
+ if demandQty <= 0 {
+ return orm.Values{"reserved_availability": float64(0)}, nil
+ }
+
+ var reservedQty float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(quantity), 0) FROM stock_move_line WHERE move_id = $1`, moveID,
+ ).Scan(&reservedQty)
+
+ // Return as absolute reserved qty (Odoo convention, not percentage)
+ return orm.Values{"reserved_availability": reservedQty}, nil
+ })
+
+ // _compute_forecast_availability: Check available qty from quants for the move's product+location.
+ // Mirrors: stock.move._compute_forecast_information()
+ m.RegisterCompute("forecast_availability", func(rs *orm.Recordset) (orm.Values, error) {
+ env := rs.Env()
+ moveID := rs.IDs()[0]
+
+ var productID, locationID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT product_id, location_id FROM stock_move WHERE id = $1`, moveID,
+ ).Scan(&productID, &locationID)
+
+ available := getAvailableQty(env, productID, locationID)
+ return orm.Values{"forecast_availability": available}, nil
+ })
+
+ // _generate_serial_move_line_commands: For serial-tracked products, create one move line per serial.
+ // Expects args: []string of serial numbers.
+ // Mirrors: stock.move._generate_serial_move_line_commands()
+ m.RegisterMethod("_generate_serial_move_line_commands", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ if len(args) < 1 {
+ return nil, fmt.Errorf("stock.move._generate_serial_move_line_commands requires serial numbers")
+ }
+ serials, ok := args[0].([]string)
+ if !ok || len(serials) == 0 {
+ return nil, fmt.Errorf("stock.move._generate_serial_move_line_commands: invalid serial numbers argument")
+ }
+
+ env := rs.Env()
+ moveID := rs.IDs()[0]
+
+ // Read move details
+ var productID, uomID, locationID, locationDestID, companyID int64
+ err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT product_id, product_uom, location_id, location_dest_id, company_id
+ FROM stock_move WHERE id = $1`, moveID,
+ ).Scan(&productID, &uomID, &locationID, &locationDestID, &companyID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.move: read move %d for serial generation: %w", moveID, err)
+ }
+
+ var createdLineIDs []int64
+ for _, serial := range serials {
+ // Find or create the lot
+ var lotID int64
+ err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT id FROM stock_lot WHERE name = $1 AND product_id = $2 LIMIT 1`,
+ serial, productID,
+ ).Scan(&lotID)
+ if err != nil || lotID == 0 {
+ // Create the lot
+ err = env.Tx().QueryRow(env.Ctx(),
+ `INSERT INTO stock_lot (name, product_id, company_id) VALUES ($1, $2, $3) RETURNING id`,
+ serial, productID, companyID,
+ ).Scan(&lotID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.move: create lot for serial %q: %w", serial, err)
+ }
+ }
+
+ // Create move line with qty 1 (one per serial)
+ var lineID int64
+ err = env.Tx().QueryRow(env.Ctx(),
+ `INSERT INTO stock_move_line
+ (move_id, product_id, product_uom_id, lot_id, location_id, location_dest_id, quantity, company_id, date)
+ VALUES ($1, $2, $3, $4, $5, $6, 1, $7, NOW())
+ RETURNING id`,
+ moveID, productID, uomID, lotID, locationID, locationDestID, companyID,
+ ).Scan(&lineID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.move: create serial move line for %q: %w", serial, err)
+ }
+ createdLineIDs = append(createdLineIDs, lineID)
+ }
+
+ return map[string]interface{}{
+ "move_line_ids": createdLineIDs,
+ "count": len(createdLineIDs),
+ }, nil
+ })
}
// initStockMoveLine registers stock.move.line β detailed operations per lot/package.
@@ -834,6 +1610,193 @@ func initStockQuant() {
return true, nil
})
+ // _get_available_quantity: Query available (unreserved) qty for a product at a location.
+ // Optionally filter by lot_id (args[2]).
+ // Mirrors: odoo/addons/stock/models/stock_quant.py StockQuant._get_available_quantity()
+ m.RegisterMethod("_get_available_quantity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ if len(args) < 2 {
+ return nil, fmt.Errorf("stock.quant._get_available_quantity requires product_id, location_id")
+ }
+ productID, _ := args[0].(int64)
+ locationID, _ := args[1].(int64)
+ if productID == 0 || locationID == 0 {
+ return nil, fmt.Errorf("stock.quant._get_available_quantity: invalid product_id or location_id")
+ }
+
+ env := rs.Env()
+ var lotID int64
+ if len(args) >= 3 {
+ lotID, _ = args[2].(int64)
+ }
+
+ var available float64
+ if lotID > 0 {
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(quantity - reserved_quantity), 0)
+ FROM stock_quant
+ WHERE product_id = $1 AND location_id = $2 AND lot_id = $3`,
+ productID, locationID, lotID).Scan(&available)
+ } else {
+ available = getAvailableQty(env, productID, locationID)
+ }
+ return available, nil
+ })
+
+ // _gather: Find quants matching product + location + optional lot criteria.
+ // Returns quant IDs as []int64.
+ // Args: product_id (int64), location_id (int64), optional lot_id (int64).
+ // Mirrors: odoo/addons/stock/models/stock_quant.py StockQuant._gather()
+ m.RegisterMethod("_gather", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ if len(args) < 2 {
+ return nil, fmt.Errorf("stock.quant._gather requires product_id, location_id")
+ }
+ productID, _ := args[0].(int64)
+ locationID, _ := args[1].(int64)
+ if productID == 0 || locationID == 0 {
+ return nil, fmt.Errorf("stock.quant._gather: invalid product_id or location_id")
+ }
+
+ env := rs.Env()
+ var lotID int64
+ if len(args) >= 3 {
+ lotID, _ = args[2].(int64)
+ }
+
+ var query string
+ var queryArgs []interface{}
+ if lotID > 0 {
+ query = `SELECT id FROM stock_quant
+ WHERE product_id = $1 AND location_id = $2 AND lot_id = $3
+ ORDER BY in_date, id`
+ queryArgs = []interface{}{productID, locationID, lotID}
+ } else {
+ query = `SELECT id FROM stock_quant
+ WHERE product_id = $1 AND location_id = $2
+ ORDER BY in_date, id`
+ queryArgs = []interface{}{productID, locationID}
+ }
+
+ rows, err := env.Tx().Query(env.Ctx(), query, queryArgs...)
+ if err != nil {
+ return nil, fmt.Errorf("stock.quant._gather: %w", err)
+ }
+ defer rows.Close()
+
+ var quantIDs []int64
+ for rows.Next() {
+ var qid int64
+ if err := rows.Scan(&qid); err != nil {
+ return nil, fmt.Errorf("stock.quant._gather scan: %w", err)
+ }
+ quantIDs = append(quantIDs, qid)
+ }
+ return quantIDs, nil
+ })
+
+ // _compute_qty_at_date: Historical stock query β SUM moves done before a given date.
+ // Args: product_id (int64), location_id (int64), date (string YYYY-MM-DD)
+ // Mirrors: stock.quant._compute_qty_at_date() / stock history
+ m.RegisterMethod("_compute_qty_at_date", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ if len(args) < 3 {
+ return nil, fmt.Errorf("stock.quant._compute_qty_at_date requires product_id, location_id, date")
+ }
+ productID, _ := args[0].(int64)
+ locationID, _ := args[1].(int64)
+ dateStr, _ := args[2].(string)
+ if productID == 0 || locationID == 0 || dateStr == "" {
+ return nil, fmt.Errorf("stock.quant._compute_qty_at_date: invalid args")
+ }
+
+ env := rs.Env()
+
+ // Sum incoming moves (destination = this location) done before the date
+ var incoming float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(
+ COALESCE((SELECT SUM(sml.quantity) FROM stock_move_line sml WHERE sml.move_id = sm.id), sm.product_uom_qty)
+ ), 0)
+ FROM stock_move sm
+ WHERE sm.product_id = $1 AND sm.location_dest_id = $2
+ AND sm.state = 'done' AND sm.date <= $3`,
+ productID, locationID, dateStr,
+ ).Scan(&incoming)
+
+ // Sum outgoing moves (source = this location) done before the date
+ var outgoing float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(
+ COALESCE((SELECT SUM(sml.quantity) FROM stock_move_line sml WHERE sml.move_id = sm.id), sm.product_uom_qty)
+ ), 0)
+ FROM stock_move sm
+ WHERE sm.product_id = $1 AND sm.location_id = $2
+ AND sm.state = 'done' AND sm.date <= $3`,
+ productID, locationID, dateStr,
+ ).Scan(&outgoing)
+
+ qtyAtDate := incoming - outgoing
+ return map[string]interface{}{
+ "product_id": productID,
+ "location_id": locationID,
+ "date": dateStr,
+ "qty_at_date": qtyAtDate,
+ "incoming": incoming,
+ "outgoing": outgoing,
+ }, nil
+ })
+
+ // _compute_forecast_qty: on_hand - outgoing_reserved + incoming_confirmed.
+ // Args: product_id (int64), location_id (int64)
+ // Mirrors: stock.quant forecast computation
+ m.RegisterMethod("_compute_forecast_qty", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ if len(args) < 2 {
+ return nil, fmt.Errorf("stock.quant._compute_forecast_qty requires product_id, location_id")
+ }
+ productID, _ := args[0].(int64)
+ locationID, _ := args[1].(int64)
+ if productID == 0 || locationID == 0 {
+ return nil, fmt.Errorf("stock.quant._compute_forecast_qty: invalid args")
+ }
+
+ env := rs.Env()
+
+ // On-hand quantity at location
+ var onHand float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(quantity), 0) FROM stock_quant
+ WHERE product_id = $1 AND location_id = $2`,
+ productID, locationID,
+ ).Scan(&onHand)
+
+ // Outgoing reserved: confirmed/assigned moves FROM this location
+ var outgoingReserved float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(product_uom_qty), 0) FROM stock_move
+ WHERE product_id = $1 AND location_id = $2
+ AND state IN ('confirmed', 'assigned', 'waiting', 'partially_available')`,
+ productID, locationID,
+ ).Scan(&outgoingReserved)
+
+ // Incoming confirmed: confirmed/assigned moves TO this location
+ var incomingConfirmed float64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(SUM(product_uom_qty), 0) FROM stock_move
+ WHERE product_id = $1 AND location_dest_id = $2
+ AND state IN ('confirmed', 'assigned', 'waiting', 'partially_available')`,
+ productID, locationID,
+ ).Scan(&incomingConfirmed)
+
+ forecastQty := onHand - outgoingReserved + incomingConfirmed
+
+ return map[string]interface{}{
+ "product_id": productID,
+ "location_id": locationID,
+ "on_hand": onHand,
+ "outgoing_reserved": outgoingReserved,
+ "incoming_confirmed": incomingConfirmed,
+ "forecast_qty": forecastQty,
+ }, nil
+ })
+
// stock.quant.package β physical packages / containers
orm.NewModel("stock.quant.package", orm.ModelOpts{
Description: "Packages",
@@ -913,6 +1876,64 @@ func initStockLot() {
).Scan(&qty)
return orm.Values{"product_qty": qty}, nil
})
+
+ // _generate_serial_numbers: Auto-create sequential lot/serial records for a product.
+ // Args: product_id (int64), prefix (string), count (int64), company_id (int64)
+ // Creates lots named prefix0001, prefix0002, ... prefixNNNN.
+ // Mirrors: stock.lot.generate_lot_names() / serial number generation wizard
+ m.RegisterMethod("_generate_serial_numbers", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
+ if len(args) < 3 {
+ return nil, fmt.Errorf("stock.lot._generate_serial_numbers requires product_id, prefix, count")
+ }
+ productID, _ := args[0].(int64)
+ prefix, _ := args[1].(string)
+ count, _ := args[2].(int64)
+ if productID == 0 || count <= 0 {
+ return nil, fmt.Errorf("stock.lot._generate_serial_numbers: invalid product_id or count")
+ }
+
+ companyID := int64(1)
+ if len(args) >= 4 {
+ if cid, ok := args[3].(int64); ok && cid > 0 {
+ companyID = cid
+ }
+ }
+
+ env := rs.Env()
+ var createdIDs []int64
+ var createdNames []string
+
+ for i := int64(1); i <= count; i++ {
+ lotName := fmt.Sprintf("%s%04d", prefix, i)
+
+ // Check if lot already exists
+ var existingID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT id FROM stock_lot WHERE name = $1 AND product_id = $2 LIMIT 1`,
+ lotName, productID,
+ ).Scan(&existingID)
+ if existingID > 0 {
+ continue // Skip duplicates
+ }
+
+ var lotID int64
+ err := env.Tx().QueryRow(env.Ctx(),
+ `INSERT INTO stock_lot (name, product_id, company_id) VALUES ($1, $2, $3) RETURNING id`,
+ lotName, productID, companyID,
+ ).Scan(&lotID)
+ if err != nil {
+ return nil, fmt.Errorf("stock.lot._generate_serial_numbers: create lot %q: %w", lotName, err)
+ }
+ createdIDs = append(createdIDs, lotID)
+ createdNames = append(createdNames, lotName)
+ }
+
+ return map[string]interface{}{
+ "lot_ids": createdIDs,
+ "names": createdNames,
+ "count": len(createdIDs),
+ }, nil
+ })
}
// initStockOrderpoint registers stock.warehouse.orderpoint β reorder rules.
@@ -1171,3 +2192,164 @@ func toInt64(v interface{}) int64 {
}
return 0
}
+
+// updatePickingStateFromMoves recomputes and writes the picking state based on
+// the aggregate states of its stock moves.
+// If all moves done β done, all cancelled β cancel, all assigned+done+cancel β assigned, else confirmed.
+// Mirrors: stock.picking._compute_state()
+func updatePickingStateFromMoves(env *orm.Environment, pickingID int64) error {
+ var total, draftCount, cancelCount, doneCount, assignedCount int64
+ err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT COUNT(*),
+ COUNT(*) FILTER (WHERE state = 'draft'),
+ COUNT(*) FILTER (WHERE state = 'cancel'),
+ COUNT(*) FILTER (WHERE state = 'done'),
+ COUNT(*) FILTER (WHERE state = 'assigned')
+ FROM stock_move WHERE picking_id = $1`, pickingID,
+ ).Scan(&total, &draftCount, &cancelCount, &doneCount, &assignedCount)
+ if err != nil {
+ return fmt.Errorf("updatePickingStateFromMoves: query move states for picking %d: %w", pickingID, err)
+ }
+
+ var newState string
+ switch {
+ case total == 0 || draftCount > 0:
+ newState = "draft"
+ case cancelCount == total:
+ newState = "cancel"
+ case doneCount+cancelCount == total:
+ newState = "done"
+ case assignedCount+doneCount+cancelCount == total:
+ newState = "assigned"
+ default:
+ newState = "confirmed"
+ }
+
+ _, err = env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_picking SET state = $1 WHERE id = $2`, newState, pickingID)
+ if err != nil {
+ return fmt.Errorf("updatePickingStateFromMoves: update picking %d to %s: %w", pickingID, newState, err)
+ }
+ // If done, also set date_done
+ if newState == "done" {
+ env.Tx().Exec(env.Ctx(),
+ `UPDATE stock_picking SET date_done = NOW() WHERE id = $1 AND date_done IS NULL`, pickingID)
+ }
+ return nil
+}
+
+// propagateChainedMove checks for push rules on the destination location and
+// auto-creates a chained move if a stock.rule exists for the route.
+// This implements multi-location transfer propagation between warehouses.
+// Mirrors: stock.move._push_apply() / _action_done chain
+func propagateChainedMove(env *orm.Environment, moveID, productID, destLocationID int64, qty float64) error {
+ // Look for a push rule where location_src_id = destLocationID
+ var ruleID, nextDestID, pickingTypeID int64
+ var delay int
+ err := env.Tx().QueryRow(env.Ctx(),
+ `SELECT sr.id, sr.location_dest_id, sr.picking_type_id, COALESCE(sr.delay, 0)
+ FROM stock_rule sr
+ WHERE sr.location_src_id = $1
+ AND sr.action IN ('push', 'pull_push')
+ AND sr.active = true
+ ORDER BY sr.sequence LIMIT 1`,
+ destLocationID,
+ ).Scan(&ruleID, &nextDestID, &pickingTypeID, &delay)
+ if err != nil {
+ return nil // No push rule found β this is normal, not an error
+ }
+ if ruleID == 0 || nextDestID == 0 {
+ return nil
+ }
+
+ // Find or create a picking for the chained move
+ var chainedPickingID int64
+ err = env.Tx().QueryRow(env.Ctx(),
+ `SELECT id FROM stock_picking
+ WHERE picking_type_id = $1 AND location_id = $2 AND location_dest_id = $3
+ AND state = 'draft'
+ ORDER BY id DESC LIMIT 1`,
+ pickingTypeID, destLocationID, nextDestID,
+ ).Scan(&chainedPickingID)
+
+ if chainedPickingID == 0 {
+ // Create a new picking
+ var companyID int64
+ env.Tx().QueryRow(env.Ctx(),
+ `SELECT COALESCE(company_id, 1) FROM stock_picking_type WHERE id = $1`, pickingTypeID,
+ ).Scan(&companyID)
+ if companyID == 0 {
+ companyID = 1
+ }
+
+ scheduled := time.Now().AddDate(0, 0, delay).Format("2006-01-02")
+ pickVals := orm.Values{
+ "picking_type_id": pickingTypeID,
+ "location_id": destLocationID,
+ "location_dest_id": nextDestID,
+ "company_id": companyID,
+ "state": "draft",
+ "scheduled_date": scheduled,
+ "origin": fmt.Sprintf("Chain from move %d", moveID),
+ }
+ pickRS, err := env.Model("stock.picking").Create(pickVals)
+ if err != nil {
+ return fmt.Errorf("propagateChainedMove: create picking: %w", err)
+ }
+ chainedPickingID = pickRS.ID()
+ }
+
+ // Create the chained move
+ scheduled := time.Now().AddDate(0, 0, delay)
+ _, err = env.Model("stock.move").Create(orm.Values{
+ "name": fmt.Sprintf("Chained: product %d from rule %d", productID, ruleID),
+ "product_id": productID,
+ "product_uom_qty": qty,
+ "product_uom": int64(1), // default UoM
+ "location_id": destLocationID,
+ "location_dest_id": nextDestID,
+ "picking_id": chainedPickingID,
+ "company_id": int64(1),
+ "state": "draft",
+ "date": scheduled,
+ "origin": fmt.Sprintf("Chain from move %d", moveID),
+ })
+ if err != nil {
+ return fmt.Errorf("propagateChainedMove: create chained move: %w", err)
+ }
+
+ log.Printf("stock: created chained move for product %d from location %d to %d (rule %d)", productID, destLocationID, nextDestID, ruleID)
+ return nil
+}
+
+// enforceSerialLotTracking validates that move lines have required lot/serial numbers.
+// Products with tracking = 'lot' or 'serial' must have lot_id set on their move lines.
+// Mirrors: odoo/addons/stock/models/stock_picking.py _check_move_lines_map_quant()
+func enforceSerialLotTracking(env *orm.Environment, pickingID int64) error {
+ rows, err := env.Tx().Query(env.Ctx(),
+ `SELECT sml.id, COALESCE(pt.name, ''), COALESCE(pt.tracking, 'none'), sml.lot_id
+ FROM stock_move_line sml
+ JOIN stock_move sm ON sm.id = sml.move_id
+ LEFT JOIN product_product pp ON pp.id = sml.product_id
+ LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id
+ WHERE sm.picking_id = $1 AND sm.state NOT IN ('done', 'cancel')
+ AND sml.quantity > 0`, pickingID)
+ if err != nil {
+ log.Printf("stock: serial/lot tracking query failed for picking %d: %v", pickingID, err)
+ return fmt.Errorf("stock: cannot verify lot/serial tracking: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var lineID int64
+ var productName, tracking string
+ var lotID *int64
+ if err := rows.Scan(&lineID, &productName, &tracking, &lotID); err != nil {
+ continue
+ }
+ if (tracking == "lot" || tracking == "serial") && (lotID == nil || *lotID == 0) {
+ return fmt.Errorf("stock: product '%s' requires a lot/serial number (tracking=%s) on move line %d", productName, tracking, lineID)
+ }
+ }
+ return nil
+}
diff --git a/addons/stock/models/stock_barcode.go b/addons/stock/models/stock_barcode.go
index 97ba587..029aab3 100644
--- a/addons/stock/models/stock_barcode.go
+++ b/addons/stock/models/stock_barcode.go
@@ -72,6 +72,137 @@ func initStockBarcode() {
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.
// Finds the product and increments qty_done on the matching move line.
// Mirrors: stock_barcode.picking barcode processing
@@ -234,3 +365,13 @@ func initStockBarcode() {
}, 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
+}
diff --git a/addons/stock/models/stock_landed_cost.go b/addons/stock/models/stock_landed_cost.go
index 42e2011..94cd283 100644
--- a/addons/stock/models/stock_landed_cost.go
+++ b/addons/stock/models/stock_landed_cost.go
@@ -2,7 +2,6 @@ package models
import (
"fmt"
- "math"
"odoo-go/pkg/orm"
)
@@ -221,8 +220,11 @@ func initStockLandedCost() {
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE stock_valuation_layer
SET remaining_value = remaining_value + $1, value = value + $1
- WHERE stock_move_id = $2 AND product_id = $3 AND remaining_qty > 0
- LIMIT 1`,
+ WHERE id = (
+ 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,
)
if err != nil {
@@ -375,8 +377,3 @@ func initStockLandedCost() {
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
-}
diff --git a/addons/stock/models/stock_report.go b/addons/stock/models/stock_report.go
index 10c7f07..715b557 100644
--- a/addons/stock/models/stock_report.go
+++ b/addons/stock/models/stock_report.go
@@ -513,3 +513,234 @@ func initStockForecast() {
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
+ })
+}
diff --git a/addons/stock/models/stock_valuation.go b/addons/stock/models/stock_valuation.go
index 2459b67..dc97b37 100644
--- a/addons/stock/models/stock_valuation.go
+++ b/addons/stock/models/stock_valuation.go
@@ -127,13 +127,22 @@ func initStockValuationLayer() {
}
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
for rows.Next() && remaining > 0 {
var layerID int64
var layerQty, layerValue, layerUnitCost float64
if err := rows.Scan(&layerID, &layerQty, &layerValue, &layerUnitCost); err != nil {
+ rows.Close()
return nil, fmt.Errorf("stock.valuation.layer: scan FIFO layer: %w", err)
}
@@ -142,20 +151,27 @@ func initStockValuationLayer() {
consumed = layerQty
}
- consumedValue := consumed * layerUnitCost
- newRemainingQty := layerQty - consumed
- newRemainingValue := layerValue - consumedValue
+ consumptions = append(consumptions, layerConsumption{
+ id: layerID,
+ 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(),
`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 {
- 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 += consumedValue
- remaining -= consumed
+ totalConsumedValue += c.consumed * c.cost
}
return map[string]interface{}{
diff --git a/cmd/odoo-server/main.go b/cmd/odoo-server/main.go
index fd13136..7255c2c 100644
--- a/cmd/odoo-server/main.go
+++ b/cmd/odoo-server/main.go
@@ -19,6 +19,7 @@ import (
// Import all modules (register models via init())
_ "odoo-go/addons/base"
+ _ "odoo-go/addons/mail"
_ "odoo-go/addons/account"
_ "odoo-go/addons/product"
_ "odoo-go/addons/sale"
@@ -126,6 +127,11 @@ func main() {
log.Printf("odoo: session table init warning: %v", err)
}
+ // Start cron scheduler
+ cronScheduler := service.NewCronScheduler(pool)
+ cronScheduler.Start()
+ defer cronScheduler.Stop()
+
// Start HTTP server
srv := server.New(cfg, pool)
log.Printf("odoo: starting HTTP service on %s:%d", cfg.HTTPInterface, cfg.HTTPPort)
diff --git a/go.mod b/go.mod
index bb694a2..b61bf60 100644
--- a/go.mod
+++ b/go.mod
@@ -1,14 +1,24 @@
module odoo-go
-go 1.22.2
+go 1.24.0
require github.com/jackc/pgx/v5 v5.7.4
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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
- golang.org/x/crypto v0.31.0 // indirect
- golang.org/x/sync v0.10.0 // indirect
- golang.org/x/text v0.21.0 // indirect
+ github.com/richardlehane/mscfb v1.0.6 // indirect
+ github.com/richardlehane/msoleps v1.0.6 // 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
)
diff --git a/go.sum b/go.sum
index fa0f7db..b292925 100644
--- a/go.sum
+++ b/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/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.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
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/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/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/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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/odoo-server b/odoo-server
index ad8f4eb..75e155b 100755
Binary files a/odoo-server and b/odoo-server differ
diff --git a/open.md b/open.md
new file mode 100644
index 0000000..2902628
--- /dev/null
+++ b/open.md
@@ -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 |
+
+
+
diff --git a/pkg/orm/compute.go b/pkg/orm/compute.go
index 09d116c..d933f72 100644
--- a/pkg/orm/compute.go
+++ b/pkg/orm/compute.go
@@ -1,6 +1,9 @@
package orm
-import "fmt"
+import (
+ "fmt"
+ "log"
+)
// ComputeFunc is a function that computes field values for a recordset.
// Mirrors: @api.depends decorated methods in Odoo.
@@ -253,7 +256,7 @@ func RunOnchangeComputes(m *Model, env *Environment, currentVals Values, changed
computed, err := fn(rs)
if err != nil {
- // Non-fatal: skip failed computes during onchange
+ log.Printf("orm: onchange compute %s.%s failed: %v", m.Name(), fieldName, err)
continue
}
for k, v := range computed {
diff --git a/pkg/orm/domain.go b/pkg/orm/domain.go
index ae24c67..358fce7 100644
--- a/pkg/orm/domain.go
+++ b/pkg/orm/domain.go
@@ -2,6 +2,8 @@ package orm
import (
"fmt"
+ "regexp"
+ "strconv"
"strings"
)
@@ -152,6 +154,8 @@ func (dc *DomainCompiler) JoinSQL() string {
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) {
if pos >= len(domain) {
return "TRUE", nil
@@ -167,7 +171,8 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
if err != nil {
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 {
return "", err
}
@@ -178,7 +183,8 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
if err != nil {
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 {
return "", err
}
@@ -196,8 +202,6 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
return dc.compileCondition(n)
case domainGroup:
- // domainGroup wraps a sub-domain as a single node.
- // Compile it recursively as a full domain.
subSQL, _, err := dc.compileDomainGroup(Domain(n))
if err != nil {
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)
}
+// 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.
// It reuses the same DomainCompiler (sharing params and joins) so parameter
// 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)
}
- // 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, ".")
column := parts[0]
- // TODO: Handle JOINs for dot notation paths
- // For now, only support direct fields
if len(parts) > 1 {
- // Placeholder for JOIN resolution
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{
table: comodel.Table(),
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
@@ -293,8 +317,12 @@ func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator st
}
// The last segment is the actual field to filter on
- leafField := fieldPath[len(fieldPath)-1]
- qualifiedColumn := fmt.Sprintf("%s.%q", currentAlias, leafField)
+ leafFieldName := fieldPath[len(fieldPath)-1]
+ 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)
}
@@ -528,13 +556,8 @@ func (dc *DomainCompiler) compileAnyOp(column string, value Value, negate bool)
// Rebase parameter indices: shift them by the current param count
baseIdx := len(dc.params)
dc.params = append(dc.params, subParams...)
- rebased := subWhere
- // Replace $N with $(N+baseIdx) in the sub-where clause
- for i := len(subParams); i >= 1; i-- {
- old := fmt.Sprintf("$%d", i)
- new := fmt.Sprintf("$%d", i+baseIdx)
- rebased = strings.ReplaceAll(rebased, old, new)
- }
+ // Replace $N with $(N+baseIdx) using regex to avoid $1 matching $10
+ rebased := rebaseParams(subWhere, baseIdx)
// Determine the join condition based on field type
var joinCond string
@@ -676,3 +699,14 @@ func wrapLikeValue(value Value) Value {
}
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)
+ })
+}
diff --git a/pkg/orm/model.go b/pkg/orm/model.go
index b6d2818..fe3b0fc 100644
--- a/pkg/orm/model.go
+++ b/pkg/orm/model.go
@@ -45,8 +45,9 @@ type Model struct {
checkCompany bool // Enforce multi-company record rules
// Hooks
- BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
- DefaultGet func(env *Environment, fields []string) Values // Dynamic defaults (e.g., from DB)
+ BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
+ 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
Methods map[string]MethodFunc // Named business methods
@@ -453,3 +454,32 @@ func (m *Model) Many2manyTableSQL() []string {
}
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
+ }
+}
diff --git a/pkg/orm/read_group.go b/pkg/orm/read_group.go
index 3a78c6c..6b9d58e 100644
--- a/pkg/orm/read_group.go
+++ b/pkg/orm/read_group.go
@@ -153,7 +153,7 @@ func (rs *Recordset) ReadGroup(domain Domain, groupby []string, aggregates []str
// Build ORDER BY
orderSQL := ""
if opt.Order != "" {
- orderSQL = opt.Order
+ orderSQL = sanitizeOrderBy(opt.Order, m)
} else if len(gbCols) > 0 {
// Default: order by groupby columns
var orderParts []string
diff --git a/pkg/orm/recordset.go b/pkg/orm/recordset.go
index fe5e3f2..888b080 100644
--- a/pkg/orm/recordset.go
+++ b/pkg/orm/recordset.go
@@ -2,6 +2,7 @@ package orm
import (
"fmt"
+ "log"
"strings"
)
@@ -265,18 +266,28 @@ func preprocessRelatedWrites(env *Environment, m *Model, ids []int64, vals Value
value := vals[fieldName]
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
- for _, id := range ids {
- var fkID *int64
- env.tx.QueryRow(env.ctx,
- fmt.Sprintf(`SELECT %q FROM %q WHERE id = $1`, fkDef.Column(), m.Table()),
- id,
- ).Scan(&fkID)
- if fkID != nil && *fkID > 0 {
- fkIDs = append(fkIDs, *fkID)
+ rows, err := env.tx.Query(env.ctx,
+ fmt.Sprintf(`SELECT %q FROM %q WHERE id = ANY($1) AND %q IS NOT NULL`,
+ fkDef.Column(), m.Table(), fkDef.Column()),
+ ids,
+ )
+ if err != nil {
+ delete(vals, fieldName)
+ 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 {
continue
@@ -315,6 +326,13 @@ func (rs *Recordset) Write(vals Values) error {
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 args []interface{}
idx := 1
@@ -787,7 +805,7 @@ func (rs *Recordset) Search(domain Domain, opts ...SearchOpts) (*Recordset, erro
// Build query
order := m.order
if opt.Order != "" {
- order = opt.Order
+ order = sanitizeOrderBy(opt.Order, m)
}
joinSQL := compiler.JoinSQL()
@@ -1103,6 +1121,72 @@ func toRecordID(v interface{}) (int64, bool) {
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.
// "name, id desc" β "\"my_table\".name, \"my_table\".id desc"
func qualifyOrderBy(table, order string) string {
diff --git a/pkg/orm/rules.go b/pkg/orm/rules.go
index 68d3c6c..5fb43b8 100644
--- a/pkg/orm/rules.go
+++ b/pkg/orm/rules.go
@@ -70,8 +70,9 @@ func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain {
ORDER BY r.id`,
m.Name(), env.UID())
if err != nil {
+ log.Printf("orm: ir.rule query failed for %s: %v β denying access", m.Name(), err)
sp.Rollback(env.ctx)
- return domain
+ return append(domain, Leaf("id", "=", -1)) // Deny all β no records match id=-1
}
type ruleRow struct {
@@ -207,7 +208,8 @@ func CheckRecordRuleAccess(env *Environment, m *Model, ids []int64, perm string)
var count int64
err := env.tx.QueryRow(env.ctx, query, args...).Scan(&count)
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)) {
diff --git a/pkg/server/action.go b/pkg/server/action.go
index 5771b20..8c5a66f 100644
--- a/pkg/server/action.go
+++ b/pkg/server/action.go
@@ -3,6 +3,7 @@ package server
import (
"encoding/json"
"fmt"
+ "log"
"net/http"
"strings"
)
@@ -145,10 +146,12 @@ func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) {
// Look up xml_id from ir_model_data
xmlID := ""
- _ = s.pool.QueryRow(ctx,
+ if err := s.pool.QueryRow(ctx,
`SELECT module || '.' || name FROM ir_model_data
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"]])
views := buildViewsFromMode(viewMode)
diff --git a/pkg/server/bank_import.go b/pkg/server/bank_import.go
new file mode 100644
index 0000000..5c4696f
--- /dev/null
+++ b/pkg/server/bank_import.go
@@ -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
+}
diff --git a/pkg/server/bus.go b/pkg/server/bus.go
new file mode 100644
index 0000000..8c88e92
--- /dev/null
+++ b/pkg/server/bus.go
@@ -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)
+}
diff --git a/pkg/server/export.go b/pkg/server/export.go
index 9c6e866..3c83c3d 100644
--- a/pkg/server/export.go
+++ b/pkg/server/export.go
@@ -6,37 +6,45 @@ import (
"fmt"
"net/http"
+ "github.com/xuri/excelize/v2"
"odoo-go/pkg/orm"
)
-// 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
- }
+// exportField describes a field in an export request.
+type exportField struct {
+ Name string `json:"name"`
+ Label string `json:"label"`
+}
+// 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
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
- return
+ return nil, err
}
var params struct {
Data struct {
- Model string `json:"model"`
- Fields []exportField `json:"fields"`
- Domain []interface{} `json:"domain"`
- IDs []float64 `json:"ids"`
+ Model string `json:"model"`
+ Fields []exportField `json:"fields"`
+ Domain []interface{} `json:"domain"`
+ IDs []float64 `json:"ids"`
} `json:"data"`
}
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
- return
+ return nil, err
}
- // Extract UID from session
uid := int64(1)
companyID := int64(1)
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{
- Pool: s.pool,
- UID: uid,
- CompanyID: companyID,
+ Pool: s.pool, UID: uid, CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
- return
+ return nil, err
}
defer env.Close()
rs := env.Model(params.Data.Model)
- // Determine which record IDs to export
var ids []int64
if len(params.Data.IDs) > 0 {
for _, id := range params.Data.IDs {
ids = append(ids, int64(id))
}
} else {
- // Search with domain
domain := parseDomain([]interface{}{params.Data.Domain})
found, err := rs.Search(domain, orm.SearchOpts{Limit: 10000})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
- return
+ return nil, err
}
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 headers []string
for _, f := range params.Data.Fields {
@@ -92,42 +89,89 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
headers = append(headers, label)
}
- // Read records
- records, err := rs.Browse(ids...).Read(fieldNames)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
+ var records []orm.Values
+ if len(ids) > 0 {
+ records, err = rs.Browse(ids...).Read(fieldNames)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return nil, err
+ }
}
if err := env.Commit(); err != nil {
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
}
- // Write CSV
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)
defer writer.Flush()
- // Header row
- writer.Write(headers)
-
- // Data rows
- for _, rec := range records {
- row := make([]string, len(fieldNames))
- for i, fname := range fieldNames {
+ writer.Write(data.Headers)
+ for _, rec := range data.Records {
+ row := make([]string, len(data.FieldNames))
+ for i, fname := range data.FieldNames {
row[i] = formatCSVValue(rec[fname])
}
writer.Write(row)
}
}
-// exportField describes a field in an export request.
-type exportField struct {
- Name string `json:"name"`
- Label string `json:"label"`
+// handleExportXLSX exports records as XLSX (Excel).
+// Mirrors: odoo/addons/web/controllers/export.py ExportXlsxController
+func (s *Server) handleExportXLSX(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
+ }
+
+ 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.
diff --git a/pkg/server/image.go b/pkg/server/image.go
index 87d26fc..d12189a 100644
--- a/pkg/server/image.go
+++ b/pkg/server/image.go
@@ -2,6 +2,7 @@ package server
import (
"fmt"
+ "log"
"net/http"
"strconv"
"strings"
@@ -55,9 +56,11 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
table := m.Table()
var data []byte
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,
- ).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 {
// Detect content type
contentType := http.DetectContentType(data)
@@ -76,9 +79,11 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
m := orm.Registry.Get(model)
if m != nil {
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,
- ).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 {
initial = strings.ToUpper(name[:1])
}
diff --git a/pkg/server/import.go b/pkg/server/import.go
new file mode 100644
index 0000000..bbe465d
--- /dev/null
+++ b/pkg/server/import.go
@@ -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
+ }
+}
diff --git a/pkg/server/middleware.go b/pkg/server/middleware.go
index 41344e7..ac16f7b 100644
--- a/pkg/server/middleware.go
+++ b/pkg/server/middleware.go
@@ -4,6 +4,7 @@ import (
"context"
"log"
"net/http"
+ "path/filepath"
"strings"
"time"
)
@@ -43,13 +44,19 @@ func (w *statusWriter) WriteHeader(code int) {
func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Public endpoints (no auth required)
- path := r.URL.Path
+ path := filepath.Clean(r.URL.Path)
if path == "/health" ||
path == "/web/login" ||
path == "/web/session/authenticate" ||
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/setup/wizard" ||
+ path == "/web/setup/wizard/save" ||
+ path == "/web/portal/signup" ||
+ path == "/web/portal/reset_password" ||
strings.Contains(path, "/static/") {
next.ServeHTTP(w, r)
return
@@ -58,8 +65,14 @@ func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
// Check session cookie
cookie, err := r.Cookie("session_id")
if err != nil || cookie.Value == "" {
- // Also check JSON-RPC params for session_id (Odoo sends it both ways)
- next.ServeHTTP(w, r) // For now, allow through β UID defaults to 1
+ // No session cookie β reject protected endpoints
+ 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
}
diff --git a/pkg/server/portal.go b/pkg/server/portal.go
new file mode 100644
index 0000000..bc88c1c
--- /dev/null
+++ b/pkg/server/portal.go
@@ -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})
+}
diff --git a/pkg/server/portal_signup.go b/pkg/server/portal_signup.go
new file mode 100644
index 0000000..3aac82a
--- /dev/null
+++ b/pkg/server/portal_signup.go
@@ -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(`
+A password reset was requested for your account.
+Click the link below to set a new password:
+Reset Password
+This link expires in 24 hours.
+If you did not request this, you can ignore this email.
+`, 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",
+ })
+}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 15c6543..ab28b64 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -9,6 +9,7 @@ import (
"log"
"net/http"
"strings"
+ "sync"
"time"
"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,
// served as a single file to avoid hundreds of individual HTTP requests.
jsBundle string
+
+ bus *Bus // Message bus for Discuss long-polling
}
// New creates a new server instance.
@@ -128,6 +131,17 @@ func (s *Server) registerRoutes() {
// CSV export
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)
s.mux.HandleFunc("/report/", s.handleReport)
@@ -137,10 +151,16 @@ func (s *Server) registerRoutes() {
// Logout & Account
s.mux.HandleFunc("/web/session/logout", s.handleLogout)
s.mux.HandleFunc("/web/session/account", s.handleSessionAccount)
+ s.mux.HandleFunc("/web/session/switch_company", s.handleSwitchCompany)
// Health check
s.mux.HandleFunc("/health", s.handleHealth)
+ // Portal routes (external user access)
+ s.registerPortalRoutes()
+ s.registerPortalSignupRoutes()
+ s.registerBusRoutes()
+
// Static files (catch-all for //static/...)
// 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
}
- // Extract UID from session, default to 1 (admin) if no session
- uid := int64(1)
- companyID := int64(1)
- if sess := GetSession(r); sess != nil {
- uid = sess.UID
- companyID = sess.CompanyID
+ // Extract UID from session β reject if no session (defense in depth)
+ sess := GetSession(r)
+ if sess == nil {
+ s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: 100, Message: "Session expired"})
+ return
}
+ uid := sess.UID
+ companyID := sess.CompanyID
// Create environment for this request
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)
}
+// 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.
// Mirrors: odoo/addons/base/models/ir_model.py IrModelAccess.check()
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
JOIN ir_model m ON m.id = a.model_id
WHERE m.model = $1`, model).Scan(&count)
- if err != nil || count == 0 {
- return nil // No ACLs defined β open access (like Odoo superuser mode)
+ if err != nil {
+ // 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
@@ -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)
)`, perm), model, env.UID()).Scan(&granted)
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 {
return &RPCError{
@@ -379,10 +448,57 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
switch params.Method {
case "has_group":
- // Always return true for admin user, stub for now
- return true, nil
+ // Check if current user belongs to the given group.
+ // 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":
+ // 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
case "fields_get":
@@ -404,6 +520,11 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
vals := parseValuesAt(params.Args, 1)
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 {
// Update existing record(s)
err := rs.Browse(ids...).Write(vals)
@@ -513,6 +634,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
case "create":
vals := parseValues(params.Args)
+ if err := checkSensitiveFields(env, params.Model, vals); err != nil {
+ return nil, err
+ }
record, err := rs.Create(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
@@ -522,6 +646,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
case "write":
ids := parseIDs(params.Args)
vals := parseValuesAt(params.Args, 1)
+ if err := checkSensitiveFields(env, params.Model, vals); err != nil {
+ return nil, err
+ }
err := rs.Browse(ids...).Write(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
@@ -645,9 +772,33 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
}, nil
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":
+ // 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
case "name_create":
@@ -665,10 +816,48 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
return []interface{}{created.ID(), nameStr}, nil
case "read_progress_bar":
- return map[string]interface{}{}, nil
+ return s.handleReadProgressBar(rs, params)
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":
ids := parseIDs(params.Args)
@@ -697,6 +886,199 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
}
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:
// Try registered business methods on the model.
// 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 ---
+// 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) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -754,6 +1188,14 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
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
var uid int64
var companyID int64
@@ -776,16 +1218,40 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
return
}
- // Check password (support both bcrypt and plaintext for migration)
- if !tools.CheckPassword(hashedPw, params.Password) && hashedPw != params.Password {
+ // Check password (bcrypt only β no plaintext fallback)
+ if !tools.CheckPassword(hashedPw, params.Password) {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: 100, Message: "Access Denied: invalid login or password",
})
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
sess := s.sessions.New(uid, companyID, params.Login)
+ sess.AllowedCompanyIDs = allowedCompanyIDs
// Set session cookie
http.SetCookie(w, &http.Cookie{
@@ -793,6 +1259,7 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
Value: sess.ID,
Path: "/",
HttpOnly: true,
+ Secure: true,
SameSite: http.SameSiteLaxMode,
})
@@ -857,6 +1324,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
Path: "/",
MaxAge: -1,
HttpOnly: true,
+ Secure: true,
})
http.Redirect(w, r, "/web/login", http.StatusFound)
}
diff --git a/pkg/server/session.go b/pkg/server/session.go
index 35da29a..8ec9fb5 100644
--- a/pkg/server/session.go
+++ b/pkg/server/session.go
@@ -13,12 +13,14 @@ import (
// Session represents an authenticated user session.
type Session struct {
- ID string
- UID int64
- CompanyID int64
- Login string
- CreatedAt time.Time
- LastActivity time.Time
+ ID string
+ UID int64
+ CompanyID int64
+ AllowedCompanyIDs []int64
+ Login string
+ CSRFToken string
+ CreatedAt time.Time
+ LastActivity time.Time
}
// 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,
company_id INT8 NOT NULL,
login VARCHAR(255),
+ csrf_token VARCHAR(64) DEFAULT '',
created_at 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 {
return err
}
@@ -67,6 +74,7 @@ func (s *SessionStore) New(uid, companyID int64, login string) *Session {
UID: uid,
CompanyID: companyID,
Login: login,
+ CSRFToken: generateToken(),
CreatedAt: 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)
defer cancel()
_, err := s.pool.Exec(ctx,
- `INSERT INTO sessions (id, uid, company_id, login, created_at, last_seen)
- VALUES ($1, $2, $3, $4, $5, $6)
+ `INSERT INTO sessions (id, uid, company_id, login, csrf_token, created_at, last_seen)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO NOTHING`,
- token, uid, companyID, login, now, now)
+ token, uid, companyID, login, sess.CSRFToken, now, now)
if err != nil {
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)
return nil
}
- // Update last activity
+
now := time.Now()
+ needsDBUpdate := time.Since(sess.LastActivity) > 30*time.Second
+
+ // Update last activity in memory
s.mu.Lock()
sess.LastActivity = now
s.mu.Unlock()
- // Update last_seen in DB asynchronously
- if s.pool != nil {
- go func() {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- s.pool.Exec(ctx,
- `UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id)
- }()
+ // Throttle DB writes: only persist every 30s to avoid per-request overhead
+ if needsDBUpdate && s.pool != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if _, err := s.pool.Exec(ctx,
+ `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
@@ -134,14 +145,20 @@ func (s *SessionStore) Get(id string) *Session {
defer cancel()
sess = &Session{}
+ var csrfToken string
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(
- &sess.ID, &sess.UID, &sess.CompanyID, &sess.Login,
+ &sess.ID, &sess.UID, &sess.CompanyID, &sess.Login, &csrfToken,
&sess.CreatedAt, &sess.LastActivity)
if err != nil {
return nil
}
+ if csrfToken != "" {
+ sess.CSRFToken = csrfToken
+ } else {
+ sess.CSRFToken = generateToken()
+ }
// Check TTL
if time.Since(sess.LastActivity) > s.ttl {
@@ -149,18 +166,18 @@ func (s *SessionStore) Get(id string) *Session {
return nil
}
- // Update last activity
+ // Update last activity and add to memory cache
now := time.Now()
- sess.LastActivity = now
-
- // Add to memory cache
s.mu.Lock()
+ sess.LastActivity = now
s.sessions[id] = sess
s.mu.Unlock()
// Update last_seen in DB
- s.pool.Exec(ctx,
- `UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id)
+ if _, err := s.pool.Exec(ctx,
+ `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
}
diff --git a/pkg/server/setup.go b/pkg/server/setup.go
index 4286783..e1cd4ff 100644
--- a/pkg/server/setup.go
+++ b/pkg/server/setup.go
@@ -6,14 +6,17 @@ import (
"fmt"
"log"
"net/http"
+ "os"
"regexp"
"strings"
+ "sync/atomic"
"time"
"odoo-go/pkg/service"
"odoo-go/pkg/tools"
)
+
var dbnamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`)
// isSetupNeeded checks if the current database has been initialized.
@@ -55,6 +58,16 @@ func (s *Server) handleDatabaseCreate(w http.ResponseWriter, r *http.Request) {
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
if params.Login == "" || params.Password == "" {
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]
domainParts := strings.Split(domain, ".")
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)
}
+// 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 = `
+
+
+
+
+ Setup β Configure Your Company
+
+
+
+
+
Configure Your Company
+
Set up your company information
+
+
+
+
+
+
+`
+
// --- Database Manager HTML ---
// Mirrors: odoo/addons/web/static/src/public/database_manager.create_form.qweb.html
var databaseManagerHTML = `
diff --git a/pkg/server/static.go b/pkg/server/static.go
index 82e18c1..729823d 100644
--- a/pkg/server/static.go
+++ b/pkg/server/static.go
@@ -43,8 +43,9 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
addonName := parts[0]
filePath := parts[2]
- // Security: prevent directory traversal
- if strings.Contains(filePath, "..") {
+ // Security: prevent directory traversal in both addonName and filePath
+ if strings.Contains(filePath, "..") || strings.Contains(addonName, "..") ||
+ strings.Contains(addonName, "/") || strings.Contains(addonName, "\\") {
http.NotFound(w, r)
return
}
diff --git a/pkg/server/transpiler_test.go b/pkg/server/transpiler_test.go
index 313fce0..01831b2 100644
--- a/pkg/server/transpiler_test.go
+++ b/pkg/server/transpiler_test.go
@@ -88,7 +88,7 @@ func TestExtractImports(t *testing.T) {
content := `import { Foo, Bar } from "@web/core/foo";
import { Baz as Qux } from "@web/core/baz";
const x = 1;`
- deps, requires, clean := extractImports(content)
+ deps, requires, clean := extractImports("test.module", content)
if len(deps) != 2 {
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) {
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" {
t.Errorf("deps = %v, want [@web/core/foo]", deps)
@@ -132,7 +132,7 @@ const x = 1;`
t.Run("namespace import", func(t *testing.T) {
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" {
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) {
content := `import "@web/core/setup";`
- deps, requires, _ := extractImports(content)
+ deps, requires, _ := extractImports("test.module", content)
if len(deps) != 1 || deps[0] != "@web/core/setup" {
t.Errorf("deps = %v, want [@web/core/setup]", deps)
@@ -157,7 +157,7 @@ const x = 1;`
t.Run("dedup deps", func(t *testing.T) {
content := `import { Foo } from "@web/core/foo";
import { Bar } from "@web/core/foo";`
- deps, _, _ := extractImports(content)
+ deps, _, _ := extractImports("test.module", content)
if len(deps) != 1 {
t.Errorf("expected deduped deps, got %v", deps)
@@ -167,7 +167,7 @@ import { Bar } from "@web/core/foo";`
func TestTransformExports(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 {"
if 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) {
- got := transformExports("export function doSomething(a, b) {")
- want := `__exports.doSomething = function doSomething(a, b) {`
+ got, deferred := transformExports("export function doSomething(a, b) {")
+ want := `function doSomething(a, b) {`
if 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) {
- got := transformExports("export const MAX_SIZE = 100;")
+ got, _ := transformExports("export const MAX_SIZE = 100;")
want := "const MAX_SIZE = __exports.MAX_SIZE = 100;"
if 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) {
- got := transformExports("export let counter = 0;")
+ got, _ := transformExports("export let counter = 0;")
want := "let counter = __exports.counter = 0;"
if 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) {
- got := transformExports("export default Foo;")
+ got, _ := transformExports("export default Foo;")
want := `__exports[Symbol.for("default")] = Foo;`
if 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) {
- got := transformExports("export { Foo, Bar };")
+ got, _ := transformExports("export { Foo, Bar };")
if !strings.Contains(got, "__exports.Foo = Foo;") {
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) {
- got := transformExports("export { Foo as default };")
+ got, _ := transformExports("export { Foo as default };")
if !strings.Contains(got, "__exports.default = Foo;") {
t.Errorf("missing aliased export in: %s", got)
}
diff --git a/pkg/server/upload.go b/pkg/server/upload.go
index 23c5fb1..cb2cec5 100644
--- a/pkg/server/upload.go
+++ b/pkg/server/upload.go
@@ -20,12 +20,27 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
return
}
- // Parse multipart form (max 128MB)
- if err := r.ParseMultipartForm(128 << 20); err != nil {
+ // Limit upload size to 50MB
+ 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)
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")
if err != nil {
http.Error(w, "No file uploaded", http.StatusBadRequest)
diff --git a/pkg/server/views.go b/pkg/server/views.go
index 07985e6..e7d2f81 100644
--- a/pkg/server/views.go
+++ b/pkg/server/views.go
@@ -195,6 +195,12 @@ func generateDefaultView(modelName, viewType string) string {
return generateDefaultPivotView(m)
case "graph":
return generateDefaultGraphView(m)
+ case "calendar":
+ return generateDefaultCalendarView(m)
+ case "activity":
+ return generateDefaultActivityView(m)
+ case "dashboard":
+ return generateDefaultDashboardView(m)
default:
return fmt.Sprintf("<%s>%s>", viewType, viewType)
}
@@ -530,6 +536,161 @@ func generateDefaultGraphView(m *orm.Model) string {
return fmt.Sprintf("\n %s\n", 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 ``
+ }
+
+ // 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(` `, nameField))
+
+ if f := m.GetField("partner_id"); f != nil {
+ fields = append(fields, ` `)
+ }
+ if f := m.GetField("user_id"); f != nil && colorField != "user_id" {
+ fields = append(fields, ` `)
+ }
+
+ return fmt.Sprintf("\n%s\n",
+ 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(`
+
+
+
+
+
+`, 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(
+ ` `,
+ 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("\n")
+ if len(widgets) > 0 {
+ buf.WriteString(" \n")
+ for _, w := range widgets {
+ buf.WriteString(w + "\n")
+ }
+ buf.WriteString(" \n")
+ }
+ if graphField != "" {
+ buf.WriteString(fmt.Sprintf(`
+
+
+
+
+`, graphField))
+ }
+ buf.WriteString("")
+ return buf.String()
+}
+
// sortedFieldNames returns field names in alphabetical order for deterministic output.
func sortedFieldNames(m *orm.Model) []string {
fields := m.Fields()
diff --git a/pkg/server/web_methods.go b/pkg/server/web_methods.go
index 5e87a79..6328a7e 100644
--- a/pkg/server/web_methods.go
+++ b/pkg/server/web_methods.go
@@ -2,6 +2,7 @@ package server
import (
"fmt"
+ "strings"
"time"
"odoo-go/pkg/orm"
@@ -451,6 +452,110 @@ func (s *Server) handleReadGroup(rs *orm.Recordset, params CallKWParams) (interf
}
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)
totalLen := len(results)
if opts.Limit > 0 || opts.Offset > 0 {
@@ -470,6 +575,203 @@ func (s *Server) handleReadGroup(rs *orm.Recordset, params CallKWParams) (interf
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.
func formatDateFields(model string, records []orm.Values) {
m := orm.Registry.Get(model)
diff --git a/pkg/server/webclient.go b/pkg/server/webclient.go
index 7c868ee..2e05d7d 100644
--- a/pkg/server/webclient.go
+++ b/pkg/server/webclient.go
@@ -2,6 +2,7 @@ package server
import (
"bufio"
+ "context"
"embed"
"encoding/json"
"fmt"
@@ -73,6 +74,14 @@ func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
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
sess := GetSession(r)
if sess == nil {
@@ -141,7 +150,7 @@ func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
%s