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:
49
addons/account/models/account_analytic.go
Normal file
49
addons/account/models/account_analytic.go
Normal 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"}),
|
||||
)
|
||||
}
|
||||
@@ -1414,10 +1414,11 @@ func initAccountBankStatement() {
|
||||
)
|
||||
|
||||
// Bank statement line
|
||||
orm.NewModel("account.bank.statement.line", orm.ModelOpts{
|
||||
stLine := orm.NewModel("account.bank.statement.line", orm.ModelOpts{
|
||||
Description: "Bank Statement Line",
|
||||
Order: "internal_index desc, sequence, id desc",
|
||||
}).AddFields(
|
||||
})
|
||||
stLine.AddFields(
|
||||
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("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
|
||||
@@ -1434,7 +1435,50 @@ func initAccountBankStatement() {
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
|
||||
orm.Char("internal_index", orm.FieldOpts{String: "Internal Index"}),
|
||||
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 --
|
||||
|
||||
281
addons/account/models/account_reports.go
Normal file
281
addons/account/models/account_reports.go
Normal 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(§ion, &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(§ion, &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, ¤t, &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
|
||||
}
|
||||
@@ -12,4 +12,7 @@ func Init() {
|
||||
initAccountReconcile()
|
||||
initAccountBankStatement()
|
||||
initAccountFiscalPosition()
|
||||
initAccountTaxReport()
|
||||
initAccountReportLine()
|
||||
initAccountAnalytic()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user