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:
Marc
2026-03-31 23:16:26 +02:00
parent 8741282322
commit 9c444061fd
32 changed files with 3416 additions and 148 deletions

View File

@@ -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(&currencyID)
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, &currencyID, &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
}

View 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
}