package models import ( "fmt" "time" "odoo-go/pkg/orm" ) // 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.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 }) // -- 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 }) // _compute_amount_to_invoice: Compute total amount still to invoice. // Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amounts() so.RegisterMethod("_compute_amount_to_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, 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(qty_invoiced * price_unit * (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) return total - invoiced, 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_invoiced: Compute invoiced quantity from linked invoice lines. // 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] var qtyInvoiced float64 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_qty_to_invoice: Quantity to invoice = qty - qty_invoiced (if delivered policy: 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, 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) toInvoice := qty - qtyInvoiced if toInvoice < 0 { toInvoice = 0 } return orm.Values{"qty_to_invoice": toInvoice}, nil }) } // initSaleOrderDiscount registers the sale.order.discount wizard. // 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 (%)", 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. // 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 discount float64 var orderID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(discount, 0), COALESCE(sale_order_id, 0) FROM sale_order_discount WHERE id = $1`, wizID, ).Scan(&discount, &orderID) if orderID == 0 { return nil, fmt.Errorf("sale_discount: no sale order linked") } _, 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')`, discount, orderID) if err != nil { return nil, fmt.Errorf("sale_discount: apply 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 }) }