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:
365
addons/account/models/account_edi.go
Normal file
365
addons/account/models/account_edi.go
Normal 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user