Migrating to new endpoints, removing tcpdf

This commit is contained in:
Finley Ghosh 2026-01-22 00:14:27 +11:00
parent b9d44fc82b
commit e6b05a726d
34 changed files with 2792 additions and 235 deletions

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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
}

View file

@ -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
}

View file

@ -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"),

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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",
},
}

View file

@ -0,0 +1,316 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{if .OrderAckNumber}}{{.OrderAckNumber}}{{else}}CMC Order Acknowledgement{{end}}</title>
<style>
@page {
size: A4;
margin: 15mm;
}
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 9pt;
line-height: 1.4;
margin: 0;
padding: 0 0 25mm 0;
color: #000;
}
.details-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 5mm;
}
.details-table td {
border: 1px solid #000;
padding: 2mm;
font-size: 9pt;
}
.details-table td.label {
font-weight: bold;
width: 25%;
background-color: #f0f0f0;
}
.address-boxes {
display: flex;
justify-content: space-between;
margin-bottom: 5mm;
}
.address-box {
width: 48%;
border: 1px solid #000;
padding: 3mm;
}
.address-box h3 {
font-size: 10pt;
margin: 0 0 2mm 0;
font-weight: bold;
}
.line-items {
width: 99.5%;
border-collapse: collapse;
margin-bottom: 5mm;
table-layout: fixed;
margin-right: 1mm;
margin-left: auto;
page-break-after: avoid;
}
.line-items th {
background-color: #f0f0f0;
border: 1px solid #000;
padding: 2mm;
font-weight: bold;
text-align: left;
}
.line-items tbody tr {
page-break-inside: avoid;
}
.line-items td {
border: 1px solid #000;
padding: 2mm;
vertical-align: top;
}
.line-items .description {
width: 50%;
word-wrap: break-word;
overflow-wrap: break-word;
}
.line-items .qty {
width: 10%;
text-align: center;
}
.line-items .unit-price {
width: 13%;
text-align: right;
}
.line-items .discount {
width: 13%;
text-align: right;
}
.line-items .total {
width: 14%;
text-align: right;
}
.totals-container {
display: flex;
justify-content: flex-end;
margin-top: 5mm;
page-break-inside: avoid;
page-break-before: avoid;
}
.totals-table {
width: auto;
border-collapse: collapse;
border: 1px solid #000;
margin-right: 0.2mm;
}
.totals-table td {
padding: 1mm 3mm;
border: 1px solid #000;
}
.totals-table td:first-child {
text-align: left;
font-weight: bold;
width: 50%;
background-color: #f0f0f0;
}
.totals-table td:last-child {
text-align: right;
width: 50%;
}
.totals-table .total-row td {
font-weight: bold;
font-size: 11pt;
padding-top: 2mm;
border-top: 1px solid #000;
}
.description strong {
font-weight: bold;
}
.description em {
font-style: italic;
}
.description ul, .description ol {
margin: 2mm 0;
padding-left: 5mm;
}
.description li {
margin: 1mm 0;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 20mm;
border-top: 1px solid #000;
padding-top: 2mm;
font-size: 9pt;
text-align: center;
}
.footer .services-title {
font-weight: normal;
margin-bottom: 2mm;
}
.footer .services-line1, .footer .services-line2 {
margin: 1mm 0;
}
.footer .service-explosion { color: #990006; }
.footer .service-fire { color: #FF9900; }
.footer .service-pressure { color: #FF0019; }
.footer .service-vision { color: #00801E; }
.footer .service-flow { color: #2F4BE0; }
.footer .service-process { color: #AB31F8; }
</style>
</head>
<body>
{{template "CompanyHeader" .}}
<!-- Order Ack Details Table -->
<table class="details-table">
<tr>
<td class="label">COMPANY NAME:</td>
<td>{{.CompanyName}}</td>
<td class="label">ORDER ACK #:</td>
<td>{{.OrderAckNumber}}</td>
</tr>
<tr>
<td class="label">CONTACT:</td>
<td>{{.Attention}}</td>
<td class="label">DATE:</td>
<td>{{.IssueDateString}}</td>
</tr>
<tr>
<td class="label">EMAIL:</td>
<td>{{.EmailTo}}</td>
<td class="label">YOUR REFERENCE:</td>
<td>{{.YourReference}}</td>
</tr>
<tr>
<td class="label">JOB TITLE:</td>
<td colspan="3">{{.JobTitle}}</td>
</tr>
</table>
<!-- Address Boxes -->
<div class="address-boxes">
<div class="address-box">
<h3>BILL TO:</h3>
<p>{{.BillTo}}</p>
</div>
<div class="address-box">
<h3>SHIP TO:</h3>
<p>{{.ShipTo}}</p>
</div>
</div>
<!-- Shipping Details -->
<table class="details-table">
<tr>
<td class="label">SHIP VIA:</td>
<td>{{.ShipVia}}</td>
<td class="label">FOB:</td>
<td>{{.FOB}}</td>
</tr>
<tr>
<td class="label">PAYMENT TERMS:</td>
<td colspan="3">{{.PaymentTerms}}</td>
</tr>
</table>
<!-- Currency Code Header -->
<div style="margin-bottom: 1mm; text-align: right; font-weight: bold; font-size: 9pt;">
Shown in {{.CurrencyCode}}
</div>
<!-- Line Items Table -->
<table class="line-items">
<thead>
<tr>
<th class="description">DESCRIPTION</th>
<th class="qty">QTY</th>
<th class="unit-price">UNIT PRICE</th>
<th class="discount">DISCOUNT</th>
<th class="total">TOTAL</th>
</tr>
</thead>
<tbody>
{{range .LineItems}}
<tr>
<td class="description"><strong>{{.Title}}</strong>{{if .Description}}<br>{{.Description}}{{end}}</td>
<td class="qty">{{.Quantity}}</td>
<td class="unit-price">{{formatPrice .UnitPrice}}</td>
<td class="discount">{{if .Discount}}{{formatPrice .Discount}}{{else}}$0.00{{end}}</td>
<td class="total">{{formatPrice .TotalPrice}}</td>
</tr>
{{end}}
</tbody>
</table>
<!-- Totals -->
<div class="totals-container">
<table class="totals-table">
<tr>
<td>SUBTOTAL</td>
<td>{{formatTotal .Subtotal}}</td>
</tr>
{{if .ShowGST}}
<tr>
<td>GST (10%)</td>
<td>{{formatTotal .GSTAmount}}</td>
</tr>
{{end}}
<tr class="total-row">
<td>{{if .ShowGST}}TOTAL PAYABLE{{else}}TOTAL{{end}}</td>
<td>{{formatTotal .Total}}</td>
</tr>
</table>
</div>
<!-- Footer -->
<div class="footer">
<div class="services-title">CMC TECHNOLOGIES Provides Solutions in the Following Fields</div>
<div class="services-line1">
<span class="service-explosion">EXPLOSION PREVENTION AND PROTECTION</span>
<span class="service-fire">FIRE PROTECTION</span>
<span class="service-pressure">PRESSURE RELIEF</span>
<span class="service-vision">VISION IN THE PROCESS</span>
</div>
<div class="services-line2">
<span class="service-flow">FLOW MEASUREMENT</span>
<span class="service-process">PROCESS INSTRUMENTATION</span>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,216 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{if .PackingListNumber}}{{.PackingListNumber}}{{else}}CMC Packing List{{end}}</title>
<style>
@page {
size: A4;
margin: 15mm;
}
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 9pt;
line-height: 1.4;
margin: 0;
padding: 0 0 25mm 0;
color: #000;
}
.details-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 5mm;
}
.details-table td {
border: 1px solid #000;
padding: 2mm;
font-size: 9pt;
}
.details-table td.label {
font-weight: bold;
width: 25%;
background-color: #f0f0f0;
}
.address-boxes {
display: flex;
justify-content: space-between;
margin-bottom: 5mm;
}
.address-box {
width: 48%;
border: 1px solid #000;
padding: 3mm;
}
.address-box h3 {
font-size: 10pt;
margin: 0 0 2mm 0;
font-weight: bold;
}
.line-items {
width: 99.5%;
border-collapse: collapse;
margin-bottom: 5mm;
table-layout: fixed;
margin-right: 1mm;
margin-left: auto;
page-break-after: avoid;
}
.line-items th {
background-color: #f0f0f0;
border: 1px solid #000;
padding: 2mm;
font-weight: bold;
text-align: left;
}
.line-items tbody tr {
page-break-inside: avoid;
}
.line-items td {
border: 1px solid #000;
padding: 2mm;
vertical-align: top;
}
.line-items .description {
width: 70%;
word-wrap: break-word;
overflow-wrap: break-word;
}
.line-items .qty {
width: 15%;
text-align: center;
}
.line-items .weight {
width: 15%;
text-align: right;
}
.description strong {
font-weight: bold;
}
.description em {
font-style: italic;
}
.description ul, .description ol {
margin: 2mm 0;
padding-left: 5mm;
}
.description li {
margin: 1mm 0;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 20mm;
border-top: 1px solid #000;
padding-top: 2mm;
font-size: 9pt;
text-align: center;
}
.footer .services-title {
font-weight: normal;
margin-bottom: 2mm;
}
.footer .services-line1, .footer .services-line2 {
margin: 1mm 0;
}
.footer .service-explosion { color: #990006; }
.footer .service-fire { color: #FF9900; }
.footer .service-pressure { color: #FF0019; }
.footer .service-vision { color: #00801E; }
.footer .service-flow { color: #2F4BE0; }
.footer .service-process { color: #AB31F8; }
</style>
</head>
<body>
{{template "CompanyHeader" .}}
<!-- Packing List Details Table -->
<table class="details-table">
<tr>
<td class="label">PACKING LIST #:</td>
<td>{{.PackingListNumber}}</td>
<td class="label">DATE:</td>
<td>{{.IssueDateString}}</td>
</tr>
<tr>
<td class="label">JOB TITLE:</td>
<td colspan="3">{{.JobTitle}}</td>
</tr>
<tr>
<td class="label">CUSTOMER ORDER #:</td>
<td colspan="3">{{.CustomerOrderNumber}}</td>
</tr>
</table>
<!-- Address Boxes -->
<div class="address-boxes">
<div class="address-box">
<h3>FROM:</h3>
<p>{{.FromAddress}}</p>
</div>
<div class="address-box">
<h3>TO:</h3>
<p>{{.ShipTo}}</p>
</div>
</div>
<!-- Line Items Table -->
<table class="line-items">
<thead>
<tr>
<th class="description">DESCRIPTION</th>
<th class="qty">QTY</th>
<th class="weight">WEIGHT (kg)</th>
</tr>
</thead>
<tbody>
{{range .LineItems}}
<tr>
<td class="description"><strong>{{.Title}}</strong>{{if .Description}}<br>{{.Description}}{{end}}</td>
<td class="qty">{{.Quantity}}</td>
<td class="weight">{{if .Weight}}{{.Weight}}{{else}}-{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
<!-- Footer -->
<div class="footer">
<div class="services-title">CMC TECHNOLOGIES Provides Solutions in the Following Fields</div>
<div class="services-line1">
<span class="service-explosion">EXPLOSION PREVENTION AND PROTECTION</span>
<span class="service-fire">FIRE PROTECTION</span>
<span class="service-pressure">PRESSURE RELIEF</span>
<span class="service-vision">VISION IN THE PROCESS</span>
</div>
<div class="services-line2">
<span class="service-flow">FLOW MEASUREMENT</span>
<span class="service-process">PROCESS INSTRUMENTATION</span>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,286 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{if .PONumber}}{{.PONumber}}{{else}}CMC Purchase Order{{end}}</title>
<style>
@page {
size: A4;
margin: 15mm;
}
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 9pt;
line-height: 1.4;
margin: 0;
padding: 0 0 25mm 0;
color: #000;
}
.details-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 5mm;
}
.details-table td {
border: 1px solid #000;
padding: 2mm;
font-size: 9pt;
}
.details-table td.label {
font-weight: bold;
width: 25%;
background-color: #f0f0f0;
}
.line-items {
width: 99.5%;
border-collapse: collapse;
margin-bottom: 5mm;
table-layout: fixed;
margin-right: 1mm;
margin-left: auto;
page-break-after: avoid;
}
.line-items th {
background-color: #f0f0f0;
border: 1px solid #000;
padding: 2mm;
font-weight: bold;
text-align: left;
}
.line-items tbody tr {
page-break-inside: avoid;
}
.line-items td {
border: 1px solid #000;
padding: 2mm;
vertical-align: top;
}
.line-items .description {
width: 50%;
word-wrap: break-word;
overflow-wrap: break-word;
}
.line-items .qty {
width: 10%;
text-align: center;
}
.line-items .unit-price {
width: 20%;
text-align: right;
}
.line-items .total {
width: 20%;
text-align: right;
}
.totals-container {
display: flex;
justify-content: flex-end;
margin-top: 5mm;
page-break-inside: avoid;
page-break-before: avoid;
}
.totals-table {
width: auto;
border-collapse: collapse;
border: 1px solid #000;
margin-right: 0.2mm;
}
.totals-table td {
padding: 1mm 3mm;
border: 1px solid #000;
}
.totals-table td:first-child {
text-align: left;
font-weight: bold;
width: 50%;
background-color: #f0f0f0;
}
.totals-table td:last-child {
text-align: right;
width: 50%;
}
.totals-table .total-row td {
font-weight: bold;
font-size: 11pt;
padding-top: 2mm;
border-top: 1px solid #000;
}
.description strong {
font-weight: bold;
}
.description em {
font-style: italic;
}
.description ul, .description ol {
margin: 2mm 0;
padding-left: 5mm;
}
.description li {
margin: 1mm 0;
}
.info-section {
margin: 5mm 0;
}
.info-section h3 {
font-size: 10pt;
font-weight: bold;
margin: 0 0 2mm 0;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 20mm;
border-top: 1px solid #000;
padding-top: 2mm;
font-size: 9pt;
text-align: center;
}
.footer .services-title {
font-weight: normal;
margin-bottom: 2mm;
}
.footer .services-line1, .footer .services-line2 {
margin: 1mm 0;
}
.footer .service-explosion { color: #990006; }
.footer .service-fire { color: #FF9900; }
.footer .service-pressure { color: #FF0019; }
.footer .service-vision { color: #00801E; }
.footer .service-flow { color: #2F4BE0; }
.footer .service-process { color: #AB31F8; }
</style>
</head>
<body>
{{template "CompanyHeader" .}}
<!-- PO Details Table -->
<table class="details-table">
<tr>
<td class="label">SUPPLIER:</td>
<td>{{.PrincipleName}}</td>
<td class="label">PO NUMBER:</td>
<td>{{.PONumber}}</td>
</tr>
<tr>
<td class="label">YOUR REFERENCE:</td>
<td>{{.YourReference}}</td>
<td class="label">ISSUE DATE:</td>
<td>{{.IssueDateString}}</td>
</tr>
<tr>
<td class="label">ORDERED FROM:</td>
<td colspan="3">{{.OrderedFrom}}</td>
</tr>
<tr>
<td class="label">DELIVER TO:</td>
<td colspan="3">{{.DeliverTo}}</td>
</tr>
{{if .DispatchBy}}
<tr>
<td class="label">DISPATCH BY:</td>
<td colspan="3">{{.DispatchBy}}</td>
</tr>
{{end}}
</table>
{{if .ShippingInstructions}}
<div class="info-section">
<h3>SHIPPING INSTRUCTIONS</h3>
<p>{{.ShippingInstructions}}</p>
</div>
{{end}}
<!-- Currency Code Header -->
<div style="margin-bottom: 1mm; text-align: right; font-weight: bold; font-size: 9pt;">
Shown in {{.CurrencyCode}}
</div>
<!-- Line Items Table -->
<table class="line-items">
<thead>
<tr>
<th class="description">DESCRIPTION</th>
<th class="qty">QTY</th>
<th class="unit-price">UNIT PRICE</th>
<th class="total">TOTAL</th>
</tr>
</thead>
<tbody>
{{range .LineItems}}
<tr>
<td class="description"><strong>{{.Title}}</strong>{{if .Description}}<br>{{.Description}}{{end}}</td>
<td class="qty">{{.Quantity}}</td>
<td class="unit-price">{{formatPrice .UnitPrice}}</td>
<td class="total">{{formatPrice .TotalPrice}}</td>
</tr>
{{end}}
</tbody>
</table>
<!-- Totals -->
<div class="totals-container">
<table class="totals-table">
<tr>
<td>SUBTOTAL</td>
<td>{{formatTotal .Subtotal}}</td>
</tr>
{{if .ShowGST}}
<tr>
<td>GST (10%)</td>
<td>{{formatTotal .GSTAmount}}</td>
</tr>
{{end}}
<tr class="total-row">
<td>{{if .ShowGST}}TOTAL PAYABLE{{else}}TOTAL{{end}}</td>
<td>{{formatTotal .Total}}</td>
</tr>
</table>
</div>
<!-- Footer -->
<div class="footer">
<div class="services-title">CMC TECHNOLOGIES Provides Solutions in the Following Fields</div>
<div class="services-line1">
<span class="service-explosion">EXPLOSION PREVENTION AND PROTECTION</span>
<span class="service-fire">FIRE PROTECTION</span>
<span class="service-pressure">PRESSURE RELIEF</span>
<span class="service-vision">VISION IN THE PROCESS</span>
</div>
<div class="services-line2">
<span class="service-flow">FLOW MEASUREMENT</span>
<span class="service-process">PROCESS INSTRUMENTATION</span>
</div>
</div>
</body>
</html>

View file

@ -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, `<div class="has-text-centered has-text-grey">
<p>No recent activity</p>
@ -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
</span>
</a>
</div>`)
}
}

View file

@ -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 <br>
func escapeToHTML(s string) string {
s = html.EscapeString(s)
s = strings.ReplaceAll(s, "\n", "<br>")
return s
}
// InvoiceLineItemRequest is the JSON shape for a single line item.
type InvoiceLineItemRequest struct {
ItemNumber string `json:"item_number"`
Quantity string `json:"quantity"`
Title string `json:"title"`
Description string `json:"description"`
IsHTML bool `json:"is_html"` // Flag to indicate description contains HTML
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
NetUnitPrice float64 `json:"net_unit_price"`
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":"<name>.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": <number>} or {"error": "<message>"}
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})
}

View file

@ -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")
}
}

View file

@ -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)

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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");

View file

@ -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');

View file

@ -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');

View file

@ -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');

View file

@ -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');

View file

@ -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');