From e6b05a726d0c14a896669a5b6ef2c82fe139e700 Mon Sep 17 00:00:00 2001 From: Finley Ghosh Date: Thu, 22 Jan 2026 00:14:27 +1100 Subject: [PATCH] Migrating to new endpoints, removing tcpdf --- Dockerfile.prod.go | 2 +- Dockerfile.stg.go | 2 +- go/cmd/server/main.go | 16 +- go/internal/cmc/{pdf => documents}/counter.go | 0 .../generator.go.deprecated} | 0 go/internal/cmc/documents/html_generator.go | 461 +++++++++++++ go/internal/cmc/documents/html_types.go | 131 ++++ .../cmc/{pdf => documents}/invoice_builder.go | 8 +- go/internal/cmc/{pdf => documents}/merge.go | 0 .../cmc/{pdf => documents}/merge_test.go | 0 .../cmc/documents/order_ack_builder.go | 161 +++++ .../cmc/documents/packing_list_builder.go | 100 +++ .../cmc/{pdf => documents}/page_numbers.go | 0 .../cmc/documents/purchase_order_builder.go | 147 ++++ .../cmc/{pdf => documents}/quote_builder.go | 12 +- .../templates.go.deprecated} | 0 .../{pdf => documents}/templates/header.html | 0 .../{pdf => documents}/templates/invoice.html | 0 .../templates/order-acknowledgement.html | 316 +++++++++ .../cmc/documents/templates/packing-list.html | 216 ++++++ .../documents/templates/purchase-order.html | 286 ++++++++ .../{pdf => documents}/templates/quote.html | 0 go/internal/cmc/handlers/document.go | 248 +++---- .../cmc/handlers/documents/documents_api.go | 647 ++++++++++++++++++ .../handlers/documents/documents_api_test.go | 83 +++ go/internal/cmc/handlers/pdf_api.go | 12 +- go/internal/cmc/handlers/pdf_api_test.go | 83 --- go/internal/cmc/pdf/html_generator.go | 84 +++ php/app/vendors/pagecounter.php | 2 +- php/app/views/documents/pdf_invoice.ctp | 2 +- php/app/views/documents/pdf_orderack.ctp | 2 +- php/app/views/documents/pdf_packinglist.ctp | 2 +- php/app/views/documents/pdf_po.ctp | 2 +- php/app/views/documents/pdf_quote.ctp | 2 +- 34 files changed, 2792 insertions(+), 235 deletions(-) rename go/internal/cmc/{pdf => documents}/counter.go (100%) rename go/internal/cmc/{pdf/generator.go => documents/generator.go.deprecated} (100%) create mode 100644 go/internal/cmc/documents/html_generator.go create mode 100644 go/internal/cmc/documents/html_types.go rename go/internal/cmc/{pdf => documents}/invoice_builder.go (95%) rename go/internal/cmc/{pdf => documents}/merge.go (100%) rename go/internal/cmc/{pdf => documents}/merge_test.go (100%) create mode 100644 go/internal/cmc/documents/order_ack_builder.go create mode 100644 go/internal/cmc/documents/packing_list_builder.go rename go/internal/cmc/{pdf => documents}/page_numbers.go (100%) create mode 100644 go/internal/cmc/documents/purchase_order_builder.go rename go/internal/cmc/{pdf => documents}/quote_builder.go (93%) rename go/internal/cmc/{pdf/templates.go => documents/templates.go.deprecated} (100%) rename go/internal/cmc/{pdf => documents}/templates/header.html (100%) rename go/internal/cmc/{pdf => documents}/templates/invoice.html (100%) create mode 100644 go/internal/cmc/documents/templates/order-acknowledgement.html create mode 100644 go/internal/cmc/documents/templates/packing-list.html create mode 100644 go/internal/cmc/documents/templates/purchase-order.html rename go/internal/cmc/{pdf => documents}/templates/quote.html (100%) create mode 100644 go/internal/cmc/handlers/documents/documents_api.go create mode 100644 go/internal/cmc/handlers/documents/documents_api_test.go delete mode 100644 go/internal/cmc/handlers/pdf_api_test.go diff --git a/Dockerfile.prod.go b/Dockerfile.prod.go index 3bc627f4..2299f92c 100644 --- a/Dockerfile.prod.go +++ b/Dockerfile.prod.go @@ -22,7 +22,7 @@ WORKDIR /app COPY --from=builder /app/server . COPY --from=builder /app/vault . COPY go/templates ./templates -COPY go/internal/cmc/pdf/templates ./templates/pdf +COPY go/internal/cmc/documents/templates ./templates/pdf COPY go/static ./static COPY go/.env.example .env EXPOSE 8082 diff --git a/Dockerfile.stg.go b/Dockerfile.stg.go index dc4ab5bf..113d2f49 100644 --- a/Dockerfile.stg.go +++ b/Dockerfile.stg.go @@ -20,7 +20,7 @@ ENV CHROME_BIN=/usr/bin/chromium-browser \ WORKDIR /app COPY --from=builder /app/server . COPY go/templates ./templates -COPY go/internal/cmc/pdf/templates ./templates/pdf +COPY go/internal/cmc/documents/templates ./templates/pdf COPY go/static ./static COPY go/.env.example .env EXPOSE 8082 diff --git a/go/cmd/server/main.go b/go/cmd/server/main.go index 7d98c1ee..5121c2f6 100644 --- a/go/cmd/server/main.go +++ b/go/cmd/server/main.go @@ -10,8 +10,8 @@ import ( "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email" - "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/attachments" + "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/documents" quotes "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/quotes" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates" "github.com/go-co-op/gocron" @@ -82,13 +82,13 @@ func main() { goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET") goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE") - // 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-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") - goRouter.HandleFunc("/api/pdf/generate-orderack", handlers.GenerateOrderAckPDF).Methods("POST") - goRouter.HandleFunc("/api/pdf/count-pages", handlers.CountPages).Methods("POST") + // Document generation routes + goRouter.HandleFunc("/document/generate/invoice", documents.GenerateInvoicePDF).Methods("POST") + goRouter.HandleFunc("/document/generate/quote", documents.GenerateQuotePDF).Methods("POST") + goRouter.HandleFunc("/document/generate/purchase-order", documents.GeneratePurchaseOrderPDF).Methods("POST") + goRouter.HandleFunc("/document/generate/packing-list", documents.GeneratePackingListPDF).Methods("POST") + goRouter.HandleFunc("/document/generate/order-acknowledgement", documents.GenerateOrderAckPDF).Methods("POST") + goRouter.HandleFunc("/document/page-count", documents.CountPages).Methods("POST") // Serve generated PDFs pdfDir := os.Getenv("PDF_OUTPUT_DIR") diff --git a/go/internal/cmc/pdf/counter.go b/go/internal/cmc/documents/counter.go similarity index 100% rename from go/internal/cmc/pdf/counter.go rename to go/internal/cmc/documents/counter.go diff --git a/go/internal/cmc/pdf/generator.go b/go/internal/cmc/documents/generator.go.deprecated similarity index 100% rename from go/internal/cmc/pdf/generator.go rename to go/internal/cmc/documents/generator.go.deprecated diff --git a/go/internal/cmc/documents/html_generator.go b/go/internal/cmc/documents/html_generator.go new file mode 100644 index 00000000..25044037 --- /dev/null +++ b/go/internal/cmc/documents/html_generator.go @@ -0,0 +1,461 @@ +package pdf + +import ( + "context" + "fmt" + "io/ioutil" + "log" + "os" + "path/filepath" + "time" + + "github.com/chromedp/cdproto/page" + "github.com/chromedp/chromedp" + "github.com/pdfcpu/pdfcpu/pkg/api" +) + +// HTMLDocumentGenerator generates PDF documents from HTML templates using chromedp +type HTMLDocumentGenerator struct { + outputDir string +} + +// NewHTMLDocumentGenerator creates a new HTML-based document generator +func NewHTMLDocumentGenerator(outputDir string) *HTMLDocumentGenerator { + return &HTMLDocumentGenerator{ + outputDir: outputDir, + } +} + +// GenerateInvoicePDF creates a PDF invoice from HTML template +// Returns (filename, error) +func (g *HTMLDocumentGenerator) 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 without page count in HTML (will be added via pdfcpu after merge) + fmt.Println("=== HTML Generator: Second pass - regenerating final PDF ===") + html = g.BuildInvoiceHTML(data, 0, 0) + + 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) + fmt.Printf("=== HTML Generator: Invoice filename generation - invoiceNumber='%s', filenameBase='%s', final filename='%s' ===\n", invoiceNumber, filenameBase, 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, err + } + } + + // Add page numbers to final PDF + // TODO: Re-enable after fixing pdfcpu watermark API usage + // fmt.Println("=== HTML Generator: Adding page numbers to final PDF ===") + // if err := AddPageNumbers(pdfPath); err != nil { + // fmt.Printf("=== HTML Generator: Warning - could not add page numbers: %v\n", err) + // } + + return filename, nil +} + +// GenerateQuotePDF creates a PDF quote from HTML template +// Returns (filename, error) +func (g *HTMLDocumentGenerator) GenerateQuotePDF(data *QuotePDFData) (string, error) { + fmt.Println("=== HTML Generator: Starting quote 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.BuildQuoteHTML(data, 0, 0) + + fmt.Printf("=== HTML Generator: Generated %d bytes of HTML ===\n", len(html)) + + tempHTML := filepath.Join(g.outputDir, "temp_quote.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, "quote_logo.png")) // Clean up temp logo + + // Generate temp PDF + tempPDFPath := filepath.Join(g.outputDir, "temp_quote_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 quote + quotePageCount, err := g.getPageCount(tempPDFPath) + if err != nil { + fmt.Printf("Warning: Could not extract quote page count: %v\n", err) + quotePageCount = 1 + } + + // Check if T&C exists and merge to get total page count + totalPageCount := quotePageCount + termsPath := filepath.Join(g.outputDir, "CMC_terms_and_conditions2006_A4.pdf") + tempMergedPath := filepath.Join(g.outputDir, "temp_quote_merged_first_pass.pdf") + + 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 quote count: %v\n", err) + totalPageCount = quotePageCount + } else { + fmt.Printf("=== HTML Generator: Total pages (quote + 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 without page count in HTML (will be added via pdfcpu after merge) + fmt.Println("=== HTML Generator: Second pass - regenerating final PDF ===") + html = g.BuildQuoteHTML(data, 0, 0) + + 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 quote number + quoteNumber := "" + if data.Document != nil { + log.Printf("=== HTML Generator: Document not nil, CmcReference='%s'", data.Document.CmcReference) + quoteNumber = data.Document.CmcReference + if quoteNumber == "" { + // Strong fallback so we never emit a leading underscore filename + quoteNumber = fmt.Sprintf("Quote-%d", data.Document.ID) + } + if data.Document.Revision > 0 { + quoteNumber = fmt.Sprintf("%s_%d", quoteNumber, data.Document.Revision) + } + } else { + log.Printf("=== HTML Generator: Document is nil!") + } + log.Printf("=== HTML Generator: Quote number before fallback: '%s', Document ID: %d, Revision: %d", + quoteNumber, data.Document.ID, data.Document.Revision) + filenameBase := quoteNumber + if filenameBase == "" { + log.Printf("=== HTML Generator: Using fallback filename") + filenameBase = "CMC Quote" + } + log.Printf("=== HTML Generator: Final filename base: '%s'", filenameBase) + 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 quote without T&C.\n", err) + fmt.Printf("=== HTML Generator: Checking if pdfPath exists: %s\n", pdfPath) + if stat, err := os.Stat(pdfPath); err == nil { + fmt.Printf("=== HTML Generator: pdfPath exists, size=%d bytes\n", stat.Size()) + } + return filename, nil + } + fmt.Printf("=== HTML Generator: Merge succeeded, replacing original PDF\n") + // 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) + fmt.Printf("=== HTML Generator: tempMergedPath: %s\n", tempMergedPath) + fmt.Printf("=== HTML Generator: pdfPath: %s\n", pdfPath) + return filename, err + } + fmt.Printf("=== HTML Generator: Replaced PDF successfully\n") + + // Verify the final file exists + if stat, err := os.Stat(pdfPath); err == nil { + fmt.Printf("=== HTML Generator: Final PDF verified, size=%d bytes\n", stat.Size()) + } else { + fmt.Printf("=== HTML Generator: ERROR - Final PDF does not exist after merge: %v\n", err) + return filename, fmt.Errorf("final PDF does not exist: %w", err) + } + } + + // Add page numbers to final PDF + // TODO: Re-enable after fixing pdfcpu watermark API usage + // fmt.Println("=== HTML Generator: Adding page numbers to final PDF ===") + // if err := AddPageNumbers(pdfPath); err != nil { + // fmt.Printf("=== HTML Generator: Warning - could not add page numbers: %v\n", err) + // } + + return filename, nil +} + +// htmlToPDF converts an HTML file to PDF using chromedp +func (g *HTMLDocumentGenerator) 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 *HTMLDocumentGenerator) 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 +} + +// loadLogo loads the logo image and returns it as a relative path +func (g *HTMLDocumentGenerator) loadLogo(logoFileName string) 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, logoFileName) + 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 logoFileName +} + +// GeneratePurchaseOrderPDF creates a PDF purchase order from HTML template +// Returns (filename, error) +func (g *HTMLDocumentGenerator) GeneratePurchaseOrderPDF(data *PurchaseOrderPDFData) (string, error) { + fmt.Println("=== HTML Generator: Starting purchase order generation ===") + + html := g.BuildPurchaseOrderHTML(data, 0, 0) + tempHTML := filepath.Join(g.outputDir, "temp_po.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, "quote_logo.png")) + + poNumber := data.PurchaseOrder.Title + if poNumber == "" { + poNumber = fmt.Sprintf("PO-%d", data.PurchaseOrder.ID) + } + + filename := fmt.Sprintf("%s.pdf", poNumber) + pdfPath := filepath.Join(g.outputDir, filename) + + if err := g.htmlToPDF(tempHTML, pdfPath); err != nil { + return "", fmt.Errorf("failed to convert HTML to PDF: %w", err) + } + + return filename, nil +} + +// GeneratePackingListPDF creates a PDF packing list from HTML template +// Returns (filename, error) +func (g *HTMLDocumentGenerator) GeneratePackingListPDF(data *PackingListPDFData) (string, error) { + fmt.Println("=== HTML Generator: Starting packing list generation ===") + + html := g.BuildPackingListHTML(data, 0, 0) + tempHTML := filepath.Join(g.outputDir, "temp_packinglist.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, "quote_logo.png")) + + packingListNumber := data.PackingList.CmcReference + if packingListNumber == "" { + packingListNumber = fmt.Sprintf("PackingList-%d", data.PackingList.ID) + } + + filename := fmt.Sprintf("%s.pdf", packingListNumber) + pdfPath := filepath.Join(g.outputDir, filename) + + if err := g.htmlToPDF(tempHTML, pdfPath); err != nil { + return "", fmt.Errorf("failed to convert HTML to PDF: %w", err) + } + + return filename, nil +} + +// GenerateOrderAckPDF creates a PDF order acknowledgement from HTML template +// Returns (filename, error) +func (g *HTMLDocumentGenerator) GenerateOrderAckPDF(data *OrderAckPDFData) (string, error) { + fmt.Println("=== HTML Generator: Starting order acknowledgement generation ===") + + html := g.BuildOrderAckHTML(data, 0, 0) + tempHTML := filepath.Join(g.outputDir, "temp_orderack.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, "quote_logo.png")) + + orderAckNumber := data.OrderAcknowledgement.CmcReference + if orderAckNumber == "" { + orderAckNumber = fmt.Sprintf("OrderAck-%d", data.OrderAcknowledgement.ID) + } + + filename := fmt.Sprintf("%s.pdf", orderAckNumber) + pdfPath := filepath.Join(g.outputDir, filename) + + if err := g.htmlToPDF(tempHTML, pdfPath); err != nil { + return "", fmt.Errorf("failed to convert HTML to PDF: %w", err) + } + + return filename, nil +} diff --git a/go/internal/cmc/documents/html_types.go b/go/internal/cmc/documents/html_types.go new file mode 100644 index 00000000..64f88e6f --- /dev/null +++ b/go/internal/cmc/documents/html_types.go @@ -0,0 +1,131 @@ +package pdf + +import ( + "fmt" + "time" + + "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db" + "github.com/pdfcpu/pdfcpu/pkg/api" +) + +// QuotePDFData contains all data needed to generate a quote PDF +type QuotePDFData struct { + Document *db.Document + Quote interface{} // Quote specific data + Enquiry *db.Enquiry + Customer *db.Customer + EmailTo string + Attention string + User *db.GetUserRow + LineItems []db.GetLineItemsTableRow + Currency interface{} // Currency data + CurrencySymbol string + CurrencyCode string + ShowGST bool + CommercialComments string + DeliveryTime string + PaymentTerms string + DaysValid int + DeliveryPoint string + ExchangeRate string + CustomsDuty string + GSTPhrase string + SalesEngineer string + BillTo string + ShipTo string + IssueDateString string + Pages []string +} + +// InvoicePDFData contains all data needed to generate an invoice PDF +type InvoicePDFData struct { + Document *db.Document + Invoice *db.Invoice + Enquiry *db.Enquiry + Customer *db.Customer + Job interface{} // Job data + LineItems []db.GetLineItemsTableRow + Currency interface{} // Currency data + CurrencySymbol string + CurrencyCode string + ShowGST bool + ShipVia string + FOB string + IssueDate time.Time + IssueDateString string + EmailTo string + Attention string + FromName string + FromEmail string + YourReference string + BillTo string + ShipTo string + ShippingDetails string + CustomerOrderNumber string + JobTitle string + PaymentTerms string + CustomerABN string + Subtotal interface{} // Can be float or "TBA" + GSTAmount interface{} + Total interface{} +} + +// PurchaseOrderPDFData contains all data needed to generate a purchase order PDF +type PurchaseOrderPDFData struct { + Document *db.Document + PurchaseOrder *db.PurchaseOrder + Principle *db.Principle + LineItems []db.GetLineItemsTableRow + Currency interface{} // Currency data + CurrencySymbol string + CurrencyCode string + ShowGST bool + Subtotal float64 + GSTAmount float64 + Total float64 + IssueDateString string +} + +// PackingListPDFData for HTML template generation +type PackingListPDFData struct { + PackingList *db.Document + Customer *db.Customer + JobTitle string + IssueDateString string + CustomerOrderNumber string + ShipTo string + LineItems []db.GetLineItemsTableRow +} + +// OrderAckPDFData for HTML template generation +type OrderAckPDFData struct { + OrderAcknowledgement *db.Document + Customer *db.Customer + EmailTo string + Attention string + IssueDateString string + YourReference string + JobTitle string + BillTo string + ShipTo string + ShipVia string + FOB string + PaymentTerms string + CurrencyCode string + CurrencySymbol string + ShowGST bool + LineItems []db.GetLineItemsTableRow +} + +// MergePDFs merges multiple PDF files into one +func MergePDFs(invoicePath, termsPath, outputPath string) error { + // Merge the PDFs: invoice first, then terms + inFiles := []string{invoicePath, termsPath} + + err := api.MergeCreateFile(inFiles, outputPath, false, nil) + if err != nil { + return fmt.Errorf("failed to merge PDFs: %w", err) + } + + return nil +} diff --git a/go/internal/cmc/pdf/invoice_builder.go b/go/internal/cmc/documents/invoice_builder.go similarity index 95% rename from go/internal/cmc/pdf/invoice_builder.go rename to go/internal/cmc/documents/invoice_builder.go index b0543765..890b0b83 100644 --- a/go/internal/cmc/pdf/invoice_builder.go +++ b/go/internal/cmc/documents/invoice_builder.go @@ -111,12 +111,12 @@ func (g *HTMLDocumentGenerator) BuildInvoiceHTML(data *InvoicePDFData, totalPage // Try multiple possible path sets to find the invoice and shared header templates together possiblePathSets := [][]string{ { - filepath.Join("internal", "cmc", "pdf", "templates", "invoice.html"), - filepath.Join("internal", "cmc", "pdf", "templates", "header.html"), + filepath.Join("internal", "cmc", "documents", "templates", "invoice.html"), + filepath.Join("internal", "cmc", "documents", "templates", "header.html"), }, { - filepath.Join("go", "internal", "cmc", "pdf", "templates", "invoice.html"), - filepath.Join("go", "internal", "cmc", "pdf", "templates", "header.html"), + filepath.Join("go", "internal", "cmc", "documents", "templates", "invoice.html"), + filepath.Join("go", "internal", "cmc", "documents", "templates", "header.html"), }, { filepath.Join("templates", "pdf", "invoice.html"), diff --git a/go/internal/cmc/pdf/merge.go b/go/internal/cmc/documents/merge.go similarity index 100% rename from go/internal/cmc/pdf/merge.go rename to go/internal/cmc/documents/merge.go diff --git a/go/internal/cmc/pdf/merge_test.go b/go/internal/cmc/documents/merge_test.go similarity index 100% rename from go/internal/cmc/pdf/merge_test.go rename to go/internal/cmc/documents/merge_test.go diff --git a/go/internal/cmc/documents/order_ack_builder.go b/go/internal/cmc/documents/order_ack_builder.go new file mode 100644 index 00000000..7d97d4cd --- /dev/null +++ b/go/internal/cmc/documents/order_ack_builder.go @@ -0,0 +1,161 @@ +package pdf + +import ( + "bytes" + "fmt" + "html/template" + "path/filepath" + "strconv" +) + +// OrderAckLineItemTemplateData represents an order ack line item for template rendering +type OrderAckLineItemTemplateData struct { + Title string + Description template.HTML + Quantity string + UnitPrice float64 + Discount float64 + TotalPrice float64 +} + +// BuildOrderAckHTML generates the complete HTML for an order acknowledgement using templates +func (g *HTMLDocumentGenerator) BuildOrderAckHTML(data *OrderAckPDFData, totalPages int, currentPage int) string { + orderAckNumber := data.OrderAcknowledgement.CmcReference + + // Calculate totals + subtotal := 0.0 + lineItemsData := []OrderAckLineItemTemplateData{} + + for _, item := range data.LineItems { + unitPrice := 0.0 + totalPrice := 0.0 + discount := 0.0 + + if item.GrossUnitPrice.Valid { + unitPrice, _ = strconv.ParseFloat(item.GrossUnitPrice.String, 64) + } + if item.GrossPrice.Valid { + totalPrice, _ = strconv.ParseFloat(item.GrossPrice.String, 64) + subtotal += totalPrice + } + if item.DiscountAmountTotal.Valid { + discount, _ = strconv.ParseFloat(item.DiscountAmountTotal.String, 64) + } + + lineItemsData = append(lineItemsData, OrderAckLineItemTemplateData{ + Title: item.Title, + Description: template.HTML(item.Description), + Quantity: item.Quantity, + UnitPrice: unitPrice, + Discount: discount, + TotalPrice: totalPrice, + }) + } + + gstAmount := 0.0 + total := subtotal + if data.ShowGST { + gstAmount = subtotal * 0.1 + total = subtotal + gstAmount + } + + // Prepare template data + templateData := struct { + OrderAckNumber string + CompanyName string + EmailTo string + Attention string + IssueDateString string + YourReference string + JobTitle string + BillTo string + ShipTo string + ShipVia string + FOB string + PaymentTerms string + CurrencyCode string + CurrencySymbol string + LineItems []OrderAckLineItemTemplateData + Subtotal float64 + GSTAmount float64 + Total float64 + ShowGST bool + LogoDataURI string + }{ + OrderAckNumber: orderAckNumber, + CompanyName: data.Customer.Name, + EmailTo: data.EmailTo, + Attention: data.Attention, + IssueDateString: data.IssueDateString, + YourReference: data.YourReference, + JobTitle: data.JobTitle, + BillTo: data.BillTo, + ShipTo: data.ShipTo, + ShipVia: data.ShipVia, + FOB: data.FOB, + PaymentTerms: data.PaymentTerms, + CurrencyCode: data.CurrencyCode, + CurrencySymbol: data.CurrencySymbol, + LineItems: lineItemsData, + Subtotal: subtotal, + GSTAmount: gstAmount, + Total: total, + ShowGST: data.ShowGST, + LogoDataURI: g.loadLogo("quote_logo.png"), + } + + // Define template functions + funcMap := template.FuncMap{ + "formatPrice": func(price float64) template.HTML { + formatted := FormatPriceWithCommas(fmt.Sprintf("%.2f", price)) + return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted)) + }, + "formatTotal": func(amount float64) template.HTML { + formatted := FormatPriceWithCommas(fmt.Sprintf("%.2f", amount)) + return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted)) + }, + } + + // Parse and execute template + possiblePathSets := [][]string{ + { + filepath.Join("internal", "cmc", "documents", "templates", "order-acknowledgement.html"), + filepath.Join("internal", "cmc", "documents", "templates", "header.html"), + }, + { + filepath.Join("go", "internal", "cmc", "documents", "templates", "order-acknowledgement.html"), + filepath.Join("go", "internal", "cmc", "documents", "templates", "header.html"), + }, + { + filepath.Join("templates", "pdf", "order-acknowledgement.html"), + filepath.Join("templates", "pdf", "header.html"), + }, + { + "/app/templates/pdf/order-acknowledgement.html", + "/app/templates/pdf/header.html", + }, + } + + var tmpl *template.Template + var err error + + for _, pathSet := range possiblePathSets { + tmpl, err = template.New("order-acknowledgement.html").Funcs(funcMap).ParseFiles(pathSet...) + if err == nil { + break + } + } + + if tmpl == nil || err != nil { + fmt.Printf("Error parsing template from any path: %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() +} diff --git a/go/internal/cmc/documents/packing_list_builder.go b/go/internal/cmc/documents/packing_list_builder.go new file mode 100644 index 00000000..888e79e8 --- /dev/null +++ b/go/internal/cmc/documents/packing_list_builder.go @@ -0,0 +1,100 @@ +package pdf + +import ( + "bytes" + "fmt" + "html/template" + "path/filepath" +) + +// PackingListLineItemTemplateData represents a packing list line item for template rendering +type PackingListLineItemTemplateData struct { + Title string + Description template.HTML + Quantity string + Weight string +} + +// BuildPackingListHTML generates the complete HTML for a packing list using templates +func (g *HTMLDocumentGenerator) BuildPackingListHTML(data *PackingListPDFData, totalPages int, currentPage int) string { + packingListNumber := data.PackingList.CmcReference + + // Prepare line items + lineItemsData := []PackingListLineItemTemplateData{} + + for _, item := range data.LineItems { + lineItemsData = append(lineItemsData, PackingListLineItemTemplateData{ + Title: item.Title, + Description: template.HTML(item.Description), + Quantity: item.Quantity, + Weight: "", // Weight not currently in LineItem model + }) + } + + // Prepare template data + templateData := struct { + PackingListNumber string + IssueDateString string + JobTitle string + CustomerOrderNumber string + FromAddress string + ShipTo string + LineItems []PackingListLineItemTemplateData + LogoDataURI string + }{ + PackingListNumber: packingListNumber, + IssueDateString: data.IssueDateString, + JobTitle: data.JobTitle, + CustomerOrderNumber: data.CustomerOrderNumber, + FromAddress: "CMC TECHNOLOGIES\n1/50 Carrington Road\nMarrickville NSW 2204\nAustralia", + ShipTo: data.ShipTo, + LineItems: lineItemsData, + LogoDataURI: g.loadLogo("quote_logo.png"), + } + + // Define template functions + funcMap := template.FuncMap{} + + // Parse and execute template + possiblePathSets := [][]string{ + { + filepath.Join("internal", "cmc", "documents", "templates", "packing-list.html"), + filepath.Join("internal", "cmc", "documents", "templates", "header.html"), + }, + { + filepath.Join("go", "internal", "cmc", "documents", "templates", "packing-list.html"), + filepath.Join("go", "internal", "cmc", "documents", "templates", "header.html"), + }, + { + filepath.Join("templates", "pdf", "packing-list.html"), + filepath.Join("templates", "pdf", "header.html"), + }, + { + "/app/templates/pdf/packing-list.html", + "/app/templates/pdf/header.html", + }, + } + + var tmpl *template.Template + var err error + + for _, pathSet := range possiblePathSets { + tmpl, err = template.New("packing-list.html").Funcs(funcMap).ParseFiles(pathSet...) + if err == nil { + break + } + } + + if tmpl == nil || err != nil { + fmt.Printf("Error parsing template from any path: %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() +} diff --git a/go/internal/cmc/pdf/page_numbers.go b/go/internal/cmc/documents/page_numbers.go similarity index 100% rename from go/internal/cmc/pdf/page_numbers.go rename to go/internal/cmc/documents/page_numbers.go diff --git a/go/internal/cmc/documents/purchase_order_builder.go b/go/internal/cmc/documents/purchase_order_builder.go new file mode 100644 index 00000000..d11bcbba --- /dev/null +++ b/go/internal/cmc/documents/purchase_order_builder.go @@ -0,0 +1,147 @@ +package pdf + +import ( + "bytes" + "fmt" + "html/template" + "path/filepath" + "strconv" +) + +// PurchaseOrderLineItemTemplateData represents a PO line item for template rendering +type PurchaseOrderLineItemTemplateData struct { + Title string + Description template.HTML + Quantity string + UnitPrice float64 + TotalPrice float64 +} + +// BuildPurchaseOrderHTML generates the complete HTML for a purchase order using templates +func (g *HTMLDocumentGenerator) BuildPurchaseOrderHTML(data *PurchaseOrderPDFData, totalPages int, currentPage int) string { + poNumber := data.PurchaseOrder.Title + + // Calculate totals + subtotal := 0.0 + lineItemsData := []PurchaseOrderLineItemTemplateData{} + + for _, item := range data.LineItems { + unitPrice := 0.0 + totalPrice := 0.0 + + if item.NetUnitPrice.Valid { + unitPrice, _ = strconv.ParseFloat(item.NetUnitPrice.String, 64) + } + if item.NetPrice.Valid { + totalPrice, _ = strconv.ParseFloat(item.NetPrice.String, 64) + subtotal += totalPrice + } + + lineItemsData = append(lineItemsData, PurchaseOrderLineItemTemplateData{ + Title: item.Title, + Description: template.HTML(item.Description), + Quantity: item.Quantity, + UnitPrice: unitPrice, + TotalPrice: totalPrice, + }) + } + + gstAmount := 0.0 + total := subtotal + if data.ShowGST { + gstAmount = subtotal * 0.1 + total = subtotal + gstAmount + } + + // Prepare template data + templateData := struct { + PONumber string + PrincipleName string + YourReference string + IssueDateString string + OrderedFrom string + DeliverTo string + DispatchBy string + ShippingInstructions string + CurrencyCode string + CurrencySymbol string + LineItems []PurchaseOrderLineItemTemplateData + Subtotal float64 + GSTAmount float64 + Total float64 + ShowGST bool + LogoDataURI string + }{ + PONumber: poNumber, + PrincipleName: data.Principle.Name, + YourReference: data.PurchaseOrder.PrincipleReference, + IssueDateString: data.IssueDateString, + OrderedFrom: data.PurchaseOrder.OrderedFrom, + DeliverTo: data.PurchaseOrder.DeliverTo, + DispatchBy: data.PurchaseOrder.DispatchBy, + ShippingInstructions: data.PurchaseOrder.ShippingInstructions, + CurrencyCode: data.CurrencyCode, + CurrencySymbol: data.CurrencySymbol, + LineItems: lineItemsData, + Subtotal: subtotal, + GSTAmount: gstAmount, + Total: total, + ShowGST: data.ShowGST, + LogoDataURI: g.loadLogo("quote_logo.png"), + } + + // Define template functions + funcMap := template.FuncMap{ + "formatPrice": func(price float64) template.HTML { + formatted := FormatPriceWithCommas(fmt.Sprintf("%.2f", price)) + return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted)) + }, + "formatTotal": func(amount float64) template.HTML { + formatted := FormatPriceWithCommas(fmt.Sprintf("%.2f", amount)) + return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted)) + }, + } + + // Parse and execute template + possiblePathSets := [][]string{ + { + filepath.Join("internal", "cmc", "documents", "templates", "purchase-order.html"), + filepath.Join("internal", "cmc", "documents", "templates", "header.html"), + }, + { + filepath.Join("go", "internal", "cmc", "documents", "templates", "purchase-order.html"), + filepath.Join("go", "internal", "cmc", "documents", "templates", "header.html"), + }, + { + filepath.Join("templates", "pdf", "purchase-order.html"), + filepath.Join("templates", "pdf", "header.html"), + }, + { + "/app/templates/pdf/purchase-order.html", + "/app/templates/pdf/header.html", + }, + } + + var tmpl *template.Template + var err error + + for _, pathSet := range possiblePathSets { + tmpl, err = template.New("purchase-order.html").Funcs(funcMap).ParseFiles(pathSet...) + if err == nil { + break + } + } + + if tmpl == nil || err != nil { + fmt.Printf("Error parsing template from any path: %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() +} diff --git a/go/internal/cmc/pdf/quote_builder.go b/go/internal/cmc/documents/quote_builder.go similarity index 93% rename from go/internal/cmc/pdf/quote_builder.go rename to go/internal/cmc/documents/quote_builder.go index 912dd9b7..dd125297 100644 --- a/go/internal/cmc/pdf/quote_builder.go +++ b/go/internal/cmc/documents/quote_builder.go @@ -161,12 +161,12 @@ func (g *HTMLDocumentGenerator) BuildQuoteHTML(data *QuotePDFData, totalPages in // Try multiple possible path sets to find the quote and shared header templates together possiblePathSets := [][]string{ { - filepath.Join("internal", "cmc", "pdf", "templates", "quote.html"), - filepath.Join("internal", "cmc", "pdf", "templates", "header.html"), + filepath.Join("internal", "cmc", "documents", "templates", "quote.html"), + filepath.Join("internal", "cmc", "documents", "templates", "header.html"), }, { - filepath.Join("go", "internal", "cmc", "pdf", "templates", "quote.html"), - filepath.Join("go", "internal", "cmc", "pdf", "templates", "header.html"), + filepath.Join("go", "internal", "cmc", "documents", "templates", "quote.html"), + filepath.Join("go", "internal", "cmc", "documents", "templates", "header.html"), }, { filepath.Join("templates", "pdf", "quote.html"), @@ -185,8 +185,8 @@ func (g *HTMLDocumentGenerator) BuildQuoteHTML(data *QuotePDFData, totalPages in "/app/templates/header.html", }, { - "/app/go/internal/cmc/pdf/templates/quote.html", - "/app/go/internal/cmc/pdf/templates/header.html", + "/app/go/internal/cmc/documents/templates/quote.html", + "/app/go/internal/cmc/documents/templates/header.html", }, } diff --git a/go/internal/cmc/pdf/templates.go b/go/internal/cmc/documents/templates.go.deprecated similarity index 100% rename from go/internal/cmc/pdf/templates.go rename to go/internal/cmc/documents/templates.go.deprecated diff --git a/go/internal/cmc/pdf/templates/header.html b/go/internal/cmc/documents/templates/header.html similarity index 100% rename from go/internal/cmc/pdf/templates/header.html rename to go/internal/cmc/documents/templates/header.html diff --git a/go/internal/cmc/pdf/templates/invoice.html b/go/internal/cmc/documents/templates/invoice.html similarity index 100% rename from go/internal/cmc/pdf/templates/invoice.html rename to go/internal/cmc/documents/templates/invoice.html diff --git a/go/internal/cmc/documents/templates/order-acknowledgement.html b/go/internal/cmc/documents/templates/order-acknowledgement.html new file mode 100644 index 00000000..36590e49 --- /dev/null +++ b/go/internal/cmc/documents/templates/order-acknowledgement.html @@ -0,0 +1,316 @@ + + + + + {{if .OrderAckNumber}}{{.OrderAckNumber}}{{else}}CMC Order Acknowledgement{{end}} + + + + {{template "CompanyHeader" .}} + + + + + + + + + + + + + + + + + + + + + + + + + +
COMPANY NAME:{{.CompanyName}}ORDER ACK #:{{.OrderAckNumber}}
CONTACT:{{.Attention}}DATE:{{.IssueDateString}}
EMAIL:{{.EmailTo}}YOUR REFERENCE:{{.YourReference}}
JOB TITLE:{{.JobTitle}}
+ + +
+
+

