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/attachments_files/*
|
||||
backups/*
|
||||
files/
|
||||
|
||||
# Go binaries
|
||||
go/server
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
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'));
|
||||
}
|
||||
|
||||
$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;
|
||||
|
|
|
|||
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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
Binary file not shown.
Loading…
Reference in a new issue