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:
Marc
2026-04-12 18:41:57 +02:00
parent 2c7c1e6c88
commit 66383adf06
87 changed files with 14696 additions and 654 deletions

View File

@@ -10,6 +10,7 @@ func Init() {
initSaleOrderTemplate()
initSaleOrderTemplateLine()
initSaleOrderTemplateOption()
initSaleOrderOption()
initSaleReport()
initSaleOrderWarnMsg()
initSaleAdvancePaymentWizard()

View File

@@ -24,6 +24,7 @@ func initSaleOrder() {
{Value: "draft", Label: "Quotation"},
{Value: "sent", Label: "Quotation Sent"},
{Value: "sale", Label: "Sales Order"},
{Value: "done", Label: "Locked"},
{Value: "cancel", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Readonly: true, Index: true}),
)
@@ -253,26 +254,82 @@ func initSaleOrder() {
return nil
}
// -- BeforeWrite Hook: Prevent modifications on locked/cancelled orders --
m.BeforeWrite = orm.StateGuard("sale_order", "state IN ('done', 'cancel')",
[]string{"write_uid", "write_date", "message_partner_ids_count"},
"cannot modify locked/cancelled orders")
// -- Business Methods --
// action_confirm: draft → sale
// Validates required fields, generates sequence number, sets date_order,
// creates stock picking for physical products if stock module is loaded.
// 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
var state, name string
var partnerID int64
var dateOrder *time.Time
err := env.Tx().QueryRow(env.Ctx(),
`SELECT state FROM sale_order WHERE id = $1`, id).Scan(&state)
`SELECT state, COALESCE(name, '/'), COALESCE(partner_id, 0), date_order
FROM sale_order WHERE id = $1`, id,
).Scan(&state, &name, &partnerID, &dateOrder)
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)
}
// Validate required fields
if partnerID == 0 {
return nil, fmt.Errorf("sale: cannot confirm order %s without a customer", name)
}
var lineCount int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
id).Scan(&lineCount)
if lineCount == 0 {
return nil, fmt.Errorf("sale: cannot confirm order %s without order lines", name)
}
// Generate sequence number if still default
if name == "/" || name == "" {
seq, seqErr := orm.NextByCode(env, "sale.order")
if seqErr != nil {
name = fmt.Sprintf("SO/%d", time.Now().UnixNano()%100000)
} else {
name = seq
}
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET name = $1 WHERE id = $2`, name, id)
}
// Set date_order if not set
if dateOrder == nil {
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET date_order = NOW() WHERE id = $1`, id)
}
// Confirm the order
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET state = 'sale' WHERE id = $1`, id); err != nil {
return nil, err
}
// Create stock picking for physical products if stock module is loaded
if stockModel := orm.Registry.Get("stock.picking"); stockModel != nil {
soRS := env.Model("sale.order").Browse(id)
soModel := orm.Registry.Get("sale.order")
if fn, ok := soModel.Methods["action_create_delivery"]; ok {
if _, err := fn(soRS); err != nil {
// Log but don't fail confirmation if delivery creation fails
fmt.Printf("sale: warning: could not create delivery for SO %d: %v\n", id, err)
}
}
}
}
return true, nil
})
@@ -305,7 +362,7 @@ func initSaleOrder() {
).Scan(&journalID)
}
if journalID == 0 {
journalID = 1 // ultimate fallback
return nil, fmt.Errorf("sale: no sales journal found for company %d", companyID)
}
// Read SO lines
@@ -431,11 +488,17 @@ func initSaleOrder() {
"credit": baseAmount,
"balance": -baseAmount,
}
if _, err := lineRS.Create(productLineVals); err != nil {
invLine, err := lineRS.Create(productLineVals)
if err != nil {
return nil, fmt.Errorf("sale: create invoice product line: %w", err)
}
totalCredit += baseAmount
// Link SO line to invoice line via M2M
env.Tx().Exec(env.Ctx(),
`INSERT INTO sale_order_line_invoice_rel (order_line_id, invoice_line_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING`, line.id, invLine.ID())
// 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)
@@ -549,9 +612,19 @@ func initSaleOrder() {
line.qty, line.id)
}
// Update SO invoice_status
// Recompute invoice_status based on actual qty_invoiced vs qty
var totalQty, totalInvoiced float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(product_uom_qty), 0), COALESCE(SUM(qty_invoiced), 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
soID).Scan(&totalQty, &totalInvoiced)
invStatus := "to invoice"
if totalQty > 0 && totalInvoiced >= totalQty {
invStatus = "invoiced"
}
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET invoice_status = 'invoiced' WHERE id = $1`, soID)
`UPDATE sale_order SET invoice_status = $1 WHERE id = $2`, invStatus, soID)
}
if len(invoiceIDs) == 0 {
@@ -613,6 +686,16 @@ func initSaleOrder() {
return true, nil
})
// action_done: Lock a confirmed sale order (state → done).
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_done()
m.RegisterMethod("action_done", 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 = 'done' WHERE id = $1 AND state = 'sale'`, 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) {
@@ -959,11 +1042,24 @@ func initSaleOrderLine() {
orm.Monetary("price_subtotal", orm.FieldOpts{
String: "Subtotal", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
orm.Float("price_tax", orm.FieldOpts{
String: "Total Tax", Compute: "_compute_amount", Store: true,
}),
orm.Monetary("price_total", orm.FieldOpts{
String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id",
}),
)
// -- Invoice link --
m.AddFields(
orm.Many2many("invoice_line_ids", "account.move.line", orm.FieldOpts{
String: "Invoice Lines",
Relation: "sale_order_line_invoice_rel",
Column1: "order_line_id",
Column2: "invoice_line_id",
}),
)
// -- Display --
m.AddFields(
orm.Selection("display_type", []orm.SelectionItem{
@@ -1023,10 +1119,12 @@ func initSaleOrderLine() {
return orm.Values{
"price_subtotal": subtotal,
"price_tax": taxTotal,
"price_total": subtotal + taxTotal,
}, nil
}
m.RegisterCompute("price_subtotal", computeLineAmount)
m.RegisterCompute("price_tax", computeLineAmount)
m.RegisterCompute("price_total", computeLineAmount)
// -- Delivery & Invoicing Quantities --

View File

@@ -1,10 +1,14 @@
package models
import (
"encoding/json"
"fmt"
"html"
"log"
"time"
"odoo-go/pkg/orm"
"odoo-go/pkg/tools"
)
// initSaleOrderExtension extends sale.order with template support, additional workflow
@@ -19,6 +23,9 @@ func initSaleOrderExtension() {
orm.Many2one("sale_order_template_id", "sale.order.template", orm.FieldOpts{
String: "Quotation Template",
}),
orm.One2many("sale_order_option_ids", "sale.order.option", "order_id", orm.FieldOpts{
String: "Optional Products",
}),
orm.Boolean("is_expired", orm.FieldOpts{
String: "Expired", Compute: "_compute_is_expired",
}),
@@ -52,6 +59,132 @@ func initSaleOrderExtension() {
return orm.Values{"is_expired": expired}, nil
})
// -- Amounts: amount_to_invoice, amount_invoiced --
so.AddFields(
orm.Monetary("amount_to_invoice", orm.FieldOpts{
String: "Un-invoiced Balance", Compute: "_compute_amount_to_invoice", CurrencyField: "currency_id",
}),
orm.Monetary("amount_invoiced", orm.FieldOpts{
String: "Already Invoiced", Compute: "_compute_amount_invoiced", CurrencyField: "currency_id",
}),
)
// _compute_amount_invoiced: Sum of invoiced amounts across order lines.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amount_invoiced()
so.RegisterCompute("amount_invoiced", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var invoiced float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(
COALESCE(qty_invoiced, 0) * COALESCE(price_unit, 0)
* (1 - COALESCE(discount, 0) / 100)
)::float8, 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
soID).Scan(&invoiced)
return orm.Values{"amount_invoiced": invoiced}, nil
})
// _compute_amount_to_invoice: Total minus already invoiced.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amount_to_invoice()
so.RegisterCompute("amount_to_invoice", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var total, invoiced float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(price_subtotal)::float8, 0),
COALESCE(SUM(
COALESCE(qty_invoiced, 0) * COALESCE(price_unit, 0)
* (1 - COALESCE(discount, 0) / 100)
)::float8, 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
soID).Scan(&total, &invoiced)
result := total - invoiced
if result < 0 {
result = 0
}
return orm.Values{"amount_to_invoice": result}, nil
})
// -- type_name: "Quotation" vs "Sales Order" based on state --
so.AddFields(
orm.Char("type_name", orm.FieldOpts{
String: "Type Name", Compute: "_compute_type_name",
}),
)
// _compute_type_name
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_type_name()
so.RegisterCompute("type_name", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(state, 'draft') FROM sale_order WHERE id = $1`, soID).Scan(&state)
typeName := "Sales Order"
if state == "draft" || state == "sent" || state == "cancel" {
typeName = "Quotation"
}
return orm.Values{"type_name": typeName}, nil
})
// -- delivery_status: nothing/partial/full based on related pickings --
so.AddFields(
orm.Selection("delivery_status", []orm.SelectionItem{
{Value: "nothing", Label: "Nothing Delivered"},
{Value: "partial", Label: "Partially Delivered"},
{Value: "full", Label: "Fully Delivered"},
}, orm.FieldOpts{String: "Delivery Status", Compute: "_compute_delivery_status"}),
)
// _compute_delivery_status
// Mirrors: odoo/addons/sale_stock/models/sale_order.py SaleOrder._compute_delivery_status()
so.RegisterCompute("delivery_status", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var state string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(state, 'draft') FROM sale_order WHERE id = $1`, soID).Scan(&state)
// Only compute for confirmed orders
if state != "sale" && state != "done" {
return orm.Values{"delivery_status": "nothing"}, nil
}
// Check line quantities: total ordered vs total delivered
var totalOrdered, totalDelivered float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(product_uom_qty), 0), COALESCE(SUM(qty_delivered), 0)
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).Scan(&totalOrdered, &totalDelivered)
status := "nothing"
if totalOrdered > 0 {
if totalDelivered >= totalOrdered {
status = "full"
} else if totalDelivered > 0 {
status = "partial"
}
}
return orm.Values{"delivery_status": status}, nil
})
// preview_sale_order: Return URL action for customer portal preview (Python method name).
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.preview_sale_order()
so.RegisterMethod("preview_sale_order", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
soID := rs.IDs()[0]
return map[string]interface{}{
"type": "ir.actions.act_url",
"url": fmt.Sprintf("/my/orders/%d", soID),
"target": "new",
}, nil
})
// -- Computed: _compute_invoice_status (extends the base) --
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_invoice_status()
so.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) {
@@ -201,19 +334,238 @@ func initSaleOrderExtension() {
return nil, nil
})
// _compute_amount_to_invoice: Compute total amount still to invoice.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_amounts()
so.RegisterMethod("_compute_amount_to_invoice", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
// Note: amount_to_invoice compute is already registered above (line ~90)
// ── Feature 1: action_quotation_send ──────────────────────────────────
// Sends a quotation email to the customer with SO details, then marks state as 'sent'.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_quotation_send()
so.RegisterMethod("action_quotation_send", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, soID := range rs.IDs() {
// Read SO header
var soName, partnerEmail, partnerName, state string
var amountTotal float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(so.name, ''), COALESCE(p.email, ''), COALESCE(p.name, ''),
COALESCE(so.state, 'draft'), COALESCE(so.amount_total::float8, 0)
FROM sale_order so
JOIN res_partner p ON p.id = so.partner_id
WHERE so.id = $1`, soID,
).Scan(&soName, &partnerEmail, &partnerName, &state, &amountTotal)
if err != nil {
return nil, fmt.Errorf("sale: read SO %d for email: %w", soID, err)
}
if partnerEmail == "" {
log.Printf("sale: action_quotation_send: no email for partner on SO %d, skipping", soID)
continue
}
// Read order lines for the email body
lineRows, err := env.Tx().Query(env.Ctx(),
`SELECT COALESCE(name, ''), COALESCE(product_uom_qty, 0),
COALESCE(price_unit, 0), COALESCE(discount, 0),
COALESCE(price_subtotal::float8, 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, fmt.Errorf("sale: read SO lines for email SO %d: %w", soID, err)
}
var linesHTML string
for lineRows.Next() {
var lName string
var lQty, lPrice, lDiscount, lSubtotal float64
if err := lineRows.Scan(&lName, &lQty, &lPrice, &lDiscount, &lSubtotal); err != nil {
lineRows.Close()
break
}
linesHTML += fmt.Sprintf(
"<tr><td>%s</td><td style=\"text-align:right\">%.2f</td>"+
"<td style=\"text-align:right\">%.2f</td>"+
"<td style=\"text-align:right\">%.1f%%</td>"+
"<td style=\"text-align:right\">%.2f</td></tr>",
htmlEscapeStr(lName), lQty, lPrice, lDiscount, lSubtotal)
}
lineRows.Close()
// Build HTML email body
subject := fmt.Sprintf("Quotation %s", soName)
partnerNameEsc := htmlEscapeStr(partnerName)
soNameEsc := htmlEscapeStr(soName)
body := fmt.Sprintf(`<div style="font-family:Arial,sans-serif;max-width:600px">
<h2>%s</h2>
<p>Dear %s,</p>
<p>Please find below your quotation <strong>%s</strong>.</p>
<table style="width:100%%;border-collapse:collapse" border="1" cellpadding="5">
<thead><tr>
<th>Description</th><th>Qty</th><th>Unit Price</th><th>Discount</th><th>Subtotal</th>
</tr></thead>
<tbody>%s</tbody>
<tfoot><tr>
<td colspan="4" style="text-align:right"><strong>Total</strong></td>
<td style="text-align:right"><strong>%.2f</strong></td>
</tr></tfoot>
</table>
<p>Do not hesitate to contact us if you have any questions.</p>
</div>`, htmlEscapeStr(subject), partnerNameEsc, soNameEsc, linesHTML, amountTotal)
// Send email via tools.SendEmail
cfg := tools.LoadSMTPConfig()
if err := tools.SendEmail(cfg, partnerEmail, subject, body); err != nil {
log.Printf("sale: action_quotation_send: email send failed for SO %d: %v", soID, err)
}
// Mark state as 'sent' if currently draft
if state == "draft" {
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order SET state = 'sent' WHERE id = $1`, soID)
}
}
return true, nil
})
// ── Feature 2: _compute_amount_by_group ──────────────────────────────
// Compute tax amounts grouped by tax group. Returns JSON with group_name, tax_amount,
// base_amount per group. Similar to account.move tax_totals.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder._compute_tax_totals()
so.AddFields(
orm.Text("tax_totals_json", orm.FieldOpts{
String: "Tax Totals JSON", Compute: "_compute_amount_by_group",
}),
)
so.RegisterCompute("tax_totals_json", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
soID := rs.IDs()[0]
var total, invoiced float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(price_subtotal::float8), 0),
COALESCE(SUM(qty_invoiced * price_unit * (1 - COALESCE(discount,0)/100))::float8, 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
soID).Scan(&total, &invoiced)
return total - invoiced, nil
rows, err := env.Tx().Query(env.Ctx(),
`SELECT COALESCE(tg.name, t.name, 'Taxes') AS group_name,
SUM(
sol.product_uom_qty * sol.price_unit * (1 - COALESCE(sol.discount,0)/100)
* COALESCE(t.amount, 0) / 100
)::float8 AS tax_amount,
SUM(
sol.product_uom_qty * sol.price_unit * (1 - COALESCE(sol.discount,0)/100)
)::float8 AS base_amount
FROM sale_order_line sol
JOIN account_tax_sale_order_line_rel rel ON rel.sale_order_line_id = sol.id
JOIN account_tax t ON t.id = rel.account_tax_id
LEFT JOIN account_tax_group tg ON tg.id = t.tax_group_id
WHERE sol.order_id = $1
AND (sol.display_type IS NULL OR sol.display_type = '' OR sol.display_type = 'product')
GROUP BY COALESCE(tg.name, t.name, 'Taxes')
ORDER BY group_name`, soID)
if err != nil {
return orm.Values{"tax_totals_json": "{}"}, nil
}
defer rows.Close()
type taxGroup struct {
GroupName string `json:"group_name"`
TaxAmount float64 `json:"tax_amount"`
BaseAmount float64 `json:"base_amount"`
}
var groups []taxGroup
var totalTax, totalBase float64
for rows.Next() {
var g taxGroup
if err := rows.Scan(&g.GroupName, &g.TaxAmount, &g.BaseAmount); err != nil {
continue
}
totalTax += g.TaxAmount
totalBase += g.BaseAmount
groups = append(groups, g)
}
result := map[string]interface{}{
"groups_by_subtotal": groups,
"amount_total": totalBase + totalTax,
"amount_untaxed": totalBase,
"amount_tax": totalTax,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return orm.Values{"tax_totals_json": "{}"}, nil
}
return orm.Values{"tax_totals_json": string(jsonBytes)}, nil
})
// ── Feature 3: action_add_option ─────────────────────────────────────
// Copies a selected sale.order.option as a new order line on this SO.
// Mirrors: odoo/addons/sale_management/models/sale_order_option.py SaleOrderOption.add_option_to_order()
so.RegisterMethod("action_add_option", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
if len(args) < 1 {
return nil, fmt.Errorf("sale: action_add_option requires option_id argument")
}
env := rs.Env()
soID := rs.IDs()[0]
// Accept option_id as float64 (JSON) or int64
var optionID int64
switch v := args[0].(type) {
case float64:
optionID = int64(v)
case int64:
optionID = v
default:
return nil, fmt.Errorf("sale: action_add_option: invalid option_id type")
}
// Read the option
var name string
var productID int64
var qty, priceUnit, discount float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, ''), COALESCE(product_id, 0), COALESCE(quantity, 1),
COALESCE(price_unit, 0), COALESCE(discount, 0)
FROM sale_order_option WHERE id = $1`, optionID,
).Scan(&name, &productID, &qty, &priceUnit, &discount)
if err != nil {
return nil, fmt.Errorf("sale: read option %d: %w", optionID, err)
}
// Create a new order line from the option
lineVals := orm.Values{
"order_id": soID,
"name": name,
"product_uom_qty": qty,
"price_unit": priceUnit,
"discount": discount,
}
if productID > 0 {
lineVals["product_id"] = productID
}
lineRS := env.Model("sale.order.line")
_, err = lineRS.Create(lineVals)
if err != nil {
return nil, fmt.Errorf("sale: create line from option %d: %w", optionID, err)
}
// Mark option as added
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order_option SET is_present = true WHERE id = $1`, optionID)
return true, nil
})
// ── Feature 6: action_print ──────────────────────────────────────────
// Returns a report URL action pointing to /report/pdf/sale.order/<id>.
// Mirrors: odoo/addons/sale/models/sale_order.py SaleOrder.action_quotation_send() print variant
so.RegisterMethod("action_print", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
soID := rs.IDs()[0]
return map[string]interface{}{
"type": "ir.actions.report",
"report_name": "sale.order",
"report_type": "qweb-pdf",
"report_file": fmt.Sprintf("/report/pdf/sale.order/%d", soID),
"data": map[string]interface{}{"ids": []int64{soID}},
}, nil
})
}
@@ -347,37 +699,402 @@ func initSaleOrderLineExtension() {
return orm.Values{"untaxed_amount_invoiced": qtyInvoiced * price * (1 - discount/100)}, nil
})
// _compute_qty_invoiced: Compute invoiced quantity from linked invoice lines.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_invoiced()
sol.RegisterCompute("qty_invoiced", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var qtyInvoiced float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(qty_invoiced, 0) FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&qtyInvoiced)
return orm.Values{"qty_invoiced": qtyInvoiced}, nil
})
// _compute_qty_to_invoice: Quantity to invoice = qty - qty_invoiced (if delivered policy: qty_delivered - qty_invoiced).
// _compute_qty_to_invoice: Quantity to invoice based on invoice policy.
// Note: qty_invoiced compute is registered later with full M2M-based logic.
// If invoice policy is 'order': product_uom_qty - qty_invoiced
// If invoice policy is 'delivery': qty_delivered - qty_invoiced
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_to_invoice()
sol.RegisterCompute("qty_to_invoice", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var qty, qtyInvoiced float64
var qty, qtyDelivered, qtyInvoiced float64
var productID *int64
var orderState string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(product_uom_qty, 0), COALESCE(qty_invoiced, 0)
FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&qty, &qtyInvoiced)
toInvoice := qty - qtyInvoiced
`SELECT COALESCE(sol.product_uom_qty, 0), COALESCE(sol.qty_delivered, 0),
COALESCE(sol.qty_invoiced, 0), sol.product_id,
COALESCE(so.state, 'draft')
FROM sale_order_line sol
JOIN sale_order so ON so.id = sol.order_id
WHERE sol.id = $1`, lineID,
).Scan(&qty, &qtyDelivered, &qtyInvoiced, &productID, &orderState)
if orderState != "sale" && orderState != "done" {
return orm.Values{"qty_to_invoice": float64(0)}, nil
}
// Check invoice policy from product template
invoicePolicy := "order" // default
if productID != nil && *productID > 0 {
var policy *string
env.Tx().QueryRow(env.Ctx(),
`SELECT pt.invoice_policy FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
WHERE pp.id = $1`, *productID).Scan(&policy)
if policy != nil && *policy != "" {
invoicePolicy = *policy
}
}
var toInvoice float64
if invoicePolicy == "delivery" {
toInvoice = qtyDelivered - qtyInvoiced
} else {
toInvoice = qty - qtyInvoiced
}
if toInvoice < 0 {
toInvoice = 0
}
return orm.Values{"qty_to_invoice": toInvoice}, nil
})
// _compute_qty_delivered: Compute delivered quantity from stock moves.
// For products of type 'service', qty_delivered is manual.
// For storable/consumable products, sum done stock move quantities.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_delivered()
// odoo/addons/sale_stock/models/sale_order_line.py (stock moves source)
sol.RegisterCompute("qty_delivered", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
// Check if stock module is loaded
if orm.Registry.Get("stock.move") == nil {
// No stock module — return existing stored value
var delivered float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(qty_delivered, 0) FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&delivered)
return orm.Values{"qty_delivered": delivered}, nil
}
// Get product info
var productID *int64
var productType string
var soName string
env.Tx().QueryRow(env.Ctx(),
`SELECT sol.product_id, COALESCE(pt.type, 'consu'),
COALESCE(so.name, '')
FROM sale_order_line sol
LEFT JOIN product_product pp ON pp.id = sol.product_id
LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id
JOIN sale_order so ON so.id = sol.order_id
WHERE sol.id = $1`, lineID,
).Scan(&productID, &productType, &soName)
// For services, qty_delivered is manual — keep stored value
if productType == "service" || productID == nil || *productID == 0 {
var delivered float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(qty_delivered, 0) FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&delivered)
return orm.Values{"qty_delivered": delivered}, nil
}
// Sum done outgoing stock move quantities for this product + SO origin
var delivered float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(sm.product_uom_qty)::float8, 0)
FROM stock_move sm
JOIN stock_picking sp ON sp.id = sm.picking_id
WHERE sm.product_id = $1 AND sm.state = 'done'
AND sp.origin = $2
AND sm.location_dest_id IN (
SELECT id FROM stock_location WHERE usage = 'customer'
)`, *productID, soName,
).Scan(&delivered)
return orm.Values{"qty_delivered": delivered}, nil
})
// _compute_qty_invoiced: Compute invoiced quantity from linked invoice lines.
// For real integration, sum quantities from account.move.line linked via the M2M relation.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_qty_invoiced()
sol.RegisterCompute("qty_invoiced", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
// Try to get from the M2M relation (sale_order_line_invoice_rel)
var qtyInvoiced float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(
CASE WHEN am.move_type = 'out_refund' THEN -aml.quantity ELSE aml.quantity END
)::float8, 0)
FROM sale_order_line_invoice_rel rel
JOIN account_move_line aml ON aml.id = rel.invoice_line_id
JOIN account_move am ON am.id = aml.move_id
WHERE rel.order_line_id = $1
AND am.state != 'cancel'`, lineID,
).Scan(&qtyInvoiced)
if err != nil || qtyInvoiced == 0 {
// Fallback to stored value
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(qty_invoiced, 0) FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&qtyInvoiced)
}
return orm.Values{"qty_invoiced": qtyInvoiced}, nil
})
// _compute_name: Product name + description + variant attributes.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_name()
sol.AddFields(
orm.Text("computed_name", orm.FieldOpts{
String: "Computed Description", Compute: "_compute_name",
}),
)
sol.RegisterCompute("computed_name", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var productID *int64
var existingName string
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, COALESCE(name, '') FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&productID, &existingName)
// If no product, keep existing name
if productID == nil || *productID == 0 {
return orm.Values{"computed_name": existingName}, nil
}
// Build name from product template + variant attributes
var productName, descSale string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(pt.name, ''), COALESCE(pt.description_sale, '')
FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
WHERE pp.id = $1`, *productID,
).Scan(&productName, &descSale)
// Get variant attribute values
attrRows, err := env.Tx().Query(env.Ctx(),
`SELECT COALESCE(pav.name, '')
FROM product_template_attribute_value ptav
JOIN product_attribute_value pav ON pav.id = ptav.product_attribute_value_id
JOIN product_product_product_template_attribute_value_rel rel
ON rel.product_template_attribute_value_id = ptav.id
WHERE rel.product_product_id = $1`, *productID)
var attrNames []string
if err == nil {
for attrRows.Next() {
var attrName string
attrRows.Scan(&attrName)
if attrName != "" {
attrNames = append(attrNames, attrName)
}
}
attrRows.Close()
}
name := productName
if len(attrNames) > 0 {
name += " ("
for i, a := range attrNames {
if i > 0 {
name += ", "
}
name += a
}
name += ")"
}
if descSale != "" {
name += "\n" + descSale
}
return orm.Values{"computed_name": name}, nil
})
// _compute_discount: Compute discount from pricelist if applicable.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_discount()
sol.AddFields(
orm.Float("computed_discount", orm.FieldOpts{
String: "Computed Discount", Compute: "_compute_discount_from_pricelist",
}),
)
sol.RegisterCompute("computed_discount", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var productID *int64
var orderID int64
var priceUnit float64
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, order_id, COALESCE(price_unit, 0)
FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&productID, &orderID, &priceUnit)
if productID == nil || *productID == 0 || priceUnit == 0 {
return orm.Values{"computed_discount": float64(0)}, nil
}
// Get pricelist from the order
var pricelistID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT pricelist_id FROM sale_order WHERE id = $1`, orderID,
).Scan(&pricelistID)
if pricelistID == nil || *pricelistID == 0 {
return orm.Values{"computed_discount": float64(0)}, nil
}
// Get the product's list_price as the base price
var listPrice float64
env.Tx().QueryRow(env.Ctx(),
`SELECT 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(&listPrice)
if listPrice <= 0 {
return orm.Values{"computed_discount": float64(0)}, nil
}
// Check pricelist for a discount-based rule
var discountPct float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(price_discount, 0)
FROM product_pricelist_item
WHERE pricelist_id = $1
AND (product_id = $2 OR product_tmpl_id = (
SELECT product_tmpl_id FROM product_product WHERE id = $2
) OR (product_id IS NULL AND product_tmpl_id IS NULL))
AND (date_start IS NULL OR date_start <= CURRENT_DATE)
AND (date_end IS NULL OR date_end >= CURRENT_DATE)
ORDER BY
CASE WHEN product_id IS NOT NULL THEN 0
WHEN product_tmpl_id IS NOT NULL THEN 1
ELSE 2 END,
min_quantity ASC
LIMIT 1`, *pricelistID, *productID,
).Scan(&discountPct)
return orm.Values{"computed_discount": discountPct}, nil
})
// _compute_invoice_status_line: Enhanced per-line invoice status considering upselling.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_invoice_status()
sol.RegisterCompute("invoice_status", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var qty, qtyDelivered, qtyInvoiced float64
var orderState string
var isDownpayment bool
var productID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(sol.product_uom_qty, 0), COALESCE(sol.qty_delivered, 0),
COALESCE(sol.qty_invoiced, 0), COALESCE(so.state, 'draft'),
COALESCE(sol.is_downpayment, false), sol.product_id
FROM sale_order_line sol
JOIN sale_order so ON so.id = sol.order_id
WHERE sol.id = $1`, lineID,
).Scan(&qty, &qtyDelivered, &qtyInvoiced, &orderState, &isDownpayment, &productID)
if orderState != "sale" && orderState != "done" {
return orm.Values{"invoice_status": "no"}, nil
}
// Down payment that is fully invoiced
if isDownpayment && qtyInvoiced >= qty {
return orm.Values{"invoice_status": "invoiced"}, nil
}
// Check qty_to_invoice
var toInvoice float64
invoicePolicy := "order"
if productID != nil && *productID > 0 {
var policy *string
env.Tx().QueryRow(env.Ctx(),
`SELECT pt.invoice_policy FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
WHERE pp.id = $1`, *productID).Scan(&policy)
if policy != nil && *policy != "" {
invoicePolicy = *policy
}
}
if invoicePolicy == "delivery" {
toInvoice = qtyDelivered - qtyInvoiced
} else {
toInvoice = qty - qtyInvoiced
}
if toInvoice > 0.001 {
return orm.Values{"invoice_status": "to invoice"}, nil
}
// Upselling: ordered qty invoiced on order policy but delivered more
if invoicePolicy == "order" && qty >= 0 && qtyDelivered > qty {
return orm.Values{"invoice_status": "upselling"}, nil
}
if qtyInvoiced >= qty && qty > 0 {
return orm.Values{"invoice_status": "invoiced"}, nil
}
return orm.Values{"invoice_status": "no"}, nil
})
// ── Feature 4: _compute_product_template_attribute_value_ids ─────────
// When product_id changes, find available product.template.attribute.value records
// for the variant. Returns JSON array of {id, name, attribute_name} objects.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_product_template_attribute_value_ids()
sol.AddFields(
orm.Text("product_template_attribute_value_ids", orm.FieldOpts{
String: "Product Attribute Values",
Compute: "_compute_product_template_attribute_value_ids",
}),
)
sol.RegisterCompute("product_template_attribute_value_ids", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
lineID := rs.IDs()[0]
var productID *int64
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&productID)
if productID == nil || *productID == 0 {
return orm.Values{"product_template_attribute_value_ids": "[]"}, nil
}
// Find attribute values for this product variant via the M2M relation
attrRows, err := env.Tx().Query(env.Ctx(),
`SELECT ptav.id, COALESCE(pav.name, '') AS value_name,
COALESCE(pa.name, '') AS attribute_name
FROM product_template_attribute_value ptav
JOIN product_attribute_value pav ON pav.id = ptav.product_attribute_value_id
JOIN product_attribute pa ON pa.id = ptav.attribute_id
JOIN product_product_product_template_attribute_value_rel rel
ON rel.product_template_attribute_value_id = ptav.id
WHERE rel.product_product_id = $1
ORDER BY pa.sequence, pa.name`, *productID)
if err != nil {
return orm.Values{"product_template_attribute_value_ids": "[]"}, nil
}
defer attrRows.Close()
type attrVal struct {
ID int64 `json:"id"`
Name string `json:"name"`
AttributeName string `json:"attribute_name"`
}
var values []attrVal
for attrRows.Next() {
var av attrVal
if err := attrRows.Scan(&av.ID, &av.Name, &av.AttributeName); err != nil {
continue
}
values = append(values, av)
}
jsonBytes, _ := json.Marshal(values)
return orm.Values{"product_template_attribute_value_ids": string(jsonBytes)}, nil
})
}
// initSaleOrderDiscount registers the sale.order.discount wizard.
// Enhanced with discount_type: percentage or fixed_amount.
// Mirrors: odoo/addons/sale/wizard/sale_order_discount.py
func initSaleOrderDiscount() {
m := orm.NewModel("sale.order.discount", orm.ModelOpts{
@@ -386,33 +1103,76 @@ func initSaleOrderDiscount() {
})
m.AddFields(
orm.Float("discount", orm.FieldOpts{String: "Discount (%)", Required: true}),
orm.Float("discount", orm.FieldOpts{String: "Discount Value", Required: true}),
orm.Selection("discount_type", []orm.SelectionItem{
{Value: "percentage", Label: "Percentage"},
{Value: "fixed_amount", Label: "Fixed Amount"},
}, orm.FieldOpts{String: "Discount Type", Default: "percentage", Required: true}),
orm.Many2one("sale_order_id", "sale.order", orm.FieldOpts{String: "Sale Order"}),
)
// action_apply_discount: Apply the discount to all lines of the SO.
// For percentage: sets discount % on each line directly.
// For fixed_amount: distributes the fixed amount evenly across all product lines.
// Mirrors: odoo/addons/sale/wizard/sale_order_discount.py action_apply_discount()
m.RegisterMethod("action_apply_discount", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
wizID := rs.IDs()[0]
var discount float64
var discountVal float64
var orderID int64
var discountType string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(discount, 0), COALESCE(sale_order_id, 0)
`SELECT COALESCE(discount, 0), COALESCE(sale_order_id, 0),
COALESCE(discount_type, 'percentage')
FROM sale_order_discount WHERE id = $1`, wizID,
).Scan(&discount, &orderID)
).Scan(&discountVal, &orderID, &discountType)
if orderID == 0 {
return nil, fmt.Errorf("sale_discount: no sale order linked")
}
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE sale_order_line SET discount = $1
WHERE order_id = $2
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
discount, orderID)
if err != nil {
return nil, fmt.Errorf("sale_discount: apply discount: %w", err)
switch discountType {
case "fixed_amount":
// Distribute fixed amount evenly across product lines as a percentage
// Calculate total undiscounted line amount to determine per-line discount %
var totalAmount float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(product_uom_qty * price_unit)::float8, 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
orderID,
).Scan(&totalAmount)
if err != nil {
return nil, fmt.Errorf("sale_discount: read total: %w", err)
}
if totalAmount <= 0 {
return nil, fmt.Errorf("sale_discount: order has no lines or zero total")
}
// Convert fixed amount to an equivalent percentage of total
pct := discountVal / totalAmount * 100
if pct > 100 {
pct = 100
}
_, err = env.Tx().Exec(env.Ctx(),
`UPDATE sale_order_line SET discount = $1
WHERE order_id = $2
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
pct, orderID)
if err != nil {
return nil, fmt.Errorf("sale_discount: apply fixed discount: %w", err)
}
default: // "percentage"
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE sale_order_line SET discount = $1
WHERE order_id = $2
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
discountVal, orderID)
if err != nil {
return nil, fmt.Errorf("sale_discount: apply percentage discount: %w", err)
}
}
return true, nil
@@ -441,3 +1201,5 @@ func initResPartnerSaleExtension2() {
return orm.Values{"sale_order_total": total}, nil
})
}
func htmlEscapeStr(s string) string { return html.EscapeString(s) }