BILL TO:

+

{{.BillTo}}

+
+
+

SHIP TO:

+

{{.ShipTo}}

+
+
+ + + + + + + + + + + + + +
SHIP VIA:{{.ShipVia}}FOB:{{.FOB}}
PAYMENT TERMS:{{.PaymentTerms}}
+ + +
+ Shown in {{.CurrencyCode}} +
+ + + + + + + + + + + + + + {{range .LineItems}} + + + + + + + + {{end}} + +
DESCRIPTIONQTYUNIT PRICEDISCOUNTTOTAL
{{.Title}}{{if .Description}}
{{.Description}}{{end}}
{{.Quantity}}{{formatPrice .UnitPrice}}{{if .Discount}}{{formatPrice .Discount}}{{else}}$0.00{{end}}{{formatPrice .TotalPrice}}
+ + +
+ + + + + + {{if .ShowGST}} + + + + + {{end}} + + + + +
SUBTOTAL{{formatTotal .Subtotal}}
GST (10%){{formatTotal .GSTAmount}}
{{if .ShowGST}}TOTAL PAYABLE{{else}}TOTAL{{end}}{{formatTotal .Total}}
+
+ + + + + diff --git a/go/internal/cmc/documents/templates/packing-list.html b/go/internal/cmc/documents/templates/packing-list.html new file mode 100644 index 00000000..e4629337 --- /dev/null +++ b/go/internal/cmc/documents/templates/packing-list.html @@ -0,0 +1,216 @@ + + + + + {{if .PackingListNumber}}{{.PackingListNumber}}{{else}}CMC Packing List{{end}} + + + + {{template "CompanyHeader" .}} + + + + + + + + + + + + + + + + + +
PACKING LIST #:{{.PackingListNumber}}DATE:{{.IssueDateString}}
JOB TITLE:{{.JobTitle}}
CUSTOMER ORDER #:{{.CustomerOrderNumber}}
+ + +
+
+

