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

View File

@@ -1,6 +1,10 @@
package models
import "odoo-go/pkg/orm"
import (
"fmt"
"odoo-go/pkg/orm"
)
// initIrCron registers ir.cron — Scheduled actions.
// Mirrors: odoo/addons/base/models/ir_cron.py class IrCron
@@ -30,5 +34,86 @@ func initIrCron() {
orm.Integer("priority", orm.FieldOpts{String: "Priority", Default: 5}),
orm.Char("code", orm.FieldOpts{String: "Python Code"}),
orm.Many2one("model_id", "ir.model", orm.FieldOpts{String: "Model"}),
// Execution target (simplified: direct model+method instead of ir.actions.server)
orm.Char("model_name", orm.FieldOpts{String: "Model Name"}),
orm.Char("method_name", orm.FieldOpts{String: "Method Name"}),
// Failure tracking
orm.Integer("failure_count", orm.FieldOpts{String: "Failure Count", Default: 0}),
orm.Datetime("first_failure_date", orm.FieldOpts{String: "First Failure Date"}),
)
// Constraint: validate model_name and method_name against the registry.
// Prevents setting arbitrary/invalid model+method combos on cron jobs.
m.AddConstraint(func(rs *orm.Recordset) error {
records, err := rs.Read([]string{"model_name", "method_name"})
if err != nil || len(records) == 0 {
return nil
}
rec := records[0]
modelName, _ := rec["model_name"].(string)
methodName, _ := rec["method_name"].(string)
if modelName == "" && methodName == "" {
return nil // both empty is OK (legacy code-based crons)
}
if modelName != "" {
model := orm.Registry.Get(modelName)
if model == nil {
return fmt.Errorf("ir.cron: model %q not found in registry", modelName)
}
if methodName != "" && model.Methods != nil {
if _, ok := model.Methods[methodName]; !ok {
return fmt.Errorf("ir.cron: method %q not found on model %q", methodName, modelName)
}
}
}
return nil
})
// method_direct_trigger — manually trigger a cron job.
// Mirrors: odoo/addons/base/models/ir_cron.py method_direct_trigger
m.RegisterMethod("method_direct_trigger", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
// Admin-only: only uid=1 or superuser may trigger cron jobs directly
env := rs.Env()
if env.UID() != 1 && !env.IsSuperuser() {
return nil, fmt.Errorf("ir.cron: method_direct_trigger requires admin privileges")
}
records, err := rs.Read([]string{"model_name", "method_name"})
if err != nil {
return nil, fmt.Errorf("ir.cron: method_direct_trigger read failed: %w", err)
}
if len(records) == 0 {
return nil, fmt.Errorf("ir.cron: method_direct_trigger: no record found")
}
rec := records[0]
modelName, _ := rec["model_name"].(string)
methodName, _ := rec["method_name"].(string)
if modelName == "" || methodName == "" {
return nil, fmt.Errorf("ir.cron: model_name or method_name not set")
}
// Validate model_name against registry (prevents calling arbitrary models)
model := orm.Registry.Get(modelName)
if model == nil {
return nil, fmt.Errorf("ir.cron: model %q not found in registry", modelName)
}
if model.Methods == nil {
return nil, fmt.Errorf("ir.cron: model %q has no methods", modelName)
}
method, ok := model.Methods[methodName]
if !ok {
return nil, fmt.Errorf("ir.cron: method %q not found on model %q", methodName, modelName)
}
result, err := method(env.Model(modelName), args...)
if err != nil {
return nil, fmt.Errorf("ir.cron: %s.%s failed: %w", modelName, methodName, err)
}
return result, nil
})
}

View File

@@ -127,9 +127,28 @@ func initIrActions() {
{Value: "object_write", Label: "Update Record"},
{Value: "object_create", Label: "Create Record"},
{Value: "multi", Label: "Execute Several Actions"},
{Value: "email", Label: "Send Email"},
}, orm.FieldOpts{String: "Action To Do", Default: "code", Required: true}),
orm.Text("code", orm.FieldOpts{String: "Code"}),
orm.Many2many("groups_id", "res.groups", orm.FieldOpts{String: "Groups"}),
// Automated action fields
orm.Selection("trigger", []orm.SelectionItem{
{Value: "on_create", Label: "On Creation"},
{Value: "on_write", Label: "On Update"},
{Value: "on_create_or_write", Label: "On Creation & Update"},
{Value: "on_unlink", Label: "On Deletion"},
{Value: "on_time", Label: "Based on Time Condition"},
}, orm.FieldOpts{String: "Trigger"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
// object_write: fields to update
orm.Text("update_field_id", orm.FieldOpts{String: "Field to Update"}),
orm.Char("update_value", orm.FieldOpts{String: "Value"}),
// email: template fields
orm.Char("email_to", orm.FieldOpts{String: "Email To", Help: "Field name on the record (e.g. email, partner_id.email)"}),
orm.Char("email_subject", orm.FieldOpts{String: "Email Subject"}),
orm.Text("email_body", orm.FieldOpts{String: "Email Body", Help: "HTML body. Use {{field_name}} for record values."}),
// filter domain
orm.Text("filter_domain", orm.FieldOpts{String: "Filter Domain", Help: "Only trigger when record matches this domain"}),
)
}

View File

@@ -66,8 +66,39 @@ func initResUsers() {
String: "Share User", Compute: "_compute_share", Store: true,
Help: "External user with limited access (portal/public)",
}),
orm.Char("signup_token", orm.FieldOpts{String: "Signup Token"}),
orm.Datetime("signup_expiration", orm.FieldOpts{String: "Signup Token Expiration"}),
)
// _compute_share: portal/public users have share=true (not in group_user).
// Mirrors: odoo/addons/base/models/res_users.py Users._compute_share()
m.RegisterMethod("_compute_share", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
// Look up group_user ID
var groupUserID int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT g.id FROM res_groups g
JOIN ir_model_data imd ON imd.res_id = g.id AND imd.model = 'res.groups'
WHERE imd.module = 'base' AND imd.name = 'group_user'`).Scan(&groupUserID)
if err != nil {
return nil, nil // Can't determine, skip
}
for _, id := range rs.IDs() {
var inGroup bool
err := env.Tx().QueryRow(env.Ctx(),
`SELECT EXISTS(
SELECT 1 FROM res_groups_res_users_rel
WHERE res_groups_id = $1 AND res_users_id = $2
)`, groupUserID, id).Scan(&inGroup)
if err != nil {
continue
}
env.Model("res.users").Browse(id).Write(orm.Values{"share": !inGroup})
}
return nil, nil
})
// -- Methods --
// action_get returns the "Change My Preferences" action for the current user.

View File

@@ -1,6 +1,11 @@
package models
import "odoo-go/pkg/orm"
import (
"fmt"
"time"
"odoo-go/pkg/orm"
)
// initCRMLead registers the crm.lead model.
// Mirrors: odoo/addons/crm/models/crm_lead.py
@@ -67,73 +72,210 @@ func initCRMLead() {
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
)
// DefaultGet: set company_id from the session so that DB NOT NULL constraint is satisfied
// Onchange: stage_id -> auto-update probability from stage.
// Mirrors: odoo/addons/crm/models/crm_lead.py _onchange_stage_id
m.RegisterOnchange("stage_id", func(env *orm.Environment, vals orm.Values) orm.Values {
result := make(orm.Values)
stageID, ok := vals["stage_id"]
if !ok || stageID == nil {
return result
}
var sid float64
switch v := stageID.(type) {
case float64:
sid = v
case int64:
sid = float64(v)
case int:
sid = float64(v)
default:
return result
}
if sid == 0 {
return result
}
var probability float64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(probability, 10) FROM crm_stage WHERE id = $1`, int64(sid),
).Scan(&probability); err != nil {
return result
}
result["probability"] = probability
result["date_last_stage_update"] = time.Now().Format("2006-01-02 15:04:05")
return result
})
// DefaultGet: set company_id, user_id, team_id, type from session/defaults.
// Mirrors: odoo/addons/crm/models/crm_lead.py default_get
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
vals := make(orm.Values)
if env.CompanyID() > 0 {
vals["company_id"] = env.CompanyID()
}
if env.UID() > 0 {
vals["user_id"] = env.UID()
}
vals["type"] = "lead"
// Try to find a default sales team for the user
var teamID int64
if env.UID() > 0 {
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT ct.id FROM crm_team ct
JOIN crm_team_member ctm ON ctm.crm_team_id = ct.id
WHERE ctm.user_id = $1 AND ct.active = true
ORDER BY ct.sequence LIMIT 1`, env.UID()).Scan(&teamID); err != nil {
// No team found for user — not an error, just no default
teamID = 0
}
}
if teamID > 0 {
vals["team_id"] = teamID
}
return vals
}
// action_set_won: mark lead as won
// action_set_won: mark lead as won, set date_closed, find won stage.
// Mirrors: odoo/addons/crm/models/crm_lead.py action_set_won
m.RegisterMethod("action_set_won", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
var wonStageID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM crm_stage WHERE is_won = true ORDER BY sequence LIMIT 1`).Scan(&wonStageID)
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET state = 'won', probability = 100 WHERE id = $1`, id)
var err error
if wonStageID > 0 {
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET state = 'won', probability = 100, automated_probability = 100,
date_closed = NOW(), active = true, stage_id = $2
WHERE id = $1`, id, wonStageID)
} else {
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET state = 'won', probability = 100, automated_probability = 100,
date_closed = NOW(), active = true
WHERE id = $1`, id)
}
if err != nil {
return nil, fmt.Errorf("crm.lead: set_won %d: %w", id, err)
}
}
return true, nil
})
// action_set_lost: mark lead as lost
// action_set_lost: mark lead as lost, accept lost_reason_id from kwargs.
// Mirrors: odoo/addons/crm/models/crm_lead.py action_set_lost
m.RegisterMethod("action_set_lost", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
// Extract lost_reason_id from kwargs if provided
var lostReasonID int64
if len(args) > 0 {
if kwargs, ok := args[0].(map[string]interface{}); ok {
if rid, ok := kwargs["lost_reason_id"]; ok {
switch v := rid.(type) {
case float64:
lostReasonID = int64(v)
case int64:
lostReasonID = v
case int:
lostReasonID = int64(v)
}
}
}
}
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET state = 'lost', probability = 0, active = false WHERE id = $1`, id)
var err error
if lostReasonID > 0 {
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET state = 'lost', probability = 0, automated_probability = 0,
active = false, date_closed = NOW(), lost_reason_id = $2
WHERE id = $1`, id, lostReasonID)
} else {
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET state = 'lost', probability = 0, automated_probability = 0,
active = false, date_closed = NOW()
WHERE id = $1`, id)
}
if err != nil {
return nil, fmt.Errorf("crm.lead: set_lost %d: %w", id, err)
}
}
return true, nil
})
// convert_to_opportunity: lead opportunity
// convert_to_opportunity: lead -> opportunity, set date_conversion.
// Mirrors: odoo/addons/crm/models/crm_lead.py _convert_opportunity_data
m.RegisterMethod("convert_to_opportunity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET type = 'opportunity' WHERE id = $1 AND type = 'lead'`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET type = 'opportunity', date_conversion = NOW(),
date_open = COALESCE(date_open, NOW())
WHERE id = $1 AND type = 'lead'`, id); err != nil {
return nil, fmt.Errorf("crm.lead: convert_to_opportunity %d: %w", id, err)
}
}
return true, nil
})
// convert_opportunity: alias for convert_to_opportunity
// convert_opportunity: convert lead to opportunity with optional partner/team assignment.
// Mirrors: odoo/addons/crm/models/crm_lead.py convert_opportunity
m.RegisterMethod("convert_opportunity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
// Optional partner_id from args
var partnerID int64
if len(args) > 0 {
if pid, ok := args[0].(float64); ok {
partnerID = int64(pid)
}
}
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET type = 'opportunity' WHERE id = $1`, id)
if partnerID > 0 {
env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET type = 'opportunity', date_conversion = NOW(),
date_open = COALESCE(date_open, NOW()), partner_id = $2
WHERE id = $1`, id, partnerID)
} else {
env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET type = 'opportunity', date_conversion = NOW(),
date_open = COALESCE(date_open, NOW())
WHERE id = $1`, id)
}
}
return true, nil
})
// action_set_won_rainbowman: set won stage + rainbow effect
// action_set_won_rainbowman: set won + rainbow effect.
// Mirrors: odoo/addons/crm/models/crm_lead.py action_set_won_rainbowman
m.RegisterMethod("action_set_won_rainbowman", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
// Find Won stage
// Find the first won stage
var wonStageID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM crm_stage WHERE is_won = true LIMIT 1`).Scan(&wonStageID)
if wonStageID == 0 {
wonStageID = 4 // fallback
}
`SELECT id FROM crm_stage WHERE is_won = true ORDER BY sequence LIMIT 1`).Scan(&wonStageID)
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET stage_id = $1, probability = 100 WHERE id = $2`, wonStageID, id)
if wonStageID > 0 {
env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET state = 'won', probability = 100, automated_probability = 100,
date_closed = NOW(), active = true, stage_id = $2
WHERE id = $1`, id, wonStageID)
} else {
env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET state = 'won', probability = 100, automated_probability = 100,
date_closed = NOW(), active = true
WHERE id = $1`, id)
}
}
return map[string]interface{}{
"effect": map[string]interface{}{
"type": "rainbow_man",
"message": "Congrats, you won this opportunity!",
"fadeout": "slow",
},
}, nil
})
@@ -152,6 +294,11 @@ func initCRMStage() {
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 1}),
orm.Boolean("fold", orm.FieldOpts{String: "Folded in Pipeline"}),
orm.Boolean("is_won", orm.FieldOpts{String: "Is Won Stage"}),
orm.Float("probability", orm.FieldOpts{
String: "Probability (%)",
Help: "Default probability when a lead enters this stage.",
Default: float64(10),
}),
orm.Many2many("team_ids", "crm.team", orm.FieldOpts{String: "Sales Teams"}),
orm.Text("requirements", orm.FieldOpts{String: "Requirements"}),
)

View File

