Phase 1: read_group/web_read_group with SQL GROUP BY, aggregates (sum/avg/min/max/count/array_agg/sum_currency), date granularity, M2O groupby resolution to [id, display_name]. Phase 2: Record rules with domain_force parsing (Python literal parser), global AND + group OR merging. Domain operators: child_of, parent_of, any, not any compiled to SQL hierarchy/EXISTS queries. Phase 3: Button dispatch via /web/dataset/call_button, method return values interpreted as actions. Payment register wizard (account.payment.register) for sale→invoice→pay flow. Phase 4: ir.filters, ir.default, product fields expanded, SO line product_id onchange, ir_model+ir_model_fields DB seeding. Phase 5: CSV export (/web/export/csv), attachment upload/download via ir.attachment, fields_get with aggregator hints. Admin/System: Session persistence (PostgreSQL-backed), ir.config_parameter with get_param/set_param, ir.cron, ir.logging, res.lang, res.config.settings with company-related fields, Settings form view. Technical menu with Views/Actions/Parameters/Security/Logging sub-menus. User change_password, preferences. Password never exposed in UI/API. Bugfixes: false→nil for varchar/int fields, int32 in toInt64, call_button route with trailing slash, create_invoices returns action, search view always included, get_formview_action, name_create, ir.http stub. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1073 lines
41 KiB
Go
1073 lines
41 KiB
Go
package models
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"odoo-go/pkg/orm"
|
|
)
|
|
|
|
// initAccountJournal registers account.journal — where entries are posted.
|
|
// Mirrors: odoo/addons/account/models/account_journal.py
|
|
func initAccountJournal() {
|
|
m := orm.NewModel("account.journal", orm.ModelOpts{
|
|
Description: "Journal",
|
|
Order: "sequence, type, code",
|
|
})
|
|
|
|
m.AddFields(
|
|
orm.Char("name", orm.FieldOpts{String: "Journal Name", Required: true, Translate: true}),
|
|
orm.Char("code", orm.FieldOpts{String: "Short Code", Required: true, Size: 5}),
|
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
|
orm.Selection("type", []orm.SelectionItem{
|
|
{Value: "sale", Label: "Sales"},
|
|
{Value: "purchase", Label: "Purchase"},
|
|
{Value: "cash", Label: "Cash"},
|
|
{Value: "bank", Label: "Bank"},
|
|
{Value: "general", Label: "Miscellaneous"},
|
|
{Value: "credit", Label: "Credit Card"},
|
|
}, orm.FieldOpts{String: "Type", Required: true}),
|
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
|
|
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
|
orm.Many2one("default_account_id", "account.account", orm.FieldOpts{String: "Default Account"}),
|
|
orm.Many2one("suspense_account_id", "account.account", orm.FieldOpts{String: "Suspense Account"}),
|
|
orm.Many2one("profit_account_id", "account.account", orm.FieldOpts{String: "Profit Account"}),
|
|
orm.Many2one("loss_account_id", "account.account", orm.FieldOpts{String: "Loss Account"}),
|
|
orm.Many2one("sequence_id", "ir.sequence", orm.FieldOpts{String: "Entry Sequence"}),
|
|
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
|
orm.Integer("color", orm.FieldOpts{String: "Color"}),
|
|
orm.Char("bank_acc_number", orm.FieldOpts{String: "Account Number"}),
|
|
orm.Many2one("bank_id", "res.bank", orm.FieldOpts{String: "Bank"}),
|
|
orm.Many2one("bank_account_id", "res.partner.bank", orm.FieldOpts{String: "Bank Account"}),
|
|
orm.Boolean("restrict_mode_hash_table", orm.FieldOpts{String: "Lock Posted Entries with Hash"}),
|
|
)
|
|
}
|
|
|
|
// initAccountMove registers account.move — the core journal entry / invoice model.
|
|
// Mirrors: odoo/addons/account/models/account_move.py
|
|
//
|
|
// account.move is THE central model in Odoo accounting. It represents:
|
|
// - Journal entries (manual bookkeeping)
|
|
// - Customer invoices / credit notes
|
|
// - Vendor bills / refunds
|
|
// - Receipts
|
|
func initAccountMove() {
|
|
m := orm.NewModel("account.move", orm.ModelOpts{
|
|
Description: "Journal Entry",
|
|
Order: "date desc, name desc, id desc",
|
|
RecName: "name",
|
|
})
|
|
|
|
// -- Identity --
|
|
m.AddFields(
|
|
orm.Char("name", orm.FieldOpts{String: "Number", Index: true, Readonly: true, Default: "/"}),
|
|
orm.Date("date", orm.FieldOpts{String: "Date", Required: true, Index: true}),
|
|
orm.Char("ref", orm.FieldOpts{String: "Reference"}),
|
|
)
|
|
|
|
// -- Type & State --
|
|
m.AddFields(
|
|
orm.Selection("move_type", []orm.SelectionItem{
|
|
{Value: "entry", Label: "Journal Entry"},
|
|
{Value: "out_invoice", Label: "Customer Invoice"},
|
|
{Value: "out_refund", Label: "Customer Credit Note"},
|
|
{Value: "in_invoice", Label: "Vendor Bill"},
|
|
{Value: "in_refund", Label: "Vendor Credit Note"},
|
|
{Value: "out_receipt", Label: "Sales Receipt"},
|
|
{Value: "in_receipt", Label: "Purchase Receipt"},
|
|
}, orm.FieldOpts{String: "Type", Required: true, Default: "entry", Index: true}),
|
|
orm.Selection("state", []orm.SelectionItem{
|
|
{Value: "draft", Label: "Draft"},
|
|
{Value: "posted", Label: "Posted"},
|
|
{Value: "cancel", Label: "Cancelled"},
|
|
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Readonly: true, Index: true}),
|
|
orm.Selection("payment_state", []orm.SelectionItem{
|
|
{Value: "not_paid", Label: "Not Paid"},
|
|
{Value: "in_payment", Label: "In Payment"},
|
|
{Value: "paid", Label: "Paid"},
|
|
{Value: "partial", Label: "Partially Paid"},
|
|
{Value: "reversed", Label: "Reversed"},
|
|
{Value: "blocked", Label: "Blocked"},
|
|
}, orm.FieldOpts{String: "Payment Status", Compute: "_compute_payment_state", Store: true}),
|
|
)
|
|
|
|
// -- Relationships --
|
|
m.AddFields(
|
|
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{
|
|
String: "Journal", Required: true, Index: true,
|
|
}),
|
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{
|
|
String: "Company", Required: true, Index: true,
|
|
}),
|
|
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{
|
|
String: "Currency", Required: true,
|
|
}),
|
|
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
|
|
String: "Partner", Index: true,
|
|
}),
|
|
orm.Many2one("commercial_partner_id", "res.partner", orm.FieldOpts{
|
|
String: "Commercial Entity", Related: "partner_id.commercial_partner_id",
|
|
}),
|
|
orm.Many2one("fiscal_position_id", "account.fiscal.position", orm.FieldOpts{
|
|
String: "Fiscal Position",
|
|
}),
|
|
orm.Many2one("partner_bank_id", "res.partner.bank", orm.FieldOpts{
|
|
String: "Recipient Bank",
|
|
}),
|
|
)
|
|
|
|
// -- Lines --
|
|
m.AddFields(
|
|
orm.One2many("line_ids", "account.move.line", "move_id", orm.FieldOpts{
|
|
String: "Journal Items",
|
|
}),
|
|
orm.One2many("invoice_line_ids", "account.move.line", "move_id", orm.FieldOpts{
|
|
String: "Invoice Lines",
|
|
}),
|
|
)
|
|
|
|
// -- Amounts (Computed) --
|
|
m.AddFields(
|
|
orm.Monetary("amount_untaxed", orm.FieldOpts{String: "Untaxed Amount", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}),
|
|
orm.Monetary("amount_tax", orm.FieldOpts{String: "Tax", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}),
|
|
orm.Monetary("amount_total", orm.FieldOpts{String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}),
|
|
orm.Monetary("amount_residual", orm.FieldOpts{String: "Amount Due", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}),
|
|
orm.Monetary("amount_total_in_currency_signed", orm.FieldOpts{String: "Total in Currency Signed", Compute: "_compute_amount", Store: true}),
|
|
)
|
|
|
|
// -- Invoice specific --
|
|
m.AddFields(
|
|
orm.Date("invoice_date", orm.FieldOpts{String: "Invoice/Bill Date"}),
|
|
orm.Date("invoice_date_due", orm.FieldOpts{String: "Due Date"}),
|
|
orm.Char("invoice_origin", orm.FieldOpts{String: "Source Document"}),
|
|
orm.Many2one("invoice_payment_term_id", "account.payment.term", orm.FieldOpts{
|
|
String: "Payment Terms",
|
|
}),
|
|
orm.Text("narration", orm.FieldOpts{String: "Terms and Conditions"}),
|
|
)
|
|
|
|
// -- Technical --
|
|
m.AddFields(
|
|
orm.Boolean("auto_post", orm.FieldOpts{String: "Auto-post"}),
|
|
orm.Char("sequence_prefix", orm.FieldOpts{String: "Sequence Prefix"}),
|
|
orm.Integer("sequence_number", orm.FieldOpts{String: "Sequence Number"}),
|
|
)
|
|
|
|
// -- Computed Fields --
|
|
// _compute_amount: sums invoice lines to produce totals.
|
|
// Mirrors: odoo/addons/account/models/account_move.py AccountMove._compute_amount()
|
|
//
|
|
// Separates untaxed (product lines) from tax (tax lines) via display_type,
|
|
// then derives total = untaxed + tax.
|
|
computeAmount := func(rs *orm.Recordset) (orm.Values, error) {
|
|
env := rs.Env()
|
|
moveID := rs.IDs()[0]
|
|
|
|
var untaxed, tax float64
|
|
err := env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT
|
|
COALESCE(SUM(CASE WHEN display_type IS NULL OR display_type = '' OR display_type = 'product' THEN ABS(balance) ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN display_type = 'tax' THEN ABS(balance) ELSE 0 END), 0)
|
|
FROM account_move_line WHERE move_id = $1`, moveID,
|
|
).Scan(&untaxed, &tax)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
total := untaxed + tax
|
|
|
|
return orm.Values{
|
|
"amount_untaxed": untaxed,
|
|
"amount_tax": tax,
|
|
"amount_total": total,
|
|
"amount_residual": total, // Simplified: residual = total until payments
|
|
}, nil
|
|
}
|
|
|
|
m.RegisterCompute("amount_untaxed", computeAmount)
|
|
m.RegisterCompute("amount_tax", computeAmount)
|
|
m.RegisterCompute("amount_total", computeAmount)
|
|
m.RegisterCompute("amount_residual", computeAmount)
|
|
|
|
// -- Business Methods: State Transitions --
|
|
// Mirrors: odoo/addons/account/models/account_move.py action_post(), button_cancel()
|
|
|
|
// action_post: draft → posted
|
|
m.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
env := rs.Env()
|
|
for _, id := range rs.IDs() {
|
|
var state string
|
|
err := env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT state FROM account_move WHERE id = $1`, id).Scan(&state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if state != "draft" {
|
|
return nil, fmt.Errorf("account: can only post draft entries (current: %s)", state)
|
|
}
|
|
// Check balanced
|
|
var debitSum, creditSum float64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT COALESCE(SUM(debit),0), COALESCE(SUM(credit),0)
|
|
FROM account_move_line WHERE move_id = $1`, id).Scan(&debitSum, &creditSum)
|
|
diff := debitSum - creditSum
|
|
if diff < -0.005 || diff > 0.005 {
|
|
return nil, fmt.Errorf("account: cannot post unbalanced entry (debit=%.2f, credit=%.2f)", debitSum, creditSum)
|
|
}
|
|
if _, err := env.Tx().Exec(env.Ctx(),
|
|
`UPDATE account_move SET state = 'posted' WHERE id = $1`, id); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return true, nil
|
|
})
|
|
|
|
// button_cancel: posted → cancel (or draft → cancel)
|
|
m.RegisterMethod("button_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
env := rs.Env()
|
|
for _, id := range rs.IDs() {
|
|
if _, err := env.Tx().Exec(env.Ctx(),
|
|
`UPDATE account_move SET state = 'cancel' WHERE id = $1`, id); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return true, nil
|
|
})
|
|
|
|
// button_draft: cancel → draft
|
|
m.RegisterMethod("button_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
env := rs.Env()
|
|
for _, id := range rs.IDs() {
|
|
var state string
|
|
err := env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT state FROM account_move WHERE id = $1`, id).Scan(&state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if state != "cancel" {
|
|
return nil, fmt.Errorf("account: can only reset cancelled entries to draft (current: %s)", state)
|
|
}
|
|
if _, err := env.Tx().Exec(env.Ctx(),
|
|
`UPDATE account_move SET state = 'draft' WHERE id = $1`, id); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return true, nil
|
|
})
|
|
|
|
// -- Business Method: create_invoice_with_tax --
|
|
// Creates a customer invoice with automatic tax line generation.
|
|
// For each product line that carries a tax_id, a separate tax line
|
|
// (display_type='tax') is created. A receivable line balances the entry.
|
|
//
|
|
// args[0]: partner_id (int64 or float64)
|
|
// args[1]: lines ([]interface{} of map[string]interface{})
|
|
// Each line: {name, quantity, price_unit, account_id, tax_id?}
|
|
//
|
|
// Returns: the created account.move ID (int64)
|
|
m.RegisterMethod("create_invoice_with_tax", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
if len(args) < 2 {
|
|
return nil, fmt.Errorf("account: create_invoice_with_tax requires partner_id and lines")
|
|
}
|
|
|
|
env := rs.Env()
|
|
|
|
partnerID, ok := toInt64Arg(args[0])
|
|
if !ok {
|
|
return nil, fmt.Errorf("account: invalid partner_id")
|
|
}
|
|
|
|
rawLines, ok := args[1].([]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("account: lines must be a list")
|
|
}
|
|
|
|
// Step 1: Create the move header (draft invoice)
|
|
moveRS := env.Model("account.move")
|
|
moveVals := orm.Values{
|
|
"move_type": "out_invoice",
|
|
"partner_id": partnerID,
|
|
}
|
|
move, err := moveRS.Create(moveVals)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("account: create move: %w", err)
|
|
}
|
|
moveID := move.ID()
|
|
|
|
// Retrieve company_id, journal_id, currency_id from the created move
|
|
moveData, err := move.Read([]string{"company_id", "journal_id", "currency_id"})
|
|
if err != nil || len(moveData) == 0 {
|
|
return nil, fmt.Errorf("account: cannot read created move")
|
|
}
|
|
companyID, _ := toInt64Arg(moveData[0]["company_id"])
|
|
journalID, _ := toInt64Arg(moveData[0]["journal_id"])
|
|
currencyID, _ := toInt64Arg(moveData[0]["currency_id"])
|
|
|
|
// Find the receivable account for the partner
|
|
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("account: no receivable account found for company %d", companyID)
|
|
}
|
|
|
|
lineRS := env.Model("account.move.line")
|
|
|
|
var totalDebit float64 // sum of all product + tax debits
|
|
|
|
// Step 2: For each input line, create product line(s) and tax line(s)
|
|
for _, rawLine := range rawLines {
|
|
lineMap, ok := rawLine.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
name, _ := lineMap["name"].(string)
|
|
quantity := floatArg(lineMap["quantity"], 1.0)
|
|
priceUnit := floatArg(lineMap["price_unit"], 0.0)
|
|
accountID, _ := toInt64Arg(lineMap["account_id"])
|
|
taxID, hasTax := toInt64Arg(lineMap["tax_id"])
|
|
|
|
if accountID == 0 {
|
|
// Fallback: use journal default account
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT default_account_id FROM account_journal WHERE id = $1`, journalID,
|
|
).Scan(&accountID)
|
|
}
|
|
if accountID == 0 {
|
|
return nil, fmt.Errorf("account: no account_id for line %q", name)
|
|
}
|
|
|
|
baseAmount := priceUnit * quantity
|
|
|
|
// Create product line (debit side for revenue)
|
|
productLineVals := orm.Values{
|
|
"move_id": moveID,
|
|
"name": name,
|
|
"quantity": quantity,
|
|
"price_unit": priceUnit,
|
|
"account_id": accountID,
|
|
"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("account: create product line: %w", err)
|
|
}
|
|
totalDebit += baseAmount
|
|
|
|
// If a tax is specified, compute and create the tax line
|
|
if hasTax && taxID > 0 {
|
|
taxResult, err := ComputeTax(env, taxID, baseAmount)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("account: compute tax: %w", err)
|
|
}
|
|
|
|
if taxResult.Amount != 0 && taxResult.AccountID != 0 {
|
|
taxLineVals := orm.Values{
|
|
"move_id": moveID,
|
|
"name": taxResult.TaxName,
|
|
"quantity": 1.0,
|
|
"account_id": taxResult.AccountID,
|
|
"company_id": companyID,
|
|
"journal_id": journalID,
|
|
"currency_id": currencyID,
|
|
"partner_id": partnerID,
|
|
"display_type": "tax",
|
|
"tax_line_id": taxResult.TaxID,
|
|
"debit": 0.0,
|
|
"credit": taxResult.Amount,
|
|
"balance": -taxResult.Amount,
|
|
}
|
|
if _, err := lineRS.Create(taxLineVals); err != nil {
|
|
return nil, fmt.Errorf("account: create tax line: %w", err)
|
|
}
|
|
totalDebit += taxResult.Amount
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 3: 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": totalDebit,
|
|
"credit": 0.0,
|
|
"balance": totalDebit,
|
|
}
|
|
if _, err := lineRS.Create(receivableVals); err != nil {
|
|
return nil, fmt.Errorf("account: create receivable line: %w", err)
|
|
}
|
|
|
|
return moveID, nil
|
|
})
|
|
|
|
// -- Double-Entry Constraint --
|
|
// SUM(debit) must equal SUM(credit) per journal entry.
|
|
// Mirrors: odoo/addons/account/models/account_move.py _check_balanced()
|
|
m.AddConstraint(func(rs *orm.Recordset) error {
|
|
env := rs.Env()
|
|
for _, id := range rs.IDs() {
|
|
var debitSum, creditSum float64
|
|
err := env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT COALESCE(SUM(debit), 0), COALESCE(SUM(credit), 0)
|
|
FROM account_move_line WHERE move_id = $1`, id,
|
|
).Scan(&debitSum, &creditSum)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Allow empty moves (no lines yet)
|
|
if debitSum == 0 && creditSum == 0 {
|
|
continue
|
|
}
|
|
diff := debitSum - creditSum
|
|
if diff < -0.005 || diff > 0.005 {
|
|
return fmt.Errorf("account: journal entry is unbalanced — debit=%.2f credit=%.2f (diff=%.2f)", debitSum, creditSum, diff)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// -- DefaultGet: Provide dynamic defaults for new records --
|
|
// Mirrors: odoo/addons/account/models/account_move.py AccountMove.default_get()
|
|
// Supplies date, journal_id, company_id, currency_id when creating a new invoice.
|
|
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
|
vals := make(orm.Values)
|
|
|
|
// Default date = today
|
|
vals["date"] = time.Now().Format("2006-01-02")
|
|
|
|
// Default company from the current user's session
|
|
companyID := env.CompanyID()
|
|
if companyID > 0 {
|
|
vals["company_id"] = companyID
|
|
}
|
|
|
|
// Default journal: first active sales journal for the company
|
|
var journalID int64
|
|
err := 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 err == nil && journalID > 0 {
|
|
vals["journal_id"] = journalID
|
|
}
|
|
|
|
// Default currency from the company
|
|
var currencyID int64
|
|
err = env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT currency_id FROM res_company WHERE id = $1`, companyID).Scan(¤cyID)
|
|
if err == nil && currencyID > 0 {
|
|
vals["currency_id"] = currencyID
|
|
}
|
|
|
|
return vals
|
|
}
|
|
|
|
// -- Onchange: partner_id → auto-fill partner address fields --
|
|
// Mirrors: odoo/addons/account/models/account_move.py _onchange_partner_id()
|
|
// When the partner changes on an invoice, look up the partner's address
|
|
// and populate the commercial_partner_id field.
|
|
m.RegisterOnchange("partner_id", func(env *orm.Environment, vals orm.Values) orm.Values {
|
|
result := make(orm.Values)
|
|
partnerID, ok := toInt64Arg(vals["partner_id"])
|
|
if !ok || partnerID == 0 {
|
|
return result
|
|
}
|
|
|
|
var name string
|
|
var commercialID *int64
|
|
err := env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT p.name, p.commercial_partner_id
|
|
FROM res_partner p WHERE p.id = $1`, partnerID,
|
|
).Scan(&name, &commercialID)
|
|
if err != nil {
|
|
return result
|
|
}
|
|
|
|
if commercialID != nil && *commercialID > 0 {
|
|
result["commercial_partner_id"] = *commercialID
|
|
} else {
|
|
result["commercial_partner_id"] = partnerID
|
|
}
|
|
return result
|
|
})
|
|
|
|
// -- Business Method: register_payment --
|
|
// Create a payment for this invoice and reconcile.
|
|
// Mirrors: odoo/addons/account/models/account_payment.py AccountPayment.action_register_payment()
|
|
m.RegisterMethod("register_payment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
env := rs.Env()
|
|
for _, moveID := range rs.IDs() {
|
|
// Read invoice info
|
|
var partnerID, journalID, companyID, currencyID int64
|
|
var amountTotal float64
|
|
var moveType string
|
|
err := env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT COALESCE(partner_id,0), COALESCE(journal_id,0), COALESCE(company_id,0),
|
|
COALESCE(currency_id,0), COALESCE(amount_total,0), COALESCE(move_type,'entry')
|
|
FROM account_move WHERE id = $1`, moveID,
|
|
).Scan(&partnerID, &journalID, &companyID, ¤cyID, &amountTotal, &moveType)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("account: read invoice %d for payment: %w", moveID, err)
|
|
}
|
|
|
|
// Determine payment type and partner type
|
|
paymentType := "inbound" // customer pays us
|
|
partnerType := "customer"
|
|
if moveType == "in_invoice" || moveType == "in_refund" {
|
|
paymentType = "outbound" // we pay vendor
|
|
partnerType = "supplier"
|
|
}
|
|
|
|
// Find bank journal
|
|
var bankJournalID int64
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT id FROM account_journal WHERE type = 'bank' AND company_id = $1 LIMIT 1`,
|
|
companyID).Scan(&bankJournalID)
|
|
if bankJournalID == 0 {
|
|
bankJournalID = journalID
|
|
}
|
|
|
|
// Create a journal entry for the payment
|
|
var payMoveID int64
|
|
err = env.Tx().QueryRow(env.Ctx(),
|
|
`INSERT INTO account_move (name, move_type, state, date, partner_id, journal_id, company_id, currency_id)
|
|
VALUES ($1, 'entry', 'posted', NOW(), $2, $3, $4, $5) RETURNING id`,
|
|
fmt.Sprintf("PAY/%d", moveID), partnerID, bankJournalID, companyID, currencyID,
|
|
).Scan(&payMoveID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("account: create payment move for invoice %d: %w", moveID, err)
|
|
}
|
|
|
|
// Create payment record linked to the journal entry
|
|
_, err = env.Tx().Exec(env.Ctx(),
|
|
`INSERT INTO account_payment
|
|
(name, payment_type, partner_type, state, date, amount,
|
|
currency_id, journal_id, partner_id, company_id, move_id, is_reconciled)
|
|
VALUES ($1, $2, $3, 'paid', NOW(), $4, $5, $6, $7, $8, $9, true)`,
|
|
fmt.Sprintf("PAY/%d", moveID), paymentType, partnerType, amountTotal,
|
|
currencyID, bankJournalID, partnerID, companyID, payMoveID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("account: create payment for invoice %d: %w", moveID, err)
|
|
}
|
|
|
|
// Update invoice payment state
|
|
_, err = env.Tx().Exec(env.Ctx(),
|
|
`UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("account: update payment state for invoice %d: %w", moveID, err)
|
|
}
|
|
}
|
|
return true, nil
|
|
})
|
|
|
|
// -- BeforeCreate Hook: Generate sequence number --
|
|
m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
|
|
name, _ := vals["name"].(string)
|
|
if name == "" || name == "/" {
|
|
moveType, _ := vals["move_type"].(string)
|
|
code := "account.move"
|
|
switch moveType {
|
|
case "out_invoice", "out_refund", "out_receipt":
|
|
code = "account.move.out_invoice"
|
|
case "in_invoice", "in_refund", "in_receipt":
|
|
code = "account.move.in_invoice"
|
|
}
|
|
seq, err := orm.NextByCode(env, code)
|
|
if err != nil {
|
|
// Fallback to generic sequence
|
|
seq, err = orm.NextByCode(env, "account.move")
|
|
if err != nil {
|
|
return nil // No sequence configured, keep "/"
|
|
}
|
|
}
|
|
vals["name"] = seq
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// initAccountMoveLine registers account.move.line — journal items / invoice lines.
|
|
// Mirrors: odoo/addons/account/models/account_move_line.py
|
|
//
|
|
// CRITICAL: In double-entry bookkeeping, sum(debit) must equal sum(credit) per move.
|
|
func initAccountMoveLine() {
|
|
m := orm.NewModel("account.move.line", orm.ModelOpts{
|
|
Description: "Journal Item",
|
|
Order: "date desc, id",
|
|
})
|
|
|
|
// -- Parent --
|
|
m.AddFields(
|
|
orm.Many2one("move_id", "account.move", orm.FieldOpts{
|
|
String: "Journal Entry", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
|
|
}),
|
|
orm.Char("move_name", orm.FieldOpts{String: "Journal Entry Name", Related: "move_id.name"}),
|
|
orm.Date("date", orm.FieldOpts{String: "Date", Related: "move_id.date", Store: true, Index: true}),
|
|
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Index: true}),
|
|
)
|
|
|
|
// -- Accounts --
|
|
m.AddFields(
|
|
orm.Many2one("account_id", "account.account", orm.FieldOpts{
|
|
String: "Account", Required: true, Index: true,
|
|
}),
|
|
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Partner", Index: true}),
|
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
|
|
orm.Many2one("company_currency_id", "res.currency", orm.FieldOpts{String: "Company Currency"}),
|
|
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
|
)
|
|
|
|
// -- Amounts (Double-Entry) --
|
|
m.AddFields(
|
|
orm.Monetary("debit", orm.FieldOpts{String: "Debit", Default: 0.0, CurrencyField: "company_currency_id"}),
|
|
orm.Monetary("credit", orm.FieldOpts{String: "Credit", Default: 0.0, CurrencyField: "company_currency_id"}),
|
|
orm.Monetary("balance", orm.FieldOpts{String: "Balance", Compute: "_compute_balance", Store: true, CurrencyField: "company_currency_id"}),
|
|
orm.Monetary("amount_currency", orm.FieldOpts{String: "Amount in Currency", CurrencyField: "currency_id"}),
|
|
orm.Float("amount_residual", orm.FieldOpts{String: "Residual Amount"}),
|
|
orm.Float("amount_residual_currency", orm.FieldOpts{String: "Residual Amount in Currency"}),
|
|
)
|
|
|
|
// -- Invoice line fields --
|
|
m.AddFields(
|
|
orm.Char("name", orm.FieldOpts{String: "Label"}),
|
|
orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1.0}),
|
|
orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
|
|
orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}),
|
|
orm.Float("price_subtotal", orm.FieldOpts{String: "Subtotal", Compute: "_compute_totals", Store: true}),
|
|
orm.Float("price_total", orm.FieldOpts{String: "Total", Compute: "_compute_totals", Store: true}),
|
|
)
|
|
|
|
// -- Tax --
|
|
m.AddFields(
|
|
orm.Many2many("tax_ids", "account.tax", orm.FieldOpts{String: "Taxes"}),
|
|
orm.Many2one("tax_line_id", "account.tax", orm.FieldOpts{String: "Originator Tax"}),
|
|
orm.Many2one("tax_group_id", "account.tax.group", orm.FieldOpts{String: "Tax Group"}),
|
|
orm.Many2one("tax_repartition_line_id", "account.tax.repartition.line", orm.FieldOpts{
|
|
String: "Tax Repartition Line",
|
|
}),
|
|
)
|
|
|
|
// -- Display --
|
|
m.AddFields(
|
|
orm.Selection("display_type", []orm.SelectionItem{
|
|
{Value: "product", Label: "Product"},
|
|
{Value: "cogs", Label: "COGS"},
|
|
{Value: "tax", Label: "Tax"},
|
|
{Value: "rounding", Label: "Rounding"},
|
|
{Value: "payment_term", Label: "Payment Term"},
|
|
{Value: "line_section", Label: "Section"},
|
|
{Value: "line_note", Label: "Note"},
|
|
{Value: "epd", Label: "Early Payment Discount"},
|
|
}, orm.FieldOpts{String: "Display Type", Default: "product"}),
|
|
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
|
)
|
|
|
|
// -- Reconciliation --
|
|
m.AddFields(
|
|
orm.Boolean("reconciled", orm.FieldOpts{String: "Reconciled"}),
|
|
orm.Many2one("full_reconcile_id", "account.full.reconcile", orm.FieldOpts{String: "Matching"}),
|
|
)
|
|
}
|
|
|
|
// initAccountPayment registers account.payment.
|
|
// Mirrors: odoo/addons/account/models/account_payment.py
|
|
func initAccountPayment() {
|
|
m := orm.NewModel("account.payment", orm.ModelOpts{
|
|
Description: "Payments",
|
|
Order: "date desc, name desc",
|
|
})
|
|
|
|
m.AddFields(
|
|
orm.Char("name", orm.FieldOpts{String: "Name", Readonly: true}),
|
|
orm.Many2one("move_id", "account.move", orm.FieldOpts{
|
|
String: "Journal Entry", Required: true, OnDelete: orm.OnDeleteCascade,
|
|
}),
|
|
orm.Selection("payment_type", []orm.SelectionItem{
|
|
{Value: "outbound", Label: "Send"},
|
|
{Value: "inbound", Label: "Receive"},
|
|
}, orm.FieldOpts{String: "Payment Type", Required: true}),
|
|
orm.Selection("partner_type", []orm.SelectionItem{
|
|
{Value: "customer", Label: "Customer"},
|
|
{Value: "supplier", Label: "Vendor"},
|
|
}, orm.FieldOpts{String: "Partner Type"}),
|
|
orm.Selection("state", []orm.SelectionItem{
|
|
{Value: "draft", Label: "Draft"},
|
|
{Value: "in_process", Label: "In Process"},
|
|
{Value: "paid", Label: "Paid"},
|
|
{Value: "canceled", Label: "Cancelled"},
|
|
{Value: "rejected", Label: "Rejected"},
|
|
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
|
orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
|
|
orm.Monetary("amount", orm.FieldOpts{String: "Amount", Required: true, CurrencyField: "currency_id"}),
|
|
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency", Required: true}),
|
|
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
|
|
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer/Vendor"}),
|
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
|
|
orm.Many2one("partner_bank_id", "res.partner.bank", orm.FieldOpts{String: "Recipient Bank Account"}),
|
|
orm.Many2one("destination_account_id", "account.account", orm.FieldOpts{String: "Destination Account"}),
|
|
orm.Boolean("is_reconciled", orm.FieldOpts{String: "Is Reconciled"}),
|
|
orm.Boolean("is_matched", orm.FieldOpts{String: "Is Matched With a Bank Statement"}),
|
|
orm.Char("payment_reference", orm.FieldOpts{String: "Payment Reference"}),
|
|
orm.Char("payment_method_code", orm.FieldOpts{String: "Payment Method Code"}),
|
|
)
|
|
|
|
// action_post: confirm and post the payment.
|
|
// Mirrors: odoo/addons/account/models/account_payment.py action_post()
|
|
m.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
env := rs.Env()
|
|
for _, id := range rs.IDs() {
|
|
if _, err := env.Tx().Exec(env.Ctx(),
|
|
`UPDATE account_payment SET state = 'paid' WHERE id = $1 AND state = 'draft'`, id); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return true, nil
|
|
})
|
|
|
|
// action_cancel: cancel the payment.
|
|
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
env := rs.Env()
|
|
for _, id := range rs.IDs() {
|
|
if _, err := env.Tx().Exec(env.Ctx(),
|
|
`UPDATE account_payment SET state = 'canceled' WHERE id = $1`, id); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return true, nil
|
|
})
|
|
}
|
|
|
|
// initAccountPaymentRegister registers the payment register wizard.
|
|
// Mirrors: odoo/addons/account/wizard/account_payment_register.py
|
|
// This is a TransientModel wizard opened via "Register Payment" button on invoices.
|
|
func initAccountPaymentRegister() {
|
|
m := orm.NewModel("account.payment.register", orm.ModelOpts{
|
|
Description: "Register Payment",
|
|
Type: orm.ModelTransient,
|
|
})
|
|
|
|
m.AddFields(
|
|
orm.Date("payment_date", orm.FieldOpts{String: "Payment Date", Required: true}),
|
|
orm.Monetary("amount", orm.FieldOpts{String: "Amount", CurrencyField: "currency_id"}),
|
|
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency", Required: true}),
|
|
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
|
|
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer/Vendor"}),
|
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
|
orm.Selection("payment_type", []orm.SelectionItem{
|
|
{Value: "outbound", Label: "Send"},
|
|
{Value: "inbound", Label: "Receive"},
|
|
}, orm.FieldOpts{String: "Payment Type", Default: "inbound"}),
|
|
orm.Selection("partner_type", []orm.SelectionItem{
|
|
{Value: "customer", Label: "Customer"},
|
|
{Value: "supplier", Label: "Vendor"},
|
|
}, orm.FieldOpts{String: "Partner Type", Default: "customer"}),
|
|
orm.Char("communication", orm.FieldOpts{String: "Memo"}),
|
|
// Context-only: which invoice(s) are being paid
|
|
orm.Many2many("line_ids", "account.move.line", orm.FieldOpts{
|
|
String: "Journal items",
|
|
Relation: "payment_register_move_line_rel",
|
|
Column1: "wizard_id",
|
|
Column2: "line_id",
|
|
}),
|
|
)
|
|
|
|
// action_create_payments: create account.payment from the wizard and mark invoice as paid.
|
|
// Mirrors: odoo/addons/account/wizard/account_payment_register.py action_create_payments()
|
|
m.RegisterMethod("action_create_payments", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
|
env := rs.Env()
|
|
wizardData, err := rs.Read([]string{
|
|
"payment_date", "amount", "currency_id", "journal_id",
|
|
"partner_id", "company_id", "payment_type", "partner_type", "communication",
|
|
})
|
|
if err != nil || len(wizardData) == 0 {
|
|
return nil, fmt.Errorf("account: cannot read payment register wizard")
|
|
}
|
|
wiz := wizardData[0]
|
|
|
|
paymentRS := env.Model("account.payment")
|
|
paymentVals := orm.Values{
|
|
"payment_type": wiz["payment_type"],
|
|
"partner_type": wiz["partner_type"],
|
|
"amount": wiz["amount"],
|
|
"date": wiz["payment_date"],
|
|
"currency_id": wiz["currency_id"],
|
|
"journal_id": wiz["journal_id"],
|
|
"partner_id": wiz["partner_id"],
|
|
"company_id": wiz["company_id"],
|
|
"payment_reference": wiz["communication"],
|
|
"state": "draft",
|
|
}
|
|
|
|
payment, err := paymentRS.Create(paymentVals)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("account: create payment: %w", err)
|
|
}
|
|
|
|
// Auto-post the payment
|
|
paymentModel := orm.Registry.Get("account.payment")
|
|
if paymentModel != nil {
|
|
if postMethod, ok := paymentModel.Methods["action_post"]; ok {
|
|
if _, err := postMethod(payment); err != nil {
|
|
return nil, fmt.Errorf("account: post payment: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark related invoices as paid (simplified: update payment_state on active invoices in context)
|
|
// In Python Odoo this happens through reconciliation; we simplify for 70% target.
|
|
if ctx := env.Context(); ctx != nil {
|
|
if activeIDs, ok := ctx["active_ids"].([]interface{}); ok {
|
|
for _, rawID := range activeIDs {
|
|
if moveID, ok := toInt64Arg(rawID); ok && moveID > 0 {
|
|
env.Tx().Exec(env.Ctx(),
|
|
`UPDATE account_move SET payment_state = 'paid' WHERE id = $1 AND state = 'posted'`, moveID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return action to close wizard (standard Odoo pattern)
|
|
return map[string]interface{}{
|
|
"type": "ir.actions.act_window_close",
|
|
}, nil
|
|
})
|
|
|
|
// DefaultGet: pre-fill wizard from active invoice context.
|
|
// Mirrors: odoo/addons/account/wizard/account_payment_register.py default_get()
|
|
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
|
vals := orm.Values{
|
|
"payment_date": time.Now().Format("2006-01-02"),
|
|
}
|
|
|
|
ctx := env.Context()
|
|
if ctx == nil {
|
|
return vals
|
|
}
|
|
|
|
// Get active invoice IDs from context
|
|
var moveIDs []int64
|
|
if ids, ok := ctx["active_ids"].([]interface{}); ok {
|
|
for _, rawID := range ids {
|
|
if id, ok := toInt64Arg(rawID); ok && id > 0 {
|
|
moveIDs = append(moveIDs, id)
|
|
}
|
|
}
|
|
}
|
|
if len(moveIDs) == 0 {
|
|
return vals
|
|
}
|
|
|
|
// Read first invoice to pre-fill defaults
|
|
moveRS := env.Model("account.move").Browse(moveIDs[0])
|
|
moveData, err := moveRS.Read([]string{
|
|
"partner_id", "company_id", "currency_id", "amount_residual", "move_type",
|
|
})
|
|
if err != nil || len(moveData) == 0 {
|
|
return vals
|
|
}
|
|
mv := moveData[0]
|
|
|
|
if pid, ok := toInt64Arg(mv["partner_id"]); ok && pid > 0 {
|
|
vals["partner_id"] = pid
|
|
}
|
|
if cid, ok := toInt64Arg(mv["company_id"]); ok && cid > 0 {
|
|
vals["company_id"] = cid
|
|
}
|
|
if curID, ok := toInt64Arg(mv["currency_id"]); ok && curID > 0 {
|
|
vals["currency_id"] = curID
|
|
}
|
|
if amt, ok := mv["amount_residual"].(float64); ok {
|
|
vals["amount"] = amt
|
|
}
|
|
|
|
// Determine payment type from move type
|
|
moveType, _ := mv["move_type"].(string)
|
|
switch moveType {
|
|
case "out_invoice", "out_receipt":
|
|
vals["payment_type"] = "inbound"
|
|
vals["partner_type"] = "customer"
|
|
case "in_invoice", "in_receipt":
|
|
vals["payment_type"] = "outbound"
|
|
vals["partner_type"] = "supplier"
|
|
}
|
|
|
|
// Default bank journal
|
|
var journalID int64
|
|
companyID := env.CompanyID()
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT id FROM account_journal
|
|
WHERE type = 'bank' AND active = true AND company_id = $1
|
|
ORDER BY sequence, id LIMIT 1`, companyID).Scan(&journalID)
|
|
if journalID > 0 {
|
|
vals["journal_id"] = journalID
|
|
}
|
|
|
|
return vals
|
|
}
|
|
}
|
|
|
|
// initAccountPaymentTerm registers payment terms.
|
|
// Mirrors: odoo/addons/account/models/account_payment_term.py
|
|
func initAccountPaymentTerm() {
|
|
m := orm.NewModel("account.payment.term", orm.ModelOpts{
|
|
Description: "Payment Terms",
|
|
Order: "sequence, id",
|
|
})
|
|
|
|
m.AddFields(
|
|
orm.Char("name", orm.FieldOpts{String: "Payment Terms", Required: true, Translate: true}),
|
|
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
|
orm.Text("note", orm.FieldOpts{String: "Description on the Invoice", Translate: true}),
|
|
orm.One2many("line_ids", "account.payment.term.line", "payment_id", orm.FieldOpts{String: "Terms"}),
|
|
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
|
orm.Selection("early_discount", []orm.SelectionItem{
|
|
{Value: "none", Label: "None"},
|
|
{Value: "mixed", Label: "On early payment"},
|
|
}, orm.FieldOpts{String: "Early Discount", Default: "none"}),
|
|
orm.Float("discount_percentage", orm.FieldOpts{String: "Discount %"}),
|
|
orm.Integer("discount_days", orm.FieldOpts{String: "Discount Days"}),
|
|
)
|
|
|
|
// Payment term lines — each line defines a portion
|
|
orm.NewModel("account.payment.term.line", orm.ModelOpts{
|
|
Description: "Payment Terms Line",
|
|
Order: "sequence, id",
|
|
}).AddFields(
|
|
orm.Many2one("payment_id", "account.payment.term", orm.FieldOpts{
|
|
String: "Payment Terms", Required: true, OnDelete: orm.OnDeleteCascade,
|
|
}),
|
|
orm.Selection("value", []orm.SelectionItem{
|
|
{Value: "balance", Label: "Balance"},
|
|
{Value: "percent", Label: "Percent"},
|
|
{Value: "fixed", Label: "Fixed Amount"},
|
|
}, orm.FieldOpts{String: "Type", Required: true, Default: "balance"}),
|
|
orm.Float("value_amount", orm.FieldOpts{String: "Value"}),
|
|
orm.Integer("nb_days", orm.FieldOpts{String: "Days", Required: true, Default: 0}),
|
|
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
|
)
|
|
}
|
|
|
|
// initAccountReconcile registers reconciliation models.
|
|
// Mirrors: odoo/addons/account/models/account_reconcile_model.py
|
|
func initAccountReconcile() {
|
|
// Full reconcile — groups partial reconciles
|
|
orm.NewModel("account.full.reconcile", orm.ModelOpts{
|
|
Description: "Full Reconcile",
|
|
}).AddFields(
|
|
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
|
orm.One2many("partial_reconcile_ids", "account.partial.reconcile", "full_reconcile_id", orm.FieldOpts{String: "Reconciliation Parts"}),
|
|
orm.One2many("reconciled_line_ids", "account.move.line", "full_reconcile_id", orm.FieldOpts{String: "Matched Journal Items"}),
|
|
orm.Many2one("exchange_move_id", "account.move", orm.FieldOpts{String: "Exchange Rate Entry"}),
|
|
)
|
|
|
|
// Partial reconcile — matches debit ↔ credit lines
|
|
orm.NewModel("account.partial.reconcile", orm.ModelOpts{
|
|
Description: "Partial Reconcile",
|
|
}).AddFields(
|
|
orm.Many2one("debit_move_id", "account.move.line", orm.FieldOpts{String: "Debit line", Required: true, Index: true}),
|
|
orm.Many2one("credit_move_id", "account.move.line", orm.FieldOpts{String: "Credit line", Required: true, Index: true}),
|
|
orm.Many2one("full_reconcile_id", "account.full.reconcile", orm.FieldOpts{String: "Full Reconcile"}),
|
|
orm.Monetary("amount", orm.FieldOpts{String: "Amount", Required: true}),
|
|
orm.Monetary("debit_amount_currency", orm.FieldOpts{String: "Debit Amount Currency"}),
|
|
orm.Monetary("credit_amount_currency", orm.FieldOpts{String: "Credit Amount Currency"}),
|
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
|
orm.Many2one("debit_currency_id", "res.currency", orm.FieldOpts{String: "Debit Currency"}),
|
|
orm.Many2one("credit_currency_id", "res.currency", orm.FieldOpts{String: "Credit Currency"}),
|
|
orm.Many2one("exchange_move_id", "account.move", orm.FieldOpts{String: "Exchange Rate Entry"}),
|
|
)
|
|
}
|
|
|
|
// initAccountBankStatement registers bank statement models.
|
|
// Mirrors: odoo/addons/account/models/account_bank_statement.py
|
|
func initAccountBankStatement() {
|
|
m := orm.NewModel("account.bank.statement", orm.ModelOpts{
|
|
Description: "Bank Statement",
|
|
Order: "date desc, name desc, id desc",
|
|
})
|
|
|
|
m.AddFields(
|
|
orm.Char("name", orm.FieldOpts{String: "Reference"}),
|
|
orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
|
|
orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
|
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
|
|
orm.Float("balance_start", orm.FieldOpts{String: "Starting Balance"}),
|
|
orm.Float("balance_end_real", orm.FieldOpts{String: "Ending Balance"}),
|
|
orm.Float("balance_end", orm.FieldOpts{String: "Computed Balance", Compute: "_compute_balance_end"}),
|
|
orm.One2many("line_ids", "account.bank.statement.line", "statement_id", orm.FieldOpts{String: "Statement Lines"}),
|
|
)
|
|
|
|
// Bank statement line
|
|
orm.NewModel("account.bank.statement.line", orm.ModelOpts{
|
|
Description: "Bank Statement Line",
|
|
Order: "internal_index desc, sequence, id desc",
|
|
}).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}),
|
|
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
|
|
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Partner"}),
|
|
orm.Char("payment_ref", orm.FieldOpts{String: "Label"}),
|
|
orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
|
|
orm.Monetary("amount", orm.FieldOpts{String: "Amount", Required: true, CurrencyField: "currency_id"}),
|
|
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
|
orm.Char("transaction_type", orm.FieldOpts{String: "Transaction Type"}),
|
|
orm.Char("account_number", orm.FieldOpts{String: "Bank Account Number"}),
|
|
orm.Char("partner_name", orm.FieldOpts{String: "Partner Name"}),
|
|
orm.Char("narration", orm.FieldOpts{String: "Notes"}),
|
|
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"}),
|
|
)
|
|
}
|
|
|
|
// -- Helper functions for argument parsing in business methods --
|
|
|
|
// toInt64Arg converts various numeric types (float64, int64, int, int32) to int64.
|
|
// Returns (value, true) on success, (0, false) if not convertible.
|
|
func toInt64Arg(v interface{}) (int64, bool) {
|
|
switch n := v.(type) {
|
|
case int64:
|
|
return n, true
|
|
case float64:
|
|
return int64(n), true
|
|
case int:
|
|
return int64(n), true
|
|
case int32:
|
|
return int64(n), true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// floatArg extracts a float64 from an interface{}, returning defaultVal if not possible.
|
|
func floatArg(v interface{}, defaultVal float64) float64 {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n
|
|
case int64:
|
|
return float64(n)
|
|
case int:
|
|
return float64(n)
|
|
case int32:
|
|
return float64(n)
|
|
}
|
|
return defaultVal
|
|
}
|