FROM:

+

{{.FromAddress}}

+
+
+

TO:

+

{{.ShipTo}}

+
+
+ + + + + + + + + + + + {{range .LineItems}} + + + + + + {{end}} + +
DESCRIPTIONQTYWEIGHT (kg)
{{.Title}}{{if .Description}}
{{.Description}}{{end}}
{{.Quantity}}{{if .Weight}}{{.Weight}}{{else}}-{{end}}
+ + + + + diff --git a/go/internal/cmc/documents/templates/purchase-order.html b/go/internal/cmc/documents/templates/purchase-order.html new file mode 100644 index 00000000..93a63965 --- /dev/null +++ b/go/internal/cmc/documents/templates/purchase-order.html @@ -0,0 +1,286 @@ + + + + + {{if .PONumber}}{{.PONumber}}{{else}}CMC Purchase Order{{end}} + + + + {{template "CompanyHeader" .}} + + + + + + + + + + + + + + + + + + + + + + + + {{if .DispatchBy}} + + + + + {{end}} +
SUPPLIER:{{.PrincipleName}}PO NUMBER:{{.PONumber}}
YOUR REFERENCE:{{.YourReference}}ISSUE DATE:{{.IssueDateString}}
ORDERED FROM:{{.OrderedFrom}}
DELIVER TO:{{.DeliverTo}}
DISPATCH BY:{{.DispatchBy}}
+ + {{if .ShippingInstructions}} +
+

