Starting quote generation, better file mounting for local
This commit is contained in:
parent
67dba461f2
commit
72a4b87193
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -14,6 +14,7 @@ app/cake_eclipse_helper.php
|
||||||
app/webroot/pdf/*
|
app/webroot/pdf/*
|
||||||
app/webroot/attachments_files/*
|
app/webroot/attachments_files/*
|
||||||
backups/*
|
backups/*
|
||||||
|
files/
|
||||||
|
|
||||||
# Go binaries
|
# Go binaries
|
||||||
go/server
|
go/server
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
volumes:
|
volumes:
|
||||||
- ./php/app/webroot/pdf:/var/www/cmc-sales/app/webroot/pdf
|
- ./files/pdf:/var/www/cmc-sales/app/webroot/pdf
|
||||||
- ./php/app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
- ./files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
||||||
networks:
|
networks:
|
||||||
- cmc-network
|
- cmc-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
@ -86,8 +86,8 @@ services:
|
||||||
- ./go:/app
|
- ./go:/app
|
||||||
- ./go/.air.toml:/root/.air.toml
|
- ./go/.air.toml:/root/.air.toml
|
||||||
- ./go/.env.example:/root/.env
|
- ./go/.env.example:/root/.env
|
||||||
- ./php/app/webroot/pdf:/var/www/cmc-sales/app/webroot/pdf
|
- ./files/pdf:/var/www/cmc-sales/app/webroot/pdf
|
||||||
- ./php/app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
- ./files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
||||||
networks:
|
networks:
|
||||||
- cmc-network
|
- cmc-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,6 @@ func main() {
|
||||||
|
|
||||||
// PDF generation routes (under /api/pdf/* to avoid conflict with file server)
|
// 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", 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-quote", handlers.GenerateQuotePDF).Methods("POST")
|
||||||
goRouter.HandleFunc("/api/pdf/generate-po", handlers.GeneratePurchaseOrderPDF).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-packinglist", handlers.GeneratePackingListPDF).Methods("POST")
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -129,16 +130,21 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Get attachments directory from environment or use default
|
// Get attachments directory from environment or use default
|
||||||
attachDir := os.Getenv("ATTACHMENTS_DIR")
|
attachDir := os.Getenv("ATTACHMENTS_DIR")
|
||||||
|
log.Printf("=== Upload Debug: ATTACHMENTS_DIR env var = '%s'", attachDir)
|
||||||
if attachDir == "" {
|
if attachDir == "" {
|
||||||
attachDir = "webroot/attachments_files"
|
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 {
|
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)
|
http.Error(w, "Failed to create attachments directory", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save file to disk
|
// Save file to disk
|
||||||
filePath := filepath.Join(attachDir, filename)
|
filePath := filepath.Join(attachDir, filename)
|
||||||
|
log.Printf("=== Upload Debug: Saving file to: %s", filePath)
|
||||||
dst, err := os.Create(filePath)
|
dst, err := os.Create(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
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
|
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)
|
result, err := h.queries.CreateAttachment(r.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Clean up file on error
|
// Clean up file on error
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ type InvoicePDFRequest struct {
|
||||||
|
|
||||||
// GenerateInvoicePDF handles POST /api/pdf/invoice and writes a PDF to disk.
|
// GenerateInvoicePDF handles POST /api/pdf/invoice and writes a PDF to disk.
|
||||||
// It returns JSON: {"filename":"<name>.pdf"}
|
// It returns JSON: {"filename":"<name>.pdf"}
|
||||||
|
// GenerateInvoicePDF generates invoice using HTML template and chromedp
|
||||||
func GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
|
func GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
|
||||||
var req InvoicePDFRequest
|
var req InvoicePDFRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
|
@ -109,105 +110,12 @@ func GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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
|
// Map request into the existing PDF generation types
|
||||||
doc := &db.Document{ID: req.DocumentID, CmcReference: req.InvoiceNumber}
|
doc := &db.Document{ID: req.DocumentID, CmcReference: req.InvoiceNumber}
|
||||||
inv := &db.Invoice{Title: req.InvoiceTitle}
|
inv := &db.Invoice{Title: req.InvoiceTitle}
|
||||||
cust := &db.Customer{Name: req.CustomerName}
|
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))
|
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
|
||||||
for i, li := range 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
|
// Use HTML generator instead of gofpdf
|
||||||
htmlGen := pdf.NewHTMLInvoiceGenerator(outputDir)
|
htmlGen := pdf.NewHTMLDocumentGenerator(outputDir)
|
||||||
filename, err := htmlGen.GenerateInvoicePDF(data)
|
filename, err := htmlGen.GenerateInvoicePDF(data)
|
||||||
if err != nil {
|
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)
|
http.Error(w, fmt.Sprintf("failed to generate PDF: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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
|
// QuoteLineItemRequest reuses the invoice item shape
|
||||||
|
|
@ -314,10 +224,12 @@ type QuotePDFRequest struct {
|
||||||
func GenerateQuotePDF(w http.ResponseWriter, r *http.Request) {
|
func GenerateQuotePDF(w http.ResponseWriter, r *http.Request) {
|
||||||
var req QuotePDFRequest
|
var req QuotePDFRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if req.CmcReference == "" || 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)
|
http.Error(w, "cmc_reference and customer_name are required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -383,7 +295,9 @@ func GenerateQuotePDF(w http.ResponseWriter, r *http.Request) {
|
||||||
Pages: req.Pages,
|
Pages: req.Pages,
|
||||||
}
|
}
|
||||||
|
|
||||||
filename, err := pdf.GenerateQuotePDF(data, outputDir)
|
// Use HTML generator
|
||||||
|
htmlGen := pdf.NewHTMLDocumentGenerator(outputDir)
|
||||||
|
filename, err := htmlGen.GenerateQuotePDF(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("GenerateQuotePDF: failed to generate PDF: %v", err)
|
log.Printf("GenerateQuotePDF: failed to generate PDF: %v", err)
|
||||||
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
|
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")
|
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
|
// PurchaseOrderLineItemRequest reuses the invoice item shape
|
||||||
|
|
@ -681,15 +597,19 @@ type CountPagesRequest struct {
|
||||||
func CountPages(w http.ResponseWriter, r *http.Request) {
|
func CountPages(w http.ResponseWriter, r *http.Request) {
|
||||||
var req CountPagesRequest
|
var req CountPagesRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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)
|
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.FilePath == "" {
|
if req.FilePath == "" {
|
||||||
|
log.Printf("CountPages: file_path is required")
|
||||||
http.Error(w, "file_path is required", http.StatusBadRequest)
|
http.Error(w, "file_path is required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("CountPages: Attempting to count pages for file: %s", req.FilePath)
|
||||||
|
|
||||||
// Count pages in the PDF file
|
// Count pages in the PDF file
|
||||||
pageCount, err := pdf.CountPDFPages(req.FilePath)
|
pageCount, err := pdf.CountPDFPages(req.FilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -703,6 +623,7 @@ func CountPages(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("CountPages: Successfully counted %d pages in %s", pageCount, req.FilePath)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]int{"page_count": pageCount})
|
_ = json.NewEncoder(w).Encode(map[string]int{"page_count": pageCount})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
package pdf
|
package pdf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
|
|
@ -18,25 +13,26 @@ import (
|
||||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTMLInvoiceGenerator generates PDF invoices from HTML templates using chromedp
|
// HTMLDocumentGenerator generates PDF documents from HTML templates using chromedp
|
||||||
type HTMLInvoiceGenerator struct {
|
type HTMLDocumentGenerator struct {
|
||||||
outputDir string
|
outputDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTMLInvoiceGenerator creates a new HTML-based invoice generator
|
// NewHTMLDocumentGenerator creates a new HTML-based document generator
|
||||||
func NewHTMLInvoiceGenerator(outputDir string) *HTMLInvoiceGenerator {
|
func NewHTMLDocumentGenerator(outputDir string) *HTMLDocumentGenerator {
|
||||||
return &HTMLInvoiceGenerator{
|
return &HTMLDocumentGenerator{
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateInvoicePDF creates a PDF invoice from HTML template
|
// 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) ===")
|
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)
|
// FIRST PASS: Generate PDF without page numbers to determine total pages (including T&C)
|
||||||
fmt.Println("=== HTML Generator: First pass - generating without page count ===")
|
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))
|
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
|
// SECOND PASS: Generate final PDF with correct page count
|
||||||
fmt.Printf("=== HTML Generator: Second pass - regenerating with page count %d ===\n", totalPageCount)
|
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 {
|
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||||
return "", fmt.Errorf("failed to write temp HTML (second pass): %w", err)
|
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
|
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
|
// 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
|
// Read the HTML file to get its content
|
||||||
htmlContent, err := ioutil.ReadFile(htmlPath)
|
htmlContent, err := ioutil.ReadFile(htmlPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -198,7 +296,7 @@ func (g *HTMLInvoiceGenerator) htmlToPDF(htmlPath, pdfPath string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// getPageCount extracts the page count from a PDF file
|
// 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)
|
pageCount, err := api.PageCountFile(pdfPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to get page count: %w", err)
|
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
|
return pageCount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadLogoAsBase64 loads the logo image and returns it as a relative path
|
// loadLogo loads the logo image and returns it as a relative path
|
||||||
func (g *HTMLInvoiceGenerator) loadLogoAsBase64() string {
|
func (g *HTMLDocumentGenerator) loadLogo(logoFileName string) string {
|
||||||
// Use canonical path: /app/static/images in Docker, go/static/images locally
|
// Use canonical path: /app/static/images in Docker, go/static/images locally
|
||||||
logoPath := "/app/static/images/CMC-Mobile-Logo.png"
|
logoPath := "/app/static/images/CMC-Mobile-Logo.png"
|
||||||
if _, err := os.Stat(logoPath); err != nil {
|
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
|
// 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 {
|
if err := ioutil.WriteFile(destPath, logoData, 0644); err != nil {
|
||||||
fmt.Printf("Warning: Could not write logo to output dir: %v\n", err)
|
fmt.Printf("Warning: Could not write logo to output dir: %v\n", err)
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -230,160 +328,5 @@ func (g *HTMLInvoiceGenerator) loadLogoAsBase64() string {
|
||||||
|
|
||||||
fmt.Printf("=== Copied logo from %s to %s ===\n", logoPath, destPath)
|
fmt.Printf("=== Copied logo from %s to %s ===\n", logoPath, destPath)
|
||||||
// Return relative path (same directory as HTML file)
|
// Return relative path (same directory as HTML file)
|
||||||
return "invoice_logo.png"
|
return logoFileName
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
181
go/internal/cmc/pdf/invoice_builder.go
Normal file
181
go/internal/cmc/pdf/invoice_builder.go
Normal 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
|
||||||
|
}
|
||||||
190
go/internal/cmc/pdf/quote_builder.go
Normal file
190
go/internal/cmc/pdf/quote_builder.go
Normal 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()
|
||||||
|
}
|
||||||
535
go/internal/cmc/pdf/templates/quote.html
Normal file
535
go/internal/cmc/pdf/templates/quote.html
Normal 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 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>
|
||||||
|
|
@ -807,6 +807,9 @@ ENDINSTRUCTIONS;
|
||||||
$this->redirect(array('controller'=>'documents', 'action'=>'index'));
|
$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);
|
$document = $this->Document->read(null,$id);
|
||||||
|
|
||||||
$this->set('document', $document);
|
$this->set('document', $document);
|
||||||
|
|
@ -864,11 +867,14 @@ ENDINSTRUCTIONS;
|
||||||
//
|
//
|
||||||
switch($docType) {
|
switch($docType) {
|
||||||
case "quote":
|
case "quote":
|
||||||
|
// Use enquiry title, or fall back to cmc_reference, or document ID
|
||||||
|
if (!empty($enquiry['Enquiry']['title'])) {
|
||||||
$filename = $enquiry['Enquiry']['title'];
|
$filename = $enquiry['Enquiry']['title'];
|
||||||
$template_name = 'pdf_quote';
|
} elseif (!empty($document['Document']['cmc_reference'])) {
|
||||||
break;
|
$filename = $document['Document']['cmc_reference'];
|
||||||
|
} else {
|
||||||
case "invoice":
|
$filename = 'Quote-' . $document['Document']['id'];
|
||||||
|
}
|
||||||
$filename = $document['Invoice']['title'];
|
$filename = $document['Invoice']['title'];
|
||||||
$this->set('docTitle', $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']))));
|
$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();
|
$document['Document']['pdf_created_by_user_id'] = $this->getCurrentUserID();
|
||||||
if($this->Document->save($document)) {
|
if($this->Document->save($document)) {
|
||||||
//echo "Set pdf_filename attritbute to: ".$filename;
|
//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 {
|
else {
|
||||||
//echo 'Failed to set pdf_filename to: '.$filename;
|
//echo 'Failed to set pdf_filename to: '.$filename;
|
||||||
|
|
|
||||||
12
php/app/vendors/pagecounter.php
vendored
12
php/app/vendors/pagecounter.php
vendored
|
|
@ -9,7 +9,10 @@ class PageCounter {
|
||||||
* Returns the page count or null if unable to determine.
|
* Returns the page count or null if unable to determine.
|
||||||
*/
|
*/
|
||||||
function count($file) {
|
function count($file) {
|
||||||
|
error_log("PageCounter: Attempting to count pages for file: $file");
|
||||||
|
|
||||||
if (!file_exists($file) || !is_readable($file)) {
|
if (!file_exists($file) || !is_readable($file)) {
|
||||||
|
error_log("PageCounter: File does not exist or is not readable: $file");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,7 +21,9 @@ class PageCounter {
|
||||||
App::import('Controller', 'App');
|
App::import('Controller', 'App');
|
||||||
$appController = new AppController();
|
$appController = new AppController();
|
||||||
$goBaseUrl = $appController::getGoBaseUrlOrFail();
|
$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
|
// Call the Go page counter endpoint
|
||||||
$payload = array('file_path' => $file);
|
$payload = array('file_path' => $file);
|
||||||
|
|
@ -34,17 +39,22 @@ class PageCounter {
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
|
error_log("PageCounter: Got response with HTTP code $httpCode: $response");
|
||||||
|
|
||||||
if ($httpCode >= 200 && $httpCode < 300) {
|
if ($httpCode >= 200 && $httpCode < 300) {
|
||||||
$result = json_decode($response, true);
|
$result = json_decode($response, true);
|
||||||
if (isset($result['page_count']) && is_numeric($result['page_count'])) {
|
if (isset($result['page_count']) && is_numeric($result['page_count'])) {
|
||||||
$count = (int)$result['page_count'];
|
$count = (int)$result['page_count'];
|
||||||
|
error_log("PageCounter: Returning page count: $count");
|
||||||
return $count > 0 ? $count : null;
|
return $count > 0 ? $count : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
error_log("PageCounter: Failed to get valid page count from response");
|
||||||
return null;
|
return null;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// If Go service fails, return null gracefully
|
// If Go service fails, return null gracefully
|
||||||
|
error_log("PageCounter: Exception: " . $e->getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,39 @@ $pagecounter = new PageCounter();
|
||||||
<?php foreach($attachments as $attachment) { ?>
|
<?php foreach($attachments as $attachment) { ?>
|
||||||
<?php
|
<?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 = '';
|
$pagecount = '';
|
||||||
|
|
||||||
if($attachment['Attachment']['type'] == 'application/pdf') {
|
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>
|
<?= $attachment['Attachment']['name']; ?> <?= $pagecount ?><br>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// Generate the Invoice PDF by calling the Go service instead of TCPDF.
|
// Generate the Invoice PDF by calling the Go service instead of TCPDF.
|
||||||
|
|
||||||
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
||||||
$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-invoice-html';
|
$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-invoice';
|
||||||
|
|
||||||
$outputDir = Configure::read('pdf_directory');
|
$outputDir = Configure::read('pdf_directory');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// Generate the Quote PDF by calling the Go service instead of TCPDF/FPDI.
|
// Generate the Quote PDF by calling the Go service instead of TCPDF/FPDI.
|
||||||
|
|
||||||
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
||||||
$goEndpoint = $goBaseUrl . '/go/pdf/generate-quote';
|
$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-quote';
|
||||||
|
|
||||||
$outputDir = Configure::read('pdf_directory');
|
$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(
|
$payload = array(
|
||||||
'document_id' => intval($document['Document']['id']),
|
'document_id' => intval($document['Document']['id']),
|
||||||
'cmc_reference' => $enquiry['Enquiry']['title'],
|
'cmc_reference' => $cmcReference,
|
||||||
'revision' => intval($document['Document']['revision']),
|
'revision' => intval($document['Document']['revision']),
|
||||||
'created_date' => date('Y-m-d', strtotime($enquiry['Enquiry']['created'])),
|
'created_date' => date('Y-m-d', strtotime($createdDate)),
|
||||||
'customer_name' => $enquiry['Customer']['name'],
|
'created_date_string' => date('j M Y', strtotime($createdDate)),
|
||||||
'contact_email' => $enquiry['Contact']['email'],
|
'customer_name' => $customerName,
|
||||||
'contact_name' => $enquiry['Contact']['first_name'].' '.$enquiry['Contact']['last_name'],
|
'contact_email' => $contactEmail,
|
||||||
'user_first_name' => $enquiry['User']['first_name'],
|
'contact_name' => $contactName,
|
||||||
'user_last_name' => $enquiry['User']['last_name'],
|
'user_first_name' => $userFirstName,
|
||||||
'user_email' => $enquiry['User']['email'],
|
'user_last_name' => $userLastName,
|
||||||
|
'user_email' => $userEmail,
|
||||||
'currency_symbol' => $currencySymbol,
|
'currency_symbol' => $currencySymbol,
|
||||||
|
'currency_code' => isset($currencyCode) ? $currencyCode : 'AUD',
|
||||||
'show_gst' => (bool)$gst,
|
'show_gst' => (bool)$gst,
|
||||||
'commercial_comments' => isset($document['Quote']['commercial_comments']) ? $document['Quote']['commercial_comments'] : '',
|
'commercial_comments' => isset($document['Quote']['commercial_comments']) ? $document['Quote']['commercial_comments'] : '',
|
||||||
'line_items' => $lineItems,
|
'line_items' => $lineItems,
|
||||||
|
|
@ -36,6 +62,12 @@ $payload = array(
|
||||||
'output_dir' => $outputDir
|
'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);
|
$ch = curl_init($goEndpoint);
|
||||||
curl_setopt($ch, CURLOPT_POST, true);
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
|
@ -52,9 +84,31 @@ if ($httpCode < 200 || $httpCode >= 300) {
|
||||||
if ($curlErr) {
|
if ($curlErr) {
|
||||||
echo " Error: $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>";
|
echo "</p>";
|
||||||
exit;
|
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">
|
<script type="text/javascript">
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in a new issue