feat: Portal, Email Inbound, Discuss + module improvements

- Portal: /my/* routes, signup, password reset, portal user support
- Email Inbound: IMAP polling (go-imap/v2), thread matching
- Discuss: mail.channel, long-polling bus, DM, unread count
- Cron: ir.cron runner (goroutine scheduler)
- Bank Import, CSV/Excel Import
- Automation (ir.actions.server)
- Fetchmail service
- HR Payroll model
- Various fixes across account, sale, stock, purchase, crm, hr, project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marc
2026-04-12 18:41:57 +02:00
parent 2c7c1e6c88
commit 66383adf06
87 changed files with 14696 additions and 654 deletions

View File

@@ -1,6 +1,10 @@
package models
import "odoo-go/pkg/orm"
import (
"fmt"
"odoo-go/pkg/orm"
)
// initAccountAccount registers the chart of accounts.
// Mirrors: odoo/addons/account/models/account_account.py
@@ -203,3 +207,95 @@ func initAccountFiscalPosition() {
orm.Many2many("country_ids", "res.country", orm.FieldOpts{String: "Countries"}),
)
}
// initAccountTaxComputes adds computed fields to account.tax for the tax computation engine.
// Mirrors: odoo/addons/account/models/account_tax.py
//
// - is_base_affected: whether this tax's base is affected by previous taxes in the sequence
// - repartition_line_ids: combined view of invoice + refund repartition lines
func initAccountTaxComputes() {
ext := orm.ExtendModel("account.tax")
ext.AddFields(
orm.Boolean("computed_is_base_affected", orm.FieldOpts{
String: "Base Affected (Computed)",
Compute: "_compute_is_base_affected",
Help: "Computed: true when include_base_amount is set on a preceding tax in the same group",
}),
orm.Char("repartition_line_ids_json", orm.FieldOpts{
String: "Repartition Lines (All)",
Compute: "_compute_repartition_line_ids",
Help: "JSON list of all repartition line IDs (invoice + refund) for the tax engine",
}),
)
// _compute_is_base_affected: determines if this tax's base amount should be
// influenced by preceding taxes in the same tax group.
// Mirrors: odoo/addons/account/models/account_tax.py _compute_is_base_affected()
//
// A tax is base-affected when:
// 1. It belongs to a group tax (has parent_tax_id), AND
// 2. Any sibling tax with a lower sequence has include_base_amount=true
// Otherwise it falls back to the manual is_base_affected field value.
ext.RegisterCompute("computed_is_base_affected", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
taxID := rs.IDs()[0]
var parentID *int64
var seq int64
var manualFlag bool
env.Tx().QueryRow(env.Ctx(),
`SELECT parent_tax_id, COALESCE(sequence, 0), COALESCE(is_base_affected, true)
FROM account_tax WHERE id = $1`, taxID,
).Scan(&parentID, &seq, &manualFlag)
// If no parent group, use the manual field value
if parentID == nil || *parentID == 0 {
return orm.Values{"computed_is_base_affected": manualFlag}, nil
}
// Check if any preceding sibling in the group has include_base_amount=true
var count int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_tax
WHERE parent_tax_id = $1 AND sequence < $2
AND include_base_amount = true AND id != $3`,
*parentID, seq, taxID,
).Scan(&count)
return orm.Values{"computed_is_base_affected": count > 0 || manualFlag}, nil
})
// _compute_repartition_line_ids: collects all repartition line IDs (invoice + refund)
// into a JSON array string for the tax computation engine.
// Mirrors: odoo/addons/account/models/account_tax.py _compute_repartition_line_ids()
ext.RegisterCompute("repartition_line_ids_json", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
taxID := rs.IDs()[0]
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id FROM account_tax_repartition_line
WHERE tax_id = $1 ORDER BY sequence, id`, taxID)
if err != nil {
return orm.Values{"repartition_line_ids_json": "[]"}, nil
}
defer rows.Close()
result := "["
first := true
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
continue
}
if !first {
result += ","
}
result += fmt.Sprintf("%d", id)
first = false
}
result += "]"
return orm.Values{"repartition_line_ids_json": result}, nil
})
}

View File

@@ -251,7 +251,17 @@ func initAccountAsset() {
periodMonths = 1
}
// Use prorata_date or acquisition_date as start, fallback to now
startDate := time.Now()
var prorataDate, acquisitionDate *time.Time
env.Tx().QueryRow(env.Ctx(),
`SELECT prorata_date, acquisition_date FROM account_asset WHERE id = $1`, assetID,
).Scan(&prorataDate, &acquisitionDate)
if prorataDate != nil {
startDate = *prorataDate
} else if acquisitionDate != nil {
startDate = *acquisitionDate
}
switch method {
case "linear":
@@ -460,6 +470,156 @@ func initAccountAsset() {
}, nil
})
// action_create_deferred_entries: generate recognition entries for deferred
// revenue (sale) or deferred expense assets.
// Mirrors: odoo/addons/account_asset/models/account_asset.py _generate_deferred_entries()
//
// Unlike depreciation (which expenses an asset), deferred entries recognise
// income or expense over time. Monthly amount = original_value / method_number.
// Debit: deferred account (asset/liability), Credit: income/expense account.
m.RegisterMethod("action_create_deferred_entries", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
assetID := rs.IDs()[0]
var name, assetType, state string
var journalID, companyID, assetAccountID, expenseAccountID int64
var currencyID *int64
var originalValue float64
var methodNumber int
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, ''), COALESCE(asset_type, 'purchase'),
COALESCE(journal_id, 0), COALESCE(company_id, 0),
COALESCE(account_asset_id, 0), COALESCE(account_depreciation_expense_id, 0),
currency_id, COALESCE(state, 'draft'),
COALESCE(original_value::float8, 0), COALESCE(method_number, 1)
FROM account_asset WHERE id = $1`, assetID,
).Scan(&name, &assetType, &journalID, &companyID,
&assetAccountID, &expenseAccountID, &currencyID, &state,
&originalValue, &methodNumber)
if err != nil {
return nil, fmt.Errorf("account: read asset %d: %w", assetID, err)
}
if assetType != "sale" && assetType != "expense" {
return nil, fmt.Errorf("account: deferred entries only apply to deferred revenue (sale) or deferred expense assets, got %q", assetType)
}
if state != "open" {
return nil, fmt.Errorf("account: can only create deferred entries for running assets")
}
if journalID == 0 || assetAccountID == 0 || expenseAccountID == 0 {
return nil, fmt.Errorf("account: asset %d is missing journal or account configuration", assetID)
}
if methodNumber <= 0 {
methodNumber = 1
}
monthlyAmount := math.Round(originalValue/float64(methodNumber)*100) / 100
// How many entries already exist?
var existingCount int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_move WHERE asset_id = $1`, assetID,
).Scan(&existingCount)
if existingCount >= methodNumber {
return nil, fmt.Errorf("account: all deferred entries already created (%d/%d)", existingCount, methodNumber)
}
// Resolve currency
var curID int64
if currencyID != nil {
curID = *currencyID
} else {
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(currency_id, 0) FROM res_company WHERE id = $1`, companyID,
).Scan(&curID)
}
// Determine start date
startDate := time.Now()
var acqDate *time.Time
env.Tx().QueryRow(env.Ctx(),
`SELECT acquisition_date FROM account_asset WHERE id = $1`, assetID,
).Scan(&acqDate)
if acqDate != nil {
startDate = *acqDate
}
entryDate := startDate.AddDate(0, existingCount+1, 0).Format("2006-01-02")
period := existingCount + 1
// Last entry absorbs rounding remainder
amount := monthlyAmount
if period == methodNumber {
var alreadyRecognised float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(ABS(l.balance)::float8), 0)
FROM account_move m
JOIN account_move_line l ON l.move_id = m.id
WHERE m.asset_id = $1
AND l.account_id = $2`, assetID, expenseAccountID,
).Scan(&alreadyRecognised)
amount = math.Round((originalValue-alreadyRecognised)*100) / 100
}
// Create the recognition journal entry
moveRS := env.Model("account.move")
move, err := moveRS.Create(orm.Values{
"move_type": "entry",
"ref": fmt.Sprintf("Deferred recognition: %s (%d/%d)", name, period, methodNumber),
"date": entryDate,
"journal_id": journalID,
"company_id": companyID,
"currency_id": curID,
"asset_id": assetID,
})
if err != nil {
return nil, fmt.Errorf("account: create deferred entry: %w", err)
}
lineRS := env.Model("account.move.line")
// Debit: deferred account (asset account — the balance sheet deferral)
if _, err := lineRS.Create(orm.Values{
"move_id": move.ID(),
"account_id": assetAccountID,
"name": fmt.Sprintf("Deferred recognition: %s", name),
"debit": amount,
"credit": 0.0,
"balance": amount,
"company_id": companyID,
"journal_id": journalID,
"currency_id": curID,
"display_type": "product",
}); err != nil {
return nil, fmt.Errorf("account: create deferred debit line: %w", err)
}
// Credit: income/expense account
if _, err := lineRS.Create(orm.Values{
"move_id": move.ID(),
"account_id": expenseAccountID,
"name": fmt.Sprintf("Deferred recognition: %s", name),
"debit": 0.0,
"credit": amount,
"balance": -amount,
"company_id": companyID,
"journal_id": journalID,
"currency_id": curID,
"display_type": "product",
}); err != nil {
return nil, fmt.Errorf("account: create deferred credit line: %w", err)
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": "account.move",
"res_id": move.ID(),
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"target": "current",
}, nil
})
// -- DefaultGet --
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
vals := orm.Values{

View File

@@ -315,7 +315,7 @@ func generateInvoiceXML(env *orm.Environment, moveID int64, formatCode string) (
IssueDate: issueDateStr,
DueDate: dueDateStr,
InvoiceTypeCode: typeCode,
DocumentCurrencyCode: "EUR",
DocumentCurrencyCode: getCurrencyCode(env, moveID),
Supplier: UBLParty{
Name: companyName,
Street: ptrStr(companyStreet),
@@ -356,6 +356,19 @@ func generateInvoiceXML(env *orm.Environment, moveID int64, formatCode string) (
return b.String(), nil
}
// getCurrencyCode returns the ISO currency code for an invoice, defaulting to "EUR".
func getCurrencyCode(env *orm.Environment, moveID int64) string {
var code string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(c.name, 'EUR') FROM account_move m
JOIN res_currency c ON c.id = m.currency_id
WHERE m.id = $1`, moveID).Scan(&code)
if code == "" {
return "EUR"
}
return code
}
// ptrStr safely dereferences a *string, returning "" if nil.
func ptrStr(s *string) string {
if s != nil {

View File

@@ -2,6 +2,7 @@ package models
import (
"fmt"
"html"
"strings"
"odoo-go/pkg/orm"
@@ -276,7 +277,7 @@ func initFollowupProcess() {
.overdue{color:#d9534f;font-weight:bold}
h2{color:#875a7b}
</style>`)
b.WriteString(fmt.Sprintf("<h2>Payment Follow-up: %s</h2>", partnerName))
b.WriteString(fmt.Sprintf("<h2>Payment Follow-up: %s</h2>", html.EscapeString(partnerName)))
b.WriteString(`<table><tr><th>Invoice</th><th>Due Date</th><th>Total</th><th>Amount Due</th><th>Overdue Days</th></tr>`)
var totalDue float64

View File

@@ -38,8 +38,11 @@ func initAccountLock() {
)
// _compute_string_to_hash: generates the string representation of the move
// used for hash computation. Includes date, journal, partner, amounts.
// used for hash computation. Includes date, journal, partner, amounts, and company VAT.
// Mirrors: odoo/addons/account/models/account_move.py _compute_string_to_hash()
//
// The company VAT is included to ensure entries from different legal entities
// produce distinct hashes even when all other fields match.
ext.RegisterCompute("string_to_hash", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
moveID := rs.IDs()[0]
@@ -54,6 +57,17 @@ func initAccountLock() {
FROM account_move WHERE id = $1`, moveID,
).Scan(&name, &moveType, &state, &date, &companyID, &journalID, &partnerID)
// Fetch company VAT for inclusion in the hash
var companyVAT string
if companyID > 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(p.vat, '')
FROM res_company c
LEFT JOIN res_partner p ON p.id = c.partner_id
WHERE c.id = $1`, companyID,
).Scan(&companyVAT)
}
// Include line amounts
rows, err := env.Tx().Query(env.Ctx(),
`SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0),
@@ -78,9 +92,9 @@ func initAccountLock() {
pid = *partnerID
}
hashStr := fmt.Sprintf("%s|%s|%v|%d|%d|%d|%s",
hashStr := fmt.Sprintf("%s|%s|%v|%d|%d|%d|%s|%s",
name, moveType, date, companyID, journalID, pid,
strings.Join(lineData, ";"))
strings.Join(lineData, ";"), companyVAT)
return orm.Values{"string_to_hash": hashStr}, nil
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
package models
import (
"encoding/json"
"fmt"
"strings"
@@ -296,3 +297,73 @@ func applyWriteoffSuggestion(env *orm.Environment, modelID, stLineID int64, amou
"suggestions": suggestions,
}, nil
}
// initAccountReconcilePreview registers account.reconcile.model.preview (Odoo 18+).
// Transient model for previewing reconciliation results before applying.
// Mirrors: odoo/addons/account/wizard/account_reconcile_model_preview.py
func initAccountReconcilePreview() {
m := orm.NewModel("account.reconcile.model.preview", orm.ModelOpts{
Description: "Reconcile Model Preview",
Type: orm.ModelTransient,
})
m.AddFields(
orm.Many2one("model_id", "account.reconcile.model", orm.FieldOpts{
String: "Reconcile Model", Required: true,
}),
orm.Many2one("statement_line_id", "account.bank.statement.line", orm.FieldOpts{
String: "Statement Line",
}),
orm.Text("preview_data", orm.FieldOpts{
String: "Preview Data", Compute: "_compute_preview",
}),
)
m.RegisterCompute("preview_data", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var modelID int64
var stLineID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(model_id, 0), statement_line_id
FROM account_reconcile_model_preview WHERE id = $1`, id,
).Scan(&modelID, &stLineID)
if modelID == 0 {
return orm.Values{"preview_data": "[]"}, nil
}
// Read reconcile model lines to preview what would be created
rows, err := env.Tx().Query(env.Ctx(),
`SELECT rml.label, rml.amount_type, rml.amount,
COALESCE(a.code, ''), COALESCE(a.name, '')
FROM account_reconcile_model_line rml
LEFT JOIN account_account a ON a.id = rml.account_id
WHERE rml.model_id = $1
ORDER BY rml.sequence`, modelID)
if err != nil {
return orm.Values{"preview_data": "[]"}, nil
}
defer rows.Close()
var preview []map[string]interface{}
for rows.Next() {
var label, amountType, accCode, accName string
var amount float64
if err := rows.Scan(&label, &amountType, &amount, &accCode, &accName); err != nil {
continue
}
preview = append(preview, map[string]interface{}{
"label": label,
"amount_type": amountType,
"amount": amount,
"account_code": accCode,
"account_name": accName,
})
}
data, _ := json.Marshal(preview)
return orm.Values{"preview_data": string(data)}, nil
})
}

View File

@@ -1,6 +1,10 @@
package models
import "odoo-go/pkg/orm"
import (
"log"
"odoo-go/pkg/orm"
)
// initAccountRecurring registers account.move.recurring — recurring entry templates.
// Mirrors: odoo/addons/account/models/account_move.py (recurring entries feature)
@@ -113,4 +117,79 @@ func initAccountRecurring() {
}
return true, nil
})
// _cron_auto_post: cron job that auto-posts draft moves and generates recurring entries.
// Mirrors: odoo/addons/account/models/account_move.py _cron_auto_post_draft_entry()
//
// 1) Find draft account.move entries with auto_post=true and date <= today, post them.
// 2) Find recurring entries (state='running') with date_next <= today, generate them.
m.RegisterMethod("_cron_auto_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
// --- Part 1: Auto-post draft moves ---
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id FROM account_move
WHERE auto_post = true AND state = 'draft' AND date <= CURRENT_DATE`)
if err != nil {
return nil, err
}
var moveIDs []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
continue
}
moveIDs = append(moveIDs, id)
}
rows.Close()
if len(moveIDs) > 0 {
moveModelDef := orm.Registry.Get("account.move")
if moveModelDef != nil {
for _, mid := range moveIDs {
moveRS := env.Model("account.move").Browse(mid)
if postFn, ok := moveModelDef.Methods["action_post"]; ok {
if _, err := postFn(moveRS); err != nil {
log.Printf("account: auto-post move %d failed: %v", mid, err)
}
}
}
}
}
// --- Part 2: Generate recurring entries due today ---
recRows, err := env.Tx().Query(env.Ctx(),
`SELECT id FROM account_move_recurring
WHERE state = 'running' AND date_next <= CURRENT_DATE AND active = true`)
if err != nil {
return nil, err
}
var recIDs []int64
for recRows.Next() {
var id int64
if err := recRows.Scan(&id); err != nil {
continue
}
recIDs = append(recIDs, id)
}
recRows.Close()
if len(recIDs) > 0 {
recModelDef := orm.Registry.Get("account.move.recurring")
if recModelDef != nil {
for _, rid := range recIDs {
recRS := env.Model("account.move.recurring").Browse(rid)
if genFn, ok := recModelDef.Methods["action_generate"]; ok {
if _, err := genFn(recRS); err != nil {
log.Printf("account: recurring generate %d failed: %v", rid, err)
}
}
}
}
}
return true, nil
})
}

View File

@@ -56,6 +56,8 @@ func initAccountTaxReport() {
return generateAgedReport(env, "liability_payable")
case "general_ledger":
return generateGeneralLedger(env)
case "tax_report":
return generateTaxReport(env)
default:
return map[string]interface{}{"lines": []interface{}{}}, nil
}
@@ -81,20 +83,52 @@ func initAccountReportLine() {
// -- Report generation functions --
// reportOpts holds optional date/state filters for report generation.
type reportOpts struct {
DateFrom string // YYYY-MM-DD, empty = no lower bound
DateTo string // YYYY-MM-DD, empty = no upper bound
TargetMove string // "posted" or "all"
}
// reportStateFilter returns the SQL WHERE clause fragment for move state filtering.
func reportStateFilter(opts reportOpts) string {
if opts.TargetMove == "all" {
return "(m.state IS NOT NULL OR m.id IS NULL)"
}
return "(m.state = 'posted' OR m.id IS NULL)"
}
// reportDateFilter returns SQL WHERE clause fragment for date filtering.
func reportDateFilter(opts reportOpts) string {
clause := ""
if opts.DateFrom != "" {
clause += " AND m.date >= '" + opts.DateFrom + "'"
}
if opts.DateTo != "" {
clause += " AND m.date <= '" + opts.DateTo + "'"
}
return clause
}
// generateTrialBalance produces a trial balance report.
// Mirrors: odoo/addons/account_reports/models/account_trial_balance_report.py
func generateTrialBalance(env *orm.Environment) (interface{}, error) {
rows, err := env.Tx().Query(env.Ctx(), `
func generateTrialBalance(env *orm.Environment, opts ...reportOpts) (interface{}, error) {
opt := reportOpts{TargetMove: "posted"}
if len(opts) > 0 {
opt = opts[0]
}
rows, err := env.Tx().Query(env.Ctx(), fmt.Sprintf(`
SELECT a.code, a.name, a.account_type,
COALESCE(SUM(l.debit), 0) as total_debit,
COALESCE(SUM(l.credit), 0) as total_credit,
COALESCE(SUM(l.balance), 0) as balance
FROM account_account a
LEFT JOIN account_move_line l ON l.account_id = a.id
LEFT JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
LEFT JOIN account_move m ON m.id = l.move_id
WHERE %s %s
GROUP BY a.id, a.code, a.name, a.account_type
HAVING COALESCE(SUM(l.debit), 0) != 0 OR COALESCE(SUM(l.credit), 0) != 0
ORDER BY a.code`)
ORDER BY a.code`, reportStateFilter(opt), reportDateFilter(opt)))
if err != nil {
return nil, fmt.Errorf("account: trial balance query: %w", err)
}
@@ -124,24 +158,29 @@ func generateTrialBalance(env *orm.Environment) (interface{}, error) {
// generateBalanceSheet produces assets vs liabilities+equity.
// Mirrors: odoo/addons/account_reports/models/account_balance_sheet.py
func generateBalanceSheet(env *orm.Environment) (interface{}, error) {
rows, err := env.Tx().Query(env.Ctx(), `
func generateBalanceSheet(env *orm.Environment, opts ...reportOpts) (interface{}, error) {
opt := reportOpts{TargetMove: "posted"}
if len(opts) > 0 {
opt = opts[0]
}
rows, err := env.Tx().Query(env.Ctx(), fmt.Sprintf(`
SELECT
CASE
WHEN a.account_type LIKE 'asset%' THEN 'Assets'
WHEN a.account_type LIKE 'liability%' THEN 'Liabilities'
WHEN a.account_type LIKE 'equity%' THEN 'Equity'
WHEN a.account_type LIKE 'asset%%' THEN 'Assets'
WHEN a.account_type LIKE 'liability%%' THEN 'Liabilities'
WHEN a.account_type LIKE 'equity%%' THEN 'Equity'
ELSE 'Other'
END as section,
a.code, a.name,
COALESCE(SUM(l.balance), 0) as balance
FROM account_account a
LEFT JOIN account_move_line l ON l.account_id = a.id
LEFT JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
WHERE a.account_type LIKE 'asset%' OR a.account_type LIKE 'liability%' OR a.account_type LIKE 'equity%'
LEFT JOIN account_move m ON m.id = l.move_id
WHERE %s %s
AND (a.account_type LIKE 'asset%%' OR a.account_type LIKE 'liability%%' OR a.account_type LIKE 'equity%%')
GROUP BY a.id, a.code, a.name, a.account_type
HAVING COALESCE(SUM(l.balance), 0) != 0
ORDER BY a.code`)
ORDER BY a.code`, reportStateFilter(opt), reportDateFilter(opt)))
if err != nil {
return nil, fmt.Errorf("account: balance sheet query: %w", err)
}
@@ -163,23 +202,28 @@ func generateBalanceSheet(env *orm.Environment) (interface{}, error) {
// generateProfitLoss produces income vs expenses.
// Mirrors: odoo/addons/account_reports/models/account_profit_loss.py
func generateProfitLoss(env *orm.Environment) (interface{}, error) {
rows, err := env.Tx().Query(env.Ctx(), `
func generateProfitLoss(env *orm.Environment, opts ...reportOpts) (interface{}, error) {
opt := reportOpts{TargetMove: "posted"}
if len(opts) > 0 {
opt = opts[0]
}
rows, err := env.Tx().Query(env.Ctx(), fmt.Sprintf(`
SELECT
CASE
WHEN a.account_type LIKE 'income%' THEN 'Income'
WHEN a.account_type LIKE 'expense%' THEN 'Expenses'
WHEN a.account_type LIKE 'income%%' THEN 'Income'
WHEN a.account_type LIKE 'expense%%' THEN 'Expenses'
ELSE 'Other'
END as section,
a.code, a.name,
COALESCE(SUM(l.balance), 0) as balance
FROM account_account a
LEFT JOIN account_move_line l ON l.account_id = a.id
LEFT JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
WHERE a.account_type LIKE 'income%' OR a.account_type LIKE 'expense%'
LEFT JOIN account_move m ON m.id = l.move_id
WHERE %s %s
AND (a.account_type LIKE 'income%%' OR a.account_type LIKE 'expense%%')
GROUP BY a.id, a.code, a.name, a.account_type
HAVING COALESCE(SUM(l.balance), 0) != 0
ORDER BY a.code`)
ORDER BY a.code`, reportStateFilter(opt), reportDateFilter(opt)))
if err != nil {
return nil, fmt.Errorf("account: profit loss query: %w", err)
}
@@ -279,3 +323,128 @@ func generateGeneralLedger(env *orm.Environment) (interface{}, error) {
}
return map[string]interface{}{"lines": lines}, nil
}
// generateTaxReport produces a tax report grouped by tax name and rate.
// Mirrors: odoo/addons/account_reports/models/account_tax_report.py
// Aggregates tax amounts from posted move lines with display_type='tax'.
func generateTaxReport(env *orm.Environment) (interface{}, error) {
rows, err := env.Tx().Query(env.Ctx(), `
SELECT COALESCE(t.name, 'Undefined Tax'),
COALESCE(t.amount, 0) AS tax_rate,
COALESCE(SUM(ABS(l.balance::float8)), 0) AS tax_amount,
COALESCE(SUM(ABS(l.tax_base_amount::float8)), 0) AS base_amount,
COUNT(*) AS line_count
FROM account_move_line l
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
LEFT JOIN account_tax t ON t.id = l.tax_line_id
WHERE l.display_type = 'tax'
GROUP BY t.name, t.amount
ORDER BY t.name, t.amount`)
if err != nil {
return nil, fmt.Errorf("account: tax report query: %w", err)
}
defer rows.Close()
var lines []map[string]interface{}
var totalTax, totalBase float64
for rows.Next() {
var name string
var rate, taxAmount, baseAmount float64
var lineCount int
if err := rows.Scan(&name, &rate, &taxAmount, &baseAmount, &lineCount); err != nil {
return nil, fmt.Errorf("account: tax report scan: %w", err)
}
lines = append(lines, map[string]interface{}{
"tax_name": name,
"tax_rate": rate,
"tax_amount": taxAmount,
"base_amount": baseAmount,
"line_count": lineCount,
})
totalTax += taxAmount
totalBase += baseAmount
}
// Totals row
lines = append(lines, map[string]interface{}{
"tax_name": "TOTAL",
"tax_rate": 0.0,
"tax_amount": totalTax,
"base_amount": totalBase,
"line_count": 0,
})
return map[string]interface{}{"lines": lines}, nil
}
// ---------------------------------------------------------------------------
// Financial Report Wizard
// Mirrors: odoo/addons/account_reports/wizard/account_report_wizard.py
// ---------------------------------------------------------------------------
// initAccountReportWizard registers a transient model that lets the user
// choose date range, target-move filter and report type, then dispatches
// to the appropriate generateXXX function.
func initAccountReportWizard() {
m := orm.NewModel("account.report.wizard", orm.ModelOpts{
Description: "Financial Report Wizard",
Type: orm.ModelTransient,
})
m.AddFields(
orm.Date("date_from", orm.FieldOpts{String: "Start Date", Required: true}),
orm.Date("date_to", orm.FieldOpts{String: "End Date", Required: true}),
orm.Selection("target_move", []orm.SelectionItem{
{Value: "all", Label: "All Entries"},
{Value: "posted", Label: "All Posted Entries"},
}, orm.FieldOpts{String: "Target Moves", Default: "posted", Required: true}),
orm.Selection("report_type", []orm.SelectionItem{
{Value: "trial_balance", Label: "Trial Balance"},
{Value: "balance_sheet", Label: "Balance Sheet"},
{Value: "profit_loss", Label: "Profit and Loss"},
{Value: "aged_receivable", Label: "Aged Receivable"},
{Value: "aged_payable", Label: "Aged Payable"},
{Value: "general_ledger", Label: "General Ledger"},
{Value: "tax_report", Label: "Tax Report"},
}, orm.FieldOpts{String: "Report Type", Required: true}),
)
// action_generate_report dispatches to the matching report generator.
// Mirrors: odoo/addons/account_reports/wizard/account_report_wizard.py action_generate_report()
m.RegisterMethod("action_generate_report", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
data, err := rs.Read([]string{"date_from", "date_to", "target_move", "report_type"})
if err != nil || len(data) == 0 {
return nil, fmt.Errorf("account: cannot read report wizard data")
}
wiz := data[0]
reportType, _ := wiz["report_type"].(string)
dateFrom, _ := wiz["date_from"].(string)
dateTo, _ := wiz["date_to"].(string)
targetMove, _ := wiz["target_move"].(string)
if targetMove == "" {
targetMove = "posted"
}
opt := reportOpts{DateFrom: dateFrom, DateTo: dateTo, TargetMove: targetMove}
switch reportType {
case "trial_balance":
return generateTrialBalance(env, opt)
case "balance_sheet":
return generateBalanceSheet(env, opt)
case "profit_loss":
return generateProfitLoss(env, opt)
case "aged_receivable":
return generateAgedReport(env, "asset_receivable")
case "aged_payable":
return generateAgedReport(env, "liability_payable")
case "general_ledger":
return generateGeneralLedger(env)
case "tax_report":
return generateTaxReport(env)
default:
return nil, fmt.Errorf("account: unknown report type %q", reportType)
}
})
}

View File

@@ -51,10 +51,12 @@ func ComputeTax(env *orm.Environment, taxID int64, baseAmount float64) (*TaxResu
case "fixed":
taxAmount = amount
case "division":
// Division tax: price = base / (1 - rate/100)
// Mirrors: odoo/addons/account/models/account_tax.py _compute_amount (division case)
if priceInclude {
taxAmount = baseAmount - (baseAmount / (1 + amount/100))
taxAmount = baseAmount - (baseAmount * (100 - amount) / 100)
} else {
taxAmount = baseAmount * amount / 100
taxAmount = baseAmount/(1-amount/100) - baseAmount
}
}

View File

@@ -28,4 +28,12 @@ func Init() {
initAccountSequence()
initAccountEdi()
initAccountReconcileModel()
initAccountMoveInvoiceExtensions()
initAccountPaymentExtensions()
initAccountJournalExtensions()
initAccountTaxComputes()
initAccountReportWizard()
initAccountMoveReversal()
initAccountMoveTemplate()
initAccountReconcilePreview()
}