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(``) 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( + "", + 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:

+
InvoiceDue DateTotalAmount DueOverdue Days
%s%.2f%.2f%.2f
+ + + +
Reference%s
Expected Arrival%s
Total%.2f
+
+ + +%s +
DescriptionQtyUnit PriceSubtotal
+

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.

+ + + + + %s + + + + +
DescriptionQtyUnit PriceDiscountSubtotal
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>", 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