Files
aura-crm/production/backend/internal/cartridge/recurring.go
T

197 lines
7.3 KiB
Go

// Recurring-item identity for cartridges that come back for service on a
// schedule (refills, sometimes a full restoration) — see
// migrations/059_cartridge_recurring_items.sql's doc comment for why this
// is its own table rather than the (client_id, model, color) grouping
// items.go's Suggest already surfaces as a soft hint. A QR label printed
// here and stuck on the physical cartridge is what makes "the same one"
// unambiguous across visits; scanning it back in re-links a brand new
// cartridge_items row to the same recurring_item_id and returns everything
// that's ever happened to it.
package cartridge
import (
"context"
"crypto/rand"
"errors"
"time"
"production/internal/auth"
"production/internal/dbutil"
qrcode "github.com/skip2/go-qrcode"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)
// codeAlphabet excludes visually-confusable characters (0/O, 1/I/L) since
// the code is also a manual-entry fallback for when a scan fails or the
// label got smudged — staff read it off the sticker and type it in.
const codeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
const codeLen = 8
func generateCode() (string, error) {
b := make([]byte, codeLen)
if _, err := rand.Read(b); err != nil {
return "", err
}
out := make([]byte, codeLen)
for i, v := range b {
out[i] = codeAlphabet[int(v)%len(codeAlphabet)]
}
return string(out), nil
}
// PrintLabel finds-or-creates the recurring_item for one already-saved
// cartridge item and returns its id + code for the frontend's print view.
// Idempotent on repeat clicks: if this item is already linked (a re-print,
// e.g. a lost label), the existing code comes back unchanged rather than
// minting a second identity for the same physical cartridge.
func (h *Handler) PrintLabel(c *fiber.Ctx) error {
itemID := c.Params("itemId")
ctx := context.Background()
var existing *string
var clientID, model, color string
var brandID, modelID *string
err := h.db.QueryRow(ctx,
`SELECT ci.recurring_item_id, cb.client_id, ci.model, ci.color, ci.cartridge_brand_id, ci.cartridge_model_id
FROM cartridge_items ci JOIN cartridge_batches cb ON cb.id = ci.batch_id
WHERE ci.id = $1::uuid`, itemID,
).Scan(&existing, &clientID, &model, &color, &brandID, &modelID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(404).JSON(fiber.Map{"error": "item not found"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if existing != nil {
var code string
if err := h.db.QueryRow(ctx, `SELECT code FROM cartridge_recurring_items WHERE id = $1::uuid`, *existing).Scan(&code); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(fiber.Map{"id": *existing, "code": code})
}
// Collision retry: vanishingly unlikely at 8 chars from a 32-symbol
// alphabet (32^8 ≈ 1.1e12 combinations), but a UNIQUE constraint means a
// hit would otherwise surface as an opaque 500 instead of just trying
// again with a fresh code.
var newID, newCode string
for attempt := 0; attempt < 5; attempt++ {
code, err := generateCode()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
err = h.db.QueryRow(ctx,
`INSERT INTO cartridge_recurring_items (client_id, cartridge_brand_id, cartridge_model_id, model, color, code, created_by_staff_id, created_by_staff_name)
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, $6, $7::uuid, $8) RETURNING id`,
clientID, dbutil.NullIfEmpty(strOrEmpty(brandID)), dbutil.NullIfEmpty(strOrEmpty(modelID)), model, color, code,
auth.StaffID(c), auth.StaffName(c),
).Scan(&newID)
if err == nil {
newCode = code
break
}
if !dbutil.IsUniqueViolation(err) {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
}
if newID == "" {
return c.Status(500).JSON(fiber.Map{"error": "could not generate a unique code"})
}
if _, err := h.db.Exec(ctx, `UPDATE cartridge_items SET recurring_item_id = $1::uuid WHERE id = $2::uuid`, newID, itemID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(fiber.Map{"id": newID, "code": newCode})
}
func strOrEmpty(s *string) string {
if s == nil {
return ""
}
return *s
}
// QRCode renders the recurring item's code as a PNG — plain text payload,
// not a URL, so it decodes identically whether read by the in-app camera
// scanner (web/src/cartridges/QrScan.jsx) or a USB keyboard-wedge scanner
// at the counter, which just "types" whatever the code encodes.
func (h *Handler) QRCode(c *fiber.Ctx) error {
id := c.Params("id")
var code string
if err := h.db.QueryRow(context.Background(), `SELECT code FROM cartridge_recurring_items WHERE id = $1::uuid`, id).Scan(&code); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(404).JSON(fiber.Map{"error": "not found"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
png, err := qrcode.Encode(code, qrcode.Medium, 320)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
c.Set("Content-Type", "image/png")
c.Set("Cache-Control", "private, max-age=31536000, immutable")
return c.Send(png)
}
type recurringHistoryEntry struct {
ItemID string `json:"item_id"`
RefillCount int `json:"refill_count"`
Status string `json:"status"`
Price *string `json:"price"`
Note *string `json:"note"`
OrderNumber *string `json:"order_number"`
CreatedAt time.Time `json:"created_at"`
}
// ScanByCode is the counter-side lookup: a scanned/typed code resolves to
// the client, model, color, and catalog ids to pre-fill a new batch's
// first item (see web/src/cartridges/BatchModal.jsx's CreateForm), plus
// every past visit for this exact physical cartridge so staff can see when
// it was last refilled or restored before deciding what it needs this time.
func (h *Handler) ScanByCode(c *fiber.Ctx) error {
code := c.Params("code")
ctx := context.Background()
var id, clientID, model, color string
var brandID, modelID *string
err := h.db.QueryRow(ctx,
`SELECT id, client_id, cartridge_brand_id, cartridge_model_id, model, color
FROM cartridge_recurring_items WHERE code = $1`, code,
).Scan(&id, &clientID, &brandID, &modelID, &model, &color)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(404).JSON(fiber.Map{"error": "code not recognized"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
rows, err := h.db.Query(ctx,
`SELECT ci.id, ci.refill_count, ci.status, ci.price::text, ci.note, cb.order_number, cb.created_at
FROM cartridge_items ci JOIN cartridge_batches cb ON cb.id = ci.batch_id
WHERE ci.recurring_item_id = $1::uuid
ORDER BY cb.created_at DESC`, id)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
history := []recurringHistoryEntry{}
for rows.Next() {
var entry recurringHistoryEntry
if err := rows.Scan(&entry.ItemID, &entry.RefillCount, &entry.Status, &entry.Price, &entry.Note, &entry.OrderNumber, &entry.CreatedAt); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
history = append(history, entry)
}
return c.JSON(fiber.Map{
"id": id, "client_id": clientID, "model": model, "color": color,
"cartridge_brand_id": brandID, "cartridge_model_id": modelID,
"history": history,
})
}