Files
goodie/addons/purchase/models/purchase_order.go
Marc 66383adf06 feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support
- Email Inbound: IMAP polling (go-imap/v2), thread matching
- Discuss: mail.channel, long-polling bus, DM, unread count
- Cron: ir.cron runner (goroutine scheduler)
- Bank Import, CSV/Excel Import
- Automation (ir.actions.server)
- Fetchmail service
- HR Payroll model
- Various fixes across account, sale, stock, purchase, crm, hr, project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:41:57 +02:00

553 lines
19 KiB
Go

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(&currencyID)
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, &currencyID, &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,
}),
)
}