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>
647 lines
20 KiB
Go
647 lines
20 KiB
Go
// 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"
|
||
}
|
||
}
|