197 lines
7.3 KiB
Go
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,
|
|
})
|
|
}
|