From d9171191afc6f60c694793bc00f96141aaddf26a Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 3 Apr 2026 14:57:33 +0200 Subject: [PATCH] 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) --- addons/account/models/account_analytic.go | 49 ++++ addons/account/models/account_move.go | 48 +++- addons/account/models/account_reports.go | 281 ++++++++++++++++++++++ addons/account/models/init.go | 3 + addons/stock/models/stock.go | 209 +++++++++++++++- pkg/service/db.go | 46 +++- 6 files changed, 632 insertions(+), 4 deletions(-) create mode 100644 addons/account/models/account_analytic.go create mode 100644 addons/account/models/account_reports.go diff --git a/addons/account/models/account_analytic.go b/addons/account/models/account_analytic.go new file mode 100644 index 0000000..3a2ccc1 --- /dev/null +++ b/addons/account/models/account_analytic.go @@ -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"}), + ) +} diff --git a/addons/account/models/account_move.go b/addons/account/models/account_move.go index 82f5344..adbd800 100644 --- a/addons/account/models/account_move.go +++ b/addons/account/models/account_move.go @@ -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 -- diff --git a/addons/account/models/account_reports.go b/addons/account/models/account_reports.go new file mode 100644 index 0000000..7711f1a --- /dev/null +++ b/addons/account/models/account_reports.go @@ -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 +} diff --git a/addons/account/models/init.go b/addons/account/models/init.go index 9670191..1093382 100644 --- a/addons/account/models/init.go +++ b/addons/account/models/init.go @@ -12,4 +12,7 @@ func Init() { initAccountReconcile() initAccountBankStatement() initAccountFiscalPosition() + initAccountTaxReport() + initAccountReportLine() + initAccountAnalytic() } diff --git a/addons/stock/models/stock.go b/addons/stock/models/stock.go index dc53fdd..f6ce7a4 100644 --- a/addons/stock/models/stock.go +++ b/addons/stock/models/stock.go @@ -21,6 +21,9 @@ func initStock() { initStockMoveLine() initStockQuant() initStockLot() + initStockOrderpoint() + initStockScrap() + initStockInventory() } // 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_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{ String: "Company", Required: true, Index: true, }), @@ -536,6 +541,18 @@ func initStockMove() { 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. // Mirrors: odoo/addons/stock/models/stock_move.py StockMove._action_confirm() 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{ 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.Many2one("package_id", "stock.quant.package", orm.FieldOpts{ String: "Package", @@ -788,3 +807,191 @@ func initStockLot() { 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(¤tQty) + + 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 +} diff --git a/pkg/service/db.go b/pkg/service/db.go index df735cd..9ff515e 100644 --- a/pkg/service/db.go +++ b/pkg/service/db.go @@ -319,6 +319,7 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err seedChartOfAccounts(ctx, tx, cfg) seedStockData(ctx, tx) seedViews(ctx, tx) + seedAccountReports(ctx, tx) seedActions(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), (4, 'WH', 'Physical Locations/WH', 'internal', true, 1, 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`) // 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"}, {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"}, + + // 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 { @@ -1115,6 +1127,20 @@ func seedMenus(ctx context.Context, tx pgx.Tx) { // ── Invoicing ──────────────────────────────────────────── {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"}, + {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 ──────────────────────────────────────────────── {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)) } +// 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. func seedDemoData(ctx context.Context, tx pgx.Tx) { log.Println("db: loading demo data...")