Implement core business logic depth: reconciliation, quants, invoicing

Account Reconciliation Engine:
- reconcile() method on account.move.line matches debit↔credit lines
- Creates account.partial.reconcile records for each match
- Detects full reconciliation (all residuals=0) → account.full.reconcile
- updatePaymentState() tracks paid/partial/not_paid on invoices
- Payment register wizard now creates journal entries + reconciles

Stock Quant Reservation:
- assignMove() reserves products from source location quants
- getAvailableQty() queries unreserved on-hand stock
- _action_confirm → confirmed + auto-assigns if stock available
- _action_assign creates stock.move.line reservations
- _action_done updates quants (decrease source, increase dest)
- button_validate on picking delegates to move._action_done
- Clears reserved_quantity on completion

Sale Invoice Creation (rewritten):
- Proper debit/credit/balance on invoice lines
- Tax computation from SO line M2M tax_ids (percent/fixed/division)
- Revenue account lookup (SKR03 8300 with fallbacks)
- amount_residual set on receivable line (enables reconciliation)
- qty_invoiced tracking on SO lines
- Line amount computes now include tax in price_total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-04-02 23:25:32 +02:00
parent 9ad633fc3c
commit 5d48737c9d
3 changed files with 921 additions and 171 deletions

View File

@@ -134,37 +134,39 @@ func initSaleOrder() {
env := rs.Env()
soID := rs.IDs()[0]
var untaxed float64
var untaxed, tax, total float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(product_uom_qty * price_unit * (1 - COALESCE(discount,0)/100)), 0)
`SELECT
COALESCE(SUM(price_subtotal), 0),
COALESCE(SUM(price_total - price_subtotal), 0),
COALESCE(SUM(price_total), 0)
FROM sale_order_line WHERE order_id = $1
AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`,
soID).Scan(&untaxed)
soID).Scan(&untaxed, &tax, &total)
if err != nil {
return nil, fmt.Errorf("sale: compute amounts for SO %d: %w", soID, err)
}
// Compute tax from linked tax records on lines; fall back to sum of line taxes
var tax float64
err = env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(
product_uom_qty * price_unit * (1 - COALESCE(discount,0)/100)
* COALESCE((SELECT t.amount / 100 FROM account_tax t
JOIN account_tax_sale_order_line_rel rel ON rel.account_tax_id = t.id
WHERE rel.sale_order_line_id = sol.id LIMIT 1), 0)
), 0)
FROM sale_order_line sol WHERE sol.order_id = $1
AND (sol.display_type IS NULL OR sol.display_type = '' OR sol.display_type = 'product')`,
soID).Scan(&tax)
if err != nil {
// Fallback: if the M2M table doesn't exist, estimate tax at 0
tax = 0
// Fallback: compute from raw line values if price_subtotal/price_total not yet stored
err = env.Tx().QueryRow(env.Ctx(),
`SELECT
COALESCE(SUM(product_uom_qty * price_unit * (1 - COALESCE(discount,0)/100)), 0),
COALESCE(SUM(
product_uom_qty * price_unit * (1 - COALESCE(discount,0)/100)
* COALESCE((SELECT t.amount / 100 FROM account_tax t
JOIN account_tax_sale_order_line_rel rel ON rel.account_tax_id = t.id
WHERE rel.sale_order_line_id = sol.id LIMIT 1), 0)
), 0)
FROM sale_order_line sol WHERE sol.order_id = $1
AND (sol.display_type IS NULL OR sol.display_type = '' OR sol.display_type = 'product')`,
soID).Scan(&untaxed, &tax)
if err != nil {
return nil, fmt.Errorf("sale: compute amounts for SO %d: %w", soID, err)
}
total = untaxed + tax
}
return orm.Values{
"amount_untaxed": untaxed,
"amount_tax": tax,
"amount_total": untaxed + tax,
"amount_total": total,
}, nil
}
m.RegisterCompute("amount_untaxed", computeSaleAmounts)
@@ -285,35 +287,51 @@ func initSaleOrder() {
// Read SO header
var partnerID, companyID, currencyID int64
var journalID int64
var soName string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT partner_id, company_id, currency_id, COALESCE(journal_id, 1)
`SELECT partner_id, company_id, currency_id, COALESCE(journal_id, 0), COALESCE(name, '')
FROM sale_order WHERE id = $1`, soID,
).Scan(&partnerID, &companyID, &currencyID, &journalID)
).Scan(&partnerID, &companyID, &currencyID, &journalID, &soName)
if err != nil {
return nil, fmt.Errorf("sale: read SO %d: %w", soID, err)
}
// Find sales journal if not set on SO
if journalID == 0 {
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_journal
WHERE type = 'sale' AND active = true AND company_id = $1
ORDER BY sequence, id LIMIT 1`, companyID,
).Scan(&journalID)
}
if journalID == 0 {
journalID = 1 // ultimate fallback
}
// Read SO lines
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id, COALESCE(name,''), COALESCE(product_uom_qty,1), COALESCE(price_unit,0), COALESCE(discount,0)
`SELECT id, COALESCE(name,''), COALESCE(product_uom_qty,1),
COALESCE(price_unit,0), COALESCE(discount,0), COALESCE(product_id, 0)
FROM sale_order_line
WHERE order_id = $1 AND (display_type IS NULL OR display_type = '')
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, err
}
type soLine struct {
id int64
name string
qty float64
price float64
discount float64
id int64
name string
qty float64
price float64
discount float64
productID int64
}
var lines []soLine
for rows.Next() {
var l soLine
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.productID); err != nil {
rows.Close()
return nil, err
}
@@ -325,35 +343,7 @@ func initSaleOrder() {
continue
}
// Build invoice line commands
var lineCmds []interface{}
for _, l := range lines {
subtotal := l.qty * l.price * (1 - l.discount/100)
lineCmds = append(lineCmds, []interface{}{
float64(0), float64(0), map[string]interface{}{
"name": l.name,
"quantity": l.qty,
"price_unit": l.price,
"discount": l.discount,
"debit": subtotal,
"credit": float64(0),
"account_id": float64(2), // Revenue account
"company_id": float64(companyID),
},
})
// Receivable counter-entry
lineCmds = append(lineCmds, []interface{}{
float64(0), float64(0), map[string]interface{}{
"name": "Receivable",
"debit": float64(0),
"credit": subtotal,
"account_id": float64(1), // Receivable account
"company_id": float64(companyID),
},
})
}
// Create invoice
// Create invoice header (draft)
invoiceRS := env.Model("account.move")
inv, err := invoiceRS.Create(orm.Values{
"move_type": "out_invoice",
@@ -361,15 +351,203 @@ func initSaleOrder() {
"company_id": companyID,
"currency_id": currencyID,
"journal_id": journalID,
"invoice_origin": fmt.Sprintf("SO%d", soID),
"invoice_origin": soName,
"date": time.Now().Format("2006-01-02"),
"line_ids": lineCmds,
})
if err != nil {
return nil, fmt.Errorf("sale: create invoice for SO %d: %w", soID, err)
return nil, fmt.Errorf("sale: create invoice header for SO %d: %w", soID, err)
}
moveID := inv.ID()
// Find the revenue account (8300 Erlöse 19% USt or fallback)
var revenueAccountID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account WHERE code = '8300' AND company_id = $1 LIMIT 1`,
companyID,
).Scan(&revenueAccountID)
if revenueAccountID == 0 {
// Fallback: any income account
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account
WHERE account_type LIKE 'income%' AND company_id = $1
ORDER BY code LIMIT 1`, companyID,
).Scan(&revenueAccountID)
}
if revenueAccountID == 0 {
// Fallback: journal default account
env.Tx().QueryRow(env.Ctx(),
`SELECT default_account_id FROM account_journal WHERE id = $1`, journalID,
).Scan(&revenueAccountID)
}
invoiceIDs = append(invoiceIDs, inv.ID())
// Find the receivable account
var receivableAccountID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account
WHERE account_type = 'asset_receivable' AND company_id = $1
ORDER BY code LIMIT 1`, companyID,
).Scan(&receivableAccountID)
if receivableAccountID == 0 {
return nil, fmt.Errorf("sale: no receivable account found for company %d", companyID)
}
lineRS := env.Model("account.move.line")
var totalCredit float64 // accumulates all product + tax credits
// Create product lines and tax lines for each SO line
for _, line := range lines {
baseAmount := line.qty * line.price * (1 - line.discount/100)
// Determine revenue account: try product-specific, then default
lineAccountID := revenueAccountID
if line.productID > 0 {
var prodAccID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(pc.property_account_income_categ_id, 0)
FROM product_product pp
JOIN product_template pt ON pt.id = pp.product_tmpl_id
JOIN product_category pc ON pc.id = pt.categ_id
WHERE pp.id = $1`, line.productID,
).Scan(&prodAccID)
if prodAccID > 0 {
lineAccountID = prodAccID
}
}
// Product line (credit side for revenue on out_invoice)
productLineVals := orm.Values{
"move_id": moveID,
"name": line.name,
"quantity": line.qty,
"price_unit": line.price,
"discount": line.discount,
"account_id": lineAccountID,
"company_id": companyID,
"journal_id": journalID,
"currency_id": currencyID,
"partner_id": partnerID,
"display_type": "product",
"debit": 0.0,
"credit": baseAmount,
"balance": -baseAmount,
}
if _, err := lineRS.Create(productLineVals); err != nil {
return nil, fmt.Errorf("sale: create invoice product line: %w", err)
}
totalCredit += baseAmount
// 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)
FROM account_tax t
JOIN account_tax_sale_order_line_rel rel ON rel.account_tax_id = t.id
WHERE rel.sale_order_line_id = $1`, line.id)
if err == nil {
for taxRows.Next() {
var taxID int64
var taxName string
var taxRate float64
var amountType string
var priceInclude bool
if err := taxRows.Scan(&taxID, &taxName, &taxRate, &amountType, &priceInclude); err != nil {
taxRows.Close()
break
}
// Compute tax amount (mirrors account_tax_calc.go ComputeTax)
var taxAmount float64
switch amountType {
case "percent":
if priceInclude {
taxAmount = baseAmount - (baseAmount / (1 + taxRate/100))
} else {
taxAmount = baseAmount * taxRate / 100
}
case "fixed":
taxAmount = taxRate
case "division":
if priceInclude {
taxAmount = baseAmount - (baseAmount / (1 + taxRate/100))
} else {
taxAmount = baseAmount * taxRate / 100
}
}
if taxAmount == 0 {
continue
}
// Find tax account from repartition lines
var taxAccountID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(account_id, 0) FROM account_tax_repartition_line
WHERE tax_id = $1 AND repartition_type = 'tax' AND document_type = 'invoice'
LIMIT 1`, taxID,
).Scan(&taxAccountID)
if taxAccountID == 0 {
// Fallback: USt account 1776 (SKR03)
env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM account_account WHERE code = '1776' LIMIT 1`,
).Scan(&taxAccountID)
}
if taxAccountID == 0 {
taxAccountID = lineAccountID // ultimate fallback
}
taxLineVals := orm.Values{
"move_id": moveID,
"name": taxName,
"quantity": 1.0,
"account_id": taxAccountID,
"company_id": companyID,
"journal_id": journalID,
"currency_id": currencyID,
"partner_id": partnerID,
"display_type": "tax",
"tax_line_id": taxID,
"debit": 0.0,
"credit": taxAmount,
"balance": -taxAmount,
}
if _, err := lineRS.Create(taxLineVals); err != nil {
taxRows.Close()
return nil, fmt.Errorf("sale: create invoice tax line: %w", err)
}
totalCredit += taxAmount
}
taxRows.Close()
}
}
// Create the receivable line (debit = total of all credits)
receivableVals := orm.Values{
"move_id": moveID,
"name": "/",
"quantity": 1.0,
"account_id": receivableAccountID,
"company_id": companyID,
"journal_id": journalID,
"currency_id": currencyID,
"partner_id": partnerID,
"display_type": "payment_term",
"debit": totalCredit,
"credit": 0.0,
"balance": totalCredit,
"amount_residual": totalCredit,
"amount_residual_currency": totalCredit,
}
if _, err := lineRS.Create(receivableVals); err != nil {
return nil, fmt.Errorf("sale: create invoice receivable line: %w", err)
}
invoiceIDs = append(invoiceIDs, moveID)
// Update qty_invoiced on SO lines
for _, line := range lines {
env.Tx().Exec(env.Ctx(),
`UPDATE sale_order_line SET qty_invoiced = COALESCE(qty_invoiced, 0) + $1 WHERE id = $2`,
line.qty, line.id)
}
// Update SO invoice_status
env.Tx().Exec(env.Ctx(),
@@ -606,7 +784,7 @@ func initSaleOrderLine() {
)
// -- Computed: _compute_amount (line subtotal/total) --
// Computes price_subtotal and price_total from qty, price, discount.
// Computes price_subtotal and price_total from qty, price, discount, and taxes.
// Mirrors: odoo/addons/sale/models/sale_order_line.py SaleOrderLine._compute_amount()
computeLineAmount := func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
@@ -617,9 +795,45 @@ func initSaleOrderLine() {
FROM sale_order_line WHERE id = $1`, lineID,
).Scan(&qty, &price, &discount)
subtotal := qty * price * (1 - discount/100)
// Compute tax amount from linked taxes via M2M (inline, no cross-package call)
var taxTotal float64
taxRows, err := env.Tx().Query(env.Ctx(),
`SELECT t.amount, t.amount_type, COALESCE(t.price_include, false)
FROM account_tax t
JOIN account_tax_sale_order_line_rel rel ON rel.account_tax_id = t.id
WHERE rel.sale_order_line_id = $1`, lineID)
if err == nil {
for taxRows.Next() {
var taxRate float64
var amountType string
var priceInclude bool
if err := taxRows.Scan(&taxRate, &amountType, &priceInclude); err != nil {
break
}
switch amountType {
case "percent":
if priceInclude {
taxTotal += subtotal - (subtotal / (1 + taxRate/100))
} else {
taxTotal += subtotal * taxRate / 100
}
case "fixed":
taxTotal += taxRate
case "division":
if priceInclude {
taxTotal += subtotal - (subtotal / (1 + taxRate/100))
} else {
taxTotal += subtotal * taxRate / 100
}
}
}
taxRows.Close()
}
return orm.Values{
"price_subtotal": subtotal,
"price_total": subtotal, // TODO: add tax amount for price_total
"price_total": subtotal + taxTotal,
}, nil
}
m.RegisterCompute("price_subtotal", computeLineAmount)