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