Deepen Account + Stock modules significantly

Account (+400 LOC):
- Accounting reports: Trial Balance, Balance Sheet, Profit & Loss,
  Aged Receivable/Payable, General Ledger (SQL-based generation)
- account.report + account.report.line models
- Analytic accounting: account.analytic.plan, account.analytic.account,
  account.analytic.line models
- Bank statement matching (button_match with 1% tolerance)
- 6 default report definitions seeded
- 8 new actions + 12 new menus (Vendor Bills, Payments, Bank Statements,
  Reporting, Configuration with Chart of Accounts/Journals/Taxes)

Stock (+230 LOC):
- Stock valuation: price_unit + value (computed) on moves and quants
- Reorder rules: stock.warehouse.orderpoint with min/max qty,
  qty_on_hand compute from quants, action_replenish method
- Scrap: stock.scrap model with action_validate (quant transfer)
- Inventory adjustment: stock.quant.adjust wizard (set qty directly)
- Scrap location seeded

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-04-03 14:57:33 +02:00
parent e0d8bc81d3
commit d9171191af
6 changed files with 632 additions and 4 deletions

View File

@@ -0,0 +1,49 @@
package models
import "odoo-go/pkg/orm"
// initAccountAnalytic registers analytic accounting models.
// Mirrors: odoo/addons/analytic/models/analytic_account.py
func initAccountAnalytic() {
// account.analytic.plan — Analytic Plan
// Mirrors: odoo/addons/analytic/models/analytic_plan.py
orm.NewModel("account.analytic.plan", orm.ModelOpts{
Description: "Analytic Plan",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Many2one("parent_id", "account.analytic.plan", orm.FieldOpts{String: "Parent"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
)
// account.analytic.account — Analytic Account
// Mirrors: odoo/addons/analytic/models/analytic_account.py AnalyticAccount
m := orm.NewModel("account.analytic.account", orm.ModelOpts{
Description: "Analytic Account",
Order: "code, name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Char("code", orm.FieldOpts{String: "Reference"}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("plan_id", "account.analytic.plan", orm.FieldOpts{String: "Plan"}),
)
// account.analytic.line — Analytic Line
// Mirrors: odoo/addons/analytic/models/analytic_line.py AnalyticLine
orm.NewModel("account.analytic.line", orm.ModelOpts{
Description: "Analytic Line",
Order: "date desc, id desc",
}).AddFields(
orm.Char("name", orm.FieldOpts{String: "Description", Required: true}),
orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
orm.Monetary("amount", orm.FieldOpts{String: "Amount", CurrencyField: "currency_id"}),
orm.Many2one("account_id", "account.analytic.account", orm.FieldOpts{String: "Analytic Account"}),
orm.Many2one("move_line_id", "account.move.line", orm.FieldOpts{String: "Journal Item"}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Partner"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
)
}

View File

@@ -1414,10 +1414,11 @@ func initAccountBankStatement() {
) )
// Bank statement line // Bank statement line
orm.NewModel("account.bank.statement.line", orm.ModelOpts{ stLine := orm.NewModel("account.bank.statement.line", orm.ModelOpts{
Description: "Bank Statement Line", Description: "Bank Statement Line",
Order: "internal_index desc, sequence, id desc", Order: "internal_index desc, sequence, id desc",
}).AddFields( })
stLine.AddFields(
orm.Many2one("statement_id", "account.bank.statement", orm.FieldOpts{String: "Statement"}), orm.Many2one("statement_id", "account.bank.statement", orm.FieldOpts{String: "Statement"}),
orm.Many2one("move_id", "account.move", orm.FieldOpts{String: "Journal Entry", Required: true}), orm.Many2one("move_id", "account.move", orm.FieldOpts{String: "Journal Entry", Required: true}),
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}), orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
@@ -1434,7 +1435,50 @@ func initAccountBankStatement() {
orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}), orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
orm.Char("internal_index", orm.FieldOpts{String: "Internal Index"}), orm.Char("internal_index", orm.FieldOpts{String: "Internal Index"}),
orm.Boolean("is_reconciled", orm.FieldOpts{String: "Is Reconciled"}), orm.Boolean("is_reconciled", orm.FieldOpts{String: "Is Reconciled"}),
orm.Many2one("move_line_id", "account.move.line", orm.FieldOpts{String: "Matched Journal Item"}),
) )
// button_match: automatically match bank statement lines to open invoices.
// Mirrors: odoo/addons/account/models/account_bank_statement_line.py _find_or_create_bank_statement()
stLine.RegisterMethod("button_match", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, lineID := range rs.IDs() {
var amount float64
var partnerID *int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(amount::float8, 0), partner_id FROM account_bank_statement_line WHERE id = $1`, lineID,
).Scan(&amount, &partnerID)
if err != nil {
return nil, fmt.Errorf("account: read statement line %d: %w", lineID, err)
}
if partnerID == nil {
continue
}
// Find unreconciled move lines for this partner with matching amount
var matchLineID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT l.id FROM account_move_line l
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
JOIN account_account a ON a.id = l.account_id
WHERE l.partner_id = $1
AND a.account_type IN ('asset_receivable', 'liability_payable')
AND ABS(COALESCE(l.amount_residual::float8, 0)) BETWEEN ABS($2) * 0.99 AND ABS($2) * 1.01
AND COALESCE(l.amount_residual, 0) != 0
ORDER BY ABS(COALESCE(l.amount_residual::float8, 0) - ABS($2)) LIMIT 1`,
*partnerID, amount,
).Scan(&matchLineID)
if matchLineID > 0 {
// Mark as matched
env.Tx().Exec(env.Ctx(),
`UPDATE account_bank_statement_line SET move_line_id = $1, is_reconciled = true WHERE id = $2`,
matchLineID, lineID)
}
}
return true, nil
})
} }
// -- Helper functions for argument parsing in business methods -- // -- Helper functions for argument parsing in business methods --