SHIPPING INSTRUCTIONS

+

{{.ShippingInstructions}}

+
+ {{end}} + + +
+ Shown in {{.CurrencyCode}} +
+ + + + + + + + + + + + + {{range .LineItems}} + + + + + + + {{end}} + +
DESCRIPTIONQTYUNIT PRICETOTAL
{{.Title}}{{if .Description}}
{{.Description}}{{end}}
{{.Quantity}}{{formatPrice .UnitPrice}}{{formatPrice .TotalPrice}}
+ + +
+ + + + + + {{if .ShowGST}} + + + + + {{end}} + + + + +
SUBTOTAL{{formatTotal .Subtotal}}
GST (10%){{formatTotal .GSTAmount}}
{{if .ShowGST}}TOTAL PAYABLE{{else}}TOTAL{{end}}{{formatTotal .Total}}
+
+ + + + + diff --git a/go/internal/cmc/pdf/templates/quote.html b/go/internal/cmc/documents/templates/quote.html similarity index 100% rename from go/internal/cmc/pdf/templates/quote.html rename to go/internal/cmc/documents/templates/quote.html diff --git a/go/internal/cmc/handlers/document.go b/go/internal/cmc/handlers/document.go index 8c0a878a..365012d5 100644 --- a/go/internal/cmc/handlers/document.go +++ b/go/internal/cmc/handlers/document.go @@ -12,7 +12,7 @@ import ( "time" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db" - "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/pdf" + pdf "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/documents" "github.com/gorilla/mux" ) @@ -27,10 +27,10 @@ func NewDocumentHandler(queries *db.Queries) *DocumentHandler { // List handles GET /api/documents func (h *DocumentHandler) List(w http.ResponseWriter, r *http.Request) { ctx := context.Background() - + // Check for type filter docType := r.URL.Query().Get("type") - + // Get pagination parameters limit := int32(20) offset := int32(0) @@ -44,10 +44,10 @@ func (h *DocumentHandler) List(w http.ResponseWriter, r *http.Request) { offset = int32(val) } } - + var documents interface{} var err error - + if docType != "" { // Convert string to DocumentsType enum documents, err = h.queries.ListDocumentsByType(ctx, db.ListDocumentsByTypeParams{ @@ -61,13 +61,13 @@ func (h *DocumentHandler) List(w http.ResponseWriter, r *http.Request) { Offset: offset, }) } - + if err != nil { log.Printf("Error fetching documents: %v", err) http.Error(w, "Failed to fetch documents", http.StatusInternalServerError) return } - + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(documents) } @@ -98,13 +98,13 @@ func nullTimeFromPtr(p *time.Time) sql.NullTime { func (h *DocumentHandler) Get(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) idStr := vars["id"] - + id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid document ID", http.StatusBadRequest) return } - + ctx := context.Background() document, err := h.queries.GetDocument(ctx, int32(id)) if err != nil { @@ -112,7 +112,7 @@ func (h *DocumentHandler) Get(w http.ResponseWriter, r *http.Request) { http.Error(w, "Document not found", http.StatusNotFound) return } - + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(document) } @@ -120,87 +120,87 @@ func (h *DocumentHandler) Get(w http.ResponseWriter, r *http.Request) { // Create handles POST /api/documents func (h *DocumentHandler) Create(w http.ResponseWriter, r *http.Request) { var req struct { - Type string `json:"type"` - UserID int32 `json:"user_id"` - DocPageCount int32 `json:"doc_page_count"` - CMCReference string `json:"cmc_reference"` - PDFFilename string `json:"pdf_filename"` - PDFCreatedAt string `json:"pdf_created_at"` - PDFCreatedByUserID int32 `json:"pdf_created_by_user_id"` - ShippingDetails *string `json:"shipping_details,omitempty"` - Revision *int32 `json:"revision,omitempty"` - BillTo *string `json:"bill_to,omitempty"` - ShipTo *string `json:"ship_to,omitempty"` - EmailSentAt string `json:"email_sent_at"` - EmailSentByUserID int32 `json:"email_sent_by_user_id"` + Type string `json:"type"` + UserID int32 `json:"user_id"` + DocPageCount int32 `json:"doc_page_count"` + CMCReference string `json:"cmc_reference"` + PDFFilename string `json:"pdf_filename"` + PDFCreatedAt string `json:"pdf_created_at"` + PDFCreatedByUserID int32 `json:"pdf_created_by_user_id"` + ShippingDetails *string `json:"shipping_details,omitempty"` + Revision *int32 `json:"revision,omitempty"` + BillTo *string `json:"bill_to,omitempty"` + ShipTo *string `json:"ship_to,omitempty"` + EmailSentAt string `json:"email_sent_at"` + EmailSentByUserID int32 `json:"email_sent_by_user_id"` } - + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } - + // Validate required fields if req.Type == "" { http.Error(w, "Type is required", http.StatusBadRequest) return } - + if req.UserID == 0 { http.Error(w, "User ID is required", http.StatusBadRequest) return } - + if req.CMCReference == "" { http.Error(w, "CMC Reference is required", http.StatusBadRequest) return } - + ctx := context.Background() - + // Parse timestamps pdfCreatedAt, err := time.Parse("2006-01-02 15:04:05", req.PDFCreatedAt) if err != nil { http.Error(w, "Invalid pdf_created_at format", http.StatusBadRequest) return } - + emailSentAt, err := time.Parse("2006-01-02 15:04:05", req.EmailSentAt) if err != nil { http.Error(w, "Invalid email_sent_at format", http.StatusBadRequest) return } - + revision := int32(0) if req.Revision != nil { revision = *req.Revision } - + params := db.CreateDocumentParams{ - Type: db.DocumentsType(req.Type), - UserID: req.UserID, - DocPageCount: req.DocPageCount, - CmcReference: req.CMCReference, - PdfFilename: req.PDFFilename, - PdfCreatedAt: pdfCreatedAt, - PdfCreatedByUserID: req.PDFCreatedByUserID, - ShippingDetails: nullStringFromPtr(req.ShippingDetails), - Revision: revision, - BillTo: nullStringFromPtr(req.BillTo), - ShipTo: nullStringFromPtr(req.ShipTo), - EmailSentAt: emailSentAt, - EmailSentByUserID: req.EmailSentByUserID, + Type: db.DocumentsType(req.Type), + UserID: req.UserID, + DocPageCount: req.DocPageCount, + CmcReference: req.CMCReference, + PdfFilename: req.PDFFilename, + PdfCreatedAt: pdfCreatedAt, + PdfCreatedByUserID: req.PDFCreatedByUserID, + ShippingDetails: nullStringFromPtr(req.ShippingDetails), + Revision: revision, + BillTo: nullStringFromPtr(req.BillTo), + ShipTo: nullStringFromPtr(req.ShipTo), + EmailSentAt: emailSentAt, + EmailSentByUserID: req.EmailSentByUserID, } - + result, err := h.queries.CreateDocument(ctx, params) if err != nil { log.Printf("Error creating document: %v", err) http.Error(w, "Failed to create document", http.StatusInternalServerError) return } - + id, _ := result.LastInsertId() - + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]interface{}{ @@ -213,78 +213,78 @@ func (h *DocumentHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *DocumentHandler) Update(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) idStr := vars["id"] - + id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid document ID", http.StatusBadRequest) return } - + var req struct { - Type string `json:"type"` - UserID int32 `json:"user_id"` - DocPageCount int32 `json:"doc_page_count"` - CMCReference string `json:"cmc_reference"` - PDFFilename string `json:"pdf_filename"` - PDFCreatedAt string `json:"pdf_created_at"` - PDFCreatedByUserID int32 `json:"pdf_created_by_user_id"` - ShippingDetails *string `json:"shipping_details,omitempty"` - Revision *int32 `json:"revision,omitempty"` - BillTo *string `json:"bill_to,omitempty"` - ShipTo *string `json:"ship_to,omitempty"` - EmailSentAt string `json:"email_sent_at"` - EmailSentByUserID int32 `json:"email_sent_by_user_id"` + Type string `json:"type"` + UserID int32 `json:"user_id"` + DocPageCount int32 `json:"doc_page_count"` + CMCReference string `json:"cmc_reference"` + PDFFilename string `json:"pdf_filename"` + PDFCreatedAt string `json:"pdf_created_at"` + PDFCreatedByUserID int32 `json:"pdf_created_by_user_id"` + ShippingDetails *string `json:"shipping_details,omitempty"` + Revision *int32 `json:"revision,omitempty"` + BillTo *string `json:"bill_to,omitempty"` + ShipTo *string `json:"ship_to,omitempty"` + EmailSentAt string `json:"email_sent_at"` + EmailSentByUserID int32 `json:"email_sent_by_user_id"` } - + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } - + ctx := context.Background() - + // Parse timestamps pdfCreatedAt, err := time.Parse("2006-01-02 15:04:05", req.PDFCreatedAt) if err != nil { http.Error(w, "Invalid pdf_created_at format", http.StatusBadRequest) return } - + emailSentAt, err := time.Parse("2006-01-02 15:04:05", req.EmailSentAt) if err != nil { http.Error(w, "Invalid email_sent_at format", http.StatusBadRequest) return } - + revision := int32(0) if req.Revision != nil { revision = *req.Revision } - + params := db.UpdateDocumentParams{ - Type: db.DocumentsType(req.Type), - UserID: req.UserID, - DocPageCount: req.DocPageCount, - CmcReference: req.CMCReference, - PdfFilename: req.PDFFilename, - PdfCreatedAt: pdfCreatedAt, - PdfCreatedByUserID: req.PDFCreatedByUserID, - ShippingDetails: nullStringFromPtr(req.ShippingDetails), - Revision: revision, - BillTo: nullStringFromPtr(req.BillTo), - ShipTo: nullStringFromPtr(req.ShipTo), - EmailSentAt: emailSentAt, - EmailSentByUserID: req.EmailSentByUserID, - ID: int32(id), + Type: db.DocumentsType(req.Type), + UserID: req.UserID, + DocPageCount: req.DocPageCount, + CmcReference: req.CMCReference, + PdfFilename: req.PDFFilename, + PdfCreatedAt: pdfCreatedAt, + PdfCreatedByUserID: req.PDFCreatedByUserID, + ShippingDetails: nullStringFromPtr(req.ShippingDetails), + Revision: revision, + BillTo: nullStringFromPtr(req.BillTo), + ShipTo: nullStringFromPtr(req.ShipTo), + EmailSentAt: emailSentAt, + EmailSentByUserID: req.EmailSentByUserID, + ID: int32(id), } - + err = h.queries.UpdateDocument(ctx, params) if err != nil { log.Printf("Error updating document %d: %v", id, err) http.Error(w, "Failed to update document", http.StatusInternalServerError) return } - + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "Document updated successfully", @@ -313,15 +313,15 @@ func (h *DocumentHandler) Search(w http.ResponseWriter, r *http.Request) { func (h *DocumentHandler) GeneratePDF(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) idStr := vars["id"] - + id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid document ID", http.StatusBadRequest) return } - + ctx := context.Background() - + // Get document document, err := h.queries.GetDocument(ctx, int32(id)) if err != nil { @@ -329,35 +329,35 @@ func (h *DocumentHandler) GeneratePDF(w http.ResponseWriter, r *http.Request) { http.Error(w, "Document not found", http.StatusNotFound) return } - + // Get output directory from environment or use default outputDir := os.Getenv("PDF_OUTPUT_DIR") if outputDir == "" { outputDir = "webroot/pdf" } - + // Ensure output directory exists if err := os.MkdirAll(outputDir, 0755); err != nil { log.Printf("Error creating PDF directory: %v", err) http.Error(w, "Failed to create PDF directory", http.StatusInternalServerError) return } - + var filename string - + // Generate PDF based on document type log.Printf("Generating PDF for document %d, type: %s, outputDir: %s", id, document.Type, outputDir) switch document.Type { case db.DocumentsTypeQuote: // Get quote-specific data // TODO: Get quote data from database - + // Get enquiry data // TODO: Get enquiry from quote - + // Get customer data // TODO: Get customer from enquiry - + // Get user data user, err := h.queries.GetUser(ctx, document.UserID) if err != nil { @@ -365,7 +365,7 @@ func (h *DocumentHandler) GeneratePDF(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to fetch user data", http.StatusInternalServerError) return } - + // Get line items lineItems, err := h.queries.GetLineItemsTable(ctx, int32(id)) if err != nil { @@ -373,32 +373,33 @@ func (h *DocumentHandler) GeneratePDF(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to fetch line items", http.StatusInternalServerError) return } - + // For now, create a simplified quote PDF data := &pdf.QuotePDFData{ - Document: &document, - User: &user, - LineItems: lineItems, - CurrencySymbol: "$", // Default to AUD - ShowGST: true, // Default to showing GST + Document: &document, + User: &user, + LineItems: lineItems, + CurrencySymbol: "$", // Default to AUD + ShowGST: true, // Default to showing GST } - + // Generate PDF log.Printf("Calling GenerateQuotePDF with outputDir: %s", outputDir) - filename, err = pdf.GenerateQuotePDF(data, outputDir) + htmlGen := pdf.NewHTMLDocumentGenerator(outputDir) + filename, err = htmlGen.GenerateQuotePDF(data) if err != nil { log.Printf("Error generating quote PDF: %v", err) http.Error(w, "Failed to generate PDF", http.StatusInternalServerError) return } log.Printf("Successfully generated PDF: %s", filename) - + case db.DocumentsTypeInvoice: // Get invoice-specific data // TODO: Implement invoice PDF generation http.Error(w, "Invoice PDF generation not yet implemented", http.StatusNotImplemented) return - + case db.DocumentsTypePurchaseOrder: // Get purchase order data po, err := h.queries.GetPurchaseOrderByDocumentID(ctx, int32(id)) @@ -407,7 +408,7 @@ func (h *DocumentHandler) GeneratePDF(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to fetch purchase order data", http.StatusInternalServerError) return } - + // Get principle data principle, err := h.queries.GetPrinciple(ctx, po.PrincipleID) if err != nil { @@ -415,7 +416,7 @@ func (h *DocumentHandler) GeneratePDF(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to fetch principle data", http.StatusInternalServerError) return } - + // Get line items lineItems, err := h.queries.GetLineItemsTable(ctx, int32(id)) if err != nil { @@ -423,34 +424,35 @@ func (h *DocumentHandler) GeneratePDF(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to fetch line items", http.StatusInternalServerError) return } - + // Create purchase order PDF data data := &pdf.PurchaseOrderPDFData{ - Document: &document, - PurchaseOrder: &po, - Principle: &principle, - LineItems: lineItems, - CurrencySymbol: "$", // Default to AUD - ShowGST: true, // Default to showing GST for Australian principles + Document: &document, + PurchaseOrder: &po, + Principle: &principle, + LineItems: lineItems, + CurrencySymbol: "$", // Default to AUD + ShowGST: true, // Default to showing GST for Australian principles } - + // Generate PDF - filename, err = pdf.GeneratePurchaseOrderPDF(data, outputDir) + htmlGen := pdf.NewHTMLDocumentGenerator(outputDir) + filename, err = htmlGen.GeneratePurchaseOrderPDF(data) if err != nil { log.Printf("Error generating purchase order PDF: %v", err) http.Error(w, "Failed to generate PDF", http.StatusInternalServerError) return } - + default: http.Error(w, "Unsupported document type for PDF generation", http.StatusBadRequest) return } - + // TODO: Update document with PDF filename and timestamp // This would require a specific query to update just PDF info log.Printf("PDF generated successfully: %s", filename) - + // Return success response with redirect to document view w.Header().Set("Content-Type", "text/html") fmt.Fprintf(w, ` @@ -479,7 +481,7 @@ func (h *DocumentHandler) GetRecentActivity(w http.ResponseWriter, r *http.Reque // Format the response as HTML w.Header().Set("Content-Type", "text/html") - + if len(documents) == 0 { fmt.Fprintf(w, `

No recent activity

@@ -520,7 +522,7 @@ func (h *DocumentHandler) GetRecentActivity(w http.ResponseWriter, r *http.Reque // Format the date createdDate := doc.Created.Format("Jan 2, 2006 3:04 PM") - + // Handle null username createdBy := "Unknown" if doc.CreatedByUsername.Valid { @@ -562,4 +564,4 @@ func (h *DocumentHandler) GetRecentActivity(w http.ResponseWriter, r *http.Reque
`) -} \ No newline at end of file +} diff --git a/go/internal/cmc/handlers/documents/documents_api.go b/go/internal/cmc/handlers/documents/documents_api.go new file mode 100644 index 00000000..d1a318b0 --- /dev/null +++ b/go/internal/cmc/handlers/documents/documents_api.go @@ -0,0 +1,647 @@ +package documents + +import ( + "database/sql" + "encoding/json" + "fmt" + "html" + "log" + "net/http" + "os" + "strings" + "time" + + "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db" + pdf "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/documents" +) + +// 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"` + NetPrice float64 `json:"net_price"` + DiscountPercent float64 `json:"discount_percent"` + DiscountAmountUnit float64 `json:"discount_amount_unit"` + DiscountAmountTotal float64 `json:"discount_amount_total"` + Option int `json:"option"` + HasTextPrices bool `json:"has_text_prices"` + UnitPriceString string `json:"unit_price_string"` + GrossPriceString string `json:"gross_price_string"` +} + +// 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"` + ContactName string `json:"contact_name"` + UserFirstName string `json:"user_first_name"` + UserLastName string `json:"user_last_name"` + UserEmail string `json:"user_email"` + YourReference string `json:"your_reference"` + ShipVia string `json:"ship_via"` + FOB string `json:"fob"` + IssueDate string `json:"issue_date"` // ISO date: 2006-01-02 + IssueDateString string `json:"issue_date_string"` // Formatted: "12 January 2026" + CurrencySymbol string `json:"currency_symbol"` // e.g. "$" + CurrencyCode string `json:"currency_code"` // e.g. "AUD", "USD" + ShowGST bool `json:"show_gst"` + BillTo string `json:"bill_to"` + ShipTo string `json:"ship_to"` + ShippingDetails string `json:"shipping_details"` + CustomerOrderNumber string `json:"customer_order_number"` + JobTitle string `json:"job_title"` + PaymentTerms string `json:"payment_terms"` + CustomerABN string `json:"customer_abn"` + Subtotal interface{} `json:"subtotal"` // Can be float or "TBA" + GSTAmount interface{} `json:"gst_amount"` + Total interface{} `json:"total"` + LineItems []InvoiceLineItemRequest `json:"line_items"` + OutputDir string `json:"output_dir"` // optional override +} + +// GenerateInvoicePDF handles POST /api/pdf/invoice and writes a PDF to disk. +// It returns JSON: {"filename":".pdf"} +// GenerateInvoicePDF generates invoice using HTML template and chromedp +func GenerateInvoicePDF(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("GenerateInvoicePDF: 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("GenerateInvoicePDF: 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.NewHTMLDocumentGenerator(outputDir) + filename, err := htmlGen.GenerateInvoicePDF(data) + if err != nil { + log.Printf("GenerateInvoicePDF: 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 + +// QuotePDFRequest payload from PHP for quotes +type QuotePDFRequest struct { + DocumentID int32 `json:"document_id"` + CmcReference string `json:"cmc_reference"` + Revision int32 `json:"revision"` + CreatedDate string `json:"created_date"` // YYYY-MM-DD + CreatedDateString string `json:"created_date_string"` // j M Y format + DateIssued string `json:"date_issued"` + CustomerName string `json:"customer_name"` + ContactEmail string `json:"contact_email"` + ContactName string `json:"contact_name"` + UserFirstName string `json:"user_first_name"` + UserLastName string `json:"user_last_name"` + UserEmail string `json:"user_email"` + CurrencySymbol string `json:"currency_symbol"` + CurrencyCode string `json:"currency_code"` + ShowGST bool `json:"show_gst"` + CommercialComments string `json:"commercial_comments"` + DeliveryTime string `json:"delivery_time"` + PaymentTerms string `json:"payment_terms"` + DaysValid int32 `json:"daysValid"` + DeliveryPoint string `json:"delivery_point"` + ExchangeRate string `json:"exchange_rate"` + CustomsDuty string `json:"customs_duty"` + GSTPhrase string `json:"gst_phrase"` + SalesEngineer string `json:"sales_engineer"` + BillTo string `json:"bill_to"` + ShipTo string `json:"ship_to"` + LineItems []QuoteLineItemRequest `json:"line_items"` + Pages []string `json:"pages"` + OutputDir string `json:"output_dir"` +} + +// GenerateQuotePDF handles POST /go/pdf/generate-quote +func GenerateQuotePDF(w http.ResponseWriter, r *http.Request) { + var req QuotePDFRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("GenerateQuotePDF: JSON decode error: %v", err) + http.Error(w, fmt.Sprintf("invalid JSON payload: %v", err), http.StatusBadRequest) + return + } + log.Printf("GenerateQuotePDF: Received request - DocumentID=%d, CmcReference='%s', Revision=%d, CustomerName='%s'", + req.DocumentID, req.CmcReference, req.Revision, req.CustomerName) + if req.CmcReference == "" || req.CustomerName == "" { + log.Printf("GenerateQuotePDF: missing required fields - cmc_reference='%s', customer_name='%s'", req.CmcReference, req.CustomerName) + http.Error(w, "cmc_reference and customer_name are required", http.StatusBadRequest) + return + } + + created := time.Now() + if req.CreatedDate != "" { + if parsed, err := time.Parse("2006-01-02", req.CreatedDate); err == nil { + created = 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("GenerateQuotePDF: failed to create output dir: %v", err) + http.Error(w, "failed to prepare output directory", http.StatusInternalServerError) + return + } + + // Map request into PDF data + doc := &db.Document{ID: req.DocumentID, CmcReference: req.CmcReference, Revision: req.Revision, Created: created} + cust := &db.Customer{Name: req.CustomerName} + user := &db.GetUserRow{FirstName: req.UserFirstName, LastName: req.UserLastName, Email: req.UserEmail} + + 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, + 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}, + } + } + + data := &pdf.QuotePDFData{ + Document: doc, + Customer: cust, + EmailTo: req.ContactEmail, + Attention: req.ContactName, + User: user, + LineItems: lineItems, + CurrencySymbol: req.CurrencySymbol, + CurrencyCode: req.CurrencyCode, + ShowGST: req.ShowGST, + CommercialComments: req.CommercialComments, + DeliveryTime: req.DeliveryTime, + PaymentTerms: req.PaymentTerms, + DaysValid: int(req.DaysValid), + DeliveryPoint: req.DeliveryPoint, + ExchangeRate: req.ExchangeRate, + CustomsDuty: req.CustomsDuty, + GSTPhrase: req.GSTPhrase, + SalesEngineer: req.SalesEngineer, + BillTo: req.BillTo, + ShipTo: req.ShipTo, + IssueDateString: req.CreatedDateString, + Pages: req.Pages, + } + + // Use HTML generator + htmlGen := pdf.NewHTMLDocumentGenerator(outputDir) + filename, err := htmlGen.GenerateQuotePDF(data) + if err != nil { + log.Printf("GenerateQuotePDF: failed to generate PDF: %v", err) + http.Error(w, "failed to generate PDF", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "filename": filename, + }) +} + +// PurchaseOrderLineItemRequest reuses the invoice item shape +type PurchaseOrderLineItemRequest = InvoiceLineItemRequest + +// PurchaseOrderPDFRequest payload from PHP for POs +type PurchaseOrderPDFRequest struct { + DocumentID int32 `json:"document_id"` + Title string `json:"title"` + IssueDate string `json:"issue_date"` // YYYY-MM-DD + IssueDateString string `json:"issue_date_string"` // formatted date + PrincipleName string `json:"principle_name"` + PrincipleReference string `json:"principle_reference"` + OrderedFrom string `json:"ordered_from"` + DispatchBy string `json:"dispatch_by"` + DeliverTo string `json:"deliver_to"` + ShippingInstructions string `json:"shipping_instructions"` + CurrencySymbol string `json:"currency_symbol"` + CurrencyCode string `json:"currency_code"` + ShowGST bool `json:"show_gst"` + Subtotal float64 `json:"subtotal"` + GSTAmount float64 `json:"gst_amount"` + Total float64 `json:"total"` + LineItems []PurchaseOrderLineItemRequest `json:"line_items"` + OutputDir string `json:"output_dir"` +} + +// GeneratePurchaseOrderPDF handles POST /go/pdf/generate-po +func GeneratePurchaseOrderPDF(w http.ResponseWriter, r *http.Request) { + var req PurchaseOrderPDFRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON payload", http.StatusBadRequest) + return + } + if req.Title == "" || req.PrincipleName == "" { + http.Error(w, "title and principle_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("GeneratePurchaseOrderPDF: failed to create output dir: %v", err) + http.Error(w, "failed to prepare output directory", http.StatusInternalServerError) + return + } + + doc := &db.Document{ID: req.DocumentID} + po := &db.PurchaseOrder{ + Title: req.Title, + PrincipleReference: req.PrincipleReference, + IssueDate: issueDate, + OrderedFrom: req.OrderedFrom, + DispatchBy: req.DispatchBy, + DeliverTo: req.DeliverTo, + ShippingInstructions: req.ShippingInstructions, + } + principle := &db.Principle{Name: req.PrincipleName} + + 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}, + } + } + + data := &pdf.PurchaseOrderPDFData{ + Document: doc, + PurchaseOrder: po, + Principle: principle, + LineItems: lineItems, + CurrencySymbol: req.CurrencySymbol, + CurrencyCode: req.CurrencyCode, + ShowGST: req.ShowGST, + Subtotal: req.Subtotal, + GSTAmount: req.GSTAmount, + Total: req.Total, + IssueDateString: req.IssueDateString, + } + + // Use HTML generator + gen := pdf.NewHTMLDocumentGenerator(outputDir) + filename, err := gen.GeneratePurchaseOrderPDF(data) + if err != nil { + log.Printf("GeneratePurchaseOrderPDF: failed to generate PDF: %v", err) + http.Error(w, "failed to generate PDF", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"filename": filename}) +} + +// GeneratePackingListPDF handles POST /go/pdf/generate-packinglist +func GeneratePackingListPDF(w http.ResponseWriter, r *http.Request) { + var req PackingListPDFRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON payload", http.StatusBadRequest) + return + } + if req.CustomerName == "" { + http.Error(w, "customer_name is required", http.StatusBadRequest) + return + } + + 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("GeneratePackingListPDF: failed to create output dir: %v", err) + http.Error(w, "failed to prepare output directory", http.StatusInternalServerError) + return + } + + // Reuse the invoice generator structure but label as PACKING LIST via DetailsBox + // Build minimal data shape + doc := &db.Document{ID: req.DocumentID} + cust := &db.Customer{Name: req.CustomerName} + + 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}, + } + } + + data := &pdf.PackingListPDFData{ + PackingList: doc, + Customer: cust, + JobTitle: req.JobTitle, + IssueDateString: req.IssueDateString, + CustomerOrderNumber: req.CustomerOrderNumber, + ShipTo: req.ShipTo, + LineItems: lineItems, + } + + // Use HTML generator + gen := pdf.NewHTMLDocumentGenerator(outputDir) + filename, err := gen.GeneratePackingListPDF(data) + if err != nil { + log.Printf("GeneratePackingListPDF: failed to generate PDF: %v", err) + http.Error(w, "failed to generate PDF", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"filename": filename}) +} + +// GenerateOrderAckPDF handles POST /go/pdf/generate-orderack +func GenerateOrderAckPDF(w http.ResponseWriter, r *http.Request) { + var req OrderAckPDFRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON payload", http.StatusBadRequest) + return + } + if req.CustomerName == "" { + http.Error(w, "customer_name is required", http.StatusBadRequest) + return + } + + 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("GenerateOrderAckPDF: failed to create output dir: %v", err) + http.Error(w, "failed to prepare output directory", http.StatusInternalServerError) + return + } + + doc := &db.Document{ID: req.DocumentID} + cust := &db.Customer{Name: req.CustomerName} + + 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}, + } + } + + data := &pdf.OrderAckPDFData{ + OrderAcknowledgement: doc, + Customer: cust, + EmailTo: req.EmailTo, + Attention: req.Attention, + IssueDateString: req.IssueDateString, + YourReference: req.YourReference, + JobTitle: req.JobTitle, + BillTo: req.BillTo, + ShipTo: req.ShipTo, + ShipVia: req.ShipVia, + FOB: req.FOB, + PaymentTerms: req.PaymentTerms, + CurrencyCode: req.CurrencyCode, + CurrencySymbol: req.CurrencySymbol, + ShowGST: req.ShowGST, + LineItems: lineItems, + } + + // Use HTML generator + gen := pdf.NewHTMLDocumentGenerator(outputDir) + filename, err := gen.GenerateOrderAckPDF(data) + if err != nil { + log.Printf("GenerateOrderAckPDF: failed to generate PDF: %v", err) + http.Error(w, "failed to generate PDF", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"filename": filename}) +} + +// PackingListLineItemRequest reuses the invoice item shape +type PackingListLineItemRequest = InvoiceLineItemRequest + +// PackingListPDFRequest payload +type PackingListPDFRequest struct { + DocumentID int32 `json:"document_id"` + Title string `json:"title"` + CustomerName string `json:"customer_name"` + CustomerOrderNumber string `json:"customer_order_number"` + JobTitle string `json:"job_title"` + IssueDate string `json:"issue_date"` // YYYY-MM-DD + IssueDateString string `json:"issue_date_string"` // formatted date + ShipTo string `json:"ship_to"` + ShipVia string `json:"ship_via"` + FOB string `json:"fob"` + CurrencySymbol string `json:"currency_symbol"` + CurrencyCode string `json:"currency_code"` + ShowGST bool `json:"show_gst"` + LineItems []PackingListLineItemRequest `json:"line_items"` + OutputDir string `json:"output_dir"` +} + +// OrderAckLineItemRequest reuses the invoice item shape +type OrderAckLineItemRequest = InvoiceLineItemRequest + +// OrderAckPDFRequest payload +type OrderAckPDFRequest struct { + DocumentID int32 `json:"document_id"` + Title string `json:"title"` + CustomerName string `json:"customer_name"` + EmailTo string `json:"email_to"` + Attention string `json:"attention"` + YourReference string `json:"your_reference"` + JobTitle string `json:"job_title"` + IssueDate string `json:"issue_date"` // YYYY-MM-DD + IssueDateString string `json:"issue_date_string"` // formatted date + BillTo string `json:"bill_to"` + ShipTo string `json:"ship_to"` + ShipVia string `json:"ship_via"` + FOB string `json:"fob"` + PaymentTerms string `json:"payment_terms"` + EstimatedDelivery string `json:"estimated_delivery"` + CurrencySymbol string `json:"currency_symbol"` + CurrencyCode string `json:"currency_code"` + ShowGST bool `json:"show_gst"` + LineItems []OrderAckLineItemRequest `json:"line_items"` + OutputDir string `json:"output_dir"` +} + +// CountPagesRequest payload for page counting +type CountPagesRequest struct { + FilePath string `json:"file_path"` +} + +// CountPages handles POST /go/pdf/count-pages +// Returns JSON: {"page_count": } or {"error": ""} +func CountPages(w http.ResponseWriter, r *http.Request) { + var req CountPagesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("CountPages: JSON decode error: %v", err) + http.Error(w, "invalid JSON payload", http.StatusBadRequest) + return + } + + if req.FilePath == "" { + log.Printf("CountPages: file_path is required") + http.Error(w, "file_path is required", http.StatusBadRequest) + return + } + + // Normalize path: remove double slashes + normalizedPath := strings.ReplaceAll(req.FilePath, "//", "/") + + log.Printf("CountPages: Attempting to count pages for file: %s", normalizedPath) + + // Count pages in the PDF file + pageCount, err := pdf.CountPDFPages(normalizedPath) + if err != nil { + log.Printf("CountPages: error counting pages in %s: %v", normalizedPath, err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "page_count": 0, + "error": err.Error(), + }) + return + } + + log.Printf("CountPages: Successfully counted %d pages in %s", pageCount, normalizedPath) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]int{"page_count": pageCount}) +} diff --git a/go/internal/cmc/handlers/documents/documents_api_test.go b/go/internal/cmc/handlers/documents/documents_api_test.go new file mode 100644 index 00000000..584875e5 --- /dev/null +++ b/go/internal/cmc/handlers/documents/documents_api_test.go @@ -0,0 +1,83 @@ +//go:build never +// +build never + +package documents + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/jung-kurt/gofpdf" +) + +func createPDF(path, title string) error { + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.AddPage() + pdf.SetFont("Helvetica", "", 12) + pdf.CellFormat(0, 10, title, "", 1, "L", false, 0, "") + return pdf.OutputFileAndClose(path) +} + +func TestGenerateInvoicePDF_Handler_CreatesFile(t *testing.T) { + dir := t.TempDir() + + // Also create a terms file so merge path is exercised + if err := createPDF(filepath.Join(dir, "CMC_terms_and_conditions2006_A4.pdf"), "terms"); err != nil { + t.Fatalf("failed to create terms: %v", err) + } + + reqBody := InvoicePDFRequest{ + DocumentID: 1, + InvoiceTitle: "INV-1001", + CustomerName: "Acme Corp", + ShipVia: "Courier", + FOB: "Sydney", + IssueDate: "2025-01-01", + CurrencySymbol: "$", + ShowGST: true, + LineItems: []InvoiceLineItemRequest{{ + ItemNumber: "1", + Quantity: "2", + Title: "Widget", + UnitPrice: 10.00, + TotalPrice: 20.00, + }}, + OutputDir: dir, + } + + // Add an extra file to append + extraPath := filepath.Join(dir, "extra.pdf") + if err := createPDF(extraPath, "extra"); err != nil { + t.Fatalf("failed to create extra: %v", err) + } + reqBody.AppendFiles = []string{extraPath} + + b, _ := json.Marshal(reqBody) + r := httptest.NewRequest(http.MethodPost, "/go/pdf/generate-invoice", bytes.NewReader(b)) + w := httptest.NewRecorder() + + GenerateInvoicePDF(w, r) + + resp := w.Result() + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + data, _ := io.ReadAll(resp.Body) + t.Fatalf("unexpected status: %d body=%s", resp.StatusCode, string(data)) + } + + // Verify file created + final := filepath.Join(dir, "INV-1001.pdf") + st, err := os.Stat(final) + if err != nil { + t.Fatalf("expected output file not found: %v", err) + } + if st.Size() <= 0 { + t.Fatalf("output pdf has zero size") + } +} diff --git a/go/internal/cmc/handlers/pdf_api.go b/go/internal/cmc/handlers/pdf_api.go index f096ef17..a45164c4 100644 --- a/go/internal/cmc/handlers/pdf_api.go +++ b/go/internal/cmc/handlers/pdf_api.go @@ -407,7 +407,9 @@ func GeneratePurchaseOrderPDF(w http.ResponseWriter, r *http.Request) { IssueDateString: req.IssueDateString, } - filename, err := pdf.GeneratePurchaseOrderPDF(data, outputDir) + // Use HTML generator + gen := pdf.NewHTMLDocumentGenerator(outputDir) + filename, err := gen.GeneratePurchaseOrderPDF(data) if err != nil { log.Printf("GeneratePurchaseOrderPDF: failed to generate PDF: %v", err) http.Error(w, "failed to generate PDF", http.StatusInternalServerError) @@ -474,7 +476,9 @@ func GeneratePackingListPDF(w http.ResponseWriter, r *http.Request) { ShowGST: req.ShowGST, } - filename, err := pdf.GeneratePackingListPDF(data, outputDir) + // Use HTML generator + gen := pdf.NewHTMLDocumentGenerator(outputDir) + filename, err := gen.GeneratePackingListPDF(data) if err != nil { log.Printf("GeneratePackingListPDF: failed to generate PDF: %v", err) http.Error(w, "failed to generate PDF", http.StatusInternalServerError) @@ -539,7 +543,9 @@ func GenerateOrderAckPDF(w http.ResponseWriter, r *http.Request) { ShowGST: req.ShowGST, } - filename, err := pdf.GenerateOrderAckPDF(data, outputDir) + // Use HTML generator + gen := pdf.NewHTMLDocumentGenerator(outputDir) + filename, err := gen.GenerateOrderAckPDF(data) if err != nil { log.Printf("GenerateOrderAckPDF: failed to generate PDF: %v", err) http.Error(w, "failed to generate PDF", http.StatusInternalServerError) diff --git a/go/internal/cmc/handlers/pdf_api_test.go b/go/internal/cmc/handlers/pdf_api_test.go deleted file mode 100644 index 79ddb134..00000000 --- a/go/internal/cmc/handlers/pdf_api_test.go +++ /dev/null @@ -1,83 +0,0 @@ -//go:build never -// +build never -package handlers -package handlers - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/jung-kurt/gofpdf" -) - -func createPDF(path, title string) error { - pdf := gofpdf.New("P", "mm", "A4", "") - pdf.AddPage() - pdf.SetFont("Helvetica", "", 12) - pdf.CellFormat(0, 10, title, "", 1, "L", false, 0, "") - return pdf.OutputFileAndClose(path) -} - -func TestGenerateInvoicePDF_Handler_CreatesFile(t *testing.T) { - dir := t.TempDir() - - // Also create a terms file so merge path is exercised - if err := createPDF(filepath.Join(dir, "CMC_terms_and_conditions2006_A4.pdf"), "terms"); err != nil { - t.Fatalf("failed to create terms: %v", err) - } - - reqBody := InvoicePDFRequest{ - DocumentID: 1, - InvoiceTitle: "INV-1001", - CustomerName: "Acme Corp", - ShipVia: "Courier", - FOB: "Sydney", - IssueDate: "2025-01-01", - CurrencySymbol: "$", - ShowGST: true, - LineItems: []InvoiceLineItemRequest{{ - ItemNumber: "1", - Quantity: "2", - Title: "Widget", - UnitPrice: 10.00, - TotalPrice: 20.00, - }}, - OutputDir: dir, - } - - // Add an extra file to append - extraPath := filepath.Join(dir, "extra.pdf") - if err := createPDF(extraPath, "extra"); err != nil { - t.Fatalf("failed to create extra: %v", err) - } - reqBody.AppendFiles = []string{extraPath} - - b, _ := json.Marshal(reqBody) - r := httptest.NewRequest(http.MethodPost, "/go/pdf/generate-invoice", bytes.NewReader(b)) - w := httptest.NewRecorder() - - GenerateInvoicePDF(w, r) - - resp := w.Result() - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - data, _ := io.ReadAll(resp.Body) - t.Fatalf("unexpected status: %d body=%s", resp.StatusCode, string(data)) - } - - // Verify file created - final := filepath.Join(dir, "INV-1001.pdf") - st, err := os.Stat(final) - if err != nil { - t.Fatalf("expected output file not found: %v", err) - } - if st.Size() <= 0 { - t.Fatalf("output pdf has zero size") - } -} diff --git a/go/internal/cmc/pdf/html_generator.go b/go/internal/cmc/pdf/html_generator.go index e326083d..81a8d1f9 100644 --- a/go/internal/cmc/pdf/html_generator.go +++ b/go/internal/cmc/pdf/html_generator.go @@ -375,3 +375,87 @@ func (g *HTMLDocumentGenerator) loadLogo(logoFileName string) string { // Return relative path (same directory as HTML file) return logoFileName } + +// GeneratePurchaseOrderPDF creates a PDF purchase order from HTML template +// Returns (filename, error) +func (g *HTMLDocumentGenerator) GeneratePurchaseOrderPDF(data *PurchaseOrderPDFData) (string, error) { + fmt.Println("=== HTML Generator: Starting purchase order generation ===") + + html := g.BuildPurchaseOrderHTML(data, 0, 0) + tempHTML := filepath.Join(g.outputDir, "temp_po.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, "quote_logo.png")) + + poNumber := data.PurchaseOrder.Title + if poNumber == "" { + poNumber = fmt.Sprintf("PO-%d", data.PurchaseOrder.ID) + } + + filename := fmt.Sprintf("%s.pdf", poNumber) + pdfPath := filepath.Join(g.outputDir, filename) + + if err := g.htmlToPDF(tempHTML, pdfPath); err != nil { + return "", fmt.Errorf("failed to convert HTML to PDF: %w", err) + } + + return filename, nil +} + +// GeneratePackingListPDF creates a PDF packing list from HTML template +// Returns (filename, error) +func (g *HTMLDocumentGenerator) GeneratePackingListPDF(data *PackingListPDFData) (string, error) { + fmt.Println("=== HTML Generator: Starting packing list generation ===") + + html := g.BuildPackingListHTML(data, 0, 0) + tempHTML := filepath.Join(g.outputDir, "temp_packinglist.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, "quote_logo.png")) + + packingListNumber := data.PackingList.Title + if packingListNumber == "" { + packingListNumber = fmt.Sprintf("PackingList-%d", data.PackingList.ID) + } + + filename := fmt.Sprintf("%s.pdf", packingListNumber) + pdfPath := filepath.Join(g.outputDir, filename) + + if err := g.htmlToPDF(tempHTML, pdfPath); err != nil { + return "", fmt.Errorf("failed to convert HTML to PDF: %w", err) + } + + return filename, nil +} + +// GenerateOrderAckPDF creates a PDF order acknowledgement from HTML template +// Returns (filename, error) +func (g *HTMLDocumentGenerator) GenerateOrderAckPDF(data *OrderAckPDFData) (string, error) { + fmt.Println("=== HTML Generator: Starting order acknowledgement generation ===") + + html := g.BuildOrderAckHTML(data, 0, 0) + tempHTML := filepath.Join(g.outputDir, "temp_orderack.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, "quote_logo.png")) + + orderAckNumber := data.OrderAcknowledgement.Title + if orderAckNumber == "" { + orderAckNumber = fmt.Sprintf("OrderAck-%d", data.OrderAcknowledgement.ID) + } + + filename := fmt.Sprintf("%s.pdf", orderAckNumber) + pdfPath := filepath.Join(g.outputDir, filename) + + if err := g.htmlToPDF(tempHTML, pdfPath); err != nil { + return "", fmt.Errorf("failed to convert HTML to PDF: %w", err) + } + + return filename, nil +} diff --git a/php/app/vendors/pagecounter.php b/php/app/vendors/pagecounter.php index 2c42e53e..f37e3b12 100755 --- a/php/app/vendors/pagecounter.php +++ b/php/app/vendors/pagecounter.php @@ -22,7 +22,7 @@ class PageCounter { App::import('Controller', 'App'); $appController = new AppController(); $goBaseUrl = $appController::getGoBaseUrlOrFail(); - $goEndpoint = $goBaseUrl . '/go/api/pdf/count-pages'; + $goEndpoint = $goBaseUrl . '/go/document/page-count'; error_log("PageCounter: Calling Go endpoint: $goEndpoint with file: $file"); diff --git a/php/app/views/documents/pdf_invoice.ctp b/php/app/views/documents/pdf_invoice.ctp index 555a8cc8..c965033c 100755 --- a/php/app/views/documents/pdf_invoice.ctp +++ b/php/app/views/documents/pdf_invoice.ctp @@ -10,7 +10,7 @@ if (!isset($document) || !isset($document['Document'])) { } $goBaseUrl = AppController::getGoBaseUrlOrFail(); -$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-invoice'; +$goEndpoint = $goBaseUrl . '/go/document/generate/invoice'; error_log("=== pdf_invoice.ctp: Go endpoint: " . $goEndpoint . " ==="); $outputDir = Configure::read('pdf_directory'); diff --git a/php/app/views/documents/pdf_orderack.ctp b/php/app/views/documents/pdf_orderack.ctp index 79379e7a..59f262ae 100755 --- a/php/app/views/documents/pdf_orderack.ctp +++ b/php/app/views/documents/pdf_orderack.ctp @@ -2,7 +2,7 @@ // Generate the Order Acknowledgement PDF by calling the Go service instead of TCPDF/FPDI. $goBaseUrl = AppController::getGoBaseUrlOrFail(); -$goEndpoint = $goBaseUrl . '/go/pdf/generate-orderack'; +$goEndpoint = $goBaseUrl . '/go/document/generate/order-acknowledgement'; $outputDir = Configure::read('pdf_directory'); diff --git a/php/app/views/documents/pdf_packinglist.ctp b/php/app/views/documents/pdf_packinglist.ctp index 449b103d..fb191f2e 100755 --- a/php/app/views/documents/pdf_packinglist.ctp +++ b/php/app/views/documents/pdf_packinglist.ctp @@ -2,7 +2,7 @@ // Generate the Packing List PDF by calling the Go service instead of TCPDF/FPDI. $goBaseUrl = AppController::getGoBaseUrlOrFail(); -$goEndpoint = $goBaseUrl . '/go/pdf/generate-packinglist'; +$goEndpoint = $goBaseUrl . '/go/document/generate/packing-list'; $outputDir = Configure::read('pdf_directory'); diff --git a/php/app/views/documents/pdf_po.ctp b/php/app/views/documents/pdf_po.ctp index 2f6ac8d6..13751f34 100755 --- a/php/app/views/documents/pdf_po.ctp +++ b/php/app/views/documents/pdf_po.ctp @@ -2,7 +2,7 @@ // Generate the Purchase Order PDF by calling the Go service instead of TCPDF/FPDI. $goBaseUrl = AppController::getGoBaseUrlOrFail(); -$goEndpoint = $goBaseUrl . '/go/pdf/generate-po'; +$goEndpoint = $goBaseUrl . '/go/document/generate/purchase-order'; $outputDir = Configure::read('pdf_directory'); diff --git a/php/app/views/documents/pdf_quote.ctp b/php/app/views/documents/pdf_quote.ctp index 104f0edf..4142e514 100755 --- a/php/app/views/documents/pdf_quote.ctp +++ b/php/app/views/documents/pdf_quote.ctp @@ -2,7 +2,7 @@ // Generate the Quote PDF by calling the Go service instead of TCPDF/FPDI. $goBaseUrl = AppController::getGoBaseUrlOrFail(); -$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-quote'; +$goEndpoint = $goBaseUrl . '/go/document/generate/quote'; $outputDir = Configure::read('pdf_directory');