Files
goodie/pkg/server/report.go
Marc 2c7c1e6c88 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) <noreply@anthropic.com>
2026-04-04 13:58:19 +02:00

647 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Report endpoint — serves HTML and PDF reports.
// Mirrors: odoo/addons/web/controllers/report.py ReportController
package server
import (
"bytes"
"fmt"
"html/template"
"log"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"odoo-go/pkg/orm"
)
// handleReport serves HTML reports.
// Route: /report/html/<report_name>/<ids>
// Mirrors: odoo/addons/web/controllers/report.py report_routes()
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
// Parse URL: /report/html/account.report_invoice/1,2,3
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
// Expected: ["report", "html", "<report_name>", "<ids>"]
if len(parts) < 4 {
http.Error(w, "Invalid report URL. Expected: /report/html/<report_name>/<ids>", http.StatusBadRequest)
return
}
reportName := parts[2]
idsStr := parts[3]
// Parse record IDs
var ids []int64
for _, s := range strings.Split(idsStr, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
id, err := strconv.ParseInt(s, 10, 64)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid ID %q: %v", s, err), http.StatusBadRequest)
return
}
ids = append(ids, id)
}
if len(ids) == 0 {
http.Error(w, "No record IDs provided", http.StatusBadRequest)
return
}
// Get session
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool, UID: uid, CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer env.Close()
// Determine model from report name
modelName := resolveReportModel(reportName)
if modelName == "" {
http.Error(w, "Unknown report: "+reportName, http.StatusNotFound)
return
}
// Read records
rs := env.Model(modelName).Browse(ids...)
records, err := rs.Read(nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := env.Commit(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 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))
}
// handleReportPDF serves PDF reports.
// Route: /report/pdf/<report_name>/<ids>
// Mirrors: odoo/addons/web/controllers/report.py report_download()
//
// It generates the HTML report and converts it to PDF via wkhtmltopdf or
// headless Chromium. If neither is available, it falls back to the HTML
// report with an auto-print dialog.
func (s *Server) handleReportPDF(w http.ResponseWriter, r *http.Request) {
// Parse URL: /report/pdf/account.report_invoice/1,2,3
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
// Expected: ["report", "pdf", "<report_name>", "<ids>"]
if len(parts) < 4 {
http.Error(w, "Invalid report URL. Expected: /report/pdf/<report_name>/<ids>", http.StatusBadRequest)
return
}
reportName := parts[2]
idsStr := parts[3]
// Parse record IDs
var ids []int64
for _, s := range strings.Split(idsStr, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
id, err := strconv.ParseInt(s, 10, 64)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid ID %q: %v", s, err), http.StatusBadRequest)
return
}
ids = append(ids, id)
}
if len(ids) == 0 {
http.Error(w, "No record IDs provided", http.StatusBadRequest)
return
}
// Get session
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool, UID: uid, CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer env.Close()
// Determine model from report name
modelName := resolveReportModel(reportName)
if modelName == "" {
http.Error(w, "Unknown report: "+reportName, http.StatusNotFound)
return
}
// Read records
rs := env.Model(modelName).Browse(ids...)
records, err := rs.Read(nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := env.Commit(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 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)
if err != nil {
// Fallback: serve HTML with auto-print dialog
log.Printf("report: PDF conversion unavailable (%v), falling back to HTML", err)
htmlFallback := renderHTMLReportPrintFallback(reportName, modelName, records)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(htmlFallback))
return
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s.pdf", reportName))
w.Write(pdfData)
}
// htmlToPDF converts an HTML string to PDF. It tries wkhtmltopdf first,
// then headless Chromium. Returns an error if neither is available.
func htmlToPDF(html string) ([]byte, error) {
// Try wkhtmltopdf first (most common in Odoo deployments)
if path, err := exec.LookPath("wkhtmltopdf"); err == nil {
cmd := exec.Command(path, "--quiet", "--print-media-type",
"--page-size", "A4",
"--margin-top", "10mm", "--margin-bottom", "10mm",
"--margin-left", "10mm", "--margin-right", "10mm",
"-", "-") // stdin → stdout
cmd.Stdin = strings.NewReader(html)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
log.Printf("report: wkhtmltopdf failed: %v %s", err, stderr.String())
} else {
return stdout.Bytes(), nil
}
}
// Try headless Chromium / Chrome
return htmlToPDFChromium(html)
}
// htmlToPDFChromium converts HTML to PDF using headless Chrome/Chromium.
func htmlToPDFChromium(html string) ([]byte, error) {
// Find browser binary
var browserPath string
for _, name := range []string{"chromium", "chromium-browser", "google-chrome", "chrome"} {
if p, err := exec.LookPath(name); err == nil {
browserPath = p
break
}
}
if browserPath == "" {
return nil, fmt.Errorf("no PDF converter available (tried wkhtmltopdf, chromium, chrome)")
}
// Write HTML to temp file
tmpFile, err := os.CreateTemp("", "report-*.html")
if err != nil {
return nil, fmt.Errorf("creating temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(html); err != nil {
tmpFile.Close()
return nil, fmt.Errorf("writing temp file: %w", err)
}
tmpFile.Close()
pdfFile := tmpFile.Name() + ".pdf"
defer os.Remove(pdfFile)
cmd := exec.Command(browserPath, "--headless", "--disable-gpu", "--no-sandbox",
"--print-to-pdf="+pdfFile, "file://"+tmpFile.Name())
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("chromium PDF generation failed: %v %s", err, stderr.String())
}
data, err := os.ReadFile(pdfFile)
if err != nil {
return nil, fmt.Errorf("reading PDF output: %w", err)
}
return data, nil
}
// renderHTMLReportPrintFallback generates an HTML report with enhanced print
// CSS and an auto-trigger for the browser print dialog. Used as fallback when
// no PDF converter (wkhtmltopdf, chromium) is installed.
func renderHTMLReportPrintFallback(reportName, modelName string, records []orm.Values) string {
var b strings.Builder
b.WriteString(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Report: `)
b.WriteString(htmlEscape(reportName))
b.WriteString(`</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; font-weight: bold; }
h1 { color: #333; }
h2 { color: #555; margin-top: 30px; }
.header { border-bottom: 2px solid #875A7B; padding-bottom: 10px; margin-bottom: 20px; }
.report-info { color: #888; font-size: 0.9em; margin-bottom: 30px; }
.record-section { page-break-after: always; margin-bottom: 40px; }
.record-section:last-child { page-break-after: avoid; }
.no-print { display: none; }
@media print {
body { margin: 0; }
.no-print { display: none; }
table { page-break-inside: auto; }
tr { page-break-inside: avoid; }
}
@page {
size: A4;
margin: 15mm;
}
</style>
</head>
<body>
<div class="header">
<h1>`)
b.WriteString(htmlEscape(reportName))
b.WriteString(`</h1>
<div class="report-info">Model: `)
b.WriteString(htmlEscape(modelName))
b.WriteString(fmt.Sprintf(` | Records: %d`, len(records)))
b.WriteString(`</div>
</div>
`)
for _, rec := range records {
b.WriteString(`<div class="record-section">`)
title := ""
if v, ok := rec["display_name"]; ok {
title = fmt.Sprintf("%v", v)
} else if v, ok := rec["name"]; ok {
title = fmt.Sprintf("%v", v)
}
if title != "" {
b.WriteString(fmt.Sprintf("<h2>%s</h2>", htmlEscape(title)))
}
b.WriteString("<table>")
b.WriteString("<tr><th>Field</th><th>Value</th></tr>")
for key, val := range rec {
if key == "id" {
continue
}
valStr := fmt.Sprintf("%v", val)
if valStr == "<nil>" {
valStr = ""
}
b.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%s</td></tr>",
htmlEscape(key), htmlEscape(valStr)))
}
b.WriteString("</table>")
b.WriteString("</div>")
}
b.WriteString(`<script>window.addEventListener('load', function() { window.print(); });</script>
</body></html>`)
return b.String()
}
// resolveReportModel maps a report name to the ORM model it operates on.
// Mirrors: odoo ir.actions.report → model field.
func resolveReportModel(reportName string) string {
mapping := map[string]string{
"account.report_invoice": "account.move",
"sale.report_saleorder": "sale.order",
"stock.report_picking": "stock.picking",
"purchase.report_purchaseorder": "purchase.order",
"contacts.report_partner": "res.partner",
}
return mapping[reportName]
}
// renderHTMLReport generates a basic HTML report for the given records.
// This is a minimal implementation — Odoo uses QWeb templates for real reports.
func renderHTMLReport(reportName, modelName string, records []orm.Values) string {
var b strings.Builder
b.WriteString(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Report: `)
b.WriteString(htmlEscape(reportName))
b.WriteString(`</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; font-weight: bold; }
h1 { color: #333; }
h2 { color: #555; margin-top: 30px; }
.header { border-bottom: 2px solid #875A7B; padding-bottom: 10px; margin-bottom: 20px; }
.report-info { color: #888; font-size: 0.9em; margin-bottom: 30px; }
.record-section { page-break-after: always; margin-bottom: 40px; }
.record-section:last-child { page-break-after: avoid; }
@media print {
body { margin: 0; }
.no-print { display: none; }
table { page-break-inside: auto; }
tr { page-break-inside: avoid; }
}
@page {
size: A4;
margin: 15mm;
}
</style>
</head>
<body>
<div class="header">
<h1>`)
b.WriteString(htmlEscape(reportName))
b.WriteString(`</h1>
<div class="report-info">Model: `)
b.WriteString(htmlEscape(modelName))
b.WriteString(fmt.Sprintf(` | Records: %d`, len(records)))
b.WriteString(`</div>
</div>
<div class="no-print">
<button onclick="window.print()">Print</button>
</div>
`)
for _, rec := range records {
b.WriteString(`<div class="record-section">`)
// Use "name" or "display_name" as section title if available
title := ""
if v, ok := rec["display_name"]; ok {
title = fmt.Sprintf("%v", v)
} else if v, ok := rec["name"]; ok {
title = fmt.Sprintf("%v", v)
}
if title != "" {
b.WriteString(fmt.Sprintf("<h2>%s</h2>", htmlEscape(title)))
}
b.WriteString("<table>")
b.WriteString("<tr><th>Field</th><th>Value</th></tr>")
for key, val := range rec {
if key == "id" {
continue
}
valStr := fmt.Sprintf("%v", val)
if valStr == "<nil>" {
valStr = ""
}
b.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%s</td></tr>",
htmlEscape(key), htmlEscape(valStr)))
}
b.WriteString("</table>")
b.WriteString("</div>")
}
b.WriteString("</body></html>")
return b.String()
}
// htmlEscape escapes HTML special characters.
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(`<!DOCTYPE html><html><head><meta charset="utf-8"/>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 11pt; color: #333; padding: 40px; }
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
.company { font-size: 10pt; color: #666; }
.company h1 { font-size: 16pt; color: #875A7B; margin-bottom: 5px; }
.invoice-title { font-size: 22pt; color: #875A7B; margin-bottom: 5px; }
.invoice-number { font-size: 14pt; font-weight: bold; }
.info-grid { display: flex; justify-content: space-between; margin-bottom: 30px; }
.info-box { width: 48%; }
.info-box h3 { color: #875A7B; font-size: 10pt; text-transform: uppercase; margin-bottom: 5px; border-bottom: 1px solid #875A7B; padding-bottom: 3px; }
.info-box p { font-size: 10pt; line-height: 1.6; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
thead th { background: #875A7B; color: white; padding: 8px 12px; text-align: left; font-size: 9pt; text-transform: uppercase; }
thead th.num { text-align: right; }
tbody td { padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 10pt; }
tbody td.num { text-align: right; }
.totals { float: right; width: 280px; }
.totals table { margin-bottom: 0; }
.totals td { padding: 4px 8px; }
.totals .total-row { font-weight: bold; font-size: 13pt; border-top: 2px solid #875A7B; }
.footer { clear: both; margin-top: 60px; padding-top: 10px; border-top: 1px solid #ddd; font-size: 8pt; color: #999; text-align: center; }
.state-badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 9pt; text-transform: uppercase; font-weight: bold; }
.state-draft { background: #ffc107; color: #333; }
.state-posted { background: #28a745; color: white; }
.state-cancel { background: #dc3545; color: white; }
@media print { body { padding: 20px; } }
@page { size: A4; margin: 15mm; }
</style></head><body>`)
// Header
stateClass := "state-" + state
b.WriteString(fmt.Sprintf(`<div class="header">
<div class="company"><h1>%s</h1><p>%s<br/>%s %s<br/>%s<br/>%s</p></div>
<div style="text-align:right">
<div class="invoice-title">%s</div>
<div class="invoice-number">%s</div>
<span class="state-badge %s">%s</span>
</div>
</div>`, 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 += "<br/>" + htmlEscape(partnerStreet)
}
if partnerZip != "" || partnerCity != "" {
partnerAddr += "<br/>" + htmlEscape(partnerZip) + " " + htmlEscape(partnerCity)
}
if partnerCountry != "" {
partnerAddr += "<br/>" + htmlEscape(partnerCountry)
}
b.WriteString(fmt.Sprintf(`<div class="info-grid">
<div class="info-box"><h3>Bill To</h3><p>%s</p></div>
<div class="info-box"><h3>Details</h3><p>Date: %s<br/>Due Date: %s</p></div>
</div>`, partnerAddr, htmlEscape(date), htmlEscape(dueDate)))
// Lines table
b.WriteString(`<table><thead><tr><th>Description</th><th class="num">Quantity</th><th class="num">Unit Price</th><th class="num">Amount</th></tr></thead><tbody>`)
for _, l := range lines {
if l.dtype == "product" {
b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td class="num">%.2f</td><td class="num">%.2f</td><td class="num">%.2f</td></tr>`,
htmlEscape(l.name), l.qty, l.price, l.amount))
}
}
// Tax lines
for _, l := range lines {
if l.dtype == "tax" {
b.WriteString(fmt.Sprintf(`<tr><td><em>%s</em></td><td></td><td></td><td class="num">%.2f</td></tr>`,
htmlEscape(l.name), l.amount))
}
}
b.WriteString(`</tbody></table>`)
// Totals
b.WriteString(fmt.Sprintf(`<div class="totals"><table>
<tr><td>Untaxed Amount:</td><td class="num">%.2f</td></tr>
<tr><td>Taxes:</td><td class="num">%.2f</td></tr>
<tr class="total-row"><td>Total:</td><td class="num">%.2f</td></tr>
</table></div>`, untaxed, tax, total))
// Footer
b.WriteString(fmt.Sprintf(`<div class="footer">%s | %s | %s</div>`,
htmlEscape(companyName), htmlEscape(companyEmail), htmlEscape(companyPhone)))
b.WriteString(`</body></html>`)
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"
}
}