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:
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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, ¤cyID, &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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,4 +28,12 @@ func Init() {
|
||||
initAccountSequence()
|
||||
initAccountEdi()
|
||||
initAccountReconcileModel()
|
||||
initAccountMoveInvoiceExtensions()
|
||||
initAccountPaymentExtensions()
|
||||
initAccountJournalExtensions()
|
||||
initAccountTaxComputes()
|
||||
initAccountReportWizard()
|
||||
initAccountMoveReversal()
|
||||
initAccountMoveTemplate()
|
||||
initAccountReconcilePreview()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user