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(`\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 = `