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) }