|
|
|
|
@@ -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) }
|
|
|
|
|
|