From 2c7c1e6c88f8dcd481f376c0fa8af768c8b75d93 Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 4 Apr 2026 13:58:19 +0200 Subject: [PATCH] PDF invoice reports + SMTP email sending PDF Reports: - Professional invoice HTML renderer (company header, partner address, styled line items, totals, Odoo-purple branding, A4 @page CSS) - wkhtmltopdf installed in Docker runtime stage for real PDF generation - Print button on invoice form (opens PDF in new tab) - Exported HtmlToPDF/RenderInvoiceHTML for cross-package use SMTP Email: - SendEmail + SendEmailWithAttachments with MIME multipart support - Base64 file attachments - Config via ODOO_SMTP_HOST/PORT/USER/PASSWORD/FROM env vars - LoadSMTPConfig helper, nil auth for relay servers Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 1 + addons/account/models/account_move.go | 11 ++ pkg/server/report.go | 210 +++++++++++++++++++++++++- pkg/service/db.go | 1 + pkg/tools/config.go | 25 +++ pkg/tools/email.go | 100 +++++++++++- 6 files changed, 336 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index ee8a040..ef0973f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ COPY . . RUN CGO_ENABLED=0 go build -o /odoo-server ./cmd/odoo-server FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends wkhtmltopdf && rm -rf /var/lib/apt/lists/* RUN useradd -m -s /bin/bash odoo COPY --from=builder /odoo-server /usr/local/bin/odoo-server COPY --from=builder /build/frontend /app/frontend diff --git a/addons/account/models/account_move.go b/addons/account/models/account_move.go index 953a6dd..bec41ab 100644 --- a/addons/account/models/account_move.go +++ b/addons/account/models/account_move.go @@ -949,6 +949,17 @@ func initAccountMove() { return true, nil }) + // action_invoice_print: opens the invoice PDF in a new tab. + // Mirrors: odoo/addons/account/models/account_move.py action_invoice_print() + m.RegisterMethod("action_invoice_print", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + moveID := rs.IDs()[0] + return map[string]interface{}{ + "type": "ir.actions.act_url", + "url": fmt.Sprintf("/report/pdf/account.report_invoice/%d", moveID), + "target": "new", + }, nil + }) + // -- BeforeCreate Hook: Generate sequence number -- m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error { name, _ := vals["name"].(string) diff --git a/pkg/server/report.go b/pkg/server/report.go index 8f4f5ce..5405873 100644 --- a/pkg/server/report.go +++ b/pkg/server/report.go @@ -87,8 +87,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { return } - // Render HTML report - html := renderHTMLReport(reportName, modelName, records) + // Use dedicated renderer for invoices, generic for others + var html string + if reportName == "account.report_invoice" && len(ids) > 0 { + html = renderInvoiceHTML(env, ids[0]) + } else { + html = renderHTMLReport(reportName, modelName, records) + } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) } @@ -168,8 +173,13 @@ func (s *Server) handleReportPDF(w http.ResponseWriter, r *http.Request) { return } - // Generate HTML report - htmlContent := renderHTMLReport(reportName, modelName, records) + // Use dedicated renderer for invoices, generic for others + var htmlContent string + if reportName == "account.report_invoice" && len(ids) > 0 { + htmlContent = renderInvoiceHTML(env, ids[0]) + } else { + htmlContent = renderHTMLReport(reportName, modelName, records) + } // Try to convert to PDF pdfData, err := htmlToPDF(htmlContent) @@ -442,3 +452,195 @@ h2 { color: #555; margin-top: 30px; } func htmlEscape(s string) string { return template.HTMLEscapeString(s) } + +// renderInvoiceHTML generates a professional invoice HTML document for PDF conversion. +// Reads directly from the database to build a complete, styled invoice. +// Mirrors: odoo/addons/account/report/account_invoice_report_templates.xml +func renderInvoiceHTML(env *orm.Environment, moveID int64) string { + // Read invoice header + var name, partnerName, moveType, state, date, dueDate string + var total, untaxed, tax float64 + env.Tx().QueryRow(env.Ctx(), ` + SELECT COALESCE(m.name,'/'), COALESCE(p.name,''), COALESCE(m.move_type,'entry'), + COALESCE(m.state,'draft'), + COALESCE(m.date::text,''), COALESCE(m.invoice_date_due::text,''), + COALESCE(m.amount_total::float8, 0), COALESCE(m.amount_untaxed::float8, 0), + COALESCE(m.amount_tax::float8, 0) + FROM account_move m + LEFT JOIN res_partner p ON p.id = m.partner_id + WHERE m.id = $1`, moveID, + ).Scan(&name, &partnerName, &moveType, &state, &date, &dueDate, &total, &untaxed, &tax) + + // Read partner address + var partnerStreet, partnerCity, partnerZip, partnerCountry string + env.Tx().QueryRow(env.Ctx(), ` + SELECT COALESCE(p.street,''), COALESCE(p.city,''), COALESCE(p.zip,''), + COALESCE(co.name,'') + FROM account_move m + LEFT JOIN res_partner p ON p.id = m.partner_id + LEFT JOIN res_country co ON co.id = p.country_id + WHERE m.id = $1`, moveID, + ).Scan(&partnerStreet, &partnerCity, &partnerZip, &partnerCountry) + + // Read company info + var companyName, companyEmail, companyPhone, companyStreet, companyCity, companyZip, companyCountry string + env.Tx().QueryRow(env.Ctx(), ` + SELECT COALESCE(c.name,''), COALESCE(p.email,''), COALESCE(p.phone,''), + COALESCE(p.street,''), COALESCE(p.city,''), COALESCE(p.zip,''), + COALESCE(co.name,'') + FROM res_company c + 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`).Scan(&companyName, &companyEmail, &companyPhone, + &companyStreet, &companyCity, &companyZip, &companyCountry) + + // Read invoice lines + type invLine struct { + name string + qty float64 + price float64 + amount float64 + dtype string + } + rows, err := env.Tx().Query(env.Ctx(), ` + SELECT COALESCE(name,''), COALESCE(quantity,0), COALESCE(price_unit::float8,0), + COALESCE(ABS(balance::float8),0), COALESCE(display_type,'product') + FROM account_move_line WHERE move_id = $1 AND display_type IN ('product','tax') + ORDER BY display_type, id`, moveID) + var lines []invLine + if err == nil { + defer rows.Close() + for rows.Next() { + var l invLine + if err := rows.Scan(&l.name, &l.qty, &l.price, &l.amount, &l.dtype); err == nil { + lines = append(lines, l) + } + } + } + + // Build professional HTML + var b strings.Builder + b.WriteString(` +`) + + // Header + stateClass := "state-" + state + b.WriteString(fmt.Sprintf(`
+

%s

%s
%s %s
%s
%s

+
+
%s
+
%s
+ %s +
+
`, htmlEscape(companyName), htmlEscape(companyStreet), + htmlEscape(companyZip), htmlEscape(companyCity), + htmlEscape(companyPhone), htmlEscape(companyEmail), + htmlEscape(getInvoiceTypeLabel(moveType)), htmlEscape(name), + stateClass, htmlEscape(state))) + + // Info boxes + partnerAddr := htmlEscape(partnerName) + if partnerStreet != "" { + partnerAddr += "
" + htmlEscape(partnerStreet) + } + if partnerZip != "" || partnerCity != "" { + partnerAddr += "
" + htmlEscape(partnerZip) + " " + htmlEscape(partnerCity) + } + if partnerCountry != "" { + partnerAddr += "
" + htmlEscape(partnerCountry) + } + + b.WriteString(fmt.Sprintf(`
+

Bill To

%s

+

Details

Date: %s
Due Date: %s

+
`, partnerAddr, htmlEscape(date), htmlEscape(dueDate))) + + // Lines table + b.WriteString(``) + for _, l := range lines { + if l.dtype == "product" { + b.WriteString(fmt.Sprintf(``, + htmlEscape(l.name), l.qty, l.price, l.amount)) + } + } + // Tax lines + for _, l := range lines { + if l.dtype == "tax" { + b.WriteString(fmt.Sprintf(``, + htmlEscape(l.name), l.amount)) + } + } + b.WriteString(`
DescriptionQuantityUnit PriceAmount
%s%.2f%.2f%.2f
%s%.2f
`) + + // Totals + b.WriteString(fmt.Sprintf(`
+ + + +
Untaxed Amount:%.2f
Taxes:%.2f
Total:%.2f
`, untaxed, tax, total)) + + // Footer + b.WriteString(fmt.Sprintf(``, + htmlEscape(companyName), htmlEscape(companyEmail), htmlEscape(companyPhone))) + b.WriteString(``) + + return b.String() +} + +// HtmlToPDF is the exported version of htmlToPDF for use by other packages. +func HtmlToPDF(html string) ([]byte, error) { + return htmlToPDF(html) +} + +// RenderInvoiceHTML is the exported version of renderInvoiceHTML for use by other packages. +func RenderInvoiceHTML(env *orm.Environment, moveID int64) string { + return renderInvoiceHTML(env, moveID) +} + +// getInvoiceTypeLabel returns a human-readable label for the move type. +func getInvoiceTypeLabel(t string) string { + switch t { + case "out_invoice": + return "Invoice" + case "out_refund": + return "Credit Note" + case "in_invoice": + return "Vendor Bill" + case "in_refund": + return "Vendor Credit Note" + case "out_receipt": + return "Sales Receipt" + case "in_receipt": + return "Purchase Receipt" + default: + return "Journal Entry" + } +} diff --git a/pkg/service/db.go b/pkg/service/db.go index 00cd2a0..7607e92 100644 --- a/pkg/service/db.go +++ b/pkg/service/db.go @@ -666,6 +666,7 @@ func seedViews(ctx context.Context, tx pgx.Tx) {