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

225 lines
8.4 KiB
Go

package cartridge
import (
"context"
"errors"
"fmt"
"log"
"time"
"unicode/utf8"
"production/internal/auth"
"production/internal/inventory"
"production/internal/manufacture"
"production/internal/notification"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)
// UpdateItem changes one cartridge's own status/refill_count/price/note/tag —
// deliberately separate from batch status, since cartridges in the same
// batch finish independently.
//
// toner_grams_used is only meaningful together with a status->"done"
// transition, and only draws down stock when this item's
// cartridge_model_id has a linked production_recipes row (Фаза 19) — most
// cartridge models have none, and for those this is exactly the same
// status update it always was. Sent without a status change, it's stored
// but never triggers consumption — there's no well-defined "re-consume"
// semantics for editing a past refill's weight after the fact.
func (h *Handler) UpdateItem(c *fiber.Ctx) error {
itemID := c.Params("itemId")
var body struct {
Status *string `json:"status"`
RefillCount *int `json:"refill_count"`
Price *string `json:"price"`
Note *string `json:"note"`
Tag *string `json:"tag"`
GramsUsed *int `json:"toner_grams_used"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if body.Status != nil && !validItemStatuses[*body.Status] {
return c.Status(400).JSON(fiber.Map{"error": "status must be one of: pending, in_progress, done, replacement_needed"})
}
if body.RefillCount != nil && (*body.RefillCount < 0 || *body.RefillCount > 50) {
return c.Status(400).JSON(fiber.Map{"error": "refill_count must be between 0 and 50"})
}
if body.Note != nil && utf8.RuneCountInString(*body.Note) > maxLongFieldLen {
return c.Status(400).JSON(fiber.Map{"error": "note is too long"})
}
if body.Tag != nil && utf8.RuneCountInString(*body.Tag) > maxShortFieldLen {
return c.Status(400).JSON(fiber.Map{"error": "tag is too long"})
}
if body.Price != nil && len(*body.Price) > maxPriceLen {
return c.Status(400).JSON(fiber.Map{"error": "price is too long"})
}
if body.Price != nil && *body.Price == "" {
body.Price = nil
}
if body.GramsUsed != nil && *body.GramsUsed <= 0 {
return c.Status(400).JSON(fiber.Map{"error": "toner_grams_used must be positive"})
}
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)
markingDone := body.Status != nil && *body.Status == "done"
// consumedRecipe/consumedGrams survive past the transaction so the
// low-stock check below can run post-commit — see its comment for why.
var consumedRecipe *manufacture.RecipeForModel
var consumedGrams int
if markingDone && body.GramsUsed != nil {
var batchID string
var modelID *string
err := tx.QueryRow(ctx, `SELECT batch_id, cartridge_model_id FROM cartridge_items WHERE id = $1::uuid FOR UPDATE`, itemID).
Scan(&batchID, &modelID)
if err == pgx.ErrNoRows {
return c.Status(404).JSON(fiber.Map{"error": "item not found"})
}
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if modelID != nil {
recipe, err := manufacture.LookupByCartridgeModel(ctx, h.db, *modelID)
if err != nil && err != pgx.ErrNoRows {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if recipe != nil {
staffID, staffName := auth.StaffID(c), auth.StaffName(c)
if err := h.inv.ConsumeForCartridgeRefill(ctx, tx, recipe.FinishedPartID, *body.GramsUsed, batchID, staffID, staffName); err != nil {
var ce *inventory.ConsumeError
if errors.As(err, &ce) {
return c.Status(ce.Status).JSON(fiber.Map{"error": "тонер: " + ce.Msg})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
consumedRecipe = recipe
consumedGrams = *body.GramsUsed
}
}
}
tag, err := tx.Exec(ctx,
`UPDATE cartridge_items SET
status = COALESCE($1, status),
refill_count = COALESCE($2, refill_count),
price = COALESCE($3::numeric, price),
note = COALESCE($4, note),
tag = COALESCE($5, tag),
toner_grams_used = COALESCE($6, toner_grams_used)
WHERE id = $7::uuid`,
body.Status, body.RefillCount, body.Price, body.Note, body.Tag, body.GramsUsed, itemID)
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": "item not found"})
}
if err := tx.Commit(ctx); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if consumedRecipe != nil {
h.checkLowStockAfterConsume(ctx, consumedRecipe, consumedGrams, auth.StaffID(c), auth.StaffName(c))
}
return c.JSON(fiber.Map{"ok": true})
}
// checkLowStockAfterConsume runs after the refill's own transaction has
// committed — it reacts to what just happened rather than being part of it,
// so a failure here (auto-produce or notification) never rolls back or
// blocks a refill that already succeeded. consumedRecipe.AvailableQty was
// read before consumption; subtracting gramsUsed gives the new balance
// without a second query. Only fires the moment the balance crosses below
// min_stock (not on every refill while it stays low), same "just crossed"
// semantics as inventory's own low-stock alerting.
func (h *Handler) checkLowStockAfterConsume(ctx context.Context, recipe *manufacture.RecipeForModel, gramsUsed int, staffID, staffName string) {
if recipe.MinStock <= 0 {
return
}
newQty := recipe.AvailableQty - gramsUsed
justCrossed := recipe.AvailableQty >= recipe.MinStock && newQty < recipe.MinStock
if !justCrossed {
return
}
topUp := manufacture.TopUpQty(newQty, recipe.MinStock)
if recipe.TriggerMode == "auto" {
if _, _, err := h.mfg.ProduceByID(ctx, recipe.RecipeID, topUp, "Автопроизводство по порогу остатка", staffID, staffName); err != nil {
log.Printf("cartridge: auto-production for recipe %s failed: %v", recipe.RecipeID, err)
}
return
}
_, err := notification.Create(ctx, h.db, h.realtime, notification.CreateParams{
Type: "low_stock_production",
Title: fmt.Sprintf("Низкий остаток: %s", recipe.FinishedPartName),
Body: fmt.Sprintf("Осталось %d %s (минимум %d). Требуется произвести ещё %d.", newQty, recipe.FinishedPartUnit, recipe.MinStock, topUp),
ActionType: "confirm_production",
ActionPayload: map[string]any{
"recipe_id": recipe.RecipeID,
"qty": topUp,
},
})
if err != nil {
log.Printf("cartridge: low-stock notification for recipe %s failed: %v", recipe.RecipeID, err)
}
}
type suggestion struct {
RefillCount int `json:"refill_count"`
Tag *string `json:"tag"`
TagMatched bool `json:"tag_matched"`
LastBatchAt time.Time `json:"last_batch_at"`
}
// Suggest looks up this client's past cartridges of the same model/color as
// a hint for the intake form — never as ground truth. A tag match means the
// same physical cartridge (staff marks the housing on drop-off, since
// cartridges have no serial number); a model+color match with no tag could
// be a different physical unit from the same fleet, so it's returned but
// flagged tag_matched=false — see migrations/003_cartridges.sql for why
// refill_count itself is never auto-computed from this.
func (h *Handler) Suggest(c *fiber.Ctx) error {
clientID := c.Query("client_id")
model := c.Query("model")
color := c.Query("color")
tagQuery := c.Query("tag")
if clientID == "" || model == "" {
return c.Status(400).JSON(fiber.Map{"error": "client_id and model are required"})
}
if color == "" {
color = "black"
}
rows, err := h.db.Query(context.Background(),
`SELECT ci.refill_count, ci.tag, cb.created_at
FROM cartridge_items ci JOIN cartridge_batches cb ON cb.id = ci.batch_id
WHERE cb.client_id = $1::uuid AND ci.model = $2 AND ci.color = $3
ORDER BY cb.created_at DESC LIMIT 5`,
clientID, model, color)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
var out []suggestion
for rows.Next() {
var s suggestion
if err := rows.Scan(&s.RefillCount, &s.Tag, &s.LastBatchAt); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
s.TagMatched = tagQuery != "" && s.Tag != nil && *s.Tag == tagQuery
out = append(out, s)
}
return c.JSON(out)
}