View File

@@ -124,6 +124,42 @@ func initSaleOrderTemplate() {
numDays, int64(orderID))
}
// Copy template options as sale.order.option records on the SO
optRows, err := env.Tx().Query(env.Ctx(),
`SELECT COALESCE(name, ''), product_id, COALESCE(quantity, 1),
COALESCE(price_unit, 0), COALESCE(discount, 0), COALESCE(sequence, 10)
FROM sale_order_template_option
WHERE sale_order_template_id = $1 ORDER BY sequence`, templateID)
if err == nil {
optionModel := orm.Registry.Get("sale.order.option")
if optionModel != nil {
optionRS := env.Model("sale.order.option")
for optRows.Next() {
var oName string
var oProdID *int64
var oQty, oPrice, oDisc float64
var oSeq int
if err := optRows.Scan(&oName, &oProdID, &oQty, &oPrice, &oDisc, &oSeq); err != nil {
continue
}
optVals := orm.Values{
"order_id": int64(orderID),
"name": oName,
"quantity": oQty,
"price_unit": oPrice,
"discount": oDisc,
"sequence": oSeq,
"is_present": false,
}
if oProdID != nil {
optVals["product_id"] = *oProdID
}
optionRS.Create(optVals)
}
}
optRows.Close()
}
return true, nil
})
@@ -290,3 +326,94 @@ func initSaleOrderTemplateOption() {
return result
})
}
// initSaleOrderOption registers sale.order.option — optional products on a specific sale order.
// When a template with options is applied to an SO, options are copied here.
// The customer or salesperson can then choose to add them as order lines.
// Mirrors: odoo/addons/sale_management/models/sale_order_option.py SaleOrderOption
func initSaleOrderOption() {
m := orm.NewModel("sale.order.option", orm.ModelOpts{
Description: "Sale Order Option",
Order: "sequence, id",
})
m.AddFields(
orm.Many2one("order_id", "sale.order", orm.FieldOpts{
String: "Sale Order", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{
String: "Product", Required: true,
}),
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1}),
orm.Many2one("uom_id", "uom.uom", orm.FieldOpts{String: "Unit of Measure"}),
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.Boolean("is_present", orm.FieldOpts{
String: "Present on Order", Default: false,
}),
)
// Onchange: product_id → name + price_unit
m.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 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
})
// button_add: Add this option as an order line. Delegates to sale.order action_add_option.
m.RegisterMethod("button_add", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
optionID := rs.IDs()[0]
var orderID int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(order_id, 0) FROM sale_order_option WHERE id = $1`, optionID,
).Scan(&orderID)
if err != nil || orderID == 0 {
return nil, fmt.Errorf("sale_option: no order linked to option %d", optionID)
}
soRS := env.Model("sale.order").Browse(orderID)
soModel := orm.Registry.Get("sale.order")
if fn, ok := soModel.Methods["action_add_option"]; ok {
return fn(soRS, float64(optionID))
}
return nil, fmt.Errorf("sale_option: action_add_option not found on sale.order")
})
}