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>
This commit is contained in:
@@ -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(`<!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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user