This commit is contained in:
Luciano Milani
2026-07-02 12:47:55 +01:00
commit 5de37bb512
132 changed files with 28495 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
package workorder
import (
"errors"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/pkg/database"
)
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/work-orders", append(ro, listWOsH())...)
app.Post("/api/v1/work-orders", append(write, createWOH())...)
app.Get("/api/v1/work-orders/:id", append(ro, getWODetailH())...)
app.Put("/api/v1/work-orders/:id", append(write, updateWOH())...)
app.Post("/api/v1/work-orders/:id/transition", append(write, transitionWOH())...)
app.Post("/api/v1/work-orders/:id/items", append(write, addItemH())...)
app.Delete("/api/v1/work-orders/:id/items/:itemId", append(write, removeItemH())...)
app.Post("/api/v1/work-orders/:id/staff-hours", append(write, addStaffHoursH())...)
app.Delete("/api/v1/work-orders/:id/staff-hours/:shId", append(write, removeStaffHoursH())...)
}
func listWOsH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
list, err := ListWorkOrders(c.Context(), conn, c.Query("status"))
if err != nil {
return fiber.NewError(500, "erro ao listar ordens")
}
if list == nil {
list = []*WorkOrder{}
}
return c.JSON(fiber.Map{"data": list, "error": nil})
}
}
type woBody struct {
ClientID string `json:"client_id"`
VehicleID string `json:"vehicle_id"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
}
func createWOH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b woBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
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)
if err != nil {
return fiber.NewError(500, "erro ao criar ordem")
}
return c.Status(201).JSON(fiber.Map{"data": wo, "error": nil})
}
}
func getWODetailH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
detail, err := GetWorkOrderDetail(c.Context(), conn, c.Params("id"))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "ordem não encontrada")
}
return fiber.NewError(500, "erro ao obter ordem")
}
return c.JSON(fiber.Map{"data": detail, "error": nil})
}
}
func updateWOH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b woBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
conn := auth.GetConn(c)
wo, err := UpdateWorkOrder(c.Context(), conn, c.Params("id"), b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "ordem não encontrada")
}
return fiber.NewError(500, "erro ao actualizar ordem")
}
return c.JSON(fiber.Map{"data": wo, "error": nil})
}
}
func transitionWOH() fiber.Handler {
return func(c *fiber.Ctx) error {
var body struct {
Status string `json:"status"`
}
if err := c.BodyParser(&body); err != nil || body.Status == "" {
return fiber.NewError(400, "status é obrigatório")
}
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)
if err != nil {
return fiber.NewError(400, err.Error())
}
return c.JSON(fiber.Map{"data": wo, "error": nil})
}
}
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"`
}
func addItemH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b woItemBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.Description == "" || b.Qty <= 0 || b.UnitPrice < 0 {
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)
if err != nil {
return fiber.NewError(500, "erro ao adicionar item")
}
return c.Status(201).JSON(fiber.Map{"data": item, "error": nil})
}
}
func removeItemH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
if err := RemoveItem(c.Context(), conn, c.Params("id"), c.Params("itemId")); err != nil {
return fiber.NewError(500, "erro ao remover item")
}
return c.SendStatus(204)
}
}
type staffHoursBody struct {
StaffID string `json:"staff_id"`
Hours float64 `json:"hours"`
CostPerHour float64 `json:"cost_per_hour"`
}
func addStaffHoursH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b staffHoursBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.StaffID == "" || b.Hours <= 0 {
return fiber.NewError(400, "funcionário e horas são obrigatórios")
}
conn := auth.GetConn(c)
sh, err := AddStaffHours(c.Context(), conn, c.Params("id"), b.StaffID, b.Hours, b.CostPerHour)
if err != nil {
return fiber.NewError(500, "erro ao adicionar horas")
}
return c.Status(201).JSON(fiber.Map{"data": sh, "error": nil})
}
}
func removeStaffHoursH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
if err := RemoveStaffHours(c.Context(), conn, c.Params("id"), c.Params("shId")); err != nil {
return fiber.NewError(500, "erro ao remover horas")
}
return c.SendStatus(204)
}
}
+236
View File
@@ -0,0 +1,236 @@
package workorder
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
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"`
}
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"`
}
type WOStaffHours struct {
ID string `json:"id"`
WorkOrderID string `json:"work_order_id"`
StaffID string `json:"staff_id"`
Hours float64 `json:"hours"`
CostPerHour float64 `json:"cost_per_hour"`
Total float64 `json:"total"`
}
type WorkOrderDetail struct {
WorkOrder
Items []*WOItem `json:"items"`
StaffHours []*WOStaffHours `json:"staff_hours"`
}
var allowedTransitions = map[string][]string{
"open": {"in_progress", "cancelled"},
"in_progress": {"completed", "cancelled"},
"completed": {"invoiced", "cancelled"},
"invoiced": {},
"cancelled": {},
}
func ValidateTransition(from, to string) error {
nexts, ok := allowedTransitions[from]
if !ok {
return errors.New("estado desconhecido")
}
for _, n := range nexts {
if n == to {
return nil
}
}
return errors.New("transição inválida")
}
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
FROM work_orders`
args := []any{}
if status != "" {
q += " WHERE status = $1"
args = append(args, status)
}
q += " ORDER BY created_at DESC"
rows, err := conn.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var list []*WorkOrder
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 {
return nil, err
}
list = append(list, &wo)
}
return list, rows.Err()
}
func CreateWorkOrder(ctx context.Context, conn *pgxpool.Conn, clientID, vehicleID, internalNotes, createdBy string) (*WorkOrder, error) {
var wo WorkOrder
err := conn.QueryRow(ctx, `
INSERT INTO work_orders (client_id, vehicle_id, internal_notes, created_by)
VALUES (
NULLIF($1,'')::uuid,
NULLIF($2,'')::uuid,
NULLIF($3,''),
(SELECT id FROM users WHERE id = NULLIF($4,'')::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).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &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) {
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
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).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
return &wo, err
}
func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, 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")
}
if err := ValidateTransition(fromStatus, toStatus); err != nil {
return nil, err
}
var wo WorkOrder
err := conn.QueryRow(ctx, `
UPDATE work_orders SET status=$2, 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).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
if err != nil {
return nil, err
}
_, _ = conn.Exec(ctx, `
INSERT INTO wo_status_log (work_order_id, from_status, to_status, changed_by)
VALUES ($1, $2, $3, (SELECT id FROM users WHERE id = NULLIF($4,'')::uuid LIMIT 1))`,
id, fromStatus, toStatus, changedBy)
return &wo, nil
}
func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*WorkOrderDetail, error) {
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
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)
if err != nil {
return nil, err
}
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
FROM wo_items WHERE work_order_id=$1`, id)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var i WOItem
if err := rows.Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description,
&i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total); err != nil {
return nil, err
}
detail.Items = append(detail.Items, &i)
}
if err := rows.Err(); err != nil {
return nil, err
}
shRows, err := conn.Query(ctx, `
SELECT id, work_order_id, staff_id, hours, cost_per_hour, total
FROM wo_staff_hours WHERE work_order_id=$1`, id)
if err != nil {
return nil, err
}
defer shRows.Close()
for shRows.Next() {
var sh WOStaffHours
if err := shRows.Scan(&sh.ID, &sh.WorkOrderID, &sh.StaffID, &sh.Hours, &sh.CostPerHour, &sh.Total); err != nil {
return nil, err
}
detail.StaffHours = append(detail.StaffHours, &sh)
}
return detail, shRows.Err()
}
func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, description 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)
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)
return err
}
func AddStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, staffID string, hours, costPerHour float64) (*WOStaffHours, error) {
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)
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)
return &sh, err
}
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)
return err
}
@@ -0,0 +1,90 @@
package workorder_test
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/internal/workorder"
)
func getTestConn(t *testing.T) *pgxpool.Conn {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
require.NoError(t, err)
t.Cleanup(func() { pool.Close() })
conn, err := pool.Acquire(context.Background())
require.NoError(t, err)
t.Cleanup(func() { conn.Release() })
_, err = conn.Exec(context.Background(), "SET search_path = tenant_test, public")
require.NoError(t, err)
return conn
}
func TestWorkOrderCRUD(t *testing.T) {
conn := getTestConn(t)
ctx := context.Background()
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
require.NoError(t, err)
assert.NotEmpty(t, wo.ID)
assert.Equal(t, "open", wo.Status)
list, err := workorder.ListWorkOrders(ctx, conn, "")
require.NoError(t, err)
assert.GreaterOrEqual(t, len(list), 1)
detail, err := workorder.GetWorkOrderDetail(ctx, conn, wo.ID)
require.NoError(t, err)
assert.Equal(t, wo.ID, detail.ID)
assert.Empty(t, detail.Items)
assert.Empty(t, detail.StaffHours)
}
func TestWorkOrderTransition(t *testing.T) {
conn := getTestConn(t)
ctx := context.Background()
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
require.NoError(t, err)
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "in_progress", "")
require.NoError(t, err)
assert.Equal(t, "in_progress", wo2.Status)
_, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "")
assert.Error(t, err, "invalid transition should error")
}
func TestAllowedTransitions(t *testing.T) {
tests := []struct {
from string
to string
valid bool
}{
{"open", "in_progress", true},
{"open", "cancelled", true},
{"open", "completed", false},
{"in_progress", "completed", true},
{"in_progress", "cancelled", true},
{"in_progress", "open", false},
{"completed", "invoiced", true},
{"completed", "cancelled", true},
{"invoiced", "cancelled", false},
}
for _, tt := range tests {
err := workorder.ValidateTransition(tt.from, tt.to)
if tt.valid {
assert.NoError(t, err, "%s->%s should be valid", tt.from, tt.to)
} else {
assert.Error(t, err, "%s->%s should be invalid", tt.from, tt.to)
}
}
}