View File

@@ -0,0 +1,281 @@
package models
import (
"fmt"
"odoo-go/pkg/orm"
)
// initAccountTaxReport registers accounting report models.
// Mirrors: odoo/addons/account_reports/models/account_report.py
func initAccountTaxReport() {
m := orm.NewModel("account.report", orm.ModelOpts{
Description: "Accounting Report",
Order: "sequence, id",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
orm.Many2one("country_id", "res.country", orm.FieldOpts{String: "Country"}),
orm.Selection("report_type", []orm.SelectionItem{
{Value: "tax_report", Label: "Tax Report"},
{Value: "general_ledger", Label: "General Ledger"},
{Value: "partner_ledger", Label: "Partner Ledger"},
{Value: "aged_receivable", Label: "Aged Receivable"},
{Value: "aged_payable", Label: "Aged Payable"},
{Value: "balance_sheet", Label: "Balance Sheet"},
{Value: "profit_loss", Label: "Profit and Loss"},
{Value: "trial_balance", Label: "Trial Balance"},
}, orm.FieldOpts{String: "Type"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
orm.One2many("line_ids", "account.report.line", "report_id", orm.FieldOpts{String: "Lines"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
// Generate report data
// Mirrors: odoo/addons/account_reports/models/account_report.py AccountReport._get_report_data()
m.RegisterMethod("get_report_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
reportID := rs.IDs()[0]
var reportType string
err := env.Tx().QueryRow(env.Ctx(), `SELECT report_type FROM account_report WHERE id = $1`, reportID).Scan(&reportType)
if err != nil {
return nil, fmt.Errorf("account: read report type: %w", err)
}
switch reportType {
case "trial_balance":
return generateTrialBalance(env)
case "balance_sheet":
return generateBalanceSheet(env)
case "profit_loss":
return generateProfitLoss(env)
case "aged_receivable":
return generateAgedReport(env, "asset_receivable")
case "aged_payable":
return generateAgedReport(env, "liability_payable")
case "general_ledger":
return generateGeneralLedger(env)
default:
return map[string]interface{}{"lines": []interface{}{}}, nil
}
})
}
// initAccountReportLine registers the report line model.
// Mirrors: odoo/addons/account_reports/models/account_report.py AccountReportLine
func initAccountReportLine() {
orm.NewModel("account.report.line", orm.ModelOpts{
Description: "Report Line",
Order: "sequence, id",
}).AddFields(
orm.Many2one("report_id", "account.report", orm.FieldOpts{String: "Report", Required: true, OnDelete: orm.OnDeleteCascade}),
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Char("code", orm.FieldOpts{String: "Code"}),
orm.Char("formula", orm.FieldOpts{String: "Formula"}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
orm.Many2one("parent_id", "account.report.line", orm.FieldOpts{String: "Parent"}),
orm.Integer("level", orm.FieldOpts{String: "Level"}),
)
}
// -- Report generation functions --
// generateTrialBalance produces a trial balance report.
// Mirrors: odoo/addons/account_reports/models/account_trial_balance_report.py
func generateTrialBalance(env *orm.Environment) (interface{}, error) {
rows, err := env.Tx().Query(env.Ctx(), `
SELECT a.code, a.name, a.account_type,
COALESCE(SUM(l.debit), 0) as total_debit,
COALESCE(SUM(l.credit), 0) as total_credit,
COALESCE(SUM(l.balance), 0) as balance
FROM account_account a
LEFT JOIN account_move_line l ON l.account_id = a.id
LEFT JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
GROUP BY a.id, a.code, a.name, a.account_type
HAVING COALESCE(SUM(l.debit), 0) != 0 OR COALESCE(SUM(l.credit), 0) != 0
ORDER BY a.code`)
if err != nil {
return nil, fmt.Errorf("account: trial balance query: %w", err)
}
defer rows.Close()
var lines []map[string]interface{}
var totalDebit, totalCredit float64
for rows.Next() {
var code, name, accType string
var debit, credit, balance float64
if err := rows.Scan(&code, &name, &accType, &debit, &credit, &balance); err != nil {
return nil, fmt.Errorf("account: trial balance scan: %w", err)
}
lines = append(lines, map[string]interface{}{
"code": code, "name": name, "account_type": accType,
"debit": debit, "credit": credit, "balance": balance,
})
totalDebit += debit
totalCredit += credit
}
lines = append(lines, map[string]interface{}{
"code": "", "name": "TOTAL", "account_type": "",
"debit": totalDebit, "credit": totalCredit, "balance": totalDebit - totalCredit,
})
return map[string]interface{}{"lines": lines}, nil
}
// generateBalanceSheet produces assets vs liabilities+equity.
// Mirrors: odoo/addons/account_reports/models/account_balance_sheet.py
func generateBalanceSheet(env *orm.Environment) (interface{}, error) {
rows, err := env.Tx().Query(env.Ctx(), `
SELECT
CASE
WHEN a.account_type LIKE 'asset%' THEN 'Assets'
WHEN a.account_type LIKE 'liability%' THEN 'Liabilities'
WHEN a.account_type LIKE 'equity%' THEN 'Equity'
ELSE 'Other'
END as section,
a.code, a.name,
COALESCE(SUM(l.balance), 0) as balance
FROM account_account a
LEFT JOIN account_move_line l ON l.account_id = a.id
LEFT JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
WHERE a.account_type LIKE 'asset%' OR a.account_type LIKE 'liability%' OR a.account_type LIKE 'equity%'
GROUP BY a.id, a.code, a.name, a.account_type
HAVING COALESCE(SUM(l.balance), 0) != 0
ORDER BY a.code`)
if err != nil {
return nil, fmt.Errorf("account: balance sheet query: %w", err)
}
defer rows.Close()
var lines []map[string]interface{}
for rows.Next() {
var section, code, name string
var balance float64
if err := rows.Scan(&section, &code, &name, &balance); err != nil {
return nil, fmt.Errorf("account: balance sheet scan: %w", err)
}
lines = append(lines, map[string]interface{}{
"section": section, "code": code, "name": name, "balance": balance,
})
}
return map[string]interface{}{"lines": lines}, nil
}
// generateProfitLoss produces income vs expenses.
// Mirrors: odoo/addons/account_reports/models/account_profit_loss.py
func generateProfitLoss(env *orm.Environment) (interface{}, error) {
rows, err := env.Tx().Query(env.Ctx(), `
SELECT
CASE
WHEN a.account_type LIKE 'income%' THEN 'Income'
WHEN a.account_type LIKE 'expense%' THEN 'Expenses'
ELSE 'Other'
END as section,
a.code, a.name,
COALESCE(SUM(l.balance), 0) as balance
FROM account_account a
LEFT JOIN account_move_line l ON l.account_id = a.id
LEFT JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
WHERE a.account_type LIKE 'income%' OR a.account_type LIKE 'expense%'
GROUP BY a.id, a.code, a.name, a.account_type
HAVING COALESCE(SUM(l.balance), 0) != 0
ORDER BY a.code`)
if err != nil {
return nil, fmt.Errorf("account: profit loss query: %w", err)
}
defer rows.Close()
var lines []map[string]interface{}
var totalIncome, totalExpense float64
for rows.Next() {
var section, code, name string
var balance float64
if err := rows.Scan(&section, &code, &name, &balance); err != nil {
return nil, fmt.Errorf("account: profit loss scan: %w", err)
}
lines = append(lines, map[string]interface{}{
"section": section, "code": code, "name": name, "balance": balance,
})
if section == "Income" {
totalIncome += balance
}
if section == "Expenses" {
totalExpense += balance
}
}
// income is negative (credit), expenses positive (debit)
profit := totalIncome + totalExpense
lines = append(lines, map[string]interface{}{
"section": "Result", "code": "", "name": "Net Profit/Loss", "balance": -profit,
})
return map[string]interface{}{"lines": lines}, nil
}
// generateAgedReport produces aged receivable/payable.
// Mirrors: odoo/addons/account_reports/models/account_aged_partner_balance.py
func generateAgedReport(env *orm.Environment, accountType string) (interface{}, error) {
rows, err := env.Tx().Query(env.Ctx(), `
SELECT COALESCE(p.name, 'Unknown') as partner,
COALESCE(SUM(CASE WHEN m.date >= CURRENT_DATE - 30 THEN l.amount_residual ELSE 0 END), 0) as current_amount,
COALESCE(SUM(CASE WHEN m.date < CURRENT_DATE - 30 AND m.date >= CURRENT_DATE - 60 THEN l.amount_residual ELSE 0 END), 0) as days_30,
COALESCE(SUM(CASE WHEN m.date < CURRENT_DATE - 60 AND m.date >= CURRENT_DATE - 90 THEN l.amount_residual ELSE 0 END), 0) as days_60,
COALESCE(SUM(CASE WHEN m.date < CURRENT_DATE - 90 THEN l.amount_residual ELSE 0 END), 0) as days_90_plus,
COALESCE(SUM(l.amount_residual), 0) as total
FROM account_move_line l
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
JOIN account_account a ON a.id = l.account_id AND a.account_type = $1
LEFT JOIN res_partner p ON p.id = l.partner_id
WHERE l.amount_residual != 0
GROUP BY p.id, p.name
ORDER BY total DESC`, accountType)
if err != nil {
return nil, fmt.Errorf("account: aged report query: %w", err)
}
defer rows.Close()
var lines []map[string]interface{}
for rows.Next() {
var partner string
var current, d30, d60, d90, total float64
if err := rows.Scan(&partner, &current, &d30, &d60, &d90, &total); err != nil {
return nil, fmt.Errorf("account: aged report scan: %w", err)
}
lines = append(lines, map[string]interface{}{
"partner": partner, "current": current, "1-30": d30,
"31-60": d60, "61-90+": d90, "total": total,
})
}
return map[string]interface{}{"lines": lines}, nil
}
// generateGeneralLedger produces detailed journal entries per account.
// Mirrors: odoo/addons/account_reports/models/account_general_ledger.py
func generateGeneralLedger(env *orm.Environment) (interface{}, error) {
rows, err := env.Tx().Query(env.Ctx(), `
SELECT a.code, a.name, m.name as move_name, m.date, l.name as label,
l.debit, l.credit, l.balance
FROM account_move_line l
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
JOIN account_account a ON a.id = l.account_id
ORDER BY a.code, m.date, l.id
LIMIT 1000`)
if err != nil {
return nil, fmt.Errorf("account: general ledger query: %w", err)
}
defer rows.Close()
var lines []map[string]interface{}
for rows.Next() {
var code, name, moveName, label string
var date interface{}
var debit, credit, balance float64
if err := rows.Scan(&code, &name, &moveName, &date, &label, &debit, &credit, &balance); err != nil {
return nil, fmt.Errorf("account: general ledger scan: %w", err)
}
lines = append(lines, map[string]interface{}{
"account_code": code, "account_name": name, "move": moveName,
"date": date, "label": label, "debit": debit, "credit": credit, "balance": balance,
})
}
return map[string]interface{}{"lines": lines}, nil
}

View File

@@ -12,4 +12,7 @@ func Init() {
initAccountReconcile() initAccountReconcile()
initAccountBankStatement() initAccountBankStatement()
initAccountFiscalPosition() initAccountFiscalPosition()
initAccountTaxReport()
initAccountReportLine()
initAccountAnalytic()
} }

View File

@@ -21,6 +21,9 @@ func initStock() {
initStockMoveLine() initStockMoveLine()
initStockQuant() initStockQuant()
initStockLot() initStockLot()
initStockOrderpoint()
initStockScrap()
initStockInventory()
} }
// initStockWarehouse registers stock.warehouse. // initStockWarehouse registers stock.warehouse.
@@ -528,7 +531,9 @@ func initStockMove() {
}), }),
orm.Datetime("date", orm.FieldOpts{String: "Date Scheduled", Required: true, Index: true}), orm.Datetime("date", orm.FieldOpts{String: "Date Scheduled", Required: true, Index: true}),
orm.Datetime("date_deadline", orm.FieldOpts{String: "Deadline"}), orm.Datetime("date_deadline", orm.FieldOpts{String: "Deadline"}),
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}), orm.Monetary("price_unit", orm.FieldOpts{String: "Unit Price", CurrencyField: "currency_id"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Monetary("value", orm.FieldOpts{String: "Value", Compute: "_compute_value", Store: true, CurrencyField: "currency_id"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{ orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true, String: "Company", Required: true, Index: true,
}), }),
@@ -536,6 +541,18 @@ func initStockMove() {
orm.Char("origin", orm.FieldOpts{String: "Source Document"}), orm.Char("origin", orm.FieldOpts{String: "Source Document"}),
) )
// _compute_value: value = price_unit * product_uom_qty
// Mirrors: odoo/addons/stock/models/stock_move.py StockMove._compute_value()
m.RegisterCompute("value", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
moveID := rs.IDs()[0]
var priceUnit, qty float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(price_unit, 0), COALESCE(product_uom_qty, 0) FROM stock_move WHERE id = $1`,
moveID).Scan(&priceUnit, &qty)
return orm.Values{"value": priceUnit * qty}, nil
})
// _action_confirm: Confirm stock moves (draft → confirmed), then try to reserve. // _action_confirm: Confirm stock moves (draft → confirmed), then try to reserve.
// Mirrors: odoo/addons/stock/models/stock_move.py StockMove._action_confirm() // Mirrors: odoo/addons/stock/models/stock_move.py StockMove._action_confirm()
m.RegisterMethod("_action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { m.RegisterMethod("_action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
@@ -695,6 +712,8 @@ func initStockQuant() {
orm.Many2one("company_id", "res.company", orm.FieldOpts{ orm.Many2one("company_id", "res.company", orm.FieldOpts{
String: "Company", Required: true, Index: true, String: "Company", Required: true, Index: true,
}), }),
orm.Monetary("value", orm.FieldOpts{String: "Value", CurrencyField: "currency_id"}),
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
orm.Datetime("in_date", orm.FieldOpts{String: "Incoming Date", Index: true}), orm.Datetime("in_date", orm.FieldOpts{String: "Incoming Date", Index: true}),
orm.Many2one("package_id", "stock.quant.package", orm.FieldOpts{ orm.Many2one("package_id", "stock.quant.package", orm.FieldOpts{
String: "Package", String: "Package",
@@ -788,3 +807,191 @@ func initStockLot() {
orm.Text("note", orm.FieldOpts{String: "Description"}), orm.Text("note", orm.FieldOpts{String: "Description"}),
) )
} }
// initStockOrderpoint registers stock.warehouse.orderpoint — reorder rules.
// Mirrors: odoo/addons/stock/models/stock_orderpoint.py
func initStockOrderpoint() {
m := orm.NewModel("stock.warehouse.orderpoint", orm.ModelOpts{
Description: "Reorder Rule",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product", Required: true}),
orm.Many2one("warehouse_id", "stock.warehouse", orm.FieldOpts{String: "Warehouse", Required: true}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{String: "Location", Required: true}),
orm.Float("product_min_qty", orm.FieldOpts{String: "Minimum Quantity"}),
orm.Float("product_max_qty", orm.FieldOpts{String: "Maximum Quantity"}),
orm.Float("qty_multiple", orm.FieldOpts{String: "Quantity Multiple", Default: 1}),
orm.Float("qty_on_hand", orm.FieldOpts{String: "On Hand", Compute: "_compute_qty", Store: false}),
orm.Float("qty_forecast", orm.FieldOpts{String: "Forecast", Compute: "_compute_qty", Store: false}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
)
// Compute on-hand qty from quants
m.RegisterCompute("qty_on_hand", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
opID := rs.IDs()[0]
var productID, locationID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, location_id FROM stock_warehouse_orderpoint WHERE id = $1`, opID,
).Scan(&productID, &locationID)
var qty float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(quantity - reserved_quantity), 0) FROM stock_quant WHERE product_id = $1 AND location_id = $2`,
productID, locationID,
).Scan(&qty)
return orm.Values{"qty_on_hand": qty, "qty_forecast": qty}, nil
})
// action_replenish: check all orderpoints and create procurement if below min.
// Mirrors: odoo/addons/stock/models/stock_orderpoint.py action_replenish()
m.RegisterMethod("action_replenish", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, opID := range rs.IDs() {
var productID, locationID int64
var minQty, maxQty, qtyMultiple float64
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, location_id, product_min_qty, product_max_qty, qty_multiple
FROM stock_warehouse_orderpoint WHERE id = $1`, opID,
).Scan(&productID, &locationID, &minQty, &maxQty, &qtyMultiple)
// Check current stock
var onHand float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(quantity - reserved_quantity), 0) FROM stock_quant WHERE product_id = $1 AND location_id = $2`,
productID, locationID,
).Scan(&onHand)
if onHand < minQty {
// Need to replenish: qty = max - on_hand, rounded up to qty_multiple
needed := maxQty - onHand
if qtyMultiple > 0 {
remainder := int(needed) % int(qtyMultiple)
if remainder > 0 {
needed += qtyMultiple - float64(remainder)
}
}
// Create internal transfer or purchase (simplified: just log)
fmt.Printf("stock: orderpoint %d needs %.0f units of product %d\n", opID, needed, productID)
}
}
return true, nil
})
}
// initStockScrap registers stock.scrap — scrap/disposal of products.
// Mirrors: odoo/addons/stock/models/stock_scrap.py
func initStockScrap() {
m := orm.NewModel("stock.scrap", orm.ModelOpts{
Description: "Scrap",
Order: "name desc",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Reference", Readonly: true, Default: "New"}),
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product", Required: true}),
orm.Float("scrap_qty", orm.FieldOpts{String: "Quantity", Required: true, Default: 1}),
orm.Many2one("lot_id", "stock.lot", orm.FieldOpts{String: "Lot/Serial"}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{String: "Source Location", Required: true}),
orm.Many2one("scrap_location_id", "stock.location", orm.FieldOpts{String: "Scrap Location", Required: true}),
orm.Many2one("picking_id", "stock.picking", orm.FieldOpts{String: "Picking"}),
orm.Many2one("move_id", "stock.move", orm.FieldOpts{String: "Scrap Move"}),
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
orm.Date("date_done", orm.FieldOpts{String: "Date"}),
orm.Selection("state", []orm.SelectionItem{
{Value: "draft", Label: "Draft"},
{Value: "done", Label: "Done"},
}, orm.FieldOpts{String: "Status", Default: "draft"}),
)
// action_validate: Validate scrap, move product from source to scrap location.
// Mirrors: odoo/addons/stock/models/stock_scrap.py StockScrap.action_validate()
m.RegisterMethod("action_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, scrapID := range rs.IDs() {
var productID, locID, scrapLocID int64
var qty float64
env.Tx().QueryRow(env.Ctx(),
`SELECT product_id, scrap_qty, location_id, scrap_location_id FROM stock_scrap WHERE id = $1`,
scrapID).Scan(&productID, &qty, &locID, &scrapLocID)
// Update quants (move from location to scrap location)
if err := updateQuant(env, productID, locID, -qty); err != nil {
return nil, fmt.Errorf("stock.scrap: update source quant for scrap %d: %w", scrapID, err)
}
if err := updateQuant(env, productID, scrapLocID, qty); err != nil {
return nil, fmt.Errorf("stock.scrap: update scrap quant for scrap %d: %w", scrapID, err)
}
_, err := env.Tx().Exec(env.Ctx(),
`UPDATE stock_scrap SET state = 'done', date_done = NOW() WHERE id = $1`, scrapID)
if err != nil {
return nil, fmt.Errorf("stock.scrap: validate scrap %d: %w", scrapID, err)
}
}
return true, nil
})
}
// initStockInventory registers stock.quant.adjust — inventory adjustment wizard.
// Mirrors: odoo/addons/stock/wizard/stock_change_product_qty.py (transient model)
func initStockInventory() {
m := orm.NewModel("stock.quant.adjust", orm.ModelOpts{
Description: "Inventory Adjustment",
Type: orm.ModelTransient,
})
m.AddFields(
orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product", Required: true}),
orm.Many2one("location_id", "stock.location", orm.FieldOpts{String: "Location", Required: true}),
orm.Float("new_quantity", orm.FieldOpts{String: "New Quantity", Required: true}),
orm.Many2one("lot_id", "stock.lot", orm.FieldOpts{String: "Lot/Serial"}),
)
// action_apply: Set quant quantity to the specified new quantity.
// Mirrors: stock.change.product.qty.change_product_qty()
m.RegisterMethod("action_apply", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
data, err := rs.Read([]string{"product_id", "location_id", "new_quantity"})
if err != nil || len(data) == 0 {
return nil, err
}
d := data[0]
productID := toInt64(d["product_id"])
locationID := toInt64(d["location_id"])
newQty, _ := d["new_quantity"].(float64)
// Get current quantity
var currentQty float64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(SUM(quantity), 0) FROM stock_quant WHERE product_id = $1 AND location_id = $2`,
productID, locationID).Scan(&currentQty)
diff := newQty - currentQty
if diff != 0 {
if err := updateQuant(env, productID, locationID, diff); err != nil {
return nil, fmt.Errorf("stock.quant.adjust: update quant: %w", err)
}
}
return map[string]interface{}{"type": "ir.actions.act_window_close"}, nil
})
}
// toInt64 converts various numeric types to int64 for use in business methods.
func toInt64(v interface{}) int64 {
switch n := v.(type) {
case int64:
return n
case float64:
return int64(n)
case int:
return int64(n)
case int32:
return int64(n)
}
return 0
}