@@ -2,6 +2,7 @@ package models
import (
"fmt"
"log"
"odoo-go/pkg/orm"
)
@@ -73,12 +74,14 @@ func initCrmAnalysis() {
// Win rate
var total, won int64
_ = env.Tx().QueryRow(env.Ctx(),
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN s.is_won THEN 1 ELSE 0 END), 0)
FROM crm_lead l
JOIN crm_stage s ON s.id = l.stage_id
WHERE l.type = 'opportunity'`,
).Scan(&total, &won)
).Scan(&total, &won); err != nil {
log.Printf("warning: crm win rate query failed: %v", err)
}
winRate := float64(0)
if total > 0 {
@@ -99,12 +102,14 @@ func initCrmAnalysis() {
env := rs.Env()
var totalLeads, convertedLeads int64
_ = env.Tx().QueryRow(env.Ctx(), `
if err := env.Tx().QueryRow(env.Ctx(), `
SELECT
COUNT(*) FILTER (WHERE type = 'lead'),
COUNT(*) FILTER (WHERE type = 'opportunity' AND date_conversion IS NOT NULL)
FROM crm_lead WHERE active = true`,
).Scan(&totalLeads, &convertedLeads)
).Scan(&totalLeads, &convertedLeads); err != nil {
log.Printf("warning: crm conversion data query failed: %v", err)
}
conversionRate := float64(0)
if totalLeads > 0 {
@@ -113,19 +118,23 @@ func initCrmAnalysis() {
// Average days to convert
var avgDaysConvert float64
_ = env.Tx().QueryRow(env.Ctx(), `
if err := env.Tx().QueryRow(env.Ctx(), `
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (date_conversion - create_date)) / 86400), 0)
FROM crm_lead
WHERE type = 'opportunity' AND date_conversion IS NOT NULL AND active = true`,
).Scan(&avgDaysConvert)
).Scan(&avgDaysConvert); err != nil {
log.Printf("warning: crm avg days to convert query failed: %v", err)
}
// Average days to close (won)
var avgDaysClose float64
_ = env.Tx().QueryRow(env.Ctx(), `
if err := env.Tx().QueryRow(env.Ctx(), `
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (date_closed - create_date)) / 86400), 0)
FROM crm_lead
WHERE state = 'won' AND date_closed IS NOT NULL`,
).Scan(&avgDaysClose)
).Scan(&avgDaysClose); err != nil {
log.Printf("warning: crm avg days to close query failed: %v", err)
}
return map[string]interface{}{
"total_leads": totalLeads,

View File

@@ -2,6 +2,8 @@ package models
import (
"fmt"
"log"
"strings"
"odoo-go/pkg/orm"
)
@@ -38,14 +40,32 @@ func initCRMLeadExtended() {
}),
// ──── Tracking / timing fields ────
// Mirrors: odoo/addons/crm/models/crm_lead.py day_open, day_close
orm.Integer("day_open", orm.FieldOpts{
String: "Days to Assign",
Help: "Number of days to assign this lead to a salesperson.",
// Mirrors: odoo/addons/crm/models/crm_lead.py date_open, day_open, day_close
orm.Datetime("date_open", orm.FieldOpts{
String: "Assignment Date",
Help: "Date when the lead was first assigned to a salesperson.",
}),
orm.Integer("day_close", orm.FieldOpts{
String: "Days to Close",
Help: "Number of days to close this lead/opportunity.",
orm.Float("day_open", orm.FieldOpts{
String: "Days to Assign",
Compute: "_compute_day_open",
Help: "Number of days between creation and assignment.",
}),
orm.Float("day_close", orm.FieldOpts{
String: "Days to Close",
Compute: "_compute_day_close",
Help: "Number of days between creation and closing.",
}),
// ──── Kanban state ────
// Mirrors: odoo/addons/crm/models/crm_lead.py kanban_state (via mail.activity.mixin)
orm.Selection("kanban_state", []orm.SelectionItem{
{Value: "grey", Label: "No next activity planned"},
{Value: "red", Label: "Next activity late"},
{Value: "green", Label: "Next activity is planned"},
}, orm.FieldOpts{
String: "Kanban State",
Compute: "_compute_kanban_state",
Help: "Activity-based status indicator for kanban views.",
}),
// ──── Additional contact/address fields ────
@@ -76,6 +96,27 @@ func initCRMLeadExtended() {
Help: "Second line of the street address.",
}),
// ──── Computed timing fields ────
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_days_in_stage
orm.Float("days_in_stage", orm.FieldOpts{
String: "Days in Current Stage",
Compute: "_compute_days_in_stage",
Help: "Number of days since the last stage change.",
}),
// ──── Email scoring / contact address ────
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_email_score
orm.Char("email_domain_criterion", orm.FieldOpts{
String: "Email Domain",
Compute: "_compute_email_score",
Help: "Domain part of the lead email (e.g. 'example.com').",
}),
orm.Text("contact_address_complete", orm.FieldOpts{
String: "Contact Address",
Compute: "_compute_contact_address",
Help: "Full contact address assembled from partner data.",
}),
// ──── Revenue fields ────
// Mirrors: odoo/addons/crm/models/crm_lead.py prorated_revenue
orm.Monetary("prorated_revenue", orm.FieldOpts{
@@ -135,35 +176,333 @@ func initCRMLeadExtended() {
var revenue float64
var probability float64
_ = env.Tx().QueryRow(env.Ctx(),
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(expected_revenue::float8, 0), COALESCE(probability, 0)
FROM crm_lead WHERE id = $1`, leadID,
).Scan(&revenue, &probability)
).Scan(&revenue, &probability); err != nil {
log.Printf("warning: crm.lead _compute_prorated_revenue query failed: %v", err)
}
prorated := revenue * probability / 100.0
return orm.Values{"prorated_revenue": prorated}, nil
})
// ──── Compute: day_open ────
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_day_open
m.RegisterCompute("day_open", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
leadID := rs.IDs()[0]
var dayOpen *float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT CASE
WHEN date_open IS NOT NULL AND create_date IS NOT NULL
THEN ABS(EXTRACT(EPOCH FROM (date_open - create_date)) / 86400)
ELSE NULL
END
FROM crm_lead WHERE id = $1`, leadID,
).Scan(&dayOpen)
if err != nil {
log.Printf("warning: crm.lead _compute_day_open query failed: %v", err)
}
result := float64(0)
if dayOpen != nil {
result = *dayOpen
}
return orm.Values{"day_open": result}, nil
})
// ──── Compute: day_close ────
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_day_close
m.RegisterCompute("day_close", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
leadID := rs.IDs()[0]
var dayClose *float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT CASE
WHEN date_closed IS NOT NULL AND create_date IS NOT NULL
THEN ABS(EXTRACT(EPOCH FROM (date_closed - create_date)) / 86400)
ELSE NULL
END
FROM crm_lead WHERE id = $1`, leadID,
).Scan(&dayClose)
if err != nil {
log.Printf("warning: crm.lead _compute_day_close query failed: %v", err)
}
result := float64(0)
if dayClose != nil {
result = *dayClose
}
return orm.Values{"day_close": result}, nil
})
// ──── Compute: kanban_state ────
// Based on activity deadline: overdue=red, today/future=green, no activity=grey.
// Mirrors: odoo/addons/mail/models/mail_activity_mixin.py _compute_kanban_state
m.RegisterCompute("kanban_state", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
leadID := rs.IDs()[0]
var deadline *string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT activity_date_deadline FROM crm_lead WHERE id = $1`, leadID,
).Scan(&deadline)
if err != nil {
log.Printf("warning: crm.lead _compute_kanban_state query failed: %v", err)
}
state := "grey" // no activity planned
if deadline != nil && *deadline != "" {
// Check if overdue
var isOverdue bool
env.Tx().QueryRow(env.Ctx(),
`SELECT activity_date_deadline < CURRENT_DATE FROM crm_lead WHERE id = $1`, leadID,
).Scan(&isOverdue)
if isOverdue {
state = "red" // overdue
} else {
state = "green" // planned (today or future)
}
}
return orm.Values{"kanban_state": state}, nil
})
// ──── Compute: days_in_stage ────
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_days_in_stage
m.RegisterCompute("days_in_stage", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
leadID := rs.IDs()[0]
var days *float64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT CASE
WHEN date_last_stage_update IS NOT NULL
THEN EXTRACT(DAY FROM NOW() - date_last_stage_update)
ELSE 0
END
FROM crm_lead WHERE id = $1`, leadID,
).Scan(&days); err != nil {
log.Printf("warning: crm.lead _compute_days_in_stage query failed: %v", err)
}
result := float64(0)
if days != nil {
result = *days
}
return orm.Values{"days_in_stage": result}, nil
})
// ──── Compute: email_score (email domain extraction) ────
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_email_domain_criterion
m.RegisterCompute("email_domain_criterion", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
leadID := rs.IDs()[0]
var email *string
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT email_from FROM crm_lead WHERE id = $1`, leadID,
).Scan(&email); err != nil {
log.Printf("warning: crm.lead _compute_email_score query failed: %v", err)
}
domain := ""
if email != nil && *email != "" {
parts := strings.SplitN(*email, "@", 2)
if len(parts) == 2 {
domain = strings.TrimSpace(parts[1])
}
}
return orm.Values{"email_domain_criterion": domain}, nil
})
// ──── Compute: contact_address ────
// Mirrors: odoo/addons/crm/models/crm_lead.py _compute_contact_address
m.RegisterCompute("contact_address_complete", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
leadID := rs.IDs()[0]
var street, street2, city, zip *string
var partnerID *int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT street, street2, city, zip, partner_id
FROM crm_lead WHERE id = $1`, leadID,
).Scan(&street, &street2, &city, &zip, &partnerID); err != nil {
log.Printf("warning: crm.lead _compute_contact_address query failed: %v", err)
}
// If partner exists, fetch address from partner instead
if partnerID != nil {
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT street, street2, city, zip
FROM res_partner WHERE id = $1`, *partnerID,
).Scan(&street, &street2, &city, &zip); err != nil {
log.Printf("warning: crm.lead _compute_contact_address partner query failed: %v", err)
}
}
var parts []string
if street != nil && *street != "" {
parts = append(parts, *street)
}
if street2 != nil && *street2 != "" {
parts = append(parts, *street2)
}
if zip != nil && *zip != "" && city != nil && *city != "" {
parts = append(parts, *zip+" "+*city)
} else if city != nil && *city != "" {
parts = append(parts, *city)
}
address := strings.Join(parts, "\n")
return orm.Values{"contact_address_complete": address}, nil
})
// ──── Business Methods ────
// action_schedule_activity: return a window action to schedule an activity.
// action_schedule_activity: create a mail.activity record linked to the lead.
// Mirrors: odoo/addons/crm/models/crm_lead.py action_schedule_activity
m.RegisterMethod("action_schedule_activity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
leadID := rs.IDs()[0]
// Extract optional kwargs: summary, activity_type_id, date_deadline, user_id, note
summary := ""
note := ""
dateDeadline := ""
var userID int64
var activityTypeID int64
if len(args) > 0 {
if kwargs, ok := args[0].(map[string]interface{}); ok {
if v, ok := kwargs["summary"].(string); ok {
summary = v
}
if v, ok := kwargs["note"].(string); ok {
note = v
}
if v, ok := kwargs["date_deadline"].(string); ok {
dateDeadline = v
}
if v, ok := kwargs["user_id"]; ok {
switch uid := v.(type) {
case float64:
userID = int64(uid)
case int64:
userID = uid
case int:
userID = int64(uid)
}
}
if v, ok := kwargs["activity_type_id"]; ok {
switch tid := v.(type) {
case float64:
activityTypeID = int64(tid)
case int64:
activityTypeID = tid
case int:
activityTypeID = int64(tid)
}
}
}
}
// Default user to current user
if userID == 0 {
userID = env.UID()
}
// Default deadline to tomorrow
if dateDeadline == "" {
dateDeadline = "CURRENT_DATE + INTERVAL '1 day'"
}
var newID int64
var err error
if dateDeadline == "CURRENT_DATE + INTERVAL '1 day'" {
err = env.Tx().QueryRow(env.Ctx(),
`INSERT INTO mail_activity (res_model, res_id, summary, note, date_deadline, user_id, activity_type_id, state)
VALUES ('crm.lead', $1, $2, $3, CURRENT_DATE + INTERVAL '1 day', $4, NULLIF($5, 0), 'planned')
RETURNING id`,
leadID, summary, note, userID, activityTypeID,
).Scan(&newID)
} else {
err = env.Tx().QueryRow(env.Ctx(),
`INSERT INTO mail_activity (res_model, res_id, summary, note, date_deadline, user_id, activity_type_id, state)
VALUES ('crm.lead', $1, $2, $3, $6::date, $4, NULLIF($5, 0), 'planned')
RETURNING id`,
leadID, summary, note, userID, activityTypeID, dateDeadline,
).Scan(&newID)
}
if err != nil {
return nil, fmt.Errorf("action_schedule_activity: %w", err)
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"name": "Schedule Activity",
"res_model": "crm.lead",
"res_id": rs.IDs()[0],
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"target": "new",
"activity_id": newID,
"type": "ir.actions.act_window",
"name": "Schedule Activity",
"res_model": "mail.activity",
"res_id": newID,
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"target": "new",
}, nil
})
// action_merge: merge multiple leads into the first one.
// Sums expected revenues from slave leads, deactivates them.
// Mirrors: odoo/addons/crm/wizard/crm_merge_opportunities.py
// action_merge: alias for action_merge_leads (delegates to the full implementation).
m.RegisterMethod("action_merge", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
mergeMethod := orm.Registry.Get("crm.lead").Methods["action_merge_leads"]
if mergeMethod != nil {
return mergeMethod(rs, args...)
}
return nil, fmt.Errorf("crm.lead: action_merge_leads not found")
})
// _get_opportunities_by_status: GROUP BY stage_id aggregation returning counts + sums.
// Mirrors: odoo/addons/crm/models/crm_lead.py _read_group (pipeline analysis)
m.RegisterMethod("_get_opportunities_by_status", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
rows, err := env.Tx().Query(env.Ctx(), `
SELECT s.id, s.name, COUNT(l.id), COALESCE(SUM(l.expected_revenue::float8), 0),
COALESCE(AVG(l.probability), 0)
FROM crm_lead l
JOIN crm_stage s ON s.id = l.stage_id
WHERE l.active = true AND l.type = 'opportunity'
GROUP BY s.id, s.name, s.sequence
ORDER BY s.sequence`)
if err != nil {
return nil, fmt.Errorf("_get_opportunities_by_status: %w", err)
}
defer rows.Close()
var results []map[string]interface{}
for rows.Next() {
var stageID int64
var stageName string
var count int64
var revenue, avgProb float64
if err := rows.Scan(&stageID, &stageName, &count, &revenue, &avgProb); err != nil {
return nil, fmt.Errorf("_get_opportunities_by_status scan: %w", err)
}
results = append(results, map[string]interface{}{
"stage_id": stageID,
"stage_name": stageName,
"count": count,
"total_revenue": revenue,
"avg_probability": avgProb,
})
}
return results, nil
})
// action_merge_leads: merge multiple leads — sum revenues, keep first partner,
// concatenate descriptions, delete merged records.
// Mirrors: odoo/addons/crm/wizard/crm_merge_opportunities.py action_merge
m.RegisterMethod("action_merge_leads", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
ids := rs.IDs()
if len(ids) < 2 {
@@ -172,25 +511,36 @@ func initCRMLeadExtended() {
masterID := ids[0]
for _, slaveID := range ids[1:] {
// Sum revenues from slave into master
_, _ = env.Tx().Exec(env.Ctx(),
// Sum revenues
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead
SET expected_revenue = COALESCE(expected_revenue, 0) +
(SELECT COALESCE(expected_revenue, 0) FROM crm_lead WHERE id = $1)
WHERE id = $2`,
slaveID, masterID)
// Copy partner info if master has none
_, _ = env.Tx().Exec(env.Ctx(),
WHERE id = $2`, slaveID, masterID); err != nil {
log.Printf("warning: crm.lead action_merge_leads revenue sum failed for slave %d: %v", slaveID, err)
}
// Keep first partner (master wins if set)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET
partner_id = COALESCE(partner_id, (SELECT partner_id FROM crm_lead WHERE id = $1))
WHERE id = $2`, slaveID, masterID); err != nil {
log.Printf("warning: crm.lead action_merge_leads partner copy failed for slave %d: %v", slaveID, err)
}
// Concatenate descriptions
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead
SET partner_id = COALESCE(
(SELECT partner_id FROM crm_lead WHERE id = $2),
partner_id)
WHERE id = $1 AND partner_id IS NULL`,
masterID, slaveID)
// Deactivate the slave lead
_, _ = env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET active = false WHERE id = $1`, slaveID)
SET description = COALESCE(description, '') || E'\n---\n' ||
COALESCE((SELECT description FROM crm_lead WHERE id = $1), '')
WHERE id = $2`, slaveID, masterID); err != nil {
log.Printf("warning: crm.lead action_merge_leads description concat failed for slave %d: %v", slaveID, err)
}
// Delete the merged (slave) lead
if _, err := env.Tx().Exec(env.Ctx(),
`DELETE FROM crm_lead WHERE id = $1`, slaveID); err != nil {
log.Printf("warning: crm.lead action_merge_leads delete failed for slave %d: %v", slaveID, err)
}
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": "crm.lead",
@@ -201,6 +551,166 @@ func initCRMLeadExtended() {
}, nil
})
// _action_reschedule_calls: update activity dates for leads with overdue activities.
// Mirrors: odoo/addons/crm/models/crm_lead.py _action_reschedule_calls
m.RegisterMethod("_action_reschedule_calls", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
// Default reschedule days = 7
rescheduleDays := 7
if len(args) > 0 {
switch v := args[0].(type) {
case float64:
rescheduleDays = int(v)
case int:
rescheduleDays = v
case int64:
rescheduleDays = int(v)
}
}
// Update all overdue mail.activity records linked to crm.lead
result, err := env.Tx().Exec(env.Ctx(),
`UPDATE mail_activity
SET date_deadline = CURRENT_DATE + ($1 || ' days')::interval,
state = 'planned'
WHERE res_model = 'crm.lead'
AND date_deadline < CURRENT_DATE
AND done = false`, rescheduleDays)
if err != nil {
return nil, fmt.Errorf("_action_reschedule_calls: %w", err)
}
rowsAffected := result.RowsAffected()
return map[string]interface{}{
"rescheduled_count": rowsAffected,
}, nil
})
// action_lead_duplicate: copy lead with "(Copy)" suffix on name.
// Mirrors: odoo/addons/crm/models/crm_lead.py copy()
m.RegisterMethod("action_lead_duplicate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
leadID := rs.IDs()[0]
var newID int64
err := env.Tx().QueryRow(env.Ctx(),
`INSERT INTO crm_lead (name, type, partner_id, email_from, phone,
stage_id, team_id, user_id, expected_revenue, probability,
priority, company_id, currency_id, active, description,
partner_name, street, city, zip, country_id, date_last_stage_update)
SELECT name || ' (Copy)', type, partner_id, email_from, phone,
stage_id, team_id, user_id, expected_revenue, probability,
priority, company_id, currency_id, true, description,
partner_name, street, city, zip, country_id, NOW()
FROM crm_lead WHERE id = $1
RETURNING id`, leadID,
).Scan(&newID)
if err != nil {
return nil, fmt.Errorf("action_lead_duplicate: %w", err)
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": "crm.lead",
"res_id": newID,
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"target": "current",
}, nil
})
// set_user_as_follower: create mail.followers entry for the lead's salesperson.
// Mirrors: odoo/addons/crm/models/crm_lead.py _create_lead_partner
m.RegisterMethod("set_user_as_follower", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, leadID := range rs.IDs() {
// Get the user_id for the lead, then find the partner_id for that user
var userID *int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT user_id FROM crm_lead WHERE id = $1`, leadID,
).Scan(&userID); err != nil || userID == nil {
continue
}
var partnerID *int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT partner_id FROM res_users WHERE id = $1`, *userID,
).Scan(&partnerID); err != nil || partnerID == nil {
continue
}
// Check if already a follower
var exists bool
env.Tx().QueryRow(env.Ctx(),
`SELECT EXISTS(
SELECT 1 FROM mail_followers
WHERE res_model = 'crm.lead' AND res_id = $1 AND partner_id = $2
)`, leadID, *partnerID,
).Scan(&exists)
if !exists {
if _, err := env.Tx().Exec(env.Ctx(),
`INSERT INTO mail_followers (res_model, res_id, partner_id)
VALUES ('crm.lead', $1, $2)`, leadID, *partnerID); err != nil {
log.Printf("warning: crm.lead set_user_as_follower failed for lead %d: %v", leadID, err)
}
}
}
return true, nil
})
// message_subscribe: subscribe partners as followers on the lead.
// Mirrors: odoo/addons/mail/models/mail_thread.py message_subscribe
m.RegisterMethod("message_subscribe", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
if len(args) < 1 {
return nil, fmt.Errorf("partner_ids required")
}
// Accept partner_ids as []interface{} or []int64
var partnerIDs []int64
switch v := args[0].(type) {
case []interface{}:
for _, p := range v {
switch pid := p.(type) {
case float64:
partnerIDs = append(partnerIDs, int64(pid))
case int64:
partnerIDs = append(partnerIDs, pid)
case int:
partnerIDs = append(partnerIDs, int64(pid))
}
}
case []int64:
partnerIDs = v
}
for _, leadID := range rs.IDs() {
for _, partnerID := range partnerIDs {
// Check if already subscribed
var exists bool
env.Tx().QueryRow(env.Ctx(),
`SELECT EXISTS(
SELECT 1 FROM mail_followers
WHERE res_model = 'crm.lead' AND res_id = $1 AND partner_id = $2
)`, leadID, partnerID,
).Scan(&exists)
if !exists {
if _, err := env.Tx().Exec(env.Ctx(),
`INSERT INTO mail_followers (res_model, res_id, partner_id)
VALUES ('crm.lead', $1, $2)`, leadID, partnerID); err != nil {
log.Printf("warning: crm.lead message_subscribe failed for lead %d partner %d: %v", leadID, partnerID, err)
}
}
}
}
return true, nil
})
// action_assign_salesperson: assign a salesperson to one or more leads.
// Mirrors: odoo/addons/crm/models/crm_lead.py _handle_salesmen_assignment
m.RegisterMethod("action_assign_salesperson", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
@@ -213,8 +723,10 @@ func initCRMLeadExtended() {
}
env := rs.Env()
for _, id := range rs.IDs() {
_, _ = env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET user_id = $1 WHERE id = $2`, int64(userID), id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET user_id = $1 WHERE id = $2`, int64(userID), id); err != nil {
log.Printf("warning: crm.lead action_assign_salesperson failed for lead %d: %v", id, err)
}
}
return true, nil
})
@@ -262,8 +774,10 @@ func initCRMLeadExtended() {
}
env := rs.Env()
for _, id := range rs.IDs() {
_, _ = env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET priority = $1 WHERE id = $2`, priority, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET priority = $1 WHERE id = $2`, priority, id); err != nil {
log.Printf("warning: crm.lead action_set_priority failed for lead %d: %v", id, err)
}
}
return true, nil
})
@@ -273,8 +787,10 @@ func initCRMLeadExtended() {
m.RegisterMethod("action_archive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
_, _ = env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET active = false WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET active = false WHERE id = $1`, id); err != nil {
log.Printf("warning: crm.lead action_archive failed for lead %d: %v", id, err)
}
}
return true, nil
})
@@ -284,8 +800,10 @@ func initCRMLeadExtended() {
m.RegisterMethod("action_unarchive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
_, _ = env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET active = true WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET active = true WHERE id = $1`, id); err != nil {
log.Printf("warning: crm.lead action_unarchive failed for lead %d: %v", id, err)
}
}
return true, nil
})
@@ -302,9 +820,11 @@ func initCRMLeadExtended() {
}
env := rs.Env()
for _, id := range rs.IDs() {
_, _ = env.Tx().Exec(env.Ctx(),
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET stage_id = $1, date_last_stage_update = NOW() WHERE id = $2`,
int64(stageID), id)
int64(stageID), id); err != nil {
log.Printf("warning: crm.lead action_set_stage failed for lead %d: %v", id, err)
}
}
return true, nil
})
@@ -317,7 +837,7 @@ func initCRMLeadExtended() {
var totalLeads, totalOpps, wonCount, lostCount int64
var totalRevenue, avgProbability float64
_ = env.Tx().QueryRow(env.Ctx(), `
if err := env.Tx().QueryRow(env.Ctx(), `
SELECT
COUNT(*) FILTER (WHERE type = 'lead'),
COUNT(*) FILTER (WHERE type = 'opportunity'),
@@ -326,7 +846,9 @@ func initCRMLeadExtended() {
COALESCE(SUM(expected_revenue::float8), 0),
COALESCE(AVG(probability), 0)
FROM crm_lead WHERE active = true`,
).Scan(&totalLeads, &totalOpps, &wonCount, &lostCount, &totalRevenue, &avgProbability)
).Scan(&totalLeads, &totalOpps, &wonCount, &lostCount, &totalRevenue, &avgProbability); err != nil {
log.Printf("warning: crm.lead _get_lead_statistics query failed: %v", err)
}
return map[string]interface{}{
"total_leads": totalLeads,
@@ -338,7 +860,158 @@ func initCRMLeadExtended() {
}, nil
})
// Onchange: partner_id → populate contact/address fields from partner
// action_schedule_meeting: return calendar action for scheduling a meeting.
// Mirrors: odoo/addons/crm/models/crm_lead.py action_schedule_meeting
m.RegisterMethod("action_schedule_meeting", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
leadID := rs.IDs()[0]
// Fetch lead data for context
var name string
var partnerID, teamID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, ''), partner_id, team_id FROM crm_lead WHERE id = $1`, leadID,
).Scan(&name, &partnerID, &teamID)
ctx := map[string]interface{}{
"default_opportunity_id": leadID,
"default_name": name,
"search_default_opportunity_id": leadID,
}
if partnerID != nil {
ctx["default_partner_id"] = *partnerID
ctx["default_partner_ids"] = []int64{*partnerID}
}
if teamID != nil {
ctx["default_team_id"] = *teamID
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"name": "Meeting",
"res_model": "calendar.event",
"view_mode": "calendar,tree,form",
"context": ctx,
}, nil
})
// action_new_quotation: return action to create a sale.order linked to the lead.
// Mirrors: odoo/addons/sale_crm/models/crm_lead.py action_new_quotation
m.RegisterMethod("action_new_quotation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
leadID := rs.IDs()[0]
// Fetch lead context data
var partnerID, teamID, companyID *int64
var name string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, ''), partner_id, team_id, company_id FROM crm_lead WHERE id = $1`, leadID,
).Scan(&name, &partnerID, &teamID, &companyID)
ctx := map[string]interface{}{
"default_opportunity_id": leadID,
"search_default_opportunity_id": leadID,
"default_origin": name,
}
if partnerID != nil {
ctx["default_partner_id"] = *partnerID
}
if teamID != nil {
ctx["default_team_id"] = *teamID
}
if companyID != nil {
ctx["default_company_id"] = *companyID
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"name": "New Quotation",
"res_model": "sale.order",
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"target": "current",
"context": ctx,
}, nil
})
// merge_opportunity: alias for action_merge_leads.
m.RegisterMethod("merge_opportunity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
mergeMethod := orm.Registry.Get("crm.lead").Methods["action_merge_leads"]
if mergeMethod != nil {
return mergeMethod(rs, args...)
}
return nil, fmt.Errorf("crm.lead: action_merge_leads not found")
})
// handle_partner_assignment: create or assign partner for leads.
// Mirrors: odoo/addons/crm/models/crm_lead.py _handle_partner_assignment
m.RegisterMethod("handle_partner_assignment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
// Optional force_partner_id from args
var forcePartnerID int64
if len(args) > 0 {
if pid, ok := args[0].(float64); ok {
forcePartnerID = int64(pid)
}
}
for _, id := range rs.IDs() {
if forcePartnerID > 0 {
env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET partner_id = $1 WHERE id = $2`, forcePartnerID, id)
continue
}
// Check if lead already has a partner
var existingPartnerID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT partner_id FROM crm_lead WHERE id = $1`, id).Scan(&existingPartnerID)
if existingPartnerID != nil {
continue
}
// Create partner from lead data
var email, phone, partnerName, street, city, zip, contactName string
var countryID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(email_from,''), COALESCE(phone,''),
COALESCE(partner_name,''), COALESCE(street,''),
COALESCE(city,''), COALESCE(zip,''),
COALESCE(contact_name,''), country_id
FROM crm_lead WHERE id = $1`, id,
).Scan(&email, &phone, &partnerName, &street, &city, &zip, &contactName, &countryID)
name := partnerName
if name == "" {
name = contactName
}
if name == "" {
name = email
}
if name == "" {
continue // cannot create partner without any identifying info
}
var newPartnerID int64
err := env.Tx().QueryRow(env.Ctx(),
`INSERT INTO res_partner (name, email, phone, street, city, zip, country_id, active, is_company)
VALUES ($1, NULLIF($2,''), NULLIF($3,''), NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), $7, true,
CASE WHEN $8 != '' THEN true ELSE false END)
RETURNING id`,
name, email, phone, street, city, zip, countryID, partnerName,
).Scan(&newPartnerID)
if err != nil {
log.Printf("warning: crm.lead handle_partner_assignment create partner failed for lead %d: %v", id, err)
continue
}
env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET partner_id = $1 WHERE id = $2`, newPartnerID, id)
}
return true, nil
})
// Onchange: partner_id -> populate contact/address fields from partner
// Mirrors: odoo/addons/crm/models/crm_lead.py _onchange_partner_id
m.RegisterOnchange("partner_id", func(env *orm.Environment, vals orm.Values) orm.Values {
result := make(orm.Values)
@@ -352,11 +1025,13 @@ func initCRMLeadExtended() {
}
var email, phone, street, city, zip, name string
_ = env.Tx().QueryRow(env.Ctx(),
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(email,''), COALESCE(phone,''), COALESCE(street,''),
COALESCE(city,''), COALESCE(zip,''), COALESCE(name,'')
FROM res_partner WHERE id = $1`, int64(pid),
).Scan(&email, &phone, &street, &city, &zip, &name)
).Scan(&email, &phone, &street, &city, &zip, &name); err != nil {
log.Printf("warning: crm.lead onchange partner_id lookup failed: %v", err)
}
if email != "" {
result["email_from"] = email

View File

@@ -2,6 +2,7 @@ package models
import (
"fmt"
"log"
"odoo-go/pkg/orm"
)
@@ -81,6 +82,24 @@ func initCrmTeamExpanded() {
}),
)
// _compute_assignment_optout: count members who opted out of auto-assignment.
// Mirrors: odoo/addons/crm/models/crm_team.py _compute_assignment_optout
m.RegisterCompute("assignment_optout_count", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
teamID := rs.IDs()[0]
var count int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM crm_team_member
WHERE crm_team_id = $1 AND active = true AND assignment_optout = true`,
teamID,
).Scan(&count); err != nil {
log.Printf("warning: crm.team _compute_assignment_optout query failed: %v", err)
}
return orm.Values{"assignment_optout_count": count}, nil
})
// _compute_counts: compute dashboard KPIs for the sales team.
// Mirrors: odoo/addons/crm/models/crm_team.py _compute_opportunities_data
m.RegisterCompute("opportunities_count", func(rs *orm.Recordset) (orm.Values, error) {
@@ -89,20 +108,24 @@ func initCrmTeamExpanded() {
var count int64
var amount float64
_ = env.Tx().QueryRow(env.Ctx(),
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*), COALESCE(SUM(expected_revenue::float8), 0)
FROM crm_lead
WHERE team_id = $1 AND active = true AND type = 'opportunity'`,
teamID,
).Scan(&count, &amount)
).Scan(&count, &amount); err != nil {
log.Printf("warning: crm.team _compute_counts opportunities query failed: %v", err)
}
var unassigned int64
_ = env.Tx().QueryRow(env.Ctx(),
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*)
FROM crm_lead
WHERE team_id = $1 AND active = true AND user_id IS NULL`,
teamID,
).Scan(&unassigned)
).Scan(&unassigned); err != nil {
log.Printf("warning: crm.team _compute_counts unassigned query failed: %v", err)
}
return orm.Values{
"opportunities_count": count,
@@ -111,6 +134,69 @@ func initCrmTeamExpanded() {
}, nil
})
// get_crm_dashboard_data: KPIs — total pipeline value, won count, lost count, conversion rate.
// Mirrors: odoo/addons/crm/models/crm_team.py _compute_dashboard_data
m.RegisterMethod("get_crm_dashboard_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
teamID := rs.IDs()[0]
var totalPipeline float64
var wonCount, lostCount, totalOpps int64
if err := env.Tx().QueryRow(env.Ctx(), `
SELECT
COALESCE(SUM(expected_revenue::float8), 0),
COUNT(*) FILTER (WHERE state = 'won'),
COUNT(*) FILTER (WHERE state = 'lost'),
COUNT(*)
FROM crm_lead
WHERE team_id = $1 AND type = 'opportunity'`,
teamID,
).Scan(&totalPipeline, &wonCount, &lostCount, &totalOpps); err != nil {
log.Printf("warning: crm.team get_crm_dashboard_data query failed: %v", err)
}
conversionRate := float64(0)
decided := wonCount + lostCount
if decided > 0 {
conversionRate = float64(wonCount) / float64(decided) * 100
}
// Active pipeline (open opportunities only)
var activePipeline float64
var activeCount int64
if err := env.Tx().QueryRow(env.Ctx(), `
SELECT COALESCE(SUM(expected_revenue::float8), 0), COUNT(*)
FROM crm_lead
WHERE team_id = $1 AND type = 'opportunity' AND active = true AND state = 'open'`,
teamID,
).Scan(&activePipeline, &activeCount); err != nil {
log.Printf("warning: crm.team get_crm_dashboard_data active pipeline query failed: %v", err)
}
// Overdue activities count
var overdueCount int64
if err := env.Tx().QueryRow(env.Ctx(), `
SELECT COUNT(DISTINCT l.id)
FROM crm_lead l
JOIN mail_activity a ON a.res_model = 'crm.lead' AND a.res_id = l.id
WHERE l.team_id = $1 AND a.date_deadline < CURRENT_DATE AND a.done = false`,
teamID,
).Scan(&overdueCount); err != nil {
log.Printf("warning: crm.team get_crm_dashboard_data overdue query failed: %v", err)
}
return map[string]interface{}{
"total_pipeline": totalPipeline,
"active_pipeline": activePipeline,
"active_count": activeCount,
"won_count": wonCount,
"lost_count": lostCount,
"total_opportunities": totalOpps,
"conversion_rate": conversionRate,
"overdue_activities": overdueCount,
}, nil
})
// action_assign_leads: trigger automatic lead assignment.
// Mirrors: odoo/addons/crm/models/crm_team.py action_assign_leads
m.RegisterMethod("action_assign_leads", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
@@ -174,8 +260,10 @@ func initCrmTeamExpanded() {
break
}
mc := &members[memberIdx]
_, _ = env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET user_id = $1 WHERE id = $2`, mc.userID, leadID)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_lead SET user_id = $1 WHERE id = $2`, mc.userID, leadID); err != nil {
log.Printf("warning: crm.team action_assign_leads update failed for lead %d: %v", leadID, err)
}
assigned++
mc.capacity--
if mc.capacity <= 0 {
@@ -233,6 +321,15 @@ func initCrmTeamMember() {
Index: true,
}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Selection("role", []orm.SelectionItem{
{Value: "member", Label: "Member"},
{Value: "leader", Label: "Team Leader"},
{Value: "manager", Label: "Sales Manager"},
}, orm.FieldOpts{
String: "Role",
Default: "member",
Help: "Role of this member within the sales team.",
}),
orm.Float("assignment_max", orm.FieldOpts{
String: "Max Leads",
Help: "Maximum number of leads this member should be assigned per month.",
@@ -260,17 +357,21 @@ func initCrmTeamMember() {
memberID := rs.IDs()[0]
var userID, teamID int64
_ = env.Tx().QueryRow(env.Ctx(),
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT user_id, crm_team_id FROM crm_team_member WHERE id = $1`, memberID,
).Scan(&userID, &teamID)
).Scan(&userID, &teamID); err != nil {
log.Printf("warning: crm.team.member _compute_lead_count member lookup failed: %v", err)
}
var count int64
_ = env.Tx().QueryRow(env.Ctx(),
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM crm_lead
WHERE user_id = $1 AND team_id = $2 AND active = true
AND create_date >= date_trunc('month', CURRENT_DATE)`,
userID, teamID,
).Scan(&count)
).Scan(&count); err != nil {
log.Printf("warning: crm.team.member _compute_lead_count query failed: %v", err)
}
return orm.Values{"lead_month_count": count}, nil
})
@@ -281,4 +382,90 @@ func initCrmTeamMember() {
"UNIQUE(crm_team_id, user_id)",
"A user can only be a member of a team once.",
)
// action_assign_to_team: add a user to a team as a member.
// Mirrors: odoo/addons/crm/models/crm_team_member.py _assign_to_team
m.RegisterMethod("action_assign_to_team", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
if len(args) < 2 {
return nil, fmt.Errorf("user_id and team_id required")
}
var userID, teamID int64
switch v := args[0].(type) {
case float64:
userID = int64(v)
case int64:
userID = v
case int:
userID = int64(v)
}
switch v := args[1].(type) {
case float64:
teamID = int64(v)
case int64:
teamID = v
case int:
teamID = int64(v)
}
// Check if already a member
var exists bool
env.Tx().QueryRow(env.Ctx(),
`SELECT EXISTS(SELECT 1 FROM crm_team_member WHERE user_id = $1 AND crm_team_id = $2)`,
userID, teamID,
).Scan(&exists)
if exists {
// Ensure active
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_team_member SET active = true WHERE user_id = $1 AND crm_team_id = $2`,
userID, teamID); err != nil {
return nil, fmt.Errorf("action_assign_to_team reactivate: %w", err)
}
return true, nil
}
var newID int64
if err := env.Tx().QueryRow(env.Ctx(),
`INSERT INTO crm_team_member (user_id, crm_team_id, active, role)
VALUES ($1, $2, true, 'member')
RETURNING id`, userID, teamID,
).Scan(&newID); err != nil {
return nil, fmt.Errorf("action_assign_to_team insert: %w", err)
}
return map[string]interface{}{"id": newID}, nil
})
// action_remove_from_team: deactivate membership (soft delete).
// Mirrors: odoo/addons/crm/models/crm_team_member.py unlink
m.RegisterMethod("action_remove_from_team", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_team_member SET active = false WHERE id = $1`, id); err != nil {
log.Printf("warning: crm.team.member action_remove_from_team failed for member %d: %v", id, err)
}
}
return true, nil
})
// action_set_role: change the role of a team member.
// Mirrors: odoo/addons/crm/models/crm_team_member.py write
m.RegisterMethod("action_set_role", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
if len(args) < 1 {
return nil, fmt.Errorf("role value required ('member', 'leader', 'manager')")
}
role, ok := args[0].(string)
if !ok {
return nil, fmt.Errorf("role must be a string")
}
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE crm_team_member SET role = $1 WHERE id = $2`, role, id); err != nil {
log.Printf("warning: crm.team.member action_set_role failed for member %d: %v", id, err)
}
}
return true, nil
})
}

View File

@@ -1,6 +1,11 @@
package models
import "odoo-go/pkg/orm"
import (
"fmt"
"time"
"odoo-go/pkg/orm"
)
// initResourceCalendar registers resource.calendar — working schedules.
// Mirrors: odoo/addons/resource/models/resource.py
@@ -98,15 +103,181 @@ func initHREmployee() {
orm.Binary("image_1920", orm.FieldOpts{String: "Image"}),
)
// DefaultGet: provide dynamic defaults for new employees.
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.default_get()
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
vals := make(orm.Values)
// Default company from current user's session
companyID := env.CompanyID()
if companyID > 0 {
vals["company_id"] = companyID
}
return vals
}
// toggle_active: archive/unarchive employee
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.toggle_active()
m.RegisterMethod("toggle_active", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_employee SET active = NOT active WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("hr.employee: toggle_active for %d: %w", id, err)
}
}
return true, nil
})
// action_archive: Archive employee (set active=false).
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.action_archive()
m.RegisterMethod("action_archive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_employee SET active = false WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("hr.employee: action_archive for %d: %w", id, err)
}
}
return true, nil
})
// _compute_remaining_leaves: Compute remaining leave days for the employee.
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee._compute_remaining_leaves()
m.RegisterCompute("leaves_count", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
empID := rs.IDs()[0]
var allocated float64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(number_of_days), 0)
FROM hr_leave_allocation
WHERE employee_id = $1 AND state = 'validate'`, empID,
).Scan(&allocated); err != nil {
return orm.Values{"leaves_count": float64(0)}, nil
}
var used float64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(number_of_days), 0)
FROM hr_leave
WHERE employee_id = $1 AND state = 'validate'`, empID,
).Scan(&used); err != nil {
return orm.Values{"leaves_count": float64(0)}, nil
}
return orm.Values{"leaves_count": allocated - used}, nil
})
m.RegisterCompute("attendance_state", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
empID := rs.IDs()[0]
var checkOut *string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT check_out FROM hr_attendance
WHERE employee_id = $1
ORDER BY check_in DESC LIMIT 1`, empID,
).Scan(&checkOut)
if err != nil {
// No attendance records or DB error → checked out
return orm.Values{"attendance_state": "checked_out"}, nil
}
if checkOut == nil {
return orm.Values{"attendance_state": "checked_in"}, nil
}
return orm.Values{"attendance_state": "checked_out"}, nil
})
}
// initHrEmployeeCategory registers the hr.employee.category model.
// Mirrors: odoo/addons/hr/models/hr_employee_category.py
func initHrEmployeeCategory() {
orm.NewModel("hr.employee.category", orm.ModelOpts{
Description: "Employee Tag",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
)
}
// initHrEmployeePublic registers the hr.employee.public model with limited fields.
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployeePublic
func initHrEmployeePublic() {
m := orm.NewModel("hr.employee.public", orm.ModelOpts{
Description: "Public Employee",
Order: "name",
})
m.AddFields(
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
String: "Employee", Required: true, Index: true,
}),
orm.Char("name", orm.FieldOpts{String: "Name", Readonly: true}),
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department", Readonly: true}),
orm.Char("job_title", orm.FieldOpts{String: "Job Title", Readonly: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Readonly: true}),
orm.Many2one("parent_id", "hr.employee.public", orm.FieldOpts{String: "Manager", Readonly: true}),
orm.Char("work_email", orm.FieldOpts{String: "Work Email", Readonly: true}),
orm.Char("work_phone", orm.FieldOpts{String: "Work Phone", Readonly: true}),
orm.Binary("image_1920", orm.FieldOpts{String: "Image", Readonly: true}),
)
// get_public_data: Reads limited public fields from hr.employee.
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployeePublic.read()
m.RegisterMethod("get_public_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
// Accept employee_id from kwargs or use IDs
var employeeIDs []int64
if len(args) > 0 {
if kw, ok := args[0].(map[string]interface{}); ok {
if v, ok := kw["employee_ids"]; ok {
if ids, ok := v.([]int64); ok {
employeeIDs = ids
}
}
}
}
if len(employeeIDs) == 0 {
employeeIDs = rs.IDs()
}
if len(employeeIDs) == 0 {
return []map[string]interface{}{}, nil
}
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id, COALESCE(name, ''), COALESCE(department_id, 0),
COALESCE(job_title, ''), COALESCE(company_id, 0),
COALESCE(work_email, ''), COALESCE(work_phone, '')
FROM hr_employee
WHERE id = ANY($1) AND COALESCE(active, true) = true`, employeeIDs)
if err != nil {
return nil, fmt.Errorf("hr.employee.public: query: %w", err)
}
defer rows.Close()
var result []map[string]interface{}
for rows.Next() {
var id, deptID, companyID int64
var name, jobTitle, email, phone string
if err := rows.Scan(&id, &name, &deptID, &jobTitle, &companyID, &email, &phone); err != nil {
continue
}
result = append(result, map[string]interface{}{
"id": id,
"name": name,
"department_id": deptID,
"job_title": jobTitle,
"company_id": companyID,
"work_email": email,
"work_phone": phone,
})
}
return result, nil
})
}
// initHrEmployeeExtensions adds skill, resume, attendance and leave fields
@@ -117,12 +288,283 @@ func initHrEmployeeExtensions() {
orm.One2many("skill_ids", "hr.employee.skill", "employee_id", orm.FieldOpts{String: "Skills"}),
orm.One2many("resume_line_ids", "hr.resume.line", "employee_id", orm.FieldOpts{String: "Resume"}),
orm.One2many("attendance_ids", "hr.attendance", "employee_id", orm.FieldOpts{String: "Attendances"}),
orm.Many2one("contract_id", "hr.contract", orm.FieldOpts{String: "Current Contract"}),
orm.One2many("contract_ids", "hr.contract", "employee_id", orm.FieldOpts{String: "Contracts"}),
orm.Float("leaves_count", orm.FieldOpts{String: "Time Off", Compute: "_compute_leaves"}),
orm.Selection("attendance_state", []orm.SelectionItem{
{Value: "checked_out", Label: "Checked Out"},
{Value: "checked_in", Label: "Checked In"},
}, orm.FieldOpts{String: "Attendance", Compute: "_compute_attendance_state"}),
orm.Float("seniority_years", orm.FieldOpts{
String: "Seniority (Years)", Compute: "_compute_seniority_years",
}),
orm.Date("first_contract_date", orm.FieldOpts{String: "First Contract Date"}),
orm.Many2many("category_ids", "hr.employee.category", orm.FieldOpts{String: "Tags"}),
orm.Integer("age", orm.FieldOpts{
String: "Age", Compute: "_compute_age",
}),
)
// _compute_seniority_years: Years since first contract start date.
// Mirrors: odoo/addons/hr_contract/models/hr_employee.py _compute_first_contract_date
emp.RegisterCompute("seniority_years", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
empID := rs.IDs()[0]
// Find earliest contract start date
var firstDate *time.Time
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT MIN(date_start) FROM hr_contract
WHERE employee_id = $1 AND state NOT IN ('cancel')`, empID,
).Scan(&firstDate); err != nil {
return orm.Values{"seniority_years": float64(0)}, nil
}
if firstDate == nil {
return orm.Values{"seniority_years": float64(0)}, nil
}
years := time.Since(*firstDate).Hours() / (24 * 365.25)
if years < 0 {
years = 0
}
return orm.Values{"seniority_years": years}, nil
})
// get_attendance_by_date_range: Return attendance summary for an employee.
// Mirrors: odoo/addons/hr_attendance/models/hr_employee.py
emp.RegisterMethod("get_attendance_by_date_range", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
empID := rs.IDs()[0]
// Parse date_from / date_to from kwargs
dateFrom := time.Now().AddDate(0, -1, 0).Format("2006-01-02")
dateTo := time.Now().Format("2006-01-02")
if len(args) > 0 {
if kw, ok := args[0].(map[string]interface{}); ok {
if v, ok := kw["date_from"].(string); ok && v != "" {
dateFrom = v
}
if v, ok := kw["date_to"].(string); ok && v != "" {
dateTo = v
}
}
}
// Daily attendance summary
rows, err := env.Tx().Query(env.Ctx(),
`SELECT check_in::date AS day,
COUNT(*) AS entries,
COALESCE(SUM(EXTRACT(EPOCH FROM (COALESCE(check_out, NOW()) - check_in)) / 3600.0), 0) AS total_hours
FROM hr_attendance
WHERE employee_id = $1 AND check_in::date >= $2 AND check_in::date <= $3
GROUP BY check_in::date
ORDER BY check_in::date`, empID, dateFrom, dateTo)
if err != nil {
return nil, fmt.Errorf("hr.employee: attendance report: %w", err)
}
defer rows.Close()
var days []map[string]interface{}
var totalHours float64
var totalDays int
for rows.Next() {
var day time.Time
var entries int
var hours float64
if err := rows.Scan(&day, &entries, &hours); err != nil {
continue
}
days = append(days, map[string]interface{}{
"date": day.Format("2006-01-02"),
"entries": entries,
"hours": hours,
})
totalHours += hours
totalDays++
}
return map[string]interface{}{
"employee_id": empID,
"date_from": dateFrom,
"date_to": dateTo,
"days": days,
"total_days": totalDays,
"total_hours": totalHours,
"avg_hours": func() float64 { if totalDays > 0 { return totalHours / float64(totalDays) }; return 0 }(),
}, nil
})
// _compute_age: Compute employee age from birthday.
// Mirrors: odoo/addons/hr/models/hr_employee.py _compute_age()
emp.RegisterCompute("age", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
empID := rs.IDs()[0]
var birthday *time.Time
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT birthday FROM hr_employee WHERE id = $1`, empID,
).Scan(&birthday); err != nil || birthday == nil {
return orm.Values{"age": int64(0)}, nil
}
now := time.Now()
age := now.Year() - birthday.Year()
// Adjust if birthday has not occurred yet this year
if now.Month() < birthday.Month() ||
(now.Month() == birthday.Month() && now.Day() < birthday.Day()) {
age--
}
if age < 0 {
age = 0
}
return orm.Values{"age": int64(age)}, nil
})
// action_check_in: Create a new attendance record with check_in = now.
// Mirrors: odoo/addons/hr_attendance/models/hr_employee.py action_check_in()
emp.RegisterMethod("action_check_in", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
now := time.Now().UTC().Format("2006-01-02 15:04:05")
for _, empID := range rs.IDs() {
// Verify employee is not already checked in
var openCount int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM hr_attendance
WHERE employee_id = $1 AND check_out IS NULL`, empID,
).Scan(&openCount)
if openCount > 0 {
return nil, fmt.Errorf("hr.employee: employee %d is already checked in", empID)
}
// Get company_id from employee
var companyID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT company_id FROM hr_employee WHERE id = $1`, empID,
).Scan(&companyID)
// Create attendance record
attRS := env.Model("hr.attendance")
vals := orm.Values{
"employee_id": empID,
"check_in": now,
}
if companyID != nil {
vals["company_id"] = *companyID
}
if _, err := attRS.Create(vals); err != nil {
return nil, fmt.Errorf("hr.employee: check_in for %d: %w", empID, err)
}
}
return true, nil
})
// action_check_out: Set check_out on the latest open attendance record.
// Mirrors: odoo/addons/hr_attendance/models/hr_employee.py action_check_out()
emp.RegisterMethod("action_check_out", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
now := time.Now().UTC().Format("2006-01-02 15:04:05")
for _, empID := range rs.IDs() {
result, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_attendance SET check_out = $1
WHERE employee_id = $2 AND check_out IS NULL`, now, empID)
if err != nil {
return nil, fmt.Errorf("hr.employee: check_out for %d: %w", empID, err)
}
if result.RowsAffected() == 0 {
return nil, fmt.Errorf("hr.employee: employee %d is not checked in", empID)
}
}
return true, nil
})
// get_org_chart: Return hierarchical org chart data for the employee.
// Mirrors: odoo/addons/hr_org_chart/models/hr_employee.py get_org_chart()
emp.RegisterMethod("get_org_chart", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
empID := rs.IDs()[0]
// Recursive function to build the tree
var buildNode func(id int64, depth int) (map[string]interface{}, error)
buildNode = func(id int64, depth int) (map[string]interface{}, error) {
// Prevent infinite recursion
if depth > 20 {
return nil, nil
}
var name, jobTitle string
var deptID, parentID *int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, ''), COALESCE(job_title, ''),
department_id, parent_id
FROM hr_employee
WHERE id = $1 AND COALESCE(active, true) = true`, id,
).Scan(&name, &jobTitle, &deptID, &parentID)
if err != nil {
return nil, nil // employee not found or inactive
}
node := map[string]interface{}{
"id": id,
"name": name,
"job_title": jobTitle,
}
if deptID != nil {
node["department_id"] = *deptID
}
if parentID != nil {
node["parent_id"] = *parentID
}
// Find subordinates
subRows, err := env.Tx().Query(env.Ctx(),
`SELECT id FROM hr_employee
WHERE parent_id = $1 AND COALESCE(active, true) = true
ORDER BY name`, id)
if err != nil {
node["subordinates"] = []map[string]interface{}{}
return node, nil
}
var subIDs []int64
for subRows.Next() {
var subID int64
if err := subRows.Scan(&subID); err != nil {
continue
}
subIDs = append(subIDs, subID)
}
subRows.Close()
var subordinates []map[string]interface{}
for _, subID := range subIDs {
subNode, err := buildNode(subID, depth+1)
if err != nil || subNode == nil {
continue
}
subordinates = append(subordinates, subNode)
}
if subordinates == nil {
subordinates = []map[string]interface{}{}
}
node["subordinates"] = subordinates
node["subordinate_count"] = len(subordinates)
return node, nil
}
chart, err := buildNode(empID, 0)
if err != nil {
return nil, fmt.Errorf("hr.employee: get_org_chart: %w", err)
}
if chart == nil {
return map[string]interface{}{}, nil
}
return chart, nil
})
}
// initHRDepartment registers the hr.department model.
@@ -149,6 +591,14 @@ func initHRDepartment() {
orm.One2many("child_ids", "hr.department", "parent_id", orm.FieldOpts{String: "Child Departments"}),
orm.One2many("member_ids", "hr.employee", "department_id", orm.FieldOpts{String: "Members"}),
)
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
vals := make(orm.Values)
if companyID := env.CompanyID(); companyID > 0 {
vals["company_id"] = companyID
}
return vals
}
}
// initHRJob registers the hr.job model.
@@ -174,4 +624,12 @@ func initHRJob() {
}, orm.FieldOpts{String: "Status", Required: true, Default: "recruit"}),
orm.Text("description", orm.FieldOpts{String: "Job Description"}),
)
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
vals := make(orm.Values)
if companyID := env.CompanyID(); companyID > 0 {
vals["company_id"] = companyID
}
return vals
}
}

View File

@@ -21,10 +21,12 @@ func initHrAttendance() {
env := rs.Env()
attID := rs.IDs()[0]
var hours float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(EXTRACT(EPOCH FROM (check_out - check_in)) / 3600.0, 0)
FROM hr_attendance WHERE id = $1 AND check_out IS NOT NULL`, attID,
).Scan(&hours)
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(EXTRACT(EPOCH FROM (COALESCE(check_out, NOW()) - check_in)) / 3600.0, 0)
FROM hr_attendance WHERE id = $1`, attID,
).Scan(&hours); err != nil {
return orm.Values{"worked_hours": float64(0)}, nil
}
return orm.Values{"worked_hours": hours}, nil
})
}

View File

@@ -1,8 +1,13 @@
package models
import "odoo-go/pkg/orm"
import (
"fmt"
"time"
// initHrContract registers the hr.contract model.
"odoo-go/pkg/orm"
)
// initHrContract registers the hr.contract model with full lifecycle.
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py
func initHrContract() {
m := orm.NewModel("hr.contract", orm.ModelOpts{
@@ -10,22 +15,383 @@ func initHrContract() {
Order: "date_start desc",
})
// -- Core Fields --
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Contract Reference", Required: true}),
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
String: "Employee", Required: true, Index: true,
}),
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}),
orm.Many2one("job_id", "hr.job", orm.FieldOpts{String: "Job Position"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
// -- Contract Type & Duration --
m.AddFields(
orm.Selection("contract_type", []orm.SelectionItem{
{Value: "permanent", Label: "Permanent"},
{Value: "fixed_term", Label: "Fixed Term"},
{Value: "probation", Label: "Probation"},
{Value: "freelance", Label: "Freelance / Contractor"},
{Value: "internship", Label: "Internship"},
}, orm.FieldOpts{String: "Contract Type", Default: "permanent"}),
orm.Date("date_start", orm.FieldOpts{String: "Start Date", Required: true}),
orm.Date("date_end", orm.FieldOpts{String: "End Date"}),
orm.Monetary("wage", orm.FieldOpts{String: "Wage", Required: true, CurrencyField: "currency_id"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Integer("trial_period_days", orm.FieldOpts{String: "Trial Period (Days)"}),
orm.Date("trial_date_end", orm.FieldOpts{String: "Trial End Date"}),
orm.Integer("notice_period_days", orm.FieldOpts{
String: "Notice Period (Days)", Default: 30,
}),
)
// -- State Machine --
m.AddFields(
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "New"},
{Value: "open", Label: "Running"},
{Value: "pending", Label: "To Renew"},
{Value: "close", Label: "Expired"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Index: true}),
)
// -- Compensation --
m.AddFields(
orm.Monetary("wage", orm.FieldOpts{
String: "Wage (Gross)", Required: true, CurrencyField: "currency_id",
Help: "Gross monthly salary",
}),
orm.Selection("schedule_pay", []orm.SelectionItem{
{Value: "monthly", Label: "Monthly"},
{Value: "weekly", Label: "Weekly"},
{Value: "bi_weekly", Label: "Bi-Weekly"},
{Value: "yearly", Label: "Yearly"},
}, orm.FieldOpts{String: "Scheduled Pay", Default: "monthly"}),
orm.Monetary("wage_annual", orm.FieldOpts{
String: "Annual Wage", Compute: "_compute_wage_annual", CurrencyField: "currency_id",
}),
orm.Monetary("bonus", orm.FieldOpts{
String: "Bonus", CurrencyField: "currency_id",
}),
orm.Monetary("transport_allowance", orm.FieldOpts{
String: "Transport Allowance", CurrencyField: "currency_id",
}),
orm.Monetary("meal_allowance", orm.FieldOpts{
String: "Meal Allowance", CurrencyField: "currency_id",
}),
orm.Monetary("other_allowance", orm.FieldOpts{
String: "Other Allowance", CurrencyField: "currency_id",
}),
orm.Monetary("total_compensation", orm.FieldOpts{
String: "Total Compensation", Compute: "_compute_total_compensation", CurrencyField: "currency_id",
}),
)
// -- Working Schedule --
m.AddFields(
orm.Many2one("resource_calendar_id", "resource.calendar", orm.FieldOpts{
String: "Working Schedule",
}),
orm.Float("hours_per_week", orm.FieldOpts{String: "Hours per Week", Default: 40.0}),
)
// -- History & Links --
m.AddFields(
orm.Many2one("previous_contract_id", "hr.contract", orm.FieldOpts{
String: "Previous Contract",
}),
orm.Text("notes", orm.FieldOpts{String: "Notes"}),
)
// -- Computed: days_remaining --
m.AddFields(
orm.Integer("days_remaining", orm.FieldOpts{
String: "Days Remaining", Compute: "_compute_days_remaining",
}),
orm.Boolean("is_expired", orm.FieldOpts{
String: "Is Expired", Compute: "_compute_is_expired",
}),
orm.Boolean("is_expiring_soon", orm.FieldOpts{
String: "Expiring Soon", Compute: "_compute_is_expiring_soon",
Help: "Contract expires within 30 days",
}),
)
// -- Computes --
m.RegisterCompute("days_remaining", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var dateEnd *time.Time
env.Tx().QueryRow(env.Ctx(),
`SELECT date_end FROM hr_contract WHERE id = $1`, id).Scan(&dateEnd)
if dateEnd == nil {
return orm.Values{"days_remaining": int64(0)}, nil
}
days := int64(time.Until(*dateEnd).Hours() / 24)
if days < 0 {
days = 0
}
return orm.Values{"days_remaining": days}, nil
})
m.RegisterCompute("is_expired", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var dateEnd *time.Time
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT date_end, COALESCE(state, 'draft') FROM hr_contract WHERE id = $1`, id,
).Scan(&dateEnd, &state)
expired := dateEnd != nil && dateEnd.Before(time.Now()) && state != "close" && state != "cancel"
return orm.Values{"is_expired": expired}, nil
})
m.RegisterCompute("is_expiring_soon", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var dateEnd *time.Time
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT date_end, COALESCE(state, 'draft') FROM hr_contract WHERE id = $1`, id,
).Scan(&dateEnd, &state)
soon := false
if dateEnd != nil && state == "open" {
daysLeft := time.Until(*dateEnd).Hours() / 24
soon = daysLeft > 0 && daysLeft <= 30
}
return orm.Values{"is_expiring_soon": soon}, nil
})
m.RegisterCompute("wage_annual", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var wage float64
var schedulePay string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(wage, 0), COALESCE(schedule_pay, 'monthly') FROM hr_contract WHERE id = $1`, id,
).Scan(&wage, &schedulePay)
var annual float64
switch schedulePay {
case "monthly":
annual = wage * 12
case "weekly":
annual = wage * 52
case "bi_weekly":
annual = wage * 26
case "yearly":
annual = wage
}
return orm.Values{"wage_annual": annual}, nil
})
m.RegisterCompute("total_compensation", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var wage, bonus, transport, meal, other float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(wage,0), COALESCE(bonus,0), COALESCE(transport_allowance,0),
COALESCE(meal_allowance,0), COALESCE(other_allowance,0)
FROM hr_contract WHERE id = $1`, id,
).Scan(&wage, &bonus, &transport, &meal, &other)
return orm.Values{"total_compensation": wage + bonus + transport + meal + other}, nil
})
// -- State Machine Methods --
// action_open: draft/pending → open (activate contract)
m.RegisterMethod("action_open", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_contract SET state = 'open'
WHERE id = $1 AND state IN ('draft', 'pending')`, id); err != nil {
return nil, fmt.Errorf("hr.contract: open %d: %w", id, err)
}
// Set as current contract on employee
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_employee SET contract_id = $1
WHERE id = (SELECT employee_id FROM hr_contract WHERE id = $1)`, id); err != nil {
return nil, fmt.Errorf("hr.contract: update employee contract link %d: %w", id, err)
}
}
return true, nil
})
// action_pending: open → pending (mark for renewal)
m.RegisterMethod("action_pending", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_contract SET state = 'pending' WHERE id = $1 AND state = 'open'`, id); err != nil {
return nil, fmt.Errorf("hr.contract: pending %d: %w", id, err)
}
}
return true, nil
})
// action_close: → close (expire contract)
m.RegisterMethod("action_close", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_contract SET state = 'close' WHERE id = $1 AND state NOT IN ('cancel')`, id); err != nil {
return nil, fmt.Errorf("hr.contract: close %d: %w", id, err)
}
}
return true, nil
})
// action_cancel: → cancel
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_contract SET state = 'cancel'
WHERE id = $1 AND state IN ('draft', 'open', 'pending')`, id); err != nil {
return nil, fmt.Errorf("hr.contract: cancel %d: %w", id, err)
}
}
return true, nil
})
// action_draft: → draft (reset)
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_contract SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id); err != nil {
return nil, fmt.Errorf("hr.contract: draft %d: %w", id, err)
}
}
return true, nil
})
// action_renew: Create a new contract from this one (close current, create copy)
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py action_renew()
m.RegisterMethod("action_renew", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
id := rs.IDs()[0]
// Read current contract
var employeeID, departmentID, jobID, companyID, currencyID, calendarID int64
var wage, bonus, transport, meal, other, hoursPerWeek float64
var contractType, schedulePay, name string
var noticePeriod int
err := env.Tx().QueryRow(env.Ctx(),
`SELECT employee_id, COALESCE(department_id,0), COALESCE(job_id,0),
COALESCE(company_id,0), COALESCE(currency_id,0),
COALESCE(resource_calendar_id,0),
COALESCE(wage,0), COALESCE(bonus,0), COALESCE(transport_allowance,0),
COALESCE(meal_allowance,0), COALESCE(other_allowance,0),
COALESCE(hours_per_week,40),
COALESCE(contract_type,'permanent'), COALESCE(schedule_pay,'monthly'),
COALESCE(name,''), COALESCE(notice_period_days,30)
FROM hr_contract WHERE id = $1`, id,
).Scan(&employeeID, &departmentID, &jobID, &companyID, &currencyID, &calendarID,
&wage, &bonus, &transport, &meal, &other, &hoursPerWeek,
&contractType, &schedulePay, &name, &noticePeriod)
if err != nil {
return nil, fmt.Errorf("hr.contract: read for renew %d: %w", id, err)
}
// Close current contract
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_contract SET state = 'close' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.contract: close for renewal %d: %w", id, err)
}
// Create new contract
newVals := orm.Values{
"name": name + " (Renewal)",
"employee_id": employeeID,
"date_start": time.Now().Format("2006-01-02"),
"wage": wage,
"contract_type": contractType,
"schedule_pay": schedulePay,
"notice_period_days": noticePeriod,
"bonus": bonus,
"transport_allowance": transport,
"meal_allowance": meal,
"other_allowance": other,
"hours_per_week": hoursPerWeek,
"previous_contract_id": id,
"state": "draft",
}
if departmentID > 0 {
newVals["department_id"] = departmentID
}
if jobID > 0 {
newVals["job_id"] = jobID
}
if companyID > 0 {
newVals["company_id"] = companyID
}
if currencyID > 0 {
newVals["currency_id"] = currencyID
}
if calendarID > 0 {
newVals["resource_calendar_id"] = calendarID
}
contractRS := env.Model("hr.contract")
newContract, err := contractRS.Create(newVals)
if err != nil {
return nil, fmt.Errorf("hr.contract: create renewal: %w", err)
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": "hr.contract",
"res_id": newContract.ID(),
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"target": "current",
}, nil
})
// -- BeforeWrite: State Guard --
m.BeforeWrite = orm.StateGuard("hr_contract", "state IN ('close', 'cancel')",
[]string{"write_uid", "write_date", "state", "active"},
"cannot modify closed/cancelled contracts")
}
// initHrContractCron registers the contract expiration check cron job.
// Should be called after initHrContract and cron system is ready.
func initHrContractCron() {
m := orm.ExtendModel("hr.contract")
// _cron_check_expiring: Auto-close expired contracts, set pending for expiring soon.
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py _cron_check_expiring()
m.RegisterMethod("_cron_check_expiring", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
today := time.Now().Format("2006-01-02")
// Close expired contracts
result, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_contract SET state = 'close'
WHERE state = 'open' AND date_end IS NOT NULL AND date_end < $1`, today)
if err != nil {
return nil, fmt.Errorf("hr.contract: cron close expired: %w", err)
}
closed := result.RowsAffected()
// Mark contracts expiring within 30 days as pending
thirtyDays := time.Now().AddDate(0, 0, 30).Format("2006-01-02")
result2, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_contract SET state = 'pending'
WHERE state = 'open' AND date_end IS NOT NULL
AND date_end >= $1 AND date_end <= $2`, today, thirtyDays)
if err != nil {
return nil, fmt.Errorf("hr.contract: cron mark pending: %w", err)
}
pending := result2.RowsAffected()
if closed > 0 || pending > 0 {
fmt.Printf("hr.contract cron: closed %d, marked pending %d\n", closed, pending)
}
return true, nil
})
}

