feat: refine OT flow, reports, theming and sidebar UX
This commit is contained in:
@@ -5,8 +5,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/techxcar/backend/internal/auth"
|
||||
"github.com/techxcar/backend/internal/settings"
|
||||
"github.com/techxcar/backend/internal/workorder"
|
||||
@@ -83,21 +86,18 @@ func createInvoiceH() fiber.Handler {
|
||||
return fiber.NewError(404, "ordem de trabalho não encontrada")
|
||||
}
|
||||
|
||||
sett, err := settings.GetSettings(c.Context(), conn)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao obter definições")
|
||||
}
|
||||
|
||||
var clientName, clientNIF, vehiclePlate string
|
||||
if detail.ClientID != nil {
|
||||
_ = conn.QueryRow(c.Context(),
|
||||
`SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`,
|
||||
*detail.ClientID).Scan(&clientName, &clientNIF)
|
||||
}
|
||||
if detail.VehicleID != nil {
|
||||
_ = conn.QueryRow(c.Context(),
|
||||
`SELECT plate FROM vehicles WHERE id = $1`,
|
||||
*detail.VehicleID).Scan(&vehiclePlate)
|
||||
if b.Type == "invoice" {
|
||||
if detail.PaymentMethod == "" {
|
||||
return fiber.NewError(400, "defina o meio de pagamento na OT antes de faturar")
|
||||
}
|
||||
var existingID string
|
||||
if err := conn.QueryRow(c.Context(),
|
||||
`SELECT id FROM invoices WHERE work_order_id=$1 AND type='invoice' ORDER BY issued_at DESC LIMIT 1`,
|
||||
b.WorkOrderID).Scan(&existingID); err == nil && existingID != "" {
|
||||
return fiber.NewError(409, "já existe uma fatura para esta OT")
|
||||
} else if err != nil && err != pgx.ErrNoRows {
|
||||
return fiber.NewError(500, "erro ao validar faturação existente")
|
||||
}
|
||||
}
|
||||
|
||||
inv, err := CreateInvoice(c.Context(), conn, b.WorkOrderID, b.Type)
|
||||
@@ -105,61 +105,27 @@ func createInvoiceH() fiber.Handler {
|
||||
return fiber.NewError(500, "erro ao criar fatura")
|
||||
}
|
||||
|
||||
docType := "Orçamento"
|
||||
prefix := "ORC"
|
||||
if b.Type == "invoice" {
|
||||
docType = "Fatura"
|
||||
prefix = "FAT"
|
||||
}
|
||||
|
||||
outPath := filepath.Join(storageRoot, tenantDir(c), fmt.Sprintf("inv_%s.pdf", inv.ID))
|
||||
|
||||
meta := pdf.DocMeta{
|
||||
CompanyName: sett["company_name"],
|
||||
CompanyNIF: sett["company_nif"],
|
||||
CompanyAddress: sett["company_address"],
|
||||
CompanyIBAN: sett["company_iban"],
|
||||
CompanyPhone: sett["company_phone"],
|
||||
CompanyEmail: sett["company_email"],
|
||||
CompanyLogo: sett["company_logo"],
|
||||
DocType: docType,
|
||||
DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number),
|
||||
IssuedAt: inv.IssuedAt.Format("02/01/2006"),
|
||||
ClientName: clientName,
|
||||
ClientNIF: clientNIF,
|
||||
VehiclePlate: vehiclePlate,
|
||||
}
|
||||
|
||||
lineItems := make([]pdf.LineItem, len(detail.Items))
|
||||
for i, item := range detail.Items {
|
||||
lineItems[i] = pdf.LineItem{
|
||||
Description: item.Description,
|
||||
Qty: item.Qty,
|
||||
UnitPrice: item.UnitPrice,
|
||||
DiscountPct: item.DiscountPct,
|
||||
Total: item.Total,
|
||||
paymentMethod := detail.PaymentMethod
|
||||
paymentDate := ""
|
||||
if detail.PaymentDate != nil {
|
||||
paymentDate = detail.PaymentDate.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
var staffTotal float64
|
||||
for _, sh := range detail.StaffHours {
|
||||
staffTotal += sh.Total
|
||||
}
|
||||
|
||||
if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil {
|
||||
return fiber.NewError(500, "erro ao gerar PDF")
|
||||
}
|
||||
|
||||
if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil {
|
||||
return fiber.NewError(500, "erro ao registar caminho do PDF")
|
||||
}
|
||||
inv.PDFPath = outPath
|
||||
|
||||
if b.Type == "invoice" {
|
||||
if _, err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, "invoiced", ""); err != nil {
|
||||
if paymentDate == "" {
|
||||
paymentDate = time.Now().Format("2006-01-02")
|
||||
}
|
||||
if _, err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, "invoiced", paymentMethod, paymentDate, ""); err != nil {
|
||||
_, _ = conn.Exec(c.Context(), `DELETE FROM invoices WHERE id=$1`, inv.ID)
|
||||
return fiber.NewError(500, "erro ao atualizar estado da ordem")
|
||||
}
|
||||
}
|
||||
|
||||
outPath, err := regeneratePDF(c, conn, inv)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao gerar PDF")
|
||||
}
|
||||
inv.PDFPath = outPath
|
||||
|
||||
return c.Status(201).JSON(fiber.Map{"data": inv, "error": nil})
|
||||
}
|
||||
}
|
||||
@@ -175,13 +141,90 @@ func downloadPDFH() fiber.Handler {
|
||||
if inv == nil {
|
||||
return fiber.NewError(404, "fatura não encontrada")
|
||||
}
|
||||
if inv.PDFPath == "" {
|
||||
return fiber.NewError(404, "PDF não disponível")
|
||||
|
||||
// Regenerate on download so layout/branding fixes are reflected on existing docs.
|
||||
outPath, err := regeneratePDF(c, conn, inv)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao gerar PDF")
|
||||
}
|
||||
if _, err := os.Stat(inv.PDFPath); os.IsNotExist(err) {
|
||||
if _, err := os.Stat(outPath); os.IsNotExist(err) {
|
||||
return fiber.NewError(404, "ficheiro PDF não encontrado")
|
||||
}
|
||||
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="invoice_%d.pdf"`, inv.Number))
|
||||
return c.SendFile(inv.PDFPath)
|
||||
return c.SendFile(outPath)
|
||||
}
|
||||
}
|
||||
|
||||
func regeneratePDF(c *fiber.Ctx, conn *pgxpool.Conn, inv *Invoice) (string, error) {
|
||||
detail, err := workorder.GetWorkOrderDetail(c.Context(), conn, inv.WorkOrderID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("erro ao obter ordem de trabalho: %w", err)
|
||||
}
|
||||
if detail == nil {
|
||||
return "", fmt.Errorf("ordem de trabalho não encontrada")
|
||||
}
|
||||
|
||||
sett, err := settings.GetSettings(c.Context(), conn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("erro ao obter definições: %w", err)
|
||||
}
|
||||
|
||||
var clientName, clientNIF, vehiclePlate string
|
||||
if detail.ClientID != nil {
|
||||
_ = conn.QueryRow(c.Context(),
|
||||
`SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`,
|
||||
*detail.ClientID).Scan(&clientName, &clientNIF)
|
||||
}
|
||||
if detail.VehicleID != nil {
|
||||
_ = conn.QueryRow(c.Context(),
|
||||
`SELECT plate FROM vehicles WHERE id = $1`,
|
||||
*detail.VehicleID).Scan(&vehiclePlate)
|
||||
}
|
||||
|
||||
docType := "Orçamento"
|
||||
prefix := "ORC"
|
||||
if inv.Type == "invoice" {
|
||||
docType = "Fatura"
|
||||
prefix = "FAT"
|
||||
}
|
||||
|
||||
outPath := filepath.Join(storageRoot, tenantDir(c), fmt.Sprintf("inv_%s.pdf", inv.ID))
|
||||
meta := pdf.DocMeta{
|
||||
CompanyName: sett["company_name"],
|
||||
CompanyNIF: sett["company_nif"],
|
||||
CompanyAddress: sett["company_address"],
|
||||
CompanyIBAN: sett["company_iban"],
|
||||
CompanyPhone: sett["company_phone"],
|
||||
CompanyEmail: sett["company_email"],
|
||||
CompanyLogo: sett["company_logo"],
|
||||
DocType: docType,
|
||||
DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number),
|
||||
IssuedAt: inv.IssuedAt.Format("02/01/2006"),
|
||||
ClientName: clientName,
|
||||
ClientNIF: clientNIF,
|
||||
VehiclePlate: vehiclePlate,
|
||||
}
|
||||
|
||||
lineItems := make([]pdf.LineItem, len(detail.Items))
|
||||
for i, item := range detail.Items {
|
||||
lineItems[i] = pdf.LineItem{
|
||||
Description: item.Description,
|
||||
Qty: item.Qty,
|
||||
UnitPrice: item.UnitPrice,
|
||||
DiscountPct: item.DiscountPct,
|
||||
Total: item.Total,
|
||||
}
|
||||
}
|
||||
var staffTotal float64
|
||||
for _, sh := range detail.StaffHours {
|
||||
staffTotal += sh.Total
|
||||
}
|
||||
|
||||
if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user