Files
goodie/addons/sale/models/sale_order.go
Marc 66383adf06 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>
2026-04-12 18:41:57 +02:00

1144 lines
38 KiB
Go

package models
import (
"fmt"
"time"
"odoo-go/pkg/orm"
)
// initSaleOrder registers sale.order — the sales quotation / order model.
// Mirrors: odoo/addons/sale/models/sale_order.py
func initSaleOrder() {
m := orm.NewModel("sale.order", orm.ModelOpts{
Description: "Sales Order",
Order: "date_order desc, id desc",
})
// -- Identity & State --
m.AddFields(
orm.Char("name", orm.FieldOpts{
String: "Order Reference", Required: true, Index: true, Readonly: true, Default: "/",
}),
orm.Selection("state", []orm.SelectionItem{
{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}),
)
// -- Partners --
m.AddFields(
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Customer", Required: true, Index: true,
}),
orm.Many2one("partner_invoice_id", "res.partner", orm.FieldOpts{
String: "Invoice Address",
}),
orm.Many2one("partner_shipping_id", "res.partner", orm.FieldOpts{
String: "Delivery Address",
}),
)
// -- Dates --
m.AddFields(
orm.Datetime("date_order", orm.FieldOpts{
String: "Order Date", Required: true, Index: true, Default: "today",
}),
orm.Date("validity_date", orm.FieldOpts{String: "Expiration"}),
)
// -- Company & Currency --
m.AddFields(
orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true,
}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{
String: "Currency", Required: true,
}),
)
// -- Order Lines --
m.AddFields(
orm.One2many("order_line", "sale.order.line", "order_id", orm.FieldOpts{
String: "Order Lines",
}),
)
// -- Amounts (Computed) --
m.AddFields(
orm.Monetary("amount_untaxed", orm.FieldOpts{
String: "Untaxed Amount", Compute: "_compute_amounts", Store: true, CurrencyField: "currency_id",
}),
orm.Monetary("amount_tax", orm.FieldOpts{
String: "Taxes", Compute: "_compute_amounts", Store: true, CurrencyField: "currency_id",
}),
orm.Monetary("amount_total", orm.FieldOpts{
String: "Total", Compute: "_compute_amounts", Store: true, CurrencyField: "currency_id",
}),
)
// -- Invoice Status --
m.AddFields(
orm.Selection("invoice_status", []orm.SelectionItem{
{Value: "upselling", Label: "Upselling Opportunity"},
{Value: "invoiced", Label: "Fully Invoiced"},
{Value: "to invoice", Label: "To Invoice"},
{Value: "no", Label: "Nothing to Invoice"},
}, orm.FieldOpts{String: "Invoice Status", Compute: "_compute_invoice_status", Store: true}),
)
// -- Accounting & Terms --
m.AddFields(
orm.Many2one("fiscal_position_id", "account.fiscal.position", orm.FieldOpts{
String: "Fiscal Position",
}),
orm.Many2one("payment_term_id", "account.payment.term", orm.FieldOpts{
String: "Payment Terms",
}),
orm.Many2one("pricelist_id", "product.pricelist", orm.FieldOpts{
String: "Pricelist",
}),
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{
String: "Invoicing Journal",
}),
)
// -- Tags --
m.AddFields(
orm.Many2many("tag_ids", "crm.tag", orm.FieldOpts{String: "Tags"}),
)
// -- Counts (Computed placeholders) --
m.AddFields(
orm.Integer("invoice_count", orm.FieldOpts{
String: "Invoice Count", Compute: "_compute_invoice_count", Store: false,
}),
orm.Integer("delivery_count", orm.FieldOpts{
String: "Delivery Count", Compute: "_compute_delivery_count", Store: false,
}),
)
// -- Misc --
m.AddFields(
orm.Text("note", orm.FieldOpts{String: "Terms and Conditions"}),
orm.Boolean("require_signature", orm.FieldOpts{String: "Online Signature", Default: true}),
orm.Boolean("require_payment", orm.FieldOpts{String: "Online Payment"}),
)
// -- Computed: _compute_amounts --
// Computes untaxed, tax, and total amounts from order lines.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amounts()
computeSaleAmounts := func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var untaxed, tax, total float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT
COALESCE(SUM(price_subtotal), 0),
COALESCE(SUM(price_total - price_subtotal), 0),
COALESCE(SUM(price_total), 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
soID).Scan(&untaxed, &tax, &total)
if err != nil {
// Fallback: compute from raw line values if price_subtotal/price_total not yet stored
err = env.Tx().QueryRow(env.Ctx(),
`SELECT
COALESCE(SUM(product_uom_qty * price_unit * (1 - COALESCE(discount,0)/100)), 0),
COALESCE(SUM(
product_uom_qty * price_unit * (1 - COALESCE(discount,0)/100)
* COALESCE((SELECT t.amount / 100 FROM account_tax t
JOIN account_tax_sale_order_line_rel rel ON rel.account_tax_id = t.id
WHERE rel.sale_order_line_id = sol.id LIMIT 1), 0)
), 0)
FROM sale_order_line sol WHERE sol.order_id = $1
AND (sol.display_type IS NULL OR sol.display_type = '' OR sol.display_type = 'product')`,
soID).Scan(&untaxed, &tax)
if err != nil {
return nil, fmt.Errorf("sale: compute amounts for SO %d: %w", soID, err)
}
total = untaxed + tax
}
return orm.Values{
"amount_untaxed": untaxed,
"amount_tax": tax,
"amount_total": total,
}, nil
}
m.RegisterCompute("amount_untaxed", computeSaleAmounts)
m.RegisterCompute("amount_tax", computeSaleAmounts)
m.RegisterCompute("amount_total", computeSaleAmounts)
// -- Computed: _compute_invoice_count --
// Counts the number of invoices linked to this sale order.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_invoice_count()
computeInvoiceCount := func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var count int
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_move WHERE invoice_origin = $1 AND move_type = 'out_invoice'`,
fmt.Sprintf("SO%d", soID)).Scan(&count)
if err != nil {
count = 0
}
return orm.Values{"invoice_count": count}, nil
}
m.RegisterCompute("invoice_count", computeInvoiceCount)
// -- Computed: _compute_delivery_count --
// Counts the number of delivery pickings linked to this sale order.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_delivery_count()
computeDeliveryCount := func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var soName string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, '') FROM sale_order WHERE id = $1`, soID).Scan(&soName)
if err != nil {
return orm.Values{"delivery_count": 0}, nil
}
var count int
err = env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM stock_picking WHERE origin = $1`, soName).Scan(&count)
if err != nil {
count = 0
}
return orm.Values{"delivery_count": count}, nil
}
m.RegisterCompute("delivery_count", computeDeliveryCount)
// -- DefaultGet: Provide dynamic defaults for new records --
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.default_get()
// Supplies company_id, currency_id, date_order when creating a new quotation.
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
vals := make(orm.Values)
// Default company from the current user's session
companyID := env.CompanyID()
if companyID > 0 {
vals["company_id"] = companyID
}
// Default currency from the company
var currencyID int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT currency_id FROM res_company WHERE id = $1`, companyID).Scan(&currencyID)
if err == nil && currencyID > 0 {
vals["currency_id"] = currencyID
}
// Default date_order = now
vals["date_order"] = time.Now().Format("2006-01-02 15:04:05")
return vals
}
// -- Sequence Hook --
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
name, _ := vals["name"].(string)
if name == "" || name == "/" {
seq, err := orm.NextByCode(env, "sale.order")
if err != nil {
// Fallback: generate a simple name like purchase.order does
vals["name"] = fmt.Sprintf("SO/%d", time.Now().UnixNano()%100000)
} else {
vals["name"] = seq
}
}
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, name string
var partnerID int64
var dateOrder *time.Time
err := env.Tx().QueryRow(env.Ctx(),
`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
})
// create_invoices: Generate invoice from confirmed sale order
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._create_invoices()
m.RegisterMethod("create_invoices", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
var invoiceIDs []int64
for _, soID := range rs.IDs() {
// Read SO header
var partnerID, companyID, currencyID int64
var journalID int64
var soName string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT partner_id, company_id, currency_id, COALESCE(journal_id, 0), COALESCE(name, '')
FROM sale_order WHERE id = $1`, soID,
).Scan(&partnerID, &companyID, &currencyID, &journalID, &soName)
if err != nil {
return nil, fmt.Errorf("sale: read SO %d: %w", soID, err)
}
// Find sales journal if not set on SO
if journalID == 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_journal
WHERE type = 'sale' AND active = true AND company_id = $1
ORDER BY sequence, id LIMIT 1`, companyID,
).Scan(&journalID)
}
if journalID == 0 {
return nil, fmt.Errorf("sale: no sales journal found for company %d", companyID)
}
// Read SO lines
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id, COALESCE(name,''), COALESCE(product_uom_qty,1),
COALESCE(price_unit,0), COALESCE(discount,0), COALESCE(product_id, 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, err
}
type soLine struct {
id int64
name string
qty float64
price float64
discount float64
productID int64
}
var lines []soLine
for rows.Next() {
var l soLine
if err := rows.Scan(&l.id, &l.name, &l.qty, &l.price, &l.discount, &l.productID); err != nil {
rows.Close()
return nil, err
}
lines = append(lines, l)
}
rows.Close()
if len(lines) == 0 {
continue
}
// Create invoice header (draft)
invoiceRS := env.Model("account.move")
inv, err := invoiceRS.Create(orm.Values{
"move_type": "out_invoice",
"partner_id": partnerID,
"company_id": companyID,
"currency_id": currencyID,
"journal_id": journalID,
"invoice_origin": soName,
"date": time.Now().Format("2006-01-02"),
})
if err != nil {
return nil, fmt.Errorf("sale: create invoice header for SO %d: %w", soID, err)
}
moveID := inv.ID()
// Find the revenue account (8300 Erlöse 19% USt or fallback)
var revenueAccountID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account WHERE code = '8300' AND company_id = $1 LIMIT 1`,
companyID,
).Scan(&revenueAccountID)
if revenueAccountID == 0 {
// Fallback: any income account
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account
WHERE account_type LIKE 'income%' AND company_id = $1
ORDER BY code LIMIT 1`, companyID,
).Scan(&revenueAccountID)
}
if revenueAccountID == 0 {
// Fallback: journal default account
env.Tx().QueryRow(env.Ctx(),
`SELECT default_account_id FROM account_journal WHERE id = $1`, journalID,
).Scan(&revenueAccountID)
}
// Find the receivable account
var receivableAccountID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account
WHERE account_type = 'asset_receivable' AND company_id = $1
ORDER BY code LIMIT 1`, companyID,
).Scan(&receivableAccountID)
if receivableAccountID == 0 {
return nil, fmt.Errorf("sale: no receivable account found for company %d", companyID)
}
lineRS := env.Model("account.move.line")
var totalCredit float64 // accumulates all product + tax credits
// Create product lines and tax lines for each SO line
for _, line := range lines {
baseAmount := line.qty * line.price * (1 - line.discount/100)
// Determine revenue account: try product-specific, then default
lineAccountID := revenueAccountID
if line.productID > 0 {
var prodAccID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(pc.property_account_income_categ_id, 0)
FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
JOIN product_category pc ON pc.id = pt.categ_id
WHERE pp.id = $1`, line.productID,
).Scan(&prodAccID)
if prodAccID > 0 {
lineAccountID = prodAccID
}
}
// Product line (credit side for revenue on out_invoice)
productLineVals := orm.Values{
"move_id": moveID,
"name": line.name,
"quantity": line.qty,
"price_unit": line.price,
"discount": line.discount,
"account_id": lineAccountID,
"company_id": companyID,
"journal_id": journalID,
"currency_id": currencyID,
"partner_id": partnerID,
"display_type": "product",
"debit": 0.0,
"credit": baseAmount,
"balance": -baseAmount,
}
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)
FROM account_tax t
JOIN account_tax_sale_order_line_rel rel ON rel.account_tax_id = t.id
WHERE rel.sale_order_line_id = $1`, line.id)
if err == nil {
for taxRows.Next() {
var taxID int64
var taxName string
var taxRate float64
var amountType string
var priceInclude bool
if err := taxRows.Scan(&taxID, &taxName, &taxRate, &amountType, &priceInclude); err != nil {
taxRows.Close()
break
}
// Compute tax amount (mirrors account_tax_calc.go ComputeTax)
var taxAmount float64
switch amountType {
case "percent":
if priceInclude {
taxAmount = baseAmount - (baseAmount / (1 + taxRate/100))
} else {
taxAmount = baseAmount * taxRate / 100
}
case "fixed":
taxAmount = taxRate
case "division":
if priceInclude {
taxAmount = baseAmount - (baseAmount / (1 + taxRate/100))
} else {
taxAmount = baseAmount * taxRate / 100
}
}
if taxAmount == 0 {
continue
}
// Find tax account from repartition lines
var taxAccountID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(account_id, 0) FROM account_tax_repartition_line
WHERE tax_id = $1 AND repartition_type = 'tax' AND document_type = 'invoice'
LIMIT 1`, taxID,
).Scan(&taxAccountID)
if taxAccountID == 0 {
// Fallback: USt account 1776 (SKR03)
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account WHERE code = '1776' LIMIT 1`,
).Scan(&taxAccountID)
}
if taxAccountID == 0 {
taxAccountID = lineAccountID // ultimate fallback
}
taxLineVals := orm.Values{
"move_id": moveID,
"name": taxName,
"quantity": 1.0,
"account_id": taxAccountID,
"company_id": companyID,
"journal_id": journalID,
"currency_id": currencyID,
"partner_id": partnerID,
"display_type": "tax",
"tax_line_id": taxID,
"debit": 0.0,
"credit": taxAmount,
"balance": -taxAmount,
}
if _, err := lineRS.Create(taxLineVals); err != nil {
taxRows.Close()
return nil, fmt.Errorf("sale: create invoice tax line: %w", err)
}
totalCredit += taxAmount
}
taxRows.Close()
}
}
// Create the receivable line (debit = total of all credits)
receivableVals := orm.Values{
"move_id": moveID,
"name": "/",
"quantity": 1.0,
"account_id": receivableAccountID,
"company_id": companyID,
"journal_id": journalID,
"currency_id": currencyID,
"partner_id": partnerID,
"display_type": "payment_term",
"debit": totalCredit,
"credit": 0.0,
"balance": totalCredit,
"amount_residual": totalCredit,
"amount_residual_currency": totalCredit,
}
if _, err := lineRS.Create(receivableVals); err != nil {
return nil, fmt.Errorf("sale: create invoice receivable line: %w", err)
}
invoiceIDs = append(invoiceIDs, moveID)
// Update qty_invoiced on SO lines
for _, line := range lines {
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order_line SET qty_invoiced = COALESCE(qty_invoiced, 0) + $1 WHERE id = $2`,
line.qty, line.id)
}
// 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 = $1 WHERE id = $2`, invStatus, soID)
}
if len(invoiceIDs) == 0 {
return false, nil
}
// Return action to open the created invoice(s)
// Mirrors: odoo/addons/sale/models/sale_order.py action_view_invoice()
if len(invoiceIDs) == 1 {
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": "account.move",
"res_id": invoiceIDs[0],
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"target": "current",
}, nil
}
// Multiple invoices → list view
ids := make([]interface{}, len(invoiceIDs))
for i, id := range invoiceIDs {
ids[i] = id
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": "account.move",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"domain": []interface{}{[]interface{}{"id", "in", ids}},
"target": "current",
}, nil
})
// action_cancel: Cancel a sale order and linked draft invoices.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_cancel()
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, soID := range rs.IDs() {
// Cancel linked draft invoices
rows, _ := env.Tx().Query(env.Ctx(),
`SELECT id FROM account_move WHERE invoice_origin = (SELECT name FROM sale_order WHERE id = $1) AND state = 'draft'`, soID)
for rows.Next() {
var invID int64
rows.Scan(&invID)
env.Tx().Exec(env.Ctx(), `UPDATE account_move SET state = 'cancel' WHERE id = $1`, invID)
}
rows.Close()
env.Tx().Exec(env.Ctx(), `UPDATE sale_order SET state = 'cancel' WHERE id = $1`, soID)
}
return true, nil
})
// action_draft: Reset a cancelled sale order back to quotation.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_draft()
m.RegisterMethod("action_draft", 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 = 'draft' WHERE id = $1 AND state = 'cancel'`, soID)
}
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) {
env := rs.Env()
soID := rs.IDs()[0]
var soName string
env.Tx().QueryRow(env.Ctx(), `SELECT name FROM sale_order WHERE id = $1`, soID).Scan(&soName)
// Find invoices linked to this SO
rows, _ := env.Tx().Query(env.Ctx(),
`SELECT id FROM account_move WHERE invoice_origin = $1`, soName)
var invIDs []interface{}
for rows.Next() {
var id int64
rows.Scan(&id)
invIDs = append(invIDs, id)
}
rows.Close()
if len(invIDs) == 1 {
return map[string]interface{}{
"type": "ir.actions.act_window", "res_model": "account.move",
"res_id": invIDs[0], "view_mode": "form",
"views": [][]interface{}{{nil, "form"}}, "target": "current",
}, nil
}
return map[string]interface{}{
"type": "ir.actions.act_window", "res_model": "account.move",
"view_mode": "list,form", "views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"domain": []interface{}{[]interface{}{"id", "in", invIDs}}, "target": "current",
}, nil
})
// action_create_delivery: Generate a stock picking (delivery) from a confirmed sale order.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._action_confirm() → _create_picking()
m.RegisterMethod("action_create_delivery", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
var pickingIDs []int64
for _, soID := range rs.IDs() {
// Read SO header for partner and company
var partnerID, companyID int64
var soName string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_shipping_id, partner_id), company_id, name
FROM sale_order WHERE id = $1`, soID,
).Scan(&partnerID, &companyID, &soName)
if err != nil {
return nil, fmt.Errorf("sale: read SO %d for delivery: %w", soID, err)
}
// Read SO lines with products
rows, err := env.Tx().Query(env.Ctx(),
`SELECT product_id, product_uom_qty, COALESCE(name, '') 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)
if err != nil {
return nil, fmt.Errorf("sale: read SO lines %d for delivery: %w", soID, err)
}
type soline struct {
productID int64
qty float64
name string
}
var lines []soline
for rows.Next() {
var l soline
if err := rows.Scan(&l.productID, &l.qty, &l.name); err != nil {
rows.Close()
return nil, err
}
lines = append(lines, l)
}
rows.Close()
if len(lines) == 0 {
continue
}
// Find outgoing picking type and locations
var pickingTypeID, srcLocID, destLocID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT pt.id, COALESCE(pt.default_location_src_id, 0), COALESCE(pt.default_location_dest_id, 0)
FROM stock_picking_type pt
WHERE pt.code = 'outgoing' AND pt.company_id = $1
LIMIT 1`, companyID,
).Scan(&pickingTypeID, &srcLocID, &destLocID)
// Fallback: find internal and customer locations
if srcLocID == 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM stock_location WHERE usage = 'internal' AND COALESCE(company_id, $1) = $1 LIMIT 1`,
companyID).Scan(&srcLocID)
}
if destLocID == 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM stock_location WHERE usage = 'customer' LIMIT 1`).Scan(&destLocID)
}
if pickingTypeID == 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM stock_picking_type WHERE code = 'outgoing' LIMIT 1`).Scan(&pickingTypeID)
}
// Create picking
var pickingID int64
err = env.Tx().QueryRow(env.Ctx(),
`INSERT INTO stock_picking
(name, state, scheduled_date, company_id, partner_id, picking_type_id,
location_id, location_dest_id, origin)
VALUES ($1, 'confirmed', NOW(), $2, $3, $4, $5, $6, $7) RETURNING id`,
fmt.Sprintf("WH/OUT/%05d", soID), companyID, partnerID, pickingTypeID,
srcLocID, destLocID, soName,
).Scan(&pickingID)
if err != nil {
return nil, fmt.Errorf("sale: create picking for SO %d: %w", soID, err)
}
// Create stock moves
for _, l := range lines {
_, err = env.Tx().Exec(env.Ctx(),
`INSERT INTO stock_move
(name, product_id, product_uom_qty, state, picking_id, company_id,
location_id, location_dest_id, date, origin, product_uom)
VALUES ($1, $2, $3, 'confirmed', $4, $5, $6, $7, NOW(), $8, 1)`,
l.name, l.productID, l.qty, pickingID, companyID,
srcLocID, destLocID, soName)
if err != nil {
return nil, fmt.Errorf("sale: create stock move for SO %d: %w", soID, err)
}
}
pickingIDs = append(pickingIDs, pickingID)
}
return pickingIDs, nil
})
// action_create_down_payment: Create a deposit invoice for a percentage of the SO total.
// Mirrors: odoo/addons/sale/wizard/sale_make_invoice_advance.py
m.RegisterMethod("action_create_down_payment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
soID := rs.IDs()[0]
percentage := float64(10) // Default 10%
if len(args) > 0 {
if p, ok := args[0].(float64); ok {
percentage = p
}
}
var total float64
var partnerID, companyID, currencyID, journalID int64
var soName string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(amount_total, 0), partner_id, company_id, currency_id,
COALESCE(journal_id, 0), COALESCE(name, '')
FROM sale_order WHERE id = $1`, soID,
).Scan(&total, &partnerID, &companyID, &currencyID, &journalID, &soName)
if err != nil {
return nil, fmt.Errorf("sale: read SO %d for down payment: %w", soID, err)
}
downAmount := total * percentage / 100
// Find sales journal if not set on SO
if journalID == 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_journal
WHERE type = 'sale' AND active = true AND company_id = $1
ORDER BY sequence, id LIMIT 1`, companyID,
).Scan(&journalID)
}
if journalID == 0 {
journalID = 1
}
// Create deposit invoice
invoiceRS := env.Model("account.move")
inv, err := invoiceRS.Create(orm.Values{
"move_type": "out_invoice",
"partner_id": partnerID,
"company_id": companyID,
"currency_id": currencyID,
"journal_id": journalID,
"invoice_origin": soName,
"date": time.Now().Format("2006-01-02"),
})
if err != nil {
return nil, fmt.Errorf("sale: create down payment invoice for SO %d: %w", soID, err)
}
moveID := inv.ID()
// Find revenue account
var revenueAccountID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account WHERE code = '8300' AND company_id = $1 LIMIT 1`,
companyID).Scan(&revenueAccountID)
if revenueAccountID == 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account
WHERE account_type LIKE 'income%%' AND company_id = $1
ORDER BY code LIMIT 1`, companyID).Scan(&revenueAccountID)
}
// Find receivable account
var receivableAccountID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account
WHERE account_type = 'asset_receivable' AND company_id = $1
ORDER BY code LIMIT 1`, companyID).Scan(&receivableAccountID)
if receivableAccountID == 0 {
receivableAccountID = revenueAccountID
}
lineRS := env.Model("account.move.line")
// Down payment product line (credit)
_, err = lineRS.Create(orm.Values{
"move_id": moveID,
"name": fmt.Sprintf("Down payment of %.0f%%", percentage),
"quantity": 1.0,
"price_unit": downAmount,
"account_id": revenueAccountID,
"company_id": companyID,
"journal_id": journalID,
"currency_id": currencyID,
"partner_id": partnerID,
"display_type": "product",
"debit": 0.0,
"credit": downAmount,
"balance": -downAmount,
})
if err != nil {
return nil, fmt.Errorf("sale: create down payment line: %w", err)
}
// Receivable line (debit)
_, err = lineRS.Create(orm.Values{
"move_id": moveID,
"name": "/",
"quantity": 1.0,
"account_id": receivableAccountID,
"company_id": companyID,
"journal_id": journalID,
"currency_id": currencyID,
"partner_id": partnerID,
"display_type": "payment_term",
"debit": downAmount,
"credit": 0.0,
"balance": downAmount,
"amount_residual": downAmount,
"amount_residual_currency": downAmount,
})
if err != nil {
return nil, fmt.Errorf("sale: create down payment receivable line: %w", err)
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": "account.move",
"res_id": moveID,
"view_mode": "form",
"target": "current",
}, nil
})
}
// initSaleOrderLine registers sale.order.line — individual line items on a sales order.
// Mirrors: odoo/addons/sale/models/sale_order_line.py
func initSaleOrderLine() {
m := orm.NewModel("sale.order.line", orm.ModelOpts{
Description: "Sales Order Line",
Order: "order_id, sequence, id",
})
// -- Onchange: product_id → name + price_unit --
// Mirrors: odoo/addons/sale/models/sale_order_line.py _compute_name(), _compute_price_unit()
// When the user selects a product on a SO line, automatically fill in the
// description from the product name and the unit price from the product list price.
m.RegisterOnchange("product_id", func(env *orm.Environment, vals orm.Values) orm.Values {
result := make(orm.Values)
// Extract product_id — may arrive as int64, int32, float64, or map with "id" key
var productID int64
switch v := vals["product_id"].(type) {
case int64:
productID = v
case int32:
productID = int64(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
}
// Read product name and list price
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
})
// -- Parent --
m.AddFields(
orm.Many2one("order_id", "sale.order", orm.FieldOpts{
String: "Order Reference", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
)
// -- Product --
m.AddFields(
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product",
}),
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
orm.Float("product_uom_qty", orm.FieldOpts{String: "Quantity", Required: true, Default: 1.0}),
orm.Many2one("product_uom", "uom.uom", orm.FieldOpts{String: "Unit of Measure"}),
)
// -- Pricing --
m.AddFields(
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price", Required: true}),
orm.Many2many("tax_id", "account.tax", orm.FieldOpts{String: "Taxes"}),
orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}),
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{
{Value: "line_section", Label: "Section"},
{Value: "line_note", Label: "Note"},
}, orm.FieldOpts{String: "Display Type"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
// -- Computed: _compute_amount (line subtotal/total) --
// Computes price_subtotal and price_total from qty, price, discount, and taxes.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_amount()
computeLineAmount := func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var qty, price, discount float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(product_uom_qty, 0), COALESCE(price_unit, 0), COALESCE(discount, 0)
FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&qty, &price, &discount)
subtotal := qty * price * (1 - discount/100)
// Compute tax amount from linked taxes via M2M (inline, no cross-package call)
var taxTotal float64
taxRows, err := env.Tx().Query(env.Ctx(),
`SELECT t.amount, t.amount_type, COALESCE(t.price_include, false)
FROM account_tax t
JOIN account_tax_sale_order_line_rel rel ON rel.account_tax_id = t.id
WHERE rel.sale_order_line_id = $1`, lineID)
if err == nil {
for taxRows.Next() {
var taxRate float64
var amountType string
var priceInclude bool
if err := taxRows.Scan(&taxRate, &amountType, &priceInclude); err != nil {
break
}
switch amountType {
case "percent":
if priceInclude {
taxTotal += subtotal - (subtotal / (1 + taxRate/100))
} else {
taxTotal += subtotal * taxRate / 100
}
case "fixed":
taxTotal += taxRate
case "division":
if priceInclude {
taxTotal += subtotal - (subtotal / (1 + taxRate/100))
} else {
taxTotal += subtotal * taxRate / 100
}
}
}
taxRows.Close()
}
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 --
m.AddFields(
orm.Float("qty_delivered", orm.FieldOpts{String: "Delivered Quantity"}),
orm.Float("qty_invoiced", orm.FieldOpts{
String: "Invoiced Quantity", Compute: "_compute_qty_invoiced", Store: true,
}),
orm.Float("qty_to_invoice", orm.FieldOpts{
String: "To Invoice Quantity", Compute: "_compute_qty_to_invoice", Store: true,
}),
orm.Many2many("invoice_lines", "account.move.line", orm.FieldOpts{
String: "Invoice Lines",
}),
)
}