diff --git a/core/api/fragments/dashboard.yaml b/core/api/fragments/dashboard.yaml index 3a0a7d891..813a151d3 100644 --- a/core/api/fragments/dashboard.yaml +++ b/core/api/fragments/dashboard.yaml @@ -40,8 +40,13 @@ paths: summary: Products at or below their reorder point description: >- Roles admin, owner, finance, branch scoped. At most 10 rows, lowest - stock first. A product with no inventory row counts as zero stock. A - reorder point of NULL counts as 10. + stock first, one row per product and yard. A yard's stock is its own + row plus every zone and bin under it, and that sum is what is + compared with the reorder point and reported. A product with no + inventory row counts as zero stock (an administrator with no branch + header sees that; a caller bound to a yard sees only products that + yard holds a row of, so a product held only at another yard gives no + alert here). A reorder point of NULL counts as 10. parameters: - $ref: "#/components/parameters/XBranchId" responses: @@ -181,7 +186,10 @@ components: location_id: type: string format: uuid - description: Absent when the product has no inventory row. + description: The yard (the branch) the stock sum is for. Absent when the product has no inventory row. + yard_name: + type: string + description: The yard's name (its code when it has none). Absent with no yard. DashboardTopCustomer: type: object description: dashboard.TopCustomer. Money is int64 cents. diff --git a/core/api/fragments/inventory.yaml b/core/api/fragments/inventory.yaml index 2507996dc..977dbe395 100644 --- a/core/api/fragments/inventory.yaml +++ b/core/api/fragments/inventory.yaml @@ -73,7 +73,14 @@ paths: tags: - inventory summary: Adjust inventory (cycle count) - description: A result that would leave negative stock answers 500. + description: >- + A result that would leave negative stock answers 422 insufficient_stock. + An adjustment that names no location (a count or a delta) answers 422 + location_required where the caller has no branch context; an absolute + count (is_delta false) that names none answers it too where the + product is held in more than one location in the caller's reach: a + count is for one location. An absolute count below the figure already + allocated to orders at that location answers 422 below_allocated. parameters: - $ref: '#/components/parameters/XBranchId' - $ref: '#/components/parameters/IdempotencyKey' diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index d889a3c88..e161de082 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -3117,7 +3117,7 @@ paths: tags: [dashboard] summary: Products at or below their reorder point description: >- - Roles admin, owner, finance, branch scoped. At most 10 rows, lowest stock first. A product with no inventory row counts as zero stock. A reorder point of NULL counts as 10. + Roles admin, owner, finance, branch scoped. At most 10 rows, lowest stock first, one row per product and yard. A yard's stock is its own row plus every zone and bin under it, and that sum is what is compared with the reorder point and reported. A product with no inventory row counts as zero stock (an administrator with no branch header sees that; a caller bound to a yard sees only products that yard holds a row of, so a product held only at another yard gives no alert here). A reorder point of NULL counts as 10. parameters: - $ref: "#/components/parameters/XBranchId" responses: @@ -6787,7 +6787,8 @@ paths: tags: - inventory summary: Adjust inventory (cycle count) - description: A result that would leave negative stock answers 500. + description: >- + A result that would leave negative stock answers 422 insufficient_stock. An adjustment that names no location (a count or a delta) answers 422 location_required where the caller has no branch context; an absolute count (is_delta false) that names none answers it too where the product is held in more than one location in the caller's reach: a count is for one location. An absolute count below the figure already allocated to orders at that location answers 422 below_allocated. parameters: - $ref: '#/components/parameters/XBranchId' - $ref: '#/components/parameters/IdempotencyKey' @@ -18233,7 +18234,10 @@ components: location_id: type: string format: uuid - description: Absent when the product has no inventory row. + description: The yard (the branch) the stock sum is for. Absent when the product has no inventory row. + yard_name: + type: string + description: The yard's name (its code when it has none). Absent with no yard. DashboardTopCustomer: type: object description: dashboard.TopCustomer. Money is int64 cents. diff --git a/core/internal/app/serve/wire_noheader_round2_test.go b/core/internal/app/serve/wire_noheader_round2_test.go index 1a3a10463..858c989d5 100644 --- a/core/internal/app/serve/wire_noheader_round2_test.go +++ b/core/internal/app/serve/wire_noheader_round2_test.go @@ -597,8 +597,10 @@ func noHeaderDashboardLeg(t *testing.T) { if trendTotal != 500 { t.Errorf("no header revenue trend sums %d cents, want A's 500", trendTotal) } - // The inventory alerts arm: A's yard row only, through the stocking - // location's branch. + // The inventory alerts arm: A's yard only, through the stocking + // location's branch; an alert is one row per product and yard, and its + // location is the yard (the branch), the sum of the yard's own row and + // every zone and bin under it (item 130, N20). status, buf = f.userAs(t, "finance", "GET", "/api/v1/dashboard/inventory-alerts", "", nil) if status != http.StatusOK { t.Fatalf("no header inventory alerts: %d %s", status, buf) @@ -615,7 +617,7 @@ func noHeaderDashboardLeg(t *testing.T) { for _, a := range alerts { if a.ProductID == f.posProd.String() { rows++ - if a.LocationID != f.yardA.String() { + if a.LocationID != f.branchA.String() { t.Errorf("no header inventory alert for the product sits at %s, want A's yard", a.LocationID) } } diff --git a/core/internal/characterization/scenarios_r1b_c_test.go b/core/internal/characterization/scenarios_r1b_c_test.go index 3238c7c21..b002c5792 100644 --- a/core/internal/characterization/scenarios_r1b_c_test.go +++ b/core/internal/characterization/scenarios_r1b_c_test.go @@ -502,11 +502,11 @@ func r1bCPOSGroups() []groupDef { name: "pos.till.current.c", method: "GET", path: "/api/v1/pos/till/current?register_id=REG-01", extract: map[string]string{"c_till": "/id"}, }, - // Sales deduct stock at the unassigned location, so seed some there. + // Seed the sales stock at the yard: an adjustment names its location. {name: "pos.setup.stock.product", method: "POST", path: "/api/v1/inventory/adjust", - body: map[string]any{"product_id": "{product}", "quantity": 50, "reason": "r1b c pos setup", "is_delta": true}}, + body: map[string]any{"product_id": "{product}", "location_id": "{branch}", "quantity": 50, "reason": "r1b c pos setup", "is_delta": true}}, {name: "pos.setup.stock.sheet", method: "POST", path: "/api/v1/inventory/adjust", - body: map[string]any{"product_id": "{productSheet}", "quantity": 10, "reason": "r1b c pos setup", "is_delta": true}}, + body: map[string]any{"product_id": "{productSheet}", "location_id": "{branch}", "quantity": 10, "reason": "r1b c pos setup", "is_delta": true}}, {name: "pos.till.open.c_duplicate", method: "POST", path: "/api/v1/pos/till/open", body: map[string]any{"register_id": "REG-01", "opening_float_cents": 5000}}, {name: "pos.till.open.bad_body", method: "POST", path: "/api/v1/pos/till/open", body: "not an object"}, diff --git a/core/internal/characterization/scenarios_sales_test.go b/core/internal/characterization/scenarios_sales_test.go index 148c1451d..77a4ecd22 100644 --- a/core/internal/characterization/scenarios_sales_test.go +++ b/core/internal/characterization/scenarios_sales_test.go @@ -859,7 +859,7 @@ func inventoryGroups() []groupDef { method: "POST", path: "/api/v1/inventory/adjust", body: map[string]any{ - "product_id": "{productSheet}", "quantity": 25, + "product_id": "{productSheet}", "location_id": "{branch}", "quantity": 25, "reason": "golden characterisation", "is_delta": true, }, }, diff --git a/core/internal/characterization/testdata/goldens/dashboard.json b/core/internal/characterization/testdata/goldens/dashboard.json index 3471cd3c6..8512fe910 100644 --- a/core/internal/characterization/testdata/goldens/dashboard.json +++ b/core/internal/characterization/testdata/goldens/dashboard.json @@ -24,7 +24,8 @@ "name": "Flashing J 6 X 6 X 10", "product_id": "", "reorder_qty": 20, - "sku": "CORN2006" + "sku": "CORN2006", + "yard_name": "Lake Country Outpost" }, { "alert_type": "LOW_STOCK", @@ -33,7 +34,8 @@ "name": "Sheathing 1/8 X 4 X 9 (Green) NSP Weather Barrier", "product_id": "", "reorder_qty": 40, - "sku": "CORNSHTGR49" + "sku": "CORNSHTGR49", + "yard_name": "Lake Country Outpost" }, { "alert_type": "LOW_STOCK", @@ -42,7 +44,8 @@ "name": "Face Mount Joist Hanger 2x8", "product_id": "", "reorder_qty": 150, - "sku": "HANGER-28FM" + "sku": "HANGER-28FM", + "yard_name": "Lake Country Outpost" }, { "alert_type": "LOW_STOCK", @@ -51,7 +54,8 @@ "name": "1x6x6 Cedar Fence Board", "product_id": "", "reorder_qty": 200, - "sku": "LUM-166-WRC" + "sku": "LUM-166-WRC", + "yard_name": "Lake Country Outpost" }, { "alert_type": "LOW_STOCK", @@ -60,7 +64,8 @@ "name": "2x6x12 SPF Premium", "product_id": "", "reorder_qty": 100, - "sku": "LUM-2612-PREM" + "sku": "LUM-2612-PREM", + "yard_name": "Lake Country Outpost" }, { "alert_type": "LOW_STOCK", @@ -69,7 +74,8 @@ "name": "Roof Edge Drip 10'", "product_id": "", "reorder_qty": 60, - "sku": "RF-DRIP-10" + "sku": "RF-DRIP-10", + "yard_name": "Lake Country Outpost" }, { "alert_type": "LOW_STOCK", @@ -78,7 +84,8 @@ "name": "Vented Soffit Fibre Cement Textured 1/4 X 24 X 8", "product_id": "", "reorder_qty": 20, - "sku": "CORNSFT2408V" + "sku": "CORNSFT2408V", + "yard_name": "Lake Country Outpost" }, { "alert_type": "LOW_STOCK", @@ -87,7 +94,8 @@ "name": "2x4x8 SPF Premium", "product_id": "", "reorder_qty": 200, - "sku": "LUM-248-PREM" + "sku": "LUM-248-PREM", + "yard_name": "Lake Country Outpost" }, { "alert_type": "LOW_STOCK", @@ -96,7 +104,8 @@ "name": "Ice and Water Barrier 65'", "product_id": "", "reorder_qty": 20, - "sku": "RF-ICE-65" + "sku": "RF-ICE-65", + "yard_name": "Lake Country Outpost" } ] } diff --git a/core/internal/characterization/testdata/goldens/inventory.json b/core/internal/characterization/testdata/goldens/inventory.json index 42c5ad56e..5f6892789 100644 --- a/core/internal/characterization/testdata/goldens/inventory.json +++ b/core/internal/characterization/testdata/goldens/inventory.json @@ -22,7 +22,8 @@ "path": "/api/v1/inventory/adjust", "body": { "is_delta": true, - "product_id": "", + "location_id": "", + "product_id": "", "quantity": 25, "reason": "golden characterisation" } @@ -46,17 +47,6 @@ "content_type": "application/json", "body": { "items": [ - { - "allocated": "0", - "available": "25", - "id": "", - "location_id": null, - "location_name": "", - "product_id": "", - "quantity": "25", - "uom": "PCS", - "updated_at": "" - }, { "allocated": "0", "available": "67", @@ -67,11 +57,22 @@ "quantity": "67", "uom": "LF", "updated_at": "" + }, + { + "allocated": "0", + "available": "51", + "id": "", + "location_id": "", + "location_name": "WK", + "product_id": "", + "quantity": "51", + "uom": "LF", + "updated_at": "" } ], "limit": 2, "next_cursor": "", - "total": 193 + "total": 192 } } } diff --git a/core/internal/characterization/testdata/goldens/pos_transactions.json b/core/internal/characterization/testdata/goldens/pos_transactions.json index 4c4d24d82..bf785fd32 100644 --- a/core/internal/characterization/testdata/goldens/pos_transactions.json +++ b/core/internal/characterization/testdata/goldens/pos_transactions.json @@ -32,6 +32,7 @@ "path": "/api/v1/inventory/adjust", "body": { "is_delta": true, + "location_id": "", "product_id": "", "quantity": 50, "reason": "r1b c pos setup" @@ -52,6 +53,7 @@ "path": "/api/v1/inventory/adjust", "body": { "is_delta": true, + "location_id": "", "product_id": "", "quantity": 10, "reason": "r1b c pos setup" diff --git a/core/internal/dashboard/inventory_alerts_pg_test.go b/core/internal/dashboard/inventory_alerts_pg_test.go new file mode 100644 index 000000000..9962457b4 --- /dev/null +++ b/core/internal/dashboard/inventory_alerts_pg_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package dashboard + +import ( + "context" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// CORRECTNESS (item 130, N20): an inventory row sits at one location, the yard +// itself or a zone or bin under it, so a yard's stock is the sum of its own row +// and every location whose branch_id is that yard. The alert compares that sum +// with the reorder point and reports it. One yard holds 112 on its own row and +// 300 in a zone (412 in all) against a reorder point of 150: no alert. Cut the +// zone to 20 (132) and the alert reports 132, not 112. +// +// Skips cleanly when Postgres is unreachable (testutil.RequireDB). +func TestGetInventoryAlerts_SumsTheYardsOwnRowAndItsZones(t *testing.T) { + db := testutil.RequireDB(t) + repo := NewRepository(db) + ctx := context.Background() + + yard, zone, otherYard, product := uuid.New(), uuid.New(), uuid.New(), uuid.New() + exec := func(q string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(ctx, q, args...); err != nil { + t.Fatalf("seed: %v\n%s", err, q) + } + } + exec(`INSERT INTO locations (id, type, code, branch_id) VALUES ($1, 'BRANCH', $2, $1)`, yard, "IA-"+yard.String()[:8]) + exec(`INSERT INTO locations (id, type, code, parent_id, branch_id) VALUES ($1, 'ZONE', $2, $3, $3)`, zone, "IZ-"+zone.String()[:8], yard) + exec(`INSERT INTO locations (id, type, code, branch_id) VALUES ($1, 'BRANCH', $2, $1)`, otherYard, "IB-"+otherYard.String()[:8]) + sku := "ALERT-" + product.String()[:8] + exec(`INSERT INTO products (id, sku, description, uom_primary, base_price, reorder_point) VALUES ($1, $2, 'Alert test hanger', 'EA', 4.00, 150)`, product, sku) + t.Cleanup(func() { + bg := context.Background() + _, _ = db.Pool.Exec(bg, `DELETE FROM inventory WHERE product_id = $1`, product) + _, _ = db.Pool.Exec(bg, `DELETE FROM products WHERE id = $1`, product) + _, _ = db.Pool.Exec(bg, `DELETE FROM locations WHERE id = $1`, zone) + for _, y := range []uuid.UUID{yard, otherYard} { + _, _ = db.Pool.Exec(bg, `DELETE FROM locations WHERE id = $1`, y) + } + }) + exec(`INSERT INTO inventory (product_id, location_id, location, quantity, allocated) VALUES ($1, $2, 'yard', 112, 0)`, product, yard) + exec(`INSERT INTO inventory (product_id, location_id, location, quantity, allocated) VALUES ($1, $2, 'zone', 300, 0)`, product, zone) + + alertFor := func(branch uuid.UUID) *InventoryAlert { + t.Helper() + alerts, err := repo.GetInventoryAlerts(ctx, &branch, nil, 10000) + if err != nil { + t.Fatalf("alerts: %v", err) + } + for i := range alerts { + if alerts[i].SKU == sku { + return &alerts[i] + } + } + return nil + } + + if a := alertFor(yard); a != nil { + t.Fatalf("yard holds 412 against a reorder point of 150, want no alert, got %+v", *a) + } + + exec(`UPDATE inventory SET quantity = 20 WHERE product_id = $1 AND location_id = $2`, product, zone) + a := alertFor(yard) + if a == nil { + t.Fatal("yard holds 132 against a reorder point of 150, want a low stock alert") + } + if a.CurrentQty != 132 || a.AlertType != "LOW_STOCK" { + t.Fatalf("alert = %+v, want current 132 and LOW_STOCK", *a) + } + if a.LocationID != yard.String() { + t.Fatalf("alert location = %q, want the yard %s", a.LocationID, yard) + } +} diff --git a/core/internal/dashboard/inventory_alerts_yards_pg_test.go b/core/internal/dashboard/inventory_alerts_yards_pg_test.go new file mode 100644 index 000000000..e0e140f6d --- /dev/null +++ b/core/internal/dashboard/inventory_alerts_yards_pg_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package dashboard + +import ( + "context" + "encoding/json" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// alertsWorld seeds two yards (the first with a zone under it) and a product +// with a reorder point of 150, and returns what a leg needs. +type alertsWorld struct { + yardA, zoneA, yardB, product uuid.UUID + sku string + repo *PostgresRepository +} + +func newAlertsWorld(t *testing.T) *alertsWorld { + t.Helper() + db := testutil.RequireDB(t) + ctx := context.Background() + w := &alertsWorld{yardA: uuid.New(), zoneA: uuid.New(), yardB: uuid.New(), product: uuid.New(), repo: NewRepository(db)} + exec := func(q string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(ctx, q, args...); err != nil { + t.Fatalf("seed: %v\n%s", err, q) + } + } + exec(`INSERT INTO locations (id, type, code, name, branch_id) VALUES ($1, 'BRANCH', $2, 'Alerts Yard A', $1)`, w.yardA, "AYA-"+w.yardA.String()[:8]) + exec(`INSERT INTO locations (id, type, code, parent_id, branch_id) VALUES ($1, 'ZONE', $2, $3, $3)`, w.zoneA, "AZA-"+w.zoneA.String()[:8], w.yardA) + exec(`INSERT INTO locations (id, type, code, name, branch_id) VALUES ($1, 'BRANCH', $2, 'Alerts Yard B', $1)`, w.yardB, "AYB-"+w.yardB.String()[:8]) + w.sku = "ALY-" + w.product.String()[:8] + exec(`INSERT INTO products (id, sku, description, uom_primary, base_price, reorder_point) VALUES ($1, $2, 'Alert yards hanger', 'EA', 4.00, 150)`, w.product, w.sku) + t.Cleanup(func() { + bg := context.Background() + _, _ = db.Pool.Exec(bg, `DELETE FROM inventory WHERE product_id = $1`, w.product) + _, _ = db.Pool.Exec(bg, `DELETE FROM products WHERE id = $1`, w.product) + _, _ = db.Pool.Exec(bg, `DELETE FROM locations WHERE id = $1`, w.zoneA) + _, _ = db.Pool.Exec(bg, `DELETE FROM locations WHERE id IN ($1, $2)`, w.yardA, w.yardB) + }) + return w +} + +func (w *alertsWorld) stock(t *testing.T, loc *uuid.UUID, qty int) { + t.Helper() + db := testutil.RequireDB(t) + if _, err := db.Pool.Exec(context.Background(), + `INSERT INTO inventory (product_id, location_id, location, quantity, allocated) VALUES ($1, $2, 'row', $3, 0)`, + w.product, loc, qty); err != nil { + t.Fatalf("stock: %v", err) + } +} + +// alerts reads every alert for the SKU as the unwalled caller (an +// administrator with no branch header). +func (w *alertsWorld) alerts(t *testing.T) []InventoryAlert { + t.Helper() + all, err := w.repo.GetInventoryAlerts(context.Background(), nil, nil, 100000) + if err != nil { + t.Fatalf("alerts: %v", err) + } + var out []InventoryAlert + for _, a := range all { + if a.SKU == w.sku { + out = append(out, a) + } + } + return out +} + +// Two yards low on one product read as two rows for an unwalled caller, each +// the yard's own sum with the yard as its location. +func TestGetInventoryAlerts_KeepsYardsApartForAnUnwalledCaller(t *testing.T) { + w := newAlertsWorld(t) + w.stock(t, &w.yardA, 100) + w.stock(t, &w.zoneA, 32) // yard A: 132 in all + w.stock(t, &w.yardB, 40) + + got := map[string]int{} + for _, a := range w.alerts(t) { + got[a.LocationID] = a.CurrentQty + } + if len(got) != 2 || got[w.yardA.String()] != 132 || got[w.yardB.String()] != 40 { + t.Fatalf("alerts by yard = %v, want yard A 132 and yard B 40", got) + } +} + +// A legacy row with no location stays its own alert beside a yard's. +func TestGetInventoryAlerts_ALegacyRowStaysItsOwnAlert(t *testing.T) { + w := newAlertsWorld(t) + w.stock(t, nil, 5) + w.stock(t, &w.yardA, 20) + + got := map[string]int{} + for _, a := range w.alerts(t) { + got[a.LocationID] = a.CurrentQty + } + if len(got) != 2 || got[""] != 5 || got[w.yardA.String()] != 20 { + t.Fatalf("alerts = %v, want the legacy row 5 (no yard) and yard A 20", got) + } +} + +// Each alert names its yard, so two rows for one SKU can be told apart on the +// dashboard. +func TestGetInventoryAlerts_NamesTheYard(t *testing.T) { + w := newAlertsWorld(t) + w.stock(t, &w.yardA, 20) + w.stock(t, &w.yardB, 30) + + names := map[string]string{} + for _, a := range w.alerts(t) { + raw, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + var m map[string]any + _ = json.Unmarshal(raw, &m) + name, _ := m["yard_name"].(string) + names[a.LocationID] = name + } + if names[w.yardA.String()] != "Alerts Yard A" || names[w.yardB.String()] != "Alerts Yard B" { + t.Fatalf("yard names = %v, want each alert to carry its yard's name", names) + } +} diff --git a/core/internal/dashboard/model.go b/core/internal/dashboard/model.go index 11165c50a..086aea315 100644 --- a/core/internal/dashboard/model.go +++ b/core/internal/dashboard/model.go @@ -24,6 +24,7 @@ type InventoryAlert struct { ReorderQty int `json:"reorder_qty"` AlertType string `json:"alert_type"` // "LOW_STOCK" or "OUT_OF_STOCK" LocationID string `json:"location_id,omitempty"` + YardName string `json:"yard_name,omitempty"` // the yard's name, so two rows for one SKU can be told apart } // TopCustomer represents a customer ranked by revenue. diff --git a/core/internal/dashboard/repository.go b/core/internal/dashboard/repository.go index 64ceaf79e..f53c32a24 100644 --- a/core/internal/dashboard/repository.go +++ b/core/internal/dashboard/repository.go @@ -164,25 +164,33 @@ func (r *PostgresRepository) GetDashboardSummary(ctx context.Context, branchID * return summary, nil } -// GetInventoryAlerts returns products with low or zero stock, walled to the -// caller's reach through the stocking location's branch. +// GetInventoryAlerts returns products with low or zero stock, one row per +// product and yard, walled to the caller's reach through the stocking +// location's branch. A yard's stock is its own row plus every zone and bin +// whose branch_id is that yard, so the quantity compared with the reorder point +// (and reported) is that sum, the rule the counter's search already follows. +// The row's location_id is the yard and yard_name its name. A row with no +// yard (a legacy row with no location) stays its own alert. func (r *PostgresRepository) GetInventoryAlerts(ctx context.Context, branchID *uuid.UUID, grantsSub *string, limit int) ([]InventoryAlert, error) { query := ` SELECT p.id, p.sku, p.description, - COALESCE(i.quantity, 0) as current_qty, + COALESCE(SUM(i.quantity), 0) as current_qty, COALESCE(p.reorder_point, 10) as reorder_qty, CASE - WHEN COALESCE(i.quantity, 0) = 0 THEN 'OUT_OF_STOCK' + WHEN COALESCE(SUM(i.quantity), 0) = 0 THEN 'OUT_OF_STOCK' ELSE 'LOW_STOCK' END as alert_type, - COALESCE(i.location_id::text, '') as location_id + COALESCE(l.branch_id::text, '') as location_id, + COALESCE(y.name, y.code, '') as yard_name FROM products p LEFT JOIN inventory i ON p.id = i.product_id LEFT JOIN locations l ON l.id = i.location_id - WHERE COALESCE(i.quantity, 0) <= COALESCE(p.reorder_point, 10) - AND ` + middleware.Wall("l", 2, 3) + ` - ORDER BY COALESCE(i.quantity, 0) ASC, p.sku ASC, i.location_id ASC + LEFT JOIN locations y ON y.id = l.branch_id + WHERE ` + middleware.Wall("l", 2, 3) + ` + GROUP BY p.id, p.sku, p.description, p.reorder_point, l.branch_id, y.name, y.code + HAVING COALESCE(SUM(i.quantity), 0) <= COALESCE(p.reorder_point, 10) + ORDER BY COALESCE(SUM(i.quantity), 0) ASC, p.sku ASC, l.branch_id ASC LIMIT $1 ` rows, err := r.db.GetExecutor(ctx).Query(ctx, query, limit, branchID, grantsSub) @@ -194,7 +202,7 @@ func (r *PostgresRepository) GetInventoryAlerts(ctx context.Context, branchID *u alerts := make([]InventoryAlert, 0) for rows.Next() { var a InventoryAlert - if err := rows.Scan(&a.ProductID, &a.SKU, &a.Name, &a.CurrentQty, &a.ReorderQty, &a.AlertType, &a.LocationID); err != nil { + if err := rows.Scan(&a.ProductID, &a.SKU, &a.Name, &a.CurrentQty, &a.ReorderQty, &a.AlertType, &a.LocationID, &a.YardName); err != nil { return nil, fmt.Errorf("failed to scan inventory alert: %w", err) } alerts = append(alerts, a) diff --git a/core/internal/inventory/branch_location_row_test.go b/core/internal/inventory/branch_location_row_test.go index d2890adb6..dc69dc048 100644 --- a/core/internal/inventory/branch_location_row_test.go +++ b/core/internal/inventory/branch_location_row_test.go @@ -213,9 +213,12 @@ func TestAnActOnAnotherBranchsYardWithNoRowIsRefused(t *testing.T) { } } -// RULE: a request that names no location keeps the stated picks: an unbound -// caller the legacy unassigned row, a branch scoped caller the branch's first -// row by creation, never the largest quantity by accident. +// RULE: a request that names no location keeps the stated picks for a delta +// (an unbound caller the legacy unassigned row, a branch scoped caller the +// branch's first row by creation, never the largest quantity by accident); an +// absolute count that names no location is refused where the product sits in +// more than one location in reach (item 130, N20: a count is for one +// location, and the oldest row would have taken the whole yard's figure). func TestAnAdjustWithNoLocationTakesTheStatedRow(t *testing.T) { w, read := newLocWorld(t) db := testutil.RequireDB(t) @@ -242,36 +245,49 @@ func TestAnAdjustWithNoLocationTakesTheStatedRow(t *testing.T) { _, _ = db.Pool.Exec(context.Background(), `DELETE FROM inventory WHERE product_id = $1 AND (location_id IS NULL OR location_id = $2)`, w.productID, newYard) _, _ = db.Pool.Exec(context.Background(), `DELETE FROM locations WHERE id = $1`, newYard) }) - // an unbound caller: the legacy unassigned row + // an absolute count with no location is refused for both callers and + // writes nothing + for name, ctx := range map[string]context.Context{"unbound": context.Background(), "branch scoped": w.ctx} { + err := w.svc.AdjustStock(ctx, inventory.StockAdjustmentRequest{ProductID: w.productID, Quantity: 41}) + if !errors.Is(err, inventory.ErrCountNeedsLocation) { + t.Fatalf("%s absolute adjust with no location = %v, want ErrCountNeedsLocation", name, err) + } + } + if got := w.qtyOf(t, read, w.yard1); got != "100.0000" { + t.Errorf("yard 1 after the refused counts = %s, want 100.0000", got) + } + // a delta with no location from an unbound caller is refused too: it + // would write a row with no location (round 3); the legacy row stands if err := w.svc.AdjustStock(context.Background(), inventory.StockAdjustmentRequest{ - ProductID: w.productID, Quantity: 41}); err != nil { - t.Fatalf("unbound adjust with no location: %v", err) + ProductID: w.productID, Quantity: 1, IsDelta: true}); !errors.Is(err, inventory.ErrCountNeedsLocation) { + t.Fatalf("unbound delta with no location = %v, want ErrCountNeedsLocation", err) } var legacy string if err := db.Pool.QueryRow(context.Background(), `SELECT quantity::text FROM inventory WHERE product_id = $1 AND location_id IS NULL`, w.productID).Scan(&legacy); err != nil { t.Fatal(err) } - if legacy != "41.0000" { - t.Errorf("the unassigned row = %s, want 41.0000 (the unbound caller's legacy row)", legacy) + if legacy != "40.0000" { + t.Errorf("the unassigned row = %s, want 40.0000 untouched", legacy) } if got := w.qtyOf(t, read, w.yard1); got != "100.0000" { - t.Errorf("yard 1 after the unbound adjust = %s, want 100.0000", got) + t.Errorf("yard 1 after the unbound delta = %s, want 100.0000", got) } - // a branch scoped caller: the branch's first row by creation (yard 1, + // a delta with no location keeps the stated picks for a branch scoped caller: + // the branch's first row by creation (yard 1, // created before yard 2 and the new yard), though the new yard holds the // most: the pick follows creation order, never quantity if err := w.svc.AdjustStock(w.ctx, inventory.StockAdjustmentRequest{ - ProductID: w.productID, Quantity: 42}); err != nil { - t.Fatalf("branch scoped adjust with no location: %v", err) + ProductID: w.productID, Quantity: 2, IsDelta: true}); err != nil { + t.Fatalf("branch scoped delta with no location: %v", err) } - if got := w.qtyOf(t, read, w.yard1); got != "42.0000" { - t.Errorf("yard 1 (the branch's first row) = %s, want 42.0000", got) + if got := w.qtyOf(t, read, w.yard1); got != "102.0000" { + t.Errorf("yard 1 (the branch's first row) = %s, want 102.0000", got) } if got := w.qtyOf(t, read, w.yard2); got != "5.0000" { - t.Errorf("yard 2 after the branch scoped adjust = %s, want 5.0000", got) + t.Errorf("yard 2 after the branch scoped delta = %s, want 5.0000", got) } if got := w.qtyOf(t, read, newYard); got != "900.0000" { - t.Errorf("the newest yard after the branch scoped adjust = %s, want 900.0000 (a silent largest quantity pick would have taken it)", got) + t.Errorf("the newest yard after the branch scoped delta = %s, want 900.0000 (a silent largest quantity pick would have taken it)", got) } } diff --git a/core/internal/inventory/count_context_pg_test.go b/core/internal/inventory/count_context_pg_test.go new file mode 100644 index 000000000..53eae96ba --- /dev/null +++ b/core/internal/inventory/count_context_pg_test.go @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package inventory_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/inventory" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/google/uuid" +) + +// countWorld is a yard, optionally a zone under it, and one product. +type countWorld struct { + t *testing.T + yard, zone, prod uuid.UUID + mux *http.ServeMux + count func(bc *branchctx.Context, body string) (int, string) + rows, onYard, sum func() float64 + cleanupGrantedSubs []string +} + +func newCountWorld(t *testing.T) *countWorld { + t.Helper() + db := testutil.RequireDB(t) + ctx := context.Background() + w := &countWorld{t: t, yard: uuid.New(), zone: uuid.New(), prod: uuid.New()} + exec := func(q string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(ctx, q, args...); err != nil { + t.Fatalf("seed: %v\n%s", err, q) + } + } + exec(`INSERT INTO locations (id, type, code, branch_id) VALUES ($1, 'BRANCH', $2, $1)`, w.yard, "CX-"+w.yard.String()[:8]) + exec(`INSERT INTO locations (id, type, code, parent_id, branch_id) VALUES ($1, 'ZONE', $2, $3, $3)`, w.zone, "CZ-"+w.zone.String()[:8], w.yard) + exec(`INSERT INTO products (id, sku, description, uom_primary, base_price) VALUES ($1, $2, 'count ctx', 'EA', 1)`, w.prod, "CX-"+w.prod.String()[:8]) + t.Cleanup(func() { + bg := context.Background() + _, _ = db.Pool.Exec(bg, `DELETE FROM user_locations WHERE branch_id = $1`, w.yard) + _, _ = db.Pool.Exec(bg, `DELETE FROM inventory WHERE product_id = $1`, w.prod) + _, _ = db.Pool.Exec(bg, `DELETE FROM products WHERE id = $1`, w.prod) + _, _ = db.Pool.Exec(bg, `DELETE FROM locations WHERE id = $1`, w.zone) + _, _ = db.Pool.Exec(bg, `DELETE FROM locations WHERE id = $1`, w.yard) + }) + w.mux = http.NewServeMux() + inventory.NewHandler(inventory.NewService(inventory.NewRepository(db))). + WithBranchGuard(middleware.NewBranchGuard(db)).RegisterRoutes(w.mux) + w.count = func(bc *branchctx.Context, body string) (int, string) { + req := httptest.NewRequest("POST", "/api/v1/inventory/adjust", strings.NewReader(body)) + if bc != nil { + req = req.WithContext(branchctx.With(ctx, bc)) + } + rec := httptest.NewRecorder() + w.mux.ServeHTTP(rec, req) + return rec.Code, rec.Body.String() + } + w.rows = func() float64 { + var n float64 + _ = db.Pool.QueryRow(ctx, `SELECT count(*) FROM inventory WHERE product_id = $1`, w.prod).Scan(&n) + return n + } + w.onYard = func() float64 { + var q float64 + _ = db.Pool.QueryRow(ctx, `SELECT COALESCE(sum(quantity),0)::float8 FROM inventory WHERE product_id = $1 AND location_id = $2`, w.prod, w.yard).Scan(&q) + return q + } + w.sum = func() float64 { + var q float64 + _ = db.Pool.QueryRow(ctx, `SELECT COALESCE(sum(quantity),0)::float8 FROM inventory WHERE product_id = $1`, w.prod).Scan(&q) + return q + } + return w +} + +func (w *countWorld) stock(loc uuid.UUID, qty float64, allocated float64, oldest bool) { + w.t.Helper() + db := testutil.RequireDB(w.t) + when := "now()" + if oldest { + when = "now() - interval '1 hour'" + } + if _, err := db.Pool.Exec(context.Background(), + `INSERT INTO inventory (product_id, location_id, location, quantity, allocated, created_at) VALUES ($1, $2, 'row', $3, $4, `+when+`)`, + w.prod, loc, qty, allocated); err != nil { + w.t.Fatalf("stock: %v", err) + } +} + +func (w *countWorld) countBody(qty float64, isDelta bool) string { + return fmt.Sprintf(`{"product_id":%q,"quantity":%g,"reason":"count","is_delta":%v}`, w.prod, qty, isDelta) +} + +// CORRECTNESS (item 130, N20, review round 2): an absolute count that names no +// location is never written where the caller has no branch context, even for a +// product held in one location: it would create a location-less row beside the +// real one (50 counted as 45 became 95). The same count from a caller bound to +// the yard, which holds the product in one row, still lands on that row. +func TestCount_NoLocationIsRefusedWithoutABranchContext(t *testing.T) { + granted := "u-granted-" + uuid.NewString()[:8] + callers := map[string]func(w *countWorld) *branchctx.Context{ + "administrator with no header": func(*countWorld) *branchctx.Context { + return &branchctx.Context{UserSub: "boss", IsAdmin: true} + }, + "unbound key": func(*countWorld) *branchctx.Context { return nil }, + "granted user with no header": func(w *countWorld) *branchctx.Context { + db := testutil.RequireDB(w.t) + if _, err := db.Pool.Exec(context.Background(), + `INSERT INTO user_locations (user_sub, branch_id) VALUES ($1, $2)`, granted, w.yard); err != nil { + w.t.Fatalf("grant: %v", err) + } + return &branchctx.Context{UserSub: granted} + }, + } + for name, mk := range callers { + t.Run(name, func(t *testing.T) { + w := newCountWorld(t) + w.stock(w.yard, 50, 0, true) + code, body := w.count(mk(w), w.countBody(45, false)) + if code != http.StatusUnprocessableEntity { + t.Fatalf("count with no location = %d, want 422: %s", code, body) + } + var e struct { + Error struct{ Code string } `json:"error"` + } + if json.Unmarshal([]byte(body), &e) != nil || e.Error.Code != "location_required" { + t.Errorf("refusal = %s", body) + } + if w.rows() != 1 || w.sum() != 50 { + t.Errorf("rows = %g, total = %g after a refused count, want 1 row of 50", w.rows(), w.sum()) + } + }) + } + t.Run("caller bound to the yard, one row", func(t *testing.T) { + w := newCountWorld(t) + w.stock(w.yard, 50, 0, true) + if code, body := w.count(&branchctx.Context{UserSub: "u", BranchID: &w.yard}, w.countBody(45, false)); code != http.StatusOK { + t.Fatalf("bound count = %d: %s", code, body) + } + if w.sum() != 45 || w.rows() != 1 { + t.Errorf("total = %g over %g rows, want 45 over 1", w.sum(), w.rows()) + } + }) +} + +// A delta naming no location that the oldest row cannot take is refused in +// words (422), never a bare 500, though the yard as a whole holds enough. +func TestAdjust_ADeltaThatTheOldestRowCannotTakeIsRefusedInWords(t *testing.T) { + w := newCountWorld(t) + w.stock(w.yard, 0, 0, true) + w.stock(w.zone, 300, 0, false) + code, body := w.count(&branchctx.Context{UserSub: "u", BranchID: &w.yard}, w.countBody(-5, true)) + if code != http.StatusUnprocessableEntity { + t.Fatalf("delta = %d, want 422: %s", code, body) + } + var e struct { + Error struct{ Code, Message string } `json:"error"` + } + if err := json.Unmarshal([]byte(body), &e); err != nil || e.Error.Code != "insufficient_stock" || !strings.Contains(e.Error.Message, "location") { + t.Errorf("refusal = %s", body) + } + if w.sum() != 300 { + t.Errorf("total = %g after a refused delta, want 300", w.sum()) + } +} + +// A count below the allocated figure is refused in words before it is +// written: allocated stock is promised to orders. +func TestCount_BelowTheAllocatedFigureIsRefusedInWords(t *testing.T) { + w := newCountWorld(t) + w.stock(w.yard, 50, 40, true) + body := fmt.Sprintf(`{"product_id":%q,"location_id":%q,"quantity":10,"reason":"count","is_delta":false}`, w.prod, w.yard) + code, out := w.count(&branchctx.Context{UserSub: "u", BranchID: &w.yard}, body) + if code != http.StatusUnprocessableEntity { + t.Fatalf("count under allocated = %d, want 422: %s", code, out) + } + var e struct { + Error struct{ Code, Message string } `json:"error"` + } + if err := json.Unmarshal([]byte(out), &e); err != nil || e.Error.Code != "below_allocated" || !strings.Contains(e.Error.Message, "allocated") { + t.Errorf("refusal = %s", out) + } + if w.sum() != 50 { + t.Errorf("total = %g after a refused count, want 50", w.sum()) + } +} + +// CORRECTNESS (item 130, N20, PR 149 round 4): a caller bound to a yard that +// holds no row of the product, naming no location, never writes a row with no +// location. Held only at another yard (50 counted as 45 became 95) or held +// nowhere: refused in words, nothing written, the other yard unchanged. +func TestCount_BoundCallerWithNoRowInTheirYardIsRefused(t *testing.T) { + refusal := func(t *testing.T, code int, body string) { + t.Helper() + if code != http.StatusUnprocessableEntity { + t.Fatalf("write with no location = %d, want 422: %s", code, body) + } + var e struct { + Error struct{ Code, Message string } `json:"error"` + } + if json.Unmarshal([]byte(body), &e) != nil || e.Error.Code != "location_required" || !strings.Contains(e.Error.Message, "location") { + t.Errorf("refusal = %s", body) + } + } + for _, kind := range []struct { + name string + qty float64 + delta bool + }{{"absolute count", 45, false}, {"delta", 5, true}} { + t.Run("held only at another yard, "+kind.name, func(t *testing.T) { + w := newCountWorld(t) + db := testutil.RequireDB(t) + other := uuid.New() + if _, err := db.Pool.Exec(context.Background(), + `INSERT INTO locations (id, type, code, branch_id) VALUES ($1, 'BRANCH', $2, $1)`, other, "CO-"+other.String()[:8]); err != nil { + t.Fatalf("other yard: %v", err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM inventory WHERE location_id = $1`, other) + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM locations WHERE id = $1`, other) + }) + w.stock(other, 50, 0, true) + code, body := w.count(&branchctx.Context{UserSub: "u", BranchID: &w.yard}, w.countBody(kind.qty, kind.delta)) + refusal(t, code, body) + if w.rows() != 1 || w.sum() != 50 { + t.Errorf("rows = %g, total = %g, want the other yard's 1 row of 50 and nothing new", w.rows(), w.sum()) + } + }) + t.Run("held nowhere, "+kind.name, func(t *testing.T) { + w := newCountWorld(t) + code, body := w.count(&branchctx.Context{UserSub: "u", BranchID: &w.yard}, w.countBody(kind.qty, kind.delta)) + refusal(t, code, body) + if w.rows() != 0 { + t.Errorf("rows = %g, want none written", w.rows()) + } + }) + } +} diff --git a/core/internal/inventory/count_delta_pg_test.go b/core/internal/inventory/count_delta_pg_test.go new file mode 100644 index 000000000..d4b68758c --- /dev/null +++ b/core/internal/inventory/count_delta_pg_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package inventory_test + +import ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/google/uuid" +) + +// CORRECTNESS (item 130, N20, round 3): a delta that names no location, from a +// caller with no branch context, would create a location less row (stock in no +// yard): the same defect as an absolute count with no location. It is refused +// 422 location_required in words, writing nothing. +func TestAdjust_ADeltaWithNoLocationIsRefusedWithoutABranchContext(t *testing.T) { + granted := "u-granted-" + uuid.NewString()[:8] + callers := map[string]func(w *countWorld) *branchctx.Context{ + "administrator with no header": func(*countWorld) *branchctx.Context { + return &branchctx.Context{UserSub: "boss", IsAdmin: true} + }, + "unbound key": func(*countWorld) *branchctx.Context { return nil }, + "granted user with no header": func(w *countWorld) *branchctx.Context { + db := testutil.RequireDB(w.t) + if _, err := db.Pool.Exec(context.Background(), + `INSERT INTO user_locations (user_sub, branch_id) VALUES ($1, $2)`, granted, w.yard); err != nil { + w.t.Fatalf("grant: %v", err) + } + return &branchctx.Context{UserSub: granted} + }, + } + for name, mk := range callers { + t.Run(name, func(t *testing.T) { + w := newCountWorld(t) + w.stock(w.yard, 50, 0, true) + code, body := w.count(mk(w), w.countBody(5, true)) + if code != http.StatusUnprocessableEntity { + t.Fatalf("delta with no location = %d, want 422: %s", code, body) + } + if w.rows() != 1 || w.sum() != 50 { + t.Errorf("rows = %g, total = %g after a refused delta, want 1 row of 50", w.rows(), w.sum()) + } + }) + } +} + +// A user granted yard A who counts a product held only at yard B, naming no +// location, is refused (absolute or delta): nothing is written for a yard the +// user has no grant on, and no location less row appears. +func TestCount_AUserGrantedYardACountingAProductHeldAtYardBIsRefused(t *testing.T) { + db := testutil.RequireDB(t) + granted := "u-granted-" + uuid.NewString()[:8] + for name, isDelta := range map[string]bool{"absolute": false, "delta": true} { + t.Run(name, func(t *testing.T) { + w := newCountWorld(t) // the product's yard is B + yardA := uuid.New() + if _, err := db.Pool.Exec(context.Background(), + `INSERT INTO locations (id, type, code, branch_id) VALUES ($1, 'BRANCH', $2, $1)`, yardA, "CA-"+yardA.String()[:8]); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + bg := context.Background() + _, _ = db.Pool.Exec(bg, `DELETE FROM user_locations WHERE branch_id = $1`, yardA) + _, _ = db.Pool.Exec(bg, `DELETE FROM locations WHERE id = $1`, yardA) + }) + if _, err := db.Pool.Exec(context.Background(), + `INSERT INTO user_locations (user_sub, branch_id) VALUES ($1, $2)`, granted, yardA); err != nil { + t.Fatal(err) + } + w.stock(w.yard, 50, 0, true) + code, body := w.count(&branchctx.Context{UserSub: granted}, w.countBody(45, isDelta)) + if code != http.StatusUnprocessableEntity { + t.Fatalf("%s no location = %d, want 422: %s", name, code, body) + } + if w.rows() != 1 || w.sum() != 50 { + t.Errorf("rows = %g, total = %g, want 1 row of 50 untouched", w.rows(), w.sum()) + } + }) + } +} diff --git a/core/internal/inventory/cycle_count_pg_test.go b/core/internal/inventory/cycle_count_pg_test.go new file mode 100644 index 000000000..d35a7e580 --- /dev/null +++ b/core/internal/inventory/cycle_count_pg_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package inventory_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/inventory" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/google/uuid" +) + +// CORRECTNESS (item 130, N20): a cycle count counts by location. A yard that +// holds a product on its own row and in a zone is counted row by row: a count +// naming a location sets that row only, a count naming a location the product +// was not in adds it (a found pallet), and an absolute count that names no +// location on a yard holding the product in more than one location is refused +// in words, never guessed onto the oldest row (which turned a count of 410 +// against 112 and 300 into a yard total of 710). +func TestCycleCount_CountsByLocationAndRefusesAGuess(t *testing.T) { + db := testutil.RequireDB(t) + ctx := context.Background() + + yard, zone, zone2, product := uuid.New(), uuid.New(), uuid.New(), uuid.New() + exec := func(q string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(ctx, q, args...); err != nil { + t.Fatalf("seed: %v\n%s", err, q) + } + } + exec(`INSERT INTO locations (id, type, code, branch_id) VALUES ($1, 'BRANCH', $2, $1)`, yard, "CC-"+yard.String()[:8]) + exec(`INSERT INTO locations (id, type, code, parent_id, branch_id) VALUES ($1, 'ZONE', $2, $3, $3)`, zone, "CZ-"+zone.String()[:8], yard) + exec(`INSERT INTO locations (id, type, code, parent_id, branch_id) VALUES ($1, 'ZONE', $2, $3, $3)`, zone2, "CY-"+zone2.String()[:8], yard) + exec(`INSERT INTO products (id, sku, description, uom_primary, base_price) VALUES ($1, $2, 'count test', 'EA', 1)`, product, "CC-"+product.String()[:8]) + t.Cleanup(func() { + bg := context.Background() + _, _ = db.Pool.Exec(bg, `DELETE FROM inventory WHERE product_id = $1`, product) + _, _ = db.Pool.Exec(bg, `DELETE FROM products WHERE id = $1`, product) + _, _ = db.Pool.Exec(bg, `DELETE FROM locations WHERE id IN ($1, $2)`, zone, zone2) + _, _ = db.Pool.Exec(bg, `DELETE FROM locations WHERE id = $1`, yard) + }) + exec(`INSERT INTO inventory (product_id, location_id, location, quantity, allocated, created_at) VALUES ($1, $2, 'yard', 112, 0, now() - interval '1 hour')`, product, yard) + exec(`INSERT INTO inventory (product_id, location_id, location, quantity, allocated) VALUES ($1, $2, 'zone', 300, 0)`, product, zone) + + mux := http.NewServeMux() + inventory.NewHandler(inventory.NewService(inventory.NewRepository(db))). + WithBranchGuard(middleware.NewBranchGuard(db)).RegisterRoutes(mux) + bound := &branchctx.Context{UserSub: "u", BranchID: &yard} + post := func(body string) (int, string) { + req := httptest.NewRequest("POST", "/api/v1/inventory/adjust", strings.NewReader(body)).WithContext(branchctx.With(ctx, bound)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec.Code, rec.Body.String() + } + qty := func(loc uuid.UUID) (float64, bool) { + var q float64 + err := db.Pool.QueryRow(ctx, `SELECT quantity::float8 FROM inventory WHERE product_id = $1 AND location_id = $2`, product, loc).Scan(&q) + return q, err == nil + } + count := func(loc *uuid.UUID, n float64) string { + if loc == nil { + return fmt.Sprintf(`{"product_id":%q,"quantity":%g,"reason":"count","is_delta":false}`, product, n) + } + return fmt.Sprintf(`{"product_id":%q,"location_id":%q,"quantity":%g,"reason":"count","is_delta":false}`, product, *loc, n) + } + + // A count naming the zone sets the zone's row and no other. + if code, body := post(count(&zone, 298)); code != http.StatusOK { + t.Fatalf("zone count = %d: %s", code, body) + } + if z, _ := qty(zone); z != 298 { + t.Errorf("zone row = %g, want 298", z) + } + if y, _ := qty(yard); y != 112 { + t.Errorf("yard row = %g, want 112 untouched", y) + } + // A count naming a location the product was not in adds it. + if code, body := post(count(&zone2, 7)); code != http.StatusOK { + t.Fatalf("found pallet count = %d: %s", code, body) + } + if z2, ok := qty(zone2); !ok || z2 != 7 { + t.Errorf("found pallet row = %g (exists %v), want 7", z2, ok) + } + // An absolute count naming no location, on a yard holding the product in + // three locations, is refused in words and writes nothing. + code, body := post(count(nil, 410)) + if code != http.StatusUnprocessableEntity { + t.Fatalf("count with no location = %d, want 422: %s", code, body) + } + var e struct { + Error struct{ Code, Message string } `json:"error"` + } + if err := json.Unmarshal([]byte(body), &e); err != nil || e.Error.Code != "location_required" || !strings.Contains(e.Error.Message, "location") { + t.Errorf("refusal = %s", body) + } + if y, _ := qty(yard); y != 112 { + t.Errorf("yard row = %g after a refused count, want 112", y) + } +} diff --git a/core/internal/inventory/handler.go b/core/internal/inventory/handler.go index c3f91920d..4b5eedca2 100644 --- a/core/internal/inventory/handler.go +++ b/core/internal/inventory/handler.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" "net/http" "time" @@ -94,6 +95,27 @@ func (h *Handler) AdjustStock(w http.ResponseWriter, r *http.Request) { httputil.RespondError(w, r, "location_id is in a branch this caller may not target", http.StatusForbidden, err) return } + if errors.Is(err, ErrCountNeedsLocation) { + httpx.WriteError(w, r, &httpx.Error{Status: http.StatusUnprocessableEntity, Code: "location_required", + Message: "Name the location you counted or adjusted. A count or adjustment with no location is not written here, because this product may be held in more than one location or the caller is not working in one yard."}) + return + } + if errors.Is(err, ErrYardHoldsNoRow) { + httpx.WriteError(w, r, &httpx.Error{Status: http.StatusUnprocessableEntity, Code: "location_required", + Message: "Name the location you counted. This yard holds no row of this product yet, so nothing was written."}) + return + } + if errors.Is(err, ErrWouldGoNegative) { + httpx.WriteError(w, r, &httpx.Error{Status: http.StatusUnprocessableEntity, Code: "insufficient_stock", + Message: "That adjustment would take the location below zero. Name the location the stock is in and adjust that one."}) + return + } + var below *BelowAllocatedError + if errors.As(err, &below) { + httpx.WriteError(w, r, &httpx.Error{Status: http.StatusUnprocessableEntity, Code: "below_allocated", + Message: fmt.Sprintf("The count of %g is below the %g allocated to orders at this location. Count at least the allocated figure, or release the orders first.", below.Counted, below.Allocated)}) + return + } slog.Error("AdjustStock failed", "error", err) httputil.RespondError(w, r, "Internal Server Error", http.StatusInternalServerError, err) return diff --git a/core/internal/inventory/service.go b/core/internal/inventory/service.go index e17ea9542..57fe795d6 100644 --- a/core/internal/inventory/service.go +++ b/core/internal/inventory/service.go @@ -26,6 +26,30 @@ var ErrNothingAllocated = errors.New("inventory: no allocated stock") // another branch's yard. var ErrLocationOutsideBranch = errors.New("inventory: the named location is not on the caller's branch") +// ErrCountNeedsLocation refuses an absolute count that names no location when +// the product is held in more than one location in the caller's reach: the +// count would be written to the oldest row and corrupt the yard's total, so +// the caller names the location it counted. +var ErrCountNeedsLocation = errors.New("inventory: this product is held in more than one location here; name the location you counted") + +// ErrYardHoldsNoRow refuses a count or delta that names no location when the +// caller's reach holds no row of the product: it would create a row with no +// location, stock in no yard. +var ErrYardHoldsNoRow = errors.New("inventory: this yard holds no row of this product yet; name the location you counted") + +// ErrWouldGoNegative refuses an adjustment whose result would be below zero at +// the row it lands on; a delta that names no location lands on the oldest row, +// which can be empty while the yard holds plenty. +var ErrWouldGoNegative = errors.New("inventory: the adjustment would take the location below zero") + +// BelowAllocatedError refuses an absolute count under the figure already +// allocated to orders at that location: allocated stock is promised. +type BelowAllocatedError struct{ Counted, Allocated float64 } + +func (e *BelowAllocatedError) Error() string { + return fmt.Sprintf("inventory: a count of %g is below the %g allocated to orders at this location", e.Counted, e.Allocated) +} + type Service struct { repo Repository levels LevelStore // the levels read's store (C3-1b), when the repo can serve it @@ -44,6 +68,33 @@ func NewService(repo Repository) *Service { // AdjustStock handles receipt (Add) or cycle count (Set/Adjust) func (s *Service) AdjustStock(ctx context.Context, req StockAdjustmentRequest) error { + // A count is an absolute figure for one location. With no location named + // it is a guess: refused in words where the caller has no branch context + // (it would create a row with no location beside the real one), and where + // the product sits in more than one location in the caller's reach (it + // would overwrite the oldest row). The reach is the read that applies the + // caller's branch or grants. + if req.LocationID == nil && branchctx.IDForQuery(ctx) == nil { + // A count or a delta with no location and no branch context would + // create a row with no location: stock in no yard. + return ErrCountNeedsLocation + } + if req.LocationID == nil && !req.IsDelta { + rows, err := s.repo.ListInventoryByProduct(ctx, req.ProductID) + if err != nil { + return err + } + if len(rows) > 1 { + return ErrCountNeedsLocation + } + } + return s.adjustStock(ctx, req) +} + +// adjustStock is the write itself, with no caller guard: MoveStock's legs call +// it (a move names its endpoints, and a move out of the unassigned legacy row +// has no source location by design). +func (s *Service) adjustStock(ctx context.Context, req StockAdjustmentRequest) error { // 1. Check if inventory record exists inv, err := s.repo.GetInventory(ctx, req.ProductID, req.LocationID) if err != nil { @@ -51,6 +102,12 @@ func (s *Service) AdjustStock(ctx context.Context, req StockAdjustmentRequest) e } if inv == nil { + // A row with no location is stock in no yard. The write never creates + // one: a count or delta that names no location and finds no row in the + // caller's reach is refused, whoever the caller is. + if req.LocationID == nil { + return ErrYardHoldsNoRow + } // Create new record. On a non-existent record a delta starts from base 0. newQty := req.Quantity if newQty < 0 { @@ -70,6 +127,9 @@ func (s *Service) AdjustStock(ctx context.Context, req StockAdjustmentRequest) e if req.IsDelta { inv.Quantity += req.Quantity } else { + if req.Quantity >= 0 && req.Quantity < inv.Allocated { + return &BelowAllocatedError{Counted: req.Quantity, Allocated: inv.Allocated} + } inv.Quantity = req.Quantity } @@ -78,7 +138,7 @@ func (s *Service) AdjustStock(ctx context.Context, req StockAdjustmentRequest) e // than persist negative inventory, which corrupts availability math // downstream (Allocate/Fulfill) and the double-entry move total. if inv.Quantity < 0 { - return fmt.Errorf("adjustment would drive stock negative for product %s (resulting quantity %g)", req.ProductID, inv.Quantity) + return fmt.Errorf("%w: product %s (resulting quantity %g)", ErrWouldGoNegative, req.ProductID, inv.Quantity) } return s.repo.UpdateInventory(ctx, inv) @@ -128,7 +188,7 @@ func (s *Service) MoveStock(ctx context.Context, req StockMovementRequest) error return s.repo.ExecuteInTx(ctx, func(ctx context.Context) error { // Subtract from source - err := s.AdjustStock(ctx, StockAdjustmentRequest{ + err := s.adjustStock(ctx, StockAdjustmentRequest{ ProductID: req.ProductID, LocationID: req.FromLocationID, Quantity: -req.Quantity, @@ -140,7 +200,7 @@ func (s *Service) MoveStock(ctx context.Context, req StockMovementRequest) error } // Add to dest - err = s.AdjustStock(ctx, StockAdjustmentRequest{ + err = s.adjustStock(ctx, StockAdjustmentRequest{ ProductID: req.ProductID, LocationID: &req.ToLocationID, Quantity: req.Quantity, diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 147c70b8f..e49550b5b 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -427,3 +427,7 @@ get an entry. | SEED-HYGIENE (item 108 b) | migration 118 `staff_demo_domain` (operational, no route) | Migration 080 left three demo staff on `@gable.com`, a real domain. | Migration 118 rewrites those three addresses to `@gablebuildingsupply.example`; staff ids and grants are unchanged. | | POS-RECEIPT-1 (item 111) | pos: `GET /api/v1/pos/transactions/{id}/receipt`, `POST /api/v1/pos/transactions/{id}/receipt/email` (new routes) | The Sale complete card's Print receipt and Email receipt called the documents print route and the invoice email route, mounted for admin, owner, sales and finance, so a cashier got a 403 from both. | The counter's own pair, mounted for admin, owner and cashier with the same reach as the sale read (the branch context, the wall and a bound key's pin; a sale the caller cannot read is the sale read's 404). Print answers the sale's invoice document by the documents print route's own renderer (same body and content type); email sends the sale's invoice to the account's contact by the invoice email route's own function (same 202 `{"status":"queued"}`, same delivery record). A sale that is not completed or has no invoice is a 409 blocker `no_receipt`; an email with no contact (a sale that names no customer or the shared walk in customer, whatever address that row holds, or an account with no email address) is a 409 blocker `no_contact` in words, and nothing is sent; only a completed sale has a receipt (a voided or held sale that keeps its invoice is `no_receipt`). Both routes are WALLED in the bound key allowlist. The documents routes and their roles are unchanged. `posTileList`'s description now says a counter sale's own invoice (origin POS) is not counted. | | AI-CLAUDE-FIX1 | `POST /api/v1/parsing/upload`, `POST /api/v1/purchase-orders/{id}/freight`; `GET /api/v1/apps` golden `apps`; pim: `POST /api/v1/products/{id}/pim/generate/image` | A file of a type the chosen provider cannot read (a BMP under Anthropic) got the canned demo list at the intake and a bare 400 at freight. The apps catalog described the AI client as "OpenRouter client". Image generation under Anthropic answered 409 AI off. | The unreadable type answers 415 `unsupported_type` in the wire envelope with the words naming what the provider reads; no canned list. The catalog line reads "Provider routed AI client (OpenRouter or Anthropic), KeyStore, and model routing (library)." and the `apps` golden is re-recorded for that one string. Image generation answers 202 under Anthropic and draws an illustration, stored only after a strict sanitiser. | +| G1-YARD-STOCK (item 130, N20) | dashboard: `GET /api/v1/dashboard/inventory-alerts` (no shape change; the rows' meaning) | The alert read one row per location, so a yard holding 112 on its own row and 300 in a zone under it was reported low at 112 against a reorder point of 150. | One row per product and yard: the yard's own row and every zone and bin whose branch_id is that yard are summed, the sum is compared with the reorder point and reported as `current_qty`, and `location_id` is the yard (the branch). The counter's search, the product list and the reorder list already followed this rule. | +| G1-YARD-STOCK-R1 (item 130, N20, review round 1) | inventory: `POST /api/v1/inventory/adjust` (new 422 `location_required`); dashboard: `GET /api/v1/dashboard/inventory-alerts` (rows gain `yard_name`) | The Cycle count page wrote a yard's summed figure to the yard's oldest row (a count of 410 against 112 on the yard and 300 in a zone made the yard total 710), and two alert rows for one SKU could not be told apart. | An absolute count (`is_delta` false) that names no `location_id`, for a product held in more than one location in the caller's reach, answers 422 `location_required` in words and writes nothing; a count naming a location sets that row only (a location the product was not in is added). The desk's Cycle count lists the yard's own row and each zone or bin with its own expected figure and writes each count to its own location. Alert rows carry the yard's name (its code when it has none). A delta with no location keeps the stated picks. | +| G1-YARD-STOCK-R2 (item 130, N20, review round 2) | inventory: `POST /api/v1/inventory/adjust` (422 `location_required` also without a branch context; new 422 `insufficient_stock` and `below_allocated`) | With no branch context (an administrator with no header, an unbound key, a granted user with no header) a count naming no location for a product held in one location created a location less row beside the real one (50 counted as 45 became 95); a delta the oldest row could not take answered a bare 500; a count under the allocated figure was accepted. | An absolute count that names no location is refused 422 `location_required` whenever the caller has no branch context, and where the product is in more than one location in the caller's reach (read through the caller's branch or grants). A result below zero is 422 `insufficient_stock` naming the fix; an absolute count below the allocated figure at that location is 422 `below_allocated`. A caller bound to a yard holding the product in one row still counts without naming it. | +| G1-YARD-STOCK-R3 (item 130, N20, round 3) | inventory: `POST /api/v1/inventory/adjust` (422 `location_required` for a delta too) | A delta naming no location, from a caller with no branch context, created a location less row: stock in no yard. | An adjustment (count or delta) that names no location is refused 422 `location_required` whenever the caller has no branch context; a caller bound to a yard keeps the stated picks for a delta. Stock moves are unchanged (they name their endpoints). | diff --git a/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.test.ts b/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.test.ts new file mode 100644 index 000000000..56f23930e --- /dev/null +++ b/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.test.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { describe, it, expect, afterEach } from 'vitest' +import './InventoryAlertsWidget' +import type { GableInventoryAlertsWidget } from './InventoryAlertsWidget' +import { mount, text } from '../../test/dom' + +afterEach(() => { document.body.innerHTML = '' }) + +describe('Inventory alerts widget', () => { + it('names the yard on each row, so two rows for one SKU can be told apart', async () => { + const alerts = [ + { product_id: 'p', sku: 'HANGER-28', name: 'Joist Hanger 2x8', current_qty: 25, reorder_qty: 150, alert_type: 'LOW_STOCK', location_id: 'a', yard_name: 'Lake Country Outpost' }, + { product_id: 'p', sku: 'HANGER-28', name: 'Joist Hanger 2x8', current_qty: 130, reorder_qty: 150, alert_type: 'LOW_STOCK', location_id: 'b', yard_name: 'West Kelowna Satellite' }, + ] as unknown as GableInventoryAlertsWidget['alerts'] + const el = await mount('gable-inventory-alerts-widget', { alerts }) + const rows = Array.from(el.querySelectorAll('[data-testid="alert-yard"]')).map((r) => text(r)) + expect(rows).toEqual(['Lake Country Outpost', 'West Kelowna Satellite']) + }) +}) diff --git a/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.ts b/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.ts index bbd0de4d7..418b3a3b1 100644 --- a/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.ts +++ b/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors -import { LitElement, html } from 'lit'; +import { LitElement, html, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; import { AlertTriangle, Package } from 'lucide'; @@ -65,6 +65,7 @@ export class GableInventoryAlertsWidget extends LitElement {
SKU: ${alert.sku} + ${alert.yard_name ? html` · ${alert.yard_name}` : nothing}
diff --git a/web/apps/desk/src/pages/yard/CycleCount.test.ts b/web/apps/desk/src/pages/yard/CycleCount.test.ts new file mode 100644 index 000000000..3aaf5d256 --- /dev/null +++ b/web/apps/desk/src/pages/yard/CycleCount.test.ts @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * A cycle count counts by location (item 130, N20): the yard's own row and + * each zone or bin that holds the product are listed with their own expected + * figure, and each count is written to its own location, so the yard's total + * after the count is the sum of the counts. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import './CycleCount' +import type { LitElement } from 'lit' +import { mount, flush, update, q, text, jsonResponse } from '../../test/dom' + +const PRODUCT = { id: 'p-hanger', sku: 'HANGER-28', description: 'Joist Hanger 2x8', on_hand: '412.0000', allocated: '0.0000' } +const ROW = (id: string, loc: string, name: string, qty: string, allocated = '0.0000') => ({ + id, product_id: PRODUCT.id, location_id: loc, location_name: name, + quantity: qty, allocated, available: qty, uom: 'EA', updated_at: '2026-01-01T00:00:00Z', +}) +const LOC = (id: string, path: string, branch: string) => ({ id, path, code: path, branch_id: branch, type: 'zone', name: null, parent_id: branch }) + +// The levels list answers newest first: the zone (a later receipt) before the yard's own row. +let levels = [ROW('i2', 'loc-zone', 'KEL-MAIN/KEL-A', '300.0000'), ROW('i1', 'loc-yard', 'KEL-MAIN', '112.0000')] + +let posts: Array> +// Location ids whose write the server refuses in words. +let refused: string[] + +beforeEach(() => { + localStorage.setItem('token', 'test-token') + levels = [ROW('i2', 'loc-zone', 'KEL-MAIN/KEL-A', '300.0000'), ROW('i1', 'loc-yard', 'KEL-MAIN', '112.0000')] + posts = [] + refused = [] + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.includes('/api/v1/inventory/adjust')) { + const body = JSON.parse(String(init?.body)) + posts.push(body) + if (refused.includes(body.location_id)) { + return jsonResponse({ error: { code: 'below_allocated', message: 'Name the location you counted.' } }, 422) + } + return jsonResponse({ status: 'ok' }) + } + if (url.includes('/api/v1/inventory')) { + return jsonResponse({ items: levels, next_cursor: null }) + } + if (url.includes('/api/v1/locations')) { + return jsonResponse({ items: [ + LOC('loc-yard', 'KEL-MAIN', 'yard-1'), LOC('loc-zone', 'KEL-MAIN/KEL-A', 'yard-1'), + LOC('loc-zone-b', 'KEL-MAIN/KEL-B', 'yard-1'), LOC('loc-other', 'LK-MAIN/LK-A', 'yard-2'), + ], next_cursor: null, limit: 200 }) + } + if (url.includes('/api/v1/products')) return jsonResponse({ items: [PRODUCT], next_cursor: null }) + return jsonResponse({ items: [], next_cursor: null }) + })) +}) +afterEach(() => { + vi.unstubAllGlobals() + document.body.innerHTML = '' +}) + +async function openZone(): Promise { + const el = await mount('gable-cycle-count') + const card = Array.from(el.querySelectorAll('.cursor-pointer')).find((c) => text(c).includes('Lumber Storage A')) + if (!card) throw new Error('no zone card') + ;(card as HTMLElement).click() + await flush() + await flush() + await update(el, {}) + return el +} + +describe('Cycle count by location', () => { + it('lists the yard row and the zone with their own expected figures', async () => { + const el = await openZone() + const rows = Array.from(el.querySelectorAll('[data-testid="count-location"]')) + expect(rows.length).toBe(2) + const byName = (n: string) => rows.find((r) => text(r).startsWith(`${n} Expected`)) as Element + expect(text(byName('KEL-MAIN/KEL-A'))).toContain('300') + expect(text(byName('KEL-MAIN'))).toContain('112') + }) + + it('writes each count to its own location and none without one', async () => { + const el = await openZone() + const inputs = Array.from(el.querySelectorAll('[data-testid="count-input"]')) + const yardInput = inputs.find((i) => i.getAttribute('aria-label')?.endsWith('KEL-MAIN')) as HTMLInputElement + const zoneInput = inputs.find((i) => i.getAttribute('aria-label')?.endsWith('KEL-A')) as HTMLInputElement + yardInput.value = '110' + yardInput.dispatchEvent(new Event('input', { bubbles: true })) + zoneInput.value = '300' + zoneInput.dispatchEvent(new Event('input', { bubbles: true })) + await update(el, {}) + const submit = Array.from(el.querySelectorAll('button')).find((b) => text(b).startsWith('Submit Count')) + ;(submit as HTMLButtonElement).click() + await flush() + await flush() + // The yard row was two short and is written to the yard's location; the + // zone's count matched, so nothing is written for it. + expect(posts).toEqual([ + { product_id: PRODUCT.id, location_id: 'loc-yard', quantity: 110, reason: expect.stringContaining('Cycle count'), is_delta: false }, + ]) + expect(q(el, '[data-testid="count-done"]')).toBeTruthy() + }) + + it('adds a scan to the location the person chose, never the newest one', async () => { + const el = await openZone() + const scan = (code: string) => (el as unknown as { _handleScan(c: string): void })._handleScan(code) + // With two locations and none chosen, a scan asks which and counts nothing. + scan('HANGER-28') + await update(el, {}) + for (const i of el.querySelectorAll('[data-testid="count-input"]')) expect(i.value).toBe('') + // Choose the yard's own row, then scan: the +1 lands there. + const chooseYard = Array.from(el.querySelectorAll('[data-testid="count-choose"]')) + .find((b) => b.getAttribute('aria-label')?.endsWith('KEL-MAIN')) as HTMLElement + chooseYard.click() + await update(el, {}) + scan('HANGER-28') + await update(el, {}) + const value = (name: string) => (Array.from(el.querySelectorAll('[data-testid="count-input"]')) + .find((i) => i.getAttribute('aria-label')?.endsWith(name)) as HTMLInputElement).value + expect(value('KEL-MAIN')).toBe('1') + expect(value('KEL-A')).toBe('') + }) + + it('offers only the yard\'s own locations for a found pallet', async () => { + const el = await openZone() + const found = Array.from(el.querySelectorAll('button')).find((b) => text(b).includes('Found elsewhere')) as HTMLButtonElement + found.click() + await flush() + await update(el, {}) + const offered = Array.from(el.querySelectorAll('button')).map((b) => text(b)) + expect(offered).toContain('KEL-MAIN/KEL-B') + expect(offered).not.toContain('LK-MAIN/LK-A') + }) + + it('says in words that a count is under the allocated figure, and holds the submit', async () => { + levels = [ROW('i1', 'loc-yard', 'KEL-MAIN', '112.0000', '40.0000')] + const el = await openZone() + const input = q(el, '[data-testid="count-input"]') + input.value = '10' + input.dispatchEvent(new Event('input', { bubbles: true })) + await update(el, {}) + expect(text(q(el, '[data-testid="count-allocated-warning"]'))).toContain('40 allocated to orders') + const submit = Array.from(el.querySelectorAll('button')).find((b) => text(b).startsWith('Submit Count')) as HTMLButtonElement + expect(submit.disabled).toBe(true) + }) + + it('keeps a refused location on the page with its words, and shows done only when every count was written', async () => { + refused = ['loc-zone'] + const el = await openZone() + const set = (name: string, value: string) => { + const i = Array.from(el.querySelectorAll('[data-testid="count-input"]')) + .find((x) => x.getAttribute('aria-label')?.endsWith(name)) as HTMLInputElement + i.value = value + i.dispatchEvent(new Event('input', { bubbles: true })) + } + set('KEL-MAIN', '110') + set('KEL-A', '290') + await update(el, {}) + const submit = Array.from(el.querySelectorAll('button')).find((b) => text(b).startsWith('Submit Count')) as HTMLButtonElement + submit.click() + await flush() + await flush() + await update(el, {}) + expect(posts.length).toBe(2) + expect(el.querySelector('[data-testid="count-done"]')).toBeNull() + const failed = q(el, '[data-testid="count-failed"]') + expect(text(failed)).toContain('KEL-MAIN/KEL-A') + expect(text(failed)).toContain('Name the location you counted.') + // The refused count is still in its box, so it can be corrected and sent again. + const zoneInput = Array.from(el.querySelectorAll('[data-testid="count-input"]')) + .find((x) => x.getAttribute('aria-label')?.endsWith('KEL-A')) as HTMLInputElement + expect(zoneInput.value).toBe('290') + }) +}) diff --git a/web/apps/desk/src/pages/yard/CycleCount.ts b/web/apps/desk/src/pages/yard/CycleCount.ts index 8c9620a76..96da406b1 100644 --- a/web/apps/desk/src/pages/yard/CycleCount.ts +++ b/web/apps/desk/src/pages/yard/CycleCount.ts @@ -8,15 +8,31 @@ import { ToastService } from '../../lib/toast-service.ts'; import { ClipboardCheck, MapPin, ChevronRight, Check, AlertTriangle, Loader2, ScanLine } from 'lucide'; import type { Product } from '../../types/product'; import { InventoryService } from '../../services/InventoryService'; +import { LocationService } from '../../services/LocationService'; +import { apiErrorMessage } from '../../services/apiError'; import { ProductService } from '../../services/product.service'; +import type { Location } from '../../types/location'; import '../../components/BarcodeScanner.ts'; -interface CountItem { - product: Product; +/** One location that holds (or was found to hold) the product, with its own count. */ +interface CountLocation { + location_id: string; + name: string; expected: number; + /** The figure already promised to orders here; a count may not go under it. */ + allocated: number; counted: string; - submitted: boolean; +} + +/** A product counted by location: the yard's own row and each zone or bin, never the yard's sum. */ +interface CountItem { + product: Product; + locations: CountLocation[]; + /** The location the person is counting at; a scan adds to it. Null until chosen when there are several. */ + chosen: string | null; + /** The locations a found pallet may be added from, loaded on first use. */ + picking: boolean; } const ZONES = [ @@ -37,7 +53,11 @@ export class CycleCount extends LitElement { @state() private loading = false; @state() private submitting = false; @state() private submitted = false; + /** Counts the server refused, kept on the page with its words so each can be corrected and sent again. */ + @state() private failures: Array<{ name: string; message: string }> = []; @state() private isScanning = false; + private _yardLocations: Location[] | null = null; + @state() private yardLocations: Location[] = []; updated(changed: Map) { if (changed.has('selectedZone') && this.selectedZone) { @@ -49,17 +69,32 @@ export class CycleCount extends LitElement { if (!this.selectedZone) return; this.loading = true; this.submitted = false; + this.failures = []; try { // Zones slice the catalog, so every page through the cursor is needed (capped at 2000 products). const data: Product[] = await ProductService.listAllProducts(); const zoneIndex = ZONES.findIndex(z => z.code === this.selectedZone); const subset = data.slice(zoneIndex * 7, zoneIndex * 7 + 7); - this.items = subset.map(p => ({ - product: p, - // The inventory adjust route is not converted and counts in numbers, so the on-hand string is read once, here. - expected: Number(p.on_hand), - counted: '', - submitted: false, + // A count is per location: the levels rows of each product are the yard's own row and + // every zone or bin that holds it, each with its own expected figure. + this.items = await Promise.all(subset.map(async (p): Promise => { + const rows = await InventoryService.getInventoryByProduct(p.id); + const locations = rows + .filter(r => r.location_id) + .map(r => ({ + location_id: r.location_id as string, + name: r.location_name || (r.location_id as string), + // The inventory adjust route counts in numbers, so the quantity string is read once, here. + expected: Number(r.quantity), + allocated: Number(r.allocated) || 0, + counted: '', + })); + return { + product: p, + picking: false, + locations, + chosen: locations.length === 1 ? locations[0].location_id : null, + }; })); } catch { this.items = []; @@ -75,55 +110,136 @@ export class CycleCount extends LitElement { ); if (index >= 0) { - const next = [...this.items]; - const currentCount = parseFloat(next[index].counted); + const item = this.items[index]; + if (item.locations.length === 0) { + ToastService.show(`${item.product.description} has no location here: add the one you found it in`, 'error'); + return; + } + // A scan adds one at the location the person chose, never at whichever the list puts first. + const locIdx = item.locations.findIndex(l => l.location_id === item.chosen); + if (locIdx < 0) { + ToastService.show(`Choose the location you are counting ${item.product.description} at, then scan again`, 'error'); + return; + } + const currentCount = parseFloat(item.locations[locIdx].counted); const newCount = isNaN(currentCount) ? 1 : currentCount + 1; - next[index] = { ...next[index], counted: newCount.toString() }; - this.items = next; - ToastService.show(`Scanned: ${this.items[index].product.description} (+1)`, 'success'); + this._setCount(index, locIdx, newCount.toString()); + ToastService.show(`Scanned: ${item.product.description} (+1)`, 'success'); } else { ToastService.show(`Item not found in current zone: ${barcode}`, 'error'); } } - private _updateCount(idx: number, value: string) { + private _setCount(idx: number, locIdx: number, value: string) { const next = [...this.items]; - next[idx] = { ...next[idx], counted: value }; + const locations = [...next[idx].locations]; + locations[locIdx] = { ...locations[locIdx], counted: value }; + next[idx] = { ...next[idx], locations }; + this.items = next; + } + + private _choose(idx: number, locationId: string) { + const next = [...this.items]; + next[idx] = { ...next[idx], chosen: locationId }; + this.items = next; + } + + /** + * The locations a found pallet may be counted at: those of the yard the page is counting, + * read from the yards of the locations already listed (the whole list when none is). + */ + private _offeredLocations(item: CountItem): Location[] { + const yardOf = new Map(this.yardLocations.map(l => [l.id, l.branch_id])); + const yards = new Set(); + for (const it of this.items) { + for (const l of it.locations) { + const y = yardOf.get(l.location_id); + if (y) yards.add(y); + } + } + return this.yardLocations + .filter(l => !item.locations.some(x => x.location_id === l.id)) + .filter(l => yards.size === 0 || (l.branch_id !== null && yards.has(l.branch_id))); + } + + /** A found pallet: add a location the product was not listed in, to count it there. */ + private async _togglePicking(idx: number) { + if (this._yardLocations === null) { + try { + this._yardLocations = await LocationService.listAllLocations(); + } catch { + this._yardLocations = []; + } + this.yardLocations = this._yardLocations; + } + const next = [...this.items]; + next[idx] = { ...next[idx], picking: !next[idx].picking }; + this.items = next; + } + + private _addLocation(idx: number, loc: Location) { + const next = [...this.items]; + const item = next[idx]; + if (!item.locations.some(l => l.location_id === loc.id)) { + item.locations = [...item.locations, { location_id: loc.id, name: loc.path || loc.code, expected: 0, allocated: 0, counted: '' }]; + } + next[idx] = { ...item, picking: false }; this.items = next; } private async _handleSubmit() { this.submitting = true; - let failCount = 0; - for (const item of this.items) { - const counted = parseFloat(item.counted); - if (isNaN(counted)) continue; - if (counted !== item.expected) { + const failures: Array<{ name: string; message: string }> = []; + const next = this.items.map(i => ({ ...i, locations: i.locations.map(l => ({ ...l })) })); + for (const item of next) { + for (const loc of item.locations) { + const counted = parseFloat(loc.counted); + if (isNaN(counted) || counted === loc.expected) continue; + // Each count is written to its own location, so the yard's total after the count is + // the sum of the counts and never one figure forced onto one row. try { await InventoryService.adjustStock({ product_id: item.product.id, + location_id: loc.location_id, quantity: counted, reason: `Cycle count - Zone ${this.selectedZone}`, is_delta: false, }); - } catch { - failCount++; + // Written: the figure is now what was counted, so it is not sent twice. + loc.expected = counted; + loc.counted = ''; + } catch (err) { + failures.push({ + name: `${item.product.sku} at ${loc.name}`, + message: apiErrorMessage(err, 'Failed to adjust stock'), + }); console.error('Failed to adjust stock'); } } } - if (failCount > 0) { - ToastService.show(`${failCount} adjustment(s) failed to save`, 'error'); + this.items = next; + this.failures = failures; + if (failures.length > 0) { + ToastService.show(`${failures.length} count(s) were not written. They stay on the page with the reason.`, 'error'); } this.submitting = false; - this.submitted = true; + // The done screen is for a count that was written in full. + this.submitted = failures.length === 0; } - private get _countedCount() { return this.items.filter(i => i.counted !== '').length; } + private get _allLocations() { return this.items.flatMap(i => i.locations); } + /** True while any count is under the figure already allocated to orders: it would be refused. */ + private get _belowAllocated() { + return this._allLocations.some(l => { + const c = parseFloat(l.counted); + return !isNaN(c) && c < l.allocated; + }); + } + private get _countedCount() { return this._allLocations.filter(l => l.counted !== '').length; } private get _discrepancies() { - return this.items.filter(i => { - const c = parseFloat(i.counted); - return !isNaN(c) && c !== i.expected; + return this._allLocations.filter(l => { + const c = parseFloat(l.counted); + return !isNaN(c) && c !== l.expected; }).length; } @@ -196,7 +312,7 @@ export class CycleCount extends LitElement {
` : this.submitted ? html` -
+
${icon(Check, 32, 'text-emerald-400')}
@@ -212,7 +328,7 @@ export class CycleCount extends LitElement { ` : html`
- ${this._countedCount}/${this.items.length} counted + ${this._countedCount}/${this._allLocations.length} locations counted ${this._discrepancies > 0 ? html` ${icon(AlertTriangle, 12)} @@ -221,47 +337,89 @@ export class CycleCount extends LitElement { ` : nothing}
+ ${this.failures.length > 0 ? html` +
+

Not written. Correct these counts and submit again.

+ ${this.failures.map(f => html`

${f.name}: ${f.message}

`)} +
+ ` : nothing} +
- ${this.items.map((item, idx) => { - const counted = parseFloat(item.counted); - const hasDiscrep = !isNaN(counted) && counted !== item.expected; - return html` -
-
-
-
-
${item.product.description}
-
${item.product.sku}
-
-
-
Expected
-
${item.expected}
-
- this._updateCount(idx, (e.target as HTMLInputElement).value)} - placeholder="-" - class="w-20 text-center bg-black/20 border rounded-lg py-2 font-mono text-sm focus:outline-none transition-colors ${hasDiscrep - ? 'border-amber-400/50 text-amber-400 focus:border-amber-400' - : item.counted !== '' - ? 'border-emerald-500/30 text-emerald-400' - : 'border-white/10 text-white focus:border-amber-400/50' - }" - /> + ${this.items.map((item, idx) => html` +
+
+
+
+
${item.product.description}
+
${item.product.sku}
+
+ ${item.locations.length === 0 ? html` +

No location holds this product here.

+ ` : nothing} + ${item.locations.map((loc, locIdx) => { + const counted = parseFloat(loc.counted); + const hasDiscrep = !isNaN(counted) && counted !== loc.expected; + return html` +
+ +
+
Expected
+
${loc.expected}
+
+ this._setCount(idx, locIdx, (e.target as HTMLInputElement).value)} + placeholder="-" + class="w-20 text-center bg-black/20 border rounded-lg py-2 font-mono text-sm focus:outline-none transition-colors ${hasDiscrep + ? 'border-amber-400/50 text-amber-400 focus:border-amber-400' + : loc.counted !== '' + ? 'border-emerald-500/30 text-emerald-400' + : 'border-white/10 text-white focus:border-amber-400/50' + }" + /> +
+ ${!isNaN(counted) && counted < loc.allocated ? html` +

+ ${counted} is below the ${loc.allocated} allocated to orders here. Count at least the allocated figure, or release the orders first. +

+ ` : nothing} + `; + })} + ${item.picking ? html` +
+ ${this._offeredLocations(item).map(l => html` + + `)} +
+ ` : nothing}
- `; - })} +
+ `)}