Invoice working in Go
This commit is contained in:
parent
934150282f
commit
67dba461f2
|
|
@ -2,6 +2,19 @@ FROM golang:1.24-alpine
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Chromium and dependencies for chromedp
|
||||
RUN apk add --no-cache \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
harfbuzz \
|
||||
ca-certificates \
|
||||
ttf-freefont
|
||||
|
||||
# Set environment variable for chromedp to find chromium
|
||||
ENV CHROME_BIN=/usr/bin/chromium-browser \
|
||||
CHROME_PATH=/usr/bin/chromium-browser
|
||||
|
||||
# Copy go.mod and go.sum first
|
||||
COPY go/go.mod go/go.sum ./
|
||||
|
||||
|
|
@ -19,6 +32,9 @@ COPY go/ .
|
|||
# Generate sqlc code
|
||||
RUN sqlc generate
|
||||
|
||||
# Ensure static assets are available
|
||||
COPY go/static ./static
|
||||
|
||||
# Copy Air config
|
||||
COPY go/.air.toml .air.toml
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o vault cmd/vault/m
|
|||
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
WORKDIR /root/
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/server .
|
||||
COPY --from=builder /app/vault .
|
||||
COPY go/templates ./templates
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server
|
|||
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
WORKDIR /root/
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/server .
|
||||
COPY go/templates ./templates
|
||||
COPY go/static ./static
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ func main() {
|
|||
|
||||
// PDF generation routes (under /api/pdf/* to avoid conflict with file server)
|
||||
goRouter.HandleFunc("/api/pdf/generate-invoice", handlers.GenerateInvoicePDF).Methods("POST")
|
||||
goRouter.HandleFunc("/api/pdf/generate-invoice-html", handlers.GenerateInvoicePDFHTML).Methods("POST") // HTML version
|
||||
goRouter.HandleFunc("/api/pdf/generate-quote", handlers.GenerateQuotePDF).Methods("POST")
|
||||
goRouter.HandleFunc("/api/pdf/generate-po", handlers.GeneratePurchaseOrderPDF).Methods("POST")
|
||||
goRouter.HandleFunc("/api/pdf/generate-packinglist", handlers.GeneratePackingListPDF).Methods("POST")
|
||||
|
|
|
|||
|
|
@ -4,21 +4,31 @@ import (
|
|||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/pdf"
|
||||
)
|
||||
|
||||
// escapeToHTML converts plain text to HTML with newlines as <br>
|
||||
func escapeToHTML(s string) string {
|
||||
s = html.EscapeString(s)
|
||||
s = strings.ReplaceAll(s, "\n", "<br>")
|
||||
return s
|
||||
}
|
||||
|
||||
// InvoiceLineItemRequest is the JSON shape for a single line item.
|
||||
type InvoiceLineItemRequest struct {
|
||||
ItemNumber string `json:"item_number"`
|
||||
Quantity string `json:"quantity"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
IsHTML bool `json:"is_html"` // Flag to indicate description contains HTML
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
NetUnitPrice float64 `json:"net_unit_price"`
|
||||
|
|
@ -35,6 +45,7 @@ type InvoiceLineItemRequest struct {
|
|||
// InvoicePDFRequest is the expected payload from the PHP app.
|
||||
type InvoicePDFRequest struct {
|
||||
DocumentID int32 `json:"document_id"`
|
||||
InvoiceNumber string `json:"invoice_number"` // e.g. "INV-001234"
|
||||
InvoiceTitle string `json:"invoice_title"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
ContactEmail string `json:"contact_email"`
|
||||
|
|
@ -106,11 +117,15 @@ func GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
|
|||
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
|
||||
for i, li := range req.LineItems {
|
||||
lineItems[i] = db.GetLineItemsTableRow{
|
||||
ItemNumber: li.ItemNumber,
|
||||
Quantity: li.Quantity,
|
||||
Title: li.Title,
|
||||
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
|
||||
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
|
||||
ItemNumber: li.ItemNumber,
|
||||
Quantity: li.Quantity,
|
||||
Title: li.Title,
|
||||
Description: li.Description,
|
||||
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
|
||||
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
|
||||
DiscountPercent: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountPercent), Valid: li.DiscountPercent > 0},
|
||||
DiscountAmountUnit: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountUnit), Valid: li.DiscountAmountUnit > 0},
|
||||
DiscountAmountTotal: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountTotal), Valid: li.DiscountAmountTotal > 0},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,6 +169,111 @@ func GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
|
|||
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
|
||||
}
|
||||
|
||||
// GenerateInvoicePDFHTML generates invoice using HTML template and chromedp
|
||||
func GenerateInvoicePDFHTML(w http.ResponseWriter, r *http.Request) {
|
||||
var req InvoicePDFRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.InvoiceTitle == "" || req.CustomerName == "" {
|
||||
http.Error(w, "invoice_title and customer_name are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
issueDate := time.Now()
|
||||
if req.IssueDate != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", req.IssueDate); err == nil {
|
||||
issueDate = parsed
|
||||
}
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = os.Getenv("PDF_OUTPUT_DIR")
|
||||
}
|
||||
if outputDir == "" {
|
||||
outputDir = "../php/app/webroot/pdf"
|
||||
}
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
log.Printf("GenerateInvoicePDFHTML: failed to create output dir: %v", err)
|
||||
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Map request into the existing PDF generation types
|
||||
doc := &db.Document{ID: req.DocumentID, CmcReference: req.InvoiceNumber}
|
||||
inv := &db.Invoice{Title: req.InvoiceTitle}
|
||||
cust := &db.Customer{Name: req.CustomerName}
|
||||
|
||||
log.Printf("GenerateInvoicePDFHTML: Setting invoice number to: %s", req.InvoiceNumber)
|
||||
|
||||
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
|
||||
for i, li := range req.LineItems {
|
||||
// Escape description if it's not HTML
|
||||
desc := li.Description
|
||||
if !li.IsHTML {
|
||||
// Escape plain text to HTML entities
|
||||
desc = escapeToHTML(desc)
|
||||
}
|
||||
|
||||
// Calculate the final price after discount
|
||||
finalPrice := li.TotalPrice - li.DiscountAmountTotal
|
||||
|
||||
lineItems[i] = db.GetLineItemsTableRow{
|
||||
ItemNumber: li.ItemNumber,
|
||||
Quantity: li.Quantity,
|
||||
Title: li.Title,
|
||||
Description: desc,
|
||||
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
|
||||
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", finalPrice), Valid: true},
|
||||
DiscountAmountTotal: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountTotal), Valid: li.DiscountAmountTotal > 0},
|
||||
}
|
||||
}
|
||||
|
||||
data := &pdf.InvoicePDFData{
|
||||
Document: doc,
|
||||
Invoice: inv,
|
||||
Customer: cust,
|
||||
LineItems: lineItems,
|
||||
CurrencySymbol: req.CurrencySymbol,
|
||||
CurrencyCode: req.CurrencyCode,
|
||||
ShowGST: req.ShowGST,
|
||||
ShipVia: req.ShipVia,
|
||||
FOB: req.FOB,
|
||||
IssueDate: issueDate,
|
||||
IssueDateString: req.IssueDateString,
|
||||
EmailTo: req.ContactEmail,
|
||||
Attention: req.ContactName,
|
||||
FromName: fmt.Sprintf("%s %s", req.UserFirstName, req.UserLastName),
|
||||
FromEmail: req.UserEmail,
|
||||
YourReference: req.YourReference,
|
||||
BillTo: req.BillTo,
|
||||
ShipTo: req.ShipTo,
|
||||
ShippingDetails: req.ShippingDetails,
|
||||
CustomerOrderNumber: req.CustomerOrderNumber,
|
||||
JobTitle: req.JobTitle,
|
||||
PaymentTerms: req.PaymentTerms,
|
||||
CustomerABN: req.CustomerABN,
|
||||
Subtotal: req.Subtotal,
|
||||
GSTAmount: req.GSTAmount,
|
||||
Total: req.Total,
|
||||
}
|
||||
|
||||
// Use HTML generator instead of gofpdf
|
||||
htmlGen := pdf.NewHTMLInvoiceGenerator(outputDir)
|
||||
filename, err := htmlGen.GenerateInvoicePDF(data)
|
||||
if err != nil {
|
||||
log.Printf("GenerateInvoicePDFHTML: failed to generate PDF: %v", err)
|
||||
http.Error(w, fmt.Sprintf("failed to generate PDF: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
|
||||
}
|
||||
|
||||
// QuoteLineItemRequest reuses the invoice item shape
|
||||
type QuoteLineItemRequest = InvoiceLineItemRequest
|
||||
|
||||
|
|
|
|||
7
go/internal/cmc/pdf/description_formatter.go
Normal file
7
go/internal/cmc/pdf/description_formatter.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package pdf
|
||||
|
||||
// formatDescription passes through descriptions as-is
|
||||
// HTML formatting should be applied in PHP before sending to this API
|
||||
func formatDescription(text string) string {
|
||||
return text
|
||||
}
|
||||
115
go/internal/cmc/pdf/description_formatter_test.go
Normal file
115
go/internal/cmc/pdf/description_formatter_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package pdf
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatDescription(t *testing.T) {
|
||||
input := `Item Code: B25SEZ22B0
|
||||
Item Description: SE Sensor Zone 22
|
||||
Type: SE - To control the function of the rupture disc
|
||||
Cable Length: 2m
|
||||
Ui< 40V
|
||||
li<57mA
|
||||
Li, Ci negligible
|
||||
II 2G Ex ib IIC T6 (Gb)
|
||||
II 2D Ex ib IIC T 80 deg C IP65 ((Db) -25 deg C < Ta < +80 deg C
|
||||
IBEx U1ATEX1017
|
||||
Includes installation instruction
|
||||
|
||||
With standard Angle Bracket to suit Brilex Non-Insulated Explosion Vents
|
||||
(If Insulated panels are used a modified (vertically extended) bracket needs to be used)
|
||||
See attached EC Conformity Declaration for the SE Sensor
|
||||
|
||||
Testing at
|
||||
|
||||
1. -4 deg C
|
||||
2. -30 deg C
|
||||
3. 20 deg C`
|
||||
|
||||
output := formatDescription(input)
|
||||
|
||||
// Check that key: value pairs are bolded
|
||||
if !strings.Contains(output, "<strong>Item Code:</strong>") {
|
||||
t.Error("Item Code should be bolded")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "<strong>Item Description:</strong>") {
|
||||
t.Error("Item Description should be bolded")
|
||||
}
|
||||
|
||||
// Check that list items are in <ol><li> tags
|
||||
if !strings.Contains(output, "<ol>") {
|
||||
t.Error("Ordered list should have <ol> tag")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "<li>-4 deg C</li>") {
|
||||
t.Error("List item 1 not formatted correctly")
|
||||
}
|
||||
|
||||
// Check that italic patterns are applied
|
||||
if !strings.Contains(output, "<em>See attached EC Conformity Declaration for the SE Sensor</em>") {
|
||||
t.Error("Italic pattern not applied to EC Conformity Declaration text")
|
||||
}
|
||||
|
||||
// Verify HTML tags are properly balanced (count opening/closing tag pairs)
|
||||
strongOpens := strings.Count(output, "<strong>")
|
||||
strongCloses := strings.Count(output, "</strong>")
|
||||
if strongOpens != strongCloses {
|
||||
t.Errorf("Unbalanced <strong> tags: %d opens, %d closes", strongOpens, strongCloses)
|
||||
}
|
||||
|
||||
emOpens := strings.Count(output, "<em>")
|
||||
emCloses := strings.Count(output, "</em>")
|
||||
if emOpens != emCloses {
|
||||
t.Errorf("Unbalanced <em> tags: %d opens, %d closes", emOpens, emCloses)
|
||||
}
|
||||
|
||||
olOpens := strings.Count(output, "<ol>")
|
||||
olCloses := strings.Count(output, "</ol>")
|
||||
if olOpens != olCloses {
|
||||
t.Errorf("Unbalanced <ol> tags: %d opens, %d closes", olOpens, olCloses)
|
||||
}
|
||||
|
||||
t.Logf("Formatted output:\n%s", output)
|
||||
}
|
||||
|
||||
func TestIsOrderedListItem(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"1. Item one", true},
|
||||
{"2. Item two", true},
|
||||
{"10. Item ten", true},
|
||||
{"Item without number", false},
|
||||
{"1 Item without dot", false},
|
||||
{"Item: with colon", false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := isOrderedListItem(test.input)
|
||||
if result != test.expected {
|
||||
t.Errorf("isOrderedListItem(%q) = %v, want %v", test.input, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
contains string
|
||||
}{
|
||||
{"Item Code: B25SEZ22B0", "<strong>Item Code:</strong>"},
|
||||
{"Type: SE - To control", "<strong>Type:</strong>"},
|
||||
{"Random text here", "Random text here"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := formatLine(test.input)
|
||||
if !strings.Contains(result, test.contains) {
|
||||
t.Errorf("formatLine(%q) should contain %q, got %q", test.input, test.contains, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
389
go/internal/cmc/pdf/html_generator.go
Normal file
389
go/internal/cmc/pdf/html_generator.go
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
)
|
||||
|
||||
// HTMLInvoiceGenerator generates PDF invoices from HTML templates using chromedp
|
||||
type HTMLInvoiceGenerator struct {
|
||||
outputDir string
|
||||
}
|
||||
|
||||
// NewHTMLInvoiceGenerator creates a new HTML-based invoice generator
|
||||
func NewHTMLInvoiceGenerator(outputDir string) *HTMLInvoiceGenerator {
|
||||
return &HTMLInvoiceGenerator{
|
||||
outputDir: outputDir,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateInvoicePDF creates a PDF invoice from HTML template
|
||||
func (g *HTMLInvoiceGenerator) GenerateInvoicePDF(data *InvoicePDFData) (string, error) {
|
||||
fmt.Println("=== HTML Generator: Starting invoice generation (two-pass with T&C page count) ===")
|
||||
|
||||
// FIRST PASS: Generate PDF without page numbers to determine total pages (including T&C)
|
||||
fmt.Println("=== HTML Generator: First pass - generating without page count ===")
|
||||
html := g.buildInvoiceHTML(data, 0, 0)
|
||||
|
||||
fmt.Printf("=== HTML Generator: Generated %d bytes of HTML ===\n", len(html))
|
||||
|
||||
tempHTML := filepath.Join(g.outputDir, "temp_invoice.html")
|
||||
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write temp HTML: %w", err)
|
||||
}
|
||||
defer os.Remove(tempHTML)
|
||||
defer os.Remove(filepath.Join(g.outputDir, "invoice_logo.png")) // Clean up temp logo
|
||||
defer os.Remove(filepath.Join(g.outputDir, "temp_logo.png")) // Clean up temp logo
|
||||
|
||||
// Generate temp PDF
|
||||
tempPDFPath := filepath.Join(g.outputDir, "temp_invoice_first_pass.pdf")
|
||||
if err := g.htmlToPDF(tempHTML, tempPDFPath); err != nil {
|
||||
return "", fmt.Errorf("failed to convert HTML to PDF (first pass): %w", err)
|
||||
}
|
||||
|
||||
// Get initial page count from invoice
|
||||
invoicePageCount, err := g.getPageCount(tempPDFPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Could not extract invoice page count: %v\n", err)
|
||||
invoicePageCount = 1
|
||||
}
|
||||
|
||||
// Check if T&C exists and merge to get total page count
|
||||
totalPageCount := invoicePageCount
|
||||
termsPath := filepath.Join(g.outputDir, "CMC_terms_and_conditions2006_A4.pdf")
|
||||
tempMergedPath := filepath.Join(g.outputDir, fmt.Sprintf("%s_merged_first_pass.pdf", data.Invoice.Title))
|
||||
|
||||
if _, err := os.Stat(termsPath); err == nil {
|
||||
fmt.Println("=== HTML Generator: T&C found, merging to determine total pages ===")
|
||||
if err := MergePDFs(tempPDFPath, termsPath, tempMergedPath); err == nil {
|
||||
// Get total page count from merged PDF
|
||||
totalPageCount, err = g.getPageCount(tempMergedPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Could not extract merged page count, using invoice count: %v\n", err)
|
||||
totalPageCount = invoicePageCount
|
||||
} else {
|
||||
fmt.Printf("=== HTML Generator: Total pages (invoice + T&C): %d ===\n", totalPageCount)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Warning: Could not merge T&C for counting: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("=== HTML Generator: First pass complete, detected %d total pages ===\n", totalPageCount)
|
||||
os.Remove(tempPDFPath)
|
||||
os.Remove(tempMergedPath)
|
||||
|
||||
// SECOND PASS: Generate final PDF with correct page count
|
||||
fmt.Printf("=== HTML Generator: Second pass - regenerating with page count %d ===\n", totalPageCount)
|
||||
html = g.buildInvoiceHTML(data, totalPageCount, 1)
|
||||
|
||||
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write temp HTML (second pass): %w", err)
|
||||
}
|
||||
|
||||
// Generate final PDF filename using invoice number; fallback to a stable default
|
||||
invoiceNumber := ""
|
||||
if data.Document != nil {
|
||||
invoiceNumber = data.Document.CmcReference
|
||||
}
|
||||
filenameBase := invoiceNumber
|
||||
if filenameBase == "" {
|
||||
filenameBase = "CMC Invoice"
|
||||
}
|
||||
filename := fmt.Sprintf("%s.pdf", filenameBase)
|
||||
pdfPath := filepath.Join(g.outputDir, filename)
|
||||
|
||||
if err := g.htmlToPDF(tempHTML, pdfPath); err != nil {
|
||||
return "", fmt.Errorf("failed to convert HTML to PDF (second pass): %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("=== HTML Generator: PDF generation complete ===")
|
||||
|
||||
// Merge with T&C PDF if it exists
|
||||
if _, err := os.Stat(termsPath); err == nil {
|
||||
fmt.Println("=== HTML Generator: Found T&C PDF, merging ===")
|
||||
tempMergedPath := filepath.Join(g.outputDir, fmt.Sprintf("%s_merged_temp.pdf", filenameBase))
|
||||
if err := MergePDFs(pdfPath, termsPath, tempMergedPath); err != nil {
|
||||
fmt.Printf("=== HTML Generator: Warning - could not merge T&C PDF: %v. Returning invoice without T&C.\n", err)
|
||||
return filename, nil
|
||||
}
|
||||
// Replace original with merged version
|
||||
if err := os.Rename(tempMergedPath, pdfPath); err != nil {
|
||||
fmt.Printf("=== HTML Generator: Warning - could not replace PDF: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
// htmlToPDF converts an HTML file to PDF using chromedp
|
||||
func (g *HTMLInvoiceGenerator) htmlToPDF(htmlPath, pdfPath string) error {
|
||||
// Read the HTML file to get its content
|
||||
htmlContent, err := ioutil.ReadFile(htmlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read HTML file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("=== htmlToPDF: Read HTML file, size=%d bytes ===\n", len(htmlContent))
|
||||
|
||||
// Create chromedp context
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
)
|
||||
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
defer cancel()
|
||||
|
||||
// Set timeout
|
||||
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Navigate to file URL and print to PDF
|
||||
var pdfBuf []byte
|
||||
fileURL := "file://" + htmlPath
|
||||
|
||||
fmt.Printf("=== htmlToPDF: Using file URL: %s ===\n", fileURL)
|
||||
fmt.Println("=== htmlToPDF: Starting chromedp navigation ===")
|
||||
|
||||
if err := chromedp.Run(ctx,
|
||||
chromedp.Navigate(fileURL),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("=== htmlToPDF: Executing PrintToPDF ===")
|
||||
var err error
|
||||
pdfBuf, _, err = page.PrintToPDF().
|
||||
WithPrintBackground(true).
|
||||
WithMarginTop(0).
|
||||
WithMarginBottom(0).
|
||||
WithMarginLeft(0).
|
||||
WithMarginRight(0).
|
||||
WithPaperWidth(8.27). // A4 width in inches
|
||||
WithPaperHeight(11.69). // A4 height in inches
|
||||
Do(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("=== htmlToPDF: PrintToPDF error: %v ===\n", err)
|
||||
} else {
|
||||
fmt.Printf("=== htmlToPDF: PrintToPDF succeeded, PDF size=%d bytes ===\n", len(pdfBuf))
|
||||
}
|
||||
return err
|
||||
}),
|
||||
); err != nil {
|
||||
return fmt.Errorf("chromedp failed: %w", err)
|
||||
}
|
||||
|
||||
// Write PDF to file
|
||||
if err := ioutil.WriteFile(pdfPath, pdfBuf, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write PDF: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPageCount extracts the page count from a PDF file
|
||||
func (g *HTMLInvoiceGenerator) getPageCount(pdfPath string) (int, error) {
|
||||
pageCount, err := api.PageCountFile(pdfPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get page count: %w", err)
|
||||
}
|
||||
return pageCount, nil
|
||||
}
|
||||
|
||||
// loadLogoAsBase64 loads the logo image and returns it as a relative path
|
||||
func (g *HTMLInvoiceGenerator) loadLogoAsBase64() string {
|
||||
// Use canonical path: /app/static/images in Docker, go/static/images locally
|
||||
logoPath := "/app/static/images/CMC-Mobile-Logo.png"
|
||||
if _, err := os.Stat(logoPath); err != nil {
|
||||
// Local development path
|
||||
logoPath = filepath.Join("go", "static", "images", "CMC-Mobile-Logo.png")
|
||||
}
|
||||
|
||||
logoData, err := ioutil.ReadFile(logoPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Could not read logo at %s: %v\n", logoPath, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Copy logo to output directory for chromedp to access
|
||||
destPath := filepath.Join(g.outputDir, "invoice_logo.png")
|
||||
if err := ioutil.WriteFile(destPath, logoData, 0644); err != nil {
|
||||
fmt.Printf("Warning: Could not write logo to output dir: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
fmt.Printf("=== Copied logo from %s to %s ===\n", logoPath, destPath)
|
||||
// Return relative path (same directory as HTML file)
|
||||
return "invoice_logo.png"
|
||||
}
|
||||
|
||||
// buildInvoiceHTML generates the complete HTML for an invoice using templates
|
||||
func (g *HTMLInvoiceGenerator) buildInvoiceHTML(data *InvoicePDFData, totalPages int, currentPage int) string {
|
||||
// Get invoice number, fall back to invoice title so the template always shows something meaningful
|
||||
invoiceNum := ""
|
||||
if data.Document != nil {
|
||||
invoiceNum = data.Document.CmcReference
|
||||
}
|
||||
if invoiceNum == "" {
|
||||
invoiceNum = data.Invoice.Title
|
||||
}
|
||||
fmt.Printf("=== buildInvoiceHTML: Invoice number: %s ===\n", invoiceNum)
|
||||
|
||||
// Prepare template data
|
||||
templateData := struct {
|
||||
InvoiceNumber string
|
||||
IssueDateString string
|
||||
BillTo template.HTML
|
||||
ShipTo template.HTML
|
||||
CustomerOrderNumber string
|
||||
JobTitle string
|
||||
FOB string
|
||||
PaymentTerms string
|
||||
CustomerABN string
|
||||
CurrencyCode string
|
||||
CurrencySymbol string
|
||||
LineItems []LineItemTemplateData
|
||||
Subtotal interface{}
|
||||
GSTAmount interface{}
|
||||
Total interface{}
|
||||
ShowGST bool
|
||||
PageCount int
|
||||
CurrentPage int
|
||||
LogoDataURI string
|
||||
}{
|
||||
InvoiceNumber: invoiceNum,
|
||||
IssueDateString: data.IssueDateString,
|
||||
BillTo: template.HTML(data.BillTo),
|
||||
ShipTo: template.HTML(data.ShipTo),
|
||||
CustomerOrderNumber: data.CustomerOrderNumber,
|
||||
JobTitle: data.JobTitle,
|
||||
FOB: data.FOB,
|
||||
PaymentTerms: data.PaymentTerms,
|
||||
CustomerABN: data.CustomerABN,
|
||||
CurrencyCode: data.CurrencyCode,
|
||||
CurrencySymbol: data.CurrencySymbol,
|
||||
Subtotal: data.Subtotal,
|
||||
GSTAmount: data.GSTAmount,
|
||||
Total: data.Total,
|
||||
ShowGST: data.ShowGST,
|
||||
PageCount: totalPages,
|
||||
CurrentPage: currentPage,
|
||||
LogoDataURI: g.loadLogoAsBase64(),
|
||||
}
|
||||
|
||||
// Convert line items to template format
|
||||
for _, item := range data.LineItems {
|
||||
templateData.LineItems = append(templateData.LineItems, LineItemTemplateData{
|
||||
Title: item.Title,
|
||||
Description: template.HTML(item.Description), // Allow HTML in description
|
||||
Quantity: item.Quantity,
|
||||
GrossUnitPrice: item.GrossUnitPrice,
|
||||
DiscountAmountTotal: item.DiscountAmountTotal,
|
||||
GrossPrice: item.GrossPrice,
|
||||
})
|
||||
}
|
||||
|
||||
// Define template functions
|
||||
funcMap := template.FuncMap{
|
||||
"formatPrice": func(price sql.NullString) template.HTML {
|
||||
if !price.Valid || price.String == "" {
|
||||
return ""
|
||||
}
|
||||
formatted := formatPriceWithCommas(price.String)
|
||||
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
|
||||
},
|
||||
"formatDiscount": func(discount sql.NullString) template.HTML {
|
||||
if !discount.Valid || discount.String == "" {
|
||||
return template.HTML(fmt.Sprintf("%s0.00", data.CurrencySymbol))
|
||||
}
|
||||
if discount.String == "0" || discount.String == "0.00" {
|
||||
return template.HTML(fmt.Sprintf("%s0.00", data.CurrencySymbol))
|
||||
}
|
||||
formatted := formatPriceWithCommas(discount.String)
|
||||
return template.HTML(fmt.Sprintf("-%s%s", data.CurrencySymbol, formatted))
|
||||
},
|
||||
"formatTotal": func(amount interface{}) template.HTML {
|
||||
if amount == nil {
|
||||
return ""
|
||||
}
|
||||
if val, ok := amount.(float64); ok {
|
||||
formatted := formatPriceWithCommas(fmt.Sprintf("%.2f", val))
|
||||
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
|
||||
}
|
||||
return template.HTML(fmt.Sprintf("%v", amount))
|
||||
},
|
||||
}
|
||||
|
||||
// Parse and execute template
|
||||
tmplPath := filepath.Join("internal", "cmc", "pdf", "templates", "invoice.html")
|
||||
tmpl, err := template.New("invoice.html").Funcs(funcMap).ParseFiles(tmplPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Error parsing template: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, templateData); err != nil {
|
||||
fmt.Printf("Error executing template: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// LineItemTemplateData represents a line item for template rendering
|
||||
type LineItemTemplateData struct {
|
||||
Title string
|
||||
Description template.HTML
|
||||
Quantity string
|
||||
GrossUnitPrice sql.NullString
|
||||
DiscountAmountTotal sql.NullString
|
||||
GrossPrice sql.NullString
|
||||
}
|
||||
|
||||
// formatPriceWithCommas formats a price string with comma separators
|
||||
func formatPriceWithCommas(priceStr string) string {
|
||||
if priceStr == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try to parse as float
|
||||
price, err := strconv.ParseFloat(priceStr, 64)
|
||||
if err != nil {
|
||||
// Return as-is if not parseable
|
||||
return priceStr
|
||||
}
|
||||
|
||||
// Format with 2 decimal places and commas
|
||||
amountStr := strconv.FormatFloat(price, 'f', 2, 64)
|
||||
parts := strings.Split(amountStr, ".")
|
||||
intPart := parts[0]
|
||||
decPart := "." + parts[1]
|
||||
|
||||
// Add comma separators
|
||||
var result strings.Builder
|
||||
for i, ch := range intPart {
|
||||
if i > 0 && (len(intPart)-i)%3 == 0 {
|
||||
result.WriteString(",")
|
||||
}
|
||||
result.WriteRune(ch)
|
||||
}
|
||||
|
||||
return result.String() + decPart
|
||||
}
|
||||
0
go/internal/cmc/pdf/html_generator.go.applied
Normal file
0
go/internal/cmc/pdf/html_generator.go.applied
Normal file
0
go/internal/cmc/pdf/html_generator.go.backup
Normal file
0
go/internal/cmc/pdf/html_generator.go.backup
Normal file
0
go/internal/cmc/pdf/html_generator.go.patch
Normal file
0
go/internal/cmc/pdf/html_generator.go.patch
Normal file
|
|
@ -202,11 +202,12 @@ func GenerateInvoicePDF(data *InvoicePDFData, outputDir string) (string, error)
|
|||
gen.AddPage()
|
||||
gen.Page1Header()
|
||||
|
||||
// Title
|
||||
gen.pdf.SetFont("Helvetica", "B", 18)
|
||||
// Title - positioned center with proper spacing
|
||||
gen.pdf.SetFont("Helvetica", "B", 20)
|
||||
gen.pdf.SetTextColor(0, 0, 0)
|
||||
gen.pdf.CellFormat(0, 10, "TAX INVOICE", "", 1, "C", false, 0, "")
|
||||
gen.pdf.Ln(5)
|
||||
gen.pdf.SetY(54)
|
||||
gen.pdf.CellFormat(0, 8, "TAX INVOICE", "", 1, "C", false, 0, "")
|
||||
gen.pdf.Ln(3)
|
||||
|
||||
// Sold To / Delivery Address boxes
|
||||
gen.AddInvoiceAddressBoxes(data)
|
||||
|
|
@ -230,9 +231,10 @@ func GenerateInvoicePDF(data *InvoicePDFData, outputDir string) (string, error)
|
|||
}
|
||||
|
||||
// Merge with actual terms and conditions PDF using pdfcpu
|
||||
// The T&C file should be mounted in the same directory as output PDFs in stg/prod
|
||||
termsPath := filepath.Join(outputDir, "CMC_terms_and_conditions2006_A4.pdf")
|
||||
|
||||
// Check if terms file exists
|
||||
// Check if terms file exists and merge
|
||||
if _, err := os.Stat(termsPath); err == nil {
|
||||
// Terms file exists, merge it
|
||||
finalPath := invoicePath
|
||||
|
|
|
|||
441
go/internal/cmc/pdf/templates/invoice.html
Normal file
441
go/internal/cmc/pdf/templates/invoice.html
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{if .InvoiceNumber}}{{.InvoiceNumber}}{{else}}CMC Invoice{{end}}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 15mm;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 9pt;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0000FF;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 10mm;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 20pt;
|
||||
margin: 0 0 5mm 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.address-boxes {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 5mm;
|
||||
}
|
||||
|
||||
.address-box {
|
||||
width: 48%;
|
||||
border: 1px solid #000;
|
||||
padding: 3mm;
|
||||
}
|
||||
|
||||
.address-box h3 {
|
||||
font-size: 10pt;
|
||||
margin: 0 0 2mm 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.details-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 5mm;
|
||||
margin-right: 0.2mm;
|
||||
}
|
||||
|
||||
.details-table td {
|
||||
border: 1px solid #000;
|
||||
padding: 2mm;
|
||||
}
|
||||
|
||||
.details-table td:first-child {
|
||||
font-weight: bold;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.line-items {
|
||||
width: 99.5%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 5mm;
|
||||
table-layout: fixed;
|
||||
margin-right: 1mm;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.line-items th {
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #000;
|
||||
padding: 2mm;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.line-items td {
|
||||
border: 1px solid #000;
|
||||
padding: 2mm;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.line-items .description {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.line-items .qty {
|
||||
width: 10%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.line-items .unit-price {
|
||||
width: 13%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.line-items .discount {
|
||||
width: 13%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.line-items .total {
|
||||
width: 14%;
|
||||
margin-top: 5mm;
|
||||
}
|
||||
|
||||
.totals-table {
|
||||
width: auto;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #000;
|
||||
margin-right: 0.2mm;
|
||||
}
|
||||
|
||||
.totals-table td {
|
||||
padding: 1mm 3mm;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.totals-table td:first-child {
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
width: 50%;
|
||||
background-color: #f0f0f0; /* header-like label cell */
|
||||
}
|
||||
|
||||
.totals-table td:last-child {
|
||||
text-align: right;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.totals-table .total-row td {
|
||||
font-weight: bold;
|
||||
font-size: 11pt;
|
||||
padding-top: 2mm;
|
||||
border-top: 1px solid #000;
|
||||
}
|
||||
|
||||
.description strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.description em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.description ul {
|
||||
margin: 2mm 0;
|
||||
padding-left: 5mm;
|
||||
}
|
||||
|
||||
.description ol {
|
||||
margin: 2mm 0;
|
||||
padding-left: 5mm;
|
||||
}
|
||||
|
||||
.description li {
|
||||
margin: 1mm 0;
|
||||
}
|
||||
|
||||
.company-header {
|
||||
text-align: center;
|
||||
margin-bottom: 8mm;
|
||||
border-bottom: 2px solid #000;
|
||||
padding-bottom: 5mm;
|
||||
}
|
||||
|
||||
.company-header h1 {
|
||||
font-size: 24pt;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
color: #003399;
|
||||
}
|
||||
|
||||
.company-header p {
|
||||
margin: 2mm 0;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.contact-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 5mm;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.contact-col {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.contact-col strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.invoice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 5mm;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.invoice-info {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.invoice-info div {
|
||||
margin: 1mm 0;
|
||||
}
|
||||
|
||||
.payment-box {
|
||||
border: 1px solid #000;
|
||||
padding: 3mm;
|
||||
margin-top: 5mm;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.payment-box h4 {
|
||||
margin: 0 0 2mm 0;
|
||||
font-weight: bold;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.payment-box div {
|
||||
margin: 1mm 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 40mm;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.company-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10mm;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.company-header {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.company-header h1 {
|
||||
color: #003399;
|
||||
margin: 0;
|
||||
font-size: 20pt;
|
||||
}
|
||||
|
||||
.company-header p {
|
||||
margin: 1mm 0;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
gap: 10mm;
|
||||
margin-bottom: 5mm;
|
||||
}
|
||||
|
||||
.addresses-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.payment-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.payment-section h4 {
|
||||
margin: 0 0 2mm 0;
|
||||
font-weight: bold;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.payment-section div {
|
||||
margin: 1mm 0;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.payment-box {
|
||||
border: 1px solid #000;
|
||||
padding: 3mm;
|
||||
margin-top: 5mm;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.payment-box h4 {
|
||||
margin: 0 0 2mm 0;
|
||||
font-weight: bold;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.payment-box div {
|
||||
margin: 1mm 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Company Header with Logo and Centered Title -->
|
||||
<div style="display: flex; align-items: flex-start; gap: 2mm; margin-bottom: 0;">
|
||||
<img src="{{.LogoDataURI}}" class="logo" alt="CMC Logo" style="width: 40mm; height: auto; display: block; padding-right: 5mm;">
|
||||
<div style="flex: 1;">
|
||||
<div style="border-bottom: 2px solid #000; padding-bottom: 0mm; text-align: center;">
|
||||
<h1 style="font-size: 32pt; color: #003399; margin: 0 0 -1mm 0;">CMC TECHNOLOGIES</h1>
|
||||
<p style="margin: 0mm 0 1mm 0; font-size: 10pt;">PTY LIMITED ACN: 085 991 224 ABN: 47 085 991 224</p>
|
||||
</div>
|
||||
<!-- Contact Details Below Line -->
|
||||
<div style="display: flex; justify-content: space-between; margin: 2mm 2mm 2mm 2mm; font-size: 10pt;">
|
||||
<div>
|
||||
<strong>Phone:</strong> +61 2 9669 4000<br>
|
||||
<strong>Fax:</strong> +61 2 9669 4111<br>
|
||||
<strong>Email:</strong> <a href="mailto:sales@cmctechnologies.com.au" style="color: #0000FF; text-decoration: underline;">sales@cmctechnologies.com.au</a><br>
|
||||
<strong>Web Site:</strong> <a href="http://www.cmctechnologies.net.au" style="color: #0000FF; text-decoration: underline;">www.cmctechnologies.net.au</a>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
Unit 19, 77 Bourke Rd<br>
|
||||
Alexandria NSW 2015<br>
|
||||
AUSTRALIA
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAX INVOICE Section with Invoice Number and Date -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin: 2mm 0 3mm 0;">
|
||||
<div style="flex: 1;"></div>
|
||||
<div style="flex: 1; text-align: center;">
|
||||
<h2 style="font-size: 16pt; margin: 0; font-weight: bold;">TAX INVOICE</h2>
|
||||
</div>
|
||||
<div style="flex: 1; text-align: right; font-size: 10pt;">
|
||||
{{if .InvoiceNumber}}
|
||||
<div style="font-weight: bold; margin-bottom: 1mm;">INVOICE# {{.InvoiceNumber}}</div>
|
||||
{{end}}
|
||||
{{if .IssueDateString}}
|
||||
<div><strong>Date:</strong> {{.IssueDateString}}</div>
|
||||
{{end}}
|
||||
{{if .PageCount}}
|
||||
<div><strong>Page:</strong> {{.CurrentPage}} of {{.PageCount}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Boxes -->
|
||||
<div class="address-boxes" style="display: flex; gap: 5mm; margin-bottom: 3mm;">
|
||||
<div class="address-box" style="flex: 1;">
|
||||
<h3>Sold To / Invoice Address:</h3>
|
||||
<div>{{.BillTo}}</div>
|
||||
</div>
|
||||
<div class="address-box" style="flex: 1; margin-right: 1mm; margin-left: auto;">
|
||||
<h3>Delivery Address:</h3>
|
||||
<div>{{.ShipTo}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Table - 5 Columns -->
|
||||
<table class="line-items" style="margin-bottom: 3mm;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 20%;">CUSTOMER ORDER NO</th>
|
||||
<th style="width: 20%;">CMC JOB #</th>
|
||||
<th style="width: 20%;">INCOTERMS 2010</th>
|
||||
<th style="width: 20%;">PAYMENT TERMS</th>
|
||||
<th style="width: 20%;">CUSTOMER ABN</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{.CustomerOrderNumber}}</td>
|
||||
<td>{{.JobTitle}}</td>
|
||||
<td>{{.FOB}}</td>
|
||||
<td>{{.PaymentTerms}}</td>
|
||||
<td>{{.CustomerABN}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Currency Code Header (appears once, not repeated) -->
|
||||
<div style="margin-bottom: 1mm; text-align: right; font-weight: bold; font-size: 9pt;">
|
||||
Shown in {{.CurrencyCode}}
|
||||
</div>
|
||||
|
||||
<!-- Line Items -->
|
||||
<table class="line-items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="description">DESCRIPTION</th>
|
||||
<th class="qty">QTY</th>
|
||||
<th class="unit-price">UNIT PRICE</th>
|
||||
<th class="discount">DISCOUNT</th>
|
||||
<th class="total">TOTAL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .LineItems}}
|
||||
<tr>
|
||||
<td class="description"><strong>{{.Title}}</strong><br>{{.Description}}</td>
|
||||
<td class="qty">{{.Quantity}}</td>
|
||||
<td class="unit-price">{{formatPrice .GrossUnitPrice}}</td>
|
||||
<td class="discount">{{formatDiscount .DiscountAmountTotal}}</td>
|
||||
<td class="total">{{formatPrice .GrossPrice}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Payment and Totals Side by Side -->
|
||||
<div style="display: flex; gap: 10mm; align-items: flex-start; margin-top: 5mm;">
|
||||
<div style="width: 50%; padding: 3mm; font-size: 9pt; border: 1px solid #000;">
|
||||
<h4 style="margin: 0 0 1mm 0; font-weight: bold; font-size: 10pt;">MAKE PAYMENT TO:</h4>
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 9pt; table-layout: fixed;">
|
||||
<tr><td style="font-weight: bold; width: 40%; padding: 0.3mm 0; padding-right: 6mm; white-space: nowrap;">Account Name:</td><td style="padding: 0.3mm 0; white-space: nowrap;">CMC Technologies Pty Ltd</td></tr>
|
||||
<tr><td style="font-weight: bold; width: 40%; padding: 0.3mm 0; padding-right: 6mm; white-space: nowrap;">BSB:</td><td style="padding: 0.3mm 0; white-space: nowrap;">062-458</td></tr>
|
||||
<tr><td style="font-weight: bold; width: 40%; padding: 0.3mm 0; padding-right: 6mm; white-space: nowrap;">Account Number:</td><td style="padding: 0.3mm 0; white-space: nowrap;">10067982</td></tr>
|
||||
<tr><td style="font-weight: bold; width: 40%; padding: 0.3mm 0; padding-right: 6mm; white-space: nowrap;">SWIFT Code:</td><td style="padding: 0.3mm 0; white-space: nowrap;">CTBAAU2S</td></tr>
|
||||
<tr><td style="font-weight: bold; width: 40%; padding: 0.3mm 0; padding-right: 6mm; white-space: nowrap;">IBAN:</td><td style="padding: 0.3mm 0; white-space: nowrap;">0624581006782</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div style="width: 50%; margin-left: auto; margin-right: 1mm;">
|
||||
<table class="totals-table" style="width: 100%;">
|
||||
<tr><td>Subtotal:</td><td>{{formatTotal .Subtotal}}</td></tr>
|
||||
{{if .ShowGST}}
|
||||
<tr><td>GST (10%):</td><td>{{formatTotal .GSTAmount}}</td></tr>
|
||||
{{end}}
|
||||
<tr class="total-row"><td>TOTAL:</td><td>{{formatTotal .Total}}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
214
php/app/vendors/description_formatter.php
vendored
Normal file
214
php/app/vendors/description_formatter.php
vendored
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<?php
|
||||
/**
|
||||
* DescriptionFormatter: Converts plain text product descriptions to formatted HTML
|
||||
* This ensures rich text formatting (bold, italics, lists) is properly supported
|
||||
* in PDF generation.
|
||||
*/
|
||||
|
||||
class DescriptionFormatter {
|
||||
|
||||
/**
|
||||
* Convert plain text description to HTML with formatting
|
||||
*
|
||||
* Handles:
|
||||
* - Bold formatting for key labels (Item Code:, Type:, etc.)
|
||||
* - Ordered lists (lines starting with "1. ", "2. ", etc.)
|
||||
* - Italic formatting for specific phrases
|
||||
* - Proper HTML entity escaping for special characters
|
||||
*
|
||||
* @param string $text Plain text description
|
||||
* @return string HTML-formatted description
|
||||
*/
|
||||
public static function format($text) {
|
||||
if (empty($text)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Escape HTML special characters that aren't part of our formatting
|
||||
// We need to do this carefully to preserve our HTML tags
|
||||
$text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Split into lines for processing
|
||||
$lines = explode("\n", $text);
|
||||
$result = array();
|
||||
$inOrderedList = false;
|
||||
$listItems = array();
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
if (empty($trimmed)) {
|
||||
// Flush any pending list before adding empty line
|
||||
if ($inOrderedList && count($listItems) > 0) {
|
||||
$result[] = self::formatOrderedList($listItems);
|
||||
$listItems = array();
|
||||
$inOrderedList = false;
|
||||
}
|
||||
$result[] = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is an ordered list item (starts with "1. ", "2. ", etc.)
|
||||
if (self::isOrderedListItem($trimmed)) {
|
||||
if (!$inOrderedList) {
|
||||
$inOrderedList = true;
|
||||
$listItems = array();
|
||||
}
|
||||
// Extract text after the number
|
||||
$listItems[] = self::extractListItemText($trimmed);
|
||||
} else {
|
||||
// Flush any pending list before processing non-list line
|
||||
if ($inOrderedList && count($listItems) > 0) {
|
||||
$result[] = self::formatOrderedList($listItems);
|
||||
$listItems = array();
|
||||
$inOrderedList = false;
|
||||
}
|
||||
|
||||
// Process regular line
|
||||
$formatted = self::formatLine($trimmed);
|
||||
$result[] = $formatted;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining list
|
||||
if ($inOrderedList && count($listItems) > 0) {
|
||||
$result[] = self::formatOrderedList($listItems);
|
||||
}
|
||||
|
||||
// Join with line breaks
|
||||
$html = implode('<br>', $result);
|
||||
|
||||
// Clean up excessive line breaks
|
||||
$html = preg_replace('/<br><br><br>+/', '<br><br>', $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is an ordered list item (e.g., "1. text")
|
||||
*/
|
||||
private static function isOrderedListItem($line) {
|
||||
return preg_match('/^\d+\.\s+/', $line) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text part of a list item, removing the number
|
||||
*/
|
||||
private static function extractListItemText($line) {
|
||||
$matches = array();
|
||||
if (preg_match('/^\d+\.\s+(.*)$/', $line, $matches)) {
|
||||
return isset($matches[1]) ? $matches[1] : $line;
|
||||
}
|
||||
return $line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an array of list items to HTML ordered list
|
||||
*/
|
||||
private static function formatOrderedList($items) {
|
||||
if (count($items) === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '<ol>';
|
||||
foreach ($items as $item) {
|
||||
$html .= '<li>' . $item . '</li>';
|
||||
}
|
||||
$html .= '</ol>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single line - mainly applying bold to key labels
|
||||
*/
|
||||
private static function formatLine($line) {
|
||||
// Match "Key: value" pattern
|
||||
if (preg_match('/^([^:]+):\s*(.*)$/', $line, $matches)) {
|
||||
$key = $matches[1];
|
||||
$value = isset($matches[2]) ? $matches[2] : '';
|
||||
|
||||
// Check if this key should be bolded
|
||||
if (self::shouldBoldKey($key)) {
|
||||
$line = '<strong>' . htmlspecialchars($key, ENT_QUOTES, 'UTF-8') . ':</strong> ' . $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply italics to specific phrases
|
||||
$line = self::applyItalics($line);
|
||||
|
||||
return $line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a key should be bolded (heuristic)
|
||||
*/
|
||||
private static function shouldBoldKey($key) {
|
||||
$key = trim($key);
|
||||
|
||||
// List of known keys that should be bolded
|
||||
$knownKeys = array(
|
||||
'Item Code' => true,
|
||||
'Item Description' => true,
|
||||
'Type' => true,
|
||||
'Cable Length' => true,
|
||||
'Ui' => true,
|
||||
'li' => true,
|
||||
'Li, Ci' => true,
|
||||
'II 2G' => true,
|
||||
'II 2D' => true,
|
||||
'IBEx' => true,
|
||||
'Includes' => true,
|
||||
'With standard' => true,
|
||||
'See attached' => true,
|
||||
'Testing at' => true,
|
||||
);
|
||||
|
||||
// Check exact matches
|
||||
if (isset($knownKeys[$key])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check partial matches for common patterns
|
||||
$lowerKey = strtolower($key);
|
||||
$commonPatterns = array(
|
||||
'code',
|
||||
'description',
|
||||
'type',
|
||||
'cable',
|
||||
'temperature',
|
||||
'pressure',
|
||||
'includes',
|
||||
'testing',
|
||||
);
|
||||
|
||||
foreach ($commonPatterns as $pattern) {
|
||||
if (strpos($lowerKey, $pattern) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply italic formatting to specific phrases
|
||||
*/
|
||||
private static function applyItalics($line) {
|
||||
$italicPatterns = array(
|
||||
'See attached EC Conformity Declaration for the SE Sensor',
|
||||
'If Insulated panels are used',
|
||||
);
|
||||
|
||||
foreach ($italicPatterns as $pattern) {
|
||||
if (strpos($line, $pattern) !== false) {
|
||||
$escapedPattern = htmlspecialchars($pattern, ENT_QUOTES, 'UTF-8');
|
||||
$line = str_replace($escapedPattern, '<em>' . $escapedPattern . '</em>', $line);
|
||||
}
|
||||
}
|
||||
|
||||
return $line;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// Generate the Invoice PDF by calling the Go service instead of TCPDF.
|
||||
|
||||
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
||||
$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-invoice';
|
||||
$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-invoice-html';
|
||||
|
||||
$outputDir = Configure::read('pdf_directory');
|
||||
|
||||
|
|
@ -13,6 +13,7 @@ foreach ($document['LineItem'] as $li) {
|
|||
'quantity' => $li['quantity'],
|
||||
'title' => $li['title'],
|
||||
'description' => isset($li['description']) ? $li['description'] : '',
|
||||
'is_html' => true, // Description is always HTML
|
||||
'unit_price' => floatval($li['gross_unit_price']),
|
||||
'total_price' => floatval($li['gross_price']),
|
||||
'net_unit_price' => floatval($li['net_unit_price']),
|
||||
|
|
@ -27,8 +28,17 @@ foreach ($document['LineItem'] as $li) {
|
|||
);
|
||||
}
|
||||
|
||||
$invoiceNumber = '';
|
||||
if (!empty($document['Document']['cmc_reference'])) {
|
||||
$invoiceNumber = $document['Document']['cmc_reference'];
|
||||
} elseif (!empty($document['Invoice']['title'])) {
|
||||
// Fallback so the Go service always receives a number-like value
|
||||
$invoiceNumber = $document['Invoice']['title'];
|
||||
}
|
||||
|
||||
$payload = array(
|
||||
'document_id' => intval($document['Document']['id']),
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'invoice_title' => $document['Invoice']['title'],
|
||||
'customer_name' => $enquiry['Customer']['name'],
|
||||
'contact_email' => $enquiry['Contact']['email'],
|
||||
|
|
|
|||
BIN
php/app/webroot/pdf/CMC Invoice.pdf
Normal file
BIN
php/app/webroot/pdf/CMC Invoice.pdf
Normal file
Binary file not shown.
Binary file not shown.
BIN
php/app/webroot/pdf/CMC_terms_and_conditions2006_A4.pdf
Normal file
BIN
php/app/webroot/pdf/CMC_terms_and_conditions2006_A4.pdf
Normal file
Binary file not shown.
Loading…
Reference in a new issue