package models import ( "fmt" "time" "odoo-go/pkg/orm" ) // initPurchaseOrder registers purchase.order and purchase.order.line. // Mirrors: odoo/addons/purchase/models/purchase_order.py func initPurchaseOrder() { // purchase.order — the purchase order header m := orm.NewModel("purchase.order", orm.ModelOpts{ Description: "Purchase Order", Order: "priority desc, id desc", RecName: "name", }) // -- Identity -- m.AddFields( orm.Char("name", orm.FieldOpts{ String: "Order Reference", Required: true, Index: true, Readonly: true, Default: "New", }), orm.Selection("state", []orm.SelectionItem{ {Value: "draft", Label: "RFQ"}, {Value: "sent", Label: "RFQ Sent"}, {Value: "to approve", Label: "To Approve"}, {Value: "purchase", Label: "Purchase Order"}, {Value: "done", Label: "Locked"}, {Value: "cancel", Label: "Cancelled"}, }, orm.FieldOpts{String: "Status", Default: "draft", Readonly: true, Index: true}), orm.Selection("priority", []orm.SelectionItem{ {Value: "0", Label: "Normal"}, {Value: "1", Label: "Urgent"}, }, orm.FieldOpts{String: "Priority", Default: "0", Index: true}), ) // -- Partner & Dates -- m.AddFields( orm.Many2one("partner_id", "res.partner", orm.FieldOpts{ String: "Vendor", Required: true, Index: true, }), orm.Datetime("date_order", orm.FieldOpts{ String: "Order Deadline", Required: true, Index: true, Default: "today", }), orm.Datetime("date_planned", orm.FieldOpts{ String: "Expected Arrival", }), orm.Datetime("date_approve", orm.FieldOpts{ String: "Confirmation Date", Readonly: true, Index: true, }), ) // -- Agreement Link -- m.AddFields( orm.Many2one("requisition_id", "purchase.requisition", orm.FieldOpts{ String: "Purchase Agreement", }), ) // -- 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, }), ) // -- Lines -- m.AddFields( orm.One2many("order_line", "purchase.order.line", "order_id", orm.FieldOpts{ String: "Order Lines", }), ) // -- Amounts -- m.AddFields( orm.Monetary("amount_untaxed", orm.FieldOpts{ String: "Untaxed Amount", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id", }), orm.Monetary("amount_tax", orm.FieldOpts{ String: "Taxes", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id", }), orm.Monetary("amount_total", orm.FieldOpts{ String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id", }), ) // -- Invoice Status -- m.AddFields( 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_invoice_status", Store: true}), ) // -- Accounting -- 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", }), ) // -- Vendor Reference & Lock -- m.AddFields( orm.Char("partner_ref", orm.FieldOpts{String: "Vendor Reference"}), orm.Boolean("locked", orm.FieldOpts{String: "Locked", Default: false}), ) // -- Notes -- m.AddFields( orm.Text("notes", orm.FieldOpts{String: "Terms and Conditions"}), orm.Char("origin", orm.FieldOpts{String: "Source Document"}), ) // -- DefaultGet: Provide dynamic defaults for new records -- // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.default_get() // Supplies company_id, currency_id, date_order when creating a new PO. 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 } // button_confirm: Validate and confirm PO. Mirrors Python PurchaseOrder.button_confirm(). // Skips orders not in draft/sent, checks order lines have products, then either // directly approves (single-step) or sets to "to approve" (double validation). m.RegisterMethod("button_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, poID := range rs.IDs() { var state, name string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(state, 'draft'), COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&state, &name) if state != "draft" && state != "sent" { continue // skip already confirmed orders (Python does same) } // Validate: all non-section lines must have a product var badLines int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM purchase_order_line WHERE order_id = $1 AND product_id IS NULL AND COALESCE(display_type, '') NOT IN ('line_section', 'line_note')`, poID).Scan(&badLines) if badLines > 0 { return nil, fmt.Errorf("purchase: some order lines are missing a product on PO %s", name) } // Generate sequence if still default if name == "" || name == "/" || name == "New" { seq, err := orm.NextByCode(env, "purchase.order") if err != nil { name = fmt.Sprintf("PO/%d", time.Now().UnixNano()%100000) } else { name = seq } env.Tx().Exec(env.Ctx(), `UPDATE purchase_order SET name = $1 WHERE id = $2`, name, poID) } // Double validation: check company setting var poDoubleVal string var companyID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(company_id, 0) FROM purchase_order WHERE id = $1`, poID).Scan(&companyID) env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(po_double_validation, 'one_step') FROM res_company WHERE id = $1`, companyID).Scan(&poDoubleVal) if poDoubleVal == "two_step" { // Check if amount exceeds threshold var amountTotal, threshold float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(amount_total::float8, 0) FROM purchase_order WHERE id = $1`, poID).Scan(&amountTotal) env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(po_double_validation_amount::float8, 0) FROM res_company WHERE id = $1`, companyID).Scan(&threshold) if amountTotal >= threshold { env.Tx().Exec(env.Ctx(), `UPDATE purchase_order SET state = 'to approve' WHERE id = $1`, poID) continue } } // Approve directly env.Tx().Exec(env.Ctx(), `UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, poID) } return true, nil }) // button_approve: Approve a PO that is in "to approve" state → purchase. // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_approve() m.RegisterMethod("button_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string env.Tx().QueryRow(env.Ctx(), `SELECT state FROM purchase_order WHERE id = $1`, id).Scan(&state) if state != "to approve" { return nil, fmt.Errorf("purchase: can only approve orders in 'to approve' state (current: %s)", state) } env.Tx().Exec(env.Ctx(), `UPDATE purchase_order SET state = 'purchase', date_approve = NOW() WHERE id = $1`, id) } return true, nil }) // button_cancel: Cancel a PO. Mirrors Python PurchaseOrder.button_cancel(). // Checks: locked orders cannot be cancelled; orders with posted bills cannot be cancelled. m.RegisterMethod("button_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, poID := range rs.IDs() { var locked bool var poName string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(locked, false), COALESCE(name, '') FROM purchase_order WHERE id = $1`, poID).Scan(&locked, &poName) if locked { return nil, fmt.Errorf("purchase: cannot cancel locked order %s, unlock it first", poName) } // Check for non-draft/non-cancelled vendor bills var billCount int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM account_move WHERE invoice_origin = $1 AND move_type = 'in_invoice' AND state NOT IN ('draft', 'cancel')`, poName).Scan(&billCount) if billCount > 0 { return nil, fmt.Errorf("purchase: cannot cancel order %s, cancel related vendor bills first", poName) } env.Tx().Exec(env.Ctx(), `UPDATE purchase_order SET state = 'cancel' WHERE id = $1`, poID) } return true, nil }) // button_draft: Reset a cancelled PO back to draft (RFQ). // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.button_draft() m.RegisterMethod("button_draft", 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 = 'draft' WHERE id = $1 AND state = 'cancel'`, poID) } return true, nil }) // action_create_bill / action_create_invoice: Generate a vendor bill from a confirmed PO. // Mirrors: odoo/addons/purchase/models/purchase_order.py PurchaseOrder.action_create_invoice() // Creates account.move (in_invoice) with linked invoice lines, updates qty_invoiced, // and writes purchase_line_id on invoice lines for proper tracking. createBillFn := func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() var billIDs []int64 for _, poID := range rs.IDs() { var partnerID, companyID, currencyID int64 var poName string var fiscalPosID, paymentTermID *int64 err := env.Tx().QueryRow(env.Ctx(), `SELECT partner_id, company_id, currency_id, COALESCE(name, ''), fiscal_position_id, payment_term_id FROM purchase_order WHERE id = $1`, poID).Scan(&partnerID, &companyID, ¤cyID, &poName, &fiscalPosID, &paymentTermID) if err != nil { return nil, fmt.Errorf("purchase: read PO %d for bill: %w", poID, err) } // Check PO state: must be in 'purchase' state var state string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(state, 'draft') FROM purchase_order WHERE id = $1`, poID).Scan(&state) if state != "purchase" { return nil, fmt.Errorf("purchase: can only create bills for confirmed purchase orders (PO %s is %s)", poName, state) } // Find purchase journal var journalID int64 env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_journal WHERE type = 'purchase' AND company_id = $1 LIMIT 1`, companyID).Scan(&journalID) if journalID == 0 { env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_journal WHERE company_id = $1 ORDER BY id LIMIT 1`, companyID).Scan(&journalID) } // Read PO lines (skip section/note display types) rows, err := env.Tx().Query(env.Ctx(), `SELECT id, COALESCE(name,''), COALESCE(product_qty,1), COALESCE(price_unit,0), COALESCE(discount,0), COALESCE(qty_invoiced,0), product_id, COALESCE(display_type, '') FROM purchase_order_line WHERE order_id = $1 ORDER BY sequence, id`, poID) if err != nil { return nil, fmt.Errorf("purchase: read PO lines %d: %w", poID, err) } type poLine struct { id int64 name string qty float64 price float64 discount float64 qtyInvoiced float64 productID *int64 displayType string } var lines []poLine for rows.Next() { var l poLine if err := rows.Scan(&l.id, &l.name, &l.qty, &l.price, &l.discount, &l.qtyInvoiced, &l.productID, &l.displayType); err != nil { rows.Close() return nil, err } lines = append(lines, l) } rows.Close() // Filter to only lines that need invoicing var invoiceableLines []poLine for _, l := range lines { if l.displayType == "line_section" || l.displayType == "line_note" { continue } qtyToInvoice := l.qty - l.qtyInvoiced if qtyToInvoice > 0 { invoiceableLines = append(invoiceableLines, l) } } if len(invoiceableLines) == 0 { continue // nothing to invoice on this PO } // Determine invoice_origin invoiceOrigin := poName if invoiceOrigin == "" { invoiceOrigin = fmt.Sprintf("PO%d", poID) } // Create the vendor bill var billID int64 err = env.Tx().QueryRow(env.Ctx(), `INSERT INTO account_move (name, move_type, state, date, partner_id, journal_id, company_id, currency_id, invoice_origin, fiscal_position_id, invoice_payment_term_id) VALUES ('/', 'in_invoice', 'draft', NOW(), $1, $2, $3, $4, $5, $6, $7) RETURNING id`, partnerID, journalID, companyID, currencyID, invoiceOrigin, fiscalPosID, paymentTermID).Scan(&billID) if err != nil { return nil, fmt.Errorf("purchase: create bill for PO %d: %w", poID, err) } // Generate sequence name seq, seqErr := orm.NextByCode(env, "account.move.in_invoice") if seqErr != nil { seq, seqErr = orm.NextByCode(env, "account.move") } if seqErr == nil && seq != "" { env.Tx().Exec(env.Ctx(), `UPDATE account_move SET name = $1 WHERE id = $2`, seq, billID) } // Create invoice lines for each invoiceable PO line seq2 := 10 for _, l := range invoiceableLines { qtyToInvoice := l.qty - l.qtyInvoiced subtotal := qtyToInvoice * l.price * (1 - l.discount/100) env.Tx().Exec(env.Ctx(), `INSERT INTO account_move_line (move_id, name, quantity, price_unit, discount, debit, credit, balance, display_type, company_id, journal_id, sequence, purchase_line_id, product_id, account_id) VALUES ($1, $2, $3, $4, $5, $6, 0, $6, 'product', $7, $8, $9, $10, $11, COALESCE((SELECT id FROM account_account WHERE company_id = $7 AND account_type = 'expense' LIMIT 1), (SELECT id FROM account_account WHERE company_id = $7 LIMIT 1), 1))`, billID, l.name, qtyToInvoice, l.price, l.discount, subtotal, companyID, journalID, seq2, l.id, l.productID) seq2 += 10 } // Update qty_invoiced on PO lines for _, l := range invoiceableLines { qtyToInvoice := l.qty - l.qtyInvoiced env.Tx().Exec(env.Ctx(), `UPDATE purchase_order_line SET qty_invoiced = COALESCE(qty_invoiced, 0) + $1 WHERE id = $2`, qtyToInvoice, l.id) } billIDs = append(billIDs, billID) // Recompute PO invoice_status based on lines 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 AND COALESCE(display_type, '') NOT IN ('line_section', 'line_note')`, poID).Scan(&totalQty, &totalInvoiced) invStatus := "no" if totalQty > 0 { if totalInvoiced >= totalQty { invStatus = "invoiced" } else { invStatus = "to invoice" } } env.Tx().Exec(env.Ctx(), `UPDATE purchase_order SET invoice_status = $1 WHERE id = $2`, invStatus, poID) } return billIDs, nil } m.RegisterMethod("action_create_bill", createBillFn) // action_create_invoice: Python-standard name for the same operation. m.RegisterMethod("action_create_invoice", createBillFn) // BeforeCreate: auto-assign sequence number m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error { name, _ := vals["name"].(string) if name == "" || name == "/" || name == "New" { seq, err := orm.NextByCode(env, "purchase.order") if err != nil { // Fallback: generate a simple name vals["name"] = fmt.Sprintf("PO/%d", time.Now().UnixNano()%100000) } else { vals["name"] = seq } } return nil } // -- BeforeWrite Hook: Prevent modifications on locked/cancelled orders -- m.BeforeWrite = orm.StateGuard("purchase_order", "state IN ('done', 'cancel')", []string{"write_uid", "write_date", "message_partner_ids_count", "locked"}, "cannot modify locked/cancelled orders") // purchase.order.line — individual line items on a PO initPurchaseOrderLine() } // initPurchaseOrderLine registers purchase.order.line. // Mirrors: odoo/addons/purchase/models/purchase_order_line.py func initPurchaseOrderLine() { m := orm.NewModel("purchase.order.line", orm.ModelOpts{ Description: "Purchase Order Line", Order: "order_id, sequence, id", }) // -- Parent -- m.AddFields( orm.Many2one("order_id", "purchase.order", orm.FieldOpts{ String: "Order Reference", Required: true, OnDelete: orm.OnDeleteCascade, Index: true, }), orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), ) // -- Product -- m.AddFields( orm.Many2one("product_id", "product.product", orm.FieldOpts{ String: "Product", Index: true, }), orm.Char("name", orm.FieldOpts{String: "Description", Required: true}), orm.Float("product_qty", orm.FieldOpts{String: "Quantity", Required: true, Default: 1.0}), orm.Many2one("product_uom", "uom.uom", orm.FieldOpts{ String: "Unit of Measure", Required: true, }), ) // -- Pricing -- m.AddFields( orm.Float("price_unit", orm.FieldOpts{String: "Unit Price", Required: true}), orm.Many2many("tax_ids", "account.tax", orm.FieldOpts{String: "Taxes"}), orm.Float("discount", orm.FieldOpts{String: "Discount (%)", Default: 0.0}), orm.Monetary("price_subtotal", orm.FieldOpts{ String: "Subtotal", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id", }), orm.Float("price_tax", orm.FieldOpts{ String: "Tax", Compute: "_compute_amount", Store: true, }), orm.Monetary("price_total", orm.FieldOpts{ String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id", }), orm.Many2one("currency_id", "res.currency", orm.FieldOpts{ String: "Currency", }), ) // -- Invoice Lines & Display -- m.AddFields( orm.One2many("invoice_lines", "account.move.line", "purchase_line_id", orm.FieldOpts{ String: "Bill Lines", Readonly: true, }), orm.Selection("display_type", []orm.SelectionItem{ {Value: "line_section", Label: "Section"}, {Value: "line_note", Label: "Note"}, }, orm.FieldOpts{String: "Display Type", Default: ""}), ) // -- Dates -- m.AddFields( orm.Datetime("date_planned", orm.FieldOpts{String: "Expected Arrival"}), ) // -- Quantities -- m.AddFields( orm.Float("qty_received", orm.FieldOpts{ String: "Received Qty", Compute: "_compute_qty_received", Store: true, }), orm.Float("qty_invoiced", orm.FieldOpts{ String: "Billed Qty", Compute: "_compute_qty_invoiced", Store: true, }), orm.Float("qty_to_invoice", orm.FieldOpts{ String: "To Invoice Quantity", Compute: "_compute_qty_to_invoice", Store: true, }), ) // -- Company -- m.AddFields( orm.Many2one("company_id", "res.company", orm.FieldOpts{ String: "Company", Index: true, }), ) }