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}}
+
+
+
+
+
+
+ | DESCRIPTION |
+ QTY |
+ UNIT PRICE |
+ DISCOUNT |
+ TOTAL |
+
+
+
+ {{range .LineItems}}
+
+ {{.Title}}{{if .Description}} {{.Description}}{{end}} |
+ {{.Quantity}} |
+ {{formatPrice .UnitPrice}} |
+ {{if .Discount}}{{formatPrice .Discount}}{{else}}$0.00{{end}} |
+ {{formatPrice .TotalPrice}} |
+
+ {{end}}
+
+
+
+
+
+
+
+ | SUBTOTAL |
+ {{formatTotal .Subtotal}} |
+
+ {{if .ShowGST}}
+
+ | GST (10%) |
+ {{formatTotal .GSTAmount}} |
+
+ {{end}}
+
+ | {{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}}
+
+
+
+
+
+
+
+
+ | DESCRIPTION |
+ QTY |
+ WEIGHT (kg) |
+
+
+
+ {{range .LineItems}}
+
+ {{.Title}}{{if .Description}} {{.Description}}{{end}} |
+ {{.Quantity}} |
+ {{if .Weight}}{{.Weight}}{{else}}-{{end}} |
+
+ {{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" .}}
+
+
+
+
+ | SUPPLIER: |
+ {{.PrincipleName}} |
+ PO NUMBER: |
+ {{.PONumber}} |
+
+
+ | YOUR REFERENCE: |
+ {{.YourReference}} |
+ ISSUE DATE: |
+ {{.IssueDateString}} |
+
+
+ | ORDERED FROM: |
+ {{.OrderedFrom}} |
+
+
+ | DELIVER TO: |
+ {{.DeliverTo}} |
+
+ {{if .DispatchBy}}
+
+ | DISPATCH BY: |
+ {{.DispatchBy}} |
+
+ {{end}}
+
+
+ {{if .ShippingInstructions}}
+
+
SHIPPING INSTRUCTIONS
+
{{.ShippingInstructions}}
+
+ {{end}}
+
+
+
+ Shown in {{.CurrencyCode}}
+
+
+
+
+
+
+ | DESCRIPTION |
+ QTY |
+ UNIT PRICE |
+ TOTAL |
+
+
+
+ {{range .LineItems}}
+
+ {{.Title}}{{if .Description}} {{.Description}}{{end}} |
+ {{.Quantity}} |
+ {{formatPrice .UnitPrice}} |
+ {{formatPrice .TotalPrice}} |
+
+ {{end}}
+
+
+
+
+
+
+
+ | SUBTOTAL |
+ {{formatTotal .Subtotal}} |
+
+ {{if .ShowGST}}
+
+ | GST (10%) |
+ {{formatTotal .GSTAmount}} |
+
+ {{end}}
+
+ | {{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');