View File

@@ -319,6 +319,7 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err
seedChartOfAccounts(ctx, tx, cfg) seedChartOfAccounts(ctx, tx, cfg)
seedStockData(ctx, tx) seedStockData(ctx, tx)
seedViews(ctx, tx) seedViews(ctx, tx)
seedAccountReports(ctx, tx)
seedActions(ctx, tx) seedActions(ctx, tx)
seedMenus(ctx, tx) seedMenus(ctx, tx)
@@ -489,7 +490,8 @@ func seedStockData(ctx context.Context, tx pgx.Tx) {
(3, 'Virtual Locations', 'Virtual Locations', 'view', true, NULL, 1), (3, 'Virtual Locations', 'Virtual Locations', 'view', true, NULL, 1),
(4, 'WH', 'Physical Locations/WH', 'internal', true, 1, 1), (4, 'WH', 'Physical Locations/WH', 'internal', true, 1, 1),
(5, 'Customers', 'Partner Locations/Customers', 'customer', true, 2, 1), (5, 'Customers', 'Partner Locations/Customers', 'customer', true, 2, 1),
(6, 'Vendors', 'Partner Locations/Vendors', 'supplier', true, 2, 1) (6, 'Vendors', 'Partner Locations/Vendors', 'supplier', true, 2, 1),
(7, 'Scrap', 'Virtual Locations/Scrap', 'inventory', true, 3, 1)
ON CONFLICT (id) DO NOTHING`) ON CONFLICT (id) DO NOTHING`)
// Default warehouse (must come before picking types due to FK) // Default warehouse (must come before picking types due to FK)
@@ -1064,6 +1066,16 @@ func seedActions(ctx context.Context, tx pgx.Tx) {
{111, "Menus", "ir.ui.menu", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_ui_menu"}, {111, "Menus", "ir.ui.menu", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_ui_menu"},
{112, "Access Rights", "ir.model.access", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_model_access"}, {112, "Access Rights", "ir.model.access", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_model_access"},
{113, "Record Rules", "ir.rule", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_rule"}, {113, "Record Rules", "ir.rule", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_rule"},
// Accounting reports
{200, "Accounting Reports", "account.report", "list,form", "[]", "{}", "current", 80, 0, "account", "action_account_report"},
{201, "Vendor Bills", "account.move", "list,form", `[("move_type","in",["in_invoice","in_refund"])]`, `{"default_move_type":"in_invoice"}`, "current", 80, 0, "account", "action_move_in_invoice_type"},
{202, "Payments", "account.payment", "list,form", "[]", "{}", "current", 80, 0, "account", "action_account_payments"},
{203, "Bank Statements", "account.bank.statement", "list,form", "[]", "{}", "current", 80, 0, "account", "action_bank_statement_tree"},
{204, "Chart of Accounts", "account.account", "list,form", "[]", "{}", "current", 80, 0, "account", "action_account_form"},
{205, "Journals", "account.journal", "list,form", "[]", "{}", "current", 80, 0, "account", "action_account_journal_form"},
{206, "Taxes", "account.tax", "list,form", "[]", "{}", "current", 80, 0, "account", "action_tax_form"},
{207, "Analytic Accounts", "account.analytic.account", "list,form", "[]", "{}", "current", 80, 0, "account", "action_analytic_account_form"},
} }
for _, a := range actions { for _, a := range actions {
@@ -1115,6 +1127,20 @@ func seedMenus(ctx context.Context, tx pgx.Tx) {
// ── Invoicing ──────────────────────────────────────────── // ── Invoicing ────────────────────────────────────────────
{2, "Invoicing", nil, 20, "ir.actions.act_window,2", "fa-book,#71639e,#FFFFFF", "account", "menu_finance"}, {2, "Invoicing", nil, 20, "ir.actions.act_window,2", "fa-book,#71639e,#FFFFFF", "account", "menu_finance"},
{20, "Invoices", p(2), 10, "ir.actions.act_window,2", "", "account", "menu_finance_invoices"}, {20, "Invoices", p(2), 10, "ir.actions.act_window,2", "", "account", "menu_finance_invoices"},
{22, "Vendor Bills", p(2), 20, "ir.actions.act_window,201", "", "account", "menu_finance_vendor_bills"},
{23, "Payments", p(2), 30, "ir.actions.act_window,202", "", "account", "menu_finance_payments"},
{24, "Bank Statements", p(2), 40, "ir.actions.act_window,203", "", "account", "menu_finance_bank_statements"},
// Invoicing → Reporting
{25, "Reporting", p(2), 50, "", "", "account", "menu_finance_reporting"},
{250, "Accounting Reports", p(25), 10, "ir.actions.act_window,200", "", "account", "menu_finance_reports"},
// Invoicing → Configuration
{26, "Configuration", p(2), 90, "", "", "account", "menu_finance_configuration"},
{260, "Chart of Accounts", p(26), 10, "ir.actions.act_window,204", "", "account", "menu_finance_chart_of_accounts"},
{261, "Journals", p(26), 20, "ir.actions.act_window,205", "", "account", "menu_finance_journals"},
{262, "Taxes", p(26), 30, "ir.actions.act_window,206", "", "account", "menu_finance_taxes"},
{263, "Analytic Accounts", p(26), 40, "ir.actions.act_window,207", "", "account", "menu_finance_analytic_accounts"},
// ── Sales ──────────────────────────────────────────────── // ── Sales ────────────────────────────────────────────────
{3, "Sales", nil, 30, "ir.actions.act_window,3", "fa-bar-chart,#71639e,#FFFFFF", "sale", "menu_sale_root"}, {3, "Sales", nil, 30, "ir.actions.act_window,3", "fa-bar-chart,#71639e,#FFFFFF", "sale", "menu_sale_root"},
@@ -1213,6 +1239,24 @@ func seedMenus(ctx context.Context, tx pgx.Tx) {
log.Printf("db: seeded %d menus with XML IDs", len(menus)) log.Printf("db: seeded %d menus with XML IDs", len(menus))
} }
// seedAccountReports creates default accounting report definitions.
// Mirrors: odoo/addons/account_reports/data/account_report_data.xml
func seedAccountReports(ctx context.Context, tx pgx.Tx) {
log.Println("db: seeding accounting reports...")
tx.Exec(ctx, `
INSERT INTO account_report (id, name, report_type, sequence, active) VALUES
(1, 'Trial Balance', 'trial_balance', 10, true),
(2, 'Balance Sheet', 'balance_sheet', 20, true),
(3, 'Profit and Loss', 'profit_loss', 30, true),
(4, 'Aged Receivable', 'aged_receivable', 40, true),
(5, 'Aged Payable', 'aged_payable', 50, true),
(6, 'General Ledger', 'general_ledger', 60, true)
ON CONFLICT (id) DO NOTHING`)
log.Println("db: seeded 6 accounting reports")
}
// seedDemoData creates example records for testing. // seedDemoData creates example records for testing.
func seedDemoData(ctx context.Context, tx pgx.Tx) { func seedDemoData(ctx context.Context, tx pgx.Tx) {
log.Println("db: loading demo data...") log.Println("db: loading demo data...")