Starting quote generation, better file mounting for local

This commit is contained in:
Finley Ghosh 2026-01-17 15:41:24 +11:00
parent 67dba461f2
commit 72a4b87193
23 changed files with 1182 additions and 417 deletions

1
.gitignore vendored
View file

@ -14,6 +14,7 @@ app/cake_eclipse_helper.php
app/webroot/pdf/*
app/webroot/attachments_files/*
backups/*
files/
# Go binaries
go/server

View file

@ -28,8 +28,8 @@ services:
depends_on:
- db
volumes:
- ./php/app/webroot/pdf:/var/www/cmc-sales/app/webroot/pdf
- ./php/app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
- ./files/pdf:/var/www/cmc-sales/app/webroot/pdf
- ./files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
networks:
- cmc-network
restart: unless-stopped
@ -86,8 +86,8 @@ services:
- ./go:/app
- ./go/.air.toml:/root/.air.toml
- ./go/.env.example:/root/.env
- ./php/app/webroot/pdf:/var/www/cmc-sales/app/webroot/pdf
- ./php/app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
- ./files/pdf:/var/www/cmc-sales/app/webroot/pdf
- ./files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
networks:
- cmc-network
restart: unless-stopped

View file

@ -84,7 +84,6 @@ func main() {
// PDF generation routes (under /api/pdf/* to avoid conflict with file server)
goRouter.HandleFunc("/api/pdf/generate-invoice", handlers.GenerateInvoicePDF).Methods("POST")
goRouter.HandleFunc("/api/pdf/generate-invoice-html", handlers.GenerateInvoicePDFHTML).Methods("POST") // HTML version
goRouter.HandleFunc("/api/pdf/generate-quote", handlers.GenerateQuotePDF).Methods("POST")
goRouter.HandleFunc("/api/pdf/generate-po", handlers.GeneratePurchaseOrderPDF).Methods("POST")
goRouter.HandleFunc("/api/pdf/generate-packinglist", handlers.GeneratePackingListPDF).Methods("POST")

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
@ -129,16 +130,21 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
// Get attachments directory from environment or use default
attachDir := os.Getenv("ATTACHMENTS_DIR")
log.Printf("=== Upload Debug: ATTACHMENTS_DIR env var = '%s'", attachDir)
if attachDir == "" {
attachDir = "webroot/attachments_files"
log.Printf("=== Upload Debug: Using fallback path: %s", attachDir)
}
log.Printf("=== Upload Debug: Final attachDir = '%s'", attachDir)
if err := os.MkdirAll(attachDir, 0755); err != nil {
log.Printf("=== Upload Debug: Failed to create directory: %v", err)
http.Error(w, "Failed to create attachments directory", http.StatusInternalServerError)
return
}
// Save file to disk
filePath := filepath.Join(attachDir, filename)
log.Printf("=== Upload Debug: Saving file to: %s", filePath)
dst, err := os.Create(filePath)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
@ -190,6 +196,8 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
params.Name = handler.Filename
}
log.Printf("=== Upload Debug: Storing in database - File path: %s, Name: %s", params.File, params.Name)
result, err := h.queries.CreateAttachment(r.Context(), params)
if err != nil {
// Clean up file on error

View file

@ -77,6 +77,7 @@ type InvoicePDFRequest struct {
// 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 {
@ -109,105 +110,12 @@ func GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
return
}
// Map request into the existing PDF generation types.
doc := &db.Document{ID: req.DocumentID}
inv := &db.Invoice{Title: req.InvoiceTitle}
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,
Description: li.Description,
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
DiscountPercent: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountPercent), Valid: li.DiscountPercent > 0},
DiscountAmountUnit: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountUnit), Valid: li.DiscountAmountUnit > 0},
DiscountAmountTotal: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountTotal), Valid: li.DiscountAmountTotal > 0},
}
}
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,
}
filename, err := pdf.GenerateInvoicePDF(data, outputDir)
if err != nil {
log.Printf("GenerateInvoicePDF: 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})
}
// GenerateInvoicePDFHTML generates invoice using HTML template and chromedp
func GenerateInvoicePDFHTML(w http.ResponseWriter, r *http.Request) {
var req InvoicePDFRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
return
}
if req.InvoiceTitle == "" || req.CustomerName == "" {
http.Error(w, "invoice_title and customer_name are required", http.StatusBadRequest)
return
}
issueDate := time.Now()
if req.IssueDate != "" {
if parsed, err := time.Parse("2006-01-02", req.IssueDate); err == nil {
issueDate = parsed
}
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = os.Getenv("PDF_OUTPUT_DIR")
}
if outputDir == "" {
outputDir = "../php/app/webroot/pdf"
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
log.Printf("GenerateInvoicePDFHTML: failed to create output dir: %v", err)
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
return
}
// Map request into the existing PDF generation types
doc := &db.Document{ID: req.DocumentID, CmcReference: req.InvoiceNumber}
inv := &db.Invoice{Title: req.InvoiceTitle}
cust := &db.Customer{Name: req.CustomerName}
log.Printf("GenerateInvoicePDFHTML: Setting invoice number to: %s", req.InvoiceNumber)
log.Printf("GenerateInvoicePDF: Setting invoice number to: %s", req.InvoiceNumber)
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
for i, li := range req.LineItems {
@ -262,16 +170,18 @@ func GenerateInvoicePDFHTML(w http.ResponseWriter, r *http.Request) {
}
// Use HTML generator instead of gofpdf
htmlGen := pdf.NewHTMLInvoiceGenerator(outputDir)
htmlGen := pdf.NewHTMLDocumentGenerator(outputDir)
filename, err := htmlGen.GenerateInvoicePDF(data)
if err != nil {
log.Printf("GenerateInvoicePDFHTML: failed to generate PDF: %v", err)
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})
_ = json.NewEncoder(w).Encode(map[string]string{
"filename": filename,
})
}
// QuoteLineItemRequest reuses the invoice item shape
@ -314,10 +224,12 @@ type QuotePDFRequest struct {
func GenerateQuotePDF(w http.ResponseWriter, r *http.Request) {
var req QuotePDFRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
log.Printf("GenerateQuotePDF: JSON decode error: %v", err)
http.Error(w, fmt.Sprintf("invalid JSON payload: %v", err), http.StatusBadRequest)
return
}
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
}
@ -383,7 +295,9 @@ func GenerateQuotePDF(w http.ResponseWriter, r *http.Request) {
Pages: req.Pages,
}
filename, err := pdf.GenerateQuotePDF(data, outputDir)
// 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)
@ -391,7 +305,9 @@ func GenerateQuotePDF(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
_ = json.NewEncoder(w).Encode(map[string]string{
"filename": filename,
})
}
// PurchaseOrderLineItemRequest reuses the invoice item shape
@ -681,15 +597,19 @@ type CountPagesRequest struct {
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
}
log.Printf("CountPages: Attempting to count pages for file: %s", req.FilePath)
// Count pages in the PDF file
pageCount, err := pdf.CountPDFPages(req.FilePath)
if err != nil {
@ -703,6 +623,7 @@ func CountPages(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("CountPages: Successfully counted %d pages in %s", pageCount, req.FilePath)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]int{"page_count": pageCount})
}

View file

@ -1,7 +0,0 @@
package pdf
// formatDescription passes through descriptions as-is
// HTML formatting should be applied in PHP before sending to this API
func formatDescription(text string) string {
return text
}

View file

@ -1,115 +0,0 @@
package pdf
import (
"strings"
"testing"
)
func TestFormatDescription(t *testing.T) {
input := `Item Code: B25SEZ22B0
Item Description: SE Sensor Zone 22
Type: SE - To control the function of the rupture disc
Cable Length: 2m
Ui< 40V
li<57mA
Li, Ci negligible
II 2G Ex ib IIC T6 (Gb)
II 2D Ex ib IIC T 80 deg C IP65 ((Db) -25 deg C < Ta < +80 deg C
IBEx U1ATEX1017
Includes installation instruction
With standard Angle Bracket to suit Brilex Non-Insulated Explosion Vents
(If Insulated panels are used a modified (vertically extended) bracket needs to be used)
See attached EC Conformity Declaration for the SE Sensor
Testing at
1. -4 deg C
2. -30 deg C
3. 20 deg C`
output := formatDescription(input)
// Check that key: value pairs are bolded
if !strings.Contains(output, "<strong>Item Code:</strong>") {
t.Error("Item Code should be bolded")
}
if !strings.Contains(output, "<strong>Item Description:</strong>") {
t.Error("Item Description should be bolded")
}
// Check that list items are in <ol><li> tags
if !strings.Contains(output, "<ol>") {
t.Error("Ordered list should have <ol> tag")
}
if !strings.Contains(output, "<li>-4 deg C</li>") {
t.Error("List item 1 not formatted correctly")
}
// Check that italic patterns are applied
if !strings.Contains(output, "<em>See attached EC Conformity Declaration for the SE Sensor</em>") {
t.Error("Italic pattern not applied to EC Conformity Declaration text")
}
// Verify HTML tags are properly balanced (count opening/closing tag pairs)
strongOpens := strings.Count(output, "<strong>")
strongCloses := strings.Count(output, "</strong>")
if strongOpens != strongCloses {
t.Errorf("Unbalanced <strong> tags: %d opens, %d closes", strongOpens, strongCloses)
}
emOpens := strings.Count(output, "<em>")
emCloses := strings.Count(output, "</em>")
if emOpens != emCloses {
t.Errorf("Unbalanced <em> tags: %d opens, %d closes", emOpens, emCloses)
}
olOpens := strings.Count(output, "<ol>")
olCloses := strings.Count(output, "</ol>")
if olOpens != olCloses {
t.Errorf("Unbalanced <ol> tags: %d opens, %d closes", olOpens, olCloses)
}
t.Logf("Formatted output:\n%s", output)
}
func TestIsOrderedListItem(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"1. Item one", true},
{"2. Item two", true},
{"10. Item ten", true},
{"Item without number", false},
{"1 Item without dot", false},
{"Item: with colon", false},
}
for _, test := range tests {
result := isOrderedListItem(test.input)
if result != test.expected {
t.Errorf("isOrderedListItem(%q) = %v, want %v", test.input, result, test.expected)
}
}
}
func TestFormatLine(t *testing.T) {
tests := []struct {
input string
contains string
}{
{"Item Code: B25SEZ22B0", "<strong>Item Code:</strong>"},
{"Type: SE - To control", "<strong>Type:</strong>"},
{"Random text here", "Random text here"},
}
for _, test := range tests {
result := formatLine(test.input)
if !strings.Contains(result, test.contains) {
t.Errorf("formatLine(%q) should contain %q, got %q", test.input, test.contains, result)
}
}
}

View file

@ -1,16 +1,11 @@
package pdf
import (
"bytes"
"context"
"database/sql"
"fmt"
"html/template"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/chromedp/cdproto/page"
@ -18,25 +13,26 @@ import (
"github.com/pdfcpu/pdfcpu/pkg/api"
)
// HTMLInvoiceGenerator generates PDF invoices from HTML templates using chromedp
type HTMLInvoiceGenerator struct {
// HTMLDocumentGenerator generates PDF documents from HTML templates using chromedp
type HTMLDocumentGenerator struct {
outputDir string
}
// NewHTMLInvoiceGenerator creates a new HTML-based invoice generator
func NewHTMLInvoiceGenerator(outputDir string) *HTMLInvoiceGenerator {
return &HTMLInvoiceGenerator{
// 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
func (g *HTMLInvoiceGenerator) GenerateInvoicePDF(data *InvoicePDFData) (string, error) {
// 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)
html := g.BuildInvoiceHTML(data, 0, 0)
fmt.Printf("=== HTML Generator: Generated %d bytes of HTML ===\n", len(html))
@ -88,7 +84,7 @@ func (g *HTMLInvoiceGenerator) GenerateInvoicePDF(data *InvoicePDFData) (string,
// SECOND PASS: Generate final PDF with correct page count
fmt.Printf("=== HTML Generator: Second pass - regenerating with page count %d ===\n", totalPageCount)
html = g.buildInvoiceHTML(data, totalPageCount, 1)
html = g.BuildInvoiceHTML(data, totalPageCount, 1)
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
return "", fmt.Errorf("failed to write temp HTML (second pass): %w", err)
@ -129,8 +125,110 @@ func (g *HTMLInvoiceGenerator) GenerateInvoicePDF(data *InvoicePDFData) (string,
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 with correct page count
fmt.Printf("=== HTML Generator: Second pass - regenerating with page count %d ===\n", totalPageCount)
html = g.BuildQuoteHTML(data, totalPageCount, 1)
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
return "", fmt.Errorf("failed to write temp HTML (second pass): %w", err)
}
// Generate final PDF filename using quote number
quoteNumber := ""
if data.Document != nil {
quoteNumber = data.Document.CmcReference
if data.Document.Revision > 0 {
quoteNumber = fmt.Sprintf("%s_%d", quoteNumber, data.Document.Revision)
}
}
filenameBase := quoteNumber
if filenameBase == "" {
filenameBase = "CMC Quote"
}
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)
return filename, nil
}
// Replace original with merged version
if err := os.Rename(tempMergedPath, pdfPath); err != nil {
fmt.Printf("=== HTML Generator: Warning - could not replace PDF: %v\n", err)
}
}
return filename, nil
}
// htmlToPDF converts an HTML file to PDF using chromedp
func (g *HTMLInvoiceGenerator) htmlToPDF(htmlPath, pdfPath string) error {
func (g *HTMLDocumentGenerator) htmlToPDF(htmlPath, pdfPath string) error {
// Read the HTML file to get its content
htmlContent, err := ioutil.ReadFile(htmlPath)
if err != nil {
@ -198,7 +296,7 @@ func (g *HTMLInvoiceGenerator) htmlToPDF(htmlPath, pdfPath string) error {
}
// getPageCount extracts the page count from a PDF file
func (g *HTMLInvoiceGenerator) getPageCount(pdfPath string) (int, error) {
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)
@ -206,8 +304,8 @@ func (g *HTMLInvoiceGenerator) getPageCount(pdfPath string) (int, error) {
return pageCount, nil
}
// loadLogoAsBase64 loads the logo image and returns it as a relative path
func (g *HTMLInvoiceGenerator) loadLogoAsBase64() string {
// 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 {
@ -222,7 +320,7 @@ func (g *HTMLInvoiceGenerator) loadLogoAsBase64() string {
}
// Copy logo to output directory for chromedp to access
destPath := filepath.Join(g.outputDir, "invoice_logo.png")
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 ""
@ -230,160 +328,5 @@ func (g *HTMLInvoiceGenerator) loadLogoAsBase64() string {
fmt.Printf("=== Copied logo from %s to %s ===\n", logoPath, destPath)
// Return relative path (same directory as HTML file)
return "invoice_logo.png"
}
// buildInvoiceHTML generates the complete HTML for an invoice using templates
func (g *HTMLInvoiceGenerator) buildInvoiceHTML(data *InvoicePDFData, totalPages int, currentPage int) string {
// Get invoice number, fall back to invoice title so the template always shows something meaningful
invoiceNum := ""
if data.Document != nil {
invoiceNum = data.Document.CmcReference
}
if invoiceNum == "" {
invoiceNum = data.Invoice.Title
}
fmt.Printf("=== buildInvoiceHTML: Invoice number: %s ===\n", invoiceNum)
// Prepare template data
templateData := struct {
InvoiceNumber string
IssueDateString string
BillTo template.HTML
ShipTo template.HTML
CustomerOrderNumber string
JobTitle string
FOB string
PaymentTerms string
CustomerABN string
CurrencyCode string
CurrencySymbol string
LineItems []LineItemTemplateData
Subtotal interface{}
GSTAmount interface{}
Total interface{}
ShowGST bool
PageCount int
CurrentPage int
LogoDataURI string
}{
InvoiceNumber: invoiceNum,
IssueDateString: data.IssueDateString,
BillTo: template.HTML(data.BillTo),
ShipTo: template.HTML(data.ShipTo),
CustomerOrderNumber: data.CustomerOrderNumber,
JobTitle: data.JobTitle,
FOB: data.FOB,
PaymentTerms: data.PaymentTerms,
CustomerABN: data.CustomerABN,
CurrencyCode: data.CurrencyCode,
CurrencySymbol: data.CurrencySymbol,
Subtotal: data.Subtotal,
GSTAmount: data.GSTAmount,
Total: data.Total,
ShowGST: data.ShowGST,
PageCount: totalPages,
CurrentPage: currentPage,
LogoDataURI: g.loadLogoAsBase64(),
}
// Convert line items to template format
for _, item := range data.LineItems {
templateData.LineItems = append(templateData.LineItems, LineItemTemplateData{
Title: item.Title,
Description: template.HTML(item.Description), // Allow HTML in description
Quantity: item.Quantity,
GrossUnitPrice: item.GrossUnitPrice,
DiscountAmountTotal: item.DiscountAmountTotal,
GrossPrice: item.GrossPrice,
})
}
// Define template functions
funcMap := template.FuncMap{
"formatPrice": func(price sql.NullString) template.HTML {
if !price.Valid || price.String == "" {
return ""
}
formatted := formatPriceWithCommas(price.String)
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
},
"formatDiscount": func(discount sql.NullString) template.HTML {
if !discount.Valid || discount.String == "" {
return template.HTML(fmt.Sprintf("%s0.00", data.CurrencySymbol))
}
if discount.String == "0" || discount.String == "0.00" {
return template.HTML(fmt.Sprintf("%s0.00", data.CurrencySymbol))
}
formatted := formatPriceWithCommas(discount.String)
return template.HTML(fmt.Sprintf("-%s%s", data.CurrencySymbol, formatted))
},
"formatTotal": func(amount interface{}) template.HTML {
if amount == nil {
return ""
}
if val, ok := amount.(float64); ok {
formatted := formatPriceWithCommas(fmt.Sprintf("%.2f", val))
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
}
return template.HTML(fmt.Sprintf("%v", amount))
},
}
// Parse and execute template
tmplPath := filepath.Join("internal", "cmc", "pdf", "templates", "invoice.html")
tmpl, err := template.New("invoice.html").Funcs(funcMap).ParseFiles(tmplPath)
if err != nil {
fmt.Printf("Error parsing template: %v\n", err)
return ""
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, templateData); err != nil {
fmt.Printf("Error executing template: %v\n", err)
return ""
}
return buf.String()
}
// LineItemTemplateData represents a line item for template rendering
type LineItemTemplateData struct {
Title string
Description template.HTML
Quantity string
GrossUnitPrice sql.NullString
DiscountAmountTotal sql.NullString
GrossPrice sql.NullString
}
// formatPriceWithCommas formats a price string with comma separators
func formatPriceWithCommas(priceStr string) string {
if priceStr == "" {
return ""
}
// Try to parse as float
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
// Return as-is if not parseable
return priceStr
}
// Format with 2 decimal places and commas
amountStr := strconv.FormatFloat(price, 'f', 2, 64)
parts := strings.Split(amountStr, ".")
intPart := parts[0]
decPart := "." + parts[1]
// Add comma separators
var result strings.Builder
for i, ch := range intPart {
if i > 0 && (len(intPart)-i)%3 == 0 {
result.WriteString(",")
}
result.WriteRune(ch)
}
return result.String() + decPart
return logoFileName
}

View file

@ -0,0 +1,181 @@
package pdf
import (
"bytes"
"database/sql"
"fmt"
"html/template"
"path/filepath"
"strconv"
"strings"
)
// BuildInvoiceHTML generates the complete HTML for an invoice using templates
func (g *HTMLDocumentGenerator) BuildInvoiceHTML(data *InvoicePDFData, totalPages int, currentPage int) string {
// Get invoice number, fall back to invoice title so the template always shows something meaningful
invoiceNum := ""
if data.Document != nil {
invoiceNum = data.Document.CmcReference
}
if invoiceNum == "" {
invoiceNum = data.Invoice.Title
}
fmt.Printf("=== buildInvoiceHTML: Invoice number: %s ===\n", invoiceNum)
// Prepare template data
templateData := struct {
InvoiceNumber string
IssueDateString string
BillTo template.HTML
ShipTo template.HTML
CustomerOrderNumber string
JobTitle string
FOB string
PaymentTerms string
CustomerABN string
CurrencyCode string
CurrencySymbol string
LineItems []LineItemTemplateData
Subtotal interface{}
GSTAmount interface{}
Total interface{}
ShowGST bool
PageCount int
CurrentPage int
LogoDataURI string
}{
InvoiceNumber: invoiceNum,
IssueDateString: data.IssueDateString,
BillTo: template.HTML(data.BillTo),
ShipTo: template.HTML(data.ShipTo),
CustomerOrderNumber: data.CustomerOrderNumber,
JobTitle: data.JobTitle,
FOB: data.FOB,
PaymentTerms: data.PaymentTerms,
CustomerABN: data.CustomerABN,
CurrencyCode: data.CurrencyCode,
CurrencySymbol: data.CurrencySymbol,
Subtotal: data.Subtotal,
GSTAmount: data.GSTAmount,
Total: data.Total,
ShowGST: data.ShowGST,
PageCount: totalPages,
CurrentPage: currentPage,
LogoDataURI: g.loadLogo("invoice_logo.png"),
}
// Convert line items to template format
for _, item := range data.LineItems {
templateData.LineItems = append(templateData.LineItems, LineItemTemplateData{
Title: item.Title,
Description: template.HTML(item.Description), // Allow HTML in description
Quantity: item.Quantity,
GrossUnitPrice: item.GrossUnitPrice,
DiscountAmountTotal: item.DiscountAmountTotal,
GrossPrice: item.GrossPrice,
})
}
// Define template functions
funcMap := template.FuncMap{
"formatPrice": func(price sql.NullString) template.HTML {
if !price.Valid || price.String == "" {
return ""
}
formatted := FormatPriceWithCommas(price.String)
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
},
"formatDiscount": func(discount sql.NullString) template.HTML {
if !discount.Valid || discount.String == "" {
return template.HTML(fmt.Sprintf("%s0.00", data.CurrencySymbol))
}
if discount.String == "0" || discount.String == "0.00" {
return template.HTML(fmt.Sprintf("%s0.00", data.CurrencySymbol))
}
formatted := FormatPriceWithCommas(discount.String)
return template.HTML(fmt.Sprintf("-%s%s", data.CurrencySymbol, formatted))
},
"formatTotal": func(amount interface{}) template.HTML {
if amount == nil {
return ""
}
if val, ok := amount.(float64); ok {
formatted := FormatPriceWithCommas(fmt.Sprintf("%.2f", val))
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
}
return template.HTML(fmt.Sprintf("%v", amount))
},
}
// Parse and execute template
// Try multiple possible paths to find the template
possiblePaths := []string{
filepath.Join("internal", "cmc", "pdf", "templates", "invoice.html"),
filepath.Join("go", "internal", "cmc", "pdf", "templates", "invoice.html"),
"/app/go/internal/cmc/pdf/templates/invoice.html",
}
var tmpl *template.Template
var err error
for _, tmplPath := range possiblePaths {
tmpl, err = template.New("invoice.html").Funcs(funcMap).ParseFiles(tmplPath)
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()
}
// LineItemTemplateData represents a line item for template rendering
type LineItemTemplateData struct {
Title string
Description template.HTML
Quantity string
GrossUnitPrice sql.NullString
DiscountAmountTotal sql.NullString
GrossPrice sql.NullString
}
// FormatPriceWithCommas formats a price string with comma separators
func FormatPriceWithCommas(priceStr string) string {
if priceStr == "" {
return ""
}
// Try to parse as float
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
// Return as-is if not parseable
return priceStr
}
// Format with 2 decimal places and commas
amountStr := strconv.FormatFloat(price, 'f', 2, 64)
parts := strings.Split(amountStr, ".")
intPart := parts[0]
decPart := "." + parts[1]
// Add comma separators
var result strings.Builder
for i, ch := range intPart {
if i > 0 && (len(intPart)-i)%3 == 0 {
result.WriteString(",")
}
result.WriteRune(ch)
}
return result.String() + decPart
}

View file

@ -0,0 +1,190 @@
package pdf
import (
"bytes"
"database/sql"
"fmt"
"html/template"
"path/filepath"
"strconv"
)
// QuoteLineItemTemplateData represents a quote line item for template rendering
type QuoteLineItemTemplateData struct {
ItemNumber string
Title string
Description template.HTML
Quantity string
GrossUnitPrice sql.NullString
GrossPrice sql.NullString
}
// BuildQuoteHTML generates the complete HTML for a quote using templates
func (g *HTMLDocumentGenerator) BuildQuoteHTML(data *QuotePDFData, totalPages int, currentPage int) string {
// Get quote number from Document.CmcReference
quoteNum := ""
if data.Document != nil {
quoteNum = data.Document.CmcReference
if data.Document.Revision > 0 {
quoteNum = fmt.Sprintf("%s.%d", quoteNum, data.Document.Revision)
}
}
fmt.Printf("=== buildQuoteHTML: Quote number: %s ===\n", quoteNum)
// Prepare FROM name
fromName := ""
if data.User != nil {
fromName = fmt.Sprintf("%s %s", data.User.FirstName, data.User.LastName)
}
fromEmail := ""
if data.User != nil {
fromEmail = data.User.Email
}
// Prepare company name
companyName := ""
if data.Customer != nil {
companyName = data.Customer.Name
}
// Prepare page content (first page only for now, multi-page support later)
pageContent := template.HTML("")
if len(data.Pages) > 0 {
pageContent = template.HTML(data.Pages[0])
}
// Calculate totals
subtotal := 0.0
for _, item := range data.LineItems {
if item.GrossPrice.Valid {
price, _ := strconv.ParseFloat(item.GrossPrice.String, 64)
subtotal += price
}
}
gstAmount := 0.0
total := subtotal
if data.ShowGST {
gstAmount = subtotal * 0.1
total = subtotal + gstAmount
}
// Prepare template data
templateData := struct {
QuoteNumber string
CompanyName string
EmailTo string
Attention string
FromName string
FromEmail string
YourReference string
IssueDateString string
PageContent template.HTML
CurrencyCode string
CurrencySymbol string
LineItems []QuoteLineItemTemplateData
Subtotal float64
GSTAmount float64
Total float64
ShowGST bool
CommercialComments string
DeliveryTime string
PaymentTerms string
DaysValid int
DeliveryPoint string
ExchangeRate string
CustomsDuty string
GSTPhrase string
SalesEngineer string
PageCount int
CurrentPage int
LogoDataURI string
}{
QuoteNumber: quoteNum,
CompanyName: companyName,
EmailTo: data.EmailTo,
Attention: data.Attention,
FromName: fromName,
FromEmail: fromEmail,
YourReference: fmt.Sprintf("Enquiry on %s", data.IssueDateString),
IssueDateString: data.IssueDateString,
PageContent: pageContent,
CurrencyCode: data.CurrencyCode,
CurrencySymbol: data.CurrencySymbol,
Subtotal: subtotal,
GSTAmount: gstAmount,
Total: total,
ShowGST: data.ShowGST,
CommercialComments: data.CommercialComments,
DeliveryTime: data.DeliveryTime,
PaymentTerms: data.PaymentTerms,
DaysValid: data.DaysValid,
DeliveryPoint: data.DeliveryPoint,
ExchangeRate: data.ExchangeRate,
CustomsDuty: data.CustomsDuty,
GSTPhrase: data.GSTPhrase,
SalesEngineer: data.SalesEngineer,
PageCount: totalPages,
CurrentPage: currentPage,
LogoDataURI: g.loadLogo("quote_logo.png"),
}
// Convert line items to template format
for _, item := range data.LineItems {
templateData.LineItems = append(templateData.LineItems, QuoteLineItemTemplateData{
ItemNumber: item.ItemNumber,
Title: item.Title,
Quantity: item.Quantity,
Description: template.HTML(""), // Quotes don't have descriptions in line items by default
GrossUnitPrice: item.GrossUnitPrice,
GrossPrice: item.GrossPrice,
})
}
// Define template functions
funcMap := template.FuncMap{
"formatPrice": func(price sql.NullString) template.HTML {
if !price.Valid || price.String == "" {
return ""
}
formatted := FormatPriceWithCommas(price.String)
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
},
"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
// Try multiple possible paths to find the template
possiblePaths := []string{
filepath.Join("internal", "cmc", "pdf", "templates", "quote.html"),
filepath.Join("go", "internal", "cmc", "pdf", "templates", "quote.html"),
"/app/go/internal/cmc/pdf/templates/quote.html",
}
var tmpl *template.Template
var err error
for _, tmplPath := range possiblePaths {
tmpl, err = template.New("quote.html").Funcs(funcMap).ParseFiles(tmplPath)
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,535 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{if .QuoteNumber}}{{.QuoteNumber}}{{else}}CMC Quote{{end}}</title>
<style>
@page {
size: A4;
margin: 15mm;
}
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 9pt;
line-height: 1.4;
margin: 0;
padding: 0;
color: #000;
}
a {
color: #0000FF;
text-decoration: underline;
}
.company-info {
display: flex;
align-items: flex-start;
gap: 10mm;
margin-bottom: 0;
padding-bottom: 0;
}
.logo {
width: 40mm;
height: auto;
display: block;
padding-right: 5mm;
}
.company-header {
text-align: center;
flex-grow: 1;
padding-top: 2mm;
}
.company-header h1 {
font-size: 24pt;
margin: 0;
font-weight: bold;
color: #003399;
}
.company-header p {
margin: 2mm 0 0 0;
font-size: 9pt;
}
.header-separator {
border-bottom: 2px solid #000;
margin: 3mm 0 5mm 0;
}
.contact-section {
display: flex;
justify-content: space-between;
margin-bottom: 5mm;
font-size: 9pt;
}
.engineering-col {
flex: 0 0 30%;
color: #003399;
font-weight: bold;
}
.contact-col {
flex: 0 0 40%;
}
.contact-col div {
margin: 1mm 0;
}
.contact-col strong {
display: inline-block;
width: 60px;
font-weight: bold;
}
.details-box {
width: 100%;
border-collapse: collapse;
margin: 5mm 0;
}
.details-box td {
border: 1px solid #000;
padding: 2mm;
font-size: 9pt;
}
.details-box td.label {
font-weight: bold;
width: 21%;
background-color: #f0f0f0;
}
.details-box td.value {
width: 29%;
}
.page-content {
margin: 5mm 0;
font-size: 9pt;
line-height: 1.5;
}
.page-content p {
margin: 3mm 0;
}
.page-content strong {
font-weight: bold;
}
.page-content em {
font-style: italic;
}
.page-content ul, .page-content ol {
margin: 2mm 0;
padding-left: 5mm;
}
.page-content li {
margin: 1mm 0;
}
.pricing-header {
text-align: center;
margin: 8mm 0 5mm 0;
}
.pricing-header h2 {
font-size: 14pt;
font-weight: bold;
margin: 0;
}
.line-items {
width: 99.5%;
border-collapse: collapse;
margin-bottom: 5mm;
table-layout: fixed;
margin-right: 1mm;
margin-left: auto;
}
.line-items th {
background-color: #f0f0f0;
border: 1px solid #000;
padding: 2mm;
font-weight: bold;
text-align: left;
}
.line-items td {
border: 1px solid #000;
padding: 2mm;
vertical-align: top;
}
.line-items .item-no {
width: 7%;
text-align: center;
}
.line-items .qty {
width: 7%;
text-align: center;
}
.line-items .description {
width: 56%;
}
.line-items .unit-price {
width: 15%;
text-align: right;
}
.line-items .total {
width: 15%;
text-align: right;
}
.totals-container {
display: flex;
justify-content: flex-end;
margin-top: 5mm;
}
.totals-table {
width: auto;
border-collapse: collapse;
border: 1px solid #000;
margin-right: 0.2mm;
}
.totals-table td {
padding: 1mm 3mm;
border: 1px solid #000;
}
.totals-table td:first-child {
text-align: left;
font-weight: bold;
width: 50%;
background-color: #f0f0f0;
}
.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;
}
.commercial-comments {
margin: 8mm 0;
}
.commercial-comments h3 {
font-size: 10pt;
font-weight: bold;
margin: 0 0 2mm 0;
}
.commercial-comments p {
margin: 0;
white-space: pre-wrap;
}
.quote-details-table {
width: 100%;
border-collapse: collapse;
margin: 5mm 0;
}
.quote-details-table td {
border: 1px solid #000;
padding: 2mm;
font-size: 9pt;
}
.quote-details-table td.label {
font-weight: bold;
width: 25%;
background-color: #f0f0f0;
}
.quote-details-table td.value {
width: 75%;
}
.description strong {
font-weight: bold;
}
.description em {
font-style: italic;
}
.description ul {
margin: 2mm 0;
padding-left: 5mm;
}
.description ol {
margin: 2mm 0;
padding-left: 5mm;
}
.description li {
margin: 1mm 0;
}
.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; }
.page-number {
position: fixed;
bottom: 25mm;
right: 15mm;
font-size: 9pt;
}
</style>
</head>
<body>
<!-- Company Header -->
<div class="company-info">
{{if .LogoDataURI}}
<img src="{{.LogoDataURI}}" class="logo" alt="CMC Technologies Logo">
{{end}}
<div class="company-header">
<h1>CMC TECHNOLOGIES</h1>
<p>PTY LIMITED ACN: 085 991 224&nbsp;&nbsp;&nbsp;&nbsp;ABN: 47 085 991 224</p>
</div>
</div>
<div class="header-separator"></div>
<!-- Contact Details Section -->
<div class="contact-section">
<div class="engineering-col">
<div>Engineering &</div>
<div>Industrial</div>
<div>Instrumentation</div>
</div>
<div class="contact-col">
<div><strong>Phone:</strong> +61 2 9669 4000</div>
<div><strong>Fax:</strong> +61 2 9669 4111</div>
<div><strong>Email:</strong> sales@cmctechnologies.com.au</div>
<div><strong>Web Site:</strong> www.cmctechnologies.net.au</div>
<div style="margin-top: 3mm;">Unit 19, 77 Bourke Rd</div>
<div>Alexandria NSW 2015</div>
<div>AUSTRALIA</div>
</div>
</div>
<!-- Quote Details Box -->
<table class="details-box">
<tr>
<td class="label">COMPANY NAME:</td>
<td class="value">{{.CompanyName}}</td>
<td class="label">QUOTE NO.:</td>
<td class="value">{{.QuoteNumber}}</td>
</tr>
<tr>
<td class="label">EMAIL TO:</td>
<td class="value">{{.EmailTo}}</td>
<td class="label">YOUR REFERENCE:</td>
<td class="value">{{.YourReference}}</td>
</tr>
<tr>
<td class="label">ATTENTION:</td>
<td class="value">{{.Attention}}</td>
<td class="label">ISSUE DATE:</td>
<td class="value">{{.IssueDateString}}</td>
</tr>
<tr>
<td class="label">FROM:</td>
<td class="value">{{.FromName}}</td>
<td class="label">EMAIL:</td>
<td class="value">{{.FromEmail}}</td>
</tr>
</table>
<!-- Page Content (WYSIWYG content) -->
{{if .PageContent}}
<div class="page-content">
{{.PageContent}}
</div>
{{end}}
<!-- Pricing & Specifications Header -->
<div class="pricing-header">
<h2>PRICING & SPECIFICATIONS</h2>
</div>
<!-- Line Items Table -->
<table class="line-items">
<thead>
<tr>
<th class="item-no">ITEM</th>
<th class="qty">QTY</th>
<th class="description">DESCRIPTION</th>
<th class="unit-price">UNIT PRICE</th>
<th class="total">TOTAL</th>
</tr>
</thead>
<tbody>
{{range .LineItems}}
<tr>
<td class="item-no">{{.ItemNumber}}</td>
<td class="qty">{{.Quantity}}</td>
<td class="description">
<strong>{{.Title}}</strong>
{{if .Description}}
<div>{{.Description}}</div>
{{end}}
</td>
<td class="unit-price">{{formatPrice .GrossUnitPrice}}</td>
<td class="total">{{formatPrice .GrossPrice}}</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>
<!-- Commercial Comments -->
{{if .CommercialComments}}
<div class="commercial-comments">
<h3>COMMERCIAL COMMENTS</h3>
<p>{{.CommercialComments}}</p>
</div>
{{end}}
<!-- Quote Details Table -->
{{if or .DeliveryTime .PaymentTerms .DaysValid .DeliveryPoint .ExchangeRate .CustomsDuty .GSTPhrase .SalesEngineer}}
<table class="quote-details-table">
{{if .DeliveryTime}}
<tr>
<td class="label">DELIVERY TIME:</td>
<td class="value">{{.DeliveryTime}}</td>
</tr>
{{end}}
{{if .PaymentTerms}}
<tr>
<td class="label">PAYMENT TERMS:</td>
<td class="value">{{.PaymentTerms}}</td>
</tr>
{{end}}
{{if .DaysValid}}
<tr>
<td class="label">QUOTATION VALID FOR:</td>
<td class="value">{{.DaysValid}} days from date of issue</td>
</tr>
{{end}}
{{if .DeliveryPoint}}
<tr>
<td class="label">DELIVERY POINT:</td>
<td class="value">{{.DeliveryPoint}}</td>
</tr>
{{end}}
{{if .ExchangeRate}}
<tr>
<td class="label">EXCHANGE RATE:</td>
<td class="value">{{.ExchangeRate}}</td>
</tr>
{{end}}
{{if .CustomsDuty}}
<tr>
<td class="label">CUSTOMS DUTY:</td>
<td class="value">{{.CustomsDuty}}</td>
</tr>
{{end}}
{{if .GSTPhrase}}
<tr>
<td class="label">GST:</td>
<td class="value">{{.GSTPhrase}}</td>
</tr>
{{end}}
{{if .SalesEngineer}}
<tr>
<td class="label">SALES ENGINEER:</td>
<td class="value">{{.SalesEngineer}}</td>
</tr>
{{end}}
</table>
{{end}}
<!-- Page Number -->
{{if .PageCount}}
<div class="page-number">
Page {{.CurrentPage}} of {{.PageCount}}
</div>
{{end}}
<!-- 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

@ -807,6 +807,9 @@ ENDINSTRUCTIONS;
$this->redirect(array('controller'=>'documents', 'action'=>'index'));
}
$pdf_dir = Configure::read('pdf_directory');
$this->Document->recursive = 2; // Load associations deeply
$document = $this->Document->read(null,$id);
$this->set('document', $document);
@ -864,11 +867,14 @@ ENDINSTRUCTIONS;
//
switch($docType) {
case "quote":
// Use enquiry title, or fall back to cmc_reference, or document ID
if (!empty($enquiry['Enquiry']['title'])) {
$filename = $enquiry['Enquiry']['title'];
$template_name = 'pdf_quote';
break;
case "invoice":
} elseif (!empty($document['Document']['cmc_reference'])) {
$filename = $document['Document']['cmc_reference'];
} else {
$filename = 'Quote-' . $document['Document']['id'];
}
$filename = $document['Invoice']['title'];
$this->set('docTitle', $document['Invoice']['title']);
$this->set('job', $this->Document->Invoice->Job->find('first', array('conditions'=>array('Job.id'=>$document['Invoice']['job_id']))));
@ -955,6 +961,16 @@ ENDINSTRUCTIONS;
$document['Document']['pdf_created_by_user_id'] = $this->getCurrentUserID();
if($this->Document->save($document)) {
//echo "Set pdf_filename attritbute to: ".$filename;
// Count pages and update doc_page_count
App::import('Vendor','pagecounter');
$pageCounter = new PageCounter();
$pdfPath = $pdf_dir . $filename;
$pageCount = $pageCounter->count($pdfPath);
if ($pageCount > 0) {
$this->Document->id = $document['Document']['id'];
$this->Document->saveField('doc_page_count', $pageCount);
}
}
else {
//echo 'Failed to set pdf_filename to: '.$filename;

View file

@ -9,7 +9,10 @@ class PageCounter {
* Returns the page count or null if unable to determine.
*/
function count($file) {
error_log("PageCounter: Attempting to count pages for file: $file");
if (!file_exists($file) || !is_readable($file)) {
error_log("PageCounter: File does not exist or is not readable: $file");
return null;
}
@ -18,7 +21,9 @@ class PageCounter {
App::import('Controller', 'App');
$appController = new AppController();
$goBaseUrl = $appController::getGoBaseUrlOrFail();
$goEndpoint = $goBaseUrl . '/go/pdf/count-pages';
$goEndpoint = $goBaseUrl . '/go/api/pdf/count-pages';
error_log("PageCounter: Calling Go endpoint: $goEndpoint with file: $file");
// Call the Go page counter endpoint
$payload = array('file_path' => $file);
@ -34,17 +39,22 @@ class PageCounter {
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
error_log("PageCounter: Got response with HTTP code $httpCode: $response");
if ($httpCode >= 200 && $httpCode < 300) {
$result = json_decode($response, true);
if (isset($result['page_count']) && is_numeric($result['page_count'])) {
$count = (int)$result['page_count'];
error_log("PageCounter: Returning page count: $count");
return $count > 0 ? $count : null;
}
}
error_log("PageCounter: Failed to get valid page count from response");
return null;
} catch (Exception $e) {
// If Go service fails, return null gracefully
error_log("PageCounter: Exception: " . $e->getMessage());
return null;
}
}

View file

@ -42,10 +42,39 @@ $pagecounter = new PageCounter();
<?php foreach($attachments as $attachment) { ?>
<?php
// Debug log for page counter paths/results
$debugPath = APP.'webroot/pdf/pagecounter_debug.txt';
// Normalize attachment path: handle double slashes and ensure correct base path
$rawPath = $attachment['Attachment']['file'];
// Log the raw path from database
file_put_contents($debugPath, "Attachment: {$attachment['Attachment']['name']}\nRaw DB path: {$rawPath}\n", FILE_APPEND);
// If path starts with /webroot, prefix with APP; otherwise use as-is
if (strpos($rawPath, '/webroot/') === 0 || strpos($rawPath, '//webroot/') === 0) {
$attachmentPath = APP . ltrim($rawPath, '/');
} elseif (strpos($rawPath, APP) === 0) {
// Already has full APP path, use as-is
$attachmentPath = $rawPath;
} else {
// Default: prefix with APP
$attachmentPath = APP . ltrim($rawPath, '/');
}
// Remove any double slashes
$attachmentPath = preg_replace('#/+#', '/', $attachmentPath);
$exists = file_exists($attachmentPath) ? 'yes' : 'no';
$readable = is_readable($attachmentPath) ? 'yes' : 'no';
file_put_contents($debugPath, "Computed path: {$attachmentPath}\nExists: {$exists}, Readable: {$readable}\n", FILE_APPEND);
$pagecount = '';
if($attachment['Attachment']['type'] == 'application/pdf') {
$pagecount = '('.$pagecounter->count($attachment['Attachment']['file']).' pages)';
$count = $pagecounter->count($attachmentPath);
$pagecount = '('.$count.' pages)';
file_put_contents($debugPath, "Count result: {$count}\n---\n", FILE_APPEND);
} else {
file_put_contents($debugPath, "Skipped (non-pdf)\n---\n", FILE_APPEND);
}
?>
<?= $attachment['Attachment']['name']; ?> <?= $pagecount ?><br>

View file

@ -2,7 +2,7 @@
// Generate the Invoice PDF by calling the Go service instead of TCPDF.
$goBaseUrl = AppController::getGoBaseUrlOrFail();
$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-invoice-html';
$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-invoice';
$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/pdf/generate-quote';
$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-quote';
$outputDir = Configure::read('pdf_directory');
@ -17,18 +17,44 @@ foreach ($document['LineItem'] as $li) {
);
}
// Prepare fields with fallbacks
$cmcReference = '';
if (!empty($enquiry['Enquiry']['title'])) {
$cmcReference = $enquiry['Enquiry']['title'];
} elseif (!empty($document['Document']['cmc_reference'])) {
$cmcReference = $document['Document']['cmc_reference'];
} else {
$cmcReference = 'Quote-' . $document['Document']['id'];
}
$customerName = '';
if (!empty($enquiry['Customer']['name'])) {
$customerName = $enquiry['Customer']['name'];
} else {
$customerName = 'Customer';
}
$contactEmail = !empty($enquiry['Contact']['email']) ? $enquiry['Contact']['email'] : '';
$contactName = !empty($enquiry['Contact']['first_name']) ? $enquiry['Contact']['first_name'].' '.$enquiry['Contact']['last_name'] : '';
$userFirstName = !empty($enquiry['User']['first_name']) ? $enquiry['User']['first_name'] : '';
$userLastName = !empty($enquiry['User']['last_name']) ? $enquiry['User']['last_name'] : '';
$userEmail = !empty($enquiry['User']['email']) ? $enquiry['User']['email'] : '';
$createdDate = !empty($enquiry['Enquiry']['created']) ? $enquiry['Enquiry']['created'] : date('Y-m-d');
$payload = array(
'document_id' => intval($document['Document']['id']),
'cmc_reference' => $enquiry['Enquiry']['title'],
'cmc_reference' => $cmcReference,
'revision' => intval($document['Document']['revision']),
'created_date' => date('Y-m-d', strtotime($enquiry['Enquiry']['created'])),
'customer_name' => $enquiry['Customer']['name'],
'contact_email' => $enquiry['Contact']['email'],
'contact_name' => $enquiry['Contact']['first_name'].' '.$enquiry['Contact']['last_name'],
'user_first_name' => $enquiry['User']['first_name'],
'user_last_name' => $enquiry['User']['last_name'],
'user_email' => $enquiry['User']['email'],
'created_date' => date('Y-m-d', strtotime($createdDate)),
'created_date_string' => date('j M Y', strtotime($createdDate)),
'customer_name' => $customerName,
'contact_email' => $contactEmail,
'contact_name' => $contactName,
'user_first_name' => $userFirstName,
'user_last_name' => $userLastName,
'user_email' => $userEmail,
'currency_symbol' => $currencySymbol,
'currency_code' => isset($currencyCode) ? $currencyCode : 'AUD',
'show_gst' => (bool)$gst,
'commercial_comments' => isset($document['Quote']['commercial_comments']) ? $document['Quote']['commercial_comments'] : '',
'line_items' => $lineItems,
@ -36,6 +62,12 @@ $payload = array(
'output_dir' => $outputDir
);
// Debug: Write payload to file for debugging
file_put_contents($outputDir . '/quote_payload_debug.txt',
"=== PAYLOAD ===\n" . print_r($payload, true) .
"\n\n=== ENQUIRY ===\n" . print_r($enquiry, true) .
"\n\n=== DOCUMENT ===\n" . print_r($document, true));
$ch = curl_init($goEndpoint);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@ -52,9 +84,31 @@ if ($httpCode < 200 || $httpCode >= 300) {
if ($curlErr) {
echo " Error: $curlErr";
}
if (!empty($response)) {
echo "<br>Response: " . htmlspecialchars($response);
}
echo "<br><br>Payload sent:<br><pre>" . htmlspecialchars(json_encode($payload, JSON_PRETTY_PRINT)) . "</pre>";
echo "</p>";
exit;
}
// PDF generated successfully - now count pages and update database
$result = json_decode($response, true);
if (isset($result['filename'])) {
$pdfPath = $outputDir . '/' . $result['filename'];
// Count pages using the Go service
App::import('Vendor','pagecounter');
$pageCounter = new PageCounter();
$pageCount = $pageCounter->count($pdfPath);
if ($pageCount > 0) {
// Update the document with the page count
$Document = ClassRegistry::init('Document');
$Document->id = $document['Document']['id'];
$Document->saveField('doc_page_count', $pageCount);
}
}
?>
<script type="text/javascript">

Binary file not shown.

Binary file not shown.

Binary file not shown.