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