2026-01-21 05:14:27 -08:00
|
|
|
package documents
|
|
|
|
|
|
|
|
|
|
import (
|
2026-01-25 05:30:43 -08:00
|
|
|
"context"
|
2026-01-21 05:14:27 -08:00
|
|
|
"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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-25 05:30:43 -08:00
|
|
|
// DocumentHandler handles document PDF generation with database integration
|
|
|
|
|
type DocumentHandler struct {
|
|
|
|
|
queries *db.Queries
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewDocumentHandler creates a new DocumentHandler
|
|
|
|
|
func NewDocumentHandler(queries *db.Queries) *DocumentHandler {
|
|
|
|
|
return &DocumentHandler{
|
|
|
|
|
queries: queries,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 05:14:27 -08:00
|
|
|
// 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},
|
2026-01-25 05:30:43 -08:00
|
|
|
HasTextPrices: li.HasTextPrices,
|
|
|
|
|
UnitPriceString: sql.NullString{String: li.UnitPriceString, Valid: li.UnitPriceString != ""},
|
|
|
|
|
GrossPriceString: sql.NullString{String: li.GrossPriceString, Valid: li.GrossPriceString != ""},
|
2026-01-21 05:14:27 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"`
|
2026-01-25 05:30:43 -08:00
|
|
|
UserID int32 `json:"user_id"`
|
2026-01-21 05:14:27 -08:00
|
|
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 05:30:43 -08:00
|
|
|
// GeneratePurchaseOrderPDF handles POST /go/document/generate/purchase-order
|
|
|
|
|
func (h *DocumentHandler) GeneratePurchaseOrderPDF(w http.ResponseWriter, r *http.Request) {
|
2026-01-21 05:14:27 -08:00
|
|
|
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 {
|
2026-01-25 05:30:43 -08:00
|
|
|
// Keep description as-is to support HTML rendering
|
2026-01-21 05:14:27 -08:00
|
|
|
lineItems[i] = db.GetLineItemsTableRow{
|
2026-01-25 05:30:43 -08:00
|
|
|
ItemNumber: li.ItemNumber,
|
|
|
|
|
Quantity: li.Quantity,
|
|
|
|
|
Title: li.Title,
|
|
|
|
|
Description: li.Description,
|
|
|
|
|
NetUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.NetUnitPrice), Valid: true},
|
|
|
|
|
NetPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.NetPrice), Valid: true},
|
|
|
|
|
DiscountAmountTotal: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountTotal), Valid: true},
|
|
|
|
|
Option: li.Option != 0,
|
|
|
|
|
HasTextPrices: li.HasTextPrices,
|
2026-01-21 05:14:27 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 05:30:43 -08:00
|
|
|
// Update the document record with PDF generation info
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
updateParams := db.UpdateDocumentParams{
|
|
|
|
|
ID: req.DocumentID,
|
|
|
|
|
PdfFilename: filename,
|
|
|
|
|
PdfCreatedAt: time.Now(),
|
|
|
|
|
PdfCreatedByUserID: req.UserID,
|
|
|
|
|
Type: db.DocumentsTypePurchaseOrder,
|
|
|
|
|
UserID: req.UserID,
|
|
|
|
|
DocPageCount: 0, // TODO: extract from PDF
|
|
|
|
|
CmcReference: "",
|
|
|
|
|
ShippingDetails: sql.NullString{},
|
|
|
|
|
Revision: 0,
|
|
|
|
|
BillTo: sql.NullString{},
|
|
|
|
|
ShipTo: sql.NullString{},
|
|
|
|
|
EmailSentAt: time.Time{},
|
|
|
|
|
EmailSentByUserID: 0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := h.queries.UpdateDocument(ctx, updateParams); err != nil {
|
|
|
|
|
log.Printf("GeneratePurchaseOrderPDF: failed to update document: %v", err)
|
|
|
|
|
// Don't fail the request if update fails, just log it
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 05:14:27 -08:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
|
2026-01-25 05:30:43 -08:00
|
|
|
} // GeneratePackingListPDF handles POST /go/pdf/generate-packinglist
|
2026-01-21 05:14:27 -08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 01:11:43 -08:00
|
|
|
doc := &db.Document{ID: req.DocumentID, CmcReference: req.CmcReference}
|
2026-01-21 05:14:27 -08:00
|
|
|
cust := &db.Customer{Name: req.CustomerName}
|
|
|
|
|
|
|
|
|
|
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
|
|
|
|
|
for i, li := range req.LineItems {
|
|
|
|
|
lineItems[i] = db.GetLineItemsTableRow{
|
2026-01-25 05:30:43 -08:00
|
|
|
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},
|
|
|
|
|
DiscountAmountTotal: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountTotal), Valid: li.DiscountAmountTotal > 0},
|
|
|
|
|
HasTextPrices: li.HasTextPrices,
|
|
|
|
|
UnitPriceString: sql.NullString{String: li.UnitPriceString, Valid: li.UnitPriceString != ""},
|
|
|
|
|
GrossPriceString: sql.NullString{String: li.GrossPriceString, Valid: li.GrossPriceString != ""},
|
2026-01-21 05:14:27 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data := &pdf.OrderAckPDFData{
|
|
|
|
|
OrderAcknowledgement: doc,
|
|
|
|
|
Customer: cust,
|
2026-01-28 01:11:43 -08:00
|
|
|
CmcReference: req.CmcReference,
|
2026-01-21 05:14:27 -08:00
|
|
|
EmailTo: req.EmailTo,
|
|
|
|
|
Attention: req.Attention,
|
|
|
|
|
IssueDateString: req.IssueDateString,
|
|
|
|
|
YourReference: req.YourReference,
|
|
|
|
|
JobTitle: req.JobTitle,
|
2026-01-28 01:11:43 -08:00
|
|
|
CustomerOrderNumber: req.CustomerOrderNumber,
|
|
|
|
|
CustomerABN: req.CustomerABN,
|
2026-01-21 05:14:27 -08:00
|
|
|
BillTo: req.BillTo,
|
|
|
|
|
ShipTo: req.ShipTo,
|
|
|
|
|
ShipVia: req.ShipVia,
|
|
|
|
|
FOB: req.FOB,
|
|
|
|
|
PaymentTerms: req.PaymentTerms,
|
2026-01-25 05:30:43 -08:00
|
|
|
FreightDetails: req.FreightDetails,
|
2026-01-21 05:14:27 -08:00
|
|
|
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 {
|
2026-01-28 01:11:43 -08:00
|
|
|
DocumentID int32 `json:"document_id"`
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
CustomerName string `json:"customer_name"`
|
|
|
|
|
CmcReference string `json:"cmc_reference"`
|
|
|
|
|
CustomerOrderNumber string `json:"customer_order_number"`
|
|
|
|
|
CustomerABN string `json:"customer_abn"`
|
|
|
|
|
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"`
|
|
|
|
|
FreightDetails string `json:"freight_details"`
|
|
|
|
|
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"`
|
2026-01-21 05:14:27 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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})
|
|
|
|
|
}
|
2026-01-25 05:30:43 -08:00
|
|
|
|
|
|
|
|
// Handler methods that delegate to standalone functions
|
|
|
|
|
func (h *DocumentHandler) GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
GenerateInvoicePDF(w, r)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *DocumentHandler) GenerateQuotePDF(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
GenerateQuotePDF(w, r)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *DocumentHandler) GeneratePackingListPDF(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
GeneratePackingListPDF(w, r)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *DocumentHandler) GenerateOrderAckPDF(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
GenerateOrderAckPDF(w, r)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *DocumentHandler) CountPages(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
CountPages(w, r)
|
|
|
|
|
}
|