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

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