View File

@@ -1,6 +1,11 @@
package models
import "odoo-go/pkg/orm"
import (
"fmt"
"time"
"odoo-go/pkg/orm"
)
// initHrExpense registers the hr.expense and hr.expense.sheet models.
// Mirrors: odoo/addons/hr_expense/models/hr_expense.py
@@ -35,10 +40,63 @@ func initHrExpense() {
orm.Binary("receipt", orm.FieldOpts{String: "Receipt"}),
)
orm.NewModel("hr.expense.sheet", orm.ModelOpts{
// -- Expense Methods --
// action_submit: draft → reported
exp := orm.Registry.Get("hr.expense")
if exp != nil {
exp.RegisterMethod("action_submit", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'reported' WHERE id = $1 AND state = 'draft'`, id); err != nil {
return nil, fmt.Errorf("hr.expense: submit %d: %w", id, err)
}
}
return true, nil
})
// _action_validate_expense: Check that expense has amount > 0 and a receipt.
exp.RegisterMethod("action_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var amount float64
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(total_amount, 0), COALESCE(state, 'draft') FROM hr_expense WHERE id = $1`, id,
).Scan(&amount, &state)
if amount <= 0 {
return nil, fmt.Errorf("hr.expense: expense %d has no amount", id)
}
if state != "reported" {
return nil, fmt.Errorf("hr.expense: expense %d must be submitted first (state: %s)", id, state)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'approved' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense: validate %d: %w", id, err)
}
}
return true, nil
})
exp.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'refused' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense: refuse %d: %w", id, err)
}
}
return true, nil
})
}
sheet := orm.NewModel("hr.expense.sheet", orm.ModelOpts{
Description: "Expense Report",
Order: "create_date desc",
}).AddFields(
})
sheet.AddFields(
orm.Char("name", orm.FieldOpts{String: "Report Name", Required: true}),
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
orm.Many2one("manager_id", "hr.employee", orm.FieldOpts{String: "Manager"}),
@@ -55,5 +113,240 @@ func initHrExpense() {
{Value: "cancel", Label: "Refused"},
}, orm.FieldOpts{String: "Status", Default: "draft"}),
orm.Many2one("account_move_id", "account.move", orm.FieldOpts{String: "Journal Entry"}),
orm.Integer("expense_count", orm.FieldOpts{String: "Expense Count", Compute: "_compute_expense_count"}),
)
// _compute_total: Sum of expense amounts.
sheet.RegisterCompute("total_amount", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var total float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(total_amount::float8), 0) FROM hr_expense WHERE sheet_id = $1`, id,
).Scan(&total)
return orm.Values{"total_amount": total}, nil
})
sheet.RegisterCompute("expense_count", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var count int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM hr_expense WHERE sheet_id = $1`, id).Scan(&count)
return orm.Values{"expense_count": count}, nil
})
// -- Expense Sheet Workflow Methods --
sheet.RegisterMethod("action_submit", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
// Validate: must have at least one expense line
var count int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM hr_expense WHERE sheet_id = $1`, id).Scan(&count)
if count == 0 {
return nil, fmt.Errorf("hr.expense.sheet: cannot submit empty report %d", id)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense_sheet SET state = 'submit' WHERE id = $1 AND state = 'draft'`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: submit %d: %w", id, err)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'reported' WHERE sheet_id = $1 AND state = 'draft'`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: update lines for submit %d: %w", id, err)
}
}
return true, nil
})
sheet.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense_sheet SET state = 'approve' WHERE id = $1 AND state = 'submit'`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: approve %d: %w", id, err)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'approved' WHERE sheet_id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: update lines for approve %d: %w", id, err)
}
}
return true, nil
})
sheet.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense_sheet SET state = 'cancel' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: refuse %d: %w", id, err)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'refused' WHERE sheet_id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: update lines for refuse %d: %w", id, err)
}
}
return true, nil
})
// action_post: Create a journal entry (account.move) from approved expense sheet.
// Debit: expense account, Credit: payable account.
// Mirrors: odoo/addons/hr_expense/models/hr_expense_sheet.py action_sheet_move_create()
sheet.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, sheetID := range rs.IDs() {
// Validate state = approve
var state string
var employeeID int64
var companyID *int64
var currencyID *int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(state, 'draft'), employee_id,
company_id, currency_id
FROM hr_expense_sheet WHERE id = $1`, sheetID,
).Scan(&state, &employeeID, &companyID, &currencyID); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: read %d: %w", sheetID, err)
}
if state != "approve" {
return nil, fmt.Errorf("hr.expense.sheet: can only post approved reports (sheet %d is %q)", sheetID, state)
}
// Fetch expense lines
expRows, err := env.Tx().Query(env.Ctx(),
`SELECT id, name, COALESCE(total_amount, 0), account_id
FROM hr_expense WHERE sheet_id = $1`, sheetID)
if err != nil {
return nil, fmt.Errorf("hr.expense.sheet: fetch expenses for %d: %w", sheetID, err)
}
type expLine struct {
id int64
name string
amount float64
accountID *int64
}
var lines []expLine
var total float64
for expRows.Next() {
var l expLine
if err := expRows.Scan(&l.id, &l.name, &l.amount, &l.accountID); err != nil {
continue
}
lines = append(lines, l)
total += l.amount
}
expRows.Close()
if len(lines) == 0 {
return nil, fmt.Errorf("hr.expense.sheet: no expenses to post on sheet %d", sheetID)
}
// Get employee's home address partner for payable line
var partnerID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT address_home_id FROM hr_employee WHERE id = $1`, employeeID,
).Scan(&partnerID)
// Create account.move
moveVals := orm.Values{
"move_type": "in_invoice",
"state": "draft",
"date": time.Now().Format("2006-01-02"),
}
if companyID != nil {
moveVals["company_id"] = *companyID
}
if currencyID != nil {
moveVals["currency_id"] = *currencyID
}
if partnerID != nil {
moveVals["partner_id"] = *partnerID
}
moveRS := env.Model("account.move")
move, err := moveRS.Create(moveVals)
if err != nil {
return nil, fmt.Errorf("hr.expense.sheet: create journal entry for %d: %w", sheetID, err)
}
moveID := move.ID()
// Create move lines: one debit line per expense, one credit (payable) line for total
for _, l := range lines {
debitVals := orm.Values{
"move_id": moveID,
"name": l.name,
"debit": l.amount,
"credit": float64(0),
}
if l.accountID != nil {
debitVals["account_id"] = *l.accountID
}
if partnerID != nil {
debitVals["partner_id"] = *partnerID
}
lineRS := env.Model("account.move.line")
if _, err := lineRS.Create(debitVals); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: create debit line: %w", err)
}
}
// Credit line (payable) — find payable account
var payableAccID int64
cid := int64(0)
if companyID != nil {
cid = *companyID
}
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account
WHERE account_type = 'liability_payable' AND company_id = $1
ORDER BY code LIMIT 1`, cid).Scan(&payableAccID)
creditVals := orm.Values{
"move_id": moveID,
"name": "Employee Expense Payable",
"debit": float64(0),
"credit": total,
}
if payableAccID > 0 {
creditVals["account_id"] = payableAccID
}
if partnerID != nil {
creditVals["partner_id"] = *partnerID
}
lineRS := env.Model("account.move.line")
if _, err := lineRS.Create(creditVals); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: create credit line: %w", err)
}
// Update expense sheet state and link to move
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense_sheet SET state = 'post', account_move_id = $1 WHERE id = $2`,
moveID, sheetID); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: update state to post %d: %w", sheetID, err)
}
// Update expense line states
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'done' WHERE sheet_id = $1`, sheetID); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: update expense states %d: %w", sheetID, err)
}
}
return true, nil
})
sheet.RegisterMethod("action_reset", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense_sheet SET state = 'draft' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: reset %d: %w", id, err)
}
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_expense SET state = 'draft' WHERE sheet_id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.expense.sheet: update lines for reset %d: %w", id, err)
}
}
return true, nil
})
}

