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
+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")
}