feat: refine OT flow, reports, theming and sidebar UX

This commit is contained in:
Luciano Milani
2026-07-02 22:19:03 +01:00
parent 8231bba3f9
commit 9bf176b385
48 changed files with 3469 additions and 599 deletions
+111 -68
View File
@@ -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
}
@@ -0,0 +1,60 @@
package platformsettings
import (
"github.com/gofiber/fiber/v2"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/pkg/database"
)
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
app.Get("/api/v1/platform/settings", getPublicSettingsH(db))
admin := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("super_admin"),
}
app.Get("/api/v1/admin/settings", append(admin, getAdminSettingsH(db))...)
app.Put("/api/v1/admin/settings", append(admin, updateAdminSettingsH(db))...)
}
func getPublicSettingsH(db *database.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
s, err := GetSettings(c.Context(), db.Pool)
if err != nil {
return fiber.NewError(500, "erro ao obter definições globais")
}
return c.JSON(fiber.Map{"data": s, "error": nil})
}
}
func getAdminSettingsH(db *database.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
s, err := GetSettings(c.Context(), db.Pool)
if err != nil {
return fiber.NewError(500, "erro ao obter definições globais")
}
return c.JSON(fiber.Map{"data": s, "error": nil})
}
}
func updateAdminSettingsH(db *database.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var body map[string]string
if err := c.BodyParser(&body); err != nil {
return fiber.NewError(400, "corpo do pedido inválido")
}
for k, v := range body {
if !AllowedKeys[k] {
return fiber.NewError(400, "chave inválida: "+k)
}
if err := SetSetting(c.Context(), db.Pool, k, v); err != nil {
return fiber.NewError(500, "erro ao guardar definição: "+k)
}
}
s, err := GetSettings(c.Context(), db.Pool)
if err != nil {
return fiber.NewError(500, "erro ao obter definições globais")
}
return c.JSON(fiber.Map{"data": s, "error": nil})
}
}
@@ -0,0 +1,44 @@
package platformsettings
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
var AllowedKeys = map[string]bool{
"platform_name": true,
"platform_subtitle": true,
"platform_logo": true,
"admin_primary_color": true,
"admin_accent_color": true,
}
func GetSettings(ctx context.Context, pool *pgxpool.Pool) (map[string]string, error) {
rows, err := pool.Query(ctx, `SELECT key, value FROM public.platform_settings`)
if err != nil {
return nil, fmt.Errorf("platform settings: get: %w", err)
}
defer rows.Close()
result := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("platform settings: scan: %w", err)
}
result[k] = v
}
return result, rows.Err()
}
func SetSetting(ctx context.Context, pool *pgxpool.Pool, key, value string) error {
_, err := pool.Exec(ctx,
`INSERT INTO public.platform_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
key, value)
if err != nil {
return fmt.Errorf("platform settings: set %s: %w", key, err)
}
return nil
}
+2
View File
@@ -13,6 +13,7 @@ import (
"github.com/techxcar/backend/internal/config"
"github.com/techxcar/backend/internal/expense"
"github.com/techxcar/backend/internal/invoice"
"github.com/techxcar/backend/internal/platformsettings"
"github.com/techxcar/backend/internal/settings"
"github.com/techxcar/backend/internal/staff"
"github.com/techxcar/backend/internal/tenant"
@@ -55,6 +56,7 @@ func New(deps Deps) *fiber.App {
staff.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
expense.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
settings.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
platformsettings.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
invoice.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
read := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager"),
auth.RequireRole("tenant_admin", "manager", "technician"),
auth.TenantMiddleware(db),
}
admin := []fiber.Handler{
+1
View File
@@ -15,6 +15,7 @@ var AllowedKeys = map[string]bool{
"company_phone": true,
"company_email": true,
"company_logo": true,
"ui_theme": true,
}
func GetSettings(ctx context.Context, conn *pgxpool.Conn) (map[string]string, error) {
+107 -11
View File
@@ -2,6 +2,7 @@ package workorder
import (
"errors"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
@@ -53,6 +54,28 @@ type woBody struct {
VehicleID string `json:"vehicle_id"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
ETADays int `json:"eta_days"`
RealDeadline string `json:"real_deadline"`
PaymentMethod string `json:"payment_method"`
PaymentDate string `json:"payment_date"`
}
func isValidETADays(v int) bool {
switch v {
case 1, 2, 3, 7, 15, 30, 31:
return true
default:
return false
}
}
func isValidPaymentMethod(v string) bool {
switch v {
case "", "numerario", "multibanco", "transferencia_bancaria", "mb_way", "cartao_debito", "cartao_credito", "cheque", "outro":
return true
default:
return false
}
}
func createWOH() fiber.Handler {
@@ -61,13 +84,32 @@ func createWOH() fiber.Handler {
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.ETADays == 0 {
b.ETADays = 1
}
if !isValidETADays(b.ETADays) {
return fiber.NewError(400, "eta_days inválido")
}
if b.RealDeadline != "" {
if _, err := time.Parse("2006-01-02", b.RealDeadline); err != nil {
return fiber.NewError(400, "real_deadline inválido (YYYY-MM-DD)")
}
}
if !isValidPaymentMethod(b.PaymentMethod) {
return fiber.NewError(400, "payment_method inválido")
}
if b.PaymentDate != "" {
if _, err := time.Parse("2006-01-02", b.PaymentDate); err != nil {
return fiber.NewError(400, "payment_date inválido (YYYY-MM-DD)")
}
}
claims, _ := c.Locals("claims").(*auth.Claims)
createdBy := ""
if claims != nil {
createdBy = claims.UserID
}
conn := auth.GetConn(c)
wo, err := CreateWorkOrder(c.Context(), conn, b.ClientID, b.VehicleID, b.InternalNotes, createdBy)
wo, err := CreateWorkOrder(c.Context(), conn, b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes, b.ETADays, b.RealDeadline, createdBy)
if err != nil {
return fiber.NewError(500, "erro ao criar ordem")
}
@@ -95,11 +137,30 @@ func updateWOH() fiber.Handler {
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.ETADays == 0 {
b.ETADays = 1
}
if !isValidETADays(b.ETADays) {
return fiber.NewError(400, "eta_days inválido")
}
if b.RealDeadline != "" {
if _, err := time.Parse("2006-01-02", b.RealDeadline); err != nil {
return fiber.NewError(400, "real_deadline inválido (YYYY-MM-DD)")
}
}
if !isValidPaymentMethod(b.PaymentMethod) {
return fiber.NewError(400, "payment_method inválido")
}
if b.PaymentDate != "" {
if _, err := time.Parse("2006-01-02", b.PaymentDate); err != nil {
return fiber.NewError(400, "payment_date inválido (YYYY-MM-DD)")
}
}
conn := auth.GetConn(c)
wo, err := UpdateWorkOrder(c.Context(), conn, c.Params("id"), b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes)
wo, err := UpdateWorkOrder(c.Context(), conn, c.Params("id"), b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes, b.ETADays, b.RealDeadline, b.PaymentMethod, b.PaymentDate)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "ordem não encontrada")
return fiber.NewError(400, "ordem não editável (cancelada/faturada) ou não encontrada")
}
return fiber.NewError(500, "erro ao actualizar ordem")
}
@@ -110,18 +171,36 @@ func updateWOH() fiber.Handler {
func transitionWOH() fiber.Handler {
return func(c *fiber.Ctx) error {
var body struct {
Status string `json:"status"`
Status string `json:"status"`
PaymentMethod string `json:"payment_method"`
PaymentDate string `json:"payment_date"`
}
if err := c.BodyParser(&body); err != nil || body.Status == "" {
return fiber.NewError(400, "status é obrigatório")
}
if !isValidPaymentMethod(body.PaymentMethod) {
return fiber.NewError(400, "payment_method inválido")
}
if body.PaymentDate != "" {
if _, err := time.Parse("2006-01-02", body.PaymentDate); err != nil {
return fiber.NewError(400, "payment_date inválido (YYYY-MM-DD)")
}
}
if body.Status == "invoiced" {
if body.PaymentMethod == "" {
return fiber.NewError(400, "meio de pagamento é obrigatório para faturar")
}
if body.PaymentDate == "" {
body.PaymentDate = time.Now().Format("2006-01-02")
}
}
claims, _ := c.Locals("claims").(*auth.Claims)
changedBy := ""
if claims != nil {
changedBy = claims.UserID
}
conn := auth.GetConn(c)
wo, err := TransitionStatus(c.Context(), conn, c.Params("id"), body.Status, changedBy)
wo, err := TransitionStatus(c.Context(), conn, c.Params("id"), body.Status, body.PaymentMethod, body.PaymentDate, changedBy)
if err != nil {
return fiber.NewError(400, err.Error())
}
@@ -130,11 +209,12 @@ func transitionWOH() fiber.Handler {
}
type woItemBody struct {
CatalogItemID string `json:"catalog_item_id"`
Description string `json:"description"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
DiscountPct float64 `json:"discount_pct"`
CatalogItemID string `json:"catalog_item_id"`
Description string `json:"description"`
ChangeJustification string `json:"change_justification"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
DiscountPct float64 `json:"discount_pct"`
}
func addItemH() fiber.Handler {
@@ -147,8 +227,21 @@ func addItemH() fiber.Handler {
return fiber.NewError(400, "descrição, quantidade e preço unitário são obrigatórios")
}
conn := auth.GetConn(c)
item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.Qty, b.UnitPrice, b.DiscountPct)
var status string
if err := conn.QueryRow(c.Context(), `SELECT status FROM work_orders WHERE id=$1`, c.Params("id")).Scan(&status); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "ordem não encontrada")
}
return fiber.NewError(500, "erro ao validar ordem")
}
if status == "open" && b.ChangeJustification == "" {
return fiber.NewError(400, "justificação da alteração é obrigatória em orçamento aprovado")
}
item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.ChangeJustification, b.Qty, b.UnitPrice, b.DiscountPct)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(400, "ordem não editável (cancelada/faturada) ou dados inválidos")
}
return fiber.NewError(500, "erro ao adicionar item")
}
return c.Status(201).JSON(fiber.Map{"data": item, "error": nil})
@@ -183,6 +276,9 @@ func addStaffHoursH() fiber.Handler {
conn := auth.GetConn(c)
sh, err := AddStaffHours(c.Context(), conn, c.Params("id"), b.StaffID, b.Hours, b.CostPerHour)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(400, "ordem não editável (cancelada/faturada) ou não encontrada")
}
return fiber.NewError(500, "erro ao adicionar horas")
}
return c.Status(201).JSON(fiber.Map{"data": sh, "error": nil})
+88 -50
View File
@@ -9,27 +9,32 @@ import (
)
type WorkOrder struct {
ID string `json:"id"`
Number int `json:"number"`
ClientID *string `json:"client_id"`
VehicleID *string `json:"vehicle_id"`
Status string `json:"status"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Number int `json:"number"`
ClientID *string `json:"client_id"`
VehicleID *string `json:"vehicle_id"`
Status string `json:"status"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
ETADays int `json:"eta_days"`
RealDeadline *time.Time `json:"real_deadline"`
PaymentMethod string `json:"payment_method"`
PaymentDate *time.Time `json:"payment_date"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type WOItem struct {
ID string `json:"id"`
WorkOrderID string `json:"work_order_id"`
CatalogItemID *string `json:"catalog_item_id"`
Description string `json:"description"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
DiscountPct float64 `json:"discount_pct"`
Total float64 `json:"total"`
ID string `json:"id"`
WorkOrderID string `json:"work_order_id"`
CatalogItemID *string `json:"catalog_item_id"`
Description string `json:"description"`
ChangeJustification string `json:"change_justification"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
DiscountPct float64 `json:"discount_pct"`
Total float64 `json:"total"`
}
type WOStaffHours struct {
@@ -71,7 +76,8 @@ func ValidateTransition(from, to string) error {
func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*WorkOrder, error) {
q := `SELECT id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at
COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline,
COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at
FROM work_orders`
args := []any{}
if status != "" {
@@ -88,7 +94,8 @@ func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*
for rows.Next() {
var wo WorkOrder
if err := rows.Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt); err != nil {
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline,
&wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt); err != nil {
return nil, err
}
list = append(list, &wo)
@@ -96,39 +103,47 @@ func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*
return list, rows.Err()
}
func CreateWorkOrder(ctx context.Context, conn *pgxpool.Conn, clientID, vehicleID, internalNotes, createdBy string) (*WorkOrder, error) {
func CreateWorkOrder(ctx context.Context, conn *pgxpool.Conn, clientID, vehicleID, internalNotes, clientNotes string, etaDays int, realDeadline, createdBy string) (*WorkOrder, error) {
var wo WorkOrder
err := conn.QueryRow(ctx, `
INSERT INTO work_orders (client_id, vehicle_id, internal_notes, created_by)
INSERT INTO work_orders (client_id, vehicle_id, internal_notes, client_notes, eta_days, real_deadline, created_by)
VALUES (
NULLIF($1,'')::uuid,
NULLIF($2,'')::uuid,
NULLIF($3,''),
(SELECT id FROM users WHERE id = NULLIF($4,'')::uuid LIMIT 1)
NULLIF($4,''),
$5,
NULLIF($6,'')::date,
(SELECT id FROM users WHERE id = NULLIF($7,'')::uuid LIMIT 1)
)
RETURNING id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`,
clientID, vehicleID, internalNotes, createdBy).
COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline,
COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at`,
clientID, vehicleID, internalNotes, clientNotes, etaDays, realDeadline, createdBy).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline,
&wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
return &wo, err
}
func UpdateWorkOrder(ctx context.Context, conn *pgxpool.Conn, id, clientID, vehicleID, internalNotes, clientNotes string) (*WorkOrder, error) {
func UpdateWorkOrder(ctx context.Context, conn *pgxpool.Conn, id, clientID, vehicleID, internalNotes, clientNotes string, etaDays int, realDeadline, paymentMethod, paymentDate string) (*WorkOrder, error) {
var wo WorkOrder
err := conn.QueryRow(ctx, `
UPDATE work_orders SET client_id=NULLIF($2,'')::uuid, vehicle_id=NULLIF($3,'')::uuid,
internal_notes=NULLIF($4,''), client_notes=NULLIF($5,''), updated_at=NOW()
WHERE id=$1
internal_notes=NULLIF($4,''), client_notes=NULLIF($5,''), eta_days=$6, real_deadline=NULLIF($7,'')::date,
payment_method=NULLIF($8,''), payment_date=NULLIF($9,'')::date, updated_at=NOW()
WHERE id=$1 AND status NOT IN ('cancelled','invoiced')
RETURNING id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`,
id, clientID, vehicleID, internalNotes, clientNotes).
COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline,
COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at`,
id, clientID, vehicleID, internalNotes, clientNotes, etaDays, realDeadline, paymentMethod, paymentDate).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline,
&wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
return &wo, err
}
func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, changedBy string) (*WorkOrder, error) {
func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, paymentMethod, paymentDate, changedBy string) (*WorkOrder, error) {
var fromStatus string
if err := conn.QueryRow(ctx, `SELECT status FROM work_orders WHERE id=$1`, id).Scan(&fromStatus); err != nil {
return nil, errors.New("ordem não encontrada")
@@ -138,12 +153,19 @@ func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, cha
}
var wo WorkOrder
err := conn.QueryRow(ctx, `
UPDATE work_orders SET status=$2, updated_at=NOW() WHERE id=$1
UPDATE work_orders
SET status=$2,
payment_method=CASE WHEN $2='invoiced' THEN NULLIF($3,'') ELSE payment_method END,
payment_date=CASE WHEN $2='invoiced' THEN NULLIF($4,'')::date ELSE payment_date END,
updated_at=NOW()
WHERE id=$1
RETURNING id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`,
id, toStatus).
COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline,
COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at`,
id, toStatus, paymentMethod, paymentDate).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline,
&wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
if err != nil {
return nil, err
}
@@ -158,10 +180,12 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo
var wo WorkOrder
err := conn.QueryRow(ctx, `
SELECT id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at
COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline,
COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at
FROM work_orders WHERE id=$1`, id).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline,
&wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
if err != nil {
return nil, err
}
@@ -169,7 +193,7 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo
detail := &WorkOrderDetail{WorkOrder: wo, Items: []*WOItem{}, StaffHours: []*WOStaffHours{}}
rows, err := conn.Query(ctx, `
SELECT id, work_order_id, catalog_item_id, description, qty, unit_price, discount_pct, total
SELECT id, work_order_id, catalog_item_id, description, COALESCE(change_justification,''), qty, unit_price, discount_pct, total
FROM wo_items WHERE work_order_id=$1`, id)
if err != nil {
return nil, err
@@ -177,7 +201,7 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo
defer rows.Close()
for rows.Next() {
var i WOItem
if err := rows.Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description,
if err := rows.Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.ChangeJustification,
&i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total); err != nil {
return nil, err
}
@@ -204,19 +228,27 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo
return detail, shRows.Err()
}
func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, description string, qty, unitPrice, discountPct float64) (*WOItem, error) {
func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, description, changeJustification string, qty, unitPrice, discountPct float64) (*WOItem, error) {
var i WOItem
err := conn.QueryRow(ctx, `
INSERT INTO wo_items (work_order_id, catalog_item_id, description, qty, unit_price, discount_pct)
VALUES ($1, NULLIF($2,'')::uuid, $3, $4, $5, $6)
RETURNING id, work_order_id, catalog_item_id, description, qty, unit_price, discount_pct, total`,
woID, catalogItemID, description, qty, unitPrice, discountPct).
Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total)
INSERT INTO wo_items (work_order_id, catalog_item_id, description, change_justification, qty, unit_price, discount_pct)
SELECT id, NULLIF($2,'')::uuid, $3, NULLIF($4,''), $5, $6, $7
FROM work_orders
WHERE id=$1
AND status NOT IN ('cancelled','invoiced')
AND (status <> 'open' OR NULLIF($4,'') IS NOT NULL)
RETURNING id, work_order_id, catalog_item_id, description, COALESCE(change_justification,''), qty, unit_price, discount_pct, total`,
woID, catalogItemID, description, changeJustification, qty, unitPrice, discountPct).
Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.ChangeJustification, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total)
return &i, err
}
func RemoveItem(ctx context.Context, conn *pgxpool.Conn, woID, itemID string) error {
_, err := conn.Exec(ctx, `DELETE FROM wo_items WHERE id=$1 AND work_order_id=$2`, itemID, woID)
_, err := conn.Exec(ctx, `
DELETE FROM wo_items i
USING work_orders w
WHERE i.id=$1 AND i.work_order_id=$2 AND w.id=i.work_order_id AND w.status NOT IN ('cancelled','invoiced')`,
itemID, woID)
return err
}
@@ -224,7 +256,9 @@ func AddStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, staffID string
var sh WOStaffHours
err := conn.QueryRow(ctx, `
INSERT INTO wo_staff_hours (work_order_id, staff_id, hours, cost_per_hour)
VALUES ($1, $2, $3, $4)
SELECT id, $2, $3, $4
FROM work_orders
WHERE id=$1 AND status NOT IN ('cancelled','invoiced')
RETURNING id, work_order_id, staff_id, hours, cost_per_hour, total`,
woID, staffID, hours, costPerHour).
Scan(&sh.ID, &sh.WorkOrderID, &sh.StaffID, &sh.Hours, &sh.CostPerHour, &sh.Total)
@@ -232,6 +266,10 @@ func AddStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, staffID string
}
func RemoveStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, shID string) error {
_, err := conn.Exec(ctx, `DELETE FROM wo_staff_hours WHERE id=$1 AND work_order_id=$2`, shID, woID)
_, err := conn.Exec(ctx, `
DELETE FROM wo_staff_hours s
USING work_orders w
WHERE s.id=$1 AND s.work_order_id=$2 AND w.id=s.work_order_id AND w.status NOT IN ('cancelled','invoiced')`,
shID, woID)
return err
}
@@ -32,7 +32,7 @@ func TestWorkOrderCRUD(t *testing.T) {
conn := getTestConn(t)
ctx := context.Background()
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "", 1, "", "")
require.NoError(t, err)
assert.NotEmpty(t, wo.ID)
assert.Equal(t, "quote", wo.Status)
@@ -52,14 +52,14 @@ func TestWorkOrderTransition(t *testing.T) {
conn := getTestConn(t)
ctx := context.Background()
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "", 1, "", "")
require.NoError(t, err)
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "open", "")
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "open", "", "", "")
require.NoError(t, err)
assert.Equal(t, "open", wo2.Status)
_, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "")
_, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "", "", "")
assert.Error(t, err, "invalid transition should error")
}
@@ -66,6 +66,10 @@ CREATE TABLE IF NOT EXISTS work_orders (
status TEXT NOT NULL DEFAULT 'quote' CHECK (status IN ('quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled')),
internal_notes TEXT,
client_notes TEXT,
eta_days INT NOT NULL DEFAULT 1 CHECK (eta_days IN (1, 2, 3, 7, 15, 30, 31)),
real_deadline DATE,
payment_method TEXT,
payment_date DATE,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
@@ -76,6 +80,7 @@ CREATE TABLE IF NOT EXISTS wo_items (
work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
catalog_item_id UUID REFERENCES catalog_items(id) ON DELETE SET NULL,
description TEXT NOT NULL,
change_justification TEXT,
qty NUMERIC(10,3) NOT NULL DEFAULT 1,
unit_price NUMERIC(10,2) NOT NULL,
discount_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
@@ -0,0 +1,5 @@
ALTER TABLE work_orders
DROP CONSTRAINT IF EXISTS work_orders_eta_days_check;
ALTER TABLE work_orders
DROP COLUMN IF EXISTS eta_days;
@@ -0,0 +1,9 @@
ALTER TABLE work_orders
ADD COLUMN IF NOT EXISTS eta_days INT NOT NULL DEFAULT 1;
ALTER TABLE work_orders
DROP CONSTRAINT IF EXISTS work_orders_eta_days_check;
ALTER TABLE work_orders
ADD CONSTRAINT work_orders_eta_days_check
CHECK (eta_days IN (1, 2, 3, 7, 15, 30, 31));
@@ -0,0 +1,2 @@
ALTER TABLE work_orders
DROP COLUMN IF EXISTS real_deadline;
@@ -0,0 +1,2 @@
ALTER TABLE work_orders
ADD COLUMN IF NOT EXISTS real_deadline DATE;
@@ -0,0 +1,5 @@
ALTER TABLE work_orders
DROP COLUMN IF EXISTS payment_date;
ALTER TABLE work_orders
DROP COLUMN IF EXISTS payment_method;
@@ -0,0 +1,5 @@
ALTER TABLE work_orders
ADD COLUMN IF NOT EXISTS payment_method TEXT;
ALTER TABLE work_orders
ADD COLUMN IF NOT EXISTS payment_date DATE;
@@ -0,0 +1,2 @@
ALTER TABLE wo_items
DROP COLUMN IF EXISTS change_justification;
@@ -0,0 +1,2 @@
ALTER TABLE wo_items
ADD COLUMN IF NOT EXISTS change_justification TEXT;
+43 -30
View File
@@ -48,37 +48,53 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
tr := f.UnicodeTranslatorFromDescriptor("")
t := func(s string) string { return tr(s) }
hasLogo := addLogo(f, meta.CompanyLogo)
headerTop := 15.0
leftX := 15.0
leftW := 120.0
if hasLogo {
leftW = 100.0
rightX := 135.0
rightW := 60.0
// Header (left): logo always reserves its own vertical space.
leftY := headerTop
const logoSize = 24.0
if addLogo(f, meta.CompanyLogo, leftX, leftY, logoSize, logoSize) {
leftY += logoSize + 3
}
// Header
f.SetXY(leftX, leftY)
f.SetFont("Helvetica", "B", 18)
f.CellFormat(leftW, 10, t(meta.CompanyName), "", 0, "L", false, 0, "")
f.SetFont("Helvetica", "B", 14)
f.CellFormat(60, 10, t(meta.DocType), "", 1, "R", false, 0, "")
f.CellFormat(leftW, 10, t(meta.CompanyName), "", 1, "L", false, 0, "")
leftY = f.GetY()
f.SetFont("Helvetica", "", 9)
if meta.CompanyNIF != "" {
f.CellFormat(leftW, 5, t("NIF: ")+meta.CompanyNIF, "", 0, "L", false, 0, "")
} else {
f.CellFormat(leftW, 5, "", "", 0, "L", false, 0, "")
f.SetXY(leftX, leftY)
f.CellFormat(leftW, 5, t("NIF: ")+meta.CompanyNIF, "", 1, "L", false, 0, "")
leftY = f.GetY()
}
f.SetFont("Helvetica", "", 11)
f.CellFormat(60, 5, t(meta.DocNumber), "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 9)
if meta.CompanyAddress != "" {
f.SetXY(leftX, leftY)
f.MultiCell(leftW, 5, t(meta.CompanyAddress), "", "L", false)
leftY = f.GetY()
}
f.Ln(3)
curY := f.GetY()
f.SetXY(135, curY-3)
f.CellFormat(60, 5, t("Data: ")+meta.IssuedAt, "", 1, "R", false, 0, "")
f.SetY(curY + 3)
f.Ln(3)
// Header (right): document meta.
f.SetXY(rightX, headerTop)
f.SetFont("Helvetica", "B", 14)
f.CellFormat(rightW, 10, t(meta.DocType), "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 11)
f.SetXY(rightX, headerTop+15)
f.CellFormat(rightW, 6, t(meta.DocNumber), "", 1, "R", false, 0, "")
f.SetXY(rightX, headerTop+26)
f.CellFormat(rightW, 6, t("Data: ")+meta.IssuedAt, "", 1, "R", false, 0, "")
// Continue after whichever side ended lower.
rightBottom := headerTop + 32
if leftY < rightBottom {
leftY = rightBottom
}
f.SetY(leftY + 4)
// Client block
if meta.ClientName != "" {
@@ -148,7 +164,7 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
return f.OutputFileAndClose(outPath)
}
func addLogo(f *fpdf.Fpdf, logo string) bool {
func addLogo(f *fpdf.Fpdf, logo string, x, y, w, h float64) bool {
logo = strings.TrimSpace(logo)
if logo == "" {
return false
@@ -172,10 +188,9 @@ func addLogo(f *fpdf.Fpdf, logo string) bool {
if strings.Contains(meta, "image/jpeg") || strings.Contains(meta, "image/jpg") {
imgType = "JPG"
}
opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: true}
opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: false}
f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(raw))
f.ImageOptions("company_logo", 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
f.ImageOptions("company_logo", x, y, w, h, false, opts, 0, "")
return true
}
@@ -198,17 +213,15 @@ func addLogo(f *fpdf.Fpdf, logo string) bool {
if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
imgType = "JPG"
}
opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: true}
opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: false}
f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(buf.Bytes()))
f.ImageOptions("company_logo", 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
f.ImageOptions("company_logo", x, y, w, h, false, opts, 0, "")
return true
}
if _, err := os.Stat(logo); err == nil {
opts := fpdf.ImageOptions{ReadDpi: true}
f.ImageOptions(logo, 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
opts := fpdf.ImageOptions{ReadDpi: false}
f.ImageOptions(logo, x, y, w, h, false, opts, 0, "")
return true
}