View File

@@ -1,6 +1,11 @@
package models
import "odoo-go/pkg/orm"
import (
"fmt"
"time"
"odoo-go/pkg/orm"
)
// initHrLeaveType registers the hr.leave.type model.
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py
@@ -52,39 +57,378 @@ func initHrLeave() {
orm.Text("notes", orm.FieldOpts{String: "Reasons"}),
)
// action_approve: Manager approves leave request (first approval).
// For leave types with 'both' validation, moves to validate1 (second approval needed).
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_approve()
m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'validate' WHERE id = $1 AND state IN ('confirm','validate1')`, id)
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM hr_leave WHERE id = $1`, id,
).Scan(&state)
if err != nil {
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
}
if state != "confirm" && state != "validate1" {
return nil, fmt.Errorf("hr.leave: can only approve leaves in 'To Approve' or 'Second Approval' state (leave %d is %q)", id, state)
}
// Check if second approval is needed
var validationType string
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(lt.leave_validation_type, 'hr')
FROM hr_leave l
JOIN hr_leave_type lt ON lt.id = l.holiday_status_id
WHERE l.id = $1`, id,
).Scan(&validationType); err != nil {
validationType = "hr" // safe default
}
newState := "validate"
if validationType == "both" && state == "confirm" {
newState = "validate1"
}
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE hr_leave SET state = $1 WHERE id = $2`, newState, id)
if err != nil {
return nil, fmt.Errorf("hr.leave: approve leave %d: %w", id, err)
}
}
return true, nil
})
// action_validate: Final validation (second approval if needed).
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_validate()
m.RegisterMethod("action_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM hr_leave WHERE id = $1`, id,
).Scan(&state)
if err != nil {
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
}
if state != "confirm" && state != "validate1" {
return nil, fmt.Errorf("hr.leave: can only validate leaves in 'To Approve' or 'Second Approval' state (leave %d is %q)", id, state)
}
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE hr_leave SET state = 'validate' WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("hr.leave: validate leave %d: %w", id, err)
}
}
return true, nil
})
// action_refuse: Manager refuses leave request.
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_refuse()
m.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'refuse' WHERE id = $1`, id)
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM hr_leave WHERE id = $1`, id,
).Scan(&state)
if err != nil {
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
}
if state == "draft" {
return nil, fmt.Errorf("hr.leave: cannot refuse a draft leave (leave %d). Submit it first", id)
}
if state == "validate" {
return nil, fmt.Errorf("hr.leave: cannot refuse an already approved leave (leave %d). Reset to draft first", id)
}
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE hr_leave SET state = 'refuse' WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("hr.leave: refuse leave %d: %w", id, err)
}
}
return true, nil
})
// action_draft: Reset leave to draft state.
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_draft()
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'draft' WHERE id = $1`, id)
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM hr_leave WHERE id = $1`, id,
).Scan(&state)
if err != nil {
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
}
if state != "confirm" && state != "refuse" {
return nil, fmt.Errorf("hr.leave: can only reset to draft from 'To Approve' or 'Refused' state (leave %d is %q)", id, state)
}
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE hr_leave SET state = 'draft' WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("hr.leave: reset to draft leave %d: %w", id, err)
}
}
return true, nil
})
// action_confirm: Submit leave for approval (draft -> confirm).
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_confirm()
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'confirm' WHERE id = $1 AND state = 'draft'`, id)
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM hr_leave WHERE id = $1`, id,
).Scan(&state)
if err != nil {
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
}
if state != "draft" {
return nil, fmt.Errorf("hr.leave: can only confirm draft leaves (leave %d is %q)", id, state)
}
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE hr_leave SET state = 'confirm' WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("hr.leave: confirm leave %d: %w", id, err)
}
}
return true, nil
})
}
// initHrLeaveExtensions adds additional leave methods.
func initHrLeaveExtensions() {
leave := orm.ExtendModel("hr.leave")
// action_approve_batch: Approve multiple leave requests at once (manager workflow).
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py action_approve (multi)
leave.RegisterMethod("action_approve_batch", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
ids := rs.IDs()
if len(ids) == 0 {
return true, nil
}
// Leaves with 'both' validation: confirm → validate1 (not directly to validate)
r1, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_leave l SET state = 'validate1'
WHERE l.id = ANY($1) AND l.state = 'confirm'
AND EXISTS (
SELECT 1 FROM hr_leave_type lt
WHERE lt.id = l.holiday_status_id AND lt.leave_validation_type = 'both'
)`, ids)
if err != nil {
return nil, fmt.Errorf("hr.leave: batch approve (first step): %w", err)
}
// Non-'both' confirm → validate, and existing validate1 → validate
// Exclude IDs that were just set to validate1 above
r2, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_leave l SET state = 'validate'
WHERE l.id = ANY($1)
AND (l.state = 'validate1'
OR (l.state = 'confirm' AND NOT EXISTS (
SELECT 1 FROM hr_leave_type lt
WHERE lt.id = l.holiday_status_id AND lt.leave_validation_type = 'both'
)))`, ids)
if err != nil {
return nil, fmt.Errorf("hr.leave: batch approve: %w", err)
}
count := r1.RowsAffected() + r2.RowsAffected()
return map[string]interface{}{"approved": count}, nil
})
// _compute_number_of_days: Auto-compute duration from date_from/date_to.
leave.RegisterCompute("number_of_days", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var days float64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(EXTRACT(EPOCH FROM (date_to - date_from)) / 86400.0, 0)
FROM hr_leave WHERE id = $1`, id).Scan(&days); err != nil {
return orm.Values{"number_of_days": float64(0)}, nil
}
if days < 0 {
days = 0
}
return orm.Values{"number_of_days": days}, nil
})
}
// initHrLeaveReport registers the hr.leave.report transient model.
// Mirrors: odoo/addons/hr_holidays/report/hr_leave_report.py
func initHrLeaveReport() {
m := orm.NewModel("hr.leave.report", orm.ModelOpts{
Description: "Time Off Summary Report",
Type: orm.ModelTransient,
})
m.AddFields(
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee"}),
orm.Date("date_from", orm.FieldOpts{String: "From"}),
orm.Date("date_to", orm.FieldOpts{String: "To"}),
)
// get_leave_summary: Returns leave days grouped by leave type for an employee in a date range.
// Mirrors: odoo/addons/hr_holidays/report/hr_leave_report.py
m.RegisterMethod("get_leave_summary", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
// Parse kwargs
var employeeID int64
dateFrom := "2000-01-01"
dateTo := "2099-12-31"
if len(args) > 0 {
if kw, ok := args[0].(map[string]interface{}); ok {
if v, ok := kw["employee_id"]; ok {
switch vid := v.(type) {
case int64:
employeeID = vid
case float64:
employeeID = int64(vid)
case int:
employeeID = int64(vid)
}
}
if v, ok := kw["date_from"].(string); ok && v != "" {
dateFrom = v
}
if v, ok := kw["date_to"].(string); ok && v != "" {
dateTo = v
}
}
}
if employeeID == 0 {
return nil, fmt.Errorf("hr.leave.report: employee_id is required")
}
// Approved leaves grouped by type
rows, err := env.Tx().Query(env.Ctx(),
`SELECT lt.id, lt.name, COALESCE(SUM(l.number_of_days), 0) AS total_days,
COUNT(l.id) AS leave_count
FROM hr_leave l
JOIN hr_leave_type lt ON lt.id = l.holiday_status_id
WHERE l.employee_id = $1
AND l.state = 'validate'
AND l.date_from::date >= $2
AND l.date_to::date <= $3
GROUP BY lt.id, lt.name
ORDER BY lt.name`, employeeID, dateFrom, dateTo)
if err != nil {
return nil, fmt.Errorf("hr.leave.report: query leaves: %w", err)
}
defer rows.Close()
var summary []map[string]interface{}
var totalDays float64
for rows.Next() {
var typeID int64
var typeName string
var days float64
var count int
if err := rows.Scan(&typeID, &typeName, &days, &count); err != nil {
continue
}
summary = append(summary, map[string]interface{}{
"leave_type_id": typeID,
"leave_type_name": typeName,
"total_days": days,
"leave_count": count,
})
totalDays += days
}
// Remaining allocation per type
allocRows, err := env.Tx().Query(env.Ctx(),
`SELECT lt.id, lt.name,
COALESCE(SUM(a.number_of_days), 0) AS allocated
FROM hr_leave_allocation a
JOIN hr_leave_type lt ON lt.id = a.holiday_status_id
WHERE a.employee_id = $1 AND a.state = 'validate'
GROUP BY lt.id, lt.name
ORDER BY lt.name`, employeeID)
if err != nil {
return nil, fmt.Errorf("hr.leave.report: query allocations: %w", err)
}
defer allocRows.Close()
var allocations []map[string]interface{}
for allocRows.Next() {
var typeID int64
var typeName string
var allocated float64
if err := allocRows.Scan(&typeID, &typeName, &allocated); err != nil {
continue
}
allocations = append(allocations, map[string]interface{}{
"leave_type_id": typeID,
"leave_type_name": typeName,
"allocated_days": allocated,
})
}
return map[string]interface{}{
"employee_id": employeeID,
"date_from": dateFrom,
"date_to": dateTo,
"leaves": summary,
"allocations": allocations,
"total_days": totalDays,
}, nil
})
}
// initHrLeaveTypeExtensions adds remaining quota computation to leave types.
func initHrLeaveTypeExtensions() {
lt := orm.ExtendModel("hr.leave.type")
lt.AddFields(
orm.Float("remaining_leaves", orm.FieldOpts{
String: "Remaining Leaves", Compute: "_compute_remaining_leaves",
Help: "Remaining leaves for current employee (allocated - taken)",
}),
)
// _compute_remaining_quota: Calculate remaining leaves per type for current user's employee.
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py _compute_leaves()
lt.RegisterCompute("remaining_leaves", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
typeID := rs.IDs()[0]
// Get current user's employee
var employeeID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM hr_employee WHERE user_id = $1 LIMIT 1`, env.UID(),
).Scan(&employeeID)
if employeeID == 0 {
return orm.Values{"remaining_leaves": float64(0)}, nil
}
// Allocated days for this type (approved allocations)
var allocated float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(number_of_days), 0) FROM hr_leave_allocation
WHERE employee_id = $1 AND holiday_status_id = $2 AND state = 'validate'`,
employeeID, typeID).Scan(&allocated)
// Used days for this type (approved leaves, current fiscal year)
var used float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(number_of_days), 0) FROM hr_leave
WHERE employee_id = $1 AND holiday_status_id = $2 AND state = 'validate'
AND date_from >= date_trunc('year', CURRENT_DATE)`,
employeeID, typeID).Scan(&used)
return orm.Values{"remaining_leaves": allocated - used}, nil
})
}
// initHrLeaveAllocation registers the hr.leave.allocation model.
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py
func initHrLeaveAllocation() {
@@ -109,13 +453,123 @@ func initHrLeaveAllocation() {
{Value: "regular", Label: "Regular Allocation"},
{Value: "accrual", Label: "Accrual Allocation"},
}, orm.FieldOpts{String: "Allocation Type", Default: "regular"}),
orm.Float("accrual_increment", orm.FieldOpts{
String: "Monthly Accrual Increment",
Help: "Number of days added each month for accrual allocations",
}),
orm.Date("last_accrual_date", orm.FieldOpts{
String: "Last Accrual Date",
Help: "Date when the last accrual increment was applied",
}),
)
// action_approve: Approve allocation request.
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py HrLeaveAllocation.action_approve()
m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave_allocation SET state = 'validate' WHERE id = $1`, id)
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM hr_leave_allocation WHERE id = $1`, id,
).Scan(&state)
if err != nil {
return nil, fmt.Errorf("hr.leave.allocation: read state for %d: %w", id, err)
}
if state != "confirm" && state != "draft" {
return nil, fmt.Errorf("hr.leave.allocation: can only approve allocations in 'To Submit' or 'To Approve' state (allocation %d is %q)", id, state)
}
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE hr_leave_allocation SET state = 'validate' WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("hr.leave.allocation: approve allocation %d: %w", id, err)
}
}
return true, nil
})
// action_refuse: Refuse allocation request.
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py HrLeaveAllocation.action_refuse()
m.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var state string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM hr_leave_allocation WHERE id = $1`, id,
).Scan(&state)
if err != nil {
return nil, fmt.Errorf("hr.leave.allocation: read state for %d: %w", id, err)
}
if state == "validate" {
return nil, fmt.Errorf("hr.leave.allocation: cannot refuse an already approved allocation (allocation %d)", id)
}
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE hr_leave_allocation SET state = 'refuse' WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("hr.leave.allocation: refuse allocation %d: %w", id, err)
}
}
return true, nil
})
}
// initHrLeaveAccrualCron registers the accrual allocation cron method.
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py _cron_accrual_allocation()
func initHrLeaveAccrualCron() {
alloc := orm.ExtendModel("hr.leave.allocation")
// _cron_accrual_allocation: Auto-increment approved accrual-type allocations monthly.
// For each approved accrual allocation whose last_accrual_date is more than a month ago
// (or NULL), add accrual_increment days to number_of_days and update last_accrual_date.
alloc.RegisterMethod("_cron_accrual_allocation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
today := time.Now().Format("2006-01-02")
// Find all approved accrual allocations due for increment
// Due = last_accrual_date is NULL or more than 30 days ago
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id, COALESCE(number_of_days, 0), COALESCE(accrual_increment, 0)
FROM hr_leave_allocation
WHERE state = 'validate'
AND allocation_type = 'accrual'
AND COALESCE(accrual_increment, 0) > 0
AND (last_accrual_date IS NULL
OR last_accrual_date <= CURRENT_DATE - INTERVAL '30 days')`)
if err != nil {
return nil, fmt.Errorf("hr.leave.allocation: accrual cron query: %w", err)
}
type accrualRow struct {
id int64
days float64
increment float64
}
var pending []accrualRow
for rows.Next() {
var r accrualRow
if err := rows.Scan(&r.id, &r.days, &r.increment); err != nil {
continue
}
pending = append(pending, r)
}
rows.Close()
var updated int64
for _, r := range pending {
newDays := r.days + r.increment
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_leave_allocation
SET number_of_days = $1, last_accrual_date = $2
WHERE id = $3`, newDays, today, r.id); err != nil {
return nil, fmt.Errorf("hr.leave.allocation: accrual update %d: %w", r.id, err)
}
updated++
}
if updated > 0 {
fmt.Printf("hr.leave.allocation accrual cron: incremented %d allocations\n", updated)
}
return map[string]interface{}{"updated": updated}, nil
})
}

View File

@@ -0,0 +1,303 @@
package models
import (
"fmt"
"sort"
"time"
"odoo-go/pkg/orm"
)
// initHrPayroll registers hr.salary.structure, hr.salary.rule, and hr.payslip models.
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py, hr_salary_rule.py, hr_payroll_structure.py
func initHrPayroll() {
// -- hr.salary.rule --
orm.NewModel("hr.salary.rule", orm.ModelOpts{
Description: "Salary Rule",
Order: "sequence, id",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Char("code", orm.FieldOpts{String: "Code", Required: true}),
orm.Selection("category", []orm.SelectionItem{
{Value: "basic", Label: "Basic"},
{Value: "allowance", Label: "Allowance"},
{Value: "deduction", Label: "Deduction"},
{Value: "gross", Label: "Gross"},
{Value: "net", Label: "Net"},
}, orm.FieldOpts{String: "Category", Required: true, Default: "basic"}),
orm.Selection("amount_select", []orm.SelectionItem{
{Value: "fixed", Label: "Fixed Amount"},
{Value: "percentage", Label: "Percentage (%)"},
{Value: "code", Label: "Python/Go Code"},
}, orm.FieldOpts{String: "Amount Type", Required: true, Default: "fixed"}),
orm.Float("amount_fix", orm.FieldOpts{String: "Fixed Amount"}),
orm.Float("amount_percentage", orm.FieldOpts{String: "Percentage (%)"}),
orm.Char("amount_percentage_base", orm.FieldOpts{
String: "Percentage Based On",
Help: "Code of the rule whose result is used as the base for percentage calculation",
}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 5}),
orm.Many2one("struct_id", "hr.salary.structure", orm.FieldOpts{String: "Salary Structure"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Text("note", orm.FieldOpts{String: "Description"}),
)
// -- hr.salary.structure --
orm.NewModel("hr.salary.structure", orm.ModelOpts{
Description: "Salary Structure",
Order: "name",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.One2many("rule_ids", "hr.salary.rule", "struct_id", orm.FieldOpts{String: "Salary Rules"}),
orm.Text("note", orm.FieldOpts{String: "Description"}),
)
// -- hr.payslip --
m := orm.NewModel("hr.payslip", orm.ModelOpts{
Description: "Pay Slip",
Order: "number desc, id desc",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Description"}),
orm.Char("number", orm.FieldOpts{String: "Reference", Readonly: true}),
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
String: "Employee", Required: true, Index: true,
}),
orm.Many2one("struct_id", "hr.salary.structure", orm.FieldOpts{
String: "Salary Structure", Required: true,
}),
orm.Many2one("contract_id", "hr.contract", orm.FieldOpts{String: "Contract"}),
orm.Date("date_from", orm.FieldOpts{String: "Date From", Required: true}),
orm.Date("date_to", orm.FieldOpts{String: "Date To", Required: true}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Draft"},
{Value: "verify", Label: "Waiting"},
{Value: "done", Label: "Done"},
{Value: "cancel", Label: "Rejected"},
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Index: true}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Monetary("net_wage", orm.FieldOpts{
String: "Net Wage", Compute: "_compute_net_wage", Store: true, CurrencyField: "currency_id",
}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Text("note", orm.FieldOpts{String: "Notes"}),
)
// _compute_net_wage: Sum salary rule results stored in hr_payslip_line.
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py _compute_basic_net()
m.RegisterCompute("net_wage", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var net float64
// Net = sum of all line amounts (allowances positive, deductions negative)
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(
CASE WHEN category = 'deduction' THEN -amount ELSE amount END
), 0)
FROM hr_payslip_line WHERE slip_id = $1`, id,
).Scan(&net); err != nil {
return orm.Values{"net_wage": float64(0)}, nil
}
return orm.Values{"net_wage": net}, nil
})
// compute_sheet: Apply salary rules from the structure to compute payslip lines.
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py compute_sheet()
m.RegisterMethod("compute_sheet", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, slipID := range rs.IDs() {
// Read payslip data
var structID, contractID, employeeID int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT struct_id, COALESCE(contract_id, 0), employee_id
FROM hr_payslip WHERE id = $1`, slipID,
).Scan(&structID, &contractID, &employeeID); err != nil {
return nil, fmt.Errorf("hr.payslip: read %d: %w", slipID, err)
}
// Fetch contract wage as the base
var wage float64
if contractID > 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(wage, 0) FROM hr_contract WHERE id = $1`, contractID,
).Scan(&wage)
} else {
// Try to find open contract for the employee
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(wage, 0) FROM hr_contract
WHERE employee_id = $1 AND state = 'open'
ORDER BY date_start DESC LIMIT 1`, employeeID,
).Scan(&wage)
}
// Fetch salary rules for this structure, ordered by sequence
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id, name, code, COALESCE(category, 'basic'),
COALESCE(amount_select, 'fixed'),
COALESCE(amount_fix, 0), COALESCE(amount_percentage, 0),
COALESCE(amount_percentage_base, ''), sequence
FROM hr_salary_rule
WHERE struct_id = $1 AND COALESCE(active, true) = true
ORDER BY sequence, id`, structID)
if err != nil {
return nil, fmt.Errorf("hr.payslip: fetch rules for struct %d: %w", structID, err)
}
type rule struct {
id int64
name, code string
category string
amountSelect string
amountFix float64
amountPct float64
amountPctBase string
sequence int
}
var rules []rule
for rows.Next() {
var r rule
if err := rows.Scan(&r.id, &r.name, &r.code, &r.category,
&r.amountSelect, &r.amountFix, &r.amountPct, &r.amountPctBase, &r.sequence); err != nil {
rows.Close()
return nil, fmt.Errorf("hr.payslip: scan rule: %w", err)
}
rules = append(rules, r)
}
rows.Close()
sort.Slice(rules, func(i, j int) bool {
if rules[i].sequence != rules[j].sequence {
return rules[i].sequence < rules[j].sequence
}
return rules[i].id < rules[j].id
})
// Delete existing lines for re-computation
if _, err := env.Tx().Exec(env.Ctx(),
`DELETE FROM hr_payslip_line WHERE slip_id = $1`, slipID); err != nil {
return nil, fmt.Errorf("hr.payslip: clear lines for %d: %w", slipID, err)
}
// Compute each rule; track results by code for percentage-base lookups
codeResults := map[string]float64{
"BASIC": wage, // default base
}
for _, r := range rules {
var amount float64
switch r.amountSelect {
case "fixed":
amount = r.amountFix
case "percentage":
base := wage // default base is wage
if r.amountPctBase != "" {
if v, ok := codeResults[r.amountPctBase]; ok {
base = v
}
}
amount = base * r.amountPct / 100.0
default:
// "code" type — use fixed amount as fallback
amount = r.amountFix
}
codeResults[r.code] = amount
// Insert payslip line
if _, err := env.Tx().Exec(env.Ctx(),
`INSERT INTO hr_payslip_line
(slip_id, name, code, category, amount, sequence, salary_rule_id,
create_uid, write_uid, create_date, write_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, NOW(), NOW())`,
slipID, r.name, r.code, r.category, amount, r.sequence, r.id,
env.UID(),
); err != nil {
return nil, fmt.Errorf("hr.payslip: insert line for rule %s: %w", r.code, err)
}
}
// Update payslip state to verify and compute net_wage inline
var net float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(
CASE WHEN category = 'deduction' THEN -amount ELSE amount END
), 0) FROM hr_payslip_line WHERE slip_id = $1`, slipID,
).Scan(&net)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_payslip SET state = 'verify', net_wage = $1 WHERE id = $2`,
net, slipID); err != nil {
return nil, fmt.Errorf("hr.payslip: update state to verify %d: %w", slipID, err)
}
// Generate payslip number if empty
now := time.Now()
number := fmt.Sprintf("SLIP/%04d/%02d/%05d", now.Year(), now.Month(), slipID)
env.Tx().Exec(env.Ctx(),
`UPDATE hr_payslip SET number = $1 WHERE id = $2 AND (number IS NULL OR number = '')`,
number, slipID)
}
return true, nil
})
// action_done: verify → done (confirm payslip)
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_payslip SET state = 'done' WHERE id = $1 AND state = 'verify'`, id); err != nil {
return nil, fmt.Errorf("hr.payslip: action_done %d: %w", id, err)
}
}
return true, nil
})
// action_cancel: → cancel
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_payslip SET state = 'cancel' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("hr.payslip: action_cancel %d: %w", id, err)
}
}
return true, nil
})
// action_draft: cancel → draft
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE hr_payslip SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id); err != nil {
return nil, fmt.Errorf("hr.payslip: action_draft %d: %w", id, err)
}
}
return true, nil
})
// -- hr.payslip.line — detail lines computed from salary rules --
orm.NewModel("hr.payslip.line", orm.ModelOpts{
Description: "Payslip Line",
Order: "sequence, id",
}).AddFields(
orm.Many2one("slip_id", "hr.payslip", orm.FieldOpts{
String: "Pay Slip", Required: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("salary_rule_id", "hr.salary.rule", orm.FieldOpts{String: "Rule"}),
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Char("code", orm.FieldOpts{String: "Code", Required: true}),
orm.Selection("category", []orm.SelectionItem{
{Value: "basic", Label: "Basic"},
{Value: "allowance", Label: "Allowance"},
{Value: "deduction", Label: "Deduction"},
{Value: "gross", Label: "Gross"},
{Value: "net", Label: "Net"},
}, orm.FieldOpts{String: "Category"}),
orm.Float("amount", orm.FieldOpts{String: "Amount"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
)
}

View File

@@ -8,6 +8,9 @@ func Init() {
initHRJob()
initHrContract()
// Employee categories (tags)
initHrEmployeeCategory()
// Leave management
initHrLeaveType()
initHrLeave()
@@ -22,6 +25,25 @@ func Init() {
// Skills & Resume
initHrSkill()
// Payroll (salary rules, structures, payslips)
initHrPayroll()
// Employee public view (read-only subset)
initHrEmployeePublic()
// Extend hr.employee with links to new models (must come last)
initHrEmployeeExtensions()
// Leave extensions (batch approve, remaining quota)
initHrLeaveExtensions()
initHrLeaveTypeExtensions()
// Leave report (transient model)
initHrLeaveReport()
// Contract cron methods (after contract model is registered)
initHrContractCron()
// Accrual allocation cron (after allocation model is registered)
initHrLeaveAccrualCron()
}

View File

@@ -0,0 +1,16 @@
// Package models registers all mail module models.
package models
// Init registers all models for the mail module.
// Called by the module loader in dependency order.
func Init() {
initMailMessage() // mail.message
initMailFollowers() // mail.followers
initMailActivityType() // mail.activity.type
initMailActivity() // mail.activity
initMailChannel() // mail.channel + mail.channel.member
// Extensions (must come after base models are registered)
initMailThread()
initMailChannelExtensions()
initDiscussBus()
}

View File

@@ -0,0 +1,62 @@
package models
import "odoo-go/pkg/orm"
// initMailActivity registers the mail.activity model.
// Mirrors: odoo/addons/mail/models/mail_activity.py MailActivity
func initMailActivity() {
m := orm.NewModel("mail.activity", orm.ModelOpts{
Description: "Activity",
Order: "date_deadline ASC",
})
m.AddFields(
orm.Char("res_model", orm.FieldOpts{
String: "Related Document Model",
Required: true,
Index: true,
}),
orm.Integer("res_id", orm.FieldOpts{
String: "Related Document ID",
Required: true,
Index: true,
}),
orm.Many2one("activity_type_id", "mail.activity.type", orm.FieldOpts{
String: "Activity Type",
OnDelete: orm.OnDeleteRestrict,
}),
orm.Char("summary", orm.FieldOpts{String: "Summary"}),
orm.Text("note", orm.FieldOpts{String: "Note"}),
orm.Date("date_deadline", orm.FieldOpts{
String: "Due Date",
Required: true,
Index: true,
}),
orm.Many2one("user_id", "res.users", orm.FieldOpts{
String: "Assigned to",
Required: true,
Index: true,
}),
orm.Selection("state", []orm.SelectionItem{
{Value: "overdue", Label: "Overdue"},
{Value: "today", Label: "Today"},
{Value: "planned", Label: "Planned"},
}, orm.FieldOpts{String: "State", Default: "planned"}),
orm.Boolean("done", orm.FieldOpts{String: "Done", Default: false}),
// Odoo 19: deadline_range for flexible deadline display
orm.Integer("deadline_range", orm.FieldOpts{
String: "Deadline Range (Days)", Help: "Number of days before/after deadline for grouping",
}),
)
// action_done: mark activity as done
// Mirrors: odoo/addons/mail/models/mail_activity.py action_done
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE mail_activity SET done = true WHERE id = $1`, id)
}
return true, nil
})
}

View File

@@ -0,0 +1,39 @@
package models
import "odoo-go/pkg/orm"
// initMailActivityType registers the mail.activity.type model.
// Mirrors: odoo/addons/mail/models/mail_activity.py MailActivityType
func initMailActivityType() {
m := orm.NewModel("mail.activity.type", orm.ModelOpts{
Description: "Activity Type",
Order: "sequence, id",
RecName: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Char("summary", orm.FieldOpts{String: "Default Summary"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Char("res_model", orm.FieldOpts{
String: "Document Model",
Help: "Specify a model if this activity type is specific to a model, otherwise it is available for all models.",
}),
orm.Selection("category", []orm.SelectionItem{
{Value: "default", Label: "Other"},
{Value: "upload_file", Label: "Upload Document"},
}, orm.FieldOpts{String: "Action", Default: "default"}),
orm.Integer("delay_count", orm.FieldOpts{
String: "Schedule",
Default: 0,
Help: "Number of days/weeks/months before executing the action.",
}),
orm.Selection("delay_unit", []orm.SelectionItem{
{Value: "days", Label: "days"},
{Value: "weeks", Label: "weeks"},
{Value: "months", Label: "months"},
}, orm.FieldOpts{String: "Delay units", Default: "days"}),
orm.Char("icon", orm.FieldOpts{String: "Icon", Help: "Font awesome icon e.g. fa-tasks"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
}

View File

@@ -0,0 +1,424 @@
package models
import (
"fmt"
"time"
"odoo-go/pkg/orm"
)
// initMailChannel registers mail.channel and mail.channel.member models.
// Mirrors: odoo/addons/mail/models/discuss_channel.py
func initMailChannel() {
m := orm.NewModel("mail.channel", orm.ModelOpts{
Description: "Discussion Channel",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Selection("channel_type", []orm.SelectionItem{
{Value: "channel", Label: "Channel"},
{Value: "chat", Label: "Direct Message"},
{Value: "group", Label: "Group"},
}, orm.FieldOpts{String: "Type", Default: "channel", Required: true}),
orm.Text("description", orm.FieldOpts{String: "Description"}),
orm.Many2one("create_uid", "res.users", orm.FieldOpts{String: "Created By", Readonly: true}),
orm.Boolean("public", orm.FieldOpts{String: "Public", Default: true,
Help: "If true, any internal user can join. If false, invitation only."}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("group_id", "res.groups", orm.FieldOpts{String: "Authorized Group"}),
orm.One2many("member_ids", "mail.channel.member", "channel_id", orm.FieldOpts{String: "Members"}),
orm.Integer("member_count", orm.FieldOpts{
String: "Member Count", Compute: "_compute_member_count",
}),
orm.Many2one("last_message_id", "mail.message", orm.FieldOpts{String: "Last Message"}),
orm.Datetime("last_message_date", orm.FieldOpts{String: "Last Message Date"}),
)
m.RegisterCompute("member_count", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var count int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM mail_channel_member WHERE channel_id = $1`, id,
).Scan(&count); err != nil {
return orm.Values{"member_count": int64(0)}, nil
}
return orm.Values{"member_count": count}, nil
})
// action_join: Current user joins the channel.
m.RegisterMethod("action_join", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
channelID := rs.IDs()[0]
// Get current user's partner
var partnerID int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
).Scan(&partnerID); err != nil || partnerID == 0 {
return nil, fmt.Errorf("mail.channel: cannot find partner for user %d", env.UID())
}
// Check not already member
var exists bool
env.Tx().QueryRow(env.Ctx(),
`SELECT EXISTS(SELECT 1 FROM mail_channel_member
WHERE channel_id = $1 AND partner_id = $2)`, channelID, partnerID,
).Scan(&exists)
if exists {
return true, nil // Already a member
}
memberRS := env.Model("mail.channel.member")
if _, err := memberRS.Create(orm.Values{
"channel_id": channelID,
"partner_id": partnerID,
}); err != nil {
return nil, fmt.Errorf("mail.channel: join %d: %w", channelID, err)
}
return true, nil
})
// action_leave: Current user leaves the channel.
m.RegisterMethod("action_leave", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
channelID := rs.IDs()[0]
var partnerID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
).Scan(&partnerID)
if _, err := env.Tx().Exec(env.Ctx(),
`DELETE FROM mail_channel_member WHERE channel_id = $1 AND partner_id = $2`,
channelID, partnerID); err != nil {
return nil, fmt.Errorf("mail.channel: leave %d: %w", channelID, err)
}
return true, nil
})
// message_post: Post a message to the channel.
m.RegisterMethod("message_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
channelID := rs.IDs()[0]
body := ""
if len(args) > 0 {
if kw, ok := args[0].(map[string]interface{}); ok {
if v, ok := kw["body"].(string); ok {
body = v
}
}
}
var authorID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
).Scan(&authorID)
var msgID int64
err := env.Tx().QueryRow(env.Ctx(),
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, date, create_uid, write_uid, create_date, write_date)
VALUES ('mail.channel', $1, $2, 'comment', $3, NOW(), $4, $4, NOW(), NOW())
RETURNING id`,
channelID, body, authorID, env.UID(),
).Scan(&msgID)
if err != nil {
return nil, fmt.Errorf("mail.channel: post message: %w", err)
}
// Update channel last message
env.Tx().Exec(env.Ctx(),
`UPDATE mail_channel SET last_message_id = $1, last_message_date = NOW() WHERE id = $2`,
msgID, channelID)
return msgID, nil
})
// get_messages: Get messages for a channel.
m.RegisterMethod("get_messages", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
channelID := rs.IDs()[0]
limit := 50
if len(args) > 0 {
if kw, ok := args[0].(map[string]interface{}); ok {
if v, ok := kw["limit"].(float64); ok && v > 0 {
limit = int(v)
}
}
}
rows, err := env.Tx().Query(env.Ctx(),
`SELECT m.id, m.body, m.date, m.author_id, COALESCE(p.name, '')
FROM mail_message m
LEFT JOIN res_partner p ON p.id = m.author_id
WHERE m.model = 'mail.channel' AND m.res_id = $1
ORDER BY m.id DESC LIMIT $2`, channelID, limit)
if err != nil {
return nil, fmt.Errorf("mail.channel: get_messages: %w", err)
}
defer rows.Close()
var messages []map[string]interface{}
for rows.Next() {
var id, authorID int64
var body, authorName string
var date interface{}
if err := rows.Scan(&id, &body, &date, &authorID, &authorName); err != nil {
continue
}
msg := map[string]interface{}{
"id": id,
"body": body,
"date": date,
}
if authorID > 0 {
msg["author_id"] = []interface{}{authorID, authorName}
} else {
msg["author_id"] = false
}
messages = append(messages, msg)
}
if messages == nil {
messages = []map[string]interface{}{}
}
return messages, nil
})
// channel_get: Get or create a direct message channel between current user and partner.
// Mirrors: odoo/addons/mail/models/discuss_channel.py channel_get()
m.RegisterMethod("channel_get", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
if len(args) < 1 {
return nil, fmt.Errorf("mail.channel: channel_get requires partner_ids")
}
var partnerIDs []int64
if kw, ok := args[0].(map[string]interface{}); ok {
if pids, ok := kw["partner_ids"].([]interface{}); ok {
for _, pid := range pids {
switch v := pid.(type) {
case float64:
partnerIDs = append(partnerIDs, int64(v))
case int64:
partnerIDs = append(partnerIDs, v)
}
}
}
}
// Add current user's partner
var myPartnerID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
).Scan(&myPartnerID)
if myPartnerID > 0 {
partnerIDs = append(partnerIDs, myPartnerID)
}
if len(partnerIDs) < 2 {
return nil, fmt.Errorf("mail.channel: need at least 2 partners for DM")
}
// Check if DM channel already exists between these partners
var existingID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT c.id FROM mail_channel c
WHERE c.channel_type = 'chat'
AND (SELECT COUNT(*) FROM mail_channel_member m WHERE m.channel_id = c.id) = $1
AND NOT EXISTS (
SELECT 1 FROM mail_channel_member m
WHERE m.channel_id = c.id AND m.partner_id != ALL($2)
)
LIMIT 1`, len(partnerIDs), partnerIDs,
).Scan(&existingID)
if existingID > 0 {
return map[string]interface{}{"id": existingID}, nil
}
// Create new DM channel
var partnerName string
for _, pid := range partnerIDs {
if pid != myPartnerID {
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, '') FROM res_partner WHERE id = $1`, pid,
).Scan(&partnerName)
break
}
}
channelRS := env.Model("mail.channel")
channel, err := channelRS.Create(orm.Values{
"name": partnerName,
"channel_type": "chat",
"public": false,
})
if err != nil {
return nil, fmt.Errorf("mail.channel: create DM: %w", err)
}
channelID := channel.ID()
// Add members
memberRS := env.Model("mail.channel.member")
for _, pid := range partnerIDs {
memberRS.Create(orm.Values{
"channel_id": channelID,
"partner_id": pid,
})
}
return map[string]interface{}{"id": channelID}, nil
})
// -- mail.channel.member --
initMailChannelMember()
}
func initMailChannelMember() {
m := orm.NewModel("mail.channel.member", orm.ModelOpts{
Description: "Channel Member",
Order: "id",
})
m.AddFields(
orm.Many2one("channel_id", "mail.channel", orm.FieldOpts{
String: "Channel", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Partner", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Datetime("last_seen_dt", orm.FieldOpts{String: "Last Seen"}),
orm.Many2one("last_seen_message_id", "mail.message", orm.FieldOpts{String: "Last Seen Message"}),
orm.Boolean("is_pinned", orm.FieldOpts{String: "Pinned", Default: true}),
orm.Boolean("is_muted", orm.FieldOpts{String: "Muted", Default: false}),
)
m.AddSQLConstraint(
"unique_channel_partner",
"UNIQUE(channel_id, partner_id)",
"A partner can only be a member of a channel once.",
)
// mark_as_read: Update last seen timestamp and message.
m.RegisterMethod("mark_as_read", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE mail_channel_member SET last_seen_dt = NOW(),
last_seen_message_id = (
SELECT MAX(m.id) FROM mail_message m
WHERE m.model = 'mail.channel'
AND m.res_id = (SELECT channel_id FROM mail_channel_member WHERE id = $1)
) WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("mail.channel.member: mark_as_read %d: %w", id, err)
}
}
return true, nil
})
}
// initMailChannelExtensions adds unread count compute after message model is registered.
func initMailChannelExtensions() {
ch := orm.ExtendModel("mail.channel")
ch.AddFields(
orm.Integer("message_unread_count", orm.FieldOpts{
String: "Unread Messages", Compute: "_compute_message_unread_count",
}),
)
ch.RegisterCompute("message_unread_count", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
channelID := rs.IDs()[0]
var partnerID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
).Scan(&partnerID)
if partnerID == 0 {
return orm.Values{"message_unread_count": int64(0)}, nil
}
var lastSeenID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(last_seen_message_id, 0) FROM mail_channel_member
WHERE channel_id = $1 AND partner_id = $2`, channelID, partnerID,
).Scan(&lastSeenID)
var count int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM mail_message
WHERE model = 'mail.channel' AND res_id = $1 AND id > $2`,
channelID, lastSeenID,
).Scan(&count)
return orm.Values{"message_unread_count": count}, nil
})
}
// initDiscussBus registers the message bus polling endpoint logic.
func initDiscussBus() {
ch := orm.ExtendModel("mail.channel")
// channel_fetch_preview: Get channel list with last message for discuss sidebar.
// Mirrors: odoo/addons/mail/models/discuss_channel.py channel_fetch_preview()
ch.RegisterMethod("channel_fetch_preview", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
var partnerID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
).Scan(&partnerID)
if partnerID == 0 {
return []map[string]interface{}{}, nil
}
rows, err := env.Tx().Query(env.Ctx(),
`SELECT c.id, c.name, c.channel_type, c.last_message_date,
COALESCE(m.body, ''), COALESCE(p.name, ''),
(SELECT COUNT(*) FROM mail_message msg
WHERE msg.model = 'mail.channel' AND msg.res_id = c.id
AND msg.id > COALESCE(cm.last_seen_message_id, 0)) AS unread
FROM mail_channel c
JOIN mail_channel_member cm ON cm.channel_id = c.id AND cm.partner_id = $1
LEFT JOIN mail_message m ON m.id = c.last_message_id
LEFT JOIN res_partner p ON p.id = m.author_id
WHERE c.active = true AND cm.is_pinned = true
ORDER BY c.last_message_date DESC NULLS LAST`, partnerID)
if err != nil {
return nil, fmt.Errorf("mail.channel: fetch_preview: %w", err)
}
defer rows.Close()
var channels []map[string]interface{}
for rows.Next() {
var id int64
var name, channelType, lastBody, lastAuthor string
var lastDate *time.Time
var unread int64
if err := rows.Scan(&id, &name, &channelType, &lastDate, &lastBody, &lastAuthor, &unread); err != nil {
continue
}
channels = append(channels, map[string]interface{}{
"id": id,
"name": name,
"channel_type": channelType,
"last_message": lastBody,
"last_author": lastAuthor,
"last_date": lastDate,
"unread_count": unread,
})
}
if channels == nil {
channels = []map[string]interface{}{}
}
return channels, nil
})
}

View File

@@ -0,0 +1,31 @@
package models
import "odoo-go/pkg/orm"
// initMailFollowers registers the mail.followers model.
// Mirrors: odoo/addons/mail/models/mail_followers.py
func initMailFollowers() {
m := orm.NewModel("mail.followers", orm.ModelOpts{
Description: "Document Followers",
})
m.AddFields(
orm.Char("res_model", orm.FieldOpts{
String: "Related Document Model Name",
Required: true,
Index: true,
}),
orm.Integer("res_id", orm.FieldOpts{
String: "Related Document ID",
Required: true,
Index: true,
Help: "Id of the followed resource",
}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Related Partner",
Required: true,
Index: true,
OnDelete: orm.OnDeleteCascade,
}),
)
}

View File

@@ -0,0 +1,53 @@
package models
import "odoo-go/pkg/orm"
// initMailMessage registers the mail.message model.
// Mirrors: odoo/addons/mail/models/mail_message.py
func initMailMessage() {
m := orm.NewModel("mail.message", orm.ModelOpts{
Description: "Message",
Order: "id desc",
RecName: "subject",
})
m.AddFields(
orm.Char("subject", orm.FieldOpts{String: "Subject"}),
orm.Datetime("date", orm.FieldOpts{String: "Date"}),
orm.Text("body", orm.FieldOpts{String: "Contents"}),
orm.Selection("message_type", []orm.SelectionItem{
{Value: "comment", Label: "Comment"},
{Value: "notification", Label: "System notification"},
{Value: "email", Label: "Email"},
{Value: "user_notification", Label: "User Notification"},
}, orm.FieldOpts{String: "Type", Required: true, Default: "comment"}),
orm.Many2one("author_id", "res.partner", orm.FieldOpts{
String: "Author",
Index: true,
Help: "Author of the message.",
}),
orm.Char("model", orm.FieldOpts{
String: "Related Document Model",
Index: true,
}),
orm.Integer("res_id", orm.FieldOpts{
String: "Related Document ID",
Index: true,
}),
orm.Many2one("parent_id", "mail.message", orm.FieldOpts{
String: "Parent Message",
OnDelete: orm.OnDeleteSetNull,
}),
orm.Boolean("starred", orm.FieldOpts{String: "Starred"}),
orm.Char("email_from", orm.FieldOpts{String: "From", Help: "Email address of the sender."}),
orm.Char("reply_to", orm.FieldOpts{String: "Reply To", Help: "Reply-To address."}),
orm.Char("record_name", orm.FieldOpts{
String: "Message Record Name",
Help: "Name of the document the message is attached to.",
}),
orm.Many2many("attachment_ids", "ir.attachment", orm.FieldOpts{
String: "Attachments",
Help: "Attachments linked to this message.",
}),
)
}

View File

@@ -0,0 +1,208 @@
package models
import (
"fmt"
"log"
"odoo-go/pkg/orm"
"odoo-go/pkg/tools"
)
// initMailThread extends existing models with mail.thread functionality.
// In Python Odoo, models inherit from mail.thread to get chatter support.
// Here we use ExtendModel to add the message fields and methods.
// Mirrors: odoo/addons/mail/models/mail_thread.py
func initMailThread() {
// Models that support mail.thread chatter
threadModels := []string{
"res.partner",
"sale.order",
"purchase.order",
"account.move",
"stock.picking",
"crm.lead",
"project.task",
}
for _, modelName := range threadModels {
// Check if the model is registered (module may not be loaded)
if orm.Registry.Get(modelName) == nil {
continue
}
m := orm.ExtendModel(modelName)
m.AddFields(
orm.Integer("message_partner_ids_count", orm.FieldOpts{
String: "Followers Count",
Help: "Number of partners following this document.",
}),
)
// message_post: post a new message on the record's chatter.
// Mirrors: odoo/addons/mail/models/mail_thread.py message_post()
m.RegisterMethod("message_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
ids := rs.IDs()
if len(ids) == 0 {
return false, nil
}
// Parse kwargs from args
body := ""
messageType := "comment"
subject := ""
var attachmentIDs []int64
if len(args) > 0 {
if kw, ok := args[0].(map[string]interface{}); ok {
if v, ok := kw["body"].(string); ok {
body = v
}
if v, ok := kw["message_type"].(string); ok {
messageType = v
}
if v, ok := kw["subject"].(string); ok {
subject = v
}
if v, ok := kw["attachment_ids"].([]interface{}); ok {
for _, aid := range v {
switch id := aid.(type) {
case float64:
attachmentIDs = append(attachmentIDs, int64(id))
case int64:
attachmentIDs = append(attachmentIDs, id)
}
}
}
}
}
// Get author from current user's partner_id
var authorID int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT partner_id FROM res_users WHERE id = $1`, env.UID(),
).Scan(&authorID); err != nil {
log.Printf("warning: mail_thread message_post author lookup failed: %v", err)
}
// Create mail.message
var msgID int64
err := env.Tx().QueryRow(env.Ctx(),
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, subject, date, create_uid, write_uid, create_date, write_date)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $7, NOW(), NOW())
RETURNING id`,
rs.ModelDef().Name(), ids[0], body, messageType, authorID, subject, env.UID(),
).Scan(&msgID)
if err != nil {
return nil, err
}
// Link attachments to the message via M2M
for _, aid := range attachmentIDs {
env.Tx().Exec(env.Ctx(),
`INSERT INTO mail_message_ir_attachment_rel (mail_message_id, ir_attachment_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING`, msgID, aid)
}
// Notify followers via email
notifyFollowers(env, rs.ModelDef().Name(), ids[0], authorID, subject, body)
return msgID, nil
})
// _message_get_thread: get messages for the record's chatter.
// Mirrors: odoo/addons/mail/models/mail_thread.py _notify_thread()
m.RegisterMethod("_message_get_thread", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
ids := rs.IDs()
if len(ids) == 0 {
return []interface{}{}, nil
}
rows, err := env.Tx().Query(env.Ctx(),
`SELECT m.id, m.body, m.message_type, m.date,
m.author_id, COALESCE(p.name, ''),
COALESCE(m.subject, ''), COALESCE(m.email_from, '')
FROM mail_message m
LEFT JOIN res_partner p ON p.id = m.author_id
WHERE m.model = $1 AND m.res_id = $2
ORDER BY m.id DESC`,
rs.ModelDef().Name(), ids[0],
)
if err != nil {
return nil, err
}
defer rows.Close()
var messages []map[string]interface{}
for rows.Next() {
var id int64
var body, msgType, subject, emailFrom string
var date interface{}
var authorID int64
var authorName string
if err := rows.Scan(&id, &body, &msgType, &date, &authorID, &authorName, &subject, &emailFrom); err != nil {
continue
}
msg := map[string]interface{}{
"id": id,
"body": body,
"message_type": msgType,
"date": date,
"subject": subject,
"email_from": emailFrom,
}
if authorID > 0 {
msg["author_id"] = []interface{}{authorID, authorName}
} else {
msg["author_id"] = false
}
messages = append(messages, msg)
}
if messages == nil {
messages = []map[string]interface{}{}
}
return messages, nil
})
}
}
// notifyFollowers sends email notifications to followers of a document.
// Skips the message author to avoid self-notifications.
// Mirrors: odoo/addons/mail/models/mail_thread.py _notify_thread()
func notifyFollowers(env *orm.Environment, modelName string, resID, authorID int64, subject, body string) {
rows, err := env.Tx().Query(env.Ctx(),
`SELECT DISTINCT p.email, p.name
FROM mail_followers f
JOIN res_partner p ON p.id = f.partner_id
WHERE f.res_model = $1 AND f.res_id = $2
AND f.partner_id != $3
AND p.email IS NOT NULL AND p.email != ''`,
modelName, resID, authorID)
if err != nil {
log.Printf("mail: follower lookup failed for %s/%d: %v", modelName, resID, err)
return
}
defer rows.Close()
cfg := tools.LoadSMTPConfig()
if cfg.Host == "" {
return // SMTP not configured — skip silently
}
emailSubject := subject
if emailSubject == "" {
emailSubject = fmt.Sprintf("New message on %s", modelName)
}
for rows.Next() {
var email, name string
if err := rows.Scan(&email, &name); err != nil {
continue
}
if err := tools.SendEmail(cfg, email, emailSubject, body); err != nil {
log.Printf("mail: failed to notify %s (%s): %v", name, email, err)
}
}
}

22
addons/mail/module.go Normal file
View File

@@ -0,0 +1,22 @@
// Package mail implements Odoo's Mail/Chatter module.
// Mirrors: odoo/addons/mail/__manifest__.py
package mail
import (
"odoo-go/addons/mail/models"
"odoo-go/pkg/modules"
)
func init() {
modules.Register(&modules.Module{
Name: "mail",
Description: "Discuss",
Version: "19.0.1.0.0",
Category: "Productivity/Discuss",
Depends: []string{"base"},
Application: true,
Installable: true,
Sequence: 5,
Init: models.Init,
})
}

View File

@@ -6,6 +6,8 @@ func Init() {
initProjectMilestone()
initProjectProject()
initProjectTask()
initProjectTaskChecklist()
initProjectSharing()
initProjectUpdate()
initProjectTimesheetExtension()
initTimesheetReport()
@@ -13,5 +15,6 @@ func Init() {
initProjectTaskExtension()
initProjectMilestoneExtension()
initProjectTaskRecurrence()
initProjectTaskRecurrenceExtension()
initProjectSharingWizard()
}

View File

@@ -80,6 +80,8 @@ func initProjectTask() {
orm.Many2one("milestone_id", "project.milestone", orm.FieldOpts{String: "Milestone"}),
orm.Many2many("depend_ids", "project.task", orm.FieldOpts{String: "Depends On"}),
orm.Boolean("recurring_task", orm.FieldOpts{String: "Recurrent"}),
orm.Datetime("planned_date_start", orm.FieldOpts{String: "Planned Start Date"}),
orm.Datetime("planned_date_end", orm.FieldOpts{String: "Planned End Date"}),
orm.Selection("display_type", []orm.SelectionItem{
{Value: "", Label: ""},
{Value: "line_section", Label: "Section"},
@@ -100,38 +102,54 @@ func initProjectTask() {
task.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'done' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'done' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: done %d: %w", id, err)
}
}
return true, nil
})
// action_cancel: mark task as cancelled
task.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'cancel' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'cancel' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: cancel %d: %w", id, err)
}
}
return true, nil
})
// action_reopen: reopen a cancelled/done task
task.RegisterMethod("action_reopen", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'open' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'open' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: reopen %d: %w", id, err)
}
}
return true, nil
})
// action_blocked: set kanban state to blocked
task.RegisterMethod("action_blocked", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET kanban_state = 'blocked' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET kanban_state = 'blocked' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: blocked %d: %w", id, err)
}
}
return true, nil
})
task.RegisterMethod("toggle_active", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET active = NOT active WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: toggle_active %d: %w", id, err)
}
}
return true, nil
})
@@ -185,3 +203,62 @@ func initProjectTags() {
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
)
}
// initProjectTaskChecklist registers the project.task.checklist model.
// Mirrors: odoo/addons/project/models/project_task_checklist.py
func initProjectTaskChecklist() {
m := orm.NewModel("project.task.checklist", orm.ModelOpts{
Description: "Task Checklist Item",
Order: "sequence, id",
})
m.AddFields(
orm.Many2one("task_id", "project.task", orm.FieldOpts{
String: "Task", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Boolean("is_done", orm.FieldOpts{String: "Done", Default: false}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
// action_toggle_done: Toggle the checklist item done status.
m.RegisterMethod("action_toggle_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task_checklist SET is_done = NOT is_done WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task.checklist: toggle_done %d: %w", id, err)
}
}
return true, nil
})
}
// initProjectSharing registers the project.sharing model.
// Mirrors: odoo/addons/project/models/project_sharing.py
func initProjectSharing() {
m := orm.NewModel("project.sharing", orm.ModelOpts{
Description: "Project Sharing",
Order: "id",
})
m.AddFields(
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Partner", Required: true, Index: true,
}),
orm.Many2one("project_id", "project.project", orm.FieldOpts{
String: "Project", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Selection("access_level", []orm.SelectionItem{
{Value: "read", Label: "Read"},
{Value: "edit", Label: "Edit"},
{Value: "admin", Label: "Admin"},
}, orm.FieldOpts{String: "Access Level", Required: true, Default: "read"}),
)
m.AddSQLConstraint(
"unique_partner_project",
"UNIQUE(partner_id, project_id)",
"A partner can only have one sharing entry per project.",
)
}

View File

@@ -1,6 +1,7 @@
package models
import (
"encoding/json"
"fmt"
"time"
@@ -56,6 +57,28 @@ func initProjectProjectExtension() {
}),
orm.Many2many("tag_ids", "project.tags", orm.FieldOpts{String: "Tags"}),
orm.One2many("task_ids", "project.task", "project_id", orm.FieldOpts{String: "Tasks"}),
orm.Float("progress_percentage", orm.FieldOpts{
String: "Progress Percentage", Compute: "_compute_progress_percentage",
}),
orm.Text("workload_by_user", orm.FieldOpts{
String: "Workload by User", Compute: "_compute_workload_by_user",
}),
orm.Float("planned_budget", orm.FieldOpts{String: "Planned Budget"}),
orm.Float("remaining_budget", orm.FieldOpts{
String: "Remaining Budget", Compute: "_compute_remaining_budget",
}),
orm.One2many("sharing_ids", "project.sharing", "project_id", orm.FieldOpts{
String: "Sharing Entries",
}),
orm.Datetime("planned_date_start", orm.FieldOpts{
String: "Planned Start Date", Compute: "_compute_planned_date_start",
}),
orm.Datetime("planned_date_end", orm.FieldOpts{
String: "Planned End Date", Compute: "_compute_planned_date_end",
}),
orm.One2many("checklist_task_ids", "project.task", "project_id", orm.FieldOpts{
String: "Tasks with Checklists",
}),
)
// -- _compute_task_count --
@@ -164,6 +187,110 @@ func initProjectProjectExtension() {
return orm.Values{"progress": pct}, nil
})
// -- _compute_progress_percentage: done_tasks / total_tasks * 100 --
// Mirrors: odoo/addons/project/models/project_project.py Project._compute_progress_percentage()
proj.RegisterCompute("progress_percentage", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
projID := rs.IDs()[0]
var total, done int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM project_task WHERE project_id = $1 AND active = true`, projID).Scan(&total)
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM project_task WHERE project_id = $1 AND active = true AND state = 'done'`, projID).Scan(&done)
pct := float64(0)
if total > 0 {
pct = float64(done) / float64(total) * 100
}
return orm.Values{"progress_percentage": pct}, nil
})
// -- _compute_workload_by_user: hours planned per user from tasks --
// Mirrors: odoo/addons/project/models/project_project.py Project._compute_workload_by_user()
proj.RegisterCompute("workload_by_user", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
projID := rs.IDs()[0]
rows, err := env.Tx().Query(env.Ctx(), `
SELECT ru.id, COALESCE(rp.name, 'Unknown') AS user_name,
COALESCE(SUM(pt.planned_hours), 0) AS planned_hours
FROM project_task pt
JOIN project_task_res_users_rel rel ON rel.project_task_id = pt.id
JOIN res_users ru ON ru.id = rel.res_users_id
LEFT JOIN res_partner rp ON rp.id = ru.partner_id
WHERE pt.project_id = $1 AND pt.active = true
GROUP BY ru.id, rp.name
ORDER BY planned_hours DESC`, projID)
if err != nil {
return orm.Values{"workload_by_user": "[]"}, nil
}
defer rows.Close()
var workload []map[string]interface{}
for rows.Next() {
var userID int64
var userName string
var plannedHours float64
if err := rows.Scan(&userID, &userName, &plannedHours); err != nil {
continue
}
workload = append(workload, map[string]interface{}{
"user_id": userID,
"user_name": userName,
"planned_hours": plannedHours,
})
}
if workload == nil {
workload = []map[string]interface{}{}
}
data, _ := json.Marshal(workload)
return orm.Values{"workload_by_user": string(data)}, nil
})
// -- _compute_remaining_budget: planned_budget - SUM(analytic_line.amount) --
// Mirrors: odoo/addons/project/models/project_project.py Project._compute_remaining_budget()
proj.RegisterCompute("remaining_budget", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
projID := rs.IDs()[0]
var plannedBudget float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(planned_budget, 0) FROM project_project WHERE id = $1`, projID).Scan(&plannedBudget)
var spent float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(amount), 0) FROM account_analytic_line
WHERE project_id = $1`, projID).Scan(&spent)
remaining := plannedBudget - spent
return orm.Values{"remaining_budget": remaining}, nil
})
// -- _compute_planned_date_start: earliest planned_date_start from tasks --
// Mirrors: odoo/addons/project/models/project_project.py Project._compute_planned_date_start()
proj.RegisterCompute("planned_date_start", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
projID := rs.IDs()[0]
var startDate *time.Time
env.Tx().QueryRow(env.Ctx(),
`SELECT MIN(planned_date_start) FROM project_task
WHERE project_id = $1 AND active = true AND planned_date_start IS NOT NULL`, projID).Scan(&startDate)
if startDate != nil {
return orm.Values{"planned_date_start": startDate.Format(time.RFC3339)}, nil
}
return orm.Values{"planned_date_start": nil}, nil
})
// -- _compute_planned_date_end: latest planned_date_end from tasks --
// Mirrors: odoo/addons/project/models/project_project.py Project._compute_planned_date_end()
proj.RegisterCompute("planned_date_end", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
projID := rs.IDs()[0]
var endDate *time.Time
env.Tx().QueryRow(env.Ctx(),
`SELECT MAX(planned_date_end) FROM project_task
WHERE project_id = $1 AND active = true AND planned_date_end IS NOT NULL`, projID).Scan(&endDate)
if endDate != nil {
return orm.Values{"planned_date_end": endDate.Format(time.RFC3339)}, nil
}
return orm.Values{"planned_date_end": nil}, nil
})
// action_view_tasks: Open tasks of this project.
// Mirrors: odoo/addons/project/models/project_project.py Project.action_view_tasks()
proj.RegisterMethod("action_view_tasks", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
@@ -264,7 +391,17 @@ func initProjectTaskExtension() {
task := orm.ExtendModel("project.task")
// Note: parent_id, child_ids, milestone_id, tag_ids, depend_ids already exist
// Note: planned_date_start, planned_date_end are defined in project.go
task.AddFields(
orm.One2many("checklist_ids", "project.task.checklist", "task_id", orm.FieldOpts{
String: "Checklist",
}),
orm.Integer("checklist_count", orm.FieldOpts{
String: "Checklist Items", Compute: "_compute_checklist_count",
}),
orm.Float("checklist_progress", orm.FieldOpts{
String: "Checklist Progress (%)", Compute: "_compute_checklist_progress",
}),
orm.Float("planned_hours", orm.FieldOpts{String: "Initially Planned Hours"}),
orm.Float("effective_hours", orm.FieldOpts{
String: "Hours Spent", Compute: "_compute_effective_hours",
@@ -524,6 +661,263 @@ func initProjectTaskExtension() {
}
return true, nil
})
// -- _compute_checklist_count --
task.RegisterCompute("checklist_count", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
taskID := rs.IDs()[0]
var count int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM project_task_checklist WHERE task_id = $1`, taskID).Scan(&count)
return orm.Values{"checklist_count": count}, nil
})
// -- _compute_checklist_progress --
task.RegisterCompute("checklist_progress", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
taskID := rs.IDs()[0]
var total, done int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM project_task_checklist WHERE task_id = $1`, taskID).Scan(&total)
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM project_task_checklist WHERE task_id = $1 AND is_done = true`, taskID).Scan(&done)
pct := float64(0)
if total > 0 {
pct = float64(done) / float64(total) * 100
}
return orm.Values{"checklist_progress": pct}, nil
})
// action_schedule_task: Create a calendar.event from task dates.
// Mirrors: odoo/addons/project/models/project_task.py Task.action_schedule_task()
task.RegisterMethod("action_schedule_task", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
taskID := rs.IDs()[0]
var name string
var plannedStart, plannedEnd *time.Time
var deadline *time.Time
var projectID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT name, planned_date_start, planned_date_end, date_deadline, project_id
FROM project_task WHERE id = $1`, taskID).Scan(&name, &plannedStart, &plannedEnd, &deadline, &projectID)
// Determine start/stop for the calendar event
now := time.Now()
start := now
stop := now.Add(time.Hour)
if plannedStart != nil {
start = *plannedStart
}
if plannedEnd != nil {
stop = *plannedEnd
} else if deadline != nil {
stop = *deadline
}
// Ensure stop is after start
if !stop.After(start) {
stop = start.Add(time.Hour)
}
var eventID int64
err := env.Tx().QueryRow(env.Ctx(),
`INSERT INTO calendar_event (name, start, stop, user_id, active, state, create_uid, create_date, write_uid, write_date)
VALUES ($1, $2, $3, $4, true, 'draft', $4, NOW(), $4, NOW())
RETURNING id`,
fmt.Sprintf("[Task] %s", name), start, stop, env.UID()).Scan(&eventID)
if err != nil {
return nil, fmt.Errorf("project.task: schedule %d: %w", taskID, err)
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": "calendar.event",
"res_id": eventID,
"view_mode": "form",
"target": "current",
"name": "Scheduled Event",
}, nil
})
// _compute_critical_path: Find tasks with dependencies that determine the longest path.
// Mirrors: odoo/addons/project/models/project_task.py Task._compute_critical_path()
// Returns the critical path as a JSON array of task IDs for the project.
task.RegisterMethod("_compute_critical_path", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
taskID := rs.IDs()[0]
// Get the project_id for this task
var projectID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT project_id FROM project_task WHERE id = $1`, taskID).Scan(&projectID)
if projectID == nil {
return map[string]interface{}{"critical_path": []int64{}, "longest_duration": float64(0)}, nil
}
// Load all tasks and dependencies for this project
type taskNode struct {
ID int64
PlannedHours float64
DependIDs []int64
}
taskRows, err := env.Tx().Query(env.Ctx(),
`SELECT id, COALESCE(planned_hours, 0) FROM project_task
WHERE project_id = $1 AND active = true`, *projectID)
if err != nil {
return nil, fmt.Errorf("critical_path: query tasks: %w", err)
}
defer taskRows.Close()
nodes := make(map[int64]*taskNode)
for taskRows.Next() {
var n taskNode
if err := taskRows.Scan(&n.ID, &n.PlannedHours); err != nil {
continue
}
nodes[n.ID] = &n
}
// Load dependencies (task depends on depend_id, meaning depend_id must finish first)
depRows, err := env.Tx().Query(env.Ctx(),
`SELECT pt.id, rel.project_task_id2
FROM project_task pt
JOIN project_task_project_task_rel rel ON rel.project_task_id1 = pt.id
WHERE pt.project_id = $1 AND pt.active = true`, *projectID)
if err != nil {
return nil, fmt.Errorf("critical_path: query deps: %w", err)
}
defer depRows.Close()
for depRows.Next() {
var tid, depID int64
if err := depRows.Scan(&tid, &depID); err != nil {
continue
}
if n, ok := nodes[tid]; ok {
n.DependIDs = append(n.DependIDs, depID)
}
}
// Compute longest path using dynamic programming (topological order)
// dist[id] = longest path ending at id
dist := make(map[int64]float64)
prev := make(map[int64]int64)
var visited map[int64]bool
var dfs func(id int64) float64
visited = make(map[int64]bool)
var inStack map[int64]bool
inStack = make(map[int64]bool)
dfs = func(id int64) float64 {
if v, ok := dist[id]; ok && visited[id] {
return v
}
visited[id] = true
inStack[id] = true
node := nodes[id]
if node == nil {
dist[id] = 0
inStack[id] = false
return 0
}
maxPredDist := float64(0)
bestPred := int64(0)
for _, depID := range node.DependIDs {
if inStack[depID] {
continue // skip circular dependencies
}
d := dfs(depID)
if d > maxPredDist {
maxPredDist = d
bestPred = depID
}
}
dist[id] = maxPredDist + node.PlannedHours
if bestPred > 0 {
prev[id] = bestPred
}
inStack[id] = false
return dist[id]
}
// Compute distances for all tasks
for id := range nodes {
if !visited[id] {
dfs(id)
}
}
// Find the task with the longest path
var maxDist float64
var endTaskID int64
for id, d := range dist {
if d > maxDist {
maxDist = d
endTaskID = id
}
}
// Reconstruct the critical path
var path []int64
for cur := endTaskID; cur != 0; cur = prev[cur] {
path = append([]int64{cur}, path...)
if _, ok := prev[cur]; !ok {
break
}
}
return map[string]interface{}{
"critical_path": path,
"longest_duration": maxDist,
}, nil
})
// _check_task_dependencies: Validate no circular dependencies in depend_ids.
// Mirrors: odoo/addons/project/models/project_task.py Task._check_task_dependencies()
task.AddConstraint(func(rs *orm.Recordset) error {
env := rs.Env()
for _, taskID := range rs.IDs() {
// BFS/DFS to detect cycles starting from this task
visited := make(map[int64]bool)
queue := []int64{taskID}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
// Get dependencies of current task
rows, err := env.Tx().Query(env.Ctx(),
`SELECT project_task_id2 FROM project_task_project_task_rel
WHERE project_task_id1 = $1`, current)
if err != nil {
continue
}
for rows.Next() {
var depID int64
if err := rows.Scan(&depID); err != nil {
continue
}
if depID == taskID {
rows.Close()
return fmt.Errorf("circular dependency detected: task %d depends on itself through task %d", taskID, current)
}
if !visited[depID] {
visited[depID] = true
queue = append(queue, depID)
}
}
rows.Close()
}
}
return nil
})
}
// initProjectMilestoneExtension extends project.milestone with additional fields.
@@ -623,6 +1017,169 @@ func initProjectTaskRecurrence() {
)
}
// initProjectTaskRecurrenceExtension extends project.task.recurrence with the
// _generate_recurrence_moves method that creates new task copies.
// Mirrors: odoo/addons/project/models/project_task_recurrence.py _generate_recurrence_moves()
func initProjectTaskRecurrenceExtension() {
rec := orm.ExtendModel("project.task.recurrence")
rec.RegisterMethod("_generate_recurrence_moves", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
var createdIDs []int64
for _, recID := range rs.IDs() {
// Read recurrence config
var repeatInterval string
var repeatNumber int
var repeatType string
var repeatUntil *time.Time
var recurrenceLeft int
var nextDate *time.Time
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(repeat_interval, 'weekly'), COALESCE(repeat_number, 1),
COALESCE(repeat_type, 'forever'), repeat_until,
COALESCE(recurrence_left, 0), next_recurrence_date
FROM project_task_recurrence WHERE id = $1`, recID).Scan(
&repeatInterval, &repeatNumber, &repeatType,
&repeatUntil, &recurrenceLeft, &nextDate)
// Check if we should generate
now := time.Now()
if nextDate != nil && nextDate.After(now) {
continue // Not yet time
}
if repeatType == "after" && recurrenceLeft <= 0 {
continue // No repetitions left
}
if repeatType == "until" && repeatUntil != nil && now.After(*repeatUntil) {
continue // Past end date
}
// Get template tasks (the original tasks linked to this recurrence)
taskRows, err := env.Tx().Query(env.Ctx(),
`SELECT pt.id, pt.name, pt.project_id, pt.stage_id, pt.priority,
pt.company_id, pt.planned_hours, pt.description, pt.partner_id
FROM project_task pt
JOIN project_task_recurrence_project_task_rel rel ON rel.project_task_id = pt.id
WHERE rel.project_task_recurrence_id = $1
LIMIT 10`, recID)
if err != nil {
continue
}
type templateTask struct {
ID, ProjectID, StageID, CompanyID, PartnerID int64
Name, Priority string
PlannedHours float64
Description *string
}
var templates []templateTask
for taskRows.Next() {
var t templateTask
var projID, stageID, compID, partnerID *int64
var desc *string
if err := taskRows.Scan(&t.ID, &t.Name, &projID, &stageID,
&t.Priority, &compID, &t.PlannedHours, &desc, &partnerID); err != nil {
continue
}
if projID != nil {
t.ProjectID = *projID
}
if stageID != nil {
t.StageID = *stageID
}
if compID != nil {
t.CompanyID = *compID
}
if partnerID != nil {
t.PartnerID = *partnerID
}
t.Description = desc
templates = append(templates, t)
}
taskRows.Close()
// Create copies of each template task
for _, t := range templates {
var newID int64
err := env.Tx().QueryRow(env.Ctx(),
`INSERT INTO project_task
(name, project_id, stage_id, priority, company_id, planned_hours,
description, partner_id, state, active, recurring_task, sequence,
create_uid, create_date, write_uid, write_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'open', true, true, 10,
$9, NOW(), $9, NOW())
RETURNING id`,
fmt.Sprintf("%s (copy)", t.Name),
nilIfZero(t.ProjectID), nilIfZero(t.StageID),
t.Priority, nilIfZero(t.CompanyID),
t.PlannedHours, t.Description,
nilIfZero(t.PartnerID), env.UID()).Scan(&newID)
if err != nil {
continue
}
createdIDs = append(createdIDs, newID)
// Copy assignees from original task
env.Tx().Exec(env.Ctx(),
`INSERT INTO project_task_res_users_rel (project_task_id, res_users_id)
SELECT $1, res_users_id FROM project_task_res_users_rel
WHERE project_task_id = $2`, newID, t.ID)
// Copy tags from original task
env.Tx().Exec(env.Ctx(),
`INSERT INTO project_task_project_tags_rel (project_task_id, project_tags_id)
SELECT $1, project_tags_id FROM project_task_project_tags_rel
WHERE project_task_id = $2`, newID, t.ID)
}
// Compute next recurrence date
base := now
if nextDate != nil {
base = *nextDate
}
var nextRecDate time.Time
switch repeatInterval {
case "daily":
nextRecDate = base.AddDate(0, 0, repeatNumber)
case "weekly":
nextRecDate = base.AddDate(0, 0, 7*repeatNumber)
case "monthly":
nextRecDate = base.AddDate(0, repeatNumber, 0)
case "yearly":
nextRecDate = base.AddDate(repeatNumber, 0, 0)
default:
nextRecDate = base.AddDate(0, 0, 7)
}
// Update recurrence record
newLeft := recurrenceLeft - 1
if newLeft < 0 {
newLeft = 0
}
env.Tx().Exec(env.Ctx(),
`UPDATE project_task_recurrence
SET next_recurrence_date = $1, recurrence_left = $2,
write_date = NOW(), write_uid = $3
WHERE id = $4`, nextRecDate, newLeft, env.UID(), recID)
}
return map[string]interface{}{
"created_task_ids": createdIDs,
"count": len(createdIDs),
}, nil
})
}
// nilIfZero returns nil if v is 0, otherwise returns v. Used for nullable FK inserts.
func nilIfZero(v int64) interface{} {
if v == 0 {
return nil
}
return v
}
// initProjectSharingWizard registers a wizard for sharing projects with external users.
// Mirrors: odoo/addons/project/wizard/project_share_wizard.py
func initProjectSharingWizard() {

View File

@@ -198,6 +198,94 @@ func initTimesheetReport() {
}, nil
})
// get_timesheet_pivot_data: Pivot data by employee x task with date breakdown.
// Returns rows grouped by employee, columns grouped by task, cells contain hours per date period.
// Mirrors: odoo/addons/hr_timesheet/report/hr_timesheet_report.py pivot view
m.RegisterMethod("get_timesheet_pivot_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
// Query: employee x task x week, with hours
rows, err := env.Tx().Query(env.Ctx(), `
SELECT COALESCE(he.name, 'Unknown') AS employee,
COALESCE(pt.name, 'No Task') AS task,
date_trunc('week', aal.date) AS week,
SUM(aal.unit_amount) AS hours
FROM account_analytic_line aal
LEFT JOIN hr_employee he ON he.id = aal.employee_id
LEFT JOIN project_task pt ON pt.id = aal.task_id
WHERE aal.project_id IS NOT NULL
GROUP BY he.name, pt.name, date_trunc('week', aal.date)
ORDER BY he.name, pt.name, week
LIMIT 500`)
if err != nil {
return nil, fmt.Errorf("timesheet_report: pivot query: %w", err)
}
defer rows.Close()
// Build pivot structure: { rows: [{employee, task, dates: [{week, hours}]}] }
type pivotCell struct {
Week string `json:"week"`
Hours float64 `json:"hours"`
}
type pivotRow struct {
Employee string `json:"employee"`
Task string `json:"task"`
Dates []pivotCell `json:"dates"`
Total float64 `json:"total"`
}
rowMap := make(map[string]*pivotRow) // key = "employee|task"
var allWeeks []string
weekSet := make(map[string]bool)
for rows.Next() {
var employee, task string
var week time.Time
var hours float64
if err := rows.Scan(&employee, &task, &week, &hours); err != nil {
continue
}
weekStr := week.Format("2006-01-02")
key := employee + "|" + task
r, ok := rowMap[key]
if !ok {
r = &pivotRow{Employee: employee, Task: task}
rowMap[key] = r
}
r.Dates = append(r.Dates, pivotCell{Week: weekStr, Hours: hours})
r.Total += hours
if !weekSet[weekStr] {
weekSet[weekStr] = true
allWeeks = append(allWeeks, weekStr)
}
}
var pivotRows []pivotRow
for _, r := range rowMap {
pivotRows = append(pivotRows, *r)
}
// Compute totals per employee
empTotals := make(map[string]float64)
for _, r := range pivotRows {
empTotals[r.Employee] += r.Total
}
var empSummary []map[string]interface{}
for emp, total := range empTotals {
empSummary = append(empSummary, map[string]interface{}{
"employee": emp,
"total": total,
})
}
return map[string]interface{}{
"pivot_rows": pivotRows,
"weeks": allWeeks,
"employee_totals": empSummary,
}, nil
})
// get_timesheet_by_week: Weekly breakdown of timesheet hours.
m.RegisterMethod("get_timesheet_by_week", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()

View File

@@ -1,12 +1,15 @@
package models
func Init() {
initPurchaseOrder()
initPurchaseOrderLine()
initPurchaseOrder() // also calls initPurchaseOrderLine()
initPurchaseAgreement()
initPurchaseReport()
initProductSupplierInfo()
initAccountMoveLinePurchaseExtension()
initPurchaseOrderExtension()
initPurchaseOrderWorkflow()
initPurchaseOrderLineExtension()
initResPartnerPurchaseExtension()
initPurchaseOrderAmount()
initVendorLeadTime()
}

View File

@@ -1,6 +1,10 @@
package models
import "odoo-go/pkg/orm"
import (
"fmt"
"odoo-go/pkg/orm"
)
// initPurchaseAgreement registers purchase.requisition and purchase.requisition.line.
// Mirrors: odoo/addons/purchase_requisition/models/purchase_requisition.py
@@ -35,28 +39,32 @@ func initPurchaseAgreement() {
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE purchase_requisition SET state = 'ongoing' WHERE id = $1 AND state = 'draft'`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE purchase_requisition SET state = 'ongoing' WHERE id = $1 AND state = 'draft'`, id); err != nil {
return nil, fmt.Errorf("purchase.requisition: confirm %d: %w", id, err)
}
}
return true, nil
})
// action_done: close the agreement
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE purchase_requisition SET state = 'done' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE purchase_requisition SET state = 'done' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("purchase.requisition: done %d: %w", id, err)
}
}
return true, nil
})
// action_cancel
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE purchase_requisition SET state = 'cancel' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE purchase_requisition SET state = 'cancel' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("purchase.requisition: cancel %d: %w", id, err)
}
}
return true, nil
})

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,13 @@ func initPurchaseOrder() {
}),
)
// -- Agreement Link --
m.AddFields(
orm.Many2one("requisition_id", "purchase.requisition", orm.FieldOpts{
String: "Purchase Agreement",
}),
)
// -- Company & Currency --
m.AddFields(
orm.Many2one("company_id", "res.company", orm.FieldOpts{
@@ -102,6 +109,12 @@ func initPurchaseOrder() {
}),
)
// -- Vendor Reference & Lock --
m.AddFields(
orm.Char("partner_ref", orm.FieldOpts{String: "Vendor Reference"}),
orm.Boolean("locked", orm.FieldOpts{String: "Locked", Default: false}),
)
// -- Notes --
m.AddFields(
orm.Text("notes", orm.FieldOpts{String: "Terms and Conditions"}),
@@ -134,15 +147,84 @@ func initPurchaseOrder() {
return vals
}
// button_confirm: draft → purchase
// button_confirm: Validate and confirm PO. Mirrors Python PurchaseOrder.button_confirm().
// Skips orders not in draft/sent, checks order lines have products, then either
// directly approves (single-step) or sets to "to approve" (double validation).
m.RegisterMethod("button_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, poID := range rs.IDs() {
var state, name string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(state, 'draft'), COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&state, &name)
if state != "draft" && state != "sent" {
continue // skip already confirmed orders (Python does same)
}
// Validate: all non-section lines must have a product
var badLines int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM purchase_order_line
WHERE order_id = $1 AND product_id IS NULL
AND COALESCE(display_type, '') NOT IN ('line_section', 'line_note')`,
poID).Scan(&badLines)
if badLines > 0 {
return nil, fmt.Errorf("purchase: some order lines are missing a product on PO %s", name)
}
// Generate sequence if still default
if name == "" || name == "/" || name == "New" {
seq, err := orm.NextByCode(env, "purchase.order")
if err != nil {
name = fmt.Sprintf("PO/%d", time.Now().UnixNano()%100000)
} else {
name = seq
}
env.Tx().Exec(env.Ctx(),
`UPDATE purchase_order SET name = $1 WHERE id = $2`, name, poID)
}
// Double validation: check company setting
var poDoubleVal string
var companyID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(company_id, 0) FROM purchase_order WHERE id = $1`, poID).Scan(&companyID)
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(po_double_validation, 'one_step') FROM res_company WHERE id = $1`,
companyID).Scan(&poDoubleVal)
if poDoubleVal == "two_step" {
// Check if amount exceeds threshold
var amountTotal, threshold float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(amount_total::float8, 0) FROM purchase_order WHERE id = $1`, poID).Scan(&amountTotal)
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(po_double_validation_amount::float8, 0) FROM res_company WHERE id = $1`,
companyID).Scan(&threshold)
if amountTotal >= threshold {
env.Tx().Exec(env.Ctx(),
`UPDATE purchase_order SET state = 'to approve' WHERE id = $1`, poID)
continue
}
}
// Approve directly
env.Tx().Exec(env.Ctx(),
`UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, poID)
}
return true, nil
})
// button_approve: Approve a PO that is in "to approve" state → purchase.
// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_approve()
m.RegisterMethod("button_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM purchase_order WHERE id = $1`, id).Scan(&state)
if state != "draft" && state != "sent" {
return nil, fmt.Errorf("purchase: can only confirm draft orders")
if state != "to approve" {
return nil, fmt.Errorf("purchase: can only approve orders in 'to approve' state (current: %s)", state)
}
env.Tx().Exec(env.Ctx(),
`UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, id)
@@ -150,12 +232,31 @@ func initPurchaseOrder() {
return true, nil
})
// button_cancel
// button_cancel: Cancel a PO. Mirrors Python PurchaseOrder.button_cancel().
// Checks: locked orders cannot be cancelled; orders with posted bills cannot be cancelled.
m.RegisterMethod("button_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
for _, poID := range rs.IDs() {
var locked bool
var poName string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(locked, false), COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&locked, &poName)
if locked {
return nil, fmt.Errorf("purchase: cannot cancel locked order %s, unlock it first", poName)
}
// Check for non-draft/non-cancelled vendor bills
var billCount int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_move
WHERE invoice_origin = $1 AND move_type = 'in_invoice'
AND state NOT IN ('draft', 'cancel')`, poName).Scan(&billCount)
if billCount > 0 {
return nil, fmt.Errorf("purchase: cannot cancel order %s, cancel related vendor bills first", poName)
}
env.Tx().Exec(env.Ctx(),
`UPDATE purchase_order SET state = 'cancel' WHERE id = $1`, id)
`UPDATE purchase_order SET state = 'cancel' WHERE id = $1`, poID)
}
return true, nil
})
@@ -170,36 +271,51 @@ func initPurchaseOrder() {
return true, nil
})
// action_create_bill: Generate a vendor bill (account.move in_invoice) from a confirmed PO.
// action_create_bill / action_create_invoice: Generate a vendor bill from a confirmed PO.
// Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_create_invoice()
m.RegisterMethod("action_create_bill", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
// Creates account.move (in_invoice) with linked invoice lines, updates qty_invoiced,
// and writes purchase_line_id on invoice lines for proper tracking.
createBillFn := func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
var billIDs []int64
for _, poID := range rs.IDs() {
var partnerID, companyID, currencyID int64
var poName string
var fiscalPosID, paymentTermID *int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT partner_id, company_id, currency_id FROM purchase_order WHERE id = $1`,
poID).Scan(&partnerID, &companyID, &currencyID)
`SELECT partner_id, company_id, currency_id, COALESCE(name, ''),
fiscal_position_id, payment_term_id
FROM purchase_order WHERE id = $1`,
poID).Scan(&partnerID, &companyID, &currencyID, &poName, &fiscalPosID, &paymentTermID)
if err != nil {
return nil, fmt.Errorf("purchase: read PO %d for bill: %w", poID, err)
}
// Check PO state: must be in 'purchase' state
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(state, 'draft') FROM purchase_order WHERE id = $1`, poID).Scan(&state)
if state != "purchase" {
return nil, fmt.Errorf("purchase: can only create bills for confirmed purchase orders (PO %s is %s)", poName, state)
}
// Find purchase journal
var journalID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_journal WHERE type = 'purchase' AND company_id = $1 LIMIT 1`,
companyID).Scan(&journalID)
if journalID == 0 {
// Fallback: first available journal
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_journal WHERE company_id = $1 ORDER BY id LIMIT 1`,
companyID).Scan(&journalID)
}
// Read PO lines to generate invoice lines
// Read PO lines (skip section/note display types)
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id, COALESCE(name,''), COALESCE(product_qty,1), COALESCE(price_unit,0), COALESCE(discount,0)
`SELECT id, COALESCE(name,''), COALESCE(product_qty,1), COALESCE(price_unit,0),
COALESCE(discount,0), COALESCE(qty_invoiced,0), product_id,
COALESCE(display_type, '')
FROM purchase_order_line
WHERE order_id = $1 ORDER BY sequence, id`, poID)
if err != nil {
@@ -207,16 +323,20 @@ func initPurchaseOrder() {
}
type poLine struct {
id int64
name string
qty float64
price float64
discount float64
id int64
name string
qty float64
price float64
discount float64
qtyInvoiced float64
productID *int64
displayType string
}
var lines []poLine
for rows.Next() {
var l poLine
if err := rows.Scan(&l.id, &l.name, &l.qty, &l.price, &l.discount); err != nil {
if err := rows.Scan(&l.id, &l.name, &l.qty, &l.price, &l.discount,
&l.qtyInvoiced, &l.productID, &l.displayType); err != nil {
rows.Close()
return nil, err
}
@@ -224,19 +344,43 @@ func initPurchaseOrder() {
}
rows.Close()
// Filter to only lines that need invoicing
var invoiceableLines []poLine
for _, l := range lines {
if l.displayType == "line_section" || l.displayType == "line_note" {
continue
}
qtyToInvoice := l.qty - l.qtyInvoiced
if qtyToInvoice > 0 {
invoiceableLines = append(invoiceableLines, l)
}
}
if len(invoiceableLines) == 0 {
continue // nothing to invoice on this PO
}
// Determine invoice_origin
invoiceOrigin := poName
if invoiceOrigin == "" {
invoiceOrigin = fmt.Sprintf("PO%d", poID)
}
// Create the vendor bill
var billID int64
err = env.Tx().QueryRow(env.Ctx(),
`INSERT INTO account_move
(name, move_type, state, date, partner_id, journal_id, company_id, currency_id, invoice_origin)
VALUES ('/', 'in_invoice', 'draft', NOW(), $1, $2, $3, $4, $5) RETURNING id`,
partnerID, journalID, companyID, currencyID,
fmt.Sprintf("PO%d", poID)).Scan(&billID)
(name, move_type, state, date, partner_id, journal_id, company_id,
currency_id, invoice_origin, fiscal_position_id, invoice_payment_term_id)
VALUES ('/', 'in_invoice', 'draft', NOW(), $1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
partnerID, journalID, companyID, currencyID, invoiceOrigin,
fiscalPosID, paymentTermID).Scan(&billID)
if err != nil {
return nil, fmt.Errorf("purchase: create bill for PO %d: %w", poID, err)
}
// Try to generate a proper sequence name
// Generate sequence name
seq, seqErr := orm.NextByCode(env, "account.move.in_invoice")
if seqErr != nil {
seq, seqErr = orm.NextByCode(env, "account.move")
@@ -246,37 +390,58 @@ func initPurchaseOrder() {
`UPDATE account_move SET name = $1 WHERE id = $2`, seq, billID)
}
// Create invoice lines for each PO line
for _, l := range lines {
subtotal := l.qty * l.price * (1 - l.discount/100)
// Create invoice lines for each invoiceable PO line
seq2 := 10
for _, l := range invoiceableLines {
qtyToInvoice := l.qty - l.qtyInvoiced
subtotal := qtyToInvoice * l.price * (1 - l.discount/100)
env.Tx().Exec(env.Ctx(),
`INSERT INTO account_move_line
(move_id, name, quantity, price_unit, discount, debit, credit, balance,
display_type, company_id, journal_id, account_id)
VALUES ($1, $2, $3, $4, $5, $6, 0, $6, 'product', $7, $8,
COALESCE((SELECT id FROM account_account WHERE company_id = $7 LIMIT 1), 1))`,
billID, l.name, l.qty, l.price, l.discount, subtotal,
companyID, journalID)
display_type, company_id, journal_id, sequence, purchase_line_id, product_id,
account_id)
VALUES ($1, $2, $3, $4, $5, $6, 0, $6, 'product', $7, $8, $9, $10, $11,
COALESCE((SELECT id FROM account_account
WHERE company_id = $7 AND account_type = 'expense' LIMIT 1),
(SELECT id FROM account_account WHERE company_id = $7 LIMIT 1), 1))`,
billID, l.name, qtyToInvoice, l.price, l.discount, subtotal,
companyID, journalID, seq2, l.id, l.productID)
seq2 += 10
}
// Update qty_invoiced on PO lines
for _, l := range lines {
for _, l := range invoiceableLines {
qtyToInvoice := l.qty - l.qtyInvoiced
env.Tx().Exec(env.Ctx(),
`UPDATE purchase_order_line SET qty_invoiced = COALESCE(qty_invoiced, 0) + $1 WHERE id = $2`,
l.qty, l.id)
qtyToInvoice, l.id)
}
billIDs = append(billIDs, billID)
// Update PO invoice_status
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE purchase_order SET invoice_status = 'invoiced' WHERE id = $1`, poID)
if err != nil {
return nil, fmt.Errorf("purchase: update invoice status for PO %d: %w", poID, err)
// Recompute PO invoice_status based on lines
var totalQty, totalInvoiced float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(product_qty), 0), COALESCE(SUM(qty_invoiced), 0)
FROM purchase_order_line WHERE order_id = $1
AND COALESCE(display_type, '') NOT IN ('line_section', 'line_note')`,
poID).Scan(&totalQty, &totalInvoiced)
invStatus := "no"
if totalQty > 0 {
if totalInvoiced >= totalQty {
invStatus = "invoiced"
} else {
invStatus = "to invoice"
}
}
env.Tx().Exec(env.Ctx(),
`UPDATE purchase_order SET invoice_status = $1 WHERE id = $2`, invStatus, poID)
}
return billIDs, nil
})
}
m.RegisterMethod("action_create_bill", createBillFn)
// action_create_invoice: Python-standard name for the same operation.
m.RegisterMethod("action_create_invoice", createBillFn)
// BeforeCreate: auto-assign sequence number
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
@@ -293,6 +458,11 @@ func initPurchaseOrder() {
return nil
}
// -- BeforeWrite Hook: Prevent modifications on locked/cancelled orders --
m.BeforeWrite = orm.StateGuard("purchase_order", "state IN ('done', 'cancel')",
[]string{"write_uid", "write_date", "message_partner_ids_count", "locked"},
"cannot modify locked/cancelled orders")
// purchase.order.line — individual line items on a PO
initPurchaseOrderLine()
}
@@ -333,6 +503,9 @@ func initPurchaseOrderLine() {
orm.Monetary("price_subtotal", orm.FieldOpts{
String: "Subtotal", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
orm.Float("price_tax", orm.FieldOpts{
String: "Tax", Compute: "_compute_amount", Store: true,
}),
orm.Monetary("price_total", orm.FieldOpts{
String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
@@ -341,6 +514,17 @@ func initPurchaseOrderLine() {
}),
)
// -- Invoice Lines & Display --
m.AddFields(
orm.One2many("invoice_lines", "account.move.line", "purchase_line_id", orm.FieldOpts{
String: "Bill Lines", Readonly: true,
}),
orm.Selection("display_type", []orm.SelectionItem{
{Value: "line_section", Label: "Section"},
{Value: "line_note", Label: "Note"},
}, orm.FieldOpts{String: "Display Type", Default: ""}),
)
// -- Dates --
m.AddFields(
orm.Datetime("date_planned", orm.FieldOpts{String: "Expected Arrival"}),

View File

@@ -10,6 +10,7 @@ func Init() {
initSaleOrderTemplate()
initSaleOrderTemplateLine()
initSaleOrderTemplateOption()
initSaleOrderOption()
initSaleReport()
initSaleOrderWarnMsg()
initSaleAdvancePaymentWizard()

View File

@@ -24,6 +24,7 @@ func initSaleOrder() {
{Value: "draft", Label: "Quotation"},
{Value: "sent", Label: "Quotation Sent"},
{Value: "sale", Label: "Sales Order"},
{Value: "done", Label: "Locked"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Readonly: true, Index: true}),
)
@@ -253,26 +254,82 @@ func initSaleOrder() {
return nil
}
// -- BeforeWrite Hook: Prevent modifications on locked/cancelled orders --
m.BeforeWrite = orm.StateGuard("sale_order", "state IN ('done', 'cancel')",
[]string{"write_uid", "write_date", "message_partner_ids_count"},
"cannot modify locked/cancelled orders")
// -- Business Methods --
// action_confirm: draft → sale
// Validates required fields, generates sequence number, sets date_order,
// creates stock picking for physical products if stock module is loaded.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_confirm()
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
var state string
var state, name string
var partnerID int64
var dateOrder *time.Time
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM sale_order WHERE id = $1`, id).Scan(&state)
`SELECT state, COALESCE(name, '/'), COALESCE(partner_id, 0), date_order
FROM sale_order WHERE id = $1`, id,
).Scan(&state, &name, &partnerID, &dateOrder)
if err != nil {
return nil, err
}
if state != "draft" && state != "sent" {
return nil, fmt.Errorf("sale: can only confirm draft/sent orders (current: %s)", state)
}
// Validate required fields
if partnerID == 0 {
return nil, fmt.Errorf("sale: cannot confirm order %s without a customer", name)
}
var lineCount int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
id).Scan(&lineCount)
if lineCount == 0 {
return nil, fmt.Errorf("sale: cannot confirm order %s without order lines", name)
}
// Generate sequence number if still default
if name == "/" || name == "" {
seq, seqErr := orm.NextByCode(env, "sale.order")
if seqErr != nil {
name = fmt.Sprintf("SO/%d", time.Now().UnixNano()%100000)
} else {
name = seq
}
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET name = $1 WHERE id = $2`, name, id)
}
// Set date_order if not set
if dateOrder == nil {
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET date_order = NOW() WHERE id = $1`, id)
}
// Confirm the order
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET state = 'sale' WHERE id = $1`, id); err != nil {
return nil, err
}
// Create stock picking for physical products if stock module is loaded
if stockModel := orm.Registry.Get("stock.picking"); stockModel != nil {
soRS := env.Model("sale.order").Browse(id)
soModel := orm.Registry.Get("sale.order")
if fn, ok := soModel.Methods["action_create_delivery"]; ok {
if _, err := fn(soRS); err != nil {
// Log but don't fail confirmation if delivery creation fails
fmt.Printf("sale: warning: could not create delivery for SO %d: %v\n", id, err)
}
}
}
}
return true, nil
})
@@ -305,7 +362,7 @@ func initSaleOrder() {
).Scan(&journalID)
}
if journalID == 0 {
journalID = 1 // ultimate fallback
return nil, fmt.Errorf("sale: no sales journal found for company %d", companyID)
}
// Read SO lines
@@ -431,11 +488,17 @@ func initSaleOrder() {
"credit": baseAmount,
"balance": -baseAmount,
}
if _, err := lineRS.Create(productLineVals); err != nil {
invLine, err := lineRS.Create(productLineVals)
if err != nil {
return nil, fmt.Errorf("sale: create invoice product line: %w", err)
}
totalCredit += baseAmount
// Link SO line to invoice line via M2M
env.Tx().Exec(env.Ctx(),
`INSERT INTO sale_order_line_invoice_rel (order_line_id, invoice_line_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING`, line.id, invLine.ID())
// Look up taxes from SO line's tax_id M2M and compute tax lines
taxRows, err := env.Tx().Query(env.Ctx(),
`SELECT t.id, t.name, t.amount, t.amount_type, COALESCE(t.price_include, false)
@@ -549,9 +612,19 @@ func initSaleOrder() {
line.qty, line.id)
}
// Update SO invoice_status
// Recompute invoice_status based on actual qty_invoiced vs qty
var totalQty, totalInvoiced float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(product_uom_qty), 0), COALESCE(SUM(qty_invoiced), 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
soID).Scan(&totalQty, &totalInvoiced)
invStatus := "to invoice"
if totalQty > 0 && totalInvoiced >= totalQty {
invStatus = "invoiced"
}
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET invoice_status = 'invoiced' WHERE id = $1`, soID)
`UPDATE sale_order SET invoice_status = $1 WHERE id = $2`, invStatus, soID)
}
if len(invoiceIDs) == 0 {
@@ -613,6 +686,16 @@ func initSaleOrder() {
return true, nil
})
// action_done: Lock a confirmed sale order (state → done).
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_done()
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, soID := range rs.IDs() {
env.Tx().Exec(env.Ctx(), `UPDATE sale_order SET state = 'done' WHERE id = $1 AND state = 'sale'`, soID)
}
return true, nil
})
// action_view_invoice: Open invoices linked to this sale order.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_view_invoice()
m.RegisterMethod("action_view_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
@@ -959,11 +1042,24 @@ func initSaleOrderLine() {
orm.Monetary("price_subtotal", orm.FieldOpts{
String: "Subtotal", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
orm.Float("price_tax", orm.FieldOpts{
String: "Total Tax", Compute: "_compute_amount", Store: true,
}),
orm.Monetary("price_total", orm.FieldOpts{
String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
)
// -- Invoice link --
m.AddFields(
orm.Many2many("invoice_line_ids", "account.move.line", orm.FieldOpts{
String: "Invoice Lines",
Relation: "sale_order_line_invoice_rel",
Column1: "order_line_id",
Column2: "invoice_line_id",
}),
)
// -- Display --
m.AddFields(
orm.Selection("display_type", []orm.SelectionItem{
@@ -1023,10 +1119,12 @@ func initSaleOrderLine() {
return orm.Values{
"price_subtotal": subtotal,
"price_tax": taxTotal,
"price_total": subtotal + taxTotal,
}, nil
}
m.RegisterCompute("price_subtotal", computeLineAmount)
m.RegisterCompute("price_tax", computeLineAmount)
m.RegisterCompute("price_total", computeLineAmount)
// -- Delivery & Invoicing Quantities --

View File

@@ -1,10 +1,14 @@
package models
import (
"encoding/json"
"fmt"
"html"
"log"
"time"
"odoo-go/pkg/orm"
"odoo-go/pkg/tools"
)
// initSaleOrderExtension extends sale.order with template support, additional workflow
@@ -19,6 +23,9 @@ func initSaleOrderExtension() {
orm.Many2one("sale_order_template_id", "sale.order.template", orm.FieldOpts{
String: "Quotation Template",
}),
orm.One2many("sale_order_option_ids", "sale.order.option", "order_id", orm.FieldOpts{
String: "Optional Products",
}),
orm.Boolean("is_expired", orm.FieldOpts{
String: "Expired", Compute: "_compute_is_expired",
}),
@@ -52,6 +59,132 @@ func initSaleOrderExtension() {
return orm.Values{"is_expired": expired}, nil
})
// -- Amounts: amount_to_invoice, amount_invoiced --
so.AddFields(
orm.Monetary("amount_to_invoice", orm.FieldOpts{
String: "Un-invoiced Balance", Compute: "_compute_amount_to_invoice", CurrencyField: "currency_id",
}),
orm.Monetary("amount_invoiced", orm.FieldOpts{
String: "Already Invoiced", Compute: "_compute_amount_invoiced", CurrencyField: "currency_id",
}),
)
// _compute_amount_invoiced: Sum of invoiced amounts across order lines.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amount_invoiced()
so.RegisterCompute("amount_invoiced", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var invoiced float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(
COALESCE(qty_invoiced, 0) * COALESCE(price_unit, 0)
* (1 - COALESCE(discount, 0) / 100)
)::float8, 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
soID).Scan(&invoiced)
return orm.Values{"amount_invoiced": invoiced}, nil
})
// _compute_amount_to_invoice: Total minus already invoiced.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amount_to_invoice()
so.RegisterCompute("amount_to_invoice", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var total, invoiced float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(price_subtotal)::float8, 0),
COALESCE(SUM(
COALESCE(qty_invoiced, 0) * COALESCE(price_unit, 0)
* (1 - COALESCE(discount, 0) / 100)
)::float8, 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
soID).Scan(&total, &invoiced)
result := total - invoiced
if result < 0 {
result = 0
}
return orm.Values{"amount_to_invoice": result}, nil
})
// -- type_name: "Quotation" vs "Sales Order" based on state --
so.AddFields(
orm.Char("type_name", orm.FieldOpts{
String: "Type Name", Compute: "_compute_type_name",
}),
)
// _compute_type_name
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_type_name()
so.RegisterCompute("type_name", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(state, 'draft') FROM sale_order WHERE id = $1`, soID).Scan(&state)
typeName := "Sales Order"
if state == "draft" || state == "sent" || state == "cancel" {
typeName = "Quotation"
}
return orm.Values{"type_name": typeName}, nil
})
// -- delivery_status: nothing/partial/full based on related pickings --
so.AddFields(
orm.Selection("delivery_status", []orm.SelectionItem{
{Value: "nothing", Label: "Nothing Delivered"},
{Value: "partial", Label: "Partially Delivered"},
{Value: "full", Label: "Fully Delivered"},
}, orm.FieldOpts{String: "Delivery Status", Compute: "_compute_delivery_status"}),
)
// _compute_delivery_status
// Mirrors: odoo/addons/sale_stock/models/sale_order.py SaleOrder._compute_delivery_status()
so.RegisterCompute("delivery_status", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(state, 'draft') FROM sale_order WHERE id = $1`, soID).Scan(&state)
// Only compute for confirmed orders
if state != "sale" && state != "done" {
return orm.Values{"delivery_status": "nothing"}, nil
}
// Check line quantities: total ordered vs total delivered
var totalOrdered, totalDelivered float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(product_uom_qty), 0), COALESCE(SUM(qty_delivered), 0)
FROM sale_order_line WHERE order_id = $1
AND product_id IS NOT NULL
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
soID).Scan(&totalOrdered, &totalDelivered)
status := "nothing"
if totalOrdered > 0 {
if totalDelivered >= totalOrdered {
status = "full"
} else if totalDelivered > 0 {
status = "partial"
}
}
return orm.Values{"delivery_status": status}, nil
})
// preview_sale_order: Return URL action for customer portal preview (Python method name).
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.preview_sale_order()
so.RegisterMethod("preview_sale_order", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
soID := rs.IDs()[0]
return map[string]interface{}{
"type": "ir.actions.act_url",
"url": fmt.Sprintf("/my/orders/%d", soID),
"target": "new",
}, nil
})
// -- Computed: _compute_invoice_status (extends the base) --
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_invoice_status()
so.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) {
@@ -201,19 +334,238 @@ func initSaleOrderExtension() {
return nil, nil
})
// _compute_amount_to_invoice: Compute total amount still to invoice.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amounts()
so.RegisterMethod("_compute_amount_to_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
// Note: amount_to_invoice compute is already registered above (line ~90)
// ── Feature 1: action_quotation_send ──────────────────────────────────
// Sends a quotation email to the customer with SO details, then marks state as 'sent'.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_quotation_send()
so.RegisterMethod("action_quotation_send", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, soID := range rs.IDs() {
// Read SO header
var soName, partnerEmail, partnerName, state string
var amountTotal float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(so.name, ''), COALESCE(p.email, ''), COALESCE(p.name, ''),
COALESCE(so.state, 'draft'), COALESCE(so.amount_total::float8, 0)
FROM sale_order so
JOIN res_partner p ON p.id = so.partner_id
WHERE so.id = $1`, soID,
).Scan(&soName, &partnerEmail, &partnerName, &state, &amountTotal)
if err != nil {
return nil, fmt.Errorf("sale: read SO %d for email: %w", soID, err)
}
if partnerEmail == "" {
log.Printf("sale: action_quotation_send: no email for partner on SO %d, skipping", soID)
continue
}
// Read order lines for the email body
lineRows, err := env.Tx().Query(env.Ctx(),
`SELECT COALESCE(name, ''), COALESCE(product_uom_qty, 0),
COALESCE(price_unit, 0), COALESCE(discount, 0),
COALESCE(price_subtotal::float8, 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')
ORDER BY sequence, id`, soID)
if err != nil {
return nil, fmt.Errorf("sale: read SO lines for email SO %d: %w", soID, err)
}
var linesHTML string
for lineRows.Next() {
var lName string
var lQty, lPrice, lDiscount, lSubtotal float64
if err := lineRows.Scan(&lName, &lQty, &lPrice, &lDiscount, &lSubtotal); err != nil {
lineRows.Close()
break
}
linesHTML += fmt.Sprintf(
"<tr><td>%s</td><td style=\"text-align:right\">%.2f</td>"+
"<td style=\"text-align:right\">%.2f</td>"+
"<td style=\"text-align:right\">%.1f%%</td>"+
"<td style=\"text-align:right\">%.2f</td></tr>",
htmlEscapeStr(lName), lQty, lPrice, lDiscount, lSubtotal)
}
lineRows.Close()
// Build HTML email body
subject := fmt.Sprintf("Quotation %s", soName)
partnerNameEsc := htmlEscapeStr(partnerName)
soNameEsc := htmlEscapeStr(soName)
body := fmt.Sprintf(`<div style="font-family:Arial,sans-serif;max-width:600px">
<h2>%s</h2>
<p>Dear %s,</p>
<p>Please find below your quotation <strong>%s</strong>.</p>
<table style="width:100%%;border-collapse:collapse" border="1" cellpadding="5">
<thead><tr>
<th>Description</th><th>Qty</th><th>Unit Price</th><th>Discount</th><th>Subtotal</th>
</tr></thead>
<tbody>%s</tbody>
<tfoot><tr>
<td colspan="4" style="text-align:right"><strong>Total</strong></td>
<td style="text-align:right"><strong>%.2f</strong></td>
</tr></tfoot>
</table>
<p>Do not hesitate to contact us if you have any questions.</p>
</div>`, htmlEscapeStr(subject), partnerNameEsc, soNameEsc, linesHTML, amountTotal)
// Send email via tools.SendEmail
cfg := tools.LoadSMTPConfig()
if err := tools.SendEmail(cfg, partnerEmail, subject, body); err != nil {
log.Printf("sale: action_quotation_send: email send failed for SO %d: %v", soID, err)
}
// Mark state as 'sent' if currently draft
if state == "draft" {
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET state = 'sent' WHERE id = $1`, soID)
}
}
return true, nil
})
// ── Feature 2: _compute_amount_by_group ──────────────────────────────
// Compute tax amounts grouped by tax group. Returns JSON with group_name, tax_amount,
// base_amount per group. Similar to account.move tax_totals.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_tax_totals()
so.AddFields(
orm.Text("tax_totals_json", orm.FieldOpts{
String: "Tax Totals JSON", Compute: "_compute_amount_by_group",
}),
)
so.RegisterCompute("tax_totals_json", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var total, invoiced float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(price_subtotal::float8), 0),
COALESCE(SUM(qty_invoiced * price_unit * (1 - COALESCE(discount,0)/100))::float8, 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
soID).Scan(&total, &invoiced)
return total - invoiced, nil
rows, err := env.Tx().Query(env.Ctx(),
`SELECT COALESCE(tg.name, t.name, 'Taxes') AS group_name,
SUM(
sol.product_uom_qty * sol.price_unit * (1 - COALESCE(sol.discount,0)/100)
* COALESCE(t.amount, 0) / 100
)::float8 AS tax_amount,
SUM(
sol.product_uom_qty * sol.price_unit * (1 - COALESCE(sol.discount,0)/100)
)::float8 AS base_amount
FROM sale_order_line sol
JOIN account_tax_sale_order_line_rel rel ON rel.sale_order_line_id = sol.id
JOIN account_tax t ON t.id = rel.account_tax_id
LEFT JOIN account_tax_group tg ON tg.id = t.tax_group_id
WHERE sol.order_id = $1
AND (sol.display_type IS NULL OR sol.display_type = '' OR sol.display_type = 'product')
GROUP BY COALESCE(tg.name, t.name, 'Taxes')
ORDER BY group_name`, soID)
if err != nil {
return orm.Values{"tax_totals_json": "{}"}, nil
}
defer rows.Close()
type taxGroup struct {
GroupName string `json:"group_name"`
TaxAmount float64 `json:"tax_amount"`
BaseAmount float64 `json:"base_amount"`
}
var groups []taxGroup
var totalTax, totalBase float64
for rows.Next() {
var g taxGroup
if err := rows.Scan(&g.GroupName, &g.TaxAmount, &g.BaseAmount); err != nil {
continue
}
totalTax += g.TaxAmount
totalBase += g.BaseAmount
groups = append(groups, g)
}
result := map[string]interface{}{
"groups_by_subtotal": groups,
"amount_total": totalBase + totalTax,
"amount_untaxed": totalBase,
"amount_tax": totalTax,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return orm.Values{"tax_totals_json": "{}"}, nil
}
return orm.Values{"tax_totals_json": string(jsonBytes)}, nil
})
// ── Feature 3: action_add_option ─────────────────────────────────────
// Copies a selected sale.order.option as a new order line on this SO.
// Mirrors: odoo/addons/sale_management/models/sale_order_option.py SaleOrderOption.add_option_to_order()
so.RegisterMethod("action_add_option", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
if len(args) < 1 {
return nil, fmt.Errorf("sale: action_add_option requires option_id argument")
}
env := rs.Env()
soID := rs.IDs()[0]
// Accept option_id as float64 (JSON) or int64
var optionID int64
switch v := args[0].(type) {
case float64:
optionID = int64(v)
case int64:
optionID = v
default:
return nil, fmt.Errorf("sale: action_add_option: invalid option_id type")
}
// Read the option
var name string
var productID int64
var qty, priceUnit, discount float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, ''), COALESCE(product_id, 0), COALESCE(quantity, 1),
COALESCE(price_unit, 0), COALESCE(discount, 0)
FROM sale_order_option WHERE id = $1`, optionID,
).Scan(&name, &productID, &qty, &priceUnit, &discount)
if err != nil {
return nil, fmt.Errorf("sale: read option %d: %w", optionID, err)
}
// Create a new order line from the option
lineVals := orm.Values{
"order_id": soID,
"name": name,
"product_uom_qty": qty,
"price_unit": priceUnit,
"discount": discount,
}
if productID > 0 {
lineVals["product_id"] = productID
}
lineRS := env.Model("sale.order.line")
_, err = lineRS.Create(lineVals)
if err != nil {
return nil, fmt.Errorf("sale: create line from option %d: %w", optionID, err)
}
// Mark option as added
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order_option SET is_present = true WHERE id = $1`, optionID)
return true, nil
})
// ── Feature 6: action_print ──────────────────────────────────────────
// Returns a report URL action pointing to /report/pdf/sale.order/<id>.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_quotation_send() print variant
so.RegisterMethod("action_print", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
soID := rs.IDs()[0]
return map[string]interface{}{
"type": "ir.actions.report",
"report_name": "sale.order",
"report_type": "qweb-pdf",
"report_file": fmt.Sprintf("/report/pdf/sale.order/%d", soID),
"data": map[string]interface{}{"ids": []int64{soID}},
}, nil
})
}
@@ -347,37 +699,402 @@ func initSaleOrderLineExtension() {
return orm.Values{"untaxed_amount_invoiced": qtyInvoiced * price * (1 - discount/100)}, nil
})
// _compute_qty_invoiced: Compute invoiced quantity from linked invoice lines.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_invoiced()
sol.RegisterCompute("qty_invoiced", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var qtyInvoiced float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(qty_invoiced, 0) FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&qtyInvoiced)
return orm.Values{"qty_invoiced": qtyInvoiced}, nil
})
// _compute_qty_to_invoice: Quantity to invoice = qty - qty_invoiced (if delivered policy: qty_delivered - qty_invoiced).
// _compute_qty_to_invoice: Quantity to invoice based on invoice policy.
// Note: qty_invoiced compute is registered later with full M2M-based logic.
// If invoice policy is 'order': product_uom_qty - qty_invoiced
// If invoice policy is 'delivery': qty_delivered - qty_invoiced
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_to_invoice()
sol.RegisterCompute("qty_to_invoice", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var qty, qtyInvoiced float64
var qty, qtyDelivered, qtyInvoiced float64
var productID *int64
var orderState string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(product_uom_qty, 0), COALESCE(qty_invoiced, 0)
FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&qty, &qtyInvoiced)
toInvoice := qty - qtyInvoiced
`SELECT COALESCE(sol.product_uom_qty, 0), COALESCE(sol.qty_delivered, 0),
COALESCE(sol.qty_invoiced, 0), sol.product_id,
COALESCE(so.state, 'draft')
FROM sale_order_line sol
JOIN sale_order so ON so.id = sol.order_id
WHERE sol.id = $1`, lineID,
).Scan(&qty, &qtyDelivered, &qtyInvoiced, &productID, &orderState)
if orderState != "sale" && orderState != "done" {
return orm.Values{"qty_to_invoice": float64(0)}, nil
}
// Check invoice policy from product template
invoicePolicy := "order" // default
if productID != nil && *productID > 0 {
var policy *string
env.Tx().QueryRow(env.Ctx(),
`SELECT pt.invoice_policy FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
WHERE pp.id = $1`, *productID).Scan(&policy)
if policy != nil && *policy != "" {
invoicePolicy = *policy
}
}
var toInvoice float64
if invoicePolicy == "delivery" {
toInvoice = qtyDelivered - qtyInvoiced
} else {
toInvoice = qty - qtyInvoiced
}
if toInvoice < 0 {
toInvoice = 0
}
return orm.Values{"qty_to_invoice": toInvoice}, nil
})
// _compute_qty_delivered: Compute delivered quantity from stock moves.
// For products of type 'service', qty_delivered is manual.
// For storable/consumable products, sum done stock move quantities.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_delivered()
// odoo/addons/sale_stock/models/sale_order_line.py (stock moves source)
sol.RegisterCompute("qty_delivered", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
// Check if stock module is loaded
if orm.Registry.Get("stock.move") == nil {
// No stock module — return existing stored value
var delivered float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(qty_delivered, 0) FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&delivered)
return orm.Values{"qty_delivered": delivered}, nil
}
// Get product info
var productID *int64
var productType string
var soName string
env.Tx().QueryRow(env.Ctx(),
`SELECT sol.product_id, COALESCE(pt.type, 'consu'),
COALESCE(so.name, '')
FROM sale_order_line sol
LEFT JOIN product_product pp ON pp.id = sol.product_id
LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id
JOIN sale_order so ON so.id = sol.order_id
WHERE sol.id = $1`, lineID,
).Scan(&productID, &productType, &soName)
// For services, qty_delivered is manual — keep stored value
if productType == "service" || productID == nil || *productID == 0 {
var delivered float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(qty_delivered, 0) FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&delivered)
return orm.Values{"qty_delivered": delivered}, nil
}
// Sum done outgoing stock move quantities for this product + SO origin
var delivered float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(sm.product_uom_qty)::float8, 0)
FROM stock_move sm
JOIN stock_picking sp ON sp.id = sm.picking_id
WHERE sm.product_id = $1 AND sm.state = 'done'
AND sp.origin = $2
AND sm.location_dest_id IN (
SELECT id FROM stock_location WHERE usage = 'customer'
)`, *productID, soName,
).Scan(&delivered)
return orm.Values{"qty_delivered": delivered}, nil
})
// _compute_qty_invoiced: Compute invoiced quantity from linked invoice lines.
// For real integration, sum quantities from account.move.line linked via the M2M relation.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_invoiced()
sol.RegisterCompute("qty_invoiced", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
// Try to get from the M2M relation (sale_order_line_invoice_rel)
var qtyInvoiced float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(
CASE WHEN am.move_type = 'out_refund' THEN -aml.quantity ELSE aml.quantity END
)::float8, 0)
FROM sale_order_line_invoice_rel rel
JOIN account_move_line aml ON aml.id = rel.invoice_line_id
JOIN account_move am ON am.id = aml.move_id
WHERE rel.order_line_id = $1
AND am.state != 'cancel'`, lineID,
).Scan(&qtyInvoiced)
if err != nil || qtyInvoiced == 0 {
// Fallback to stored value
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(qty_invoiced, 0) FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&qtyInvoiced)
}
return orm.Values{"qty_invoiced": qtyInvoiced}, nil
})
// _compute_name: Product name + description + variant attributes.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_name()
sol.AddFields(
orm.Text("computed_name", orm.FieldOpts{
String: "Computed Description", Compute: "_compute_name",
}),
)
sol.RegisterCompute("computed_name", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var productID *int64
var existingName string
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, COALESCE(name, '') FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&productID, &existingName)
// If no product, keep existing name
if productID == nil || *productID == 0 {
return orm.Values{"computed_name": existingName}, nil
}
// Build name from product template + variant attributes
var productName, descSale string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(pt.name, ''), COALESCE(pt.description_sale, '')
FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
WHERE pp.id = $1`, *productID,
).Scan(&productName, &descSale)
// Get variant attribute values
attrRows, err := env.Tx().Query(env.Ctx(),
`SELECT COALESCE(pav.name, '')
FROM product_template_attribute_value ptav
JOIN product_attribute_value pav ON pav.id = ptav.product_attribute_value_id
JOIN product_product_product_template_attribute_value_rel rel
ON rel.product_template_attribute_value_id = ptav.id
WHERE rel.product_product_id = $1`, *productID)
var attrNames []string
if err == nil {
for attrRows.Next() {
var attrName string
attrRows.Scan(&attrName)
if attrName != "" {
attrNames = append(attrNames, attrName)
}
}
attrRows.Close()
}
name := productName
if len(attrNames) > 0 {
name += " ("
for i, a := range attrNames {
if i > 0 {
name += ", "
}
name += a
}
name += ")"
}
if descSale != "" {
name += "\n" + descSale
}
return orm.Values{"computed_name": name}, nil
})
// _compute_discount: Compute discount from pricelist if applicable.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_discount()
sol.AddFields(
orm.Float("computed_discount", orm.FieldOpts{
String: "Computed Discount", Compute: "_compute_discount_from_pricelist",
}),
)
sol.RegisterCompute("computed_discount", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var productID *int64
var orderID int64
var priceUnit float64
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, order_id, COALESCE(price_unit, 0)
FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&productID, &orderID, &priceUnit)
if productID == nil || *productID == 0 || priceUnit == 0 {
return orm.Values{"computed_discount": float64(0)}, nil
}
// Get pricelist from the order
var pricelistID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT pricelist_id FROM sale_order WHERE id = $1`, orderID,
).Scan(&pricelistID)
if pricelistID == nil || *pricelistID == 0 {
return orm.Values{"computed_discount": float64(0)}, nil
}
// Get the product's list_price as the base price
var listPrice float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(pt.list_price, 0)
FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
WHERE pp.id = $1`, *productID,
).Scan(&listPrice)
if listPrice <= 0 {
return orm.Values{"computed_discount": float64(0)}, nil
}
// Check pricelist for a discount-based rule
var discountPct float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(price_discount, 0)
FROM product_pricelist_item
WHERE pricelist_id = $1
AND (product_id = $2 OR product_tmpl_id = (
SELECT product_tmpl_id FROM product_product WHERE id = $2
) OR (product_id IS NULL AND product_tmpl_id IS NULL))
AND (date_start IS NULL OR date_start <= CURRENT_DATE)
AND (date_end IS NULL OR date_end >= CURRENT_DATE)
ORDER BY
CASE WHEN product_id IS NOT NULL THEN 0
WHEN product_tmpl_id IS NOT NULL THEN 1
ELSE 2 END,
min_quantity ASC
LIMIT 1`, *pricelistID, *productID,
).Scan(&discountPct)
return orm.Values{"computed_discount": discountPct}, nil
})
// _compute_invoice_status_line: Enhanced per-line invoice status considering upselling.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_invoice_status()
sol.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var qty, qtyDelivered, qtyInvoiced float64
var orderState string
var isDownpayment bool
var productID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(sol.product_uom_qty, 0), COALESCE(sol.qty_delivered, 0),
COALESCE(sol.qty_invoiced, 0), COALESCE(so.state, 'draft'),
COALESCE(sol.is_downpayment, false), sol.product_id
FROM sale_order_line sol
JOIN sale_order so ON so.id = sol.order_id
WHERE sol.id = $1`, lineID,
).Scan(&qty, &qtyDelivered, &qtyInvoiced, &orderState, &isDownpayment, &productID)
if orderState != "sale" && orderState != "done" {
return orm.Values{"invoice_status": "no"}, nil
}
// Down payment that is fully invoiced
if isDownpayment && qtyInvoiced >= qty {
return orm.Values{"invoice_status": "invoiced"}, nil
}
// Check qty_to_invoice
var toInvoice float64
invoicePolicy := "order"
if productID != nil && *productID > 0 {
var policy *string
env.Tx().QueryRow(env.Ctx(),
`SELECT pt.invoice_policy FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
WHERE pp.id = $1`, *productID).Scan(&policy)
if policy != nil && *policy != "" {
invoicePolicy = *policy
}
}
if invoicePolicy == "delivery" {
toInvoice = qtyDelivered - qtyInvoiced
} else {
toInvoice = qty - qtyInvoiced
}
if toInvoice > 0.001 {
return orm.Values{"invoice_status": "to invoice"}, nil
}
// Upselling: ordered qty invoiced on order policy but delivered more
if invoicePolicy == "order" && qty >= 0 && qtyDelivered > qty {
return orm.Values{"invoice_status": "upselling"}, nil
}
if qtyInvoiced >= qty && qty > 0 {
return orm.Values{"invoice_status": "invoiced"}, nil
}
return orm.Values{"invoice_status": "no"}, nil
})
// ── Feature 4: _compute_product_template_attribute_value_ids ─────────
// When product_id changes, find available product.template.attribute.value records
// for the variant. Returns JSON array of {id, name, attribute_name} objects.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_product_template_attribute_value_ids()
sol.AddFields(
orm.Text("product_template_attribute_value_ids", orm.FieldOpts{
String: "Product Attribute Values",
Compute: "_compute_product_template_attribute_value_ids",
}),
)
sol.RegisterCompute("product_template_attribute_value_ids", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var productID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&productID)
if productID == nil || *productID == 0 {
return orm.Values{"product_template_attribute_value_ids": "[]"}, nil
}
// Find attribute values for this product variant via the M2M relation
attrRows, err := env.Tx().Query(env.Ctx(),
`SELECT ptav.id, COALESCE(pav.name, '') AS value_name,
COALESCE(pa.name, '') AS attribute_name
FROM product_template_attribute_value ptav
JOIN product_attribute_value pav ON pav.id = ptav.product_attribute_value_id
JOIN product_attribute pa ON pa.id = ptav.attribute_id
JOIN product_product_product_template_attribute_value_rel rel
ON rel.product_template_attribute_value_id = ptav.id
WHERE rel.product_product_id = $1
ORDER BY pa.sequence, pa.name`, *productID)
if err != nil {
return orm.Values{"product_template_attribute_value_ids": "[]"}, nil
}
defer attrRows.Close()
type attrVal struct {
ID int64 `json:"id"`
Name string `json:"name"`
AttributeName string `json:"attribute_name"`
}
var values []attrVal
for attrRows.Next() {
var av attrVal
if err := attrRows.Scan(&av.ID, &av.Name, &av.AttributeName); err != nil {
continue
}
values = append(values, av)
}
jsonBytes, _ := json.Marshal(values)
return orm.Values{"product_template_attribute_value_ids": string(jsonBytes)}, nil
})
}
// initSaleOrderDiscount registers the sale.order.discount wizard.
// Enhanced with discount_type: percentage or fixed_amount.
// Mirrors: odoo/addons/sale/wizard/sale_order_discount.py
func initSaleOrderDiscount() {
m := orm.NewModel("sale.order.discount", orm.ModelOpts{
@@ -386,33 +1103,76 @@ func initSaleOrderDiscount() {
})
m.AddFields(
orm.Float("discount", orm.FieldOpts{String: "Discount (%)", Required: true}),
orm.Float("discount", orm.FieldOpts{String: "Discount Value", Required: true}),
orm.Selection("discount_type", []orm.SelectionItem{
{Value: "percentage", Label: "Percentage"},
{Value: "fixed_amount", Label: "Fixed Amount"},
}, orm.FieldOpts{String: "Discount Type", Default: "percentage", Required: true}),
orm.Many2one("sale_order_id", "sale.order", orm.FieldOpts{String: "Sale Order"}),
)
// action_apply_discount: Apply the discount to all lines of the SO.
// For percentage: sets discount % on each line directly.
// For fixed_amount: distributes the fixed amount evenly across all product lines.
// Mirrors: odoo/addons/sale/wizard/sale_order_discount.py action_apply_discount()
m.RegisterMethod("action_apply_discount", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
wizID := rs.IDs()[0]
var discount float64
var discountVal float64
var orderID int64
var discountType string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(discount, 0), COALESCE(sale_order_id, 0)
`SELECT COALESCE(discount, 0), COALESCE(sale_order_id, 0),
COALESCE(discount_type, 'percentage')
FROM sale_order_discount WHERE id = $1`, wizID,
).Scan(&discount, &orderID)
).Scan(&discountVal, &orderID, &discountType)
if orderID == 0 {
return nil, fmt.Errorf("sale_discount: no sale order linked")
}
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE sale_order_line SET discount = $1
WHERE order_id = $2
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
discount, orderID)
if err != nil {
return nil, fmt.Errorf("sale_discount: apply discount: %w", err)
switch discountType {
case "fixed_amount":
// Distribute fixed amount evenly across product lines as a percentage
// Calculate total undiscounted line amount to determine per-line discount %
var totalAmount float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(product_uom_qty * price_unit)::float8, 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
orderID,
).Scan(&totalAmount)
if err != nil {
return nil, fmt.Errorf("sale_discount: read total: %w", err)
}
if totalAmount <= 0 {
return nil, fmt.Errorf("sale_discount: order has no lines or zero total")
}
// Convert fixed amount to an equivalent percentage of total
pct := discountVal / totalAmount * 100
if pct > 100 {
pct = 100
}
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE sale_order_line SET discount = $1
WHERE order_id = $2
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
pct, orderID)
if err != nil {
return nil, fmt.Errorf("sale_discount: apply fixed discount: %w", err)
}
default: // "percentage"
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE sale_order_line SET discount = $1
WHERE order_id = $2
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
discountVal, orderID)
if err != nil {
return nil, fmt.Errorf("sale_discount: apply percentage discount: %w", err)
}
}
return true, nil
@@ -441,3 +1201,5 @@ func initResPartnerSaleExtension2() {
return orm.Values{"sale_order_total": total}, nil
})
}
func htmlEscapeStr(s string) string { return html.EscapeString(s) }

View File

@@ -124,6 +124,42 @@ func initSaleOrderTemplate() {
numDays, int64(orderID))
}
// Copy template options as sale.order.option records on the SO
optRows, err := env.Tx().Query(env.Ctx(),
`SELECT COALESCE(name, ''), product_id, COALESCE(quantity, 1),
COALESCE(price_unit, 0), COALESCE(discount, 0), COALESCE(sequence, 10)
FROM sale_order_template_option
WHERE sale_order_template_id = $1 ORDER BY sequence`, templateID)
if err == nil {
optionModel := orm.Registry.Get("sale.order.option")
if optionModel != nil {
optionRS := env.Model("sale.order.option")
for optRows.Next() {
var oName string
var oProdID *int64
var oQty, oPrice, oDisc float64
var oSeq int
if err := optRows.Scan(&oName, &oProdID, &oQty, &oPrice, &oDisc, &oSeq); err != nil {
continue
}
optVals := orm.Values{
"order_id": int64(orderID),
"name": oName,
"quantity": oQty,
"price_unit": oPrice,
"discount": oDisc,
"sequence": oSeq,
"is_present": false,
}
if oProdID != nil {
optVals["product_id"] = *oProdID
}
optionRS.Create(optVals)
}
}
optRows.Close()
}
return true, nil
})
@@ -290,3 +326,94 @@ func initSaleOrderTemplateOption() {
return result
})
}
// initSaleOrderOption registers sale.order.option — optional products on a specific sale order.
// When a template with options is applied to an SO, options are copied here.
// The customer or salesperson can then choose to add them as order lines.
// Mirrors: odoo/addons/sale_management/models/sale_order_option.py SaleOrderOption
func initSaleOrderOption() {
m := orm.NewModel("sale.order.option", orm.ModelOpts{
Description: "Sale Order Option",
Order: "sequence, id",
})
m.AddFields(
orm.Many2one("order_id", "sale.order", orm.FieldOpts{
String: "Sale Order", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Required: true,
}),
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1}),
orm.Many2one("uom_id", "uom.uom", orm.FieldOpts{String: "Unit of Measure"}),
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Boolean("is_present", orm.FieldOpts{
String: "Present on Order", Default: false,
}),
)
// Onchange: product_id → name + price_unit
m.RegisterOnchange("product_id", func(env *orm.Environment, vals orm.Values) orm.Values {
result := make(orm.Values)
var productID int64
switch v := vals["product_id"].(type) {
case int64:
productID = v
case float64:
productID = int64(v)
case map[string]interface{}:
if id, ok := v["id"]; ok {
switch n := id.(type) {
case float64:
productID = int64(n)
case int64:
productID = n
}
}
}
if productID <= 0 {
return result
}
var name string
var listPrice float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(pt.name, ''), COALESCE(pt.list_price, 0)
FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
WHERE pp.id = $1`, productID,
).Scan(&name, &listPrice)
if err != nil {
return result
}
result["name"] = name
result["price_unit"] = listPrice
return result
})
// button_add: Add this option as an order line. Delegates to sale.order action_add_option.
m.RegisterMethod("button_add", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
optionID := rs.IDs()[0]
var orderID int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(order_id, 0) FROM sale_order_option WHERE id = $1`, optionID,
).Scan(&orderID)
if err != nil || orderID == 0 {
return nil, fmt.Errorf("sale_option: no order linked to option %d", optionID)
}
soRS := env.Model("sale.order").Browse(orderID)
soModel := orm.Registry.Get("sale.order")
if fn, ok := soModel.Methods["action_add_option"]; ok {
return fn(soRS, float64(optionID))
}
return nil, fmt.Errorf("sale_option: action_add_option not found on sale.order")
})
}

View File

@@ -2,4 +2,5 @@ package models
func Init() {
initStock()
initStockIntrastat()
}

File diff suppressed because it is too large Load Diff

View File

@@ -72,6 +72,137 @@ func initStockBarcode() {
return map[string]interface{}{"found": false, "barcode": barcode}, nil
})
// action_process_barcode: Enhanced barcode scan loop — handles UPC/EAN by searching
// product.product.barcode field directly. Supports UPC-A (12 digits), EAN-13 (13 digits),
// EAN-8 (8 digits), and arbitrary barcodes. In the context of a picking, increments
// qty_done on the matching move line.
// Mirrors: stock_barcode.picking barcode scan loop with UPC/EAN support
m.RegisterMethod("action_process_barcode", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
if len(args) < 2 {
return nil, fmt.Errorf("stock.barcode.picking.action_process_barcode requires picking_id, barcode")
}
pickingID, _ := args[0].(int64)
barcode, _ := args[1].(string)
if pickingID == 0 || barcode == "" {
return nil, fmt.Errorf("stock.barcode.picking: invalid picking_id or barcode")
}
env := rs.Env()
// Step 1: Try to find product by barcode on product.product.barcode (UPC/EAN stored here)
var productID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM product_product WHERE barcode = $1 LIMIT 1`, barcode,
).Scan(&productID)
// Step 2: If not found on product_product, try product_template.barcode
if productID == 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT pp.id FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
WHERE pt.barcode = $1 LIMIT 1`, barcode,
).Scan(&productID)
}
// Step 3: For UPC-A (12 digits), try converting to EAN-13 by prepending '0'
if productID == 0 && len(barcode) == 12 && isNumeric(barcode) {
ean13 := "0" + barcode
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM product_product WHERE barcode = $1 LIMIT 1`, ean13,
).Scan(&productID)
// Also try the reverse: if stored as UPC but scanned as EAN
if productID == 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT pp.id FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
WHERE pt.barcode = $1 LIMIT 1`, ean13,
).Scan(&productID)
}
}
// Step 4: For EAN-13 (13 digits starting with 0), try stripping leading 0 to get UPC-A
if productID == 0 && len(barcode) == 13 && barcode[0] == '0' && isNumeric(barcode) {
upc := barcode[1:]
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM product_product WHERE barcode = $1 LIMIT 1`, upc,
).Scan(&productID)
if productID == 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT pp.id FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
WHERE pt.barcode = $1 LIMIT 1`, upc,
).Scan(&productID)
}
}
// Step 5: Try lot/serial number
if productID == 0 {
var lotProductID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id FROM stock_lot WHERE name = $1 LIMIT 1`, barcode,
).Scan(&lotProductID)
productID = lotProductID
}
if productID == 0 {
return map[string]interface{}{
"found": false,
"barcode": barcode,
"message": fmt.Sprintf("No product found for barcode %q (tried UPC/EAN lookup)", barcode),
}, nil
}
// Step 6: Find matching move line in the picking
var moveLineID int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT sml.id FROM stock_move_line sml
JOIN stock_move sm ON sm.id = sml.move_id
WHERE sm.picking_id = $1 AND sml.product_id = $2 AND sm.state NOT IN ('done', 'cancel')
ORDER BY sml.id LIMIT 1`,
pickingID, productID,
).Scan(&moveLineID)
if err != nil || moveLineID == 0 {
// Check if product expected in any move
var moveID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM stock_move WHERE picking_id = $1 AND product_id = $2 AND state NOT IN ('done', 'cancel') LIMIT 1`,
pickingID, productID,
).Scan(&moveID)
if moveID == 0 {
return map[string]interface{}{
"found": false,
"product_id": productID,
"message": fmt.Sprintf("Product %d not expected in this transfer", productID),
}, nil
}
return map[string]interface{}{
"found": true,
"product_id": productID,
"move_id": moveID,
"action": "new_line",
"message": "Product found in move, new line needed",
}, nil
}
// Increment quantity on the move line
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE stock_move_line SET quantity = quantity + 1 WHERE id = $1`, moveLineID)
if err != nil {
return nil, fmt.Errorf("stock.barcode.picking: increment qty on move line %d: %w", moveLineID, err)
}
return map[string]interface{}{
"found": true,
"product_id": productID,
"move_line_id": moveLineID,
"action": "incremented",
"message": "Quantity incremented",
}, nil
})
// process_barcode_picking: Process a barcode in the context of a picking.
// Finds the product and increments qty_done on the matching move line.
// Mirrors: stock_barcode.picking barcode processing
@@ -234,3 +365,13 @@ func initStockBarcode() {
}, nil
})
}
// isNumeric checks if a string contains only digit characters.
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}

View File

@@ -2,7 +2,6 @@ package models
import (
"fmt"
"math"
"odoo-go/pkg/orm"
)
@@ -221,8 +220,11 @@ func initStockLandedCost() {
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE stock_valuation_layer
SET remaining_value = remaining_value + $1, value = value + $1
WHERE stock_move_id = $2 AND product_id = $3 AND remaining_qty > 0
LIMIT 1`,
WHERE id = (
SELECT id FROM stock_valuation_layer
WHERE stock_move_id = $2 AND product_id = $3 AND remaining_qty > 0
ORDER BY id LIMIT 1
)`,
adj.AdditionalCost, adj.MoveID, adj.ProductID,
)
if err != nil {
@@ -375,8 +377,3 @@ func initStockLandedCost() {
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
)
}
// roundCurrency rounds a monetary value to 2 decimal places.
func roundCurrency(value float64) float64 {
return math.Round(value*100) / 100
}

View File

@@ -513,3 +513,234 @@ func initStockForecast() {
return map[string]interface{}{"products": products}, nil
})
}
// initStockIntrastat registers stock.intrastat.line — Intrastat reporting model for
// EU cross-border trade declarations. Tracks move-level trade data.
// Mirrors: odoo/addons/stock_intrastat/models/stock_intrastat.py
func initStockIntrastat() {
m := orm.NewModel("stock.intrastat.line", orm.ModelOpts{
Description: "Intrastat Line",
Order: "id desc",
})
m.AddFields(
orm.Many2one("move_id", "stock.move", orm.FieldOpts{
String: "Stock Move", Required: true, Index: true, OnDelete: orm.OnDeleteCascade,
}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Required: true, Index: true,
}),
orm.Many2one("country_id", "res.country", orm.FieldOpts{
String: "Country", Required: true, Index: true,
}),
orm.Float("weight", orm.FieldOpts{String: "Weight (kg)", Required: true}),
orm.Monetary("value", orm.FieldOpts{String: "Fiscal Value", CurrencyField: "currency_id", Required: true}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Selection("transaction_type", []orm.SelectionItem{
{Value: "arrival", Label: "Arrival"},
{Value: "dispatch", Label: "Dispatch"},
}, orm.FieldOpts{String: "Transaction Type", Required: true, Index: true}),
orm.Char("intrastat_code", orm.FieldOpts{String: "Commodity Code"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Index: true}),
orm.Date("date", orm.FieldOpts{String: "Date", Index: true}),
orm.Char("transport_mode", orm.FieldOpts{String: "Transport Mode"}),
)
// generate_lines: Auto-generate Intrastat lines from done stock moves in a date range.
// Args: date_from (string), date_to (string), optional company_id (int64)
// Mirrors: stock.intrastat.report generation
m.RegisterMethod("generate_lines", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
if len(args) < 2 {
return nil, fmt.Errorf("stock.intrastat.line.generate_lines requires date_from, date_to")
}
dateFrom, _ := args[0].(string)
dateTo, _ := args[1].(string)
if dateFrom == "" || dateTo == "" {
return nil, fmt.Errorf("stock.intrastat.line: invalid date range")
}
companyID := int64(1)
if len(args) >= 3 {
if cid, ok := args[2].(int64); ok && cid > 0 {
companyID = cid
}
}
env := rs.Env()
// Find done moves crossing borders (source or dest is in a different country)
// For simplicity, look for moves between locations belonging to different warehouses
// or between internal and non-internal locations.
rows, err := env.Tx().Query(env.Ctx(),
`SELECT sm.id, sm.product_id, sm.product_uom_qty, sm.price_unit, sm.date,
sl_src.usage as src_usage, sl_dst.usage as dst_usage,
COALESCE(rp.country_id, 0) as partner_country_id
FROM stock_move sm
JOIN stock_location sl_src ON sl_src.id = sm.location_id
JOIN stock_location sl_dst ON sl_dst.id = sm.location_dest_id
LEFT JOIN stock_picking sp ON sp.id = sm.picking_id
LEFT JOIN res_partner rp ON rp.id = sp.partner_id
WHERE sm.state = 'done'
AND sm.date >= $1 AND sm.date <= $2
AND sm.company_id = $3
AND (
(sl_src.usage = 'supplier' AND sl_dst.usage = 'internal')
OR (sl_src.usage = 'internal' AND sl_dst.usage = 'customer')
)
ORDER BY sm.date`,
dateFrom, dateTo, companyID,
)
if err != nil {
return nil, fmt.Errorf("stock.intrastat.line: query moves: %w", err)
}
type moveData struct {
MoveID, ProductID int64
Qty, PriceUnit float64
Date *string
SrcUsage string
DstUsage string
CountryID int64
}
var moves []moveData
for rows.Next() {
var md moveData
if err := rows.Scan(&md.MoveID, &md.ProductID, &md.Qty, &md.PriceUnit,
&md.Date, &md.SrcUsage, &md.DstUsage, &md.CountryID); err != nil {
rows.Close()
return nil, fmt.Errorf("stock.intrastat.line: scan move: %w", err)
}
moves = append(moves, md)
}
rows.Close()
var created int
for _, md := range moves {
// Determine transaction type
txnType := "arrival"
if md.SrcUsage == "internal" && md.DstUsage == "customer" {
txnType = "dispatch"
}
// Use partner country; skip if no country (can't determine border crossing)
countryID := md.CountryID
if countryID == 0 {
continue
}
// Compute value and weight
value := md.Qty * md.PriceUnit
weight := md.Qty // Simplified: weight = qty (would use product.weight in full impl)
dateStr := ""
if md.Date != nil {
dateStr = *md.Date
}
// Check if line already exists for this move
var existing int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM stock_intrastat_line WHERE move_id = $1 LIMIT 1`, md.MoveID,
).Scan(&existing)
if existing > 0 {
continue
}
_, err := env.Tx().Exec(env.Ctx(),
`INSERT INTO stock_intrastat_line
(move_id, product_id, country_id, weight, value, transaction_type, company_id, date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
md.MoveID, md.ProductID, countryID, weight, value, txnType, companyID, dateStr,
)
if err != nil {
return nil, fmt.Errorf("stock.intrastat.line: create line for move %d: %w", md.MoveID, err)
}
created++
}
return map[string]interface{}{
"created": created,
"date_from": dateFrom,
"date_to": dateTo,
}, nil
})
// get_report: Return Intrastat report data for a period.
// Args: date_from (string), date_to (string), optional transaction_type (string)
// Mirrors: stock.intrastat.report views
m.RegisterMethod("get_report", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
if len(args) < 2 {
return nil, fmt.Errorf("stock.intrastat.line.get_report requires date_from, date_to")
}
dateFrom, _ := args[0].(string)
dateTo, _ := args[1].(string)
var txnTypeFilter string
if len(args) >= 3 {
txnTypeFilter, _ = args[2].(string)
}
env := rs.Env()
query := `SELECT sil.id, sil.move_id, sil.product_id, pt.name as product_name,
sil.country_id, COALESCE(rc.name, '') as country_name,
sil.weight, sil.value, sil.transaction_type,
COALESCE(sil.intrastat_code, '') as commodity_code,
sil.date
FROM stock_intrastat_line sil
JOIN product_product pp ON pp.id = sil.product_id
JOIN product_template pt ON pt.id = pp.product_tmpl_id
LEFT JOIN res_country rc ON rc.id = sil.country_id
WHERE sil.date >= $1 AND sil.date <= $2`
queryArgs := []interface{}{dateFrom, dateTo}
if txnTypeFilter != "" {
query += ` AND sil.transaction_type = $3`
queryArgs = append(queryArgs, txnTypeFilter)
}
query += ` ORDER BY sil.date, sil.id`
rows, err := env.Tx().Query(env.Ctx(), query, queryArgs...)
if err != nil {
return nil, fmt.Errorf("stock.intrastat.line: query report: %w", err)
}
defer rows.Close()
var lines []map[string]interface{}
var totalWeight, totalValue float64
for rows.Next() {
var lineID, moveID, productID, countryID int64
var productName, countryName, txnType, commodityCode string
var weight, value float64
var date *string
if err := rows.Scan(&lineID, &moveID, &productID, &productName,
&countryID, &countryName, &weight, &value, &txnType,
&commodityCode, &date); err != nil {
return nil, fmt.Errorf("stock.intrastat.line: scan report row: %w", err)
}
dateStr := ""
if date != nil {
dateStr = *date
}
lines = append(lines, map[string]interface{}{
"id": lineID, "move_id": moveID,
"product_id": productID, "product": productName,
"country_id": countryID, "country": countryName,
"weight": weight, "value": value,
"transaction_type": txnType,
"commodity_code": commodityCode,
"date": dateStr,
})
totalWeight += weight
totalValue += value
}
return map[string]interface{}{
"lines": lines,
"total_weight": totalWeight,
"total_value": totalValue,
"date_from": dateFrom,
"date_to": dateTo,
}, nil
})
}

View File

@@ -127,13 +127,22 @@ func initStockValuationLayer() {
}
defer rows.Close()
var totalConsumedValue float64
// Collect layers first, then close cursor before updating (pgx safety)
type layerConsumption struct {
id int64
newQty float64
newValue float64
consumed float64
cost float64
}
var consumptions []layerConsumption
remaining := qtyToConsume
for rows.Next() && remaining > 0 {
var layerID int64
var layerQty, layerValue, layerUnitCost float64
if err := rows.Scan(&layerID, &layerQty, &layerValue, &layerUnitCost); err != nil {
rows.Close()
return nil, fmt.Errorf("stock.valuation.layer: scan FIFO layer: %w", err)
}
@@ -142,20 +151,27 @@ func initStockValuationLayer() {
consumed = layerQty
}
consumedValue := consumed * layerUnitCost
newRemainingQty := layerQty - consumed
newRemainingValue := layerValue - consumedValue
consumptions = append(consumptions, layerConsumption{
id: layerID,
newQty: layerQty - consumed,
newValue: layerValue - consumed*layerUnitCost,
consumed: consumed,
cost: layerUnitCost,
})
remaining -= consumed
}
rows.Close()
// Now update layers outside the cursor
var totalConsumedValue float64
for _, c := range consumptions {
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE stock_valuation_layer SET remaining_qty = $1, remaining_value = $2 WHERE id = $3`,
newRemainingQty, newRemainingValue, layerID,
)
c.newQty, c.newValue, c.id)
if err != nil {
return nil, fmt.Errorf("stock.valuation.layer: update layer %d: %w", layerID, err)
return nil, fmt.Errorf("stock.valuation.layer: update layer %d: %w", c.id, err)
}
totalConsumedValue += consumedValue
remaining -= consumed
totalConsumedValue += c.consumed * c.cost
}
return map[string]interface{}{