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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user