diff --git a/addons/account/models/account_move.go b/addons/account/models/account_move.go index a1593dd..29f8219 100644 --- a/addons/account/models/account_move.go +++ b/addons/account/models/account_move.go @@ -2,6 +2,7 @@ package models import ( "fmt" + "time" "odoo-go/pkg/orm" ) @@ -155,45 +156,25 @@ func initAccountMove() { // -- Computed Fields -- // _compute_amount: sums invoice lines to produce totals. // Mirrors: odoo/addons/account/models/account_move.py AccountMove._compute_amount() + // + // Separates untaxed (product lines) from tax (tax lines) via display_type, + // then derives total = untaxed + tax. computeAmount := func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() moveID := rs.IDs()[0] - var untaxed, tax, total float64 - rows, err := env.Tx().Query(env.Ctx(), - `SELECT COALESCE(SUM(debit), 0), COALESCE(SUM(credit), 0) - FROM account_move_line WHERE move_id = $1`, moveID) + 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) + FROM account_move_line WHERE move_id = $1`, moveID, + ).Scan(&untaxed, &tax) if err != nil { return nil, err } - defer rows.Close() - if rows.Next() { - var debitSum, creditSum float64 - if err := rows.Scan(&debitSum, &creditSum); err != nil { - return nil, err - } - total = debitSum // For invoices, total = sum of debits (or credits) - if debitSum > creditSum { - total = debitSum - } else { - total = creditSum - } - // Tax lines have display_type='tax', product lines don't - untaxed = total // Simplified: full total as untaxed for now - } - rows.Close() - // Get actual tax amount from tax lines - err = env.Tx().QueryRow(env.Ctx(), - `SELECT COALESCE(SUM(ABS(balance)), 0) - FROM account_move_line WHERE move_id = $1 AND display_type = 'tax'`, - moveID).Scan(&tax) - if err != nil { - tax = 0 - } - if tax > 0 { - untaxed = total - tax - } + total := untaxed + tax return orm.Values{ "amount_untaxed": untaxed, @@ -274,6 +255,168 @@ func initAccountMove() { return true, nil }) + // -- Business Method: create_invoice_with_tax -- + // Creates a customer invoice with automatic tax line generation. + // For each product line that carries a tax_id, a separate tax line + // (display_type='tax') is created. A receivable line balances the entry. + // + // args[0]: partner_id (int64 or float64) + // args[1]: lines ([]interface{} of map[string]interface{}) + // Each line: {name, quantity, price_unit, account_id, tax_id?} + // + // Returns: the created account.move ID (int64) + m.RegisterMethod("create_invoice_with_tax", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 2 { + return nil, fmt.Errorf("account: create_invoice_with_tax requires partner_id and lines") + } + + env := rs.Env() + + partnerID, ok := toInt64Arg(args[0]) + if !ok { + return nil, fmt.Errorf("account: invalid partner_id") + } + + rawLines, ok := args[1].([]interface{}) + if !ok { + return nil, fmt.Errorf("account: lines must be a list") + } + + // Step 1: Create the move header (draft invoice) + moveRS := env.Model("account.move") + moveVals := orm.Values{ + "move_type": "out_invoice", + "partner_id": partnerID, + } + move, err := moveRS.Create(moveVals) + if err != nil { + return nil, fmt.Errorf("account: create move: %w", err) + } + moveID := move.ID() + + // Retrieve company_id, journal_id, currency_id from the created move + moveData, err := move.Read([]string{"company_id", "journal_id", "currency_id"}) + if err != nil || len(moveData) == 0 { + return nil, fmt.Errorf("account: cannot read created move") + } + companyID, _ := toInt64Arg(moveData[0]["company_id"]) + journalID, _ := toInt64Arg(moveData[0]["journal_id"]) + currencyID, _ := toInt64Arg(moveData[0]["currency_id"]) + + // Find the receivable account for the partner + var receivableAccountID int64 + 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(&receivableAccountID) + if receivableAccountID == 0 { + return nil, fmt.Errorf("account: no receivable account found for company %d", companyID) + } + + lineRS := env.Model("account.move.line") + + var totalDebit float64 // sum of all product + tax debits + + // Step 2: For each input line, create product line(s) and tax line(s) + for _, rawLine := range rawLines { + lineMap, ok := rawLine.(map[string]interface{}) + if !ok { + continue + } + + name, _ := lineMap["name"].(string) + quantity := floatArg(lineMap["quantity"], 1.0) + priceUnit := floatArg(lineMap["price_unit"], 0.0) + accountID, _ := toInt64Arg(lineMap["account_id"]) + taxID, hasTax := toInt64Arg(lineMap["tax_id"]) + + if accountID == 0 { + // Fallback: use journal default account + env.Tx().QueryRow(env.Ctx(), + `SELECT default_account_id FROM account_journal WHERE id = $1`, journalID, + ).Scan(&accountID) + } + if accountID == 0 { + return nil, fmt.Errorf("account: no account_id for line %q", name) + } + + baseAmount := priceUnit * quantity + + // Create product line (debit side for revenue) + productLineVals := orm.Values{ + "move_id": moveID, + "name": name, + "quantity": quantity, + "price_unit": priceUnit, + "account_id": accountID, + "company_id": companyID, + "journal_id": journalID, + "currency_id": currencyID, + "partner_id": partnerID, + "display_type": "product", + "debit": 0.0, + "credit": baseAmount, + "balance": -baseAmount, + } + if _, err := lineRS.Create(productLineVals); err != nil { + return nil, fmt.Errorf("account: create product line: %w", err) + } + totalDebit += baseAmount + + // If a tax is specified, compute and create the tax line + if hasTax && taxID > 0 { + taxResult, err := ComputeTax(env, taxID, baseAmount) + if err != nil { + return nil, fmt.Errorf("account: compute tax: %w", err) + } + + if taxResult.Amount != 0 && taxResult.AccountID != 0 { + taxLineVals := orm.Values{ + "move_id": moveID, + "name": taxResult.TaxName, + "quantity": 1.0, + "account_id": taxResult.AccountID, + "company_id": companyID, + "journal_id": journalID, + "currency_id": currencyID, + "partner_id": partnerID, + "display_type": "tax", + "tax_line_id": taxResult.TaxID, + "debit": 0.0, + "credit": taxResult.Amount, + "balance": -taxResult.Amount, + } + if _, err := lineRS.Create(taxLineVals); err != nil { + return nil, fmt.Errorf("account: create tax line: %w", err) + } + totalDebit += taxResult.Amount + } + } + } + + // Step 3: Create the receivable line (debit = total of all credits) + receivableVals := orm.Values{ + "move_id": moveID, + "name": "/", + "quantity": 1.0, + "account_id": receivableAccountID, + "company_id": companyID, + "journal_id": journalID, + "currency_id": currencyID, + "partner_id": partnerID, + "display_type": "payment_term", + "debit": totalDebit, + "credit": 0.0, + "balance": totalDebit, + } + if _, err := lineRS.Create(receivableVals); err != nil { + return nil, fmt.Errorf("account: create receivable line: %w", err) + } + + return moveID, nil + }) + // -- Double-Entry Constraint -- // SUM(debit) must equal SUM(credit) per journal entry. // Mirrors: odoo/addons/account/models/account_move.py _check_balanced() @@ -300,6 +443,140 @@ func initAccountMove() { return nil }) + // -- DefaultGet: Provide dynamic defaults for new records -- + // Mirrors: odoo/addons/account/models/account_move.py AccountMove.default_get() + // Supplies date, journal_id, company_id, currency_id when creating a new invoice. + m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values { + vals := make(orm.Values) + + // Default date = today + vals["date"] = time.Now().Format("2006-01-02") + + // Default company from the current user's session + companyID := env.CompanyID() + if companyID > 0 { + vals["company_id"] = companyID + } + + // Default journal: first active sales journal for the company + var journalID int64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM account_journal + WHERE type = 'sale' AND active = true AND company_id = $1 + ORDER BY sequence, id LIMIT 1`, companyID).Scan(&journalID) + if err == nil && journalID > 0 { + vals["journal_id"] = journalID + } + + // Default currency from the company + var currencyID int64 + err = env.Tx().QueryRow(env.Ctx(), + `SELECT currency_id FROM res_company WHERE id = $1`, companyID).Scan(¤cyID) + if err == nil && currencyID > 0 { + vals["currency_id"] = currencyID + } + + return vals + } + + // -- Onchange: partner_id → auto-fill partner address fields -- + // Mirrors: odoo/addons/account/models/account_move.py _onchange_partner_id() + // When the partner changes on an invoice, look up the partner's address + // and populate the commercial_partner_id field. + m.RegisterOnchange("partner_id", func(env *orm.Environment, vals orm.Values) orm.Values { + result := make(orm.Values) + partnerID, ok := toInt64Arg(vals["partner_id"]) + if !ok || partnerID == 0 { + return result + } + + var name string + var commercialID *int64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT p.name, p.commercial_partner_id + FROM res_partner p WHERE p.id = $1`, partnerID, + ).Scan(&name, &commercialID) + if err != nil { + return result + } + + if commercialID != nil && *commercialID > 0 { + result["commercial_partner_id"] = *commercialID + } else { + result["commercial_partner_id"] = partnerID + } + return result + }) + + // -- Business Method: register_payment -- + // Create a payment for this invoice and reconcile. + // 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() + for _, moveID := range rs.IDs() { + // Read invoice info + var partnerID, journalID, companyID, currencyID int64 + var amountTotal 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') + FROM account_move WHERE id = $1`, moveID, + ).Scan(&partnerID, &journalID, &companyID, ¤cyID, &amountTotal, &moveType) + if err != nil { + return nil, fmt.Errorf("account: read invoice %d for payment: %w", moveID, err) + } + + // Determine payment type and partner type + paymentType := "inbound" // customer pays us + partnerType := "customer" + if moveType == "in_invoice" || moveType == "in_refund" { + paymentType = "outbound" // we pay vendor + partnerType = "supplier" + } + + // Find bank journal + var bankJournalID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM account_journal WHERE type = 'bank' AND company_id = $1 LIMIT 1`, + companyID).Scan(&bankJournalID) + if bankJournalID == 0 { + 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) + if err != nil { + return nil, fmt.Errorf("account: create payment move for invoice %d: %w", moveID, err) + } + + // Create payment record linked to the journal entry + _, err = env.Tx().Exec(env.Ctx(), + `INSERT INTO account_payment + (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, + currencyID, bankJournalID, partnerID, companyID, payMoveID) + if err != nil { + return nil, fmt.Errorf("account: create payment for invoice %d: %w", moveID, err) + } + + // Update invoice payment state + _, err = env.Tx().Exec(env.Ctx(), + `UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID) + if err != nil { + return nil, fmt.Errorf("account: update payment state for invoice %d: %w", moveID, err) + } + } + return true, nil + }) + // -- BeforeCreate Hook: Generate sequence number -- m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error { name, _ := vals["name"].(string) @@ -566,3 +843,36 @@ func initAccountBankStatement() { orm.Boolean("is_reconciled", orm.FieldOpts{String: "Is Reconciled"}), ) } + +// -- Helper functions for argument parsing in business methods -- + +// toInt64Arg converts various numeric types (float64, int64, int, int32) to int64. +// Returns (value, true) on success, (0, false) if not convertible. +func toInt64Arg(v interface{}) (int64, bool) { + switch n := v.(type) { + case int64: + return n, true + case float64: + return int64(n), true + case int: + return int64(n), true + case int32: + return int64(n), true + } + return 0, false +} + +// floatArg extracts a float64 from an interface{}, returning defaultVal if not possible. +func floatArg(v interface{}, defaultVal float64) float64 { + switch n := v.(type) { + case float64: + return n + case int64: + return float64(n) + case int: + return float64(n) + case int32: + return float64(n) + } + return defaultVal +} diff --git a/addons/account/models/account_tax_calc.go b/addons/account/models/account_tax_calc.go new file mode 100644 index 0000000..7b5ff66 --- /dev/null +++ b/addons/account/models/account_tax_calc.go @@ -0,0 +1,70 @@ +package models + +import "odoo-go/pkg/orm" + +// TaxResult holds the result of computing a tax on an amount. +type TaxResult struct { + TaxID int64 + TaxName string + Amount float64 // tax amount + Base float64 // base amount (before tax) + AccountID int64 // account to post tax to +} + +// ComputeTax calculates tax for a given base amount. +// Mirrors: odoo/addons/account/models/account_tax.py AccountTax._compute_amount() +func ComputeTax(env *orm.Environment, taxID int64, baseAmount float64) (*TaxResult, error) { + var name string + var amount float64 + var amountType string + var priceInclude bool + + err := env.Tx().QueryRow(env.Ctx(), + `SELECT name, amount, amount_type, COALESCE(price_include, false) + FROM account_tax WHERE id = $1`, taxID, + ).Scan(&name, &amount, &amountType, &priceInclude) + if err != nil { + return nil, err + } + + var taxAmount float64 + switch amountType { + case "percent": + if priceInclude { + taxAmount = baseAmount - (baseAmount / (1 + amount/100)) + } else { + taxAmount = baseAmount * amount / 100 + } + case "fixed": + taxAmount = amount + case "division": + if priceInclude { + taxAmount = baseAmount - (baseAmount / (1 + amount/100)) + } else { + taxAmount = baseAmount * amount / 100 + } + } + + // Find the tax account (from repartition lines) + var accountID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(account_id, 0) FROM account_tax_repartition_line + WHERE tax_id = $1 AND repartition_type = 'tax' AND document_type = 'invoice' + LIMIT 1`, taxID, + ).Scan(&accountID) + + // Fallback: use the USt account 1776 (SKR03) + if accountID == 0 { + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM account_account WHERE code = '1776' LIMIT 1`, + ).Scan(&accountID) + } + + return &TaxResult{ + TaxID: taxID, + TaxName: name, + Amount: taxAmount, + Base: baseAmount, + AccountID: accountID, + }, nil +} diff --git a/addons/crm/models/crm.go b/addons/crm/models/crm.go index 17f4b8a..2c43c9d 100644 --- a/addons/crm/models/crm.go +++ b/addons/crm/models/crm.go @@ -53,7 +53,7 @@ func initCRMLead() { orm.Integer("color", orm.FieldOpts{String: "Color Index"}), orm.Many2many("tag_ids", "crm.tag", orm.FieldOpts{String: "Tags"}), orm.Many2one("company_id", "res.company", orm.FieldOpts{ - String: "Company", Required: true, Index: true, + String: "Company", Index: true, }), orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), orm.Float("probability", orm.FieldOpts{String: "Probability (%)"}), @@ -66,6 +66,45 @@ func initCRMLead() { orm.Char("zip", orm.FieldOpts{String: "Zip"}), 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 + m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values { + vals := make(orm.Values) + if env.CompanyID() > 0 { + vals["company_id"] = env.CompanyID() + } + return vals + } + + // action_set_won: mark lead as won + m.RegisterMethod("action_set_won", 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 state = 'won', probability = 100 WHERE id = $1`, id) + } + return true, nil + }) + + // action_set_lost: mark lead as lost + m.RegisterMethod("action_set_lost", 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 state = 'lost', probability = 0, active = false WHERE id = $1`, id) + } + return true, nil + }) + + // convert_to_opportunity: lead → opportunity + 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) + } + return true, nil + }) } // initCRMStage registers the crm.stage model. diff --git a/addons/fleet/models/fleet.go b/addons/fleet/models/fleet.go index 18d60d7..deb9c8f 100644 --- a/addons/fleet/models/fleet.go +++ b/addons/fleet/models/fleet.go @@ -1,16 +1,19 @@ package models -import "odoo-go/pkg/orm" +import ( + "fmt" + "odoo-go/pkg/orm" +) // initFleetVehicle registers the fleet.vehicle model. // Mirrors: odoo/addons/fleet/models/fleet_vehicle.py func initFleetVehicle() { - m := orm.NewModel("fleet.vehicle", orm.ModelOpts{ + vehicle := orm.NewModel("fleet.vehicle", orm.ModelOpts{ Description: "Vehicle", Order: "license_plate asc, name asc", }) - m.AddFields( + vehicle.AddFields( orm.Char("name", orm.FieldOpts{String: "Name", Compute: "_compute_vehicle_name", Store: true}), orm.Char("license_plate", orm.FieldOpts{String: "License Plate", Required: true, Index: true}), orm.Char("vin_sn", orm.FieldOpts{String: "Chassis Number", Help: "Unique vehicle identification number (VIN)"}), @@ -57,6 +60,42 @@ func initFleetVehicle() { orm.One2many("log_services", "fleet.vehicle.log.services", "vehicle_id", orm.FieldOpts{String: "Services"}), orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), ) + + // action_accept: set vehicle state to active + vehicle.RegisterMethod("action_accept", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + // Find or create 'active' state + var stateID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM fleet_vehicle_state WHERE name = 'Active' LIMIT 1`).Scan(&stateID) + if stateID > 0 { + env.Tx().Exec(env.Ctx(), + `UPDATE fleet_vehicle SET state_id = $1 WHERE id = $2`, stateID, id) + } + } + return true, nil + }) + + // log_odometer: record an odometer reading + vehicle.RegisterMethod("log_odometer", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + if len(args) < 1 { + return nil, fmt.Errorf("fleet: odometer value required") + } + value, ok := args[0].(float64) + if !ok { + return nil, fmt.Errorf("fleet: invalid odometer value") + } + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `INSERT INTO fleet_vehicle_odometer (vehicle_id, value, date) VALUES ($1, $2, NOW())`, + id, value) + env.Tx().Exec(env.Ctx(), + `UPDATE fleet_vehicle SET odometer = $1 WHERE id = $2`, value, id) + } + return true, nil + }) } // initFleetVehicleModel registers the fleet.vehicle.model model. diff --git a/addons/hr/models/hr.go b/addons/hr/models/hr.go index e34f890..86e44d9 100644 --- a/addons/hr/models/hr.go +++ b/addons/hr/models/hr.go @@ -92,6 +92,16 @@ func initHREmployee() { orm.Integer("km_home_work", orm.FieldOpts{String: "Home-Work Distance (km)"}), orm.Binary("image_1920", orm.FieldOpts{String: "Image"}), ) + + // toggle_active: archive/unarchive employee + 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(), + `UPDATE hr_employee SET active = NOT active WHERE id = $1`, id) + } + return true, nil + }) } // initHRDepartment registers the hr.department model. diff --git a/addons/l10n_de/din5008.go b/addons/l10n_de/din5008.go new file mode 100644 index 0000000..b1b6143 --- /dev/null +++ b/addons/l10n_de/din5008.go @@ -0,0 +1,42 @@ +package l10n_de + +import "fmt" + +// DIN5008Config holds the German business letter format configuration. +type DIN5008Config struct { + // Type A: 27mm top margin, Type B: 45mm top margin + Type string // "A" or "B" + PaperFormat string // "A4" + MarginTop int // mm + MarginBottom int // mm + MarginLeft int // mm + MarginRight int // mm + AddressFieldY int // mm from top (Type A: 27mm, Type B: 45mm) + InfoBlockX int // mm from left for info block +} + +// DefaultDIN5008A returns Type A configuration. +func DefaultDIN5008A() DIN5008Config { + return DIN5008Config{ + Type: "A", PaperFormat: "A4", + MarginTop: 27, MarginBottom: 20, + MarginLeft: 25, MarginRight: 20, + AddressFieldY: 27, InfoBlockX: 125, + } +} + +// DATEVExportConfig holds configuration for DATEV accounting export. +type DATEVExportConfig struct { + ConsultantNumber int // Beraternummer + ClientNumber int // Mandantennummer + FiscalYearStart string // YYYY-MM-DD + ChartOfAccounts string // "skr03" or "skr04" + BookingType string // "DTVF" (Datev-Format) +} + +// FormatDATEVHeader generates the DATEV CSV header line. +func FormatDATEVHeader(cfg DATEVExportConfig) string { + return "\"EXTF\";700;21;\"Buchungsstapel\";7;;" + + fmt.Sprintf("%d;%d;", cfg.ConsultantNumber, cfg.ClientNumber) + + cfg.FiscalYearStart + ";" + "4" + ";;" // 4 = length of fiscal year +} diff --git a/addons/project/models/project.go b/addons/project/models/project.go index 426bebc..0dfe637 100644 --- a/addons/project/models/project.go +++ b/addons/project/models/project.go @@ -1,6 +1,9 @@ package models -import "odoo-go/pkg/orm" +import ( + "fmt" + "odoo-go/pkg/orm" +) // initProjectProject registers the project.project model. // Mirrors: odoo/addons/project/models/project_project.py @@ -34,12 +37,12 @@ func initProjectProject() { // initProjectTask registers the project.task model. // Mirrors: odoo/addons/project/models/project_task.py func initProjectTask() { - m := orm.NewModel("project.task", orm.ModelOpts{ + task := orm.NewModel("project.task", orm.ModelOpts{ Description: "Task", Order: "priority desc, sequence, id desc", }) - m.AddFields( + task.AddFields( orm.Char("name", orm.FieldOpts{String: "Title", Required: true, Index: true}), orm.HTML("description", orm.FieldOpts{String: "Description"}), orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), @@ -75,6 +78,48 @@ func initProjectTask() { {Value: "line_note", Label: "Note"}, }, orm.FieldOpts{String: "Display Type"}), ) + + // DefaultGet: set company_id from session + task.DefaultGet = func(env *orm.Environment, fields []string) orm.Values { + vals := make(orm.Values) + if env.CompanyID() > 0 { + vals["company_id"] = env.CompanyID() + } + return vals + } + + // action_done: mark task as done + 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) + } + 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) + } + 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) + } + return true, nil + }) + + // Ensure fmt is used + _ = fmt.Sprintf } // initProjectTaskType registers the project.task.type model (stages). diff --git a/addons/purchase/models/purchase_order.go b/addons/purchase/models/purchase_order.go index fa6ea3e..c96a8e5 100644 --- a/addons/purchase/models/purchase_order.go +++ b/addons/purchase/models/purchase_order.go @@ -1,6 +1,11 @@ package models -import "odoo-go/pkg/orm" +import ( + "fmt" + "time" + + "odoo-go/pkg/orm" +) // initPurchaseOrder registers purchase.order and purchase.order.line. // Mirrors: odoo/addons/purchase/models/purchase_order.py @@ -37,7 +42,7 @@ func initPurchaseOrder() { String: "Vendor", Required: true, Index: true, }), orm.Datetime("date_order", orm.FieldOpts{ - String: "Order Deadline", Required: true, Index: true, + String: "Order Deadline", Required: true, Index: true, Default: "today", }), orm.Datetime("date_planned", orm.FieldOpts{ String: "Expected Arrival", @@ -102,6 +107,147 @@ func initPurchaseOrder() { orm.Char("origin", orm.FieldOpts{String: "Source Document"}), ) + // button_confirm: draft → purchase + m.RegisterMethod("button_confirm", 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") + } + env.Tx().Exec(env.Ctx(), + `UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, id) + } + return true, nil + }) + + // button_cancel + m.RegisterMethod("button_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE purchase_order SET state = 'cancel' WHERE id = $1`, id) + } + return true, nil + }) + + // action_create_bill: Generate a vendor bill (account.move in_invoice) 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) { + env := rs.Env() + var billIDs []int64 + + for _, poID := range rs.IDs() { + var partnerID, companyID, currencyID 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) + if err != nil { + return nil, fmt.Errorf("purchase: read PO %d for bill: %w", poID, err) + } + + // 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 + rows, err := env.Tx().Query(env.Ctx(), + `SELECT COALESCE(name,''), COALESCE(product_qty,1), COALESCE(price_unit,0), COALESCE(discount,0) + FROM purchase_order_line + WHERE order_id = $1 ORDER BY sequence, id`, poID) + if err != nil { + return nil, fmt.Errorf("purchase: read PO lines %d: %w", poID, err) + } + + type poLine struct { + name string + qty float64 + price float64 + discount float64 + } + var lines []poLine + for rows.Next() { + var l poLine + if err := rows.Scan(&l.name, &l.qty, &l.price, &l.discount); err != nil { + rows.Close() + return nil, err + } + lines = append(lines, l) + } + rows.Close() + + // 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) + if err != nil { + return nil, fmt.Errorf("purchase: create bill for PO %d: %w", poID, err) + } + + // Try to generate a proper sequence name + seq, seqErr := orm.NextByCode(env, "account.move.in_invoice") + if seqErr != nil { + seq, seqErr = orm.NextByCode(env, "account.move") + } + if seqErr == nil && seq != "" { + env.Tx().Exec(env.Ctx(), + `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) + 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) + } + + 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) + } + } + return billIDs, nil + }) + + // BeforeCreate: auto-assign sequence number + m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error { + name, _ := vals["name"].(string) + if name == "" || name == "/" || name == "New" { + seq, err := orm.NextByCode(env, "purchase.order") + if err != nil { + // Fallback: generate a simple name + vals["name"] = fmt.Sprintf("PO/%d", time.Now().UnixNano()%100000) + } else { + vals["name"] = seq + } + } + return nil + } + // purchase.order.line — individual line items on a PO initPurchaseOrderLine() } diff --git a/addons/sale/models/sale_order.go b/addons/sale/models/sale_order.go index 75731c1..e3d0398 100644 --- a/addons/sale/models/sale_order.go +++ b/addons/sale/models/sale_order.go @@ -2,6 +2,7 @@ package models import ( "fmt" + "time" "odoo-go/pkg/orm" ) @@ -43,7 +44,7 @@ func initSaleOrder() { // -- Dates -- m.AddFields( orm.Datetime("date_order", orm.FieldOpts{ - String: "Order Date", Required: true, Index: true, + String: "Order Date", Required: true, Index: true, Default: "today", }), orm.Date("validity_date", orm.FieldOpts{String: "Expiration"}), ) @@ -111,6 +112,50 @@ func initSaleOrder() { orm.Boolean("require_payment", orm.FieldOpts{String: "Online Payment"}), ) + // -- Computed: _compute_amounts -- + // Computes untaxed, tax, and total amounts from order lines. + // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amounts() + computeSaleAmounts := func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + soID := rs.IDs()[0] + + var untaxed float64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(product_uom_qty * price_unit * (1 - COALESCE(discount,0)/100)), 0) + FROM sale_order_line WHERE order_id = $1 + AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`, + soID).Scan(&untaxed) + if err != nil { + return nil, fmt.Errorf("sale: compute amounts for SO %d: %w", soID, err) + } + + // Compute tax from linked tax records on lines; fall back to sum of line taxes + var tax float64 + err = env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM( + product_uom_qty * price_unit * (1 - COALESCE(discount,0)/100) + * COALESCE((SELECT t.amount / 100 FROM account_tax t + JOIN sale_order_line_account_tax_rel rel ON rel.account_tax_id = t.id + WHERE rel.sale_order_line_id = sol.id LIMIT 1), 0) + ), 0) + FROM sale_order_line sol WHERE sol.order_id = $1 + AND (sol.display_type IS NULL OR sol.display_type = '' OR sol.display_type = 'product')`, + soID).Scan(&tax) + if err != nil { + // Fallback: if the M2M table doesn't exist, estimate tax at 0 + tax = 0 + } + + return orm.Values{ + "amount_untaxed": untaxed, + "amount_tax": tax, + "amount_total": untaxed + tax, + }, nil + } + m.RegisterCompute("amount_untaxed", computeSaleAmounts) + m.RegisterCompute("amount_tax", computeSaleAmounts) + m.RegisterCompute("amount_total", computeSaleAmounts) + // -- Sequence Hook -- m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error { name, _ := vals["name"].(string) @@ -234,7 +279,7 @@ func initSaleOrder() { "currency_id": currencyID, "journal_id": journalID, "invoice_origin": fmt.Sprintf("SO%d", soID), - "date": "2026-03-30", // TODO: use current date + "date": time.Now().Format("2006-01-02"), "line_ids": lineCmds, }) if err != nil { @@ -250,6 +295,109 @@ func initSaleOrder() { return invoiceIDs, nil }) + + // action_create_delivery: Generate a stock picking (delivery) from a confirmed sale order. + // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._action_confirm() → _create_picking() + m.RegisterMethod("action_create_delivery", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + var pickingIDs []int64 + + for _, soID := range rs.IDs() { + // Read SO header for partner and company + var partnerID, companyID int64 + var soName string + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(partner_shipping_id, partner_id), company_id, name + FROM sale_order WHERE id = $1`, soID, + ).Scan(&partnerID, &companyID, &soName) + if err != nil { + return nil, fmt.Errorf("sale: read SO %d for delivery: %w", soID, err) + } + + // Read SO lines with products + rows, err := env.Tx().Query(env.Ctx(), + `SELECT product_id, product_uom_qty, COALESCE(name, '') 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) + if err != nil { + return nil, fmt.Errorf("sale: read SO lines %d for delivery: %w", soID, err) + } + + type soline struct { + productID int64 + qty float64 + name string + } + var lines []soline + for rows.Next() { + var l soline + if err := rows.Scan(&l.productID, &l.qty, &l.name); err != nil { + rows.Close() + return nil, err + } + lines = append(lines, l) + } + rows.Close() + if len(lines) == 0 { + continue + } + + // Find outgoing picking type and locations + var pickingTypeID, srcLocID, destLocID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT pt.id, COALESCE(pt.default_location_src_id, 0), COALESCE(pt.default_location_dest_id, 0) + FROM stock_picking_type pt + WHERE pt.code = 'outgoing' AND pt.company_id = $1 + LIMIT 1`, companyID, + ).Scan(&pickingTypeID, &srcLocID, &destLocID) + + // Fallback: find internal and customer locations + if srcLocID == 0 { + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM stock_location WHERE usage = 'internal' AND COALESCE(company_id, $1) = $1 LIMIT 1`, + companyID).Scan(&srcLocID) + } + if destLocID == 0 { + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM stock_location WHERE usage = 'customer' LIMIT 1`).Scan(&destLocID) + } + if pickingTypeID == 0 { + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM stock_picking_type WHERE code = 'outgoing' LIMIT 1`).Scan(&pickingTypeID) + } + + // Create picking + var pickingID int64 + err = env.Tx().QueryRow(env.Ctx(), + `INSERT INTO stock_picking + (name, state, scheduled_date, company_id, partner_id, picking_type_id, + location_id, location_dest_id, origin) + VALUES ($1, 'confirmed', NOW(), $2, $3, $4, $5, $6, $7) RETURNING id`, + fmt.Sprintf("WH/OUT/%05d", soID), companyID, partnerID, pickingTypeID, + srcLocID, destLocID, soName, + ).Scan(&pickingID) + if err != nil { + return nil, fmt.Errorf("sale: create picking for SO %d: %w", soID, err) + } + + // Create stock moves + for _, l := range lines { + _, err = env.Tx().Exec(env.Ctx(), + `INSERT INTO stock_move + (name, product_id, product_uom_qty, state, picking_id, company_id, + location_id, location_dest_id, date, origin, product_uom) + VALUES ($1, $2, $3, 'confirmed', $4, $5, $6, $7, NOW(), $8, 1)`, + l.name, l.productID, l.qty, pickingID, companyID, + srcLocID, destLocID, soName) + if err != nil { + return nil, fmt.Errorf("sale: create stock move for SO %d: %w", soID, err) + } + } + + pickingIDs = append(pickingIDs, pickingID) + } + return pickingIDs, nil + }) } // initSaleOrderLine registers sale.order.line — individual line items on a sales order. diff --git a/addons/stock/models/stock.go b/addons/stock/models/stock.go index c642ad0..ca2ca08 100644 --- a/addons/stock/models/stock.go +++ b/addons/stock/models/stock.go @@ -1,6 +1,11 @@ package models -import "odoo-go/pkg/orm" +import ( + "fmt" + "time" + + "odoo-go/pkg/orm" +) // initStock registers all stock models. // Mirrors: odoo/addons/stock/models/stock_warehouse.py, @@ -168,6 +173,150 @@ func initStockPicking() { orm.Text("note", orm.FieldOpts{String: "Notes"}), orm.Char("origin", orm.FieldOpts{String: "Source Document", Index: true}), ) + + // --- BeforeCreate hook: auto-generate picking reference --- + // Mirrors: stock.picking._create_sequence() / ir.sequence + 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) + } + return nil + } + + // --- Business methods: stock move workflow --- + + // action_confirm transitions a picking from draft → confirmed. + // Confirms all associated stock moves that are still in draft. + // Mirrors: stock.picking.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 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT state FROM stock_picking WHERE id = $1`, id).Scan(&state) + if err != nil { + return nil, fmt.Errorf("stock: cannot read picking %d: %w", id, err) + } + 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) + } + // Also confirm all draft moves on this picking + _, err = env.Tx().Exec(env.Ctx(), + `UPDATE stock_move SET state = 'confirmed' WHERE picking_id = $1 AND state = 'draft'`, id) + if err != nil { + return nil, fmt.Errorf("stock: confirm moves for picking %d: %w", id, err) + } + } + return true, nil + }) + + // action_assign transitions a picking from confirmed → assigned (reserve stock). + // Mirrors: stock.picking.action_assign() + m.RegisterMethod("action_assign", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + _, err := env.Tx().Exec(env.Ctx(), + `UPDATE stock_picking SET state = 'assigned' WHERE id = $1`, id) + if err != nil { + return nil, fmt.Errorf("stock: assign picking %d: %w", id, err) + } + _, err = env.Tx().Exec(env.Ctx(), + `UPDATE stock_move SET state = 'assigned' WHERE picking_id = $1 AND state IN ('confirmed', 'partially_available')`, id) + if err != nil { + return nil, fmt.Errorf("stock: assign moves for picking %d: %w", id, err) + } + } + return true, nil + }) + + // button_validate transitions a picking from assigned → done (process the transfer). + // Updates all moves to done and adjusts stock quants (source decremented, dest incremented). + // Mirrors: stock.picking.button_validate() + m.RegisterMethod("button_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + // Mark picking as done + _, err := env.Tx().Exec(env.Ctx(), + `UPDATE stock_picking SET state = 'done', date_done = NOW() WHERE id = $1`, id) + if err != nil { + return nil, fmt.Errorf("stock: validate picking %d: %w", id, err) + } + // Mark all moves as done + _, err = env.Tx().Exec(env.Ctx(), + `UPDATE stock_move SET state = 'done', date = NOW() WHERE picking_id = $1`, id) + if err != nil { + return nil, fmt.Errorf("stock: validate moves for picking %d: %w", id, err) + } + + // Update quants: for each done move, adjust source and destination locations + rows, err := env.Tx().Query(env.Ctx(), + `SELECT product_id, product_uom_qty, location_id, location_dest_id + FROM stock_move WHERE picking_id = $1 AND state = 'done'`, id) + if err != nil { + return nil, fmt.Errorf("stock: read moves for picking %d: %w", id, err) + } + + type moveInfo struct { + productID int64 + qty float64 + srcLoc int64 + dstLoc int64 + } + var moves []moveInfo + for rows.Next() { + var mi moveInfo + if err := rows.Scan(&mi.productID, &mi.qty, &mi.srcLoc, &mi.dstLoc); err != nil { + rows.Close() + return nil, fmt.Errorf("stock: scan move for picking %d: %w", id, err) + } + moves = append(moves, mi) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("stock: iterate moves for picking %d: %w", id, err) + } + + for _, mi := range moves { + // Decrease source location quant + if err := updateQuant(env, mi.productID, mi.srcLoc, -mi.qty); err != nil { + return nil, fmt.Errorf("stock: update source quant for picking %d: %w", id, err) + } + // Increase destination location quant + if err := updateQuant(env, mi.productID, mi.dstLoc, mi.qty); err != nil { + return nil, fmt.Errorf("stock: update dest quant for picking %d: %w", id, err) + } + } + } + return true, nil + }) +} + +// 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, company_id) VALUES ($1, $2, $3, 1)`, + productID, locationID, delta) + } + return err } // initStockMove registers stock.move — individual product movements. diff --git a/cmd/transpile/main.go b/cmd/transpile/main.go new file mode 100644 index 0000000..99042d7 --- /dev/null +++ b/cmd/transpile/main.go @@ -0,0 +1,169 @@ +// Command transpile converts Odoo ES module JS files to odoo.define() format. +// This is a pure Go replacement for tools/transpile_assets.py, eliminating +// the Python dependency entirely. +// +// Usage: +// +// go run ./cmd/transpile -source ../odoo -output build/js +// go run ./cmd/transpile -source ../odoo -output build/js -list pkg/server/assets_js.txt +package main + +import ( + "bufio" + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + "odoo-go/pkg/server" +) + +func main() { + log.SetFlags(0) + + sourceDir := flag.String("source", "", "Path to Odoo source tree (e.g. ../odoo)") + outputDir := flag.String("output", "build/js", "Output directory for transpiled JS files") + listFile := flag.String("list", "pkg/server/assets_js.txt", "Path to the JS asset list file") + flag.Parse() + + if *sourceDir == "" { + log.Fatal("error: -source flag is required (path to Odoo source tree)") + } + + // Resolve paths + absSource, err := filepath.Abs(*sourceDir) + if err != nil { + log.Fatalf("error: invalid source path: %v", err) + } + absOutput, err := filepath.Abs(*outputDir) + if err != nil { + log.Fatalf("error: invalid output path: %v", err) + } + + // Read asset list + jsFiles, err := readAssetList(*listFile) + if err != nil { + log.Fatalf("error: reading asset list %s: %v", *listFile, err) + } + + log.Printf("transpile: %d JS files from %s", len(jsFiles), *listFile) + log.Printf("transpile: source: %s", absSource) + log.Printf("transpile: output: %s", absOutput) + + // Addon directories to search (in order of priority) + addonsDirs := []string{ + filepath.Join(absSource, "addons"), + filepath.Join(absSource, "odoo", "addons"), + } + + var transpiled, copied, skipped, errors int + + for _, urlPath := range jsFiles { + // Find the source file in one of the addons directories + relPath := strings.TrimPrefix(urlPath, "/") + sourceFile := findFile(addonsDirs, relPath) + if sourceFile == "" { + log.Printf(" SKIP (not found): %s", urlPath) + skipped++ + continue + } + + // Read source content + content, err := os.ReadFile(sourceFile) + if err != nil { + log.Printf(" ERROR reading %s: %v", sourceFile, err) + errors++ + continue + } + + // Determine output path + outFile := filepath.Join(absOutput, relPath) + + // Ensure output directory exists + if err := os.MkdirAll(filepath.Dir(outFile), 0o755); err != nil { + log.Printf(" ERROR mkdir %s: %v", filepath.Dir(outFile), err) + errors++ + continue + } + + src := string(content) + + if server.IsOdooModule(urlPath, src) { + // Transpile ES module to odoo.define() format + result := server.TranspileJS(urlPath, src) + if err := os.WriteFile(outFile, []byte(result), 0o644); err != nil { + log.Printf(" ERROR writing %s: %v", outFile, err) + errors++ + continue + } + transpiled++ + } else { + // Not an ES module: copy as-is + if err := copyFile(sourceFile, outFile); err != nil { + log.Printf(" ERROR copying %s: %v", urlPath, err) + errors++ + continue + } + copied++ + } + } + + fmt.Printf("\nDone: %d transpiled, %d copied as-is, %d skipped, %d errors\n", + transpiled, copied, skipped, errors) + fmt.Printf("Output: %s\n", absOutput) + + if errors > 0 { + os.Exit(1) + } +} + +// readAssetList reads a newline-separated list of JS file paths. +func readAssetList(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var files []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" && !strings.HasPrefix(line, "#") { + files = append(files, line) + } + } + return files, scanner.Err() +} + +// findFile searches for a relative path in the given directories. +func findFile(dirs []string, relPath string) string { + for _, dir := range dirs { + candidate := filepath.Join(dir, relPath) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + } + return "" +} + +// copyFile copies a file from src to dst. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} diff --git a/odoo-server b/odoo-server new file mode 100755 index 0000000..a205fd1 Binary files /dev/null and b/odoo-server differ diff --git a/pkg/orm/domain.go b/pkg/orm/domain.go index 9e67c7c..2632d46 100644 --- a/pkg/orm/domain.go +++ b/pkg/orm/domain.go @@ -262,19 +262,19 @@ func (dc *DomainCompiler) compileSimpleCondition(column, operator string, value return fmt.Sprintf("%q NOT IN (%s)", column, strings.Join(placeholders, ", ")), nil case "like": - dc.params = append(dc.params, value) + dc.params = append(dc.params, wrapLikeValue(value)) return fmt.Sprintf("%q LIKE $%d", column, paramIdx), nil case "not like": - dc.params = append(dc.params, value) + dc.params = append(dc.params, wrapLikeValue(value)) return fmt.Sprintf("%q NOT LIKE $%d", column, paramIdx), nil case "ilike": - dc.params = append(dc.params, value) + dc.params = append(dc.params, wrapLikeValue(value)) return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil case "not ilike": - dc.params = append(dc.params, value) + dc.params = append(dc.params, wrapLikeValue(value)) return fmt.Sprintf("%q NOT ILIKE $%d", column, paramIdx), nil case "=like": @@ -369,26 +369,27 @@ func (dc *DomainCompiler) compileQualifiedCondition(qualifiedColumn, operator st } return fmt.Sprintf("%s %s (%s)", qualifiedColumn, op, strings.Join(placeholders, ", ")), nil - case "like", "not like", "ilike", "not ilike", "=like", "=ilike": - dc.params = append(dc.params, value) - sqlOp := strings.ToUpper(strings.TrimPrefix(operator, "=")) - if strings.HasPrefix(operator, "=") { - sqlOp = strings.ToUpper(operator[1:]) - } + case "like", "not like", "ilike", "not ilike": + dc.params = append(dc.params, wrapLikeValue(value)) + sqlOp := "LIKE" switch operator { - case "like": - sqlOp = "LIKE" case "not like": sqlOp = "NOT LIKE" - case "ilike", "=ilike": + case "ilike": sqlOp = "ILIKE" case "not ilike": sqlOp = "NOT ILIKE" - case "=like": - sqlOp = "LIKE" } return fmt.Sprintf("%s %s $%d", qualifiedColumn, sqlOp, paramIdx), nil + case "=like": + dc.params = append(dc.params, value) + return fmt.Sprintf("%s LIKE $%d", qualifiedColumn, paramIdx), nil + + case "=ilike": + dc.params = append(dc.params, value) + return fmt.Sprintf("%s ILIKE $%d", qualifiedColumn, paramIdx), nil + default: dc.params = append(dc.params, value) return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), nil @@ -427,3 +428,18 @@ func normalizeSlice(value Value) []interface{} { } return nil } + +// wrapLikeValue wraps a string value with % wildcards for LIKE/ILIKE operators, +// matching Odoo's behavior where ilike/like auto-wrap the search term. +// If the value already contains %, it is left as-is. +// Mirrors: odoo/orm/domains.py _expression._unaccent_wrap (value wrapping) +func wrapLikeValue(value Value) Value { + s, ok := value.(string) + if !ok { + return value + } + if strings.Contains(s, "%") || strings.Contains(s, "_") { + return value // Already has wildcards, leave as-is + } + return "%" + s + "%" +} diff --git a/pkg/orm/model.go b/pkg/orm/model.go index 1aac786..7f7cc47 100644 --- a/pkg/orm/model.go +++ b/pkg/orm/model.go @@ -46,6 +46,7 @@ type Model struct { // Hooks BeforeCreate func(env *Environment, vals Values) error // Called before INSERT + DefaultGet func(env *Environment, fields []string) Values // Dynamic defaults (e.g., from DB) Constraints []ConstraintFunc // Validation constraints Methods map[string]MethodFunc // Named business methods @@ -53,6 +54,11 @@ type Model struct { computes map[string]ComputeFunc // field_name → compute function dependencyMap map[string][]string // trigger_field → []computed_field_names + // Onchange handlers + // Maps field_name → handler that receives current vals and returns computed updates. + // Mirrors: @api.onchange in Odoo. + OnchangeHandlers map[string]func(env *Environment, vals Values) Values + // Resolved parents []*Model // Resolved parent models from _inherit allFields map[string]*Field // Including fields from parents @@ -227,6 +233,17 @@ func (m *Model) RegisterMethod(name string, fn MethodFunc) *Model { return m } +// RegisterOnchange registers an onchange handler for a field. +// When the field changes on the client, the handler is called with the current +// record values and returns computed field updates. +// Mirrors: @api.onchange('field_name') in Odoo. +func (m *Model) RegisterOnchange(fieldName string, handler func(env *Environment, vals Values) Values) { + if m.OnchangeHandlers == nil { + m.OnchangeHandlers = make(map[string]func(env *Environment, vals Values) Values) + } + m.OnchangeHandlers[fieldName] = handler +} + // Extend extends this model with additional fields (like _inherit in Odoo). // Mirrors: class MyModelExt(models.Model): _inherit = 'res.partner' func (m *Model) Extend(fields ...*Field) *Model { diff --git a/pkg/orm/recordset.go b/pkg/orm/recordset.go index 5dccbea..43e80d0 100644 --- a/pkg/orm/recordset.go +++ b/pkg/orm/recordset.go @@ -97,6 +97,16 @@ func (rs *Recordset) Create(vals Values) (*Recordset, error) { // Phase 1: Apply defaults for missing fields ApplyDefaults(m, vals) + // Apply dynamic defaults from model's DefaultGet hook (e.g., DB lookups) + if m.DefaultGet != nil { + dynDefaults := m.DefaultGet(rs.env, nil) + for k, v := range dynDefaults { + if _, exists := vals[k]; !exists { + vals[k] = v + } + } + } + // Add magic fields if rs.env.uid > 0 { vals["create_uid"] = rs.env.uid @@ -363,12 +373,13 @@ func (rs *Recordset) Read(fields []string) ([]Values, error) { idPlaceholders[i] = fmt.Sprintf("$%d", i+1) } + // Fetch without ORDER BY — we'll reorder to match rs.ids below. + // This preserves the caller's intended order (e.g., from Search with a custom ORDER). query := fmt.Sprintf( - `SELECT %s FROM %q WHERE "id" IN (%s) ORDER BY %s`, + `SELECT %s FROM %q WHERE "id" IN (%s)`, strings.Join(columns, ", "), m.table, strings.Join(idPlaceholders, ", "), - m.order, ) rows, err := rs.env.tx.Query(rs.env.ctx, query, args...) @@ -377,7 +388,8 @@ func (rs *Recordset) Read(fields []string) ([]Values, error) { } defer rows.Close() - var results []Values + // Collect results keyed by ID so we can reorder them. + resultsByID := make(map[int64]Values, len(rs.ids)) for rows.Next() { scanDest := make([]interface{}, len(columns)) for i := range scanDest { @@ -398,12 +410,22 @@ func (rs *Recordset) Read(fields []string) ([]Values, error) { rs.env.cache.Set(m.name, id, name, val) } } - results = append(results, record) + if id, ok := toRecordID(record["id"]); ok { + resultsByID[id] = record + } } if err := rows.Err(); err != nil { return nil, err } + // Reorder results to match the original rs.ids order. + results := make([]Values, 0, len(rs.ids)) + for _, id := range rs.ids { + if rec, ok := resultsByID[id]; ok { + results = append(results, rec) + } + } + // Post-fetch: M2M fields (from junction tables) if len(m2mFields) > 0 && len(rs.ids) > 0 { for _, fname := range m2mFields { @@ -619,7 +641,7 @@ func (rs *Recordset) NameGet() (map[int64]string, error) { result := make(map[int64]string, len(records)) for _, rec := range records { - id, _ := rec["id"].(int64) + id, _ := toRecordID(rec["id"]) name, _ := rec[recName].(string) result[id] = name } diff --git a/pkg/orm/rules.go b/pkg/orm/rules.go index dde841b..f042713 100644 --- a/pkg/orm/rules.go +++ b/pkg/orm/rules.go @@ -1,6 +1,9 @@ package orm -import "fmt" +import ( + "fmt" + "log" +) // ApplyRecordRules adds ir.rule domain filters to a search. // Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain() @@ -10,32 +13,80 @@ import "fmt" // - Group rules are OR-ed within the group set // - The final domain is: global_rules AND (group_rule_1 OR group_rule_2 OR ...) // -// For the initial implementation, we support company-based record rules: -// Records with a company_id field are filtered to the user's company. +// Implementation: +// 1. Built-in company filter (for models with company_id) +// 2. Custom ir.rule records loaded from the database func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain { if env.su { return domain // Superuser bypasses record rules } - // Auto-apply company filter if model has company_id + // 1. Auto-apply company filter if model has company_id // Records where company_id = user's company OR company_id IS NULL (shared records) if f := m.GetField("company_id"); f != nil && f.Type == TypeMany2one { myCompany := Leaf("company_id", "=", env.CompanyID()) noCompany := Leaf("company_id", "=", nil) companyFilter := Or(myCompany, noCompany) if len(domain) == 0 { - return companyFilter + domain = companyFilter + } else { + // AND the company filter with existing domain + result := Domain{OpAnd} + result = append(result, domain...) + result = append(result, companyFilter...) + domain = result } - // AND the company filter with existing domain - result := Domain{OpAnd} - result = append(result, domain...) - // Wrap company filter in the domain - result = append(result, companyFilter...) - return result } - // TODO: Load custom ir.rule records from DB and compile their domains - // For now, only the built-in company filter is applied + // 2. Load custom ir.rule records from DB + // Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain() + // + // Query rules that apply to this model for the current user: + // - Rule must be active and have perm_read = true + // - Either the rule has no group restriction (global rule), + // or the user belongs to one of the rule's groups. + // Use a savepoint so that a failed query (e.g., missing junction table) + // doesn't abort the parent transaction. + sp, spErr := env.tx.Begin(env.ctx) + if spErr != nil { + return domain + } + rows, err := sp.Query(env.ctx, + `SELECT r.id, r.domain_force, COALESCE(r.global, false) + FROM ir_rule r + JOIN ir_model m ON m.id = r.model_id + WHERE m.model = $1 AND r.active = true + AND r.perm_read = true`, + m.Name()) + if err != nil { + sp.Rollback(env.ctx) + return domain + } + defer func() { + rows.Close() + sp.Commit(env.ctx) + }() + + // Collect domain_force strings from matching rules + // TODO: parse domain_force strings into Domain objects and merge them + ruleCount := 0 + for rows.Next() { + var ruleID int64 + var domainForce *string + var global bool + if err := rows.Scan(&ruleID, &domainForce, &global); err != nil { + continue + } + ruleCount++ + // TODO: parse domainForce (Python-style domain string) into Domain + // and AND global rules / OR group rules into the result domain. + // For now, rules are loaded but domain parsing is deferred. + _ = domainForce + _ = global + } + if ruleCount > 0 { + log.Printf("orm: loaded %d ir.rule record(s) for %s (domain parsing pending)", ruleCount, m.Name()) + } return domain } diff --git a/pkg/server/action.go b/pkg/server/action.go index 1f0e042..5135e1e 100644 --- a/pkg/server/action.go +++ b/pkg/server/action.go @@ -5,6 +5,23 @@ import ( "net/http" ) +// handleLoadBreadcrumbs returns breadcrumb data for the current navigation path. +// Mirrors: odoo/addons/web/controllers/action.py Action.load_breadcrumbs() +func (s *Server) handleLoadBreadcrumbs(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 + } + + s.writeJSONRPC(w, req.ID, []interface{}{}, nil) +} + // handleActionLoad loads an action definition by ID. // Mirrors: odoo/addons/web/controllers/action.py Action.load() func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) { @@ -25,22 +42,211 @@ func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) { } json.Unmarshal(req.Params, ¶ms) - // For now, return the Contacts action for any request - // TODO: Load from ir_act_window table - action := map[string]interface{}{ - "id": 1, - "type": "ir.actions.act_window", - "name": "Contacts", - "res_model": "res.partner", - "view_mode": "list,form", - "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, - "search_view_id": false, - "domain": "[]", - "context": "{}", - "target": "current", - "limit": 80, - "help": "", - "xml_id": "contacts.action_contacts", + // Parse action_id from params (can be float64 from JSON or string) + actionID := 0 + switch v := params.ActionID.(type) { + case float64: + actionID = int(v) + case string: + // Try to parse numeric string + for _, c := range v { + if c >= '0' && c <= '9' { + actionID = actionID*10 + int(c-'0') + } else { + actionID = 0 + break + } + } + } + + // Action definitions by ID + actions := map[int]map[string]interface{}{ + 1: { + "id": 1, + "type": "ir.actions.act_window", + "name": "Contacts", + "res_model": "res.partner", + "view_mode": "list,kanban,form", + "views": [][]interface{}{{nil, "list"}, {nil, "kanban"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "contacts.action_contacts", + }, + 2: { + "id": 2, + "type": "ir.actions.act_window", + "name": "Invoices", + "res_model": "account.move", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": `[("move_type","in",["out_invoice","out_refund"])]`, + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "account.action_move_out_invoice_type", + }, + 3: { + "id": 3, + "type": "ir.actions.act_window", + "name": "Sale Orders", + "res_model": "sale.order", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "sale.action_quotations_with_onboarding", + }, + 4: { + "id": 4, + "type": "ir.actions.act_window", + "name": "CRM Pipeline", + "res_model": "crm.lead", + "view_mode": "kanban,list,form", + "views": [][]interface{}{{nil, "kanban"}, {nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "crm.crm_lead_all_pipeline", + }, + 5: { + "id": 5, + "type": "ir.actions.act_window", + "name": "Transfers", + "res_model": "stock.picking", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "stock.action_picking_tree_all", + }, + 6: { + "id": 6, + "type": "ir.actions.act_window", + "name": "Products", + "res_model": "product.template", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "stock.action_product_template", + }, + 7: { + "id": 7, + "type": "ir.actions.act_window", + "name": "Purchase Orders", + "res_model": "purchase.order", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "purchase.action_purchase_orders", + }, + 8: { + "id": 8, + "type": "ir.actions.act_window", + "name": "Employees", + "res_model": "hr.employee", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "hr.action_hr_employee", + }, + 9: { + "id": 9, + "type": "ir.actions.act_window", + "name": "Departments", + "res_model": "hr.department", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "hr.action_hr_department", + }, + 10: { + "id": 10, + "type": "ir.actions.act_window", + "name": "Projects", + "res_model": "project.project", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "project.action_project", + }, + 11: { + "id": 11, + "type": "ir.actions.act_window", + "name": "Tasks", + "res_model": "project.task", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "project.action_project_task", + }, + 12: { + "id": 12, + "type": "ir.actions.act_window", + "name": "Vehicles", + "res_model": "fleet.vehicle", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "fleet.action_fleet_vehicle", + }, + } + + action, ok := actions[actionID] + if !ok { + // Default to Contacts if unknown action ID + action = actions[1] } s.writeJSONRPC(w, req.ID, action, nil) diff --git a/pkg/server/fields_get.go b/pkg/server/fields_get.go index bc3528c..b800fb1 100644 --- a/pkg/server/fields_get.go +++ b/pkg/server/fields_get.go @@ -12,21 +12,24 @@ func fieldsGetForModel(modelName string) map[string]interface{} { result := make(map[string]interface{}) for name, f := range m.Fields() { + fType := f.Type.String() + fieldInfo := map[string]interface{}{ - "name": name, - "type": f.Type.String(), - "string": f.String, - "help": f.Help, - "readonly": f.Readonly, - "required": f.Required, - "searchable": f.IsStored(), - "sortable": f.IsStored(), - "store": f.IsStored(), - "manual": false, - "depends": f.Depends, - "groupable": f.IsStored() && f.Type != orm.TypeText && f.Type != orm.TypeHTML, - "exportable": true, + "name": name, + "type": fType, + "string": f.String, + "help": f.Help, + "readonly": f.Readonly, + "required": f.Required, + "searchable": f.IsStored(), + "sortable": f.IsStored(), + "store": f.IsStored(), + "manual": false, + "depends": f.Depends, + "groupable": f.IsStored() && f.Type != orm.TypeText && f.Type != orm.TypeHTML, + "exportable": true, "change_default": false, + "company_dependent": false, } // Relational fields @@ -46,7 +49,24 @@ func fieldsGetForModel(modelName string) map[string]interface{} { fieldInfo["selection"] = sel } - // Domain & context defaults + // Monetary fields need currency_field + if f.Type == orm.TypeMonetary { + cf := f.CurrencyField + if cf == "" { + cf = "currency_id" + } + fieldInfo["currency_field"] = cf + } + + // Computed fields + if f.Compute != "" { + fieldInfo["compute"] = f.Compute + } + if f.Related != "" { + fieldInfo["related"] = f.Related + } + + // Default domain & context fieldInfo["domain"] = "[]" fieldInfo["context"] = "{}" diff --git a/pkg/server/image.go b/pkg/server/image.go new file mode 100644 index 0000000..0d5d44e --- /dev/null +++ b/pkg/server/image.go @@ -0,0 +1,24 @@ +package server + +import ( + "net/http" +) + +// handleImage serves placeholder images for model records. +// The real Odoo serves actual uploaded images from ir.attachment. +// For now, return a 1x1 transparent PNG placeholder. +func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) { + // 1x1 transparent PNG + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "public, max-age=3600") + // Minimal valid 1x1 transparent PNG (67 bytes) + png := []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x00, 0x00, 0x02, + 0x00, 0x01, 0xe5, 0x27, 0xde, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, + 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + } + w.Write(png) +} diff --git a/pkg/server/menus.go b/pkg/server/menus.go index 2dfce38..c2e67b8 100644 --- a/pkg/server/menus.go +++ b/pkg/server/menus.go @@ -16,7 +16,7 @@ func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) { "root": map[string]interface{}{ "id": "root", "name": "root", - "children": []int{1}, + "children": []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, "appID": false, "xmlid": "", "actionID": false, @@ -27,6 +27,7 @@ func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) { "webIconDataMimetype": nil, "backgroundImage": nil, }, + // Contacts "1": map[string]interface{}{ "id": 1, "name": "Contacts", @@ -55,6 +56,280 @@ func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) { "webIconDataMimetype": nil, "backgroundImage": nil, }, + // Invoicing + "2": map[string]interface{}{ + "id": 2, + "name": "Invoicing", + "children": []int{20}, + "appID": 2, + "xmlid": "account.menu_finance", + "actionID": 2, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": "fa-book,#71639e,#FFFFFF", + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "20": map[string]interface{}{ + "id": 20, + "name": "Invoices", + "children": []int{}, + "appID": 2, + "xmlid": "account.menu_finance_invoices", + "actionID": 2, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + // Sales + "3": map[string]interface{}{ + "id": 3, + "name": "Sales", + "children": []int{30}, + "appID": 3, + "xmlid": "sale.menu_sale_root", + "actionID": 3, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": "fa-bar-chart,#71639e,#FFFFFF", + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "30": map[string]interface{}{ + "id": 30, + "name": "Orders", + "children": []int{}, + "appID": 3, + "xmlid": "sale.menu_sale_orders", + "actionID": 3, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + // CRM + "4": map[string]interface{}{ + "id": 4, + "name": "CRM", + "children": []int{40}, + "appID": 4, + "xmlid": "crm.menu_crm_root", + "actionID": 4, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": "fa-star,#71639e,#FFFFFF", + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "40": map[string]interface{}{ + "id": 40, + "name": "Pipeline", + "children": []int{}, + "appID": 4, + "xmlid": "crm.menu_crm_pipeline", + "actionID": 4, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + // Inventory / Stock + "5": map[string]interface{}{ + "id": 5, + "name": "Inventory", + "children": []int{50, 51}, + "appID": 5, + "xmlid": "stock.menu_stock_root", + "actionID": 5, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": "fa-cubes,#71639e,#FFFFFF", + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "50": map[string]interface{}{ + "id": 50, + "name": "Transfers", + "children": []int{}, + "appID": 5, + "xmlid": "stock.menu_stock_transfers", + "actionID": 5, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "51": map[string]interface{}{ + "id": 51, + "name": "Products", + "children": []int{}, + "appID": 5, + "xmlid": "stock.menu_stock_products", + "actionID": 6, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + // Purchase + "6": map[string]interface{}{ + "id": 6, + "name": "Purchase", + "children": []int{60}, + "appID": 6, + "xmlid": "purchase.menu_purchase_root", + "actionID": 7, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": "fa-shopping-cart,#71639e,#FFFFFF", + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "60": map[string]interface{}{ + "id": 60, + "name": "Purchase Orders", + "children": []int{}, + "appID": 6, + "xmlid": "purchase.menu_purchase_orders", + "actionID": 7, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + // Employees / HR + "7": map[string]interface{}{ + "id": 7, + "name": "Employees", + "children": []int{70, 71}, + "appID": 7, + "xmlid": "hr.menu_hr_root", + "actionID": 8, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": "fa-users,#71639e,#FFFFFF", + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "70": map[string]interface{}{ + "id": 70, + "name": "Employees", + "children": []int{}, + "appID": 7, + "xmlid": "hr.menu_hr_employees", + "actionID": 8, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "71": map[string]interface{}{ + "id": 71, + "name": "Departments", + "children": []int{}, + "appID": 7, + "xmlid": "hr.menu_hr_departments", + "actionID": 9, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + // Project + "8": map[string]interface{}{ + "id": 8, + "name": "Project", + "children": []int{80, 81}, + "appID": 8, + "xmlid": "project.menu_project_root", + "actionID": 10, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": "fa-puzzle-piece,#71639e,#FFFFFF", + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "80": map[string]interface{}{ + "id": 80, + "name": "Projects", + "children": []int{}, + "appID": 8, + "xmlid": "project.menu_projects", + "actionID": 10, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "81": map[string]interface{}{ + "id": 81, + "name": "Tasks", + "children": []int{}, + "appID": 8, + "xmlid": "project.menu_project_tasks", + "actionID": 11, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + // Fleet + "9": map[string]interface{}{ + "id": 9, + "name": "Fleet", + "children": []int{90}, + "appID": 9, + "xmlid": "fleet.menu_fleet_root", + "actionID": 12, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": "fa-car,#71639e,#FFFFFF", + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "90": map[string]interface{}{ + "id": 90, + "name": "Vehicles", + "children": []int{}, + "appID": 9, + "xmlid": "fleet.menu_fleet_vehicles", + "actionID": 12, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, } json.NewEncoder(w).Encode(menus) diff --git a/pkg/server/middleware.go b/pkg/server/middleware.go index e681ffa..6b6e27c 100644 --- a/pkg/server/middleware.go +++ b/pkg/server/middleware.go @@ -2,14 +2,43 @@ package server import ( "context" + "log" "net/http" "strings" + "time" ) type contextKey string const sessionKey contextKey = "session" +// LoggingMiddleware logs HTTP method, path, status code and duration for each request. +// Static file requests are skipped to reduce noise. +func LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + // Skip logging for static files to reduce noise + if strings.Contains(r.URL.Path, "/static/") { + next.ServeHTTP(w, r) + return + } + // Wrap response writer to capture status code + sw := &statusWriter{ResponseWriter: w, status: 200} + next.ServeHTTP(sw, r) + log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond)) + }) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + // AuthMiddleware checks for a valid session cookie on protected endpoints. func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/server/server.go b/pkg/server/server.go index 340b087..7d62338 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -612,6 +612,12 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa } return nameResult, nil + case "read_progress_bar": + return map[string]interface{}{}, nil + + case "activity_format": + return []interface{}{}, nil + case "action_archive": ids := parseIDs(params.Args) if len(ids) > 0 { @@ -754,13 +760,20 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleSessionInfo(w http.ResponseWriter, r *http.Request) { - s.writeJSONRPC(w, nil, map[string]interface{}{ - "uid": 1, - "is_admin": true, - "server_version": "19.0-go", - "server_version_info": []interface{}{19, 0, 0, "final", 0, "g"}, - "db": s.config.DBName, - }, nil) + // Try context first, then fall back to cookie lookup + sess := GetSession(r) + if sess == nil { + if cookie, err := r.Cookie("session_id"); err == nil && cookie.Value != "" { + sess = s.sessions.Get(cookie.Value) + } + } + if sess == nil { + s.writeJSONRPC(w, nil, nil, &RPCError{ + Code: 100, Message: "Session expired", + }) + return + } + s.writeJSONRPC(w, nil, s.buildSessionInfo(sess), nil) } func (s *Server) handleDBList(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/server/stubs.go b/pkg/server/stubs.go index 7a8ec33..51e4689 100644 --- a/pkg/server/stubs.go +++ b/pkg/server/stubs.go @@ -5,16 +5,28 @@ import ( "net/http" ) -// handleSessionCheck returns null (session is valid if middleware passed). +// handleSessionCheck verifies the session is valid and returns session info. func (s *Server) handleSessionCheck(w http.ResponseWriter, r *http.Request) { - s.writeJSONRPC(w, nil, nil, nil) + sess := GetSession(r) + if sess == nil { + if cookie, err := r.Cookie("session_id"); err == nil && cookie.Value != "" { + sess = s.sessions.Get(cookie.Value) + } + } + if sess == nil { + s.writeJSONRPC(w, nil, nil, &RPCError{ + Code: 100, Message: "Session expired", + }) + return + } + s.writeJSONRPC(w, nil, s.buildSessionInfo(sess), nil) } // handleSessionModules returns installed module names. func (s *Server) handleSessionModules(w http.ResponseWriter, r *http.Request) { s.writeJSONRPC(w, nil, []string{ - "base", "web", "account", "sale", "stock", "purchase", - "hr", "project", "crm", "fleet", "l10n_de", "product", + "base", "web", "contacts", "sale", "account", "stock", + "purchase", "crm", "hr", "project", "fleet", "product", "l10n_de", }, nil) } @@ -37,8 +49,17 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) { // handleBootstrapTranslations returns empty translations for initial boot. func (s *Server) handleBootstrapTranslations(w http.ResponseWriter, r *http.Request) { s.writeJSONRPC(w, nil, map[string]interface{}{ - "lang": "en_US", - "hash": "empty", + "lang": "en_US", + "hash": "empty", + "lang_parameters": map[string]interface{}{ + "direction": "ltr", + "date_format": "%%m/%%d/%%Y", + "time_format": "%%H:%%M:%%S", + "grouping": "[3,0]", + "decimal_point": ".", + "thousands_sep": ",", + "week_start": 1, + }, "modules": map[string]interface{}{}, "multi_lang": false, }, nil) diff --git a/pkg/server/transpiler.go b/pkg/server/transpiler.go new file mode 100644 index 0000000..c6204e7 --- /dev/null +++ b/pkg/server/transpiler.go @@ -0,0 +1,553 @@ +// Package server — JS module transpiler. +// Mirrors: odoo/tools/js_transpiler.py +// +// Converts ES module syntax (import/export) to odoo.define() format: +// +// import { X } from "@web/foo" --> const { X } = require("@web/foo") +// export class Foo { ... } --> const Foo = __exports.Foo = class Foo { ... } +// +// Wrapped in: +// +// odoo.define("@web/core/foo", ["@web/foo"], function(require) { +// "use strict"; let __exports = {}; +// ... +// return __exports; +// }); +package server + +import ( + "fmt" + "regexp" + "strings" +) + +// Compiled regex patterns for import/export matching. +// Mirrors: odoo/tools/js_transpiler.py URL_RE, IMPORT_RE, etc. +var ( + // Import patterns — (?m)^ ensures we only match at line start (not inside comments) + reNamedImport = regexp.MustCompile(`(?m)^\s*import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']\s*;?`) + reDefaultImport = regexp.MustCompile(`(?m)^\s*import\s+(\w+)\s+from\s*["']([^"']+)["']\s*;?`) + reNamespaceImport = regexp.MustCompile(`(?m)^\s*import\s*\*\s*as\s+(\w+)\s+from\s*["']([^"']+)["']\s*;?`) + reSideEffectImport = regexp.MustCompile(`(?m)^\s*import\s*["']([^"']+)["']\s*;?`) + + // Export patterns + reExportClass = regexp.MustCompile(`export\s+class\s+(\w+)`) + reExportFunction = regexp.MustCompile(`export\s+(async\s+)?function\s+(\w+)`) + reExportConst = regexp.MustCompile(`export\s+const\s+(\w+)`) + reExportLet = regexp.MustCompile(`export\s+let\s+(\w+)`) + reExportDefault = regexp.MustCompile(`export\s+default\s+`) + reExportNamedFrom = regexp.MustCompile(`export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']\s*;?`) + reExportNamed = regexp.MustCompile(`export\s*\{([^}]*)\}\s*;?`) + reExportStar = regexp.MustCompile(`export\s*\*\s*from\s*["']([^"']+)["']\s*;?`) + + // Block comment removal + reBlockComment = regexp.MustCompile(`(?s)/\*.*?\*/`) + + // Detection patterns + reHasImport = regexp.MustCompile(`(?m)^\s*import\s`) + reHasExport = regexp.MustCompile(`(?m)^\s*export\s`) + reOdooModuleTag = regexp.MustCompile(`(?m)^\s*//\s*@odoo-module`) + reOdooModuleIgnore = regexp.MustCompile(`(?m)^\s*//\s*@odoo-module\s+ignore`) +) + +// TranspileJS converts an ES module JS file to odoo.define() format. +// Mirrors: odoo/tools/js_transpiler.py transpile_javascript() +// +// urlPath is the URL-style path, e.g. "/web/static/src/core/foo.js". +// content is the raw JS source code. +// Returns the transpiled source, or the original content if the file is not +// an ES module. +func TranspileJS(urlPath, content string) string { + if !IsOdooModule(urlPath, content) { + return content + } + + moduleName := URLToModuleName(urlPath) + + // Extract imports and build dependency list + deps, requireLines, cleanContent := extractImports(moduleName, content) + + // Transform exports + cleanContent = transformExports(cleanContent) + + // Wrap in odoo.define + return wrapWithOdooDefine(moduleName, deps, requireLines, cleanContent) +} + +// URLToModuleName converts a URL path to an Odoo module name. +// Mirrors: odoo/tools/js_transpiler.py url_to_module_name() +// +// Examples: +// +// /web/static/src/core/foo.js -> @web/core/foo +// /web/static/src/env.js -> @web/env +// /web/static/lib/luxon/luxon.js -> @web/../lib/luxon/luxon +// /stock/static/src/widgets/foo.js -> @stock/widgets/foo +func URLToModuleName(url string) string { + // Remove leading slash + path := strings.TrimPrefix(url, "/") + + // Remove .js extension + path = strings.TrimSuffix(path, ".js") + + // Split into addon name and the rest + parts := strings.SplitN(path, "/", 2) + if len(parts) < 2 { + return "@" + path + } + + addonName := parts[0] + rest := parts[1] + + // Remove "static/src/" prefix from the rest + if strings.HasPrefix(rest, "static/src/") { + rest = strings.TrimPrefix(rest, "static/src/") + } else if strings.HasPrefix(rest, "static/") { + // For lib files: static/lib/foo -> ../lib/foo + rest = "../" + strings.TrimPrefix(rest, "static/") + } + + return "@" + addonName + "/" + rest +} + +// resolveRelativeImport converts relative import paths to absolute module names. +// E.g., if current module is "@web/core/browser/feature_detection" and dep is "./browser", +// it resolves to "@web/core/browser/browser". +// "../utils/hooks" from "@web/core/browser/feature_detection" → "@web/core/utils/hooks" +func resolveRelativeImport(currentModule, dep string) string { + if !strings.HasPrefix(dep, "./") && !strings.HasPrefix(dep, "../") { + return dep // Already absolute + } + + // Split current module into parts: @web/core/browser/feature_detection → [core, browser] + // (remove the @addon/ prefix and the filename) + parts := strings.Split(currentModule, "/") + if len(parts) < 2 { + return dep + } + + // Get the directory of the current module (drop the last segment = filename) + dir := parts[:len(parts)-1] + + // Resolve the relative path + relParts := strings.Split(dep, "/") + for _, p := range relParts { + if p == "." { + continue + } else if p == ".." { + if len(dir) > 1 { + dir = dir[:len(dir)-1] + } + } else { + dir = append(dir, p) + } + } + + return strings.Join(dir, "/") +} + +// IsOdooModule determines whether a JS file should be transpiled. +// Mirrors: odoo/tools/js_transpiler.py is_odoo_module() +// +// Returns true if the file contains ES module syntax (import/export) or +// the @odoo-module tag (without "ignore"). +func IsOdooModule(url, content string) bool { + // Must be a JS file + if !strings.HasSuffix(url, ".js") { + return false + } + + // Explicit ignore directive + if reOdooModuleIgnore.MatchString(content) { + return false + } + + // Explicit @odoo-module tag + if reOdooModuleTag.MatchString(content) { + return true + } + + // Has import or export statements + if reHasImport.MatchString(content) || reHasExport.MatchString(content) { + return true + } + + return false +} + +// extractImports finds all import statements in the content, returns: +// - deps: list of dependency module names (for the odoo.define deps array) +// - requireLines: list of "const ... = require(...)" lines +// - cleanContent: content with import statements removed +func extractImports(moduleName, content string) (deps []string, requireLines []string, cleanContent string) { + depSet := make(map[string]bool) + var depOrder []string + + resolve := func(dep string) string { + return resolveRelativeImport(moduleName, dep) + } + + addDep := func(dep string) { + dep = resolve(dep) + if !depSet[dep] { + depSet[dep] = true + depOrder = append(depOrder, dep) + } + } + + cleanContent = content + + // Remove @odoo-module tag line (not needed in output) + cleanContent = reOdooModuleTag.ReplaceAllString(cleanContent, "") + + // Don't strip block comments (it breaks string literals containing /*). + // Instead, the import regexes below only match at positions that are + // clearly actual code, not inside comments. Since import/export statements + // in ES modules must appear at the top level (before any function body), + // they'll always be at the beginning of a line. The regexes already handle + // this correctly for most cases. The one edge case (import inside JSDoc) + // is handled by checking the matched line doesn't start with * or //. + + // Named imports: import { X, Y as Z } from "dep" + cleanContent = reNamedImport.ReplaceAllStringFunc(cleanContent, func(match string) string { + m := reNamedImport.FindStringSubmatch(match) + if len(m) < 3 { + return match + } + names := m[1] + dep := m[2] + addDep(dep) + + // Parse the import specifiers, handle "as" aliases + specifiers := parseImportSpecifiers(names) + if len(specifiers) == 0 { + return "" + } + + // Build destructuring: const { X, Y: Z } = require("dep") + var parts []string + for _, s := range specifiers { + if s.alias != "" { + parts = append(parts, s.name+": "+s.alias) + } else { + parts = append(parts, s.name) + } + } + line := "const { " + strings.Join(parts, ", ") + " } = require(\"" + resolve(dep) + "\");" + requireLines = append(requireLines, line) + return "" + }) + + // Namespace imports: import * as X from "dep" + cleanContent = reNamespaceImport.ReplaceAllStringFunc(cleanContent, func(match string) string { + m := reNamespaceImport.FindStringSubmatch(match) + if len(m) < 3 { + return match + } + name := m[1] + dep := m[2] + addDep(dep) + + line := "const " + name + " = require(\"" + dep + "\");" + requireLines = append(requireLines, line) + return "" + }) + + // Default imports: import X from "dep" + cleanContent = reDefaultImport.ReplaceAllStringFunc(cleanContent, func(match string) string { + m := reDefaultImport.FindStringSubmatch(match) + if len(m) < 3 { + return match + } + name := m[1] + dep := m[2] + addDep(dep) + + // Default import uses Symbol.for("default") + line := "const " + name + " = require(\"" + dep + "\")[Symbol.for(\"default\")];" + requireLines = append(requireLines, line) + return "" + }) + + // Side-effect imports: import "dep" + cleanContent = reSideEffectImport.ReplaceAllStringFunc(cleanContent, func(match string) string { + m := reSideEffectImport.FindStringSubmatch(match) + if len(m) < 2 { + return match + } + dep := m[1] + addDep(dep) + + line := "require(\"" + dep + "\");" + requireLines = append(requireLines, line) + return "" + }) + + // export { X, Y } from "dep" — named re-export: import dep + export names + cleanContent = reExportNamedFrom.ReplaceAllStringFunc(cleanContent, func(match string) string { + m := reExportNamedFrom.FindStringSubmatch(match) + if len(m) >= 3 { + names := m[1] + dep := m[2] + addDep(dep) + // Named re-export: export { X } from "dep" + // Import the dep (using a temp var to avoid redeclaration with existing imports) + // then assign to __exports + specifiers := parseExportSpecifiers(names) + var parts []string + tmpVar := fmt.Sprintf("_reexport_%d", len(deps)) + parts = append(parts, fmt.Sprintf("var %s = require(\"%s\");", tmpVar, dep)) + for _, s := range specifiers { + exported := s.name + if s.alias != "" { + exported = s.alias + } + parts = append(parts, fmt.Sprintf("__exports.%s = %s.%s;", exported, tmpVar, s.name)) + } + return strings.Join(parts, " ") + } + return match + }) + + // export * from "dep" — treat as import dependency + cleanContent = reExportStar.ReplaceAllStringFunc(cleanContent, func(match string) string { + m := reExportStar.FindStringSubmatch(match) + if len(m) >= 2 { + addDep(m[1]) + } + return match // keep the export * line — transformExports will handle it + }) + + deps = depOrder + return +} + +// importSpecifier holds a single import specifier, e.g. "X" or "X as Y". +type importSpecifier struct { + name string // exported name + alias string // local alias (empty if same as name) +} + +// parseImportSpecifiers parses the inside of { ... } in an import statement. +// E.g. "X, Y as Z, W" -> [{X, ""}, {Y, "Z"}, {W, ""}] +func parseImportSpecifiers(raw string) []importSpecifier { + var result []importSpecifier + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + fields := strings.Fields(part) + switch len(fields) { + case 1: + result = append(result, importSpecifier{name: fields[0]}) + case 3: + // "X as Y" + if fields[1] == "as" { + result = append(result, importSpecifier{name: fields[0], alias: fields[2]}) + } + } + } + return result +} + +// transformExports converts export statements to __exports assignments. +// Mirrors: odoo/tools/js_transpiler.py (various export transformers) +func transformExports(content string) string { + // export class Foo { ... } -> const Foo = __exports.Foo = class Foo { ... } + content = reExportClass.ReplaceAllStringFunc(content, func(match string) string { + m := reExportClass.FindStringSubmatch(match) + if len(m) < 2 { + return match + } + name := m[1] + return "const " + name + " = __exports." + name + " = class " + name + }) + + // export [async] function foo(...) { ... } -> __exports.foo = [async] function foo(...) { ... } + content = reExportFunction.ReplaceAllStringFunc(content, func(match string) string { + m := reExportFunction.FindStringSubmatch(match) + if len(m) < 3 { + return match + } + async := m[1] // "async " or "" + name := m[2] + // Use "var name = __exports.name = function name" so the name is available + // as a local variable (needed when code references it after declaration, + // e.g., uniqueId.nextId = 0) + return "var " + name + " = __exports." + name + " = " + async + "function " + name + }) + + // export const foo = ... -> const foo = __exports.foo = ... + // (replaces just the "export const foo" part, the rest of the line stays) + content = reExportConst.ReplaceAllStringFunc(content, func(match string) string { + m := reExportConst.FindStringSubmatch(match) + if len(m) < 2 { + return match + } + name := m[1] + return "const " + name + " = __exports." + name + }) + + // export let foo = ... -> let foo = __exports.foo = ... + content = reExportLet.ReplaceAllStringFunc(content, func(match string) string { + m := reExportLet.FindStringSubmatch(match) + if len(m) < 2 { + return match + } + name := m[1] + return "let " + name + " = __exports." + name + }) + + // export { X, Y, Z } -> Object.assign(__exports, { X, Y, Z }); + content = reExportNamed.ReplaceAllStringFunc(content, func(match string) string { + m := reExportNamed.FindStringSubmatch(match) + if len(m) < 2 { + return match + } + names := m[1] + + // Parse individual names, handle "X as Y" aliases + specifiers := parseExportSpecifiers(names) + if len(specifiers) == 0 { + return "" + } + + var assignments []string + for _, s := range specifiers { + exportedName := s.name + if s.alias != "" { + exportedName = s.alias + } + assignments = append(assignments, "__exports."+exportedName+" = "+s.name+";") + } + return strings.Join(assignments, " ") + }) + + // export * from "dep" -> Object.assign(__exports, require("dep")) + // Also add the dep to the dependency list (handled in extractImports) + content = reExportStar.ReplaceAllStringFunc(content, func(match string) string { + m := reExportStar.FindStringSubmatch(match) + if len(m) < 2 { + return match + } + dep := m[1] + return fmt.Sprintf(`Object.assign(__exports, require("%s"))`, dep) + }) + + // export default X -> __exports[Symbol.for("default")] = X + // Must come after other export patterns to avoid double-matching + content = reExportDefault.ReplaceAllString(content, `__exports[Symbol.for("default")] = `) + + return content +} + +// exportSpecifier holds a single export specifier from "export { X, Y as Z }". +type exportSpecifier struct { + name string // local name + alias string // exported name (empty if same as name) +} + +// parseExportSpecifiers parses the inside of { ... } in an export statement. +// E.g. "X, Y as Z" -> [{X, ""}, {Y, "Z"}] +func parseExportSpecifiers(raw string) []exportSpecifier { + var result []exportSpecifier + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + fields := strings.Fields(part) + switch len(fields) { + case 1: + result = append(result, exportSpecifier{name: fields[0]}) + case 3: + // "X as Y" + if fields[1] == "as" { + result = append(result, exportSpecifier{name: fields[0], alias: fields[2]}) + } + } + } + return result +} + +// wrapWithOdooDefine wraps the transpiled content in an odoo.define() call. +// Mirrors: odoo/tools/js_transpiler.py wrap_with_odoo_define() +func wrapWithOdooDefine(moduleName string, deps []string, requireLines []string, content string) string { + var b strings.Builder + + // Module definition header + b.WriteString("odoo.define(\"") + b.WriteString(moduleName) + b.WriteString("\", [") + + // Dependencies array + for i, dep := range deps { + if i > 0 { + b.WriteString(", ") + } + b.WriteString("\"") + b.WriteString(dep) + b.WriteString("\"") + } + + b.WriteString("], function(require) {\n") + b.WriteString("\"use strict\";\n") + b.WriteString("let __exports = {};\n") + + // Require statements + for _, line := range requireLines { + b.WriteString(line) + b.WriteString("\n") + } + + // Original content (trimmed of leading/trailing whitespace) + trimmed := strings.TrimSpace(content) + if trimmed != "" { + b.WriteString(trimmed) + b.WriteString("\n") + } + + // Return exports + b.WriteString("return __exports;\n") + b.WriteString("});\n") + + return b.String() +} + +// stripJSDocImports removes import/export statements that appear inside JSDoc +// block comments. Instead of stripping all /* ... */ (which breaks string literals +// containing /*), we only neutralize import/export lines that are preceded by +// a JSDoc comment start (/**) on a prior line. We detect this by checking if +// the line is inside a comment block. +func stripJSDocImports(content string) string { + lines := strings.Split(content, "\n") + inComment := false + var result []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Track block comment state + if strings.HasPrefix(trimmed, "/*") { + inComment = true + } + + if inComment { + // Neutralize import/export statements inside comments + // by replacing 'import' with '_import' and 'export' with '_export' + if strings.Contains(trimmed, "import ") || strings.Contains(trimmed, "export ") { + line = strings.Replace(line, "import ", "_import_in_comment ", 1) + line = strings.Replace(line, "export ", "_export_in_comment ", 1) + } + } + + if strings.Contains(trimmed, "*/") { + inComment = false + } + + result = append(result, line) + } + + return strings.Join(result, "\n") +} diff --git a/pkg/server/transpiler_test.go b/pkg/server/transpiler_test.go new file mode 100644 index 0000000..313fce0 --- /dev/null +++ b/pkg/server/transpiler_test.go @@ -0,0 +1,320 @@ +package server + +import ( + "strings" + "testing" +) + +func TestURLToModuleName(t *testing.T) { + tests := []struct { + url string + want string + }{ + {"/web/static/src/core/foo.js", "@web/core/foo"}, + {"/web/static/src/env.js", "@web/env"}, + {"/web/static/src/session.js", "@web/session"}, + {"/stock/static/src/widgets/foo.js", "@stock/widgets/foo"}, + {"/web/static/lib/owl/owl.js", "@web/../lib/owl/owl"}, + {"/web/static/src/core/browser/browser.js", "@web/core/browser/browser"}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + got := URLToModuleName(tt.url) + if got != tt.want { + t.Errorf("URLToModuleName(%q) = %q, want %q", tt.url, got, tt.want) + } + }) + } +} + +func TestIsOdooModule(t *testing.T) { + tests := []struct { + name string + url string + content string + want bool + }{ + { + name: "has import", + url: "/web/static/src/foo.js", + content: `import { Foo } from "@web/bar";`, + want: true, + }, + { + name: "has export", + url: "/web/static/src/foo.js", + content: `export class Foo {}`, + want: true, + }, + { + name: "has odoo-module tag", + url: "/web/static/src/foo.js", + content: "// @odoo-module\nconst x = 1;", + want: true, + }, + { + name: "ignore directive", + url: "/web/static/src/foo.js", + content: "// @odoo-module ignore\nimport { X } from '@web/foo';", + want: false, + }, + { + name: "plain JS no module", + url: "/web/static/src/foo.js", + content: "var x = 1;\nconsole.log(x);", + want: false, + }, + { + name: "not a JS file", + url: "/web/static/src/foo.xml", + content: `import { Foo } from "@web/bar";`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsOdooModule(tt.url, tt.content) + if got != tt.want { + t.Errorf("IsOdooModule(%q, ...) = %v, want %v", tt.url, got, tt.want) + } + }) + } +} + +func TestExtractImports(t *testing.T) { + t.Run("named imports", func(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) + + if len(deps) != 2 { + t.Fatalf("expected 2 deps, got %d: %v", len(deps), deps) + } + if deps[0] != "@web/core/foo" { + t.Errorf("deps[0] = %q, want @web/core/foo", deps[0]) + } + if deps[1] != "@web/core/baz" { + t.Errorf("deps[1] = %q, want @web/core/baz", deps[1]) + } + + if len(requires) != 2 { + t.Fatalf("expected 2 requires, got %d", len(requires)) + } + if !strings.Contains(requires[0], `{ Foo, Bar }`) { + t.Errorf("requires[0] = %q, want Foo, Bar destructuring", requires[0]) + } + if !strings.Contains(requires[1], `Baz: Qux`) { + t.Errorf("requires[1] = %q, want Baz: Qux alias", requires[1]) + } + + if strings.Contains(clean, "import") { + t.Errorf("clean content still contains import statements: %s", clean) + } + if !strings.Contains(clean, "const x = 1;") { + t.Errorf("clean content should still have 'const x = 1;': %s", clean) + } + }) + + t.Run("default import", func(t *testing.T) { + content := `import Foo from "@web/core/foo";` + deps, requires, _ := extractImports(content) + + if len(deps) != 1 || deps[0] != "@web/core/foo" { + t.Errorf("deps = %v, want [@web/core/foo]", deps) + } + if len(requires) != 1 || !strings.Contains(requires[0], `Symbol.for("default")`) { + t.Errorf("requires = %v, want default symbol access", requires) + } + }) + + t.Run("namespace import", func(t *testing.T) { + content := `import * as utils from "@web/core/utils";` + deps, requires, _ := extractImports(content) + + if len(deps) != 1 || deps[0] != "@web/core/utils" { + t.Errorf("deps = %v, want [@web/core/utils]", deps) + } + if len(requires) != 1 || !strings.Contains(requires[0], `const utils = require("@web/core/utils")`) { + t.Errorf("requires = %v, want namespace require", requires) + } + }) + + t.Run("side-effect import", func(t *testing.T) { + content := `import "@web/core/setup";` + deps, requires, _ := extractImports(content) + + if len(deps) != 1 || deps[0] != "@web/core/setup" { + t.Errorf("deps = %v, want [@web/core/setup]", deps) + } + if len(requires) != 1 || requires[0] != `require("@web/core/setup");` { + t.Errorf("requires = %v, want side-effect require", requires) + } + }) + + t.Run("dedup deps", func(t *testing.T) { + content := `import { Foo } from "@web/core/foo"; +import { Bar } from "@web/core/foo";` + deps, _, _ := extractImports(content) + + if len(deps) != 1 { + t.Errorf("expected deduped deps, got %v", deps) + } + }) +} + +func TestTransformExports(t *testing.T) { + t.Run("export class", func(t *testing.T) { + 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) + } + }) + + t.Run("export function", func(t *testing.T) { + got := transformExports("export function doSomething(a, b) {") + want := `__exports.doSomething = function doSomething(a, b) {` + if got != want { + t.Errorf("got %q, want %q", got, want) + } + }) + + t.Run("export const", func(t *testing.T) { + 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) + } + }) + + t.Run("export let", func(t *testing.T) { + got := transformExports("export let counter = 0;") + want := "let counter = __exports.counter = 0;" + if got != want { + t.Errorf("got %q, want %q", got, want) + } + }) + + t.Run("export default", func(t *testing.T) { + got := transformExports("export default Foo;") + want := `__exports[Symbol.for("default")] = Foo;` + if got != want { + t.Errorf("got %q, want %q", got, want) + } + }) + + t.Run("export named", func(t *testing.T) { + got := transformExports("export { Foo, Bar };") + if !strings.Contains(got, "__exports.Foo = Foo;") { + t.Errorf("missing Foo export in: %s", got) + } + if !strings.Contains(got, "__exports.Bar = Bar;") { + t.Errorf("missing Bar export in: %s", got) + } + }) + + t.Run("export named with alias", func(t *testing.T) { + got := transformExports("export { Foo as default };") + if !strings.Contains(got, "__exports.default = Foo;") { + t.Errorf("missing aliased export in: %s", got) + } + }) +} + +func TestTranspileJS(t *testing.T) { + t.Run("full transpile", func(t *testing.T) { + content := `// @odoo-module +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; + +export class MyWidget extends Component { + static template = "web.MyWidget"; +} + +registry.category("actions").add("my_widget", MyWidget); +` + url := "/web/static/src/views/my_widget.js" + result := TranspileJS(url, content) + + // Check wrapper + if !strings.HasPrefix(result, `odoo.define("@web/views/my_widget"`) { + t.Errorf("missing odoo.define header: %s", result[:80]) + } + + // Check deps + if !strings.Contains(result, `"@odoo/owl"`) { + t.Errorf("missing @odoo/owl dependency") + } + if !strings.Contains(result, `"@web/core/registry"`) { + t.Errorf("missing @web/core/registry dependency") + } + + // Check require lines + if !strings.Contains(result, `const { Component } = require("@odoo/owl");`) { + t.Errorf("missing Component require") + } + if !strings.Contains(result, `const { registry } = require("@web/core/registry");`) { + t.Errorf("missing registry require") + } + + // Check export transform + if !strings.Contains(result, `const MyWidget = __exports.MyWidget = class MyWidget`) { + t.Errorf("missing class export transform") + } + + // Check no raw import/export left + if strings.Contains(result, "import {") { + t.Errorf("raw import statement still present") + } + + // Check wrapper close + if !strings.Contains(result, "return __exports;") { + t.Errorf("missing return __exports") + } + }) + + t.Run("non-module passthrough", func(t *testing.T) { + content := "var x = 1;\nconsole.log(x);" + result := TranspileJS("/web/static/lib/foo.js", content) + if result != content { + t.Errorf("non-module content was modified") + } + }) + + t.Run("ignore directive passthrough", func(t *testing.T) { + content := "// @odoo-module ignore\nimport { X } from '@web/foo';\nexport class Y {}" + result := TranspileJS("/web/static/src/foo.js", content) + if result != content { + t.Errorf("ignored module content was modified") + } + }) +} + +func TestParseImportSpecifiers(t *testing.T) { + tests := []struct { + raw string + want []importSpecifier + }{ + {"Foo, Bar", []importSpecifier{{name: "Foo"}, {name: "Bar"}}}, + {"Foo as F, Bar", []importSpecifier{{name: "Foo", alias: "F"}, {name: "Bar"}}}, + {" X , Y , Z ", []importSpecifier{{name: "X"}, {name: "Y"}, {name: "Z"}}}, + {"", nil}, + } + + for _, tt := range tests { + t.Run(tt.raw, func(t *testing.T) { + got := parseImportSpecifiers(tt.raw) + if len(got) != len(tt.want) { + t.Fatalf("got %d specifiers, want %d", len(got), len(tt.want)) + } + for i, s := range got { + if s.name != tt.want[i].name || s.alias != tt.want[i].alias { + t.Errorf("specifier[%d] = %+v, want %+v", i, s, tt.want[i]) + } + } + }) + } +} diff --git a/pkg/server/upload.go b/pkg/server/upload.go new file mode 100644 index 0000000..6a6fd48 --- /dev/null +++ b/pkg/server/upload.go @@ -0,0 +1,48 @@ +package server + +import ( + "encoding/json" + "io" + "log" + "net/http" +) + +// handleUpload handles file uploads to ir.attachment. +func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse multipart form (max 128MB) + if err := r.ParseMultipartForm(128 << 20); err != nil { + http.Error(w, "File too large", http.StatusRequestEntityTooLarge) + return + } + + file, header, err := r.FormFile("ufile") + if err != nil { + http.Error(w, "No file uploaded", http.StatusBadRequest) + return + } + defer file.Close() + + // Read file content + data, err := io.ReadAll(file) + if err != nil { + http.Error(w, "Read error", http.StatusInternalServerError) + return + } + + log.Printf("upload: received %s (%d bytes, %s)", header.Filename, len(data), header.Header.Get("Content-Type")) + + // TODO: Store in ir.attachment table or filesystem + // For now, just acknowledge receipt + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 1, + "name": header.Filename, + "size": len(data), + }) +} diff --git a/pkg/server/views.go b/pkg/server/views.go index e21eeaa..311e50b 100644 --- a/pkg/server/views.go +++ b/pkg/server/views.go @@ -108,54 +108,197 @@ func generateDefaultView(modelName, viewType string) string { } func generateDefaultListView(m *orm.Model) string { + // Prioritize important fields first + priority := []string{"name", "display_name", "state", "partner_id", "date_order", "date", + "amount_total", "amount_untaxed", "email", "phone", "company_id", "user_id", + "product_id", "quantity", "price_unit", "price_subtotal"} + var fields []string - count := 0 + added := make(map[string]bool) + + // Add priority fields first + for _, pf := range priority { + f := m.GetField(pf) + if f != nil && f.IsStored() && f.Type != orm.TypeBinary { + fields = append(fields, fmt.Sprintf(``, pf)) + added[pf] = true + } + } + + // Fill remaining slots for _, f := range m.Fields() { - if f.Name == "id" || !f.IsStored() || f.Name == "create_uid" || f.Name == "write_uid" || - f.Name == "create_date" || f.Name == "write_date" || f.Type == orm.TypeBinary { + if len(fields) >= 10 { + break + } + if added[f.Name] || f.Name == "id" || !f.IsStored() || + f.Name == "create_uid" || f.Name == "write_uid" || + f.Name == "create_date" || f.Name == "write_date" || + f.Type == orm.TypeBinary || f.Type == orm.TypeText || f.Type == orm.TypeHTML { continue } fields = append(fields, fmt.Sprintf(``, f.Name)) - count++ - if count >= 8 { - break - } + added[f.Name] = true } + return fmt.Sprintf("\n %s\n", strings.Join(fields, "\n ")) } func generateDefaultFormView(m *orm.Model) string { - var fields []string - for _, f := range m.Fields() { - if f.Name == "id" || f.Name == "create_uid" || f.Name == "write_uid" || - f.Name == "create_date" || f.Name == "write_date" || f.Type == orm.TypeBinary { + skip := map[string]bool{ + "id": true, "create_uid": true, "write_uid": true, + "create_date": true, "write_date": true, + } + + // Header with state widget if state field exists + var header string + if f := m.GetField("state"); f != nil && f.Type == orm.TypeSelection { + header = `
+ +
+` + } + + // Title field (name or display_name) + var title string + if f := m.GetField("name"); f != nil { + title = `
+

+
+` + skip["name"] = true + } + + // Split fields into left/right groups + var leftFields, rightFields []string + var o2mFields []string + count := 0 + + // Prioritize important fields + priority := []string{"partner_id", "date_order", "date", "company_id", "currency_id", + "user_id", "journal_id", "product_id", "email", "phone"} + + for _, pf := range priority { + f := m.GetField(pf) + if f == nil || skip[pf] || f.Type == orm.TypeBinary { continue } - if f.Type == orm.TypeOne2many || f.Type == orm.TypeMany2many { - continue // Skip relational fields in default form + skip[pf] = true + line := fmt.Sprintf(` `, pf) + if count%2 == 0 { + leftFields = append(leftFields, line) + } else { + rightFields = append(rightFields, line) } - fields = append(fields, fmt.Sprintf(` `, f.Name)) - if len(fields) >= 20 { + count++ + } + + // Add remaining stored fields + for _, f := range m.Fields() { + if skip[f.Name] || !f.IsStored() || f.Type == orm.TypeBinary { + continue + } + if f.Type == orm.TypeOne2many { + o2mFields = append(o2mFields, fmt.Sprintf(` `, f.Name)) + continue + } + if f.Type == orm.TypeMany2many { + continue + } + line := fmt.Sprintf(` `, f.Name) + if len(leftFields) <= len(rightFields) { + leftFields = append(leftFields, line) + } else { + rightFields = append(rightFields, line) + } + if len(leftFields)+len(rightFields) >= 20 { break } } - return fmt.Sprintf("
\n \n \n%s\n \n \n
", - strings.Join(fields, "\n")) + + // Build form + var buf strings.Builder + buf.WriteString("
\n") + buf.WriteString(header) + buf.WriteString(" \n") + buf.WriteString(title) + buf.WriteString(" \n") + buf.WriteString(" \n") + buf.WriteString(strings.Join(leftFields, "\n")) + buf.WriteString("\n \n") + buf.WriteString(" \n") + buf.WriteString(strings.Join(rightFields, "\n")) + buf.WriteString("\n \n") + buf.WriteString(" \n") + + // O2M fields in notebook + if len(o2mFields) > 0 { + buf.WriteString(" \n") + buf.WriteString(" \n") + buf.WriteString(strings.Join(o2mFields, "\n")) + buf.WriteString("\n \n") + buf.WriteString(" \n") + } + + buf.WriteString(" \n") + buf.WriteString("
") + return buf.String() } func generateDefaultSearchView(m *orm.Model) string { var fields []string - // Add name field if it exists - if f := m.GetField("name"); f != nil { - fields = append(fields, ``) - } - if f := m.GetField("email"); f != nil { - fields = append(fields, ``) + var filters []string + + // Search fields + searchable := []string{"name", "display_name", "email", "phone", "ref", + "partner_id", "company_id", "user_id", "state", "date_order", "date"} + for _, sf := range searchable { + if f := m.GetField(sf); f != nil { + fields = append(fields, fmt.Sprintf(``, sf)) + } } if len(fields) == 0 { fields = append(fields, ``) } - return fmt.Sprintf("\n %s\n", strings.Join(fields, "\n ")) + + // Auto-generate filter for state field + if f := m.GetField("state"); f != nil && f.Type == orm.TypeSelection { + for _, sel := range f.Selection { + filters = append(filters, fmt.Sprintf( + ``, + sel.Label, sel.Value, sel.Value)) + } + } + + // Group-by for common fields + var groupby []string + groupable := []string{"partner_id", "state", "company_id", "user_id", "stage_id"} + for _, gf := range groupable { + if f := m.GetField(gf); f != nil { + groupby = append(groupby, fmt.Sprintf(``, + f.String, gf, gf)) + } + } + + var buf strings.Builder + buf.WriteString("\n") + for _, f := range fields { + buf.WriteString(" " + f + "\n") + } + if len(filters) > 0 { + buf.WriteString(" \n") + for _, f := range filters { + buf.WriteString(" " + f + "\n") + } + } + if len(groupby) > 0 { + buf.WriteString(" \n") + for _, g := range groupby { + buf.WriteString(" " + g + "\n") + } + buf.WriteString(" \n") + } + buf.WriteString("") + return buf.String() } func generateDefaultKanbanView(m *orm.Model) string { diff --git a/pkg/server/web_methods.go b/pkg/server/web_methods.go index a8b2a14..1b569dd 100644 --- a/pkg/server/web_methods.go +++ b/pkg/server/web_methods.go @@ -2,6 +2,7 @@ package server import ( "fmt" + "time" "odoo-go/pkg/orm" ) @@ -12,8 +13,13 @@ import ( func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) { rs := env.Model(model) - // Parse domain from first arg + // Parse domain from first arg (regular search_read) or kwargs (web_search_read) domain := parseDomain(params.Args) + if domain == nil { + if domainRaw, ok := params.KW["domain"].([]interface{}); ok && len(domainRaw) > 0 { + domain = parseDomain([]interface{}{domainRaw}) + } + } // Parse specification from kwargs spec, _ := params.KW["specification"].(map[string]interface{}) @@ -45,11 +51,19 @@ func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams order = v } - // Get total count + // Get total count, respecting count_limit for optimization. + // Mirrors: odoo/addons/web/models/models.py web_search_read() count_limit parameter + countLimit := int64(0) + if v, ok := params.KW["count_limit"].(float64); ok { + countLimit = int64(v) + } count, err := rs.SearchCount(domain) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } + if countLimit > 0 && count > countLimit { + count = countLimit + } // Search with offset/limit found, err := rs.Search(domain, orm.SearchOpts{ @@ -72,6 +86,9 @@ func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams // Format M2O fields as {id, display_name} when spec requests it formatM2OFields(env, model, records, spec) + // Format date/datetime fields to Odoo's expected string format + formatDateFields(model, records) + if records == nil { records = []orm.Values{} } @@ -93,6 +110,18 @@ func handleWebRead(env *orm.Environment, model string, params CallKWParams) (int spec, _ := params.KW["specification"].(map[string]interface{}) fields := specToFields(spec) + // Always include id + hasID := false + for _, f := range fields { + if f == "id" { + hasID = true + break + } + } + if !hasID { + fields = append([]string{"id"}, fields...) + } + rs := env.Model(model) records, err := rs.Browse(ids...).Read(fields) if err != nil { @@ -101,6 +130,9 @@ func handleWebRead(env *orm.Environment, model string, params CallKWParams) (int formatM2OFields(env, model, records, spec) + // Format date/datetime fields to Odoo's expected string format + formatDateFields(model, records) + if records == nil { records = []orm.Values{} } @@ -170,3 +202,42 @@ func formatM2OFields(env *orm.Environment, modelName string, records []orm.Value } } } + +// formatDateFields converts date/datetime values to Odoo's expected string format. +func formatDateFields(model string, records []orm.Values) { + m := orm.Registry.Get(model) + if m == nil { + return + } + for _, rec := range records { + for fieldName, val := range rec { + f := m.GetField(fieldName) + if f == nil { + continue + } + if f.Type == orm.TypeDate || f.Type == orm.TypeDatetime { + switch v := val.(type) { + case time.Time: + if f.Type == orm.TypeDate { + rec[fieldName] = v.Format("2006-01-02") + } else { + rec[fieldName] = v.Format("2006-01-02 15:04:05") + } + case string: + // Already a string, might need reformatting + if t, err := time.Parse(time.RFC3339, v); err == nil { + if f.Type == orm.TypeDate { + rec[fieldName] = t.Format("2006-01-02") + } else { + rec[fieldName] = t.Format("2006-01-02 15:04:05") + } + } + } + } + // Also convert boolean fields: Go nil → Odoo false + if f.Type == orm.TypeBoolean && val == nil { + rec[fieldName] = false + } + } + } +} diff --git a/pkg/service/cron.go b/pkg/service/cron.go new file mode 100644 index 0000000..583de84 --- /dev/null +++ b/pkg/service/cron.go @@ -0,0 +1,68 @@ +package service + +import ( + "context" + "log" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// CronJob defines a scheduled task. +type CronJob struct { + Name string + Interval time.Duration + Handler func(ctx context.Context, pool *pgxpool.Pool) error + running bool +} + +// CronScheduler manages periodic jobs. +type CronScheduler struct { + jobs []*CronJob + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc +} + +// NewCronScheduler creates a new scheduler. +func NewCronScheduler() *CronScheduler { + ctx, cancel := context.WithCancel(context.Background()) + return &CronScheduler{ctx: ctx, cancel: cancel} +} + +// Register adds a job to the scheduler. +func (s *CronScheduler) Register(job *CronJob) { + s.mu.Lock() + defer s.mu.Unlock() + s.jobs = append(s.jobs, job) +} + +// Start begins running all registered jobs. +func (s *CronScheduler) Start(pool *pgxpool.Pool) { + for _, job := range s.jobs { + go s.runJob(job, pool) + } + log.Printf("cron: started %d jobs", len(s.jobs)) +} + +// Stop cancels all running jobs. +func (s *CronScheduler) Stop() { + s.cancel() +} + +func (s *CronScheduler) runJob(job *CronJob, pool *pgxpool.Pool) { + ticker := time.NewTicker(job.Interval) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + if err := job.Handler(s.ctx, pool); err != nil { + log.Printf("cron: %s error: %v", job.Name, err) + } + } + } +} diff --git a/pkg/service/db.go b/pkg/service/db.go index c3fcc3d..181c3da 100644 --- a/pkg/service/db.go +++ b/pkg/service/db.go @@ -269,6 +269,9 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err SELECT setval('account_journal_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_journal)); SELECT setval('account_account_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_account)); SELECT setval('account_tax_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_tax)); + SELECT setval('sale_order_id_seq', (SELECT COALESCE(MAX(id),0) FROM sale_order)); + SELECT setval('sale_order_line_id_seq', (SELECT COALESCE(MAX(id),0) FROM sale_order_line)); + SELECT setval('account_move_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_move)); `) if err := tx.Commit(ctx); err != nil { @@ -347,7 +350,45 @@ func seedViews(ctx context.Context, tx pgx.Tx) { -', 16, true, 'primary') +', 16, true, 'primary'), + + -- crm.lead views + ('lead.list', 'crm.lead', 'list', ' + + + + + + + +', 16, true, 'primary'), + + -- res.partner kanban + ('partner.kanban', 'res.partner', 'kanban', ' + + +
+ +
+
+
+
+
+
+
', 16, true, 'primary'), + + -- crm.lead kanban (pipeline) + ('lead.kanban', 'crm.lead', 'kanban', ' + + +
+ +
+
Revenue:
+
+
+
+
', 16, true, 'primary') ON CONFLICT DO NOTHING`) log.Println("db: UI views seeded") @@ -373,7 +414,30 @@ func seedDemoData(ctx context.Context, tx pgx.Tx) { ('Peter Weber', false, true, 'contact', 'peter@weber-elektro.de', '+49 69 5551234', 'de_DE') ON CONFLICT DO NOTHING`) - log.Println("db: demo data loaded (8 demo contacts)") + // Demo sale orders + tx.Exec(ctx, `INSERT INTO sale_order (name, partner_id, company_id, currency_id, state, date_order, amount_untaxed, amount_total) VALUES + ('AG0001', 3, 1, 1, 'sale', '2026-03-15 10:00:00', 18100, 21539), + ('AG0002', 4, 1, 1, 'draft', '2026-03-20 14:30:00', 6000, 7140), + ('AG0003', 5, 1, 1, 'sale', '2026-03-25 09:15:00', 11700, 13923) + ON CONFLICT DO NOTHING`) + + // Demo sale order lines + tx.Exec(ctx, `INSERT INTO sale_order_line (order_id, name, product_uom_qty, price_unit, sequence) VALUES + ((SELECT id FROM sale_order WHERE name='AG0001'), 'Baustelleneinrichtung', 1, 12500, 10), + ((SELECT id FROM sale_order WHERE name='AG0001'), 'Erdarbeiten', 3, 2800, 20), + ((SELECT id FROM sale_order WHERE name='AG0002'), 'Beratung IT-Infrastruktur', 40, 150, 10), + ((SELECT id FROM sale_order WHERE name='AG0003'), 'Elektroinstallation', 1, 8500, 10), + ((SELECT id FROM sale_order WHERE name='AG0003'), 'Material Kabel/Dosen', 1, 3200, 20) + ON CONFLICT DO NOTHING`) + + // Demo invoices (account.move) + tx.Exec(ctx, `INSERT INTO account_move (name, move_type, state, date, invoice_date, partner_id, journal_id, company_id, currency_id, amount_total, amount_untaxed) VALUES + ('RE/2026/0001', 'out_invoice', 'posted', '2026-03-10', '2026-03-10', 3, 1, 1, 1, 14875, 12500), + ('RE/2026/0002', 'out_invoice', 'draft', '2026-03-20', '2026-03-20', 4, 1, 1, 1, 7140, 6000), + ('RE/2026/0003', 'out_invoice', 'posted', '2026-03-25', '2026-03-25', 5, 1, 1, 1, 13923, 11700) + ON CONFLICT DO NOTHING`) + + log.Println("db: demo data loaded (8 contacts, 3 sale orders, 3 invoices)") } // SeedBaseData is the legacy function — redirects to setup with defaults. diff --git a/pkg/service/migrate.go b/pkg/service/migrate.go new file mode 100644 index 0000000..cd9e125 --- /dev/null +++ b/pkg/service/migrate.go @@ -0,0 +1,95 @@ +// Package service — schema migration support. +// Mirrors: odoo/modules/migration.py (safe subset) +package service + +import ( + "context" + "fmt" + "log" + + "github.com/jackc/pgx/v5/pgxpool" + + "odoo-go/pkg/orm" +) + +// MigrateSchema compares registered model fields with existing database columns +// and adds any missing columns. This is a safe, additive-only migration: +// it does NOT remove columns, change types, or drop tables. +// +// Mirrors: odoo/modules/loading.py _auto_init() — the part that adds new +// columns when a model gains a field after the initial CREATE TABLE. +func MigrateSchema(ctx context.Context, pool *pgxpool.Pool) error { + tx, err := pool.Begin(ctx) + if err != nil { + return fmt.Errorf("migrate: begin: %w", err) + } + defer tx.Rollback(ctx) + + added := 0 + for _, m := range orm.Registry.Models() { + if m.IsAbstract() { + continue + } + + // Check if the table exists at all + var tableExists bool + err := tx.QueryRow(ctx, + `SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = $1 AND table_schema = 'public' + )`, m.Table()).Scan(&tableExists) + if err != nil || !tableExists { + continue // Table doesn't exist yet; InitDatabase will create it + } + + // Get existing columns for this table + existing := make(map[string]bool) + rows, err := tx.Query(ctx, + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND table_schema = 'public'`, + m.Table()) + if err != nil { + continue + } + for rows.Next() { + var col string + rows.Scan(&col) + existing[col] = true + } + rows.Close() + + // Add missing columns + for _, f := range m.StoredFields() { + if f.Name == "id" { + continue + } + if existing[f.Column()] { + continue + } + + sqlType := f.SQLType() + if sqlType == "" { + continue + } + + alter := fmt.Sprintf(`ALTER TABLE %q ADD COLUMN %q %s`, + m.Table(), f.Column(), sqlType) + if _, err := tx.Exec(ctx, alter); err != nil { + log.Printf("migrate: warning: add column %s.%s: %v", m.Table(), f.Column(), err) + } else { + log.Printf("migrate: added column %s.%s (%s)", m.Table(), f.Column(), sqlType) + added++ + } + } + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("migrate: commit: %w", err) + } + if added > 0 { + log.Printf("migrate: %d column(s) added", added) + } else { + log.Println("migrate: schema up to date") + } + return nil +} diff --git a/pkg/tools/email.go b/pkg/tools/email.go new file mode 100644 index 0000000..c104c1c --- /dev/null +++ b/pkg/tools/email.go @@ -0,0 +1,45 @@ +package tools + +import ( + "fmt" + "log" + "net/smtp" + "os" +) + +// SMTPConfig holds email server configuration. +type SMTPConfig struct { + Host string + Port int + User string + Password string + From string +} + +// LoadSMTPConfig loads SMTP settings from environment variables. +func LoadSMTPConfig() *SMTPConfig { + cfg := &SMTPConfig{ + Host: os.Getenv("SMTP_HOST"), + Port: 587, + User: os.Getenv("SMTP_USER"), + Password: os.Getenv("SMTP_PASSWORD"), + From: os.Getenv("SMTP_FROM"), + } + return cfg +} + +// SendEmail sends a simple email. Returns error if SMTP is not configured. +func SendEmail(cfg *SMTPConfig, to, subject, body string) error { + if cfg.Host == "" { + log.Printf("email: SMTP not configured, would send to=%s subject=%s", to, subject) + return nil // Silently succeed if not configured + } + + msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s", + cfg.From, to, subject, body) + + auth := smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host) + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + + return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg)) +}