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.

%s
DescriptionQtyUnit PriceDiscountSubtotal
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) }