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>
This commit is contained in:
@@ -53,6 +53,13 @@ func initPurchaseOrder() {
|
||||
}),
|
||||
)
|
||||
|
||||
// -- 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{
|
||||
@@ -102,6 +109,12 @@ func initPurchaseOrder() {
|
||||
}),
|
||||
)
|
||||
|
||||
// -- 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"}),
|
||||
@@ -134,15 +147,84 @@ func initPurchaseOrder() {
|
||||
return vals
|
||||
}
|
||||
|
||||
// button_confirm: draft → purchase
|
||||
// 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 != "draft" && state != "sent" {
|
||||
return nil, fmt.Errorf("purchase: can only confirm draft orders")
|
||||
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)
|
||||
@@ -150,12 +232,31 @@ func initPurchaseOrder() {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// button_cancel
|
||||
// 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 _, id := range rs.IDs() {
|
||||
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`, id)
|
||||
`UPDATE purchase_order SET state = 'cancel' WHERE id = $1`, poID)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
@@ -170,36 +271,51 @@ func initPurchaseOrder() {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_create_bill: Generate a vendor bill (account.move in_invoice) from a confirmed PO.
|
||||
// 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()
|
||||
m.RegisterMethod("action_create_bill", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
// 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 FROM purchase_order WHERE id = $1`,
|
||||
poID).Scan(&partnerID, &companyID, ¤cyID)
|
||||
`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 {
|
||||
// Fallback: first available journal
|
||||
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 to generate invoice lines
|
||||
// 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)
|
||||
`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 {
|
||||
@@ -207,16 +323,20 @@ func initPurchaseOrder() {
|
||||
}
|
||||
|
||||
type poLine struct {
|
||||
id int64
|
||||
name string
|
||||
qty float64
|
||||
price float64
|
||||
discount float64
|
||||
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); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -224,19 +344,43 @@ func initPurchaseOrder() {
|
||||
}
|
||||
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)
|
||||
VALUES ('/', 'in_invoice', 'draft', NOW(), $1, $2, $3, $4, $5) RETURNING id`,
|
||||
partnerID, journalID, companyID, currencyID,
|
||||
fmt.Sprintf("PO%d", poID)).Scan(&billID)
|
||||
(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)
|
||||
}
|
||||
|
||||
// Try to generate a proper sequence name
|
||||
// Generate sequence name
|
||||
seq, seqErr := orm.NextByCode(env, "account.move.in_invoice")
|
||||
if seqErr != nil {
|
||||
seq, seqErr = orm.NextByCode(env, "account.move")
|
||||
@@ -246,37 +390,58 @@ func initPurchaseOrder() {
|
||||
`UPDATE account_move SET name = $1 WHERE id = $2`, seq, billID)
|
||||
}
|
||||
|
||||
// Create invoice lines for each PO line
|
||||
for _, l := range lines {
|
||||
subtotal := l.qty * l.price * (1 - l.discount/100)
|
||||
// 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, account_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 0, $6, 'product', $7, $8,
|
||||
COALESCE((SELECT id FROM account_account WHERE company_id = $7 LIMIT 1), 1))`,
|
||||
billID, l.name, l.qty, l.price, l.discount, subtotal,
|
||||
companyID, journalID)
|
||||
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 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`,
|
||||
l.qty, l.id)
|
||||
qtyToInvoice, l.id)
|
||||
}
|
||||
|
||||
billIDs = append(billIDs, billID)
|
||||
|
||||
// Update PO invoice_status
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE purchase_order SET invoice_status = 'invoiced' WHERE id = $1`, poID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase: update invoice status for PO %d: %w", poID, err)
|
||||
// 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 {
|
||||
@@ -293,6 +458,11 @@ func initPurchaseOrder() {
|
||||
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()
|
||||
}
|
||||
@@ -333,6 +503,9 @@ func initPurchaseOrderLine() {
|
||||
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",
|
||||
}),
|
||||
@@ -341,6 +514,17 @@ func initPurchaseOrderLine() {
|
||||
}),
|
||||
)
|
||||
|
||||
// -- 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"}),
|
||||
|
||||
Reference in New Issue
Block a user