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
// methods, and computed fields.
// Mirrors: odoo/addons/sale/models/sale_order.py (additional fields)
// odoo/addons/sale_management/models/sale_order.py (template fields)
func initSaleOrderExtension() {
so := orm.ExtendModel("sale.order")
// -- Template & Additional Fields --
so.AddFields(
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",
}),
orm.Char("client_order_ref", orm.FieldOpts{String: "Customer Reference"}),
orm.Char("origin", orm.FieldOpts{String: "Source Document"}),
orm.Boolean("locked", orm.FieldOpts{String: "Locked", Readonly: true}),
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Salesperson", Index: true}),
orm.Many2one("team_id", "crm.team", orm.FieldOpts{String: "Sales Team"}),
orm.Char("reference", orm.FieldOpts{String: "Payment Reference"}),
orm.Datetime("commitment_date", orm.FieldOpts{String: "Delivery Date"}),
orm.Datetime("date_last_order_followup", orm.FieldOpts{String: "Last Follow-up Date"}),
orm.Text("internal_note", orm.FieldOpts{String: "Internal Note"}),
)
// -- Computed: is_expired --
// An SO is expired when validity_date is in the past and state is still draft/sent.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_is_expired()
so.RegisterCompute("is_expired", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var validityDate *time.Time
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT validity_date, state FROM sale_order WHERE id = $1`, soID,
).Scan(&validityDate, &state)
expired := false
if validityDate != nil && (state == "draft" || state == "sent") {
expired = validityDate.Before(time.Now().Truncate(24 * time.Hour))
}
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) {
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/done orders
if state != "sale" && state != "done" {
return orm.Values{"invoice_status": "no"}, nil
}
// Check line quantities
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)
status := "no"
if totalQty > 0 {
if totalInvoiced >= totalQty {
status = "invoiced"
} else if totalInvoiced > 0 {
status = "to invoice"
} else {
status = "to invoice"
}
}
return orm.Values{"invoice_status": status}, nil
})
// action_quotation_sent: Mark the SO as "sent" (quotation has been emailed).
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_quotation_sent()
so.RegisterMethod("action_quotation_sent", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, soID := range rs.IDs() {
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM sale_order WHERE id = $1`, soID).Scan(&state)
if state != "draft" {
continue
}
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET state = 'sent' WHERE id = $1`, soID)
}
return true, nil
})
// action_lock: Lock a confirmed sale order to prevent modifications.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_lock()
so.RegisterMethod("action_lock", 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 locked = true WHERE id = $1`, soID)
}
return true, nil
})
// action_unlock: Unlock a confirmed sale order.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_unlock()
so.RegisterMethod("action_unlock", 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 locked = false WHERE id = $1`, soID)
}
return true, nil
})
// action_view_delivery: Open delivery orders linked to this sale order.
// Mirrors: odoo/addons/sale_stock/models/sale_order.py SaleOrder.action_view_delivery()
so.RegisterMethod("action_view_delivery", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
soID := rs.IDs()[0]
var soName string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, '') FROM sale_order WHERE id = $1`, soID).Scan(&soName)
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id FROM stock_picking WHERE origin = $1`, soName)
if err != nil {
return nil, fmt.Errorf("sale: view delivery query: %w", err)
}
defer rows.Close()
var pickingIDs []interface{}
for rows.Next() {
var id int64
rows.Scan(&id)
pickingIDs = append(pickingIDs, id)
}
if len(pickingIDs) == 1 {
return map[string]interface{}{
"type": "ir.actions.act_window", "res_model": "stock.picking",
"res_id": pickingIDs[0], "view_mode": "form",
"views": [][]interface{}{{nil, "form"}}, "target": "current",
}, nil
}
return map[string]interface{}{
"type": "ir.actions.act_window", "res_model": "stock.picking",
"view_mode": "list,form", "views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"domain": []interface{}{[]interface{}{"id", "in", pickingIDs}}, "target": "current",
}, nil
})
// preview_quotation: Generate a preview URL for the quotation portal.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.preview_sale_order()
so.RegisterMethod("preview_quotation", 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
})
// action_open_discount_wizard: Open the discount wizard.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_open_discount_wizard()
so.RegisterMethod("action_open_discount_wizard", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": "sale.order.discount",
"view_mode": "form",
"target": "new",
}, nil
})
// _get_order_confirmation_date: Return the date when the SO was confirmed.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._get_order_confirmation_date()
so.RegisterMethod("_get_order_confirmation_date", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
soID := rs.IDs()[0]
var dateOrder *time.Time
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT date_order, state FROM sale_order WHERE id = $1`, soID).Scan(&dateOrder, &state)
if state == "sale" || state == "done" {
return dateOrder, nil
}
return nil, nil
})
// 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(
"
| %s | %.2f | "+
"%.2f | "+
"%.1f%% | "+
"%.2f |
",
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(`
%s
Dear %s,
Please find below your quotation %s.
| Description | Qty | Unit Price | Discount | Subtotal |
%s
| Total |
%.2f |
Do not hesitate to contact us if you have any questions.
`, 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]
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/.
// 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
})
}
// initSaleOrderLineExtension extends sale.order.line with additional fields and methods.
// Mirrors: odoo/addons/sale/models/sale_order_line.py (additional fields)
func initSaleOrderLineExtension() {
sol := orm.ExtendModel("sale.order.line")
sol.AddFields(
orm.Many2one("salesman_id", "res.users", orm.FieldOpts{String: "Salesperson"}),
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_line"}),
orm.Boolean("is_downpayment", orm.FieldOpts{String: "Is a down payment"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Float("product_packaging_qty", orm.FieldOpts{String: "Packaging Quantity"}),
orm.Char("product_type", orm.FieldOpts{String: "Product Type"}),
orm.Boolean("product_updatable", orm.FieldOpts{String: "Can Edit Product", Default: true}),
orm.Float("price_reduce", orm.FieldOpts{
String: "Price Reduce", Compute: "_compute_price_reduce",
}),
orm.Float("price_reduce_taxinc", orm.FieldOpts{
String: "Price Reduce Tax inc", Compute: "_compute_price_reduce_taxinc",
}),
orm.Float("price_reduce_taxexcl", orm.FieldOpts{
String: "Price Reduce Tax excl", Compute: "_compute_price_reduce_taxexcl",
}),
orm.Monetary("untaxed_amount_to_invoice", orm.FieldOpts{
String: "Untaxed Amount To Invoice", Compute: "_compute_untaxed_amount_to_invoice",
CurrencyField: "currency_id",
}),
orm.Monetary("untaxed_amount_invoiced", orm.FieldOpts{
String: "Untaxed Amount Invoiced", Compute: "_compute_untaxed_amount_invoiced",
CurrencyField: "currency_id",
}),
)
// _compute_invoice_status_line: Per-line invoice status.
// 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, qtyInvoiced float64
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)
status := "no"
if qty > 0 {
if qtyInvoiced >= qty {
status = "invoiced"
} else {
status = "to invoice"
}
}
return orm.Values{"invoice_status": status}, nil
})
// _compute_price_reduce: Unit price after discount.
sol.RegisterCompute("price_reduce", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var price, discount float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(price_unit, 0), COALESCE(discount, 0) FROM sale_order_line WHERE id = $1`,
lineID).Scan(&price, &discount)
return orm.Values{"price_reduce": price * (1 - discount/100)}, nil
})
// _compute_price_reduce_taxinc: Reduced price including taxes.
sol.RegisterCompute("price_reduce_taxinc", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var priceTotal, qty float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(price_total, 0), COALESCE(product_uom_qty, 1) FROM sale_order_line WHERE id = $1`,
lineID).Scan(&priceTotal, &qty)
val := float64(0)
if qty > 0 {
val = priceTotal / qty
}
return orm.Values{"price_reduce_taxinc": val}, nil
})
// _compute_price_reduce_taxexcl: Reduced price excluding taxes.
sol.RegisterCompute("price_reduce_taxexcl", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var priceSubtotal, qty float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(price_subtotal, 0), COALESCE(product_uom_qty, 1) FROM sale_order_line WHERE id = $1`,
lineID).Scan(&priceSubtotal, &qty)
val := float64(0)
if qty > 0 {
val = priceSubtotal / qty
}
return orm.Values{"price_reduce_taxexcl": val}, nil
})
// _compute_untaxed_amount_to_invoice
sol.RegisterCompute("untaxed_amount_to_invoice", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var qty, qtyInvoiced, price, discount float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(product_uom_qty, 0), COALESCE(qty_invoiced, 0),
COALESCE(price_unit, 0), COALESCE(discount, 0)
FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&qty, &qtyInvoiced, &price, &discount)
remaining := qty - qtyInvoiced
if remaining < 0 {
remaining = 0
}
return orm.Values{"untaxed_amount_to_invoice": remaining * price * (1 - discount/100)}, nil
})
// _compute_untaxed_amount_invoiced
sol.RegisterCompute("untaxed_amount_invoiced", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var qtyInvoiced, price, discount float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(qty_invoiced, 0), COALESCE(price_unit, 0), COALESCE(discount, 0)
FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&qtyInvoiced, &price, &discount)
return orm.Values{"untaxed_amount_invoiced": qtyInvoiced * price * (1 - discount/100)}, nil
})
// _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, qtyDelivered, qtyInvoiced float64
var productID *int64
var orderState string
env.Tx().QueryRow(env.Ctx(),
`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{
Description: "Sale Order Discount Wizard",
Type: orm.ModelTransient,
})
m.AddFields(
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 discountVal float64
var orderID int64
var discountType string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(discount, 0), COALESCE(sale_order_id, 0),
COALESCE(discount_type, 'percentage')
FROM sale_order_discount WHERE id = $1`, wizID,
).Scan(&discountVal, &orderID, &discountType)
if orderID == 0 {
return nil, fmt.Errorf("sale_discount: no sale order linked")
}
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
})
}
// initResPartnerSaleExtension2 adds additional sale-specific computed fields to res.partner.
// Mirrors: odoo/addons/sale/models/res_partner.py (additional fields)
func initResPartnerSaleExtension2() {
partner := orm.ExtendModel("res.partner")
partner.AddFields(
orm.Monetary("sale_order_total", orm.FieldOpts{
String: "Total Sales", Compute: "_compute_sale_order_total", CurrencyField: "currency_id",
}),
)
partner.RegisterCompute("sale_order_total", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
partnerID := rs.IDs()[0]
var total float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(amount_total::float8), 0) FROM sale_order
WHERE partner_id = $1 AND state IN ('sale', 'done')`, partnerID,
).Scan(&total)
return orm.Values{"sale_order_total": total}, nil
})
}
func htmlEscapeStr(s string) string { return html.EscapeString(s) }