Migrating to new endpoints, removing tcpdf
This commit is contained in:
parent
b9d44fc82b
commit
e6b05a726d
|
|
@ -22,7 +22,7 @@ WORKDIR /app
|
||||||
COPY --from=builder /app/server .
|
COPY --from=builder /app/server .
|
||||||
COPY --from=builder /app/vault .
|
COPY --from=builder /app/vault .
|
||||||
COPY go/templates ./templates
|
COPY go/templates ./templates
|
||||||
COPY go/internal/cmc/pdf/templates ./templates/pdf
|
COPY go/internal/cmc/documents/templates ./templates/pdf
|
||||||
COPY go/static ./static
|
COPY go/static ./static
|
||||||
COPY go/.env.example .env
|
COPY go/.env.example .env
|
||||||
EXPOSE 8082
|
EXPOSE 8082
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ ENV CHROME_BIN=/usr/bin/chromium-browser \
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/server .
|
COPY --from=builder /app/server .
|
||||||
COPY go/templates ./templates
|
COPY go/templates ./templates
|
||||||
COPY go/internal/cmc/pdf/templates ./templates/pdf
|
COPY go/internal/cmc/documents/templates ./templates/pdf
|
||||||
COPY go/static ./static
|
COPY go/static ./static
|
||||||
COPY go/.env.example .env
|
COPY go/.env.example .env
|
||||||
EXPOSE 8082
|
EXPOSE 8082
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import (
|
||||||
|
|
||||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email"
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email"
|
||||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers"
|
|
||||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/attachments"
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/attachments"
|
||||||
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/documents"
|
||||||
quotes "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/quotes"
|
quotes "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/quotes"
|
||||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
|
||||||
"github.com/go-co-op/gocron"
|
"github.com/go-co-op/gocron"
|
||||||
|
|
@ -82,13 +82,13 @@ func main() {
|
||||||
goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET")
|
goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET")
|
||||||
goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE")
|
goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE")
|
||||||
|
|
||||||
// PDF generation routes (under /api/pdf/* to avoid conflict with file server)
|
// Document generation routes
|
||||||
goRouter.HandleFunc("/api/pdf/generate-invoice", handlers.GenerateInvoicePDF).Methods("POST")
|
goRouter.HandleFunc("/document/generate/invoice", documents.GenerateInvoicePDF).Methods("POST")
|
||||||
goRouter.HandleFunc("/api/pdf/generate-quote", handlers.GenerateQuotePDF).Methods("POST")
|
goRouter.HandleFunc("/document/generate/quote", documents.GenerateQuotePDF).Methods("POST")
|
||||||
goRouter.HandleFunc("/api/pdf/generate-po", handlers.GeneratePurchaseOrderPDF).Methods("POST")
|
goRouter.HandleFunc("/document/generate/purchase-order", documents.GeneratePurchaseOrderPDF).Methods("POST")
|
||||||
goRouter.HandleFunc("/api/pdf/generate-packinglist", handlers.GeneratePackingListPDF).Methods("POST")
|
goRouter.HandleFunc("/document/generate/packing-list", documents.GeneratePackingListPDF).Methods("POST")
|
||||||
goRouter.HandleFunc("/api/pdf/generate-orderack", handlers.GenerateOrderAckPDF).Methods("POST")
|
goRouter.HandleFunc("/document/generate/order-acknowledgement", documents.GenerateOrderAckPDF).Methods("POST")
|
||||||
goRouter.HandleFunc("/api/pdf/count-pages", handlers.CountPages).Methods("POST")
|
goRouter.HandleFunc("/document/page-count", documents.CountPages).Methods("POST")
|
||||||
|
|
||||||
// Serve generated PDFs
|
// Serve generated PDFs
|
||||||
pdfDir := os.Getenv("PDF_OUTPUT_DIR")
|
pdfDir := os.Getenv("PDF_OUTPUT_DIR")
|
||||||
|
|
|
||||||
461
go/internal/cmc/documents/html_generator.go
Normal file
461
go/internal/cmc/documents/html_generator.go
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
package pdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/page"
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTMLDocumentGenerator generates PDF documents from HTML templates using chromedp
|
||||||
|
type HTMLDocumentGenerator struct {
|
||||||
|
outputDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTMLDocumentGenerator creates a new HTML-based document generator
|
||||||
|
func NewHTMLDocumentGenerator(outputDir string) *HTMLDocumentGenerator {
|
||||||
|
return &HTMLDocumentGenerator{
|
||||||
|
outputDir: outputDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateInvoicePDF creates a PDF invoice from HTML template
|
||||||
|
// Returns (filename, error)
|
||||||
|
func (g *HTMLDocumentGenerator) GenerateInvoicePDF(data *InvoicePDFData) (string, error) {
|
||||||
|
fmt.Println("=== HTML Generator: Starting invoice generation (two-pass with T&C page count) ===")
|
||||||
|
|
||||||
|
// FIRST PASS: Generate PDF without page numbers to determine total pages (including T&C)
|
||||||
|
fmt.Println("=== HTML Generator: First pass - generating without page count ===")
|
||||||
|
html := g.BuildInvoiceHTML(data, 0, 0)
|
||||||
|
|
||||||
|
fmt.Printf("=== HTML Generator: Generated %d bytes of HTML ===\n", len(html))
|
||||||
|
|
||||||
|
tempHTML := filepath.Join(g.outputDir, "temp_invoice.html")
|
||||||
|
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write temp HTML: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tempHTML)
|
||||||
|
defer os.Remove(filepath.Join(g.outputDir, "invoice_logo.png")) // Clean up temp logo
|
||||||
|
defer os.Remove(filepath.Join(g.outputDir, "temp_logo.png")) // Clean up temp logo
|
||||||
|
|
||||||
|
// Generate temp PDF
|
||||||
|
tempPDFPath := filepath.Join(g.outputDir, "temp_invoice_first_pass.pdf")
|
||||||
|
if err := g.htmlToPDF(tempHTML, tempPDFPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to convert HTML to PDF (first pass): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial page count from invoice
|
||||||
|
invoicePageCount, err := g.getPageCount(tempPDFPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: Could not extract invoice page count: %v\n", err)
|
||||||
|
invoicePageCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if T&C exists and merge to get total page count
|
||||||
|
totalPageCount := invoicePageCount
|
||||||
|
termsPath := filepath.Join(g.outputDir, "CMC_terms_and_conditions2006_A4.pdf")
|
||||||
|
tempMergedPath := filepath.Join(g.outputDir, fmt.Sprintf("%s_merged_first_pass.pdf", data.Invoice.Title))
|
||||||
|
|
||||||
|
if _, err := os.Stat(termsPath); err == nil {
|
||||||
|
fmt.Println("=== HTML Generator: T&C found, merging to determine total pages ===")
|
||||||
|
if err := MergePDFs(tempPDFPath, termsPath, tempMergedPath); err == nil {
|
||||||
|
// Get total page count from merged PDF
|
||||||
|
totalPageCount, err = g.getPageCount(tempMergedPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: Could not extract merged page count, using invoice count: %v\n", err)
|
||||||
|
totalPageCount = invoicePageCount
|
||||||
|
} else {
|
||||||
|
fmt.Printf("=== HTML Generator: Total pages (invoice + T&C): %d ===\n", totalPageCount)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Warning: Could not merge T&C for counting: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("=== HTML Generator: First pass complete, detected %d total pages ===\n", totalPageCount)
|
||||||
|
os.Remove(tempPDFPath)
|
||||||
|
os.Remove(tempMergedPath)
|
||||||
|
|
||||||
|
// SECOND PASS: Generate final PDF without page count in HTML (will be added via pdfcpu after merge)
|
||||||
|
fmt.Println("=== HTML Generator: Second pass - regenerating final PDF ===")
|
||||||
|
html = g.BuildInvoiceHTML(data, 0, 0)
|
||||||
|
|
||||||
|
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write temp HTML (second pass): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate final PDF filename using invoice number; fallback to a stable default
|
||||||
|
invoiceNumber := ""
|
||||||
|
if data.Document != nil {
|
||||||
|
invoiceNumber = data.Document.CmcReference
|
||||||
|
}
|
||||||
|
filenameBase := invoiceNumber
|
||||||
|
if filenameBase == "" {
|
||||||
|
filenameBase = "CMC Invoice"
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("%s.pdf", filenameBase)
|
||||||
|
pdfPath := filepath.Join(g.outputDir, filename)
|
||||||
|
fmt.Printf("=== HTML Generator: Invoice filename generation - invoiceNumber='%s', filenameBase='%s', final filename='%s' ===\n", invoiceNumber, filenameBase, filename)
|
||||||
|
|
||||||
|
if err := g.htmlToPDF(tempHTML, pdfPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to convert HTML to PDF (second pass): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("=== HTML Generator: PDF generation complete ===")
|
||||||
|
|
||||||
|
// Merge with T&C PDF if it exists
|
||||||
|
if _, err := os.Stat(termsPath); err == nil {
|
||||||
|
fmt.Println("=== HTML Generator: Found T&C PDF, merging ===")
|
||||||
|
tempMergedPath := filepath.Join(g.outputDir, fmt.Sprintf("%s_merged_temp.pdf", filenameBase))
|
||||||
|
if err := MergePDFs(pdfPath, termsPath, tempMergedPath); err != nil {
|
||||||
|
fmt.Printf("=== HTML Generator: Warning - could not merge T&C PDF: %v. Returning invoice without T&C.\n", err)
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
|
// Replace original with merged version
|
||||||
|
if err := os.Rename(tempMergedPath, pdfPath); err != nil {
|
||||||
|
fmt.Printf("=== HTML Generator: Warning - could not replace PDF: %v\n", err)
|
||||||
|
return filename, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add page numbers to final PDF
|
||||||
|
// TODO: Re-enable after fixing pdfcpu watermark API usage
|
||||||
|
// fmt.Println("=== HTML Generator: Adding page numbers to final PDF ===")
|
||||||
|
// if err := AddPageNumbers(pdfPath); err != nil {
|
||||||
|
// fmt.Printf("=== HTML Generator: Warning - could not add page numbers: %v\n", err)
|
||||||
|
// }
|
||||||
|
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateQuotePDF creates a PDF quote from HTML template
|
||||||
|
// Returns (filename, error)
|
||||||
|
func (g *HTMLDocumentGenerator) GenerateQuotePDF(data *QuotePDFData) (string, error) {
|
||||||
|
fmt.Println("=== HTML Generator: Starting quote generation (two-pass with T&C page count) ===")
|
||||||
|
|
||||||
|
// FIRST PASS: Generate PDF without page numbers to determine total pages (including T&C)
|
||||||
|
fmt.Println("=== HTML Generator: First pass - generating without page count ===")
|
||||||
|
html := g.BuildQuoteHTML(data, 0, 0)
|
||||||
|
|
||||||
|
fmt.Printf("=== HTML Generator: Generated %d bytes of HTML ===\n", len(html))
|
||||||
|
|
||||||
|
tempHTML := filepath.Join(g.outputDir, "temp_quote.html")
|
||||||
|
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write temp HTML: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tempHTML)
|
||||||
|
defer os.Remove(filepath.Join(g.outputDir, "quote_logo.png")) // Clean up temp logo
|
||||||
|
|
||||||
|
// Generate temp PDF
|
||||||
|
tempPDFPath := filepath.Join(g.outputDir, "temp_quote_first_pass.pdf")
|
||||||
|
if err := g.htmlToPDF(tempHTML, tempPDFPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to convert HTML to PDF (first pass): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial page count from quote
|
||||||
|
quotePageCount, err := g.getPageCount(tempPDFPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: Could not extract quote page count: %v\n", err)
|
||||||
|
quotePageCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if T&C exists and merge to get total page count
|
||||||
|
totalPageCount := quotePageCount
|
||||||
|
termsPath := filepath.Join(g.outputDir, "CMC_terms_and_conditions2006_A4.pdf")
|
||||||
|
tempMergedPath := filepath.Join(g.outputDir, "temp_quote_merged_first_pass.pdf")
|
||||||
|
|
||||||
|
if _, err := os.Stat(termsPath); err == nil {
|
||||||
|
fmt.Println("=== HTML Generator: T&C found, merging to determine total pages ===")
|
||||||
|
if err := MergePDFs(tempPDFPath, termsPath, tempMergedPath); err == nil {
|
||||||
|
// Get total page count from merged PDF
|
||||||
|
totalPageCount, err = g.getPageCount(tempMergedPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: Could not extract merged page count, using quote count: %v\n", err)
|
||||||
|
totalPageCount = quotePageCount
|
||||||
|
} else {
|
||||||
|
fmt.Printf("=== HTML Generator: Total pages (quote + T&C): %d ===\n", totalPageCount)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Warning: Could not merge T&C for counting: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("=== HTML Generator: First pass complete, detected %d total pages ===\n", totalPageCount)
|
||||||
|
os.Remove(tempPDFPath)
|
||||||
|
os.Remove(tempMergedPath)
|
||||||
|
|
||||||
|
// SECOND PASS: Generate final PDF without page count in HTML (will be added via pdfcpu after merge)
|
||||||
|
fmt.Println("=== HTML Generator: Second pass - regenerating final PDF ===")
|
||||||
|
html = g.BuildQuoteHTML(data, 0, 0)
|
||||||
|
|
||||||
|
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write temp HTML (second pass): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate final PDF filename using quote number
|
||||||
|
quoteNumber := ""
|
||||||
|
if data.Document != nil {
|
||||||
|
log.Printf("=== HTML Generator: Document not nil, CmcReference='%s'", data.Document.CmcReference)
|
||||||
|
quoteNumber = data.Document.CmcReference
|
||||||
|
if quoteNumber == "" {
|
||||||
|
// Strong fallback so we never emit a leading underscore filename
|
||||||
|
quoteNumber = fmt.Sprintf("Quote-%d", data.Document.ID)
|
||||||
|
}
|
||||||
|
if data.Document.Revision > 0 {
|
||||||
|
quoteNumber = fmt.Sprintf("%s_%d", quoteNumber, data.Document.Revision)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("=== HTML Generator: Document is nil!")
|
||||||
|
}
|
||||||
|
log.Printf("=== HTML Generator: Quote number before fallback: '%s', Document ID: %d, Revision: %d",
|
||||||
|
quoteNumber, data.Document.ID, data.Document.Revision)
|
||||||
|
filenameBase := quoteNumber
|
||||||
|
if filenameBase == "" {
|
||||||
|
log.Printf("=== HTML Generator: Using fallback filename")
|
||||||
|
filenameBase = "CMC Quote"
|
||||||
|
}
|
||||||
|
log.Printf("=== HTML Generator: Final filename base: '%s'", filenameBase)
|
||||||
|
filename := fmt.Sprintf("%s.pdf", filenameBase)
|
||||||
|
pdfPath := filepath.Join(g.outputDir, filename)
|
||||||
|
|
||||||
|
if err := g.htmlToPDF(tempHTML, pdfPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to convert HTML to PDF (second pass): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("=== HTML Generator: PDF generation complete ===")
|
||||||
|
|
||||||
|
// Merge with T&C PDF if it exists
|
||||||
|
if _, err := os.Stat(termsPath); err == nil {
|
||||||
|
fmt.Println("=== HTML Generator: Found T&C PDF, merging ===")
|
||||||
|
tempMergedPath := filepath.Join(g.outputDir, fmt.Sprintf("%s_merged_temp.pdf", filenameBase))
|
||||||
|
if err := MergePDFs(pdfPath, termsPath, tempMergedPath); err != nil {
|
||||||
|
fmt.Printf("=== HTML Generator: Warning - could not merge T&C PDF: %v. Returning quote without T&C.\n", err)
|
||||||
|
fmt.Printf("=== HTML Generator: Checking if pdfPath exists: %s\n", pdfPath)
|
||||||
|
if stat, err := os.Stat(pdfPath); err == nil {
|
||||||
|
fmt.Printf("=== HTML Generator: pdfPath exists, size=%d bytes\n", stat.Size())
|
||||||
|
}
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
|
fmt.Printf("=== HTML Generator: Merge succeeded, replacing original PDF\n")
|
||||||
|
// Replace original with merged version
|
||||||
|
if err := os.Rename(tempMergedPath, pdfPath); err != nil {
|
||||||
|
fmt.Printf("=== HTML Generator: Warning - could not replace PDF: %v\n", err)
|
||||||
|
fmt.Printf("=== HTML Generator: tempMergedPath: %s\n", tempMergedPath)
|
||||||
|
fmt.Printf("=== HTML Generator: pdfPath: %s\n", pdfPath)
|
||||||
|
return filename, err
|
||||||
|
}
|
||||||
|
fmt.Printf("=== HTML Generator: Replaced PDF successfully\n")
|
||||||
|
|
||||||
|
// Verify the final file exists
|
||||||
|
if stat, err := os.Stat(pdfPath); err == nil {
|
||||||
|
fmt.Printf("=== HTML Generator: Final PDF verified, size=%d bytes\n", stat.Size())
|
||||||
|
} else {
|
||||||
|
fmt.Printf("=== HTML Generator: ERROR - Final PDF does not exist after merge: %v\n", err)
|
||||||
|
return filename, fmt.Errorf("final PDF does not exist: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add page numbers to final PDF
|
||||||
|
// TODO: Re-enable after fixing pdfcpu watermark API usage
|
||||||
|
// fmt.Println("=== HTML Generator: Adding page numbers to final PDF ===")
|
||||||
|
// if err := AddPageNumbers(pdfPath); err != nil {
|
||||||
|
// fmt.Printf("=== HTML Generator: Warning - could not add page numbers: %v\n", err)
|
||||||
|
// }
|
||||||
|
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlToPDF converts an HTML file to PDF using chromedp
|
||||||
|
func (g *HTMLDocumentGenerator) htmlToPDF(htmlPath, pdfPath string) error {
|
||||||
|
// Read the HTML file to get its content
|
||||||
|
htmlContent, err := ioutil.ReadFile(htmlPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read HTML file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("=== htmlToPDF: Read HTML file, size=%d bytes ===\n", len(htmlContent))
|
||||||
|
|
||||||
|
// Create chromedp context
|
||||||
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||||
|
chromedp.Flag("headless", true),
|
||||||
|
chromedp.Flag("disable-gpu", true),
|
||||||
|
chromedp.Flag("no-sandbox", true),
|
||||||
|
chromedp.Flag("disable-dev-shm-usage", true),
|
||||||
|
)
|
||||||
|
|
||||||
|
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Set timeout
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Navigate to file URL and print to PDF
|
||||||
|
var pdfBuf []byte
|
||||||
|
fileURL := "file://" + htmlPath
|
||||||
|
|
||||||
|
fmt.Printf("=== htmlToPDF: Using file URL: %s ===\n", fileURL)
|
||||||
|
fmt.Println("=== htmlToPDF: Starting chromedp navigation ===")
|
||||||
|
|
||||||
|
if err := chromedp.Run(ctx,
|
||||||
|
chromedp.Navigate(fileURL),
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
fmt.Println("=== htmlToPDF: Executing PrintToPDF ===")
|
||||||
|
var err error
|
||||||
|
pdfBuf, _, err = page.PrintToPDF().
|
||||||
|
WithPrintBackground(true).
|
||||||
|
WithMarginTop(0).
|
||||||
|
WithMarginBottom(0).
|
||||||
|
WithMarginLeft(0).
|
||||||
|
WithMarginRight(0).
|
||||||
|
WithPaperWidth(8.27). // A4 width in inches
|
||||||
|
WithPaperHeight(11.69). // A4 height in inches
|
||||||
|
Do(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("=== htmlToPDF: PrintToPDF error: %v ===\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("=== htmlToPDF: PrintToPDF succeeded, PDF size=%d bytes ===\n", len(pdfBuf))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}),
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("chromedp failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write PDF to file
|
||||||
|
if err := ioutil.WriteFile(pdfPath, pdfBuf, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write PDF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPageCount extracts the page count from a PDF file
|
||||||
|
func (g *HTMLDocumentGenerator) getPageCount(pdfPath string) (int, error) {
|
||||||
|
pageCount, err := api.PageCountFile(pdfPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get page count: %w", err)
|
||||||
|
}
|
||||||
|
return pageCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadLogo loads the logo image and returns it as a relative path
|
||||||
|
func (g *HTMLDocumentGenerator) loadLogo(logoFileName string) string {
|
||||||
|
// Use canonical path: /app/static/images in Docker, go/static/images locally
|
||||||
|
logoPath := "/app/static/images/CMC-Mobile-Logo.png"
|
||||||
|
if _, err := os.Stat(logoPath); err != nil {
|
||||||
|
// Local development path
|
||||||
|
logoPath = filepath.Join("go", "static", "images", "CMC-Mobile-Logo.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
logoData, err := ioutil.ReadFile(logoPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: Could not read logo at %s: %v\n", logoPath, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy logo to output directory for chromedp to access
|
||||||
|
destPath := filepath.Join(g.outputDir, logoFileName)
|
||||||
|
if err := ioutil.WriteFile(destPath, logoData, 0644); err != nil {
|
||||||
|
fmt.Printf("Warning: Could not write logo to output dir: %v\n", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("=== Copied logo from %s to %s ===\n", logoPath, destPath)
|
||||||
|
// Return relative path (same directory as HTML file)
|
||||||
|
return logoFileName
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePurchaseOrderPDF creates a PDF purchase order from HTML template
|
||||||
|
// Returns (filename, error)
|
||||||
|
func (g *HTMLDocumentGenerator) GeneratePurchaseOrderPDF(data *PurchaseOrderPDFData) (string, error) {
|
||||||
|
fmt.Println("=== HTML Generator: Starting purchase order generation ===")
|
||||||
|
|
||||||
|
html := g.BuildPurchaseOrderHTML(data, 0, 0)
|
||||||
|
tempHTML := filepath.Join(g.outputDir, "temp_po.html")
|
||||||
|
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write temp HTML: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tempHTML)
|
||||||
|
defer os.Remove(filepath.Join(g.outputDir, "quote_logo.png"))
|
||||||
|
|
||||||
|
poNumber := data.PurchaseOrder.Title
|
||||||
|
if poNumber == "" {
|
||||||
|
poNumber = fmt.Sprintf("PO-%d", data.PurchaseOrder.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s.pdf", poNumber)
|
||||||
|
pdfPath := filepath.Join(g.outputDir, filename)
|
||||||
|
|
||||||
|
if err := g.htmlToPDF(tempHTML, pdfPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to convert HTML to PDF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePackingListPDF creates a PDF packing list from HTML template
|
||||||
|
// Returns (filename, error)
|
||||||
|
func (g *HTMLDocumentGenerator) GeneratePackingListPDF(data *PackingListPDFData) (string, error) {
|
||||||
|
fmt.Println("=== HTML Generator: Starting packing list generation ===")
|
||||||
|
|
||||||
|
html := g.BuildPackingListHTML(data, 0, 0)
|
||||||
|
tempHTML := filepath.Join(g.outputDir, "temp_packinglist.html")
|
||||||
|
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write temp HTML: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tempHTML)
|
||||||
|
defer os.Remove(filepath.Join(g.outputDir, "quote_logo.png"))
|
||||||
|
|
||||||
|
packingListNumber := data.PackingList.CmcReference
|
||||||
|
if packingListNumber == "" {
|
||||||
|
packingListNumber = fmt.Sprintf("PackingList-%d", data.PackingList.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s.pdf", packingListNumber)
|
||||||
|
pdfPath := filepath.Join(g.outputDir, filename)
|
||||||
|
|
||||||
|
if err := g.htmlToPDF(tempHTML, pdfPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to convert HTML to PDF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateOrderAckPDF creates a PDF order acknowledgement from HTML template
|
||||||
|
// Returns (filename, error)
|
||||||
|
func (g *HTMLDocumentGenerator) GenerateOrderAckPDF(data *OrderAckPDFData) (string, error) {
|
||||||
|
fmt.Println("=== HTML Generator: Starting order acknowledgement generation ===")
|
||||||
|
|
||||||
|
html := g.BuildOrderAckHTML(data, 0, 0)
|
||||||
|
tempHTML := filepath.Join(g.outputDir, "temp_orderack.html")
|
||||||
|
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write temp HTML: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tempHTML)
|
||||||
|
defer os.Remove(filepath.Join(g.outputDir, "quote_logo.png"))
|
||||||
|
|
||||||
|
orderAckNumber := data.OrderAcknowledgement.CmcReference
|
||||||
|
if orderAckNumber == "" {
|
||||||
|
orderAckNumber = fmt.Sprintf("OrderAck-%d", data.OrderAcknowledgement.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s.pdf", orderAckNumber)
|
||||||
|
pdfPath := filepath.Join(g.outputDir, filename)
|
||||||
|
|
||||||
|
if err := g.htmlToPDF(tempHTML, pdfPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to convert HTML to PDF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
131
go/internal/cmc/documents/html_types.go
Normal file
131
go/internal/cmc/documents/html_types.go
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
package pdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||||
|
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QuotePDFData contains all data needed to generate a quote PDF
|
||||||
|
type QuotePDFData struct {
|
||||||
|
Document *db.Document
|
||||||
|
Quote interface{} // Quote specific data
|
||||||
|
Enquiry *db.Enquiry
|
||||||
|
Customer *db.Customer
|
||||||
|
EmailTo string
|
||||||
|
Attention string
|
||||||
|
User *db.GetUserRow
|
||||||
|
LineItems []db.GetLineItemsTableRow
|
||||||
|
Currency interface{} // Currency data
|
||||||
|
CurrencySymbol string
|
||||||
|
CurrencyCode string
|
||||||
|
ShowGST bool
|
||||||
|
CommercialComments string
|
||||||
|
DeliveryTime string
|
||||||
|
PaymentTerms string
|
||||||
|
DaysValid int
|
||||||
|
DeliveryPoint string
|
||||||
|
ExchangeRate string
|
||||||
|
CustomsDuty string
|
||||||
|
GSTPhrase string
|
||||||
|
SalesEngineer string
|
||||||
|
BillTo string
|
||||||
|
ShipTo string
|
||||||
|
IssueDateString string
|
||||||
|
Pages []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoicePDFData contains all data needed to generate an invoice PDF
|
||||||
|
type InvoicePDFData struct {
|
||||||
|
Document *db.Document
|
||||||
|
Invoice *db.Invoice
|
||||||
|
Enquiry *db.Enquiry
|
||||||
|
Customer *db.Customer
|
||||||
|
Job interface{} // Job data
|
||||||
|
LineItems []db.GetLineItemsTableRow
|
||||||
|
Currency interface{} // Currency data
|
||||||
|
CurrencySymbol string
|
||||||
|
CurrencyCode string
|
||||||
|
ShowGST bool
|
||||||
|
ShipVia string
|
||||||
|
FOB string
|
||||||
|
IssueDate time.Time
|
||||||
|
IssueDateString string
|
||||||
|
EmailTo string
|
||||||
|
Attention string
|
||||||
|
FromName string
|
||||||
|
FromEmail string
|
||||||
|
YourReference string
|
||||||
|
BillTo string
|
||||||
|
ShipTo string
|
||||||
|
ShippingDetails string
|
||||||
|
CustomerOrderNumber string
|
||||||
|
JobTitle string
|
||||||
|
PaymentTerms string
|
||||||
|
CustomerABN string
|
||||||
|
Subtotal interface{} // Can be float or "TBA"
|
||||||
|
GSTAmount interface{}
|
||||||
|
Total interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchaseOrderPDFData contains all data needed to generate a purchase order PDF
|
||||||
|
type PurchaseOrderPDFData struct {
|
||||||
|
Document *db.Document
|
||||||
|
PurchaseOrder *db.PurchaseOrder
|
||||||
|
Principle *db.Principle
|
||||||
|
LineItems []db.GetLineItemsTableRow
|
||||||
|
Currency interface{} // Currency data
|
||||||
|
CurrencySymbol string
|
||||||
|
CurrencyCode string
|
||||||
|
ShowGST bool
|
||||||
|
Subtotal float64
|
||||||
|
GSTAmount float64
|
||||||
|
Total float64
|
||||||
|
IssueDateString string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackingListPDFData for HTML template generation
|
||||||
|
type PackingListPDFData struct {
|
||||||
|
PackingList *db.Document
|
||||||
|
Customer *db.Customer
|
||||||
|
JobTitle string
|
||||||
|
IssueDateString string
|
||||||
|
CustomerOrderNumber string
|
||||||
|
ShipTo string
|
||||||
|
LineItems []db.GetLineItemsTableRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderAckPDFData for HTML template generation
|
||||||
|
type OrderAckPDFData struct {
|
||||||
|
OrderAcknowledgement *db.Document
|
||||||
|
Customer *db.Customer
|
||||||
|
EmailTo string
|
||||||
|
Attention string
|
||||||
|
IssueDateString string
|
||||||
|
YourReference string
|
||||||
|
JobTitle string
|
||||||
|
BillTo string
|
||||||
|
ShipTo string
|
||||||
|
ShipVia string
|
||||||
|
FOB string
|
||||||
|
PaymentTerms string
|
||||||
|
CurrencyCode string
|
||||||
|
CurrencySymbol string
|
||||||
|
ShowGST bool
|
||||||
|
LineItems []db.GetLineItemsTableRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergePDFs merges multiple PDF files into one
|
||||||
|
func MergePDFs(invoicePath, termsPath, outputPath string) error {
|
||||||
|
// Merge the PDFs: invoice first, then terms
|
||||||
|
inFiles := []string{invoicePath, termsPath}
|
||||||
|
|
||||||
|
err := api.MergeCreateFile(inFiles, outputPath, false, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to merge PDFs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -111,12 +111,12 @@ func (g *HTMLDocumentGenerator) BuildInvoiceHTML(data *InvoicePDFData, totalPage
|
||||||
// Try multiple possible path sets to find the invoice and shared header templates together
|
// Try multiple possible path sets to find the invoice and shared header templates together
|
||||||
possiblePathSets := [][]string{
|
possiblePathSets := [][]string{
|
||||||
{
|
{
|
||||||
filepath.Join("internal", "cmc", "pdf", "templates", "invoice.html"),
|
filepath.Join("internal", "cmc", "documents", "templates", "invoice.html"),
|
||||||
filepath.Join("internal", "cmc", "pdf", "templates", "header.html"),
|
filepath.Join("internal", "cmc", "documents", "templates", "header.html"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filepath.Join("go", "internal", "cmc", "pdf", "templates", "invoice.html"),
|
filepath.Join("go", "internal", "cmc", "documents", "templates", "invoice.html"),
|
||||||
filepath.Join("go", "internal", "cmc", "pdf", "templates", "header.html"),
|
filepath.Join("go", "internal", "cmc", "documents", "templates", "header.html"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filepath.Join("templates", "pdf", "invoice.html"),
|
filepath.Join("templates", "pdf", "invoice.html"),
|
||||||
161
go/internal/cmc/documents/order_ack_builder.go
Normal file
161
go/internal/cmc/documents/order_ack_builder.go
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
package pdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrderAckLineItemTemplateData represents an order ack line item for template rendering
|
||||||
|
type OrderAckLineItemTemplateData struct {
|
||||||
|
Title string
|
||||||
|
Description template.HTML
|
||||||
|
Quantity string
|
||||||
|
UnitPrice float64
|
||||||
|
Discount float64
|
||||||
|
TotalPrice float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildOrderAckHTML generates the complete HTML for an order acknowledgement using templates
|
||||||
|
func (g *HTMLDocumentGenerator) BuildOrderAckHTML(data *OrderAckPDFData, totalPages int, currentPage int) string {
|
||||||
|
orderAckNumber := data.OrderAcknowledgement.CmcReference
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
subtotal := 0.0
|
||||||
|
lineItemsData := []OrderAckLineItemTemplateData{}
|
||||||
|
|
||||||
|
for _, item := range data.LineItems {
|
||||||
|
unitPrice := 0.0
|
||||||
|
totalPrice := 0.0
|
||||||
|
discount := 0.0
|
||||||
|
|
||||||
|
if item.GrossUnitPrice.Valid {
|
||||||
|
unitPrice, _ = strconv.ParseFloat(item.GrossUnitPrice.String, 64)
|
||||||
|
}
|
||||||
|
if item.GrossPrice.Valid {
|
||||||
|
totalPrice, _ = strconv.ParseFloat(item.GrossPrice.String, 64)
|
||||||
|
subtotal += totalPrice
|
||||||
|
}
|
||||||
|
if item.DiscountAmountTotal.Valid {
|
||||||
|
discount, _ = strconv.ParseFloat(item.DiscountAmountTotal.String, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
lineItemsData = append(lineItemsData, OrderAckLineItemTemplateData{
|
||||||
|
Title: item.Title,
|
||||||
|
Description: template.HTML(item.Description),
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: unitPrice,
|
||||||
|
Discount: discount,
|
||||||
|
TotalPrice: totalPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
gstAmount := 0.0
|
||||||
|
total := subtotal
|
||||||
|
if data.ShowGST {
|
||||||
|
gstAmount = subtotal * 0.1
|
||||||
|
total = subtotal + gstAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare template data
|
||||||
|
templateData := struct {
|
||||||
|
OrderAckNumber string
|
||||||
|
CompanyName string
|
||||||
|
EmailTo string
|
||||||
|
Attention string
|
||||||
|
IssueDateString string
|
||||||
|
YourReference string
|
||||||
|
JobTitle string
|
||||||
|
BillTo string
|
||||||
|
ShipTo string
|
||||||
|
ShipVia string
|
||||||
|
FOB string
|
||||||
|
PaymentTerms string
|
||||||
|
CurrencyCode string
|
||||||
|
CurrencySymbol string
|
||||||
|
LineItems []OrderAckLineItemTemplateData
|
||||||
|
Subtotal float64
|
||||||
|
GSTAmount float64
|
||||||
|
Total float64
|
||||||
|
ShowGST bool
|
||||||
|
LogoDataURI string
|
||||||
|
}{
|
||||||
|
OrderAckNumber: orderAckNumber,
|
||||||
|
CompanyName: data.Customer.Name,
|
||||||
|
EmailTo: data.EmailTo,
|
||||||
|
Attention: data.Attention,
|
||||||
|
IssueDateString: data.IssueDateString,
|
||||||
|
YourReference: data.YourReference,
|
||||||
|
JobTitle: data.JobTitle,
|
||||||
|
BillTo: data.BillTo,
|
||||||
|
ShipTo: data.ShipTo,
|
||||||
|
ShipVia: data.ShipVia,
|
||||||
|
FOB: data.FOB,
|
||||||
|
PaymentTerms: data.PaymentTerms,
|
||||||
|
CurrencyCode: data.CurrencyCode,
|
||||||
|
CurrencySymbol: data.CurrencySymbol,
|
||||||
|
LineItems: lineItemsData,
|
||||||
|
Subtotal: subtotal,
|
||||||
|
GSTAmount: gstAmount,
|
||||||
|
Total: total,
|
||||||
|
ShowGST: data.ShowGST,
|
||||||
|
LogoDataURI: g.loadLogo("quote_logo.png"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define template functions
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
"formatPrice": func(price float64) template.HTML {
|
||||||
|
formatted := FormatPriceWithCommas(fmt.Sprintf("%.2f", price))
|
||||||
|
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
|
||||||
|
},
|
||||||
|
"formatTotal": func(amount float64) template.HTML {
|
||||||
|
formatted := FormatPriceWithCommas(fmt.Sprintf("%.2f", amount))
|
||||||
|
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and execute template
|
||||||
|
possiblePathSets := [][]string{
|
||||||
|
{
|
||||||
|
filepath.Join("internal", "cmc", "documents", "templates", "order-acknowledgement.html"),
|
||||||
|
filepath.Join("internal", "cmc", "documents", "templates", "header.html"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filepath.Join("go", "internal", "cmc", "documents", "templates", "order-acknowledgement.html"),
|
||||||
|
filepath.Join("go", "internal", "cmc", "documents", "templates", "header.html"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filepath.Join("templates", "pdf", "order-acknowledgement.html"),
|
||||||
|
filepath.Join("templates", "pdf", "header.html"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/app/templates/pdf/order-acknowledgement.html",
|
||||||
|
"/app/templates/pdf/header.html",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmpl *template.Template
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for _, pathSet := range possiblePathSets {
|
||||||
|
tmpl, err = template.New("order-acknowledgement.html").Funcs(funcMap).ParseFiles(pathSet...)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmpl == nil || err != nil {
|
||||||
|
fmt.Printf("Error parsing template from any path: %v\n", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, templateData); err != nil {
|
||||||
|
fmt.Printf("Error executing template: %v\n", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
100
go/internal/cmc/documents/packing_list_builder.go
Normal file
100
go/internal/cmc/documents/packing_list_builder.go
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
package pdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PackingListLineItemTemplateData represents a packing list line item for template rendering
|
||||||
|
type PackingListLineItemTemplateData struct {
|
||||||
|
Title string
|
||||||
|
Description template.HTML
|
||||||
|
Quantity string
|
||||||
|
Weight string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPackingListHTML generates the complete HTML for a packing list using templates
|
||||||
|
func (g *HTMLDocumentGenerator) BuildPackingListHTML(data *PackingListPDFData, totalPages int, currentPage int) string {
|
||||||
|
packingListNumber := data.PackingList.CmcReference
|
||||||
|
|
||||||
|
// Prepare line items
|
||||||
|
lineItemsData := []PackingListLineItemTemplateData{}
|
||||||
|
|
||||||
|
for _, item := range data.LineItems {
|
||||||
|
lineItemsData = append(lineItemsData, PackingListLineItemTemplateData{
|
||||||
|
Title: item.Title,
|
||||||
|
Description: template.HTML(item.Description),
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Weight: "", // Weight not currently in LineItem model
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare template data
|
||||||
|
templateData := struct {
|
||||||
|
PackingListNumber string
|
||||||
|
IssueDateString string
|
||||||
|
JobTitle string
|
||||||
|
CustomerOrderNumber string
|
||||||
|
FromAddress string
|
||||||
|
ShipTo string
|
||||||
|
LineItems []PackingListLineItemTemplateData
|
||||||
|
LogoDataURI string
|
||||||
|
}{
|
||||||
|
PackingListNumber: packingListNumber,
|
||||||
|
IssueDateString: data.IssueDateString,
|
||||||
|
JobTitle: data.JobTitle,
|
||||||
|
CustomerOrderNumber: data.CustomerOrderNumber,
|
||||||
|
FromAddress: "CMC TECHNOLOGIES\n1/50 Carrington Road\nMarrickville NSW 2204\nAustralia",
|
||||||
|
ShipTo: data.ShipTo,
|
||||||
|
LineItems: lineItemsData,
|
||||||
|
LogoDataURI: g.loadLogo("quote_logo.png"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define template functions
|
||||||
|
funcMap := template.FuncMap{}
|
||||||
|
|
||||||
|
// Parse and execute template
|
||||||
|
possiblePathSets := [][]string{
|
||||||
|
{
|
||||||
|
filepath.Join("internal", "cmc", "documents", "templates", "packing-list.html"),
|
||||||
|
filepath.Join("internal", "cmc", "documents", "templates", "header.html"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filepath.Join("go", "internal", "cmc", "documents", "templates", "packing-list.html"),
|
||||||
|
filepath.Join("go", "internal", "cmc", "documents", "templates", "header.html"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filepath.Join("templates", "pdf", "packing-list.html"),
|
||||||
|
filepath.Join("templates", "pdf", "header.html"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/app/templates/pdf/packing-list.html",
|
||||||
|
"/app/templates/pdf/header.html",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmpl *template.Template
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for _, pathSet := range possiblePathSets {
|
||||||
|
tmpl, err = template.New("packing-list.html").Funcs(funcMap).ParseFiles(pathSet...)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmpl == nil || err != nil {
|
||||||
|
fmt.Printf("Error parsing template from any path: %v\n", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, templateData); err != nil {
|
||||||
|
fmt.Printf("Error executing template: %v\n", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
147
go/internal/cmc/documents/purchase_order_builder.go
Normal file
147
go/internal/cmc/documents/purchase_order_builder.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package pdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PurchaseOrderLineItemTemplateData represents a PO line item for template rendering
|
||||||
|
type PurchaseOrderLineItemTemplateData struct {
|
||||||
|
Title string
|
||||||
|
Description template.HTML
|
||||||
|
Quantity string
|
||||||
|
UnitPrice float64
|
||||||
|
TotalPrice float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPurchaseOrderHTML generates the complete HTML for a purchase order using templates
|
||||||
|
func (g *HTMLDocumentGenerator) BuildPurchaseOrderHTML(data *PurchaseOrderPDFData, totalPages int, currentPage int) string {
|
||||||
|
poNumber := data.PurchaseOrder.Title
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
subtotal := 0.0
|
||||||
|
lineItemsData := []PurchaseOrderLineItemTemplateData{}
|
||||||
|
|
||||||
|
for _, item := range data.LineItems {
|
||||||
|
unitPrice := 0.0
|
||||||
|
totalPrice := 0.0
|
||||||
|
|
||||||
|
if item.NetUnitPrice.Valid {
|
||||||
|
unitPrice, _ = strconv.ParseFloat(item.NetUnitPrice.String, 64)
|
||||||
|
}
|
||||||
|
if item.NetPrice.Valid {
|
||||||
|
totalPrice, _ = strconv.ParseFloat(item.NetPrice.String, 64)
|
||||||
|
subtotal += totalPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
lineItemsData = append(lineItemsData, PurchaseOrderLineItemTemplateData{
|
||||||
|
Title: item.Title,
|
||||||
|
Description: template.HTML(item.Description),
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: unitPrice,
|
||||||
|
TotalPrice: totalPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
gstAmount := 0.0
|
||||||
|
total := subtotal
|
||||||
|
if data.ShowGST {
|
||||||
|
gstAmount = subtotal * 0.1
|
||||||
|
total = subtotal + gstAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare template data
|
||||||
|
templateData := struct {
|
||||||
|
PONumber string
|
||||||
|
PrincipleName string
|
||||||
|
YourReference string
|
||||||
|
IssueDateString string
|
||||||
|
OrderedFrom string
|
||||||
|
DeliverTo string
|
||||||
|
DispatchBy string
|
||||||
|
ShippingInstructions string
|
||||||
|
CurrencyCode string
|
||||||
|
CurrencySymbol string
|
||||||
|
LineItems []PurchaseOrderLineItemTemplateData
|
||||||
|
Subtotal float64
|
||||||
|
GSTAmount float64
|
||||||
|
Total float64
|
||||||
|
ShowGST bool
|
||||||
|
LogoDataURI string
|
||||||
|
}{
|
||||||
|
PONumber: poNumber,
|
||||||
|
PrincipleName: data.Principle.Name,
|
||||||
|
YourReference: data.PurchaseOrder.PrincipleReference,
|
||||||
|
IssueDateString: data.IssueDateString,
|
||||||
|
OrderedFrom: data.PurchaseOrder.OrderedFrom,
|
||||||
|
DeliverTo: data.PurchaseOrder.DeliverTo,
|
||||||
|
DispatchBy: data.PurchaseOrder.DispatchBy,
|
||||||
|
ShippingInstructions: data.PurchaseOrder.ShippingInstructions,
|
||||||
|
CurrencyCode: data.CurrencyCode,
|
||||||
|
CurrencySymbol: data.CurrencySymbol,
|
||||||
|
LineItems: lineItemsData,
|
||||||
|
Subtotal: subtotal,
|
||||||
|
GSTAmount: gstAmount,
|
||||||
|
Total: total,
|
||||||
|
ShowGST: data.ShowGST,
|
||||||
|
LogoDataURI: g.loadLogo("quote_logo.png"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define template functions
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
"formatPrice": func(price float64) template.HTML {
|
||||||
|
formatted := FormatPriceWithCommas(fmt.Sprintf("%.2f", price))
|
||||||
|
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
|
||||||
|
},
|
||||||
|
"formatTotal": func(amount float64) template.HTML {
|
||||||
|
formatted := FormatPriceWithCommas(fmt.Sprintf("%.2f", amount))
|
||||||
|
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and execute template
|
||||||
|
possiblePathSets := [][]string{
|
||||||
|
{
|
||||||
|
filepath.Join("internal", "cmc", "documents", "templates", "purchase-order.html"),
|
||||||
|
filepath.Join("internal", "cmc", "documents", "templates", "header.html"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filepath.Join("go", "internal", "cmc", "documents", "templates", "purchase-order.html"),
|
||||||
|
filepath.Join("go", "internal", "cmc", "documents", "templates", "header.html"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filepath.Join("templates", "pdf", "purchase-order.html"),
|
||||||
|
filepath.Join("templates", "pdf", "header.html"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/app/templates/pdf/purchase-order.html",
|
||||||
|
"/app/templates/pdf/header.html",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmpl *template.Template
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for _, pathSet := range possiblePathSets {
|
||||||
|
tmpl, err = template.New("purchase-order.html").Funcs(funcMap).ParseFiles(pathSet...)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmpl == nil || err != nil {
|
||||||
|
fmt.Printf("Error parsing template from any path: %v\n", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, templateData); err != nil {
|
||||||
|
fmt.Printf("Error executing template: %v\n", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
@ -161,12 +161,12 @@ func (g *HTMLDocumentGenerator) BuildQuoteHTML(data *QuotePDFData, totalPages in
|
||||||
// Try multiple possible path sets to find the quote and shared header templates together
|
// Try multiple possible path sets to find the quote and shared header templates together
|
||||||
possiblePathSets := [][]string{
|
possiblePathSets := [][]string{
|
||||||
{
|
{
|
||||||
filepath.Join("internal", "cmc", "pdf", "templates", "quote.html"),
|
filepath.Join("internal", "cmc", "documents", "templates", "quote.html"),
|
||||||
filepath.Join("internal", "cmc", "pdf", "templates", "header.html"),
|
filepath.Join("internal", "cmc", "documents", "templates", "header.html"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filepath.Join("go", "internal", "cmc", "pdf", "templates", "quote.html"),
|
filepath.Join("go", "internal", "cmc", "documents", "templates", "quote.html"),
|
||||||
filepath.Join("go", "internal", "cmc", "pdf", "templates", "header.html"),
|
filepath.Join("go", "internal", "cmc", "documents", "templates", "header.html"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filepath.Join("templates", "pdf", "quote.html"),
|
filepath.Join("templates", "pdf", "quote.html"),
|
||||||
|
|
@ -185,8 +185,8 @@ func (g *HTMLDocumentGenerator) BuildQuoteHTML(data *QuotePDFData, totalPages in
|
||||||
"/app/templates/header.html",
|
"/app/templates/header.html",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/app/go/internal/cmc/pdf/templates/quote.html",
|
"/app/go/internal/cmc/documents/templates/quote.html",
|
||||||
"/app/go/internal/cmc/pdf/templates/header.html",
|
"/app/go/internal/cmc/documents/templates/header.html",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
316
go/internal/cmc/documents/templates/order-acknowledgement.html
Normal file
316
go/internal/cmc/documents/templates/order-acknowledgement.html
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{if .OrderAckNumber}}{{.OrderAckNumber}}{{else}}CMC Order Acknowledgement{{end}}</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 15mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 25mm 0;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 2mm;
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td.label {
|
||||||
|
font-weight: bold;
|
||||||
|
width: 25%;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-boxes {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-box {
|
||||||
|
width: 48%;
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 3mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-box h3 {
|
||||||
|
font-size: 10pt;
|
||||||
|
margin: 0 0 2mm 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items {
|
||||||
|
width: 99.5%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 5mm;
|
||||||
|
table-layout: fixed;
|
||||||
|
margin-right: 1mm;
|
||||||
|
margin-left: auto;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 2mm;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items tbody tr {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items td {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 2mm;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items .description {
|
||||||
|
width: 50%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items .qty {
|
||||||
|
width: 10%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items .unit-price {
|
||||||
|
width: 13%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items .discount {
|
||||||
|
width: 13%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items .total {
|
||||||
|
width: 14%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 5mm;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
page-break-before: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table {
|
||||||
|
width: auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 1px solid #000;
|
||||||
|
margin-right: 0.2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td {
|
||||||
|
padding: 1mm 3mm;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
width: 50%;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table .total-row td {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 11pt;
|
||||||
|
padding-top: 2mm;
|
||||||
|
border-top: 1px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description ul, .description ol {
|
||||||
|
margin: 2mm 0;
|
||||||
|
padding-left: 5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description li {
|
||||||
|
margin: 1mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 20mm;
|
||||||
|
border-top: 1px solid #000;
|
||||||
|
padding-top: 2mm;
|
||||||
|
font-size: 9pt;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer .services-title {
|
||||||
|
font-weight: normal;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer .services-line1, .footer .services-line2 {
|
||||||
|
margin: 1mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer .service-explosion { color: #990006; }
|
||||||
|
.footer .service-fire { color: #FF9900; }
|
||||||
|
.footer .service-pressure { color: #FF0019; }
|
||||||
|
.footer .service-vision { color: #00801E; }
|
||||||
|
.footer .service-flow { color: #2F4BE0; }
|
||||||
|
.footer .service-process { color: #AB31F8; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "CompanyHeader" .}}
|
||||||
|
|
||||||
|
<!-- Order Ack Details Table -->
|
||||||
|
<table class="details-table">
|
||||||
|
<tr>
|
||||||
|
<td class="label">COMPANY NAME:</td>
|
||||||
|
<td>{{.CompanyName}}</td>
|
||||||
|
<td class="label">ORDER ACK #:</td>
|
||||||
|
<td>{{.OrderAckNumber}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">CONTACT:</td>
|
||||||
|
<td>{{.Attention}}</td>
|
||||||
|
<td class="label">DATE:</td>
|
||||||
|
<td>{{.IssueDateString}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">EMAIL:</td>
|
||||||
|
<td>{{.EmailTo}}</td>
|
||||||
|
<td class="label">YOUR REFERENCE:</td>
|
||||||
|
<td>{{.YourReference}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">JOB TITLE:</td>
|
||||||
|
<td colspan="3">{{.JobTitle}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Address Boxes -->
|
||||||
|
<div class="address-boxes">
|
||||||
|
<div class="address-box">
|
||||||
|
<h3>BILL TO:</h3>
|
||||||
|
<p>{{.BillTo}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="address-box">
|
||||||
|
<h3>SHIP TO:</h3>
|
||||||
|
<p>{{.ShipTo}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shipping Details -->
|
||||||
|
<table class="details-table">
|
||||||
|
<tr>
|
||||||
|
<td class="label">SHIP VIA:</td>
|
||||||
|
<td>{{.ShipVia}}</td>
|
||||||
|
<td class="label">FOB:</td>
|
||||||
|
<td>{{.FOB}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">PAYMENT TERMS:</td>
|
||||||
|
<td colspan="3">{{.PaymentTerms}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Currency Code Header -->
|
||||||
|
<div style="margin-bottom: 1mm; text-align: right; font-weight: bold; font-size: 9pt;">
|
||||||
|
Shown in {{.CurrencyCode}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line Items Table -->
|
||||||
|
<table class="line-items">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="description">DESCRIPTION</th>
|
||||||
|
<th class="qty">QTY</th>
|
||||||
|
<th class="unit-price">UNIT PRICE</th>
|
||||||
|
<th class="discount">DISCOUNT</th>
|
||||||
|
<th class="total">TOTAL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .LineItems}}
|
||||||
|
<tr>
|
||||||
|
<td class="description"><strong>{{.Title}}</strong>{{if .Description}}<br>{{.Description}}{{end}}</td>
|
||||||
|
<td class="qty">{{.Quantity}}</td>
|
||||||
|
<td class="unit-price">{{formatPrice .UnitPrice}}</td>
|
||||||
|
<td class="discount">{{if .Discount}}{{formatPrice .Discount}}{{else}}$0.00{{end}}</td>
|
||||||
|
<td class="total">{{formatPrice .TotalPrice}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Totals -->
|
||||||
|
<div class="totals-container">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tr>
|
||||||
|
<td>SUBTOTAL</td>
|
||||||
|
<td>{{formatTotal .Subtotal}}</td>
|
||||||
|
</tr>
|
||||||
|
{{if .ShowGST}}
|
||||||
|
<tr>
|
||||||
|
<td>GST (10%)</td>
|
||||||
|
<td>{{formatTotal .GSTAmount}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>{{if .ShowGST}}TOTAL PAYABLE{{else}}TOTAL{{end}}</td>
|
||||||
|
<td>{{formatTotal .Total}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
<div class="services-title">CMC TECHNOLOGIES Provides Solutions in the Following Fields</div>
|
||||||
|
<div class="services-line1">
|
||||||
|
<span class="service-explosion">EXPLOSION PREVENTION AND PROTECTION</span> —
|
||||||
|
<span class="service-fire">FIRE PROTECTION</span> —
|
||||||
|
<span class="service-pressure">PRESSURE RELIEF</span> —
|
||||||
|
<span class="service-vision">VISION IN THE PROCESS</span>
|
||||||
|
</div>
|
||||||
|
<div class="services-line2">
|
||||||
|
<span class="service-flow">FLOW MEASUREMENT</span> —
|
||||||
|
<span class="service-process">PROCESS INSTRUMENTATION</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
216
go/internal/cmc/documents/templates/packing-list.html
Normal file
216
go/internal/cmc/documents/templates/packing-list.html
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{if .PackingListNumber}}{{.PackingListNumber}}{{else}}CMC Packing List{{end}}</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 15mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 25mm 0;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 2mm;
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td.label {
|
||||||
|
font-weight: bold;
|
||||||
|
width: 25%;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-boxes {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-box {
|
||||||
|
width: 48%;
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 3mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-box h3 {
|
||||||
|
font-size: 10pt;
|
||||||
|
margin: 0 0 2mm 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items {
|
||||||
|
width: 99.5%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 5mm;
|
||||||
|
table-layout: fixed;
|
||||||
|
margin-right: 1mm;
|
||||||
|
margin-left: auto;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 2mm;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items tbody tr {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items td {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 2mm;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items .description {
|
||||||
|
width: 70%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items .qty {
|
||||||
|
width: 15%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items .weight {
|
||||||
|
width: 15%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description ul, .description ol {
|
||||||
|
margin: 2mm 0;
|
||||||
|
padding-left: 5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description li {
|
||||||
|
margin: 1mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 20mm;
|
||||||
|
border-top: 1px solid #000;
|
||||||
|
padding-top: 2mm;
|
||||||
|
font-size: 9pt;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer .services-title {
|
||||||
|
font-weight: normal;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer .services-line1, .footer .services-line2 {
|
||||||
|
margin: 1mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer .service-explosion { color: #990006; }
|
||||||
|
.footer .service-fire { color: #FF9900; }
|
||||||
|
.footer .service-pressure { color: #FF0019; }
|
||||||
|
.footer .service-vision { color: #00801E; }
|
||||||
|
.footer .service-flow { color: #2F4BE0; }
|
||||||
|
.footer .service-process { color: #AB31F8; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "CompanyHeader" .}}
|
||||||
|
|
||||||
|
<!-- Packing List Details Table -->
|
||||||
|
<table class="details-table">
|
||||||
|
<tr>
|
||||||
|
<td class="label">PACKING LIST #:</td>
|
||||||
|
<td>{{.PackingListNumber}}</td>
|
||||||
|
<td class="label">DATE:</td>
|
||||||
|
<td>{{.IssueDateString}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">JOB TITLE:</td>
|
||||||
|
<td colspan="3">{{.JobTitle}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">CUSTOMER ORDER #:</td>
|
||||||
|
<td colspan="3">{{.CustomerOrderNumber}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Address Boxes -->
|
||||||
|
<div class="address-boxes">
|
||||||
|
<div class="address-box">
|
||||||
|
<h3>FROM:</h3>
|
||||||
|
<p>{{.FromAddress}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="address-box">
|
||||||
|
<h3>TO:</h3>
|
||||||
|
<p>{{.ShipTo}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line Items Table -->
|
||||||
|
<table class="line-items">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="description">DESCRIPTION</th>
|
||||||
|
<th class="qty">QTY</th>
|
||||||
|
<th class="weight">WEIGHT (kg)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .LineItems}}
|
||||||
|
<tr>
|
||||||
|
<td class="description"><strong>{{.Title}}</strong>{{if .Description}}<br>{{.Description}}{{end}}</td>
|
||||||
|
<td class="qty">{{.Quantity}}</td>
|
||||||
|
<td class="weight">{{if .Weight}}{{.Weight}}{{else}}-{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
<div class="services-title">CMC TECHNOLOGIES Provides Solutions in the Following Fields</div>
|
||||||
|
<div class="services-line1">
|
||||||
|
<span class="service-explosion">EXPLOSION PREVENTION AND PROTECTION</span> —
|
||||||
|
<span class="service-fire">FIRE PROTECTION</span> —
|
||||||
|
<span class="service-pressure">PRESSURE RELIEF</span> —
|
||||||
|
<span class="service-vision">VISION IN THE PROCESS</span>
|
||||||
|
</div>
|
||||||
|
<div class="services-line2">
|
||||||
|
<span class="service-flow">FLOW MEASUREMENT</span> —
|
||||||
|
<span class="service-process">PROCESS INSTRUMENTATION</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
286
go/internal/cmc/documents/templates/purchase-order.html
Normal file
286
go/internal/cmc/documents/templates/purchase-order.html
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{if .PONumber}}{{.PONumber}}{{else}}CMC Purchase Order{{end}}</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 15mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 25mm 0;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 2mm;
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td.label {
|
||||||
|
font-weight: bold;
|
||||||
|
width: 25%;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items {
|
||||||
|
width: 99.5%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 5mm;
|
||||||
|
table-layout: fixed;
|
||||||
|
margin-right: 1mm;
|
||||||
|
margin-left: auto;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 2mm;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items tbody tr {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items td {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 2mm;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items .description {
|
||||||
|
width: 50%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items .qty {
|
||||||
|
width: 10%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items .unit-price {
|
||||||
|
width: 20%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items .total {
|
||||||
|
width: 20%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 5mm;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
page-break-before: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table {
|
||||||
|
width: auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 1px solid #000;
|
||||||
|
margin-right: 0.2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td {
|
||||||
|
padding: 1mm 3mm;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
width: 50%;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table .total-row td {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 11pt;
|
||||||
|
padding-top: 2mm;
|
||||||
|
border-top: 1px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description ul, .description ol {
|
||||||
|
margin: 2mm 0;
|
||||||
|
padding-left: 5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description li {
|
||||||
|
margin: 1mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
margin: 5mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section h3 {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 2mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 20mm;
|
||||||
|
border-top: 1px solid #000;
|
||||||
|
padding-top: 2mm;
|
||||||
|
font-size: 9pt;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer .services-title {
|
||||||
|
font-weight: normal;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer .services-line1, .footer .services-line2 {
|
||||||
|
margin: 1mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer .service-explosion { color: #990006; }
|
||||||
|
.footer .service-fire { color: #FF9900; }
|
||||||
|
.footer .service-pressure { color: #FF0019; }
|
||||||
|
.footer .service-vision { color: #00801E; }
|
||||||
|
.footer .service-flow { color: #2F4BE0; }
|
||||||
|
.footer .service-process { color: #AB31F8; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "CompanyHeader" .}}
|
||||||
|
|
||||||
|
<!-- PO Details Table -->
|
||||||
|
<table class="details-table">
|
||||||
|
<tr>
|
||||||
|
<td class="label">SUPPLIER:</td>
|
||||||
|
<td>{{.PrincipleName}}</td>
|
||||||
|
<td class="label">PO NUMBER:</td>
|
||||||
|
<td>{{.PONumber}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">YOUR REFERENCE:</td>
|
||||||
|
<td>{{.YourReference}}</td>
|
||||||
|
<td class="label">ISSUE DATE:</td>
|
||||||
|
<td>{{.IssueDateString}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">ORDERED FROM:</td>
|
||||||
|
<td colspan="3">{{.OrderedFrom}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">DELIVER TO:</td>
|
||||||
|
<td colspan="3">{{.DeliverTo}}</td>
|
||||||
|
</tr>
|
||||||
|
{{if .DispatchBy}}
|
||||||
|
<tr>
|
||||||
|
<td class="label">DISPATCH BY:</td>
|
||||||
|
<td colspan="3">{{.DispatchBy}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{if .ShippingInstructions}}
|
||||||
|
<div class="info-section">
|
||||||
|
<h3>SHIPPING INSTRUCTIONS</h3>
|
||||||
|
<p>{{.ShippingInstructions}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Currency Code Header -->
|
||||||
|
<div style="margin-bottom: 1mm; text-align: right; font-weight: bold; font-size: 9pt;">
|
||||||
|
Shown in {{.CurrencyCode}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line Items Table -->
|
||||||
|
<table class="line-items">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="description">DESCRIPTION</th>
|
||||||
|
<th class="qty">QTY</th>
|
||||||
|
<th class="unit-price">UNIT PRICE</th>
|
||||||
|
<th class="total">TOTAL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .LineItems}}
|
||||||
|
<tr>
|
||||||
|
<td class="description"><strong>{{.Title}}</strong>{{if .Description}}<br>{{.Description}}{{end}}</td>
|
||||||
|
<td class="qty">{{.Quantity}}</td>
|
||||||
|
<td class="unit-price">{{formatPrice .UnitPrice}}</td>
|
||||||
|
<td class="total">{{formatPrice .TotalPrice}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Totals -->
|
||||||
|
<div class="totals-container">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tr>
|
||||||
|
<td>SUBTOTAL</td>
|
||||||
|
<td>{{formatTotal .Subtotal}}</td>
|
||||||
|
</tr>
|
||||||
|
{{if .ShowGST}}
|
||||||
|
<tr>
|
||||||
|
<td>GST (10%)</td>
|
||||||
|
<td>{{formatTotal .GSTAmount}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>{{if .ShowGST}}TOTAL PAYABLE{{else}}TOTAL{{end}}</td>
|
||||||
|
<td>{{formatTotal .Total}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
<div class="services-title">CMC TECHNOLOGIES Provides Solutions in the Following Fields</div>
|
||||||
|
<div class="services-line1">
|
||||||
|
<span class="service-explosion">EXPLOSION PREVENTION AND PROTECTION</span> —
|
||||||
|
<span class="service-fire">FIRE PROTECTION</span> —
|
||||||
|
<span class="service-pressure">PRESSURE RELIEF</span> —
|
||||||
|
<span class="service-vision">VISION IN THE PROCESS</span>
|
||||||
|
</div>
|
||||||
|
<div class="services-line2">
|
||||||
|
<span class="service-flow">FLOW MEASUREMENT</span> —
|
||||||
|
<span class="service-process">PROCESS INSTRUMENTATION</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||||
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/pdf"
|
pdf "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/documents"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -385,7 +385,8 @@ func (h *DocumentHandler) GeneratePDF(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Generate PDF
|
// Generate PDF
|
||||||
log.Printf("Calling GenerateQuotePDF with outputDir: %s", outputDir)
|
log.Printf("Calling GenerateQuotePDF with outputDir: %s", outputDir)
|
||||||
filename, err = pdf.GenerateQuotePDF(data, outputDir)
|
htmlGen := pdf.NewHTMLDocumentGenerator(outputDir)
|
||||||
|
filename, err = htmlGen.GenerateQuotePDF(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error generating quote PDF: %v", err)
|
log.Printf("Error generating quote PDF: %v", err)
|
||||||
http.Error(w, "Failed to generate PDF", http.StatusInternalServerError)
|
http.Error(w, "Failed to generate PDF", http.StatusInternalServerError)
|
||||||
|
|
@ -435,7 +436,8 @@ func (h *DocumentHandler) GeneratePDF(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate PDF
|
// Generate PDF
|
||||||
filename, err = pdf.GeneratePurchaseOrderPDF(data, outputDir)
|
htmlGen := pdf.NewHTMLDocumentGenerator(outputDir)
|
||||||
|
filename, err = htmlGen.GeneratePurchaseOrderPDF(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error generating purchase order PDF: %v", err)
|
log.Printf("Error generating purchase order PDF: %v", err)
|
||||||
http.Error(w, "Failed to generate PDF", http.StatusInternalServerError)
|
http.Error(w, "Failed to generate PDF", http.StatusInternalServerError)
|
||||||
|
|
|
||||||
647
go/internal/cmc/handlers/documents/documents_api.go
Normal file
647
go/internal/cmc/handlers/documents/documents_api.go
Normal file
|
|
@ -0,0 +1,647 @@
|
||||||
|
package documents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
|
||||||
|
pdf "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/documents"
|
||||||
|
)
|
||||||
|
|
||||||
|
// escapeToHTML converts plain text to HTML with newlines as <br>
|
||||||
|
func escapeToHTML(s string) string {
|
||||||
|
s = html.EscapeString(s)
|
||||||
|
s = strings.ReplaceAll(s, "\n", "<br>")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceLineItemRequest is the JSON shape for a single line item.
|
||||||
|
type InvoiceLineItemRequest struct {
|
||||||
|
ItemNumber string `json:"item_number"`
|
||||||
|
Quantity string `json:"quantity"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsHTML bool `json:"is_html"` // Flag to indicate description contains HTML
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
TotalPrice float64 `json:"total_price"`
|
||||||
|
NetUnitPrice float64 `json:"net_unit_price"`
|
||||||
|
NetPrice float64 `json:"net_price"`
|
||||||
|
DiscountPercent float64 `json:"discount_percent"`
|
||||||
|
DiscountAmountUnit float64 `json:"discount_amount_unit"`
|
||||||
|
DiscountAmountTotal float64 `json:"discount_amount_total"`
|
||||||
|
Option int `json:"option"`
|
||||||
|
HasTextPrices bool `json:"has_text_prices"`
|
||||||
|
UnitPriceString string `json:"unit_price_string"`
|
||||||
|
GrossPriceString string `json:"gross_price_string"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoicePDFRequest is the expected payload from the PHP app.
|
||||||
|
type InvoicePDFRequest struct {
|
||||||
|
DocumentID int32 `json:"document_id"`
|
||||||
|
InvoiceNumber string `json:"invoice_number"` // e.g. "INV-001234"
|
||||||
|
InvoiceTitle string `json:"invoice_title"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
ContactEmail string `json:"contact_email"`
|
||||||
|
ContactName string `json:"contact_name"`
|
||||||
|
UserFirstName string `json:"user_first_name"`
|
||||||
|
UserLastName string `json:"user_last_name"`
|
||||||
|
UserEmail string `json:"user_email"`
|
||||||
|
YourReference string `json:"your_reference"`
|
||||||
|
ShipVia string `json:"ship_via"`
|
||||||
|
FOB string `json:"fob"`
|
||||||
|
IssueDate string `json:"issue_date"` // ISO date: 2006-01-02
|
||||||
|
IssueDateString string `json:"issue_date_string"` // Formatted: "12 January 2026"
|
||||||
|
CurrencySymbol string `json:"currency_symbol"` // e.g. "$"
|
||||||
|
CurrencyCode string `json:"currency_code"` // e.g. "AUD", "USD"
|
||||||
|
ShowGST bool `json:"show_gst"`
|
||||||
|
BillTo string `json:"bill_to"`
|
||||||
|
ShipTo string `json:"ship_to"`
|
||||||
|
ShippingDetails string `json:"shipping_details"`
|
||||||
|
CustomerOrderNumber string `json:"customer_order_number"`
|
||||||
|
JobTitle string `json:"job_title"`
|
||||||
|
PaymentTerms string `json:"payment_terms"`
|
||||||
|
CustomerABN string `json:"customer_abn"`
|
||||||
|
Subtotal interface{} `json:"subtotal"` // Can be float or "TBA"
|
||||||
|
GSTAmount interface{} `json:"gst_amount"`
|
||||||
|
Total interface{} `json:"total"`
|
||||||
|
LineItems []InvoiceLineItemRequest `json:"line_items"`
|
||||||
|
OutputDir string `json:"output_dir"` // optional override
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateInvoicePDF handles POST /api/pdf/invoice and writes a PDF to disk.
|
||||||
|
// It returns JSON: {"filename":"<name>.pdf"}
|
||||||
|
// GenerateInvoicePDF generates invoice using HTML template and chromedp
|
||||||
|
func GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req InvoicePDFRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.InvoiceTitle == "" || req.CustomerName == "" {
|
||||||
|
http.Error(w, "invoice_title and customer_name are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
issueDate := time.Now()
|
||||||
|
if req.IssueDate != "" {
|
||||||
|
if parsed, err := time.Parse("2006-01-02", req.IssueDate); err == nil {
|
||||||
|
issueDate = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := req.OutputDir
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = os.Getenv("PDF_OUTPUT_DIR")
|
||||||
|
}
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = "../php/app/webroot/pdf"
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
log.Printf("GenerateInvoicePDF: failed to create output dir: %v", err)
|
||||||
|
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map request into the existing PDF generation types
|
||||||
|
doc := &db.Document{ID: req.DocumentID, CmcReference: req.InvoiceNumber}
|
||||||
|
inv := &db.Invoice{Title: req.InvoiceTitle}
|
||||||
|
cust := &db.Customer{Name: req.CustomerName}
|
||||||
|
|
||||||
|
log.Printf("GenerateInvoicePDF: Setting invoice number to: %s", req.InvoiceNumber)
|
||||||
|
|
||||||
|
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
|
||||||
|
for i, li := range req.LineItems {
|
||||||
|
// Escape description if it's not HTML
|
||||||
|
desc := li.Description
|
||||||
|
if !li.IsHTML {
|
||||||
|
// Escape plain text to HTML entities
|
||||||
|
desc = escapeToHTML(desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the final price after discount
|
||||||
|
finalPrice := li.TotalPrice - li.DiscountAmountTotal
|
||||||
|
|
||||||
|
lineItems[i] = db.GetLineItemsTableRow{
|
||||||
|
ItemNumber: li.ItemNumber,
|
||||||
|
Quantity: li.Quantity,
|
||||||
|
Title: li.Title,
|
||||||
|
Description: desc,
|
||||||
|
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
|
||||||
|
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", finalPrice), Valid: true},
|
||||||
|
DiscountAmountTotal: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountTotal), Valid: li.DiscountAmountTotal > 0},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &pdf.InvoicePDFData{
|
||||||
|
Document: doc,
|
||||||
|
Invoice: inv,
|
||||||
|
Customer: cust,
|
||||||
|
LineItems: lineItems,
|
||||||
|
CurrencySymbol: req.CurrencySymbol,
|
||||||
|
CurrencyCode: req.CurrencyCode,
|
||||||
|
ShowGST: req.ShowGST,
|
||||||
|
ShipVia: req.ShipVia,
|
||||||
|
FOB: req.FOB,
|
||||||
|
IssueDate: issueDate,
|
||||||
|
IssueDateString: req.IssueDateString,
|
||||||
|
EmailTo: req.ContactEmail,
|
||||||
|
Attention: req.ContactName,
|
||||||
|
FromName: fmt.Sprintf("%s %s", req.UserFirstName, req.UserLastName),
|
||||||
|
FromEmail: req.UserEmail,
|
||||||
|
YourReference: req.YourReference,
|
||||||
|
BillTo: req.BillTo,
|
||||||
|
ShipTo: req.ShipTo,
|
||||||
|
ShippingDetails: req.ShippingDetails,
|
||||||
|
CustomerOrderNumber: req.CustomerOrderNumber,
|
||||||
|
JobTitle: req.JobTitle,
|
||||||
|
PaymentTerms: req.PaymentTerms,
|
||||||
|
CustomerABN: req.CustomerABN,
|
||||||
|
Subtotal: req.Subtotal,
|
||||||
|
GSTAmount: req.GSTAmount,
|
||||||
|
Total: req.Total,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use HTML generator instead of gofpdf
|
||||||
|
htmlGen := pdf.NewHTMLDocumentGenerator(outputDir)
|
||||||
|
filename, err := htmlGen.GenerateInvoicePDF(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GenerateInvoicePDF: failed to generate PDF: %v", err)
|
||||||
|
http.Error(w, fmt.Sprintf("failed to generate PDF: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"filename": filename,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuoteLineItemRequest reuses the invoice item shape
|
||||||
|
type QuoteLineItemRequest = InvoiceLineItemRequest
|
||||||
|
|
||||||
|
// QuotePDFRequest payload from PHP for quotes
|
||||||
|
type QuotePDFRequest struct {
|
||||||
|
DocumentID int32 `json:"document_id"`
|
||||||
|
CmcReference string `json:"cmc_reference"`
|
||||||
|
Revision int32 `json:"revision"`
|
||||||
|
CreatedDate string `json:"created_date"` // YYYY-MM-DD
|
||||||
|
CreatedDateString string `json:"created_date_string"` // j M Y format
|
||||||
|
DateIssued string `json:"date_issued"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
ContactEmail string `json:"contact_email"`
|
||||||
|
ContactName string `json:"contact_name"`
|
||||||
|
UserFirstName string `json:"user_first_name"`
|
||||||
|
UserLastName string `json:"user_last_name"`
|
||||||
|
UserEmail string `json:"user_email"`
|
||||||
|
CurrencySymbol string `json:"currency_symbol"`
|
||||||
|
CurrencyCode string `json:"currency_code"`
|
||||||
|
ShowGST bool `json:"show_gst"`
|
||||||
|
CommercialComments string `json:"commercial_comments"`
|
||||||
|
DeliveryTime string `json:"delivery_time"`
|
||||||
|
PaymentTerms string `json:"payment_terms"`
|
||||||
|
DaysValid int32 `json:"daysValid"`
|
||||||
|
DeliveryPoint string `json:"delivery_point"`
|
||||||
|
ExchangeRate string `json:"exchange_rate"`
|
||||||
|
CustomsDuty string `json:"customs_duty"`
|
||||||
|
GSTPhrase string `json:"gst_phrase"`
|
||||||
|
SalesEngineer string `json:"sales_engineer"`
|
||||||
|
BillTo string `json:"bill_to"`
|
||||||
|
ShipTo string `json:"ship_to"`
|
||||||
|
LineItems []QuoteLineItemRequest `json:"line_items"`
|
||||||
|
Pages []string `json:"pages"`
|
||||||
|
OutputDir string `json:"output_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateQuotePDF handles POST /go/pdf/generate-quote
|
||||||
|
func GenerateQuotePDF(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req QuotePDFRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Printf("GenerateQuotePDF: JSON decode error: %v", err)
|
||||||
|
http.Error(w, fmt.Sprintf("invalid JSON payload: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("GenerateQuotePDF: Received request - DocumentID=%d, CmcReference='%s', Revision=%d, CustomerName='%s'",
|
||||||
|
req.DocumentID, req.CmcReference, req.Revision, req.CustomerName)
|
||||||
|
if req.CmcReference == "" || req.CustomerName == "" {
|
||||||
|
log.Printf("GenerateQuotePDF: missing required fields - cmc_reference='%s', customer_name='%s'", req.CmcReference, req.CustomerName)
|
||||||
|
http.Error(w, "cmc_reference and customer_name are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
created := time.Now()
|
||||||
|
if req.CreatedDate != "" {
|
||||||
|
if parsed, err := time.Parse("2006-01-02", req.CreatedDate); err == nil {
|
||||||
|
created = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := req.OutputDir
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = os.Getenv("PDF_OUTPUT_DIR")
|
||||||
|
}
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = "../php/app/webroot/pdf"
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
log.Printf("GenerateQuotePDF: failed to create output dir: %v", err)
|
||||||
|
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map request into PDF data
|
||||||
|
doc := &db.Document{ID: req.DocumentID, CmcReference: req.CmcReference, Revision: req.Revision, Created: created}
|
||||||
|
cust := &db.Customer{Name: req.CustomerName}
|
||||||
|
user := &db.GetUserRow{FirstName: req.UserFirstName, LastName: req.UserLastName, Email: req.UserEmail}
|
||||||
|
|
||||||
|
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
|
||||||
|
for i, li := range req.LineItems {
|
||||||
|
lineItems[i] = db.GetLineItemsTableRow{
|
||||||
|
ItemNumber: li.ItemNumber,
|
||||||
|
Quantity: li.Quantity,
|
||||||
|
Title: li.Title,
|
||||||
|
Description: li.Description,
|
||||||
|
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
|
||||||
|
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &pdf.QuotePDFData{
|
||||||
|
Document: doc,
|
||||||
|
Customer: cust,
|
||||||
|
EmailTo: req.ContactEmail,
|
||||||
|
Attention: req.ContactName,
|
||||||
|
User: user,
|
||||||
|
LineItems: lineItems,
|
||||||
|
CurrencySymbol: req.CurrencySymbol,
|
||||||
|
CurrencyCode: req.CurrencyCode,
|
||||||
|
ShowGST: req.ShowGST,
|
||||||
|
CommercialComments: req.CommercialComments,
|
||||||
|
DeliveryTime: req.DeliveryTime,
|
||||||
|
PaymentTerms: req.PaymentTerms,
|
||||||
|
DaysValid: int(req.DaysValid),
|
||||||
|
DeliveryPoint: req.DeliveryPoint,
|
||||||
|
ExchangeRate: req.ExchangeRate,
|
||||||
|
CustomsDuty: req.CustomsDuty,
|
||||||
|
GSTPhrase: req.GSTPhrase,
|
||||||
|
SalesEngineer: req.SalesEngineer,
|
||||||
|
BillTo: req.BillTo,
|
||||||
|
ShipTo: req.ShipTo,
|
||||||
|
IssueDateString: req.CreatedDateString,
|
||||||
|
Pages: req.Pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use HTML generator
|
||||||
|
htmlGen := pdf.NewHTMLDocumentGenerator(outputDir)
|
||||||
|
filename, err := htmlGen.GenerateQuotePDF(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GenerateQuotePDF: failed to generate PDF: %v", err)
|
||||||
|
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"filename": filename,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchaseOrderLineItemRequest reuses the invoice item shape
|
||||||
|
type PurchaseOrderLineItemRequest = InvoiceLineItemRequest
|
||||||
|
|
||||||
|
// PurchaseOrderPDFRequest payload from PHP for POs
|
||||||
|
type PurchaseOrderPDFRequest struct {
|
||||||
|
DocumentID int32 `json:"document_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
IssueDate string `json:"issue_date"` // YYYY-MM-DD
|
||||||
|
IssueDateString string `json:"issue_date_string"` // formatted date
|
||||||
|
PrincipleName string `json:"principle_name"`
|
||||||
|
PrincipleReference string `json:"principle_reference"`
|
||||||
|
OrderedFrom string `json:"ordered_from"`
|
||||||
|
DispatchBy string `json:"dispatch_by"`
|
||||||
|
DeliverTo string `json:"deliver_to"`
|
||||||
|
ShippingInstructions string `json:"shipping_instructions"`
|
||||||
|
CurrencySymbol string `json:"currency_symbol"`
|
||||||
|
CurrencyCode string `json:"currency_code"`
|
||||||
|
ShowGST bool `json:"show_gst"`
|
||||||
|
Subtotal float64 `json:"subtotal"`
|
||||||
|
GSTAmount float64 `json:"gst_amount"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
LineItems []PurchaseOrderLineItemRequest `json:"line_items"`
|
||||||
|
OutputDir string `json:"output_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePurchaseOrderPDF handles POST /go/pdf/generate-po
|
||||||
|
func GeneratePurchaseOrderPDF(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req PurchaseOrderPDFRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Title == "" || req.PrincipleName == "" {
|
||||||
|
http.Error(w, "title and principle_name are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
issueDate := time.Now()
|
||||||
|
if req.IssueDate != "" {
|
||||||
|
if parsed, err := time.Parse("2006-01-02", req.IssueDate); err == nil {
|
||||||
|
issueDate = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := req.OutputDir
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = os.Getenv("PDF_OUTPUT_DIR")
|
||||||
|
}
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = "../php/app/webroot/pdf"
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
log.Printf("GeneratePurchaseOrderPDF: failed to create output dir: %v", err)
|
||||||
|
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := &db.Document{ID: req.DocumentID}
|
||||||
|
po := &db.PurchaseOrder{
|
||||||
|
Title: req.Title,
|
||||||
|
PrincipleReference: req.PrincipleReference,
|
||||||
|
IssueDate: issueDate,
|
||||||
|
OrderedFrom: req.OrderedFrom,
|
||||||
|
DispatchBy: req.DispatchBy,
|
||||||
|
DeliverTo: req.DeliverTo,
|
||||||
|
ShippingInstructions: req.ShippingInstructions,
|
||||||
|
}
|
||||||
|
principle := &db.Principle{Name: req.PrincipleName}
|
||||||
|
|
||||||
|
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
|
||||||
|
for i, li := range req.LineItems {
|
||||||
|
lineItems[i] = db.GetLineItemsTableRow{
|
||||||
|
ItemNumber: li.ItemNumber,
|
||||||
|
Quantity: li.Quantity,
|
||||||
|
Title: li.Title,
|
||||||
|
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
|
||||||
|
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &pdf.PurchaseOrderPDFData{
|
||||||
|
Document: doc,
|
||||||
|
PurchaseOrder: po,
|
||||||
|
Principle: principle,
|
||||||
|
LineItems: lineItems,
|
||||||
|
CurrencySymbol: req.CurrencySymbol,
|
||||||
|
CurrencyCode: req.CurrencyCode,
|
||||||
|
ShowGST: req.ShowGST,
|
||||||
|
Subtotal: req.Subtotal,
|
||||||
|
GSTAmount: req.GSTAmount,
|
||||||
|
Total: req.Total,
|
||||||
|
IssueDateString: req.IssueDateString,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use HTML generator
|
||||||
|
gen := pdf.NewHTMLDocumentGenerator(outputDir)
|
||||||
|
filename, err := gen.GeneratePurchaseOrderPDF(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GeneratePurchaseOrderPDF: failed to generate PDF: %v", err)
|
||||||
|
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePackingListPDF handles POST /go/pdf/generate-packinglist
|
||||||
|
func GeneratePackingListPDF(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req PackingListPDFRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.CustomerName == "" {
|
||||||
|
http.Error(w, "customer_name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := req.OutputDir
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = os.Getenv("PDF_OUTPUT_DIR")
|
||||||
|
}
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = "../php/app/webroot/pdf"
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
log.Printf("GeneratePackingListPDF: failed to create output dir: %v", err)
|
||||||
|
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse the invoice generator structure but label as PACKING LIST via DetailsBox
|
||||||
|
// Build minimal data shape
|
||||||
|
doc := &db.Document{ID: req.DocumentID}
|
||||||
|
cust := &db.Customer{Name: req.CustomerName}
|
||||||
|
|
||||||
|
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
|
||||||
|
for i, li := range req.LineItems {
|
||||||
|
lineItems[i] = db.GetLineItemsTableRow{
|
||||||
|
ItemNumber: li.ItemNumber,
|
||||||
|
Quantity: li.Quantity,
|
||||||
|
Title: li.Title,
|
||||||
|
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
|
||||||
|
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &pdf.PackingListPDFData{
|
||||||
|
PackingList: doc,
|
||||||
|
Customer: cust,
|
||||||
|
JobTitle: req.JobTitle,
|
||||||
|
IssueDateString: req.IssueDateString,
|
||||||
|
CustomerOrderNumber: req.CustomerOrderNumber,
|
||||||
|
ShipTo: req.ShipTo,
|
||||||
|
LineItems: lineItems,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use HTML generator
|
||||||
|
gen := pdf.NewHTMLDocumentGenerator(outputDir)
|
||||||
|
filename, err := gen.GeneratePackingListPDF(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GeneratePackingListPDF: failed to generate PDF: %v", err)
|
||||||
|
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateOrderAckPDF handles POST /go/pdf/generate-orderack
|
||||||
|
func GenerateOrderAckPDF(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req OrderAckPDFRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.CustomerName == "" {
|
||||||
|
http.Error(w, "customer_name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := req.OutputDir
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = os.Getenv("PDF_OUTPUT_DIR")
|
||||||
|
}
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = "../php/app/webroot/pdf"
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
log.Printf("GenerateOrderAckPDF: failed to create output dir: %v", err)
|
||||||
|
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := &db.Document{ID: req.DocumentID}
|
||||||
|
cust := &db.Customer{Name: req.CustomerName}
|
||||||
|
|
||||||
|
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
|
||||||
|
for i, li := range req.LineItems {
|
||||||
|
lineItems[i] = db.GetLineItemsTableRow{
|
||||||
|
ItemNumber: li.ItemNumber,
|
||||||
|
Quantity: li.Quantity,
|
||||||
|
Title: li.Title,
|
||||||
|
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
|
||||||
|
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &pdf.OrderAckPDFData{
|
||||||
|
OrderAcknowledgement: doc,
|
||||||
|
Customer: cust,
|
||||||
|
EmailTo: req.EmailTo,
|
||||||
|
Attention: req.Attention,
|
||||||
|
IssueDateString: req.IssueDateString,
|
||||||
|
YourReference: req.YourReference,
|
||||||
|
JobTitle: req.JobTitle,
|
||||||
|
BillTo: req.BillTo,
|
||||||
|
ShipTo: req.ShipTo,
|
||||||
|
ShipVia: req.ShipVia,
|
||||||
|
FOB: req.FOB,
|
||||||
|
PaymentTerms: req.PaymentTerms,
|
||||||
|
CurrencyCode: req.CurrencyCode,
|
||||||
|
CurrencySymbol: req.CurrencySymbol,
|
||||||
|
ShowGST: req.ShowGST,
|
||||||
|
LineItems: lineItems,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use HTML generator
|
||||||
|
gen := pdf.NewHTMLDocumentGenerator(outputDir)
|
||||||
|
filename, err := gen.GenerateOrderAckPDF(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GenerateOrderAckPDF: failed to generate PDF: %v", err)
|
||||||
|
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackingListLineItemRequest reuses the invoice item shape
|
||||||
|
type PackingListLineItemRequest = InvoiceLineItemRequest
|
||||||
|
|
||||||
|
// PackingListPDFRequest payload
|
||||||
|
type PackingListPDFRequest struct {
|
||||||
|
DocumentID int32 `json:"document_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
CustomerOrderNumber string `json:"customer_order_number"`
|
||||||
|
JobTitle string `json:"job_title"`
|
||||||
|
IssueDate string `json:"issue_date"` // YYYY-MM-DD
|
||||||
|
IssueDateString string `json:"issue_date_string"` // formatted date
|
||||||
|
ShipTo string `json:"ship_to"`
|
||||||
|
ShipVia string `json:"ship_via"`
|
||||||
|
FOB string `json:"fob"`
|
||||||
|
CurrencySymbol string `json:"currency_symbol"`
|
||||||
|
CurrencyCode string `json:"currency_code"`
|
||||||
|
ShowGST bool `json:"show_gst"`
|
||||||
|
LineItems []PackingListLineItemRequest `json:"line_items"`
|
||||||
|
OutputDir string `json:"output_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderAckLineItemRequest reuses the invoice item shape
|
||||||
|
type OrderAckLineItemRequest = InvoiceLineItemRequest
|
||||||
|
|
||||||
|
// OrderAckPDFRequest payload
|
||||||
|
type OrderAckPDFRequest struct {
|
||||||
|
DocumentID int32 `json:"document_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
EmailTo string `json:"email_to"`
|
||||||
|
Attention string `json:"attention"`
|
||||||
|
YourReference string `json:"your_reference"`
|
||||||
|
JobTitle string `json:"job_title"`
|
||||||
|
IssueDate string `json:"issue_date"` // YYYY-MM-DD
|
||||||
|
IssueDateString string `json:"issue_date_string"` // formatted date
|
||||||
|
BillTo string `json:"bill_to"`
|
||||||
|
ShipTo string `json:"ship_to"`
|
||||||
|
ShipVia string `json:"ship_via"`
|
||||||
|
FOB string `json:"fob"`
|
||||||
|
PaymentTerms string `json:"payment_terms"`
|
||||||
|
EstimatedDelivery string `json:"estimated_delivery"`
|
||||||
|
CurrencySymbol string `json:"currency_symbol"`
|
||||||
|
CurrencyCode string `json:"currency_code"`
|
||||||
|
ShowGST bool `json:"show_gst"`
|
||||||
|
LineItems []OrderAckLineItemRequest `json:"line_items"`
|
||||||
|
OutputDir string `json:"output_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountPagesRequest payload for page counting
|
||||||
|
type CountPagesRequest struct {
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountPages handles POST /go/pdf/count-pages
|
||||||
|
// Returns JSON: {"page_count": <number>} or {"error": "<message>"}
|
||||||
|
func CountPages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req CountPagesRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Printf("CountPages: JSON decode error: %v", err)
|
||||||
|
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.FilePath == "" {
|
||||||
|
log.Printf("CountPages: file_path is required")
|
||||||
|
http.Error(w, "file_path is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize path: remove double slashes
|
||||||
|
normalizedPath := strings.ReplaceAll(req.FilePath, "//", "/")
|
||||||
|
|
||||||
|
log.Printf("CountPages: Attempting to count pages for file: %s", normalizedPath)
|
||||||
|
|
||||||
|
// Count pages in the PDF file
|
||||||
|
pageCount, err := pdf.CountPDFPages(normalizedPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("CountPages: error counting pages in %s: %v", normalizedPath, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"page_count": 0,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("CountPages: Successfully counted %d pages in %s", pageCount, normalizedPath)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]int{"page_count": pageCount})
|
||||||
|
}
|
||||||
83
go/internal/cmc/handlers/documents/documents_api_test.go
Normal file
83
go/internal/cmc/handlers/documents/documents_api_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
//go:build never
|
||||||
|
// +build never
|
||||||
|
|
||||||
|
package documents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jung-kurt/gofpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createPDF(path, title string) error {
|
||||||
|
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||||
|
pdf.AddPage()
|
||||||
|
pdf.SetFont("Helvetica", "", 12)
|
||||||
|
pdf.CellFormat(0, 10, title, "", 1, "L", false, 0, "")
|
||||||
|
return pdf.OutputFileAndClose(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateInvoicePDF_Handler_CreatesFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Also create a terms file so merge path is exercised
|
||||||
|
if err := createPDF(filepath.Join(dir, "CMC_terms_and_conditions2006_A4.pdf"), "terms"); err != nil {
|
||||||
|
t.Fatalf("failed to create terms: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody := InvoicePDFRequest{
|
||||||
|
DocumentID: 1,
|
||||||
|
InvoiceTitle: "INV-1001",
|
||||||
|
CustomerName: "Acme Corp",
|
||||||
|
ShipVia: "Courier",
|
||||||
|
FOB: "Sydney",
|
||||||
|
IssueDate: "2025-01-01",
|
||||||
|
CurrencySymbol: "$",
|
||||||
|
ShowGST: true,
|
||||||
|
LineItems: []InvoiceLineItemRequest{{
|
||||||
|
ItemNumber: "1",
|
||||||
|
Quantity: "2",
|
||||||
|
Title: "Widget",
|
||||||
|
UnitPrice: 10.00,
|
||||||
|
TotalPrice: 20.00,
|
||||||
|
}},
|
||||||
|
OutputDir: dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an extra file to append
|
||||||
|
extraPath := filepath.Join(dir, "extra.pdf")
|
||||||
|
if err := createPDF(extraPath, "extra"); err != nil {
|
||||||
|
t.Fatalf("failed to create extra: %v", err)
|
||||||
|
}
|
||||||
|
reqBody.AppendFiles = []string{extraPath}
|
||||||
|
|
||||||
|
b, _ := json.Marshal(reqBody)
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/go/pdf/generate-invoice", bytes.NewReader(b))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
GenerateInvoicePDF(w, r)
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("unexpected status: %d body=%s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file created
|
||||||
|
final := filepath.Join(dir, "INV-1001.pdf")
|
||||||
|
st, err := os.Stat(final)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected output file not found: %v", err)
|
||||||
|
}
|
||||||
|
if st.Size() <= 0 {
|
||||||
|
t.Fatalf("output pdf has zero size")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -407,7 +407,9 @@ func GeneratePurchaseOrderPDF(w http.ResponseWriter, r *http.Request) {
|
||||||
IssueDateString: req.IssueDateString,
|
IssueDateString: req.IssueDateString,
|
||||||
}
|
}
|
||||||
|
|
||||||
filename, err := pdf.GeneratePurchaseOrderPDF(data, outputDir)
|
// Use HTML generator
|
||||||
|
gen := pdf.NewHTMLDocumentGenerator(outputDir)
|
||||||
|
filename, err := gen.GeneratePurchaseOrderPDF(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("GeneratePurchaseOrderPDF: failed to generate PDF: %v", err)
|
log.Printf("GeneratePurchaseOrderPDF: failed to generate PDF: %v", err)
|
||||||
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
|
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
|
||||||
|
|
@ -474,7 +476,9 @@ func GeneratePackingListPDF(w http.ResponseWriter, r *http.Request) {
|
||||||
ShowGST: req.ShowGST,
|
ShowGST: req.ShowGST,
|
||||||
}
|
}
|
||||||
|
|
||||||
filename, err := pdf.GeneratePackingListPDF(data, outputDir)
|
// Use HTML generator
|
||||||
|
gen := pdf.NewHTMLDocumentGenerator(outputDir)
|
||||||
|
filename, err := gen.GeneratePackingListPDF(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("GeneratePackingListPDF: failed to generate PDF: %v", err)
|
log.Printf("GeneratePackingListPDF: failed to generate PDF: %v", err)
|
||||||
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
|
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
|
||||||
|
|
@ -539,7 +543,9 @@ func GenerateOrderAckPDF(w http.ResponseWriter, r *http.Request) {
|
||||||
ShowGST: req.ShowGST,
|
ShowGST: req.ShowGST,
|
||||||
}
|
}
|
||||||
|
|
||||||
filename, err := pdf.GenerateOrderAckPDF(data, outputDir)
|
// Use HTML generator
|
||||||
|
gen := pdf.NewHTMLDocumentGenerator(outputDir)
|
||||||
|
filename, err := gen.GenerateOrderAckPDF(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("GenerateOrderAckPDF: failed to generate PDF: %v", err)
|
log.Printf("GenerateOrderAckPDF: failed to generate PDF: %v", err)
|
||||||
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
|
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
//go:build never
|
|
||||||
// +build never
|
|
||||||
package handlers
|
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jung-kurt/gofpdf"
|
|
||||||
)
|
|
||||||
|
|
||||||
func createPDF(path, title string) error {
|
|
||||||
pdf := gofpdf.New("P", "mm", "A4", "")
|
|
||||||
pdf.AddPage()
|
|
||||||
pdf.SetFont("Helvetica", "", 12)
|
|
||||||
pdf.CellFormat(0, 10, title, "", 1, "L", false, 0, "")
|
|
||||||
return pdf.OutputFileAndClose(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateInvoicePDF_Handler_CreatesFile(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
// Also create a terms file so merge path is exercised
|
|
||||||
if err := createPDF(filepath.Join(dir, "CMC_terms_and_conditions2006_A4.pdf"), "terms"); err != nil {
|
|
||||||
t.Fatalf("failed to create terms: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reqBody := InvoicePDFRequest{
|
|
||||||
DocumentID: 1,
|
|
||||||
InvoiceTitle: "INV-1001",
|
|
||||||
CustomerName: "Acme Corp",
|
|
||||||
ShipVia: "Courier",
|
|
||||||
FOB: "Sydney",
|
|
||||||
IssueDate: "2025-01-01",
|
|
||||||
CurrencySymbol: "$",
|
|
||||||
ShowGST: true,
|
|
||||||
LineItems: []InvoiceLineItemRequest{{
|
|
||||||
ItemNumber: "1",
|
|
||||||
Quantity: "2",
|
|
||||||
Title: "Widget",
|
|
||||||
UnitPrice: 10.00,
|
|
||||||
TotalPrice: 20.00,
|
|
||||||
}},
|
|
||||||
OutputDir: dir,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add an extra file to append
|
|
||||||
extraPath := filepath.Join(dir, "extra.pdf")
|
|
||||||
if err := createPDF(extraPath, "extra"); err != nil {
|
|
||||||
t.Fatalf("failed to create extra: %v", err)
|
|
||||||
}
|
|
||||||
reqBody.AppendFiles = []string{extraPath}
|
|
||||||
|
|
||||||
b, _ := json.Marshal(reqBody)
|
|
||||||
r := httptest.NewRequest(http.MethodPost, "/go/pdf/generate-invoice", bytes.NewReader(b))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
GenerateInvoicePDF(w, r)
|
|
||||||
|
|
||||||
resp := w.Result()
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
data, _ := io.ReadAll(resp.Body)
|
|
||||||
t.Fatalf("unexpected status: %d body=%s", resp.StatusCode, string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify file created
|
|
||||||
final := filepath.Join(dir, "INV-1001.pdf")
|
|
||||||
st, err := os.Stat(final)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected output file not found: %v", err)
|
|
||||||
}
|
|
||||||
if st.Size() <= 0 {
|
|
||||||
t.Fatalf("output pdf has zero size")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -375,3 +375,87 @@ func (g *HTMLDocumentGenerator) loadLogo(logoFileName string) string {
|
||||||
// Return relative path (same directory as HTML file)
|
// Return relative path (same directory as HTML file)
|
||||||
return logoFileName
|
return logoFileName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GeneratePurchaseOrderPDF creates a PDF purchase order from HTML template
|
||||||
|
// Returns (filename, error)
|
||||||
|
func (g *HTMLDocumentGenerator) GeneratePurchaseOrderPDF(data *PurchaseOrderPDFData) (string, error) {
|
||||||
|
fmt.Println("=== HTML Generator: Starting purchase order generation ===")
|
||||||
|
|
||||||
|
html := g.BuildPurchaseOrderHTML(data, 0, 0)
|
||||||
|
tempHTML := filepath.Join(g.outputDir, "temp_po.html")
|
||||||
|
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write temp HTML: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tempHTML)
|
||||||
|
defer os.Remove(filepath.Join(g.outputDir, "quote_logo.png"))
|
||||||
|
|
||||||
|
poNumber := data.PurchaseOrder.Title
|
||||||
|
if poNumber == "" {
|
||||||
|
poNumber = fmt.Sprintf("PO-%d", data.PurchaseOrder.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s.pdf", poNumber)
|
||||||
|
pdfPath := filepath.Join(g.outputDir, filename)
|
||||||
|
|
||||||
|
if err := g.htmlToPDF(tempHTML, pdfPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to convert HTML to PDF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePackingListPDF creates a PDF packing list from HTML template
|
||||||
|
// Returns (filename, error)
|
||||||
|
func (g *HTMLDocumentGenerator) GeneratePackingListPDF(data *PackingListPDFData) (string, error) {
|
||||||
|
fmt.Println("=== HTML Generator: Starting packing list generation ===")
|
||||||
|
|
||||||
|
html := g.BuildPackingListHTML(data, 0, 0)
|
||||||
|
tempHTML := filepath.Join(g.outputDir, "temp_packinglist.html")
|
||||||
|
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write temp HTML: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tempHTML)
|
||||||
|
defer os.Remove(filepath.Join(g.outputDir, "quote_logo.png"))
|
||||||
|
|
||||||
|
packingListNumber := data.PackingList.Title
|
||||||
|
if packingListNumber == "" {
|
||||||
|
packingListNumber = fmt.Sprintf("PackingList-%d", data.PackingList.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s.pdf", packingListNumber)
|
||||||
|
pdfPath := filepath.Join(g.outputDir, filename)
|
||||||
|
|
||||||
|
if err := g.htmlToPDF(tempHTML, pdfPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to convert HTML to PDF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateOrderAckPDF creates a PDF order acknowledgement from HTML template
|
||||||
|
// Returns (filename, error)
|
||||||
|
func (g *HTMLDocumentGenerator) GenerateOrderAckPDF(data *OrderAckPDFData) (string, error) {
|
||||||
|
fmt.Println("=== HTML Generator: Starting order acknowledgement generation ===")
|
||||||
|
|
||||||
|
html := g.BuildOrderAckHTML(data, 0, 0)
|
||||||
|
tempHTML := filepath.Join(g.outputDir, "temp_orderack.html")
|
||||||
|
if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write temp HTML: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tempHTML)
|
||||||
|
defer os.Remove(filepath.Join(g.outputDir, "quote_logo.png"))
|
||||||
|
|
||||||
|
orderAckNumber := data.OrderAcknowledgement.Title
|
||||||
|
if orderAckNumber == "" {
|
||||||
|
orderAckNumber = fmt.Sprintf("OrderAck-%d", data.OrderAcknowledgement.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s.pdf", orderAckNumber)
|
||||||
|
pdfPath := filepath.Join(g.outputDir, filename)
|
||||||
|
|
||||||
|
if err := g.htmlToPDF(tempHTML, pdfPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to convert HTML to PDF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
2
php/app/vendors/pagecounter.php
vendored
2
php/app/vendors/pagecounter.php
vendored
|
|
@ -22,7 +22,7 @@ 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/api/pdf/count-pages';
|
$goEndpoint = $goBaseUrl . '/go/document/page-count';
|
||||||
|
|
||||||
error_log("PageCounter: Calling Go endpoint: $goEndpoint with file: $file");
|
error_log("PageCounter: Calling Go endpoint: $goEndpoint with file: $file");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ if (!isset($document) || !isset($document['Document'])) {
|
||||||
}
|
}
|
||||||
|
|
||||||
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
||||||
$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-invoice';
|
$goEndpoint = $goBaseUrl . '/go/document/generate/invoice';
|
||||||
error_log("=== pdf_invoice.ctp: Go endpoint: " . $goEndpoint . " ===");
|
error_log("=== pdf_invoice.ctp: Go endpoint: " . $goEndpoint . " ===");
|
||||||
|
|
||||||
$outputDir = Configure::read('pdf_directory');
|
$outputDir = Configure::read('pdf_directory');
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// Generate the Order Acknowledgement PDF by calling the Go service instead of TCPDF/FPDI.
|
// Generate the Order Acknowledgement PDF by calling the Go service instead of TCPDF/FPDI.
|
||||||
|
|
||||||
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
||||||
$goEndpoint = $goBaseUrl . '/go/pdf/generate-orderack';
|
$goEndpoint = $goBaseUrl . '/go/document/generate/order-acknowledgement';
|
||||||
|
|
||||||
$outputDir = Configure::read('pdf_directory');
|
$outputDir = Configure::read('pdf_directory');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// Generate the Packing List PDF by calling the Go service instead of TCPDF/FPDI.
|
// Generate the Packing List PDF by calling the Go service instead of TCPDF/FPDI.
|
||||||
|
|
||||||
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
||||||
$goEndpoint = $goBaseUrl . '/go/pdf/generate-packinglist';
|
$goEndpoint = $goBaseUrl . '/go/document/generate/packing-list';
|
||||||
|
|
||||||
$outputDir = Configure::read('pdf_directory');
|
$outputDir = Configure::read('pdf_directory');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// Generate the Purchase Order PDF by calling the Go service instead of TCPDF/FPDI.
|
// Generate the Purchase Order PDF by calling the Go service instead of TCPDF/FPDI.
|
||||||
|
|
||||||
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
$goBaseUrl = AppController::getGoBaseUrlOrFail();
|
||||||
$goEndpoint = $goBaseUrl . '/go/pdf/generate-po';
|
$goEndpoint = $goBaseUrl . '/go/document/generate/purchase-order';
|
||||||
|
|
||||||
$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/api/pdf/generate-quote';
|
$goEndpoint = $goBaseUrl . '/go/document/generate/quote';
|
||||||
|
|
||||||
$outputDir = Configure::read('pdf_directory');
|
$outputDir = Configure::read('pdf_directory');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue