Account module massive expansion: 2499→5049 LOC (+2550)
New models (12): - account.asset: depreciation (linear/degressive), journal entry generation - account.edi.format + account.edi.document: UBL 2.1 XML e-invoicing - account.followup.line: payment follow-up escalation levels - account.reconcile.model + lines: automatic bank reconciliation rules - crossovered.budget + lines + account.budget.post: budgeting system - account.cash.rounding: invoice rounding (UP/DOWN/HALF-UP) - account.payment.method + lines: payment method definitions - account.invoice.send: invoice sending wizard Enhanced existing: - account.move: action_reverse (credit notes), access_url, invoice_has_outstanding - account.move.line: tax_tag_ids, analytic_distribution, date_maturity, matching_number - Entry hash chain integrity (SHA-256, secure_sequence_number) - Report HTML rendering for all 6 report types - res.partner extended with followup status + overdue tracking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -154,6 +154,20 @@ func initAccountMove() {
|
||||
orm.Text("narration", orm.FieldOpts{String: "Terms and Conditions"}),
|
||||
)
|
||||
|
||||
// -- Invoice Responsible & References --
|
||||
m.AddFields(
|
||||
orm.Many2one("invoice_user_id", "res.users", orm.FieldOpts{
|
||||
String: "Salesperson", Help: "User responsible for this invoice",
|
||||
}),
|
||||
orm.Many2one("reversed_entry_id", "account.move", orm.FieldOpts{
|
||||
String: "Reversed Entry", Help: "The move that was reversed to create this",
|
||||
}),
|
||||
orm.Char("access_url", orm.FieldOpts{String: "Portal Access URL", Compute: "_compute_access_url"}),
|
||||
orm.Boolean("invoice_has_outstanding", orm.FieldOpts{
|
||||
String: "Has Outstanding Payments", Compute: "_compute_invoice_has_outstanding",
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Technical --
|
||||
m.AddFields(
|
||||
orm.Boolean("auto_post", orm.FieldOpts{String: "Auto-post"}),
|
||||
@@ -161,6 +175,45 @@ func initAccountMove() {
|
||||
orm.Integer("sequence_number", orm.FieldOpts{String: "Sequence Number"}),
|
||||
)
|
||||
|
||||
// _compute_access_url: generates /my/invoices/<id> for portal access.
|
||||
// Mirrors: odoo/addons/account/models/account_move.py _compute_access_url()
|
||||
m.RegisterCompute("access_url", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
moveID := rs.IDs()[0]
|
||||
return orm.Values{
|
||||
"access_url": fmt.Sprintf("/my/invoices/%d", moveID),
|
||||
}, nil
|
||||
})
|
||||
|
||||
// _compute_invoice_has_outstanding: checks for outstanding payments.
|
||||
// Mirrors: odoo/addons/account/models/account_move.py _compute_has_outstanding()
|
||||
m.RegisterCompute("invoice_has_outstanding", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
moveID := rs.IDs()[0]
|
||||
|
||||
var partnerID *int64
|
||||
var moveType string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT partner_id, COALESCE(move_type, 'entry') FROM account_move WHERE id = $1`, moveID,
|
||||
).Scan(&partnerID, &moveType)
|
||||
|
||||
hasOutstanding := false
|
||||
if partnerID != nil && *partnerID > 0 && (moveType == "out_invoice" || moveType == "out_refund") {
|
||||
var count int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM account_move_line l
|
||||
JOIN account_account a ON a.id = l.account_id
|
||||
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
|
||||
WHERE l.partner_id = $1 AND a.account_type = 'asset_receivable'
|
||||
AND l.amount_residual < -0.005 AND l.reconciled = false`,
|
||||
*partnerID).Scan(&count)
|
||||
hasOutstanding = count > 0
|
||||
}
|
||||
|
||||
return orm.Values{
|
||||
"invoice_has_outstanding": hasOutstanding,
|
||||
}, nil
|
||||
})
|
||||
|
||||
// -- Computed Fields --
|
||||
// _compute_amount: sums invoice lines to produce totals.
|
||||
// Mirrors: odoo/addons/account/models/account_move.py AccountMove._compute_amount()
|
||||
@@ -342,6 +395,116 @@ func initAccountMove() {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_reverse: creates a credit note (reversal) for the current move.
|
||||
// Mirrors: odoo/addons/account/models/account_move.py action_reverse()
|
||||
m.RegisterMethod("action_reverse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, moveID := range rs.IDs() {
|
||||
// Read original move
|
||||
var partnerID, journalID, companyID, currencyID int64
|
||||
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(move_type,'entry')
|
||||
FROM account_move WHERE id = $1`, moveID,
|
||||
).Scan(&partnerID, &journalID, &companyID, ¤cyID, &moveType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: read move %d for reversal: %w", moveID, err)
|
||||
}
|
||||
|
||||
// Determine reverse type
|
||||
reverseType := moveType
|
||||
switch moveType {
|
||||
case "out_invoice":
|
||||
reverseType = "out_refund"
|
||||
case "in_invoice":
|
||||
reverseType = "in_refund"
|
||||
case "out_refund":
|
||||
reverseType = "out_invoice"
|
||||
case "in_refund":
|
||||
reverseType = "in_invoice"
|
||||
}
|
||||
|
||||
// Create reverse move
|
||||
reverseRS := env.Model("account.move")
|
||||
reverseMoveVals := orm.Values{
|
||||
"move_type": reverseType,
|
||||
"partner_id": partnerID,
|
||||
"journal_id": journalID,
|
||||
"company_id": companyID,
|
||||
"currency_id": currencyID,
|
||||
"reversed_entry_id": moveID,
|
||||
"ref": fmt.Sprintf("Reversal of %d", moveID),
|
||||
}
|
||||
reverseMove, err := reverseRS.Create(reverseMoveVals)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: create reverse move: %w", err)
|
||||
}
|
||||
|
||||
// Copy lines with reversed debit/credit
|
||||
lineRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT account_id, name, COALESCE(debit::float8, 0), COALESCE(credit::float8, 0),
|
||||
COALESCE(balance::float8, 0), COALESCE(quantity, 1), COALESCE(price_unit::float8, 0),
|
||||
COALESCE(display_type, 'product'), partner_id, currency_id
|
||||
FROM account_move_line WHERE move_id = $1`, moveID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: read lines for reversal: %w", err)
|
||||
}
|
||||
|
||||
lineRS := env.Model("account.move.line")
|
||||
var reverseLines []orm.Values
|
||||
for lineRows.Next() {
|
||||
var accID int64
|
||||
var name string
|
||||
var debit, credit, balance, qty, price float64
|
||||
var displayType string
|
||||
var lpID, lcurID *int64
|
||||
if err := lineRows.Scan(&accID, &name, &debit, &credit, &balance, &qty, &price, &displayType, &lpID, &lcurID); err != nil {
|
||||
lineRows.Close()
|
||||
return nil, fmt.Errorf("account: scan line for reversal: %w", err)
|
||||
}
|
||||
|
||||
lineVals := orm.Values{
|
||||
"move_id": reverseMove.ID(),
|
||||
"account_id": accID,
|
||||
"name": name,
|
||||
"debit": credit, // REVERSED
|
||||
"credit": debit, // REVERSED
|
||||
"balance": -balance, // REVERSED
|
||||
"quantity": qty,
|
||||
"price_unit": price,
|
||||
"display_type": displayType,
|
||||
"company_id": companyID,
|
||||
"journal_id": journalID,
|
||||
}
|
||||
if lpID != nil {
|
||||
lineVals["partner_id"] = *lpID
|
||||
}
|
||||
if lcurID != nil {
|
||||
lineVals["currency_id"] = *lcurID
|
||||
}
|
||||
reverseLines = append(reverseLines, lineVals)
|
||||
}
|
||||
lineRows.Close()
|
||||
|
||||
for _, lv := range reverseLines {
|
||||
if _, err := lineRS.Create(lv); err != nil {
|
||||
return nil, fmt.Errorf("account: create reverse line: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "account.move",
|
||||
"res_id": reverseMove.ID(),
|
||||
"view_mode": "form",
|
||||
"views": [][]interface{}{{nil, "form"}},
|
||||
"target": "current",
|
||||
}, nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
|
||||
// action_register_payment: opens the payment register wizard.
|
||||
// Mirrors: odoo/addons/account/models/account_move.py action_register_payment()
|
||||
m.RegisterMethod("action_register_payment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
@@ -873,6 +1036,30 @@ func initAccountMoveLine() {
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Analytic & Tags --
|
||||
m.AddFields(
|
||||
orm.Many2many("tax_tag_ids", "account.account.tag", orm.FieldOpts{
|
||||
String: "Tax Tags",
|
||||
Relation: "account_move_line_account_tag_rel",
|
||||
Column1: "line_id",
|
||||
Column2: "tag_id",
|
||||
}),
|
||||
orm.Json("analytic_distribution", orm.FieldOpts{
|
||||
String: "Analytic Distribution",
|
||||
Help: "JSON distribution across analytic accounts, e.g. {\"42\": 100}",
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Maturity & Related --
|
||||
m.AddFields(
|
||||
orm.Date("date_maturity", orm.FieldOpts{String: "Due Date"}),
|
||||
orm.Selection("parent_state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "Draft"},
|
||||
{Value: "posted", Label: "Posted"},
|
||||
{Value: "cancel", Label: "Cancelled"},
|
||||
}, orm.FieldOpts{String: "Parent State", Related: "move_id.state", Store: true}),
|
||||
)
|
||||
|
||||
// -- Display --
|
||||
m.AddFields(
|
||||
orm.Selection("display_type", []orm.SelectionItem{
|
||||
@@ -892,8 +1079,45 @@ func initAccountMoveLine() {
|
||||
m.AddFields(
|
||||
orm.Boolean("reconciled", orm.FieldOpts{String: "Reconciled"}),
|
||||
orm.Many2one("full_reconcile_id", "account.full.reconcile", orm.FieldOpts{String: "Matching"}),
|
||||
orm.Char("matching_number", orm.FieldOpts{
|
||||
String: "Matching #", Compute: "_compute_matching_number",
|
||||
Help: "P for partial, full reconcile name otherwise",
|
||||
}),
|
||||
)
|
||||
|
||||
// _compute_matching_number: derives the matching display from reconcile state.
|
||||
// Mirrors: odoo/addons/account/models/account_move_line.py _compute_matching_number()
|
||||
m.RegisterCompute("matching_number", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
lineID := rs.IDs()[0]
|
||||
|
||||
var fullRecID *int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT full_reconcile_id FROM account_move_line WHERE id = $1`, lineID,
|
||||
).Scan(&fullRecID)
|
||||
|
||||
if fullRecID != nil && *fullRecID > 0 {
|
||||
var name string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT name FROM account_full_reconcile WHERE id = $1`, *fullRecID,
|
||||
).Scan(&name)
|
||||
return orm.Values{"matching_number": name}, nil
|
||||
}
|
||||
|
||||
// Check if partially reconciled
|
||||
var partialCount int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM account_partial_reconcile
|
||||
WHERE debit_move_id = $1 OR credit_move_id = $1`, lineID,
|
||||
).Scan(&partialCount)
|
||||
|
||||
if partialCount > 0 {
|
||||
return orm.Values{"matching_number": "P"}, nil
|
||||
}
|
||||
|
||||
return orm.Values{"matching_number": ""}, nil
|
||||
})
|
||||
|
||||
// reconcile: matches debit lines against credit lines and creates
|
||||
// account.partial.reconcile (and optionally account.full.reconcile) records.
|
||||
// Mirrors: odoo/addons/account/models/account_move_line.py AccountMoveLine.reconcile()
|
||||
|
||||
Reference in New Issue
Block a user