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

@@ -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{