package models import ( "fmt" "time" "odoo-go/pkg/orm" ) // initPurchaseOrderExtension extends purchase.order with additional fields and methods. // Mirrors: odoo/addons/purchase/models/purchase_order.py (additional workflow fields) // odoo/addons/purchase_stock/models/purchase_order.py (stock integration) func initPurchaseOrderExtension() { po := orm.ExtendModel("purchase.order") // -- Additional Fields -- // Note: date_planned, date_approve, origin, invoice_status already exist on purchase.order po.AddFields( orm.Boolean("is_shipped", orm.FieldOpts{ String: "Fully Shipped", Compute: "_compute_is_shipped", }), orm.Integer("invoice_count", orm.FieldOpts{ String: "Bill Count", Compute: "_compute_invoice_count", }), orm.Integer("picking_count", orm.FieldOpts{ String: "Receipt Count", Compute: "_compute_picking_count", }), orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Purchase Representative", Index: true}), orm.Many2one("dest_address_id", "res.partner", orm.FieldOpts{String: "Dropship Address"}), orm.Boolean("mail_reception_confirmed", orm.FieldOpts{String: "Receipt Confirmation Sent"}), orm.Boolean("mail_reminder_confirmed", orm.FieldOpts{String: "Reminder Sent"}), orm.Datetime("receipt_reminder_email", orm.FieldOpts{String: "Receipt Reminder Email"}), orm.Datetime("effective_date", orm.FieldOpts{String: "Effective Date"}), orm.Integer("incoming_picking_count", orm.FieldOpts{ String: "Incoming Shipment Count", Compute: "_compute_incoming_picking_count", }), ) // -- Computed: _compute_is_shipped -- // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py PurchaseOrder._compute_is_shipped() po.RegisterCompute("is_shipped", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() poID := rs.IDs()[0] var totalQty, receivedQty float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(product_qty), 0), COALESCE(SUM(qty_received), 0) FROM purchase_order_line WHERE order_id = $1`, poID, ).Scan(&totalQty, &receivedQty) shipped := totalQty > 0 && receivedQty >= totalQty return orm.Values{"is_shipped": shipped}, nil }) // -- Computed: _compute_invoice_count -- // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder._compute_invoice() po.RegisterCompute("invoice_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() poID := rs.IDs()[0] // Bills linked via invoice_origin var poName string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName) var count int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM account_move WHERE invoice_origin = $1 AND move_type = 'in_invoice'`, poName, ).Scan(&count) // Also check by PO ID pattern fallback if count == 0 { env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM account_move WHERE invoice_origin = $1 AND move_type = 'in_invoice'`, fmt.Sprintf("PO%d", poID), ).Scan(&count) } return orm.Values{"invoice_count": count}, nil }) // -- Computed: _compute_picking_count -- // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py PurchaseOrder._compute_picking() po.RegisterCompute("picking_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() poID := rs.IDs()[0] var poName string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName) var count int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM stock_picking WHERE origin = $1`, poName, ).Scan(&count) return orm.Values{"picking_count": count}, nil }) // -- Computed: _compute_incoming_picking_count -- po.RegisterCompute("incoming_picking_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() poID := rs.IDs()[0] var poName string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName) var count int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM stock_picking sp JOIN stock_picking_type spt ON spt.id = sp.picking_type_id WHERE sp.origin = $1 AND spt.code = 'incoming'`, poName, ).Scan(&count) return orm.Values{"incoming_picking_count": count}, nil }) // action_view_picking: Open the receipts (incoming pickings) linked to this PO. // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py PurchaseOrder.action_view_picking() po.RegisterMethod("action_view_picking", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() poID := rs.IDs()[0] var poName string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName) rows, err := env.Tx().Query(env.Ctx(), `SELECT id FROM stock_picking WHERE origin = $1`, poName) if err != nil { return nil, fmt.Errorf("purchase: view picking 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", "name": "Receipts", }, nil }) // action_view_invoice: Open vendor bills linked to this PO. // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_view_invoice() po.RegisterMethod("action_view_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() poID := rs.IDs()[0] var poName string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&poName) rows, err := env.Tx().Query(env.Ctx(), `SELECT id FROM account_move WHERE invoice_origin = $1 AND move_type = 'in_invoice'`, poName) if err != nil { return nil, fmt.Errorf("purchase: view invoice query: %w", err) } defer rows.Close() var invIDs []interface{} for rows.Next() { var id int64 rows.Scan(&id) invIDs = append(invIDs, id) } // Also check by PO ID pattern fallback if len(invIDs) == 0 { rows2, _ := env.Tx().Query(env.Ctx(), `SELECT id FROM account_move WHERE invoice_origin = $1 AND move_type = 'in_invoice'`, fmt.Sprintf("PO%d", poID)) if rows2 != nil { for rows2.Next() { var id int64 rows2.Scan(&id) invIDs = append(invIDs, id) } rows2.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", "name": "Vendor Bills", }, nil }) // action_create_picking: Generate incoming stock picking from a confirmed PO. // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py PurchaseOrder._create_picking() po.RegisterMethod("action_create_picking", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() var pickingIDs []int64 for _, poID := range rs.IDs() { var partnerID, companyID int64 var poName string err := env.Tx().QueryRow(env.Ctx(), `SELECT partner_id, company_id, COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID, ).Scan(&partnerID, &companyID, &poName) if err != nil { return nil, fmt.Errorf("purchase: read PO %d for picking: %w", poID, err) } // Read PO lines with products rows, err := env.Tx().Query(env.Ctx(), `SELECT product_id, product_qty, COALESCE(name, '') FROM purchase_order_line WHERE order_id = $1 AND product_id IS NOT NULL`, poID) if err != nil { return nil, fmt.Errorf("purchase: read PO lines %d for picking: %w", poID, err) } type poline struct { productID int64 qty float64 name string } var lines []poline for rows.Next() { var l poline 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 incoming 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 = 'incoming' AND pt.company_id = $1 LIMIT 1`, companyID, ).Scan(&pickingTypeID, &srcLocID, &destLocID) if srcLocID == 0 { env.Tx().QueryRow(env.Ctx(), `SELECT id FROM stock_location WHERE usage = 'supplier' LIMIT 1`).Scan(&srcLocID) } if destLocID == 0 { env.Tx().QueryRow(env.Ctx(), `SELECT id FROM stock_location WHERE usage = 'internal' AND COALESCE(company_id, $1) = $1 LIMIT 1`, companyID).Scan(&destLocID) } if pickingTypeID == 0 { env.Tx().QueryRow(env.Ctx(), `SELECT id FROM stock_picking_type WHERE code = 'incoming' 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/IN/%05d", poID), companyID, partnerID, pickingTypeID, srcLocID, destLocID, poName, ).Scan(&pickingID) if err != nil { return nil, fmt.Errorf("purchase: create picking for PO %d: %w", poID, 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, poName) if err != nil { return nil, fmt.Errorf("purchase: create stock move for PO %d: %w", poID, err) } } pickingIDs = append(pickingIDs, pickingID) } if len(pickingIDs) == 0 { return nil, nil } return pickingIDs, nil }) // button_approve: Approve a PO that requires approval (to approve → purchase). // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_approve() po.RegisterMethod("button_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, poID := range rs.IDs() { var state string env.Tx().QueryRow(env.Ctx(), `SELECT state FROM purchase_order WHERE id = $1`, poID).Scan(&state) if state != "to approve" { continue } env.Tx().Exec(env.Ctx(), `UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, poID) } return true, nil }) // button_done: Lock a confirmed PO. // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_done() po.RegisterMethod("button_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, poID := range rs.IDs() { env.Tx().Exec(env.Ctx(), `UPDATE purchase_order SET state = 'done' WHERE id = $1`, poID) } return true, nil }) // button_unlock: Unlock a locked PO back to purchase state. // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_unlock() po.RegisterMethod("button_unlock", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, poID := range rs.IDs() { env.Tx().Exec(env.Ctx(), `UPDATE purchase_order SET state = 'purchase' WHERE id = $1 AND state = 'done'`, poID) } return true, nil }) // action_rfq_send: Mark the PO as "sent" (RFQ has been emailed). // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_rfq_send() po.RegisterMethod("action_rfq_send", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, poID := range rs.IDs() { env.Tx().Exec(env.Ctx(), `UPDATE purchase_order SET state = 'sent' WHERE id = $1 AND state = 'draft'`, poID) } return true, nil }) // _compute_effective_date: The effective date is set when all products have been received. // Mirrors: odoo/addons/purchase_stock/models/purchase_order.py po.RegisterCompute("effective_date", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() poID := rs.IDs()[0] var totalQty, receivedQty float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(product_qty), 0), COALESCE(SUM(qty_received), 0) FROM purchase_order_line WHERE order_id = $1`, poID, ).Scan(&totalQty, &receivedQty) if totalQty > 0 && receivedQty >= totalQty { return orm.Values{"effective_date": time.Now()}, nil } return orm.Values{"effective_date": nil}, nil }) } // initPurchaseOrderLineExtension extends purchase.order.line with additional fields. // Mirrors: odoo/addons/purchase/models/purchase_order_line.py (additional fields) func initPurchaseOrderLineExtension() { pol := orm.ExtendModel("purchase.order.line") // Note: date_planned, qty_received, qty_invoiced already exist pol.AddFields( orm.Float("qty_received_manual", orm.FieldOpts{String: "Manual Received Qty"}), orm.Selection("invoice_status", []orm.SelectionItem{ {Value: "no", Label: "Nothing to Bill"}, {Value: "to invoice", Label: "Waiting Bills"}, {Value: "invoiced", Label: "Fully Billed"}, }, orm.FieldOpts{String: "Billing Status", Compute: "_compute_line_invoice_status"}), orm.Float("qty_to_invoice", orm.FieldOpts{ String: "To Invoice Quantity", Compute: "_compute_line_qty_to_invoice", }), orm.Char("product_type", orm.FieldOpts{String: "Product Type"}), orm.Boolean("product_qty_updated", orm.FieldOpts{String: "Qty Updated"}), ) // _compute_line_invoice_status: Per-line billing status. // Mirrors: odoo/addons/purchase/models/purchase_order_line.py _compute_qty_invoiced() pol.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_qty, 0), COALESCE(qty_invoiced, 0) FROM purchase_order_line WHERE id = $1`, lineID, ).Scan(&qty, &qtyInvoiced) status := "no" if qty > 0 { if qtyInvoiced >= qty { status = "invoiced" } else if qtyInvoiced > 0 { status = "to invoice" } else { status = "to invoice" } } return orm.Values{"invoice_status": status}, nil }) // _compute_line_qty_to_invoice pol.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_qty, 0), COALESCE(qty_invoiced, 0) FROM purchase_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 }) // _compute_qty_received: Uses manual received qty if set, otherwise from stock moves. // Mirrors: odoo/addons/purchase_stock/models/purchase_order_line.py _compute_qty_received() pol.RegisterCompute("qty_received", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() lineID := rs.IDs()[0] // Check for manual override var manual *float64 env.Tx().QueryRow(env.Ctx(), `SELECT qty_received_manual FROM purchase_order_line WHERE id = $1`, lineID).Scan(&manual) if manual != nil && *manual > 0 { return orm.Values{"qty_received": *manual}, nil } // Fallback: sum from linked stock moves var qty float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(qty_received, 0) FROM purchase_order_line WHERE id = $1`, lineID).Scan(&qty) return orm.Values{"qty_received": qty}, nil }) // _compute_price_subtotal and _compute_price_total for PO lines. // Mirrors: odoo/addons/purchase/models/purchase_order_line.py _compute_amount() computePOLineAmount := 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_qty, 0), COALESCE(price_unit, 0), COALESCE(discount, 0) FROM purchase_order_line WHERE id = $1`, lineID, ).Scan(&qty, &price, &discount) subtotal := qty * price * (1 - discount/100) // Compute tax from linked taxes 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_purchase_order_line_rel rel ON rel.account_tax_id = t.id WHERE rel.purchase_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 } } taxRows.Close() } return orm.Values{ "price_subtotal": subtotal, "price_total": subtotal + taxTotal, }, nil } pol.RegisterCompute("price_subtotal", computePOLineAmount) pol.RegisterCompute("price_total", computePOLineAmount) // Onchange: product_id → name, price_unit // Mirrors: odoo/addons/purchase/models/purchase_order_line.py _compute_price_unit_and_date_planned_and_name() pol.RegisterOnchange("product_id", func(env *orm.Environment, vals orm.Values) orm.Values { result := make(orm.Values) var productID int64 switch v := vals["product_id"].(type) { case int64: productID = 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 } var name string var standardPrice float64 err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(pt.name, ''), COALESCE(pt.standard_price, 0) FROM product_product pp JOIN product_template pt ON pt.id = pp.product_tmpl_id WHERE pp.id = $1`, productID, ).Scan(&name, &standardPrice) if err != nil { return result } result["name"] = name result["price_unit"] = standardPrice return result }) } // initResPartnerPurchaseExtension extends res.partner with purchase-specific fields. // Mirrors: odoo/addons/purchase/models/res_partner.py func initResPartnerPurchaseExtension() { partner := orm.ExtendModel("res.partner") partner.AddFields( orm.One2many("purchase_order_ids", "purchase.order", "partner_id", orm.FieldOpts{ String: "Purchase Orders", }), orm.Integer("purchase_order_count", orm.FieldOpts{ String: "Purchase Order Count", Compute: "_compute_purchase_order_count", }), orm.Monetary("purchase_order_total", orm.FieldOpts{ String: "Total Purchases", Compute: "_compute_purchase_order_total", CurrencyField: "currency_id", }), ) partner.RegisterCompute("purchase_order_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() partnerID := rs.IDs()[0] var count int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM purchase_order WHERE partner_id = $1`, partnerID).Scan(&count) return orm.Values{"purchase_order_count": count}, nil }) partner.RegisterCompute("purchase_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 purchase_order WHERE partner_id = $1 AND state IN ('purchase', 'done')`, partnerID).Scan(&total) return orm.Values{"purchase_order_total": total}, nil }) } // initPurchaseOrderAmount extends purchase.order with amount compute functions. // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder._compute_amount() func initPurchaseOrderAmount() { po := orm.ExtendModel("purchase.order") computeAmounts := func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() poID := 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 purchase_order_line WHERE order_id = $1`, poID, ).Scan(&untaxed, &tax, &total) if err != nil { // Fallback: compute from raw line values err = env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(product_qty * price_unit * (1 - COALESCE(discount,0)/100)), 0) FROM purchase_order_line WHERE order_id = $1`, poID, ).Scan(&untaxed) if err != nil { return nil, fmt.Errorf("purchase: compute amounts for PO %d: %w", poID, err) } total = untaxed tax = 0 } return orm.Values{ "amount_untaxed": untaxed, "amount_tax": tax, "amount_total": total, }, nil } po.RegisterCompute("amount_untaxed", computeAmounts) po.RegisterCompute("amount_tax", computeAmounts) po.RegisterCompute("amount_total", computeAmounts) // _compute_invoice_status for the whole PO. // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder._compute_invoice() po.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() poID := rs.IDs()[0] var state string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(state, 'draft') FROM purchase_order WHERE id = $1`, poID).Scan(&state) if state != "purchase" && state != "done" { return orm.Values{"invoice_status": "no"}, nil } var totalQty, totalInvoiced float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(product_qty), 0), COALESCE(SUM(qty_invoiced), 0) FROM purchase_order_line WHERE order_id = $1`, poID, ).Scan(&totalQty, &totalInvoiced) status := "no" if totalQty > 0 { if totalInvoiced >= totalQty { status = "invoiced" } else { status = "to invoice" } } return orm.Values{"invoice_status": status}, nil }) }