- 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>
276 lines
6.7 KiB
Go
276 lines
6.7 KiB
Go
package orm
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
)
|
|
|
|
// ComputeFunc is a function that computes field values for a recordset.
|
|
// Mirrors: @api.depends decorated methods in Odoo.
|
|
//
|
|
// The function receives a singleton recordset and must return a Values map
|
|
// with the computed field values.
|
|
//
|
|
// Example:
|
|
//
|
|
// func computeAmount(rs *Recordset) (Values, error) {
|
|
// total := 0.0
|
|
// // ... sum line amounts ...
|
|
// return Values{"amount_total": total}, nil
|
|
// }
|
|
type ComputeFunc func(rs *Recordset) (Values, error)
|
|
|
|
// RegisterCompute registers a compute function for a field.
|
|
// The same function can compute multiple fields (call RegisterCompute for each).
|
|
func (m *Model) RegisterCompute(fieldName string, fn ComputeFunc) {
|
|
if m.computes == nil {
|
|
m.computes = make(map[string]ComputeFunc)
|
|
}
|
|
m.computes[fieldName] = fn
|
|
}
|
|
|
|
// SetupComputes builds the reverse dependency map for this model.
|
|
// Called after all modules are loaded.
|
|
//
|
|
// For each field with Depends, creates a mapping:
|
|
//
|
|
// trigger_field → []computed_field_names
|
|
//
|
|
// So when trigger_field is written, we know which computes to re-run.
|
|
func (m *Model) SetupComputes() {
|
|
m.dependencyMap = make(map[string][]string)
|
|
for _, name := range m.fieldOrder {
|
|
f := m.fields[name]
|
|
if f.Compute == "" || len(f.Depends) == 0 {
|
|
continue
|
|
}
|
|
for _, dep := range f.Depends {
|
|
m.dependencyMap[dep] = append(m.dependencyMap[dep], name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RunStoredComputes runs compute functions for stored computed fields
|
|
// and merges results into vals. Called before INSERT in Create().
|
|
func RunStoredComputes(m *Model, env *Environment, id int64, vals Values) error {
|
|
if len(m.computes) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Collect stored computed fields that have registered functions
|
|
seen := make(map[string]bool) // Track compute functions already called (by field name)
|
|
|
|
for _, name := range m.fieldOrder {
|
|
f := m.fields[name]
|
|
if f.Compute == "" || !f.Store {
|
|
continue
|
|
}
|
|
fn, ok := m.computes[name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Deduplicate: same function may compute multiple fields
|
|
if seen[f.Compute] {
|
|
continue
|
|
}
|
|
seen[f.Compute] = true
|
|
|
|
// Create a temporary recordset for the computation
|
|
rs := &Recordset{env: env, model: m, ids: []int64{id}}
|
|
computed, err := fn(rs)
|
|
if err != nil {
|
|
return fmt.Errorf("orm: compute %s.%s: %w", m.name, name, err)
|
|
}
|
|
|
|
// Merge computed values
|
|
for k, v := range computed {
|
|
cf := m.GetField(k)
|
|
if cf != nil && cf.Store && cf.Compute != "" {
|
|
vals[k] = v
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TriggerRecompute re-computes stored fields that depend on written fields.
|
|
// Called after UPDATE in Write().
|
|
func TriggerRecompute(rs *Recordset, writtenFields Values) error {
|
|
m := rs.model
|
|
if len(m.dependencyMap) == 0 || len(m.computes) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Find which computed fields need re-computation
|
|
toRecompute := make(map[string]bool)
|
|
for fieldName := range writtenFields {
|
|
for _, computedField := range m.dependencyMap[fieldName] {
|
|
toRecompute[computedField] = true
|
|
}
|
|
}
|
|
|
|
if len(toRecompute) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Run computes for each record
|
|
seen := make(map[string]bool)
|
|
for _, id := range rs.IDs() {
|
|
singleton := rs.Browse(id)
|
|
recomputedVals := make(Values)
|
|
|
|
for fieldName := range toRecompute {
|
|
f := m.GetField(fieldName)
|
|
if f == nil || !f.Store {
|
|
continue
|
|
}
|
|
fn, ok := m.computes[fieldName]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if seen[f.Compute] {
|
|
continue
|
|
}
|
|
seen[f.Compute] = true
|
|
|
|
computed, err := fn(singleton)
|
|
if err != nil {
|
|
return fmt.Errorf("orm: recompute %s.%s: %w", m.name, fieldName, err)
|
|
}
|
|
for k, v := range computed {
|
|
cf := m.GetField(k)
|
|
if cf != nil && cf.Store && cf.Compute != "" {
|
|
recomputedVals[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write recomputed values directly to DB (bypass hooks to avoid infinite loop)
|
|
if len(recomputedVals) > 0 {
|
|
if err := writeDirectNohook(rs.env, m, id, recomputedVals); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Reset seen for next record
|
|
seen = make(map[string]bool)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeDirectNohook writes values directly without triggering hooks or recomputes.
|
|
func writeDirectNohook(env *Environment, m *Model, id int64, vals Values) error {
|
|
var setClauses []string
|
|
var args []interface{}
|
|
idx := 1
|
|
|
|
for k, v := range vals {
|
|
f := m.GetField(k)
|
|
if f == nil || !f.IsStored() {
|
|
continue
|
|
}
|
|
setClauses = append(setClauses, fmt.Sprintf("%q = $%d", f.Column(), idx))
|
|
args = append(args, v)
|
|
idx++
|
|
}
|
|
|
|
if len(setClauses) == 0 {
|
|
return nil
|
|
}
|
|
|
|
args = append(args, id)
|
|
query := fmt.Sprintf(
|
|
`UPDATE %q SET %s WHERE "id" = $%d`,
|
|
m.Table(), joinStrings(setClauses, ", "), idx,
|
|
)
|
|
|
|
_, err := env.tx.Exec(env.ctx, query, args...)
|
|
return err
|
|
}
|
|
|
|
func joinStrings(s []string, sep string) string {
|
|
result := ""
|
|
for i, str := range s {
|
|
if i > 0 {
|
|
result += sep
|
|
}
|
|
result += str
|
|
}
|
|
return result
|
|
}
|
|
|
|
// RunOnchangeComputes runs computed fields that depend on changed fields.
|
|
// Used by the onchange RPC to recompute values when form fields change.
|
|
// Mirrors: odoo/orm/models.py BaseModel._onchange_eval()
|
|
//
|
|
// Creates a virtual record (ID=-1) with the client's values in cache,
|
|
// then runs any compute functions whose dependencies include the changed fields.
|
|
func RunOnchangeComputes(m *Model, env *Environment, currentVals Values, changedFields []string) Values {
|
|
result := make(Values)
|
|
if m == nil || len(m.dependencyMap) == 0 {
|
|
return result
|
|
}
|
|
|
|
// Find which computed fields need recomputation
|
|
toRecompute := make(map[string]bool)
|
|
for _, fname := range changedFields {
|
|
if deps, ok := m.dependencyMap[fname]; ok {
|
|
for _, computedField := range deps {
|
|
toRecompute[computedField] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(toRecompute) == 0 {
|
|
return result
|
|
}
|
|
|
|
// Populate cache with current form values so computes can read them
|
|
fakeID := int64(-1)
|
|
for k, v := range currentVals {
|
|
env.cache.Set(m.Name(), fakeID, k, v)
|
|
}
|
|
defer env.cache.InvalidateRecord(m.Name(), fakeID)
|
|
|
|
// Create virtual recordset
|
|
rs := &Recordset{env: env, model: m, ids: []int64{fakeID}}
|
|
|
|
// Track which compute functions we've already called (multiple fields can share one function)
|
|
seen := make(map[string]bool)
|
|
|
|
for fieldName := range toRecompute {
|
|
f := m.GetField(fieldName)
|
|
if f == nil || f.Compute == "" {
|
|
continue
|
|
}
|
|
// Don't call the same compute function twice
|
|
if seen[f.Compute] {
|
|
continue
|
|
}
|
|
seen[f.Compute] = true
|
|
|
|
fn, ok := m.computes[fieldName]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
computed, err := fn(rs)
|
|
if err != nil {
|
|
log.Printf("orm: onchange compute %s.%s failed: %v", m.Name(), fieldName, err)
|
|
continue
|
|
}
|
|
for k, v := range computed {
|
|
result[k] = v
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// SetupAllComputes calls SetupComputes on all registered models.
|
|
func SetupAllComputes() {
|
|
for _, m := range Registry.Models() {
|
|
m.SetupComputes()
|
|
}
|
|
}
|