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

@@ -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)
}
})
}