231 lines
6.7 KiB
Go
231 lines
6.7 KiB
Go
package invoice
|
|
|
|
import (
|
|
"fmt"
|
|
"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"
|
|
"github.com/techxcar/backend/pkg/database"
|
|
"github.com/techxcar/backend/pkg/pdf"
|
|
)
|
|
|
|
const storageRoot = "/app/storage"
|
|
|
|
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
|
|
ro := []fiber.Handler{
|
|
auth.RequireAuth(secret),
|
|
auth.RequireRole("tenant_admin", "manager", "technician"),
|
|
auth.TenantMiddleware(db),
|
|
}
|
|
write := []fiber.Handler{
|
|
auth.RequireAuth(secret),
|
|
auth.RequireRole("tenant_admin", "manager"),
|
|
auth.TenantMiddleware(db),
|
|
}
|
|
|
|
app.Get("/api/v1/invoices", append(ro, listInvoicesH())...)
|
|
app.Post("/api/v1/invoices", append(write, createInvoiceH())...)
|
|
app.Get("/api/v1/invoices/:id/pdf", append(ro, downloadPDFH())...)
|
|
}
|
|
|
|
func listInvoicesH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
conn := auth.GetConn(c)
|
|
list, err := ListInvoices(c.Context(), conn)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao listar faturas")
|
|
}
|
|
if list == nil {
|
|
list = []*Invoice{}
|
|
}
|
|
return c.JSON(fiber.Map{"data": list, "error": nil})
|
|
}
|
|
}
|
|
|
|
type createBody struct {
|
|
WorkOrderID string `json:"work_order_id"`
|
|
Type string `json:"type"` // "quote" or "invoice"
|
|
}
|
|
|
|
func tenantDir(c *fiber.Ctx) string {
|
|
claims, _ := c.Locals("claims").(*auth.Claims)
|
|
if claims == nil || claims.TenantID == "" {
|
|
return "unknown"
|
|
}
|
|
return "tenant_" + strings.ReplaceAll(claims.TenantID, "-", "_")
|
|
}
|
|
|
|
func createInvoiceH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
var b createBody
|
|
if err := c.BodyParser(&b); err != nil {
|
|
return fiber.NewError(400, "corpo do pedido inválido")
|
|
}
|
|
if b.WorkOrderID == "" {
|
|
return fiber.NewError(400, "work_order_id obrigatório")
|
|
}
|
|
if b.Type != "quote" && b.Type != "invoice" {
|
|
return fiber.NewError(400, "tipo deve ser 'quote' ou 'invoice'")
|
|
}
|
|
|
|
conn := auth.GetConn(c)
|
|
|
|
detail, err := workorder.GetWorkOrderDetail(c.Context(), conn, b.WorkOrderID)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao obter ordem de trabalho")
|
|
}
|
|
if detail == nil {
|
|
return fiber.NewError(404, "ordem de trabalho não encontrada")
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro ao criar fatura")
|
|
}
|
|
|
|
if b.Type == "invoice" {
|
|
paymentMethod := detail.PaymentMethod
|
|
paymentDate := ""
|
|
if detail.PaymentDate != nil {
|
|
paymentDate = detail.PaymentDate.Format("2006-01-02")
|
|
}
|
|
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})
|
|
}
|
|
}
|
|
|
|
func downloadPDFH() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
conn := auth.GetConn(c)
|
|
inv, err := GetInvoice(c.Context(), conn, id)
|
|
if err != nil {
|
|
return fiber.NewError(500, "erro interno")
|
|
}
|
|
if inv == nil {
|
|
return fiber.NewError(404, "fatura não encontrada")
|
|
}
|
|
|
|
// 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(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(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
|
|
}
|