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>
366 lines
12 KiB
Go
366 lines
12 KiB
Go
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 ""
|
|
}
|