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

570 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package cartridge implements Phase 3 — заправка картриджей. A batch is one
// drop-off visit (often several cartridges at once, especially for B2B
// clients on a recurring schedule); each cartridge is its own item with its
// own status, since some finish before others. See migrations/003_cartridges.sql
// for why this is separate tables rather than reusing orders, and items.go's
// Suggest for why refill_count is staff-entered rather than computed.
package cartridge
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"unicode/utf8"
"production/internal/auth"
"production/internal/authz"
"production/internal/clientnotify"
"production/internal/dbutil"
"production/internal/file"
"production/internal/inventory"
"production/internal/loyalty"
"production/internal/manufacture"
"production/internal/notification"
"production/internal/notify"
"production/internal/ordernum"
"production/internal/realtime"
"production/internal/settings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var validBatchStatuses = map[string]bool{
"new": true, "in_progress": true, "ready": true, "completed": true, "cancelled": true,
}
// Mirrors web/src/cartridges/statuses.js's STATUS_LABELS (a separate enum
// from order's — batches and orders don't share a status lifecycle). Only
// used for notification text.
var batchStatusLabels = map[string]string{
"new": "Новая",
"in_progress": "В работе",
"ready": "Готово",
"completed": "Выдано",
"cancelled": "Отменено",
}
var validItemStatuses = map[string]bool{
"pending": true, "in_progress": true, "done": true, "replacement_needed": true,
}
const (
maxShortFieldLen = 255
maxLongFieldLen = 5000
// Caps the batch item count server-side — item fields each carry their
// own length cap, but an unbounded *count* of items multiplies that into
// the same MultiCell resource-exhaustion shape security-reviewer flagged
// for Phase 2 (see order/handler.go). 50 is generous for the stated
// real-world max of ~15 cartridges per visit.
maxItemsPerBatch = 50
// price is rendered via formatMoney (fixed-width), never MultiCell, so it
// doesn't carry the same DoS shape as the text fields above — this cap is
// just to reject garbage before it hits the ::numeric cast with a clean
// 400 instead of a bare 500.
maxPriceLen = 32
)
type Handler struct {
db *pgxpool.Pool
files *file.Handler
notify *notify.Handler
clientNotify *clientnotify.Handler
realtime *realtime.Hub
inv *inventory.Handler
mfg *manufacture.Handler
notifications *notification.Handler
}
func NewHandler(db *pgxpool.Pool, files *file.Handler, notify *notify.Handler, clientNotify *clientnotify.Handler, realtime *realtime.Hub, inv *inventory.Handler, mfg *manufacture.Handler, notifications *notification.Handler) *Handler {
return &Handler{db: db, files: files, notify: notify, clientNotify: clientNotify, realtime: realtime, inv: inv, mfg: mfg, notifications: notifications}
}
type itemInput struct {
Model string `json:"model"`
Color string `json:"color"`
Tag string `json:"tag"`
RefillCount int `json:"refill_count"`
Price *string `json:"price"`
Note string `json:"note"`
CartridgeBrandID string `json:"cartridge_brand_id"`
CartridgeModelID string `json:"cartridge_model_id"`
// Set when this item comes from a QR/code scan of a previously-labeled
// cartridge (see recurring.go) — chains the new item onto that same
// physical cartridge's history instead of starting a fresh one.
RecurringItemID string `json:"recurring_item_id"`
}
func (it itemInput) validate() string {
if it.Model == "" {
return "each item requires a model"
}
if utf8.RuneCountInString(it.Model) > maxShortFieldLen || utf8.RuneCountInString(it.Color) > maxShortFieldLen ||
utf8.RuneCountInString(it.Tag) > maxShortFieldLen {
return "one of the item fields is too long"
}
if utf8.RuneCountInString(it.Note) > maxLongFieldLen {
return "item note is too long"
}
if it.RefillCount < 0 || it.RefillCount > 50 {
return "refill_count must be between 0 and 50"
}
if it.Price != nil && len(*it.Price) > maxPriceLen {
return "price is too long"
}
return ""
}
func (h *Handler) Create(c *fiber.Ctx) error {
var body struct {
ClientID string `json:"client_id"`
PickupRequired bool `json:"pickup_required"`
Items []itemInput `json:"items"`
// Optional — lets the create form assign a master up front instead
// of requiring a separate UpdateBatch call right after (mirrors
// order.Create's own assigned_master_id/name pair).
AssignedMasterID string `json:"assigned_master_id"`
AssignedMasterName string `json:"assigned_master_name"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if body.ClientID == "" {
return c.Status(400).JSON(fiber.Map{"error": "client_id is required"})
}
if utf8.RuneCountInString(body.AssignedMasterName) > maxShortFieldLen {
return c.Status(400).JSON(fiber.Map{"error": "assigned_master_name is too long"})
}
if len(body.Items) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "at least one item is required"})
}
if len(body.Items) > maxItemsPerBatch {
return c.Status(400).JSON(fiber.Map{"error": "too many items in one batch"})
}
for _, it := range body.Items {
if msg := it.validate(); msg != "" {
return c.Status(400).JSON(fiber.Map{"error": msg})
}
}
ctx := context.Background()
tx, err := h.db.Begin(ctx)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer tx.Rollback(ctx)
orderNumber, err := ordernum.Next(ctx, h.db, ordernum.CartridgePrefix)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
var batchID, token string
err = tx.QueryRow(ctx,
`INSERT INTO cartridge_batches (client_id, pickup_required, created_by_staff_id, created_by_staff_name, order_number, assigned_master_id, assigned_master_name)
VALUES ($1::uuid, $2, $3::uuid, $4, $5, $6::uuid, $7) RETURNING id, tracking_token`,
body.ClientID, body.PickupRequired, auth.StaffID(c), auth.StaffName(c), orderNumber,
dbutil.NullIfEmpty(body.AssignedMasterID), dbutil.NullIfEmpty(body.AssignedMasterName),
).Scan(&batchID, &token)
if err != nil {
if dbutil.IsFKViolation(err) {
return c.Status(400).JSON(fiber.Map{"error": "client_id does not exist"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
for i, it := range body.Items {
color := it.Color
if color == "" {
color = "black"
}
price := it.Price
if price != nil && *price == "" {
price = nil
}
_, err = tx.Exec(ctx,
`INSERT INTO cartridge_items (batch_id, position, model, color, tag, refill_count, price, note, cartridge_brand_id, cartridge_model_id, recurring_item_id)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7::numeric, $8, $9::uuid, $10::uuid, $11::uuid)`,
batchID, i+1, it.Model, color, dbutil.NullIfEmpty(it.Tag), it.RefillCount, price, dbutil.NullIfEmpty(it.Note),
dbutil.NullIfEmpty(it.CartridgeBrandID), dbutil.NullIfEmpty(it.CartridgeModelID), dbutil.NullIfEmpty(it.RecurringItemID),
)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
}
if err := tx.Commit(ctx); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
h.logEvent(ctx, batchID, "status_change", "new", "", true, c)
models := make([]string, len(body.Items))
for i, it := range body.Items {
models[i] = it.Model
}
h.notify.Send(fmt.Sprintf("🆕 Новая партия картриджей (%d шт.): %s\nПринял: %s",
len(body.Items), strings.Join(models, ", "), auth.StaffName(c)))
itemsLabel := fmt.Sprintf("Партия картриджей (%d шт.): %s", len(body.Items), strings.Join(models, ", "))
createdTrackingURL := settings.TrackingURL(ctx, h.db, token)
h.clientNotify.Enqueue(clientnotify.Event{
ClientID: body.ClientID,
CartridgeBatchID: batchID,
Trigger: "created",
DedupeSeed: batchID,
TGBody: clientnotify.OrderCreated("telegram", itemsLabel, createdTrackingURL),
SMSBody: clientnotify.OrderCreated("sms", itemsLabel, createdTrackingURL),
})
h.realtime.Broadcast("cartridge_batches")
return c.Status(201).JSON(fiber.Map{"id": batchID, "tracking_token": token, "order_number": orderNumber})
}
type batchRow struct {
ID string `json:"id"`
ClientID string `json:"client_id"`
TrackingToken string `json:"tracking_token"`
OrderNumber *string `json:"order_number"`
Status string `json:"status"`
PickupRequired bool `json:"pickup_required"`
AssignedMasterID *string `json:"assigned_master_id"`
AssignedMasterName *string `json:"assigned_master_name"`
ItemCount int `json:"item_count"`
ItemsDone int `json:"items_done"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const batchListColumns = `
cb.id, cb.client_id, cb.tracking_token, cb.order_number, cb.status, cb.pickup_required,
cb.assigned_master_id, cb.assigned_master_name,
COUNT(ci.id), COUNT(ci.id) FILTER (WHERE ci.status = 'done'),
cb.created_at, cb.updated_at`
// List is the Kanban feed for /cartridges — one card per batch, with an
// items-done/total count so staff see progress without opening it.
func (h *Handler) List(c *fiber.Ctx) error {
status := c.Query("status")
clientID := c.Query("client_id")
query := `SELECT ` + batchListColumns + `
FROM cartridge_batches cb LEFT JOIN cartridge_items ci ON ci.batch_id = cb.id
WHERE ($1 = '' OR cb.status = $1) AND ($2 = '' OR cb.client_id::text = $2)`
args := []any{status, clientID}
// Same scoping as order.List — a master only ever sees their own
// batches and unassigned ones.
if !auth.HasPermission(c, "unscoped") {
query += fmt.Sprintf(" AND (cb.assigned_master_id IS NULL OR cb.assigned_master_id = $%d::uuid)", len(args)+1)
args = append(args, auth.StaffID(c))
}
query += ` GROUP BY cb.id ORDER BY cb.created_at DESC LIMIT 500`
rows, err := h.db.Query(context.Background(), query, args...)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
var out []batchRow
for rows.Next() {
var r batchRow
if err := rows.Scan(&r.ID, &r.ClientID, &r.TrackingToken, &r.OrderNumber, &r.Status, &r.PickupRequired,
&r.AssignedMasterID, &r.AssignedMasterName, &r.ItemCount, &r.ItemsDone, &r.CreatedAt, &r.UpdatedAt); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
out = append(out, r)
}
return c.JSON(out)
}
type itemRow struct {
ID string `json:"id"`
Position int `json:"position"`
Model string `json:"model"`
Color string `json:"color"`
Tag *string `json:"tag"`
RefillCount int `json:"refill_count"`
Status string `json:"status"`
Price *string `json:"price"`
Note *string `json:"note"`
CartridgeModelID *string `json:"cartridge_model_id"`
TonerGramsUsed *int `json:"toner_grams_used"`
RecurringItemID *string `json:"recurring_item_id"`
}
type eventRow struct {
ID string `json:"id"`
Type string `json:"type"`
Body *string `json:"body"`
FileKey *string `json:"file_key"`
IsPublic bool `json:"is_public"`
StaffName string `json:"staff_name"`
CreatedAt time.Time `json:"created_at"`
}
func (h *Handler) Get(c *fiber.Ctx) error {
id := c.Params("id")
ctx := context.Background()
var r batchRow
err := h.db.QueryRow(ctx,
`SELECT `+batchListColumns+`
FROM cartridge_batches cb LEFT JOIN cartridge_items ci ON ci.batch_id = cb.id
WHERE cb.id = $1::uuid
GROUP BY cb.id`, id,
).Scan(&r.ID, &r.ClientID, &r.TrackingToken, &r.OrderNumber, &r.Status, &r.PickupRequired,
&r.AssignedMasterID, &r.AssignedMasterName, &r.ItemCount, &r.ItemsDone, &r.CreatedAt, &r.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(404).JSON(fiber.Map{"error": "batch not found"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if !authz.CanAccessAssigned(auth.StaffPermissions(c), r.AssignedMasterID, auth.StaffID(c)) {
return c.Status(403).JSON(fiber.Map{"error": "not assigned to you"})
}
items, err := h.items(ctx, id)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
events, err := h.events(ctx, id, false)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(fiber.Map{"batch": r, "items": items, "events": events})
}
func (h *Handler) items(ctx context.Context, batchID string) ([]itemRow, error) {
rows, err := h.db.Query(ctx,
`SELECT id, position, model, color, tag, refill_count, status, price::text, note, cartridge_model_id, toner_grams_used, recurring_item_id
FROM cartridge_items WHERE batch_id = $1::uuid ORDER BY position`, batchID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []itemRow
for rows.Next() {
var it itemRow
if err := rows.Scan(&it.ID, &it.Position, &it.Model, &it.Color, &it.Tag, &it.RefillCount, &it.Status, &it.Price, &it.Note,
&it.CartridgeModelID, &it.TonerGramsUsed, &it.RecurringItemID); err != nil {
return nil, err
}
out = append(out, it)
}
return out, nil
}
func (h *Handler) events(ctx context.Context, batchID string, publicOnly bool) ([]eventRow, error) {
query := `SELECT id, type, body, file_key, is_public, staff_name, created_at FROM batch_events WHERE batch_id = $1::uuid`
if publicOnly {
query += ` AND is_public = true`
}
query += ` ORDER BY created_at`
rows, err := h.db.Query(ctx, query, batchID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []eventRow
for rows.Next() {
var e eventRow
if err := rows.Scan(&e.ID, &e.Type, &e.Body, &e.FileKey, &e.IsPublic, &e.StaffName, &e.CreatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}
func (h *Handler) UpdateStatus(c *fiber.Ctx) error {
id := c.Params("id")
var body struct {
Status string `json:"status"`
}
if err := c.BodyParser(&body); err != nil || !validBatchStatuses[body.Status] {
return c.Status(400).JSON(fiber.Map{"error": "status must be one of: new, in_progress, ready, completed, cancelled"})
}
if err := h.assertBatchAccess(context.Background(), id, c); err != nil {
return err
}
ctx := context.Background()
var models *string
var clientID, trackingToken, itemsTotal string
err := h.db.QueryRow(ctx,
`UPDATE cartridge_batches SET status = $1, updated_at = NOW() WHERE id = $2::uuid
RETURNING (SELECT string_agg(model, ', ') FROM cartridge_items WHERE batch_id = $2::uuid), client_id, tracking_token,
(SELECT COALESCE(SUM(price), 0) FROM cartridge_items WHERE batch_id = $2::uuid)::text`,
body.Status, id,
).Scan(&models, &clientID, &trackingToken, &itemsTotal)
if err != nil {
if err == pgx.ErrNoRows {
return c.Status(404).JSON(fiber.Map{"error": "batch not found"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
eventID := h.logEvent(ctx, id, "status_change", body.Status, "", true, c)
label := batchStatusLabels[body.Status]
if label == "" {
label = body.Status
}
itemsLabel := fmt.Sprintf("Партия картриджей (%s)", ptrToStr(models))
h.notify.Send(fmt.Sprintf("🔄 %s — статус: %s", itemsLabel, label))
if body.Status == "completed" {
loyalty.AccrueFromPrice(ctx, h.db, h.clientNotify, clientID, "", id, itemsTotal, auth.StaffID(c), auth.StaffName(c))
}
if eventID != "" {
trigger := "status_changed"
trackingURL := settings.TrackingURL(ctx, h.db, trackingToken)
tgBody := clientnotify.StatusChanged("telegram", itemsLabel, label, trackingURL)
smsBody := clientnotify.StatusChanged("sms", itemsLabel, label, trackingURL)
if body.Status == "ready" {
trigger = "ready"
tgBody = clientnotify.Ready("telegram", itemsLabel, trackingURL)
smsBody = clientnotify.Ready("sms", itemsLabel, trackingURL)
}
h.clientNotify.Enqueue(clientnotify.Event{
ClientID: clientID,
CartridgeBatchID: id,
Trigger: trigger,
DedupeSeed: eventID,
TGBody: tgBody,
SMSBody: smsBody,
})
}
h.realtime.Broadcast("cartridge_batches")
return c.JSON(fiber.Map{"ok": true})
}
func ptrToStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// UpdateBatch handles master assignment — no timeline event, matches
// order.Update's rationale (bookkeeping, not a milestone).
func (h *Handler) UpdateBatch(c *fiber.Ctx) error {
id := c.Params("id")
var body struct {
AssignedMasterID *string `json:"assigned_master_id"`
AssignedMasterName *string `json:"assigned_master_name"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if body.AssignedMasterName != nil && utf8.RuneCountInString(*body.AssignedMasterName) > maxShortFieldLen {
return c.Status(400).JSON(fiber.Map{"error": "assigned_master_name is too long"})
}
if err := h.assertBatchAccess(context.Background(), id, c); err != nil {
return err
}
tag, err := h.db.Exec(context.Background(),
`UPDATE cartridge_batches SET
assigned_master_id = COALESCE($1::uuid, assigned_master_id),
assigned_master_name = COALESCE($2, assigned_master_name),
updated_at = NOW()
WHERE id = $3::uuid`,
body.AssignedMasterID, body.AssignedMasterName, id)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if tag.RowsAffected() == 0 {
return c.Status(404).JSON(fiber.Map{"error": "batch not found"})
}
h.realtime.Broadcast("cartridge_batches")
return c.JSON(fiber.Map{"ok": true})
}
func (h *Handler) AddComment(c *fiber.Ctx) error {
id := c.Params("id")
var body struct {
Body string `json:"body"`
IsPublic bool `json:"is_public"`
}
if err := c.BodyParser(&body); err != nil || body.Body == "" {
return c.Status(400).JSON(fiber.Map{"error": "body is required"})
}
if utf8.RuneCountInString(body.Body) > maxLongFieldLen {
return c.Status(400).JSON(fiber.Map{"error": "comment is too long"})
}
if err := h.assertBatchAccess(context.Background(), id, c); err != nil {
return err
}
h.logEvent(context.Background(), id, "comment", body.Body, "", body.IsPublic, c)
return c.Status(201).JSON(fiber.Map{"ok": true})
}
func (h *Handler) AddPhoto(c *fiber.Ctx) error {
id := c.Params("id")
if err := h.assertBatchAccess(context.Background(), id, c); err != nil {
return err
}
fh, err := c.FormFile("file")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "file required"})
}
if fh.Size > 20*1024*1024 {
return c.Status(413).JSON(fiber.Map{"error": "file too large (max 20MB)"})
}
f, err := fh.Open()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer f.Close()
key, err := h.files.Put(context.Background(), fh.Filename, fh.Size, f)
if err != nil {
return c.Status(415).JSON(fiber.Map{"error": err.Error()})
}
// Opt-in, matching AddComment's own default below — an omitted field
// (missing frontend param, stray manual request) must never make a
// photo public by accident.
isPublic := c.FormValue("is_public") == "true"
h.logEvent(context.Background(), id, "photo", "", key, isPublic, c)
return c.Status(201).JSON(fiber.Map{"key": key})
}
// assertBatchAccess 404s if the batch doesn't exist and 403s if the calling
// staff member (anyone lacking the "unscoped" permission — see
// authz.CanAccessAssigned) isn't allowed to touch it.
func (h *Handler) assertBatchAccess(ctx context.Context, id string, c *fiber.Ctx) error {
return authz.CheckBatchAccess(ctx, h.db, id, auth.StaffPermissions(c), auth.StaffID(c))
}
// logEvent returns the new batch_events row's id (empty on failure) — see
// order.Handler.logEvent's doc comment for why UpdateStatus needs it.
func (h *Handler) logEvent(ctx context.Context, batchID, eventType, body, fileKey string, isPublic bool, c *fiber.Ctx) string {
var id string
if err := h.db.QueryRow(ctx,
`INSERT INTO batch_events (batch_id, type, body, file_key, is_public, staff_id, staff_name)
VALUES ($1::uuid, $2, $3, $4, $5, $6::uuid, $7) RETURNING id`,
batchID, eventType, dbutil.NullIfEmpty(body), dbutil.NullIfEmpty(fileKey), isPublic, auth.StaffID(c), auth.StaffName(c),
).Scan(&id); err != nil {
log.Printf("cartridge: logEvent batch=%s type=%s: %v", batchID, eventType, err)
}
return id
}