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:
Marc
2026-04-03 21:59:50 +02:00
parent b8fa4719ad
commit 0a76a2b9aa
11 changed files with 2572 additions and 0 deletions

View File

@@ -0,0 +1,365 @@
package models
import (
"encoding/xml"
"fmt"
"strings"
"time"
"odoo-go/pkg/orm"
)
// initAccountEdi registers electronic invoicing (EDI) models.
// Mirrors: odoo/addons/account_edi/models/account_edi_format.py
//
// EDI (Electronic Data Interchange) handles electronic invoice formats like
// UBL, Factur-X, and XRechnung. This provides the base framework.
func initAccountEdi() {
// -- EDI Format --
// Defines a supported electronic invoice format.
ef := orm.NewModel("account.edi.format", orm.ModelOpts{
Description: "Electronic Invoicing Format",
Order: "name",
})
ef.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Char("code", orm.FieldOpts{String: "Code", Required: true, Help: "Technical code, e.g. facturx_1_0_05, ubl_2_1"}),
)
// -- EDI Document --
// Links an invoice to an EDI format with state tracking.
ed := orm.NewModel("account.edi.document", orm.ModelOpts{
Description: "Electronic Invoicing Document",
Order: "id desc",
})
ed.AddFields(
orm.Many2one("move_id", "account.move", orm.FieldOpts{
String: "Invoice", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Many2one("edi_format_id", "account.edi.format", orm.FieldOpts{
String: "Format", Required: true,
}),
orm.Selection("state", []orm.SelectionItem{
{Value: "to_send", Label: "To Send"},
{Value: "sent", Label: "Sent"},
{Value: "to_cancel", Label: "To Cancel"},
{Value: "cancelled", Label: "Cancelled"},
}, orm.FieldOpts{String: "Status", Default: "to_send", Required: true}),
orm.Binary("attachment_id", orm.FieldOpts{String: "Attachment"}),
orm.Char("error", orm.FieldOpts{String: "Error"}),
orm.Boolean("blocking_level", orm.FieldOpts{String: "Blocking Level"}),
)
// action_export_xml: generate UBL XML for the invoice.
// Mirrors: odoo/addons/account_edi/models/account_edi_format.py _export_invoice_ubl()
ed.RegisterMethod("action_export_xml", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
docID := rs.IDs()[0]
var moveID int64
var formatCode string
env.Tx().QueryRow(env.Ctx(),
`SELECT d.move_id, f.code
FROM account_edi_document d
JOIN account_edi_format f ON f.id = d.edi_format_id
WHERE d.id = $1`, docID,
).Scan(&moveID, &formatCode)
if moveID == 0 {
return nil, fmt.Errorf("account: EDI document has no linked invoice")
}
xmlContent, err := generateInvoiceXML(env, moveID, formatCode)
if err != nil {
env.Tx().Exec(env.Ctx(),
`UPDATE account_edi_document SET error = $1 WHERE id = $2`,
err.Error(), docID)
return nil, err
}
// Mark as sent
env.Tx().Exec(env.Ctx(),
`UPDATE account_edi_document SET state = 'sent', error = NULL WHERE id = $1`, docID)
return map[string]interface{}{
"xml": xmlContent,
"move_id": moveID,
"format": formatCode,
}, nil
})
// Extend account.move with EDI fields
initAccountMoveEdiExtension()
}
// initAccountMoveEdiExtension adds EDI-related fields to account.move.
func initAccountMoveEdiExtension() {
ext := orm.ExtendModel("account.move")
ext.AddFields(
orm.One2many("edi_document_ids", "account.edi.document", "move_id", orm.FieldOpts{
String: "Electronic Documents",
}),
orm.Selection("edi_state", []orm.SelectionItem{
{Value: "to_send", Label: "To Send"},
{Value: "sent", Label: "Sent"},
{Value: "to_cancel", Label: "To Cancel"},
{Value: "cancelled", Label: "Cancelled"},
}, orm.FieldOpts{String: "Electronic Invoicing State"}),
orm.Boolean("edi_show_cancel_button", orm.FieldOpts{
String: "Show Cancel EDI Button", Compute: "_compute_edi_show_cancel_button",
}),
)
// _compute_edi_show_cancel_button
ext.RegisterCompute("edi_show_cancel_button", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
moveID := rs.IDs()[0]
var sentCount int
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_edi_document WHERE move_id = $1 AND state = 'sent'`, moveID,
).Scan(&sentCount)
return orm.Values{"edi_show_cancel_button": sentCount > 0}, nil
})
// action_process_edi_web_services: send all pending EDI documents
ext.RegisterMethod("action_process_edi_web_services", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, moveID := range rs.IDs() {
// Find pending EDI documents
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id FROM account_edi_document WHERE move_id = $1 AND state = 'to_send'`, moveID)
if err != nil {
continue
}
var docIDs []int64
for rows.Next() {
var docID int64
rows.Scan(&docID)
docIDs = append(docIDs, docID)
}
rows.Close()
for _, docID := range docIDs {
docRS := env.Model("account.edi.document").Browse(docID)
ediModel := orm.Registry.Get("account.edi.document")
if ediModel != nil {
if exportMethod, ok := ediModel.Methods["action_export_xml"]; ok {
exportMethod(docRS)
}
}
}
}
return true, nil
})
}
// -- XML generation for UBL/Factur-X --
// UBLInvoice represents a simplified UBL 2.1 invoice structure.
type UBLInvoice struct {
XMLName xml.Name `xml:"Invoice"`
XMLNS string `xml:"xmlns,attr"`
XMLNSCAC string `xml:"xmlns:cac,attr"`
XMLNSCBC string `xml:"xmlns:cbc,attr"`
UBLVersionID string `xml:"cbc:UBLVersionID"`
CustomizationID string `xml:"cbc:CustomizationID"`
ID string `xml:"cbc:ID"`
IssueDate string `xml:"cbc:IssueDate"`
DueDate string `xml:"cbc:DueDate,omitempty"`
InvoiceTypeCode string `xml:"cbc:InvoiceTypeCode"`
DocumentCurrencyCode string `xml:"cbc:DocumentCurrencyCode"`
Supplier UBLParty `xml:"cac:AccountingSupplierParty>cac:Party"`
Customer UBLParty `xml:"cac:AccountingCustomerParty>cac:Party"`
TaxTotal UBLTaxTotal `xml:"cac:TaxTotal"`
LegalMonetaryTotal UBLMonetary `xml:"cac:LegalMonetaryTotal"`
InvoiceLines []UBLLine `xml:"cac:InvoiceLine"`
}
// UBLParty represents a party in UBL.
type UBLParty struct {
Name string `xml:"cac:PartyName>cbc:Name"`
Street string `xml:"cac:PostalAddress>cbc:StreetName,omitempty"`
City string `xml:"cac:PostalAddress>cbc:CityName,omitempty"`
Zip string `xml:"cac:PostalAddress>cbc:PostalZone,omitempty"`
Country string `xml:"cac:PostalAddress>cac:Country>cbc:IdentificationCode,omitempty"`
TaxID string `xml:"cac:PartyTaxScheme>cbc:CompanyID,omitempty"`
}
// UBLTaxTotal represents tax totals.
type UBLTaxTotal struct {
TaxAmount string `xml:"cbc:TaxAmount"`
}
// UBLMonetary represents monetary totals.
type UBLMonetary struct {
LineExtensionAmount string `xml:"cbc:LineExtensionAmount"`
TaxExclusiveAmount string `xml:"cbc:TaxExclusiveAmount"`
TaxInclusiveAmount string `xml:"cbc:TaxInclusiveAmount"`
PayableAmount string `xml:"cbc:PayableAmount"`
}
// UBLLine represents an invoice line in UBL.
type UBLLine struct {
ID string `xml:"cbc:ID"`
Quantity string `xml:"cbc:InvoicedQuantity"`
LineAmount string `xml:"cbc:LineExtensionAmount"`
ItemName string `xml:"cac:Item>cbc:Name"`
PriceAmount string `xml:"cac:Price>cbc:PriceAmount"`
}
// generateInvoiceXML creates a UBL 2.1 XML representation of an invoice.
// Mirrors: odoo/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py
func generateInvoiceXML(env *orm.Environment, moveID int64, formatCode string) (string, error) {
// Read move header
var name, moveType string
var invoiceDate, dueDate *string
var partnerID, companyID int64
var amountUntaxed, amountTax, amountTotal float64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, '/'), COALESCE(move_type, 'out_invoice'),
invoice_date::text, invoice_date_due::text,
COALESCE(partner_id, 0), COALESCE(company_id, 0),
COALESCE(amount_untaxed::float8, 0), COALESCE(amount_tax::float8, 0),
COALESCE(amount_total::float8, 0)
FROM account_move WHERE id = $1`, moveID,
).Scan(&name, &moveType, &invoiceDate, &dueDate, &partnerID, &companyID,
&amountUntaxed, &amountTax, &amountTotal)
if err != nil {
return "", fmt.Errorf("account: read move for XML: %w", err)
}
// Determine invoice type code (UBL standard)
typeCode := "380" // Commercial Invoice
switch moveType {
case "out_refund", "in_refund":
typeCode = "381" // Credit Note
case "out_receipt", "in_receipt":
typeCode = "325" // Receipt
}
// Read supplier (company)
var companyName string
var companyStreet, companyCity, companyZip, companyCountry *string
var companyVat *string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(c.name, ''), p.street, p.city, p.zip,
co.code, c.vat
FROM res_company c
LEFT JOIN res_partner p ON p.id = c.partner_id
LEFT JOIN res_country co ON co.id = p.country_id
WHERE c.id = $1`, companyID,
).Scan(&companyName, &companyStreet, &companyCity, &companyZip, &companyCountry, &companyVat)
// Read customer (partner)
var customerName string
var customerStreet, customerCity, customerZip, customerCountry *string
var customerVat *string
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(p.name, ''), p.street, p.city, p.zip,
co.code, p.vat
FROM res_partner p
LEFT JOIN res_country co ON co.id = p.country_id
WHERE p.id = $1`, partnerID,
).Scan(&customerName, &customerStreet, &customerCity, &customerZip, &customerCountry, &customerVat)
// Read invoice lines
lineRows, err := env.Tx().Query(env.Ctx(),
`SELECT COALESCE(name, ''), COALESCE(quantity, 1), COALESCE(price_unit::float8, 0),
COALESCE(price_subtotal::float8, 0)
FROM account_move_line
WHERE move_id = $1 AND (display_type IS NULL OR display_type = '' OR display_type = 'product')
ORDER BY sequence, id`, moveID)
if err != nil {
return "", fmt.Errorf("account: read lines for XML: %w", err)
}
defer lineRows.Close()
var ublLines []UBLLine
lineNum := 1
for lineRows.Next() {
var lineName string
var qty, price, subtotal float64
if err := lineRows.Scan(&lineName, &qty, &price, &subtotal); err != nil {
continue
}
ublLines = append(ublLines, UBLLine{
ID: fmt.Sprintf("%d", lineNum),
Quantity: fmt.Sprintf("%.2f", qty),
LineAmount: fmt.Sprintf("%.2f", subtotal),
ItemName: lineName,
PriceAmount: fmt.Sprintf("%.2f", price),
})
lineNum++
}
issueDateStr := time.Now().Format("2006-01-02")
if invoiceDate != nil && *invoiceDate != "" {
issueDateStr = *invoiceDate
}
dueDateStr := ""
if dueDate != nil {
dueDateStr = *dueDate
}
invoice := UBLInvoice{
XMLNS: "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
XMLNSCAC: "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
XMLNSCBC: "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
UBLVersionID: "2.1",
CustomizationID: "urn:cen.eu:en16931:2017",
ID: name,
IssueDate: issueDateStr,
DueDate: dueDateStr,
InvoiceTypeCode: typeCode,
DocumentCurrencyCode: "EUR",
Supplier: UBLParty{
Name: companyName,
Street: ptrStr(companyStreet),
City: ptrStr(companyCity),
Zip: ptrStr(companyZip),
Country: ptrStr(companyCountry),
TaxID: ptrStr(companyVat),
},
Customer: UBLParty{
Name: customerName,
Street: ptrStr(customerStreet),
City: ptrStr(customerCity),
Zip: ptrStr(customerZip),
Country: ptrStr(customerCountry),
TaxID: ptrStr(customerVat),
},
TaxTotal: UBLTaxTotal{
TaxAmount: fmt.Sprintf("%.2f", amountTax),
},
LegalMonetaryTotal: UBLMonetary{
LineExtensionAmount: fmt.Sprintf("%.2f", amountUntaxed),
TaxExclusiveAmount: fmt.Sprintf("%.2f", amountUntaxed),
TaxInclusiveAmount: fmt.Sprintf("%.2f", amountTotal),
PayableAmount: fmt.Sprintf("%.2f", amountTotal),
},
InvoiceLines: ublLines,
}
output, err := xml.MarshalIndent(invoice, "", " ")
if err != nil {
return "", fmt.Errorf("account: marshal XML: %w", err)
}
var b strings.Builder
b.WriteString(xml.Header)
b.Write(output)
return b.String(), nil
}
// ptrStr safely dereferences a *string, returning "" if nil.
func ptrStr(s *string) string {
if s != nil {
return *s
}
return ""
}