diff --git a/Dockerfile.local.go b/Dockerfile.local.go index 2c37cd43..468cb3e8 100644 --- a/Dockerfile.local.go +++ b/Dockerfile.local.go @@ -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 diff --git a/Dockerfile.prod.go b/Dockerfile.prod.go index b3f8fd84..9f334d82 100644 --- a/Dockerfile.prod.go +++ b/Dockerfile.prod.go @@ -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 diff --git a/Dockerfile.stg.go b/Dockerfile.stg.go index e34d4f22..6d185875 100644 --- a/Dockerfile.stg.go +++ b/Dockerfile.stg.go @@ -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 diff --git a/go/cmd/server/main.go b/go/cmd/server/main.go index 7d98c1ee..64495b0f 100644 --- a/go/cmd/server/main.go +++ b/go/cmd/server/main.go @@ -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") diff --git a/go/internal/cmc/handlers/pdf_api.go b/go/internal/cmc/handlers/pdf_api.go index 30c74b4b..adf25b08 100644 --- a/go/internal/cmc/handlers/pdf_api.go +++ b/go/internal/cmc/handlers/pdf_api.go @@ -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
+func escapeToHTML(s string) string { + s = html.EscapeString(s) + s = strings.ReplaceAll(s, "\n", "
") + 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 diff --git a/go/internal/cmc/pdf/description_formatter.go b/go/internal/cmc/pdf/description_formatter.go new file mode 100644 index 00000000..5b75613f --- /dev/null +++ b/go/internal/cmc/pdf/description_formatter.go @@ -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 +} diff --git a/go/internal/cmc/pdf/description_formatter_test.go b/go/internal/cmc/pdf/description_formatter_test.go new file mode 100644 index 00000000..891c8ff8 --- /dev/null +++ b/go/internal/cmc/pdf/description_formatter_test.go @@ -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, "Item Code:") { + t.Error("Item Code should be bolded") + } + + if !strings.Contains(output, "Item Description:") { + t.Error("Item Description should be bolded") + } + + // Check that list items are in
  1. tags + if !strings.Contains(output, "
      ") { + t.Error("Ordered list should have
        tag") + } + + if !strings.Contains(output, "
      1. -4 deg C
      2. ") { + t.Error("List item 1 not formatted correctly") + } + + // Check that italic patterns are applied + if !strings.Contains(output, "See attached EC Conformity Declaration for the SE Sensor") { + 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, "") + strongCloses := strings.Count(output, "") + if strongOpens != strongCloses { + t.Errorf("Unbalanced tags: %d opens, %d closes", strongOpens, strongCloses) + } + + emOpens := strings.Count(output, "") + emCloses := strings.Count(output, "") + if emOpens != emCloses { + t.Errorf("Unbalanced tags: %d opens, %d closes", emOpens, emCloses) + } + + olOpens := strings.Count(output, "
          ") + olCloses := strings.Count(output, "
        ") + if olOpens != olCloses { + t.Errorf("Unbalanced
          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", "Item Code:"}, + {"Type: SE - To control", "Type:"}, + {"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) + } + } +} diff --git a/go/internal/cmc/pdf/generator.go b/go/internal/cmc/pdf/generator.go index ae50c8c0..06f1e0cf 100644 --- a/go/internal/cmc/pdf/generator.go +++ b/go/internal/cmc/pdf/generator.go @@ -1,12 +1,467 @@ package pdf import ( + "context" "fmt" + "os" "path/filepath" + "regexp" + "strconv" + "strings" + "time" + "github.com/chromedp/chromedp" "github.com/jung-kurt/gofpdf" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" ) +// TextSegment represents a text segment with formatting information +type TextSegment struct { + Text string + Bold bool + Italic bool +} + +// formatCurrency formats a float as currency with thousand separators +func formatCurrency(symbol string, amount float64) string { + // Format with 2 decimal places + amountStr := strconv.FormatFloat(amount, 'f', 2, 64) + // Split into integer and decimal parts + parts := strings.Split(amountStr, ".") + intPart := parts[0] + decPart := "." + parts[1] + + // Add comma separators to integer part + var result strings.Builder + for i, ch := range intPart { + if i > 0 && (len(intPart)-i)%3 == 0 { + result.WriteString(",") + } + result.WriteRune(ch) + } + + return symbol + result.String() + decPart +} + +// renderHTMLToImage renders an HTML fragment to a PNG image using chromedp +func renderHTMLToImage(htmlContent string, width int) (string, float64, error) { + // Wrap HTML fragment in a document + fullHTML := fmt.Sprintf(` + + + + + +%s +`, width, htmlContent) + + // Create temp file for screenshot + tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("pdf_desc_%d.png", time.Now().UnixNano())) + + // Create chromedp allocator with headless options + 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), + chromedp.Flag("disable-software-rasterizer", true), + ) + + allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) + defer cancel() + + // Create chromedp context + ctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() + + // Set timeout + ctx, cancel = context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + var height int64 + var buf []byte + + // Run chromedp tasks + err := chromedp.Run(ctx, + chromedp.Navigate("about:blank"), + chromedp.ActionFunc(func(ctx context.Context) error { + return chromedp.Run(ctx, + chromedp.Evaluate(fmt.Sprintf(`document.write(%q)`, fullHTML), nil), + ) + }), + chromedp.Sleep(100*time.Millisecond), // Wait for render + chromedp.Evaluate(`document.body.scrollHeight`, &height), + chromedp.CaptureScreenshot(&buf), + ) + + if err != nil { + return "", 0, fmt.Errorf("chromedp error: %w", err) + } + + // Write screenshot to file + if err := os.WriteFile(tmpFile, buf, 0644); err != nil { + return "", 0, err + } + + // Convert height from pixels to mm (assuming 96 DPI) + heightMM := float64(height) * 25.4 / 96.0 + + return tmpFile, heightMM, nil +} + +// parseHTMLToSegments parses HTML using the DOM and extracts text segments with formatting +func parseHTMLToSegments(htmlStr string) []TextSegment { + segments := []TextSegment{} + + // Parse HTML into a DOM tree + doc, err := html.Parse(strings.NewReader(htmlStr)) + if err != nil { + // Fallback: treat as plain text if parsing fails + return []TextSegment{{Text: htmlStr, Bold: false, Italic: false}} + } + + // Traverse the DOM tree and extract segments + var traverse func(*html.Node, bool, bool) + traverse = func(n *html.Node, bold, italic bool) { + if n == nil { + return + } + + switch n.Type { + case html.TextNode: + text := strings.TrimSpace(n.Data) + if text != "" { + // Decode HTML entities + text = decodeEntities(text) + segments = append(segments, TextSegment{ + Text: text, + Bold: bold, + Italic: italic, + }) + } + + case html.ElementNode: + // Determine if this element adds bold or italic + newBold := bold + newItalic := italic + addLineBreak := false + + switch n.DataAtom { + case atom.Strong, atom.B: + newBold = true + case atom.Em, atom.I: + newItalic = true + case atom.Br: + segments = append(segments, TextSegment{Text: "\n", Bold: bold, Italic: italic}) + return + case atom.P, atom.Div: + // Add line break after paragraph/div + addLineBreak = true + case atom.Li: + // Add bullet point for list items + segments = append(segments, TextSegment{Text: "• ", Bold: false, Italic: false}) + } + + // Recursively traverse children + for c := n.FirstChild; c != nil; c = c.NextSibling { + traverse(c, newBold, newItalic) + } + + // Add line break after block elements + if addLineBreak { + segments = append(segments, TextSegment{Text: "\n", Bold: false, Italic: false}) + } + } + } + + traverse(doc, false, false) + + // Clean up segments + cleanedSegments := []TextSegment{} + for _, seg := range segments { + // Collapse multiple newlines + text := seg.Text + for strings.Contains(text, "\n\n\n") { + text = strings.ReplaceAll(text, "\n\n\n", "\n\n") + } + + // Trim leading/trailing whitespace (but preserve newlines) + text = strings.TrimRight(text, " \t") + text = strings.TrimLeft(text, " \t") + + if text != "" { + cleanedSegments = append(cleanedSegments, TextSegment{ + Text: text, + Bold: seg.Bold, + Italic: seg.Italic, + }) + } + } + + return cleanedSegments +} + +// cleanHTMLLineBreaks converts HTML line breaks to newlines +func cleanHTMLLineBreaks(text string) string { + text = strings.ReplaceAll(text, "
          ", "\n") + text = strings.ReplaceAll(text, "
          ", "\n") + text = strings.ReplaceAll(text, "
          ", "\n") + return text +} + +// decodeEntities decodes HTML entities to their character equivalents +func decodeEntities(text string) string { + // Handle common HTML entities + text = strings.ReplaceAll(text, " ", " ") + text = strings.ReplaceAll(text, "&", "&") + text = strings.ReplaceAll(text, "<", "<") + text = strings.ReplaceAll(text, ">", ">") + text = strings.ReplaceAll(text, """, "\"") + text = strings.ReplaceAll(text, "'", "'") + text = strings.ReplaceAll(text, "'", "'") + text = strings.ReplaceAll(text, "®", "®") + text = strings.ReplaceAll(text, "®", "®") + text = strings.ReplaceAll(text, "™", "™") + text = strings.ReplaceAll(text, "™", "™") + text = strings.ReplaceAll(text, "©", "©") + text = strings.ReplaceAll(text, "©", "©") + text = strings.ReplaceAll(text, "°", "°") + text = strings.ReplaceAll(text, "°", "°") + text = strings.ReplaceAll(text, "Ëš", "°") + text = strings.ReplaceAll(text, "°", "°") + text = strings.ReplaceAll(text, "Â", "") + text = strings.ReplaceAll(text, "•", "•") + text = strings.ReplaceAll(text, "’", "'") + text = strings.ReplaceAll(text, "“", "\"") + text = strings.ReplaceAll(text, "â€", "\"") + return text +} + +// estimateHTMLHeight estimates the height needed for HTML content with formatting +func estimateHTMLHeight(pdf *gofpdf.Fpdf, html string, width float64, lineHeight float64) float64 { + // Parse to plain text to estimate line count + segments := parseHTMLToSegments(html) + + currentWidth := 0.0 + lineCount := 1.0 + lineSpacing := 3.1 + + currentStyle := "" + for _, segment := range segments { + style := "" + if segment.Bold { + style += "B" + } + if segment.Italic { + style += "I" + } + + if style != currentStyle { + pdf.SetFont("Helvetica", style, 9) + currentStyle = style + } + + // Handle newlines + if strings.Contains(segment.Text, "\n") { + parts := strings.Split(segment.Text, "\n") + for i, part := range parts { + if i > 0 { + lineCount++ + currentWidth = 0 + } + if part != "" { + words := strings.Fields(part) + for _, word := range words { + wordWidth := pdf.GetStringWidth(word + " ") + if currentWidth+wordWidth > width && currentWidth > 0 { + lineCount++ + currentWidth = wordWidth + } else { + currentWidth += wordWidth + } + } + } + } + } else { + words := strings.Fields(segment.Text) + for _, word := range words { + wordWidth := pdf.GetStringWidth(word + " ") + if currentWidth+wordWidth > width && currentWidth > 0 { + lineCount++ + currentWidth = wordWidth + } else { + currentWidth += wordWidth + } + } + } + } + + return lineCount * lineSpacing +} + +// renderHTMLWithFormatting renders HTML content with rich formatting in a MultiCell +// Returns the height actually used by the content +func renderHTMLWithFormatting(pdf *gofpdf.Fpdf, html string, width float64, lineHeight float64, align string) float64 { + segments := parseHTMLToSegments(html) + + startX := pdf.GetX() + currentX := startX + startY := pdf.GetY() + currentY := startY + maxWidth := width + lineSpacing := 3.1 // Proper line spacing to prevent overlapping + + for _, segment := range segments { + // Handle newlines + if strings.Contains(segment.Text, "\n") { + parts := strings.Split(segment.Text, "\n") + for i, part := range parts { + if i > 0 { + currentY += lineSpacing + currentX = startX + pdf.SetXY(currentX, currentY) + } + if part != "" { + // Set font style + style := "" + if segment.Bold { + style += "B" + } + if segment.Italic { + style += "I" + } + pdf.SetFont("Helvetica", style, 9) + + // Render text character by character for proper wrapping + for _, ch := range part { + charStr := string(ch) + charWidth := pdf.GetStringWidth(charStr) + if currentX+charWidth > startX+maxWidth { + currentY += lineSpacing + currentX = startX + pdf.SetXY(currentX, currentY) + } + pdf.CellFormat(charWidth, lineSpacing, charStr, "", 0, "L", false, 0, "") + currentX += charWidth + } + } + } + } else { + // Set font style + style := "" + if segment.Bold { + style += "B" + } + if segment.Italic { + style += "I" + } + pdf.SetFont("Helvetica", style, 9) + + // Handle word wrapping + words := strings.Fields(segment.Text) + for wi, word := range words { + // Add space after word except for last word + if wi > 0 { + word = " " + word + } + if wi < len(words)-1 { + word = word + " " + } else if wi == len(words)-1 && wi > 0 { + word = word + " " + } + + wordWidth := pdf.GetStringWidth(word) + if currentX+wordWidth > startX+maxWidth && currentX > startX { + currentY += lineSpacing + currentX = startX + pdf.SetXY(currentX, currentY) + // Remove leading space if we wrapped + word = strings.TrimLeft(word, " ") + wordWidth = pdf.GetStringWidth(word) + } + + pdf.CellFormat(wordWidth, lineSpacing, word, "", 0, "L", false, 0, "") + currentX += wordWidth + } + } + } + + // Set position to end of rendered content and return height used + pdf.SetXY(startX, currentY) + return currentY - startY + lineSpacing // Add one line height for proper spacing +} + +// renderHTMLContent renders HTML content with proper formatting support +// Returns a string suitable for MultiCell, with formatting preserved through segments +func renderHTMLContent(pdf *gofpdf.Fpdf, htmlStr string, width float64) string { + segments := parseHTMLToSegments(htmlStr) + + // Build formatted text by applying font changes inline + // This is a simplified approach - we'll just return plain text with newlines preserved + // The actual formatting (bold/italic) will be handled at render time + var result strings.Builder + + for _, seg := range segments { + result.WriteString(seg.Text) + // Add space after non-newline segments for proper word spacing + if !strings.HasSuffix(seg.Text, "\n") && !strings.HasSuffix(seg.Text, " ") { + result.WriteString(" ") + } + } + + text := strings.TrimSpace(result.String()) + + // Clean up multiple spaces and newlines + text = regexp.MustCompile(` +`).ReplaceAllString(text, " ") + text = regexp.MustCompile(`\n\n+`).ReplaceAllString(text, "\n") + text = regexp.MustCompile(`\n `).ReplaceAllString(text, "\n") + + return text +} + +// renderFormattedDescription renders HTML segments with proper formatting (bold/italic) within a cell +func (g *Generator) renderFormattedDescription(title string, htmlContent string, x, y, cellWidth, lineHeight float64) float64 { + fmt.Printf("=== renderFormattedDescription called: title=%s, html=%s ===\n", title, htmlContent) + + // Build full HTML with title + fullHTML := fmt.Sprintf("%s
          %s", title, htmlContent) + + // Convert cell width from mm to pixels (96 DPI) + widthPx := int(cellWidth * 96.0 / 25.4) + + fmt.Printf("=== Calling renderHTMLToImage with width=%d ===\n", widthPx) + + // Render HTML to image + imgPath, heightMM, err := renderHTMLToImage(fullHTML, widthPx) + if err != nil { + // No fallback - panic so we see the error + panic(fmt.Sprintf("chromedp render failed: %v", err)) + } + defer os.Remove(imgPath) // Clean up temp file + + fmt.Printf("=== Image rendered: path=%s, height=%.2fmm ===\n", imgPath, heightMM) + + // Embed image in PDF + g.pdf.Image(imgPath, x, y, cellWidth, heightMM, false, "", 0, "") + + return y + heightMM +} + // Generator handles PDF generation for documents type Generator struct { pdf *gofpdf.Fpdf @@ -37,85 +492,81 @@ func (g *Generator) AddPage() { // Page1Header adds the standard header for page 1 func (g *Generator) Page1Header() { - g.pdf.SetY(10) - - // Set text color to blue - g.pdf.SetTextColor(0, 0, 152) - - // Add logo if available (assuming logo is in static directory) + // Add logo at top left (above the line) logoPath := filepath.Join("static", "images", "CMC-Mobile-Logo.png") - // Try to add logo, but don't fail if it doesn't exist or isn't a proper image g.pdf.ImageOptions(logoPath, 10, 10, 0, 28, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "http://www.cmctechnologies.com.au") - // Company name + // Company name centered + g.pdf.SetTextColor(0, 0, 152) g.pdf.SetFont("Helvetica", "B", 30) - g.pdf.SetX(40) - g.pdf.CellFormat(0, 0, g.headerText, "", 1, "C", false, 0, "") + g.pdf.SetXY(10, 15) + g.pdf.CellFormat(190, 10, g.headerText, "", 1, "C", false, 0, "") - // Company details + // Company details centered with more spacing g.pdf.SetFont("Helvetica", "", 10) - g.pdf.SetY(22) - g.pdf.SetX(40) - g.pdf.CellFormat(0, 0, "PTY LIMITED ACN: 085 991 224 ABN: 47 085 991 224", "", 1, "C", false, 0, "") + g.pdf.SetXY(10, 26) + g.pdf.CellFormat(190, 5, "PTY LIMITED ACN: 085 991 224 ABN: 47 085 991 224", "", 1, "C", false, 0, "") + + // Add padding before the line + lineY := 34.0 // Draw horizontal line g.pdf.SetDrawColor(0, 0, 0) - g.pdf.Line(43, 24, 200, 24) + g.pdf.Line(10, lineY, 200, lineY) - // Contact details in table format - g.pdf.SetTextColor(0, 0, 0) - g.pdf.SetFont("Helvetica", "", 10) - - startY := 32.0 - labelX := 45.0 - valueX := 65.0 - addressX := 150.0 - lineHeight := 5.0 - - // Row 1: Phone - g.pdf.SetXY(labelX, startY) - g.pdf.Cell(20, lineHeight, "Phone:") - g.pdf.SetXY(valueX, startY) - g.pdf.Cell(55, lineHeight, "+61 2 9669 4000") - g.pdf.SetXY(addressX, startY) - g.pdf.Cell(52, lineHeight, "Unit 19, 77 Bourke Rd") - - // Row 2: Fax - g.pdf.SetXY(labelX, startY+lineHeight) - g.pdf.Cell(20, lineHeight, "Fax:") - g.pdf.SetXY(valueX, startY+lineHeight) - g.pdf.Cell(55, lineHeight, "+61 2 9669 4111") - g.pdf.SetXY(addressX, startY+lineHeight) - g.pdf.Cell(52, lineHeight, "Alexandria NSW 2015") - - // Row 3: Email - g.pdf.SetXY(labelX, startY+lineHeight*2) - g.pdf.Cell(20, lineHeight, "Email:") - g.pdf.SetXY(valueX, startY+lineHeight*2) - g.pdf.Cell(55, lineHeight, "sales@cmctechnologies.com.au") - g.pdf.SetXY(addressX, startY+lineHeight*2) - g.pdf.Cell(52, lineHeight, "AUSTRALIA") - - // Row 4: Web Site - g.pdf.SetXY(labelX, startY+lineHeight*3) - g.pdf.Cell(20, lineHeight, "Web Site:") - g.pdf.SetXY(valueX, startY+lineHeight*3) - g.pdf.Cell(55, lineHeight, "www.cmctechnologies.net.au") - - // Engineering text on left + // Engineering text on LEFT below the line, beneath the logo g.pdf.SetTextColor(0, 0, 152) - g.pdf.SetFont("Helvetica", "B", 10) - engineeringX := 10.0 - engineeringY := 37.0 - g.pdf.SetXY(engineeringX, engineeringY) - g.pdf.Cell(30, lineHeight, "Engineering &") - g.pdf.SetXY(engineeringX, engineeringY+lineHeight) - g.pdf.Cell(30, lineHeight, "Industrial") - g.pdf.SetXY(engineeringX, engineeringY+lineHeight*2) - g.pdf.Cell(30, lineHeight, "Instrumentation") + g.pdf.SetFont("Helvetica", "B", 9) + startY := lineY + 2 + lineHeight := 4.0 - // Reset text color to black for subsequent content + g.pdf.SetXY(10, startY) + g.pdf.Cell(50, lineHeight, "Engineering &") + g.pdf.SetXY(10, startY+lineHeight) + g.pdf.Cell(50, lineHeight, "Industrial") + g.pdf.SetXY(10, startY+lineHeight*2) + g.pdf.Cell(50, lineHeight, "Instrumentation") + + // Contact details on RIGHT side g.pdf.SetTextColor(0, 0, 0) + g.pdf.SetFont("Helvetica", "", 9) + contactX := 120.0 + + // Phone + g.pdf.SetXY(contactX, startY) + g.pdf.Cell(15, lineHeight, "Phone:") + g.pdf.SetXY(contactX+15, startY) + g.pdf.Cell(50, lineHeight, "+61 2 9669 4000") + + // Fax + g.pdf.SetXY(contactX, startY+lineHeight) + g.pdf.Cell(15, lineHeight, "Fax:") + g.pdf.SetXY(contactX+15, startY+lineHeight) + g.pdf.Cell(50, lineHeight, "+61 2 9669 4111") + + // Email + g.pdf.SetXY(contactX, startY+lineHeight*2) + g.pdf.Cell(15, lineHeight, "Email:") + g.pdf.SetXY(contactX+15, startY+lineHeight*2) + g.pdf.Cell(50, lineHeight, "sales@cmctechnologies.com.au") + + // Web Site + g.pdf.SetXY(contactX, startY+lineHeight*3) + g.pdf.Cell(15, lineHeight, "Web Site:") + g.pdf.SetXY(contactX+15, startY+lineHeight*3) + g.pdf.Cell(50, lineHeight, "www.cmctechnologies.net.au") + + // Address on far RIGHT + g.pdf.SetXY(contactX, startY+lineHeight*5) + g.pdf.Cell(50, lineHeight, "Unit 19, 77 Bourke Rd") + g.pdf.SetXY(contactX, startY+lineHeight*6) + g.pdf.Cell(50, lineHeight, "Alexandria NSW 2015") + g.pdf.SetXY(contactX, startY+lineHeight*7) + g.pdf.Cell(50, lineHeight, "AUSTRALIA") + + // Reset text color and set position for content below + g.pdf.SetTextColor(0, 0, 0) + g.pdf.SetY(68) } // Page1Footer adds the standard footer @@ -278,11 +729,37 @@ func (g *Generator) AddLineItemsTable(items []LineItem, currencySymbol string, s g.pdf.SetFont("Helvetica", "", 9) } - g.pdf.CellFormat(itemWidth, 6, item.ItemNumber, "1", 0, "C", false, 0, "") - g.pdf.CellFormat(qtyWidth, 6, item.Quantity, "1", 0, "C", false, 0, "") - g.pdf.CellFormat(descWidth, 6, item.Title, "1", 0, "L", false, 0, "") - g.pdf.CellFormat(unitWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, item.UnitPrice), "1", 0, "R", false, 0, "") - g.pdf.CellFormat(totalWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, item.TotalPrice), "1", 1, "R", false, 0, "") + currentY := g.pdf.GetY() + + // Render the description with formatting using chromedp + descEndY := g.renderFormattedDescription(item.Title, item.Description, 40, currentY, descWidth, 5) + rowHeight := descEndY - currentY + if rowHeight < 6.0 { + rowHeight = 6.0 + } + + // Item number at X=10, width=15 - stretch to match description height + g.pdf.SetXY(10, currentY) + g.pdf.CellFormat(itemWidth, rowHeight, item.ItemNumber, "1", 0, "C", false, 0, "") + + // Quantity at X=25 (10+15), width=15 - stretch to match description height + g.pdf.SetXY(25, currentY) + g.pdf.CellFormat(qtyWidth, rowHeight, item.Quantity, "1", 0, "C", false, 0, "") + + // Description cell with border (drawing border only since we render content manually) + g.pdf.SetXY(40, currentY) + g.pdf.Rect(40, currentY, descWidth, rowHeight, "") + + // Unit Price at X=140 (10+15+15+100), width=30 - stretch to match description height + g.pdf.SetXY(140, currentY) + g.pdf.CellFormat(unitWidth, rowHeight, fmt.Sprintf("%s%.2f", currencySymbol, item.UnitPrice), "1", 0, "R", false, 0, "") + + // Total Price at X=170 (10+15+15+100+30), width=30 - stretch to match description height + g.pdf.SetXY(170, currentY) + g.pdf.CellFormat(totalWidth, rowHeight, fmt.Sprintf("%s%.2f", currencySymbol, item.TotalPrice), "1", 0, "R", false, 0, "") + + // Move Y position for next row + g.pdf.SetY(currentY + rowHeight) subtotal += item.TotalPrice } @@ -376,7 +853,7 @@ func (g *Generator) AddQuoteDetailsTable(data *QuotePDFData) { g.pdf.SetFont("Helvetica", "B", 8) g.pdf.CellFormat(colWidth, 4, "Payment Terms:", "", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 8) - g.pdf.CellFormat(0, 4, data.PaymentTerms, "R", 1, "L", false, 0, "") + g.pdf.MultiCell(0, 4, cleanHTMLLineBreaks(data.PaymentTerms), "R", "L", false) } // Row 2: Delivery Point | Days Valid @@ -402,107 +879,82 @@ func (g *Generator) AddQuoteDetailsTable(data *QuotePDFData) { func (g *Generator) AddInvoiceAddressBoxes(data *InvoicePDFData) { g.pdf.SetFont("Helvetica", "", 9) - // Top row with headers and invoice details - x := g.pdf.GetX() + // Start position + x := 10.0 y := g.pdf.GetY() + boxHeight := 30.0 - // Left: Sold To header + // Left box: Sold To / Invoice Address header g.pdf.SetXY(x, y) g.pdf.SetFillColor(242, 242, 242) + g.pdf.SetFont("Helvetica", "B", 9) g.pdf.CellFormat(58, 5, "Sold To / Invoice Address:", "1", 0, "L", true, 0, "") - // Middle: Delivery Address header + // Middle box: Delivery Address header g.pdf.CellFormat(58, 5, "Delivery Address:", "1", 0, "L", true, 0, "") - // Right: Invoice details (starts here, spans 2 rows) + // Right side: Invoice number (only on first line) g.pdf.SetXY(x+122, y) g.pdf.SetFont("Helvetica", "U", 11) g.pdf.CellFormat(0, 5, fmt.Sprintf("CMC INVOICE#: %s", data.Invoice.Title), "", 1, "L", false, 0, "") - // Second row with address content + // Second row - address content y += 5 g.pdf.SetXY(x, y) - g.pdf.SetFont("Helvetica", "", 9) + g.pdf.SetFont("Helvetica", "", 8) - // Sold To content - billToHeight := 30.0 - g.pdf.MultiCell(58, 4, data.BillTo, "1", "L", false) + // Sold To content with borders + g.pdf.MultiCell(58, 4, cleanHTMLLineBreaks(data.BillTo), "1", "L", false) soldToEndY := g.pdf.GetY() - // Ship To content + // Ship To content - positioned next to Sold To g.pdf.SetXY(x+58, y) - g.pdf.MultiCell(58, 4, data.ShipTo, "1", "L", false) + g.pdf.MultiCell(58, 4, cleanHTMLLineBreaks(data.ShipTo), "1", "L", false) shipToEndY := g.pdf.GetY() - // Ensure both cells have same height + // Calculate max end Y for right column positioning maxEndY := soldToEndY if shipToEndY > maxEndY { maxEndY = shipToEndY } - if maxEndY < y+billToHeight { - maxEndY = y + billToHeight + if maxEndY < y+boxHeight { + maxEndY = y + boxHeight } - // Add remaining invoice details on the right + // Right column: Date and Page on first two lines g.pdf.SetXY(x+122, y) - g.pdf.SetFont("Helvetica", "", 9) + g.pdf.SetFont("Helvetica", "", 8) g.pdf.Cell(0, 4, fmt.Sprintf("Date: %s", data.IssueDateString)) + g.pdf.SetXY(x+122, y+4) g.pdf.Cell(0, 4, "Page: 1 of {nb}") - g.pdf.Ln(2) - g.pdf.SetFont("Helvetica", "U", 9) - g.pdf.CellFormat(0, 4, "MAKE PAYMENT TO:", "", 1, "L", false, 0, "") - g.pdf.SetFont("Helvetica", "", 8) + // Right column: MAKE PAYMENT TO section + g.pdf.SetXY(x+122, y+10) + g.pdf.SetFont("Helvetica", "B", 8) + g.pdf.Cell(0, 4, "MAKE PAYMENT TO:") - // Bank details based on currency in table format - bankY := g.pdf.GetY() + // Bank details + g.pdf.SetFont("Helvetica", "", 7) + bankY := y + 14 bankLineHeight := 3.0 + switch data.CurrencyCode { case "EUR": - g.pdf.SetXY(10, bankY) - g.pdf.Cell(0, bankLineHeight, "Account Name: CMC Technologies Pty Ltd") - g.pdf.SetXY(10, bankY+bankLineHeight) - g.pdf.Cell(0, bankLineHeight, "Account Number/IBAN: 06200015682004") - g.pdf.SetXY(10, bankY+bankLineHeight*2) - g.pdf.Cell(0, bankLineHeight, "Branch code: 06200") - g.pdf.SetXY(10, bankY+bankLineHeight*3) - g.pdf.Cell(0, bankLineHeight, "SWIFT Code/BIC: CTBAAU2S") - g.pdf.SetY(bankY + bankLineHeight*4) + g.pdf.SetXY(x+122, bankY) + g.pdf.MultiCell(80, bankLineHeight, "Account Name: CMC Technologies Pty Ltd\nAccount Number/IBAN: 06200015682004\nBranch code: 06200\nSWIFT Code/BIC: CTBAAU2S", "", "L", false) case "GBP": - g.pdf.SetXY(10, bankY) - g.pdf.Cell(0, bankLineHeight, "Account Name: CMC Technologies Pty Ltd") - g.pdf.SetXY(10, bankY+bankLineHeight) - g.pdf.Cell(0, bankLineHeight, "Account Number/IBAN: 06200015642694") - g.pdf.SetXY(10, bankY+bankLineHeight*2) - g.pdf.Cell(0, bankLineHeight, "Branch code: 06200") - g.pdf.SetXY(10, bankY+bankLineHeight*3) - g.pdf.Cell(0, bankLineHeight, "SWIFT Code/BIC: CTBAAU2S") - g.pdf.SetY(bankY + bankLineHeight*4) + g.pdf.SetXY(x+122, bankY) + g.pdf.MultiCell(80, bankLineHeight, "Account Name: CMC Technologies Pty Ltd\nAccount Number/IBAN: 06200015642694\nBranch code: 06200\nSWIFT Code/BIC: CTBAAU2S", "", "L", false) case "USD": - g.pdf.SetXY(10, bankY) - g.pdf.Cell(0, bankLineHeight, "Account Name: CMC Technologies Pty Ltd") - g.pdf.SetXY(10, bankY+bankLineHeight) - g.pdf.Cell(0, bankLineHeight, "Account Number/IBAN: 06200015681984") - g.pdf.SetXY(10, bankY+bankLineHeight*2) - g.pdf.Cell(0, bankLineHeight, "Branch code: 06200") - g.pdf.SetXY(10, bankY+bankLineHeight*3) - g.pdf.Cell(0, bankLineHeight, "SWIFT Code/BIC: CTBAAU2S") - g.pdf.SetY(bankY + bankLineHeight*4) + g.pdf.SetXY(x+122, bankY) + g.pdf.MultiCell(80, bankLineHeight, "Account Name: CMC Technologies Pty Ltd\nAccount Number/IBAN: 06200015681984\nBranch code: 06200\nSWIFT Code/BIC: CTBAAU2S", "", "L", false) default: // AUD and others - g.pdf.SetXY(10, bankY) - g.pdf.Cell(0, bankLineHeight, "Account Name: CMC Technologies Pty Ltd") - g.pdf.SetXY(10, bankY+bankLineHeight) - g.pdf.Cell(0, bankLineHeight, "Bank Number BSB#: 062-458") - g.pdf.SetXY(10, bankY+bankLineHeight*2) - g.pdf.Cell(0, bankLineHeight, "Account Number: 10067982") - g.pdf.SetXY(10, bankY+bankLineHeight*3) - g.pdf.Cell(0, bankLineHeight, "SWIFT Code: CTBAAU2S") - g.pdf.SetXY(10, bankY+bankLineHeight*4) - g.pdf.Cell(0, bankLineHeight, "IBAN: 06245810067982") - g.pdf.SetY(bankY + bankLineHeight*5) + g.pdf.SetXY(x+122, bankY) + g.pdf.MultiCell(80, bankLineHeight, "Account Name: CMC Technologies Pty Ltd\nBSB: 062-458\nAccount Number: 10067982\nSWIFT Code: CTBAAU2S\nIBAN: 06245810067982", "", "L", false) } + // Position for next section g.pdf.SetY(maxEndY + 2) } @@ -521,11 +973,30 @@ func (g *Generator) AddInvoiceDetailsTable(data *InvoicePDFData) { // Data row g.pdf.SetFont("Helvetica", "", 9) - g.pdf.CellFormat(colWidth, 5, data.CustomerOrderNumber, "1", 0, "C", false, 0, "") - g.pdf.CellFormat(colWidth, 5, data.JobTitle, "1", 0, "C", false, 0, "") - g.pdf.CellFormat(colWidth, 5, data.FOB, "1", 0, "C", false, 0, "") - g.pdf.CellFormat(colWidth, 5, data.PaymentTerms, "1", 0, "C", false, 0, "") - g.pdf.CellFormat(colWidth, 5, data.CustomerABN, "1", 1, "C", false, 0, "") + + // Record starting Y for the row + rowStartY := g.pdf.GetY() + + // Calculate payment terms height first + paymentTermsLines := g.pdf.SplitLines([]byte(data.PaymentTerms), colWidth-2) + paymentTermsHeight := float64(len(paymentTermsLines))*3.0 + 2.0 // Add padding + if paymentTermsHeight < 5 { + paymentTermsHeight = 5 + } + + // Draw all cells with the calculated height + g.pdf.CellFormat(colWidth, paymentTermsHeight, data.CustomerOrderNumber, "1", 0, "C", false, 0, "") + g.pdf.CellFormat(colWidth, paymentTermsHeight, data.JobTitle, "1", 0, "C", false, 0, "") + g.pdf.CellFormat(colWidth, paymentTermsHeight, data.FOB, "1", 0, "C", false, 0, "") + + // Payment terms with wrapping + paymentTermsX := g.pdf.GetX() + g.pdf.CellFormat(colWidth, paymentTermsHeight, "", "1", 0, "C", false, 0, "") + g.pdf.SetXY(paymentTermsX+1, rowStartY+0.5) + g.pdf.MultiCell(colWidth-2, 3, data.PaymentTerms, "", "C", false) + + g.pdf.SetXY(paymentTermsX+colWidth, rowStartY) + g.pdf.CellFormat(colWidth, paymentTermsHeight, data.CustomerABN, "1", 1, "C", false, 0, "") } // AddInvoiceLineItemsHeader adds just the header row for line items table @@ -557,8 +1028,16 @@ func (g *Generator) AddInvoiceLineItemsContent(data *InvoicePDFData) { // Line items for i, item := range data.LineItems { - // Check if we need a new page (leave room for footer/totals) - if g.pdf.GetY() > 240 && i > 0 { + // Build description HTML with title and description + descriptionHTML := "" + item.Title + "" + if item.Description != "" { + // Use raw description - PHP should format it before sending + descriptionHTML += "
          " + item.Description + } + + // Check if row will fit on current page + estimatedHeight := estimateHTMLHeight(g.pdf, descriptionHTML, 98, 4) + if g.pdf.GetY()+estimatedHeight > 240 && i > 0 { g.AddPage() if !firstPageBreak { g.pdf.SetFont("Helvetica", "B", 12) @@ -580,27 +1059,158 @@ func (g *Generator) AddInvoiceLineItemsContent(data *InvoicePDFData) { unitPrice := 0.0 totalPrice := 0.0 + netUnitPrice := 0.0 + netTotalPrice := 0.0 + discountPercent := 0.0 + discountAmountUnit := 0.0 + discountAmountTotal := 0.0 + unitPriceStr := "" + totalPriceStr := "" - if item.GrossUnitPrice.Valid { + if item.GrossUnitPrice.Valid && item.GrossUnitPrice.String != "" { fmt.Sscanf(item.GrossUnitPrice.String, "%f", &unitPrice) } - if item.GrossPrice.Valid { + if item.GrossPrice.Valid && item.GrossPrice.String != "" { fmt.Sscanf(item.GrossPrice.String, "%f", &totalPrice) } + if item.NetUnitPrice.Valid && item.NetUnitPrice.String != "" { + fmt.Sscanf(item.NetUnitPrice.String, "%f", &netUnitPrice) + } + if item.NetPrice.Valid && item.NetPrice.String != "" { + fmt.Sscanf(item.NetPrice.String, "%f", &netTotalPrice) + } + if item.DiscountPercent.Valid && item.DiscountPercent.String != "" { + fmt.Sscanf(item.DiscountPercent.String, "%f", &discountPercent) + } + if item.DiscountAmountUnit.Valid && item.DiscountAmountUnit.String != "" { + fmt.Sscanf(item.DiscountAmountUnit.String, "%f", &discountAmountUnit) + } + if item.DiscountAmountTotal.Valid && item.DiscountAmountTotal.String != "" { + fmt.Sscanf(item.DiscountAmountTotal.String, "%f", &discountAmountTotal) + } - g.pdf.CellFormat(15, 5, item.ItemNumber, "1", 0, "C", false, 0, "") - g.pdf.CellFormat(15, 5, item.Quantity, "1", 0, "C", false, 0, "") - g.pdf.CellFormat(100, 5, item.Title, "1", 0, "L", false, 0, "") - g.pdf.CellFormat(30, 5, fmt.Sprintf("%s%.2f", data.CurrencySymbol, unitPrice), "1", 0, "C", false, 0, "") - g.pdf.CellFormat(30, 5, fmt.Sprintf("%s%.2f", data.CurrencySymbol, totalPrice), "1", 1, "C", false, 0, "") + // Calculate net prices if not provided + if netUnitPrice == 0 && unitPrice > 0 { + netUnitPrice = unitPrice - discountAmountUnit + } + if netTotalPrice == 0 && totalPrice > 0 { + netTotalPrice = totalPrice - discountAmountTotal + } + + // Build unit price string with discount formatting + if discountPercent > 0 || discountAmountUnit > 0 { + unitPriceStr = fmt.Sprintf("%s\nless\n%.2f%%\ndiscount*\n(-%s)\n=\n%s", + formatCurrency(data.CurrencySymbol, unitPrice), + discountPercent, + formatCurrency("", discountAmountUnit), + formatCurrency(data.CurrencySymbol, netUnitPrice)) + } else { + unitPriceStr = formatCurrency(data.CurrencySymbol, unitPrice) + } + + // Build total price string with discount formatting + if discountPercent > 0 || discountAmountTotal > 0 { + totalPriceStr = fmt.Sprintf("%s\nless\n%.2f%%\ndiscount*\n(-%s)\n=\n%s", + formatCurrency(data.CurrencySymbol, totalPrice), + discountPercent, + formatCurrency("", discountAmountTotal), + formatCurrency(data.CurrencySymbol, netTotalPrice)) + } else { + totalPriceStr = formatCurrency(data.CurrencySymbol, totalPrice) + } + + // Record starting Y position + startY := g.pdf.GetY() + + // Calculate heights for all columns WITHOUT rendering + // Description height - count lines in HTML + plainDesc := descriptionHTML + re := regexp.MustCompile(`<[^>]*>`) + plainDesc = re.ReplaceAllString(plainDesc, "") + descLines := strings.Count(plainDesc, "\n") + 1 + if descLines < 1 { + descLines = 1 + } + descriptionHeight := float64(descLines) * 3.1 + + // Price columns heights + unitPriceLines := g.pdf.SplitLines([]byte(unitPriceStr), 28) + unitPriceTextHeight := float64(len(unitPriceLines))*3.1 + 0.5 + + totalPriceLines := g.pdf.SplitLines([]byte(totalPriceStr), 28) + totalPriceTextHeight := float64(len(totalPriceLines))*3.1 + 0.5 + + // Use the maximum height from all columns + rowHeight := descriptionHeight + 1.5 // Add minimal padding to description + if unitPriceTextHeight > rowHeight { + rowHeight = unitPriceTextHeight + } + if totalPriceTextHeight > rowHeight { + rowHeight = totalPriceTextHeight + } + if rowHeight < 5 { + rowHeight = 5 + } + + // Now draw all cells with borders at the correct row height + // Draw item number at X=10, width=15 + g.pdf.SetXY(10, startY) + g.pdf.CellFormat(15, rowHeight, item.ItemNumber, "1", 0, "C", false, 0, "") + + // Draw quantity at X=25 (10+15), width=15 + g.pdf.SetXY(25, startY) + g.pdf.CellFormat(15, rowHeight, item.Quantity, "1", 0, "C", false, 0, "") + + // Draw description border at X=40, width=100 + g.pdf.SetXY(40, startY) + g.pdf.CellFormat(100, rowHeight, "", "1", 0, "L", false, 0, "") + + // Render description using chromedp + g.pdf.SetXY(41, startY+1) + _ = g.renderFormattedDescription(item.Title, item.Description, 41, startY+1, 98, 3.1) + + // Reset to normal font + g.pdf.SetFont("Helvetica", "", 9) + + // Recalculate vertical centering offset for price text with new row height + unitPriceTextHeight = float64(len(unitPriceLines)) * 3.1 + unitPriceOffset := (rowHeight - unitPriceTextHeight) / 2 + if unitPriceOffset < 0.5 { + unitPriceOffset = 0.5 + } + + totalPriceTextHeight = float64(len(totalPriceLines)) * 3.1 + totalPriceOffset := (rowHeight - totalPriceTextHeight) / 2 + if totalPriceOffset < 0.5 { + totalPriceOffset = 0.5 + } + + // Draw unit price at X=140 (10+15+15+100), width=30 + g.pdf.SetXY(140, startY) + g.pdf.CellFormat(30, rowHeight, "", "1", 0, "C", false, 0, "") + g.pdf.SetXY(141, startY+unitPriceOffset) + g.pdf.SetFont("Helvetica", "", 9) + g.pdf.MultiCell(28, 3.1, unitPriceStr, "", "C", false) + + // Draw total price at X=170 (10+15+15+100+30), width=30 + g.pdf.SetXY(170, startY) + g.pdf.CellFormat(30, rowHeight, "", "1", 0, "C", false, 0, "") + g.pdf.SetXY(171, startY+totalPriceOffset) + g.pdf.SetFont("Helvetica", "", 9) + g.pdf.MultiCell(28, 3.1, totalPriceStr, "", "C", false) + + // Position cursor for next row - CRITICAL to avoid overlapping cells + g.pdf.SetXY(10, startY+rowHeight) } // Freight details and totals g.pdf.SetFillColor(242, 242, 242) g.pdf.SetFont("Helvetica", "B", 9) - // Freight details (left side, spans 2 rows) + // Record starting Y for freight section y := g.pdf.GetY() + + // Freight details header (left side) g.pdf.CellFormat(130, 5, "FREIGHT DETAILS:", "1", 0, "L", true, 0, "") // Subtotal @@ -612,13 +1222,7 @@ func (g *Generator) AddInvoiceLineItemsContent(data *InvoicePDFData) { } g.pdf.CellFormat(30, 5, subtotalStr, "1", 1, "C", false, 0, "") - // Shipping details text (left side) - g.pdf.SetFont("Helvetica", "", 8) - g.pdf.SetXY(10, y+5) - g.pdf.MultiCell(130, 4, data.ShippingDetails, "1", "L", false) - shippingEndY := g.pdf.GetY() - - // GST row + // GST row (right side) g.pdf.SetXY(140, y+5) g.pdf.SetFont("Helvetica", "B", 9) g.pdf.SetFillColor(242, 242, 242) @@ -630,9 +1234,9 @@ func (g *Generator) AddInvoiceLineItemsContent(data *InvoicePDFData) { } g.pdf.CellFormat(30, 5, gstStr, "1", 1, "C", false, 0, "") - // Total row + // Total row (right side) + g.pdf.SetXY(140, y+10) g.pdf.SetFont("Helvetica", "B", 9) - g.pdf.CellFormat(130, 5, "", "", 0, "L", false, 0, "") g.pdf.SetFillColor(242, 242, 242) g.pdf.CellFormat(30, 5, "TOTAL DUE", "1", 0, "L", true, 0, "") g.pdf.SetFont("Helvetica", "", 9) @@ -642,10 +1246,23 @@ func (g *Generator) AddInvoiceLineItemsContent(data *InvoicePDFData) { } g.pdf.CellFormat(30, 5, totalStr, "1", 1, "C", false, 0, "") - // Make sure we're past the shipping details - if g.pdf.GetY() < shippingEndY { - g.pdf.SetY(shippingEndY) - } + // Calculate height of freight section (from y to bottom of total due) + totalDueEndY := y + 15 // Header (5) + GST (5) + Total (5) + freightDetailsHeight := 10.0 // Should span GST + Total rows + + // Draw shipping details box on left side with proper height + g.pdf.SetFont("Helvetica", "", 8) + g.pdf.SetXY(10, y+5) + + // Draw the border box first + g.pdf.CellFormat(130, freightDetailsHeight, "", "1", 0, "L", false, 0, "") + + // Then draw the text inside without border + g.pdf.SetXY(11, y+6) // Slight offset for padding + g.pdf.MultiCell(128, 4, cleanHTMLLineBreaks(data.ShippingDetails), "", "L", false) + + // Make sure we're past the freight section + g.pdf.SetY(totalDueEndY) } // Save saves the PDF to a file @@ -664,9 +1281,13 @@ func (g *Generator) Save(filename string) error { // LineItem represents a line item for the PDF type LineItem struct { - ItemNumber string - Quantity string - Title string - UnitPrice float64 - TotalPrice float64 + ItemNumber string + Quantity string + Title string + Description string + UnitPrice float64 + TotalPrice float64 + DiscountPercent float64 + DiscountAmountUnit float64 + DiscountAmountTotal float64 } diff --git a/go/internal/cmc/pdf/html_generator.go b/go/internal/cmc/pdf/html_generator.go new file mode 100644 index 00000000..737892c7 --- /dev/null +++ b/go/internal/cmc/pdf/html_generator.go @@ -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 +} diff --git a/go/internal/cmc/pdf/html_generator.go.applied b/go/internal/cmc/pdf/html_generator.go.applied new file mode 100644 index 00000000..e69de29b diff --git a/go/internal/cmc/pdf/html_generator.go.backup b/go/internal/cmc/pdf/html_generator.go.backup new file mode 100644 index 00000000..e69de29b diff --git a/go/internal/cmc/pdf/html_generator.go.patch b/go/internal/cmc/pdf/html_generator.go.patch new file mode 100644 index 00000000..e69de29b diff --git a/go/internal/cmc/pdf/templates.go b/go/internal/cmc/pdf/templates.go index 6c5847ec..ef75e6ef 100644 --- a/go/internal/cmc/pdf/templates.go +++ b/go/internal/cmc/pdf/templates.go @@ -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 diff --git a/go/internal/cmc/pdf/templates/invoice.html b/go/internal/cmc/pdf/templates/invoice.html new file mode 100644 index 00000000..7ebb7cce --- /dev/null +++ b/go/internal/cmc/pdf/templates/invoice.html @@ -0,0 +1,441 @@ + + + + + {{if .InvoiceNumber}}{{.InvoiceNumber}}{{else}}CMC Invoice{{end}} + + + + +
          + +
          +
          +

          CMC TECHNOLOGIES

          +

          PTY LIMITED ACN: 085 991 224  ABN: 47 085 991 224

          +
          + +
          +
          + Phone: +61 2 9669 4000
          + Fax: +61 2 9669 4111
          + Email: sales@cmctechnologies.com.au
          + Web Site: www.cmctechnologies.net.au +
          +
          + Unit 19, 77 Bourke Rd
          + Alexandria NSW 2015
          + AUSTRALIA +
          +
          +
          +
          + + +
          +
          +
          +

          TAX INVOICE

          +
          +
          + {{if .InvoiceNumber}} +
          INVOICE# {{.InvoiceNumber}}
          + {{end}} + {{if .IssueDateString}} +
          Date: {{.IssueDateString}}
          + {{end}} + {{if .PageCount}} +
          Page: {{.CurrentPage}} of {{.PageCount}}
          + {{end}} +
          +
          + + +
          +
          +

          Sold To / Invoice Address:

          +
          {{.BillTo}}
          +
          +
          +

          Delivery Address:

          +
          {{.ShipTo}}
          +
          +
          + + + + + + + + + + + + + + + + + + + + + +
          CUSTOMER ORDER NOCMC JOB #INCOTERMS 2010PAYMENT TERMSCUSTOMER ABN
          {{.CustomerOrderNumber}}{{.JobTitle}}{{.FOB}}{{.PaymentTerms}}{{.CustomerABN}}
          + + +
          + Shown in {{.CurrencyCode}} +
          + + + + + + + + + + + + + + {{range .LineItems}} + + + + + + + + {{end}} + +
          DESCRIPTIONQTYUNIT PRICEDISCOUNTTOTAL
          {{.Title}}
          {{.Description}}
          {{.Quantity}}{{formatPrice .GrossUnitPrice}}{{formatDiscount .DiscountAmountTotal}}{{formatPrice .GrossPrice}}
          + + +
          +
          +

          MAKE PAYMENT TO:

          + + + + + + +
          Account Name:CMC Technologies Pty Ltd
          BSB:062-458
          Account Number:10067982
          SWIFT Code:CTBAAU2S
          IBAN:0624581006782
          +
          +
          + + + {{if .ShowGST}} + + {{end}} + +
          Subtotal:{{formatTotal .Subtotal}}
          GST (10%):{{formatTotal .GSTAmount}}
          TOTAL:{{formatTotal .Total}}
          +
          +
          + + diff --git a/php/app/vendors/description_formatter.php b/php/app/vendors/description_formatter.php new file mode 100644 index 00000000..a57fade3 --- /dev/null +++ b/php/app/vendors/description_formatter.php @@ -0,0 +1,214 @@ + 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('
          ', $result); + + // Clean up excessive line breaks + $html = preg_replace('/


          +/', '

          ', $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 = '
            '; + foreach ($items as $item) { + $html .= '
          1. ' . $item . '
          2. '; + } + $html .= '
          '; + + 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 = '' . htmlspecialchars($key, ENT_QUOTES, 'UTF-8') . ': ' . $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, '' . $escapedPattern . '', $line); + } + } + + return $line; + } +} + +?> diff --git a/php/app/views/documents/pdf_invoice.ctp b/php/app/views/documents/pdf_invoice.ctp index 4ddd6618..27cbb5f4 100755 --- a/php/app/views/documents/pdf_invoice.ctp +++ b/php/app/views/documents/pdf_invoice.ctp @@ -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'], diff --git a/php/app/webroot/pdf/CMC Invoice.pdf b/php/app/webroot/pdf/CMC Invoice.pdf new file mode 100644 index 00000000..2eb2c568 Binary files /dev/null and b/php/app/webroot/pdf/CMC Invoice.pdf differ diff --git a/php/app/webroot/pdf/CMCIN9387.pdf b/php/app/webroot/pdf/CMCIN9387.pdf index 9108a610..dae96709 100644 Binary files a/php/app/webroot/pdf/CMCIN9387.pdf and b/php/app/webroot/pdf/CMCIN9387.pdf differ diff --git a/php/app/webroot/pdf/CMC_terms_and_conditions2006_A4.pdf b/php/app/webroot/pdf/CMC_terms_and_conditions2006_A4.pdf new file mode 100644 index 00000000..bb44ac92 Binary files /dev/null and b/php/app/webroot/pdf/CMC_terms_and_conditions2006_A4.pdf differ