225 lines
8.4 KiB
Go
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)
|
|
}
|