From 460e6fb30e08f64cdcfaa1951107b54619fabe02 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:17:47 -0700 Subject: [PATCH 01/22] test: a yard's inventory alert sums its own row and its zones (red) Item 130, N20. A yard holding 112 on its own row and 300 in a zone is above a reorder point of 150; the alert reads one row per location and reports it low. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../dashboard/inventory_alerts_pg_test.go | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 core/internal/dashboard/inventory_alerts_pg_test.go 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) + } +} From 24686c56c114720966f11ef809190293e29aaf39 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:29:14 -0700 Subject: [PATCH 02/22] stock: the dashboard's inventory alerts sum a yard's own row and its zones An inventory row sits at one location, the yard or a zone or bin under it, and every location carries its yard in branch_id. The alert compared one row with the reorder point; it now sums every row whose location is in the yard, as the counter's search does, reports the sum and names the yard. The serve wire test that read the alert's location expects the yard. Item 130, N20. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/fragments/dashboard.yaml | 9 +++++--- core/api/openapi.yaml | 4 ++-- .../app/serve/wire_noheader_round2_test.go | 8 ++++--- core/internal/dashboard/repository.go | 21 ++++++++++++------- docs/refactor/CONTRACT-CHANGES.md | 1 + web/packages/api-client/src/schema.d.ts | 4 ++-- 6 files changed, 29 insertions(+), 18 deletions(-) diff --git a/core/api/fragments/dashboard.yaml b/core/api/fragments/dashboard.yaml index 3a0a7d891..af2f172b9 100644 --- a/core/api/fragments/dashboard.yaml +++ b/core/api/fragments/dashboard.yaml @@ -40,8 +40,11 @@ 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. A reorder point of NULL counts + as 10. parameters: - $ref: "#/components/parameters/XBranchId" responses: @@ -181,7 +184,7 @@ 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. DashboardTopCustomer: type: object description: dashboard.TopCustomer. Money is int64 cents. diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index d889a3c88..904145cbe 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. A reorder point of NULL counts as 10. parameters: - $ref: "#/components/parameters/XBranchId" responses: @@ -18233,7 +18233,7 @@ 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. 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 be5d10a15..709657d92 100644 --- a/core/internal/app/serve/wire_noheader_round2_test.go +++ b/core/internal/app/serve/wire_noheader_round2_test.go @@ -577,8 +577,10 @@ func TestNoHeader_Dashboard(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) @@ -595,7 +597,7 @@ func TestNoHeader_Dashboard(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/dashboard/repository.go b/core/internal/dashboard/repository.go index 64ceaf79e..a94091687 100644 --- a/core/internal/dashboard/repository.go +++ b/core/internal/dashboard/repository.go @@ -164,25 +164,30 @@ 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. 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 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 + WHERE ` + middleware.Wall("l", 2, 3) + ` + GROUP BY p.id, p.sku, p.description, p.reorder_point, l.branch_id + 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) diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 147c70b8f..acc987547 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -427,3 +427,4 @@ 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. | diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 84c3cf3fc..9b869953b 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -1219,7 +1219,7 @@ export interface paths { }; /** * 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. + * @description 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. A reorder point of NULL counts as 10. */ get: operations["dashboardInventoryAlerts"]; put?: never; @@ -8532,7 +8532,7 @@ export interface components { alert_type: "LOW_STOCK" | "OUT_OF_STOCK"; /** * 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. */ location_id?: string; }; From 7fd245609755bcc013f2759edd5e94403dc0a417 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 14:40:44 -0700 Subject: [PATCH 03/22] test: a count naming no location on a multi location yard is refused (red) Item 130, N20, review P2-1. An absolute count with no location sets the yard's oldest row: 410 against 112 and 300 made the yard total 710. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../internal/inventory/cycle_count_pg_test.go | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 core/internal/inventory/cycle_count_pg_test.go 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) + } +} From d7072f985fec9681244492b6bae28abd0c273f25 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 14:40:44 -0700 Subject: [PATCH 04/22] test: alerts keep yards apart, a legacy row stays its own, each names its yard The first two legs pin the grouping (they pass on the base); the third is red: no alert carries its yard's name. Item 130, N20, review P3s. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../inventory_alerts_yards_pg_test.go | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 core/internal/dashboard/inventory_alerts_yards_pg_test.go 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) + } +} From 2aa0893f6f7e6518f10f90e29c0a1d7bdf564d0f Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 14:40:44 -0700 Subject: [PATCH 05/22] test: the cycle count lists each location and writes each count to its own (red) Item 130, N20, review P2-1. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../desk/src/pages/yard/CycleCount.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 web/apps/desk/src/pages/yard/CycleCount.test.ts 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..1db781a82 --- /dev/null +++ b/web/apps/desk/src/pages/yard/CycleCount.test.ts @@ -0,0 +1,85 @@ +// 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) => ({ + id, product_id: PRODUCT.id, location_id: loc, location_name: name, + quantity: qty, allocated: '0.0000', available: qty, uom: 'EA', updated_at: '2026-01-01T00:00:00Z', +}) + +let posts: Array> + +beforeEach(() => { + localStorage.setItem('token', 'test-token') + posts = [] + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.includes('/api/v1/inventory/adjust')) { + posts.push(JSON.parse(String(init?.body))) + return jsonResponse({ status: 'ok' }) + } + if (url.includes('/api/v1/inventory')) { + return jsonResponse({ items: [ROW('i1', 'loc-yard', 'KEL-MAIN', '112.0000'), ROW('i2', 'loc-zone', 'KEL-MAIN/KEL-A', '300.0000')], next_cursor: null }) + } + 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) + expect(text(rows[0])).toContain('KEL-MAIN') + expect(text(rows[0])).toContain('112') + expect(text(rows[1])).toContain('KEL-A') + expect(text(rows[1])).toContain('300') + }) + + 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"]')) + inputs[0].value = '110' + inputs[0].dispatchEvent(new Event('input', { bubbles: true })) + inputs[1].value = '300' + inputs[1].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() + }) +}) From 6e4905ce8e519917fe84bcb8ce739758767e655a Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 14:40:45 -0700 Subject: [PATCH 06/22] test: the inventory alerts widget names the yard on each row (red) Item 130, N20, review P3. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../dashboard/InventoryAlertsWidget.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 web/apps/desk/src/components/dashboard/InventoryAlertsWidget.test.ts 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']) + }) +}) From 1a7ace2e776290a6a7808c4d701fc3ffd7b693a0 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 14:50:29 -0700 Subject: [PATCH 07/22] stock: a count names its location, or is refused in words An absolute count with no location, for a product held in more than one location in reach, answers 422 location_required and writes nothing; it used to set the yard's oldest row, turning a count of 410 against 112 and 300 into a yard total of 710. A delta with no location keeps the stated picks. The one older test that pinned the guess now pins the refusal and the delta. Item 130, N20, review P2-1. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/fragments/inventory.yaml | 6 ++- .../inventory/branch_location_row_test.go | 41 +++++++++++++------ core/internal/inventory/handler.go | 5 +++ core/internal/inventory/service.go | 18 ++++++++ 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/core/api/fragments/inventory.yaml b/core/api/fragments/inventory.yaml index 2507996dc..a828b40f1 100644 --- a/core/api/fragments/inventory.yaml +++ b/core/api/fragments/inventory.yaml @@ -73,7 +73,11 @@ 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 500. An absolute count + (is_delta false) that names no location, for a product held in more than + one location in the caller's reach, answers 422 with the code + location_required: a count is for one location. parameters: - $ref: '#/components/parameters/XBranchId' - $ref: '#/components/parameters/IdempotencyKey' diff --git a/core/internal/inventory/branch_location_row_test.go b/core/internal/inventory/branch_location_row_test.go index d2890adb6..5d92907d3 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,10 +245,22 @@ 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 keeps the stated picks. An unbound caller: the + // legacy unassigned row 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}); err != nil { + t.Fatalf("unbound delta with no location: %v", err) } var legacy string if err := db.Pool.QueryRow(context.Background(), @@ -256,22 +271,22 @@ func TestAnAdjustWithNoLocationTakesTheStatedRow(t *testing.T) { t.Errorf("the unassigned row = %s, want 41.0000 (the unbound caller's legacy row)", 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, // 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/handler.go b/core/internal/inventory/handler.go index c3f91920d..b606cb928 100644 --- a/core/internal/inventory/handler.go +++ b/core/internal/inventory/handler.go @@ -94,6 +94,11 @@ 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: "This product is held in more than one location here. Count each location and name the one you counted."}) + 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..f6d29c4e7 100644 --- a/core/internal/inventory/service.go +++ b/core/internal/inventory/service.go @@ -26,6 +26,12 @@ 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") + type Service struct { repo Repository levels LevelStore // the levels read's store (C3-1b), when the repo can serve it @@ -44,6 +50,18 @@ 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 can only be a guess when the product sits in several, so it is + // refused in words, never written to the oldest row. + if req.LocationID == nil && !req.IsDelta { + rows, err := s.repo.ListInventoryByProductAndBranch(ctx, req.ProductID, branchctx.IDForQuery(ctx)) + if err != nil { + return err + } + if len(rows) > 1 { + return ErrCountNeedsLocation + } + } // 1. Check if inventory record exists inv, err := s.repo.GetInventory(ctx, req.ProductID, req.LocationID) if err != nil { From 6e75de58cab4d61d68483fe9b18bdb5ebba7d125 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 14:50:29 -0700 Subject: [PATCH 08/22] stock: each alert row names its yard, a legacy row stays its own The yard's name (its code when it has none) rides each alert, grouping keeps a row with no yard apart, and the contract text says what a branch caller sees for a product its yard holds no row of. The dashboard golden gains yard_name. Item 130, N20, review P3s. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/fragments/dashboard.yaml | 9 +++++-- .../testdata/goldens/dashboard.json | 27 ++++++++++++------- core/internal/dashboard/model.go | 1 + core/internal/dashboard/repository.go | 11 +++++--- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/core/api/fragments/dashboard.yaml b/core/api/fragments/dashboard.yaml index af2f172b9..813a151d3 100644 --- a/core/api/fragments/dashboard.yaml +++ b/core/api/fragments/dashboard.yaml @@ -43,8 +43,10 @@ paths: 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. A reorder point of NULL counts - as 10. + 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: @@ -185,6 +187,9 @@ components: type: string format: uuid 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/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/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 a94091687..8c62cc92a 100644 --- a/core/internal/dashboard/repository.go +++ b/core/internal/dashboard/repository.go @@ -169,7 +169,8 @@ func (r *PostgresRepository) GetDashboardSummary(ctx context.Context, branchID * // 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. +// 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 @@ -180,12 +181,14 @@ func (r *PostgresRepository) GetInventoryAlerts(ctx context.Context, branchID *u WHEN COALESCE(SUM(i.quantity), 0) = 0 THEN 'OUT_OF_STOCK' ELSE 'LOW_STOCK' END as alert_type, - COALESCE(l.branch_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 + 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 + GROUP BY p.id, p.sku, p.description, p.reorder_point, l.branch_id, y.name, y.code, COALESCE(l.branch_id, i.location_id) 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 @@ -199,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) From f4a59b74d4a3babc9c61eb4afe6973566ef3ddda Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 14:50:29 -0700 Subject: [PATCH 09/22] desk: the cycle count counts by location and each alert shows its yard The page lists the yard's own row and each zone or bin that holds the product with its own expected figure, writes each count to its own location and can add a location the product was not in. The alerts widget shows the yard's name. Generated types and the CONTRACT-CHANGES row follow. Item 130, N20, review P2-1 and P3. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/openapi.yaml | 8 +- docs/refactor/CONTRACT-CHANGES.md | 1 + .../dashboard/InventoryAlertsWidget.ts | 3 +- web/apps/desk/src/pages/yard/CycleCount.ts | 196 +++++++++++++----- web/apps/desk/src/types/dashboard.ts | 2 + web/packages/api-client/src/schema.d.ts | 6 +- 6 files changed, 155 insertions(+), 61 deletions(-) diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index 904145cbe..bb363ad3d 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, 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. 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 500. An absolute count (is_delta false) that names no location, for a product held in more than one location in the caller's reach, answers 422 with the code location_required: a count is for one location. parameters: - $ref: '#/components/parameters/XBranchId' - $ref: '#/components/parameters/IdempotencyKey' @@ -18234,6 +18235,9 @@ components: type: string format: uuid 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/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index acc987547..b9d9bad8a 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -428,3 +428,4 @@ get an entry. | 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. | 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.ts b/web/apps/desk/src/pages/yard/CycleCount.ts index 0672102d0..2cc019209 100644 --- a/web/apps/desk/src/pages/yard/CycleCount.ts +++ b/web/apps/desk/src/pages/yard/CycleCount.ts @@ -8,15 +8,26 @@ 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 { 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; 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 locations a found pallet may be added from, loaded on first use. */ + picking: boolean; } const ZONES = [ @@ -38,6 +49,8 @@ export class CycleCount extends LitElement { @state() private submitting = false; @state() private submitted = false; @state() private isScanning = false; + private _yardLocations: Location[] | null = null; + @state() private yardLocations: Location[] = []; updated(changed: Map) { if (changed.has('selectedZone') && this.selectedZone) { @@ -54,12 +67,23 @@ export class CycleCount extends LitElement { 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); + return { + product: p, + picking: false, + 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), + counted: '', + })), + }; })); } catch { this.items = []; @@ -75,20 +99,52 @@ 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 to the first location listed; the person moves it with the count field. + const first = item.locations[0]; + const currentCount = parseFloat(first.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, 0, 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; + } + + /** 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, counted: '' }]; + } + next[idx] = { ...item, picking: false }; this.items = next; } @@ -96,12 +152,15 @@ export class CycleCount extends LitElement { 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) { + 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, @@ -119,11 +178,12 @@ export class CycleCount extends LitElement { this.submitted = true; } - private get _countedCount() { return this.items.filter(i => i.counted !== '').length; } + private get _allLocations() { return this.items.flatMap(i => i.locations); } + 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; } @@ -141,7 +201,7 @@ export class CycleCount extends LitElement {

Select a zone to count:

${ZONES.map(z => html`
{ this.selectedZone = z.code; }} >
@@ -196,7 +256,7 @@ export class CycleCount extends LitElement {
` : this.submitted ? html` -
+
${icon(Check, 32, 'text-emerald-400')}
@@ -212,7 +272,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)} @@ -222,39 +282,63 @@ export class CycleCount extends LitElement {
- ${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` +
+
${loc.name}
+
+
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' + }" + /> +
+ `; + })} + ${item.picking ? html` +
+ ${this.yardLocations.filter(l => !item.locations.some(x => x.location_id === l.id)).map(l => html` + + `)} +
+ ` : nothing}
- `; - })} +
+ `)}
diff --git a/web/apps/desk/src/types/dashboard.ts b/web/apps/desk/src/types/dashboard.ts index 19727a4d3..a8adf3215 100644 --- a/web/apps/desk/src/types/dashboard.ts +++ b/web/apps/desk/src/types/dashboard.ts @@ -20,6 +20,8 @@ export interface InventoryAlert { reorder_qty: number; alert_type: 'LOW_STOCK' | 'OUT_OF_STOCK'; location_id?: string; + /** The yard's name, so two rows for one SKU can be told apart. */ + yard_name?: string; } export interface TopCustomer { diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 9b869953b..4d2cfe5e4 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -1219,7 +1219,7 @@ export interface paths { }; /** * Products at or below their reorder point - * @description 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. A reorder point of NULL counts as 10. + * @description 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. */ get: operations["dashboardInventoryAlerts"]; put?: never; @@ -2852,7 +2852,7 @@ export interface paths { put?: never; /** * Adjust inventory (cycle count) - * @description A result that would leave negative stock answers 500. + * @description A result that would leave negative stock answers 500. An absolute count (is_delta false) that names no location, for a product held in more than one location in the caller's reach, answers 422 with the code location_required: a count is for one location. */ post: operations["inventoryAdjust"]; delete?: never; @@ -8535,6 +8535,8 @@ export interface components { * @description The yard (the branch) the stock sum is for. Absent when the product has no inventory row. */ location_id?: string; + /** @description The yard's name (its code when it has none). Absent with no yard. */ + yard_name?: string; }; /** @description dashboard.TopCustomer. Money is int64 cents. */ DashboardTopCustomer: { From cdf1a44baf137591ec57e3940c4624dc38c1d168 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:44:18 -0700 Subject: [PATCH 10/22] test: a count with no location is refused without a branch context, and in words below allocated (red) Item 130, N20, review round 2 P2-1 and P3s. Three callers with no branch context turn 50 counted as 45 into 95; a delta the oldest row cannot take is a bare 500; a count under the allocated figure is accepted. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../inventory/count_context_pg_test.go | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 core/internal/inventory/count_context_pg_test.go 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..7c5c552a0 --- /dev/null +++ b/core/internal/inventory/count_context_pg_test.go @@ -0,0 +1,194 @@ +// 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()) + } +} From abb71b6fb449b7573dadf3974036ec67330a5d2f Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:44:35 -0700 Subject: [PATCH 11/22] test: a scan goes where the person chose, found elsewhere offers the yard, under allocated is said (red) Item 130, N20, review round 2 P3-2, P3-3, P3-5. The mock lists the newest row first, as the server does. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../desk/src/pages/yard/CycleCount.test.ts | 77 ++++++++++++++++--- 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/web/apps/desk/src/pages/yard/CycleCount.test.ts b/web/apps/desk/src/pages/yard/CycleCount.test.ts index 1db781a82..dc7ab3ef4 100644 --- a/web/apps/desk/src/pages/yard/CycleCount.test.ts +++ b/web/apps/desk/src/pages/yard/CycleCount.test.ts @@ -13,15 +13,20 @@ 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) => ({ +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: '0.0000', available: qty, uom: 'EA', updated_at: '2026-01-01T00:00:00Z', + 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> 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 = [] vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) @@ -30,7 +35,13 @@ beforeEach(() => { return jsonResponse({ status: 'ok' }) } if (url.includes('/api/v1/inventory')) { - return jsonResponse({ items: [ROW('i1', 'loc-yard', 'KEL-MAIN', '112.0000'), ROW('i2', 'loc-zone', 'KEL-MAIN/KEL-A', '300.0000')], next_cursor: null }) + 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 }) @@ -57,19 +68,20 @@ describe('Cycle count by location', () => { const el = await openZone() const rows = Array.from(el.querySelectorAll('[data-testid="count-location"]')) expect(rows.length).toBe(2) - expect(text(rows[0])).toContain('KEL-MAIN') - expect(text(rows[0])).toContain('112') - expect(text(rows[1])).toContain('KEL-A') - expect(text(rows[1])).toContain('300') + 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"]')) - inputs[0].value = '110' - inputs[0].dispatchEvent(new Event('input', { bubbles: true })) - inputs[1].value = '300' - inputs[1].dispatchEvent(new Event('input', { bubbles: true })) + 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() @@ -82,4 +94,47 @@ describe('Cycle count by location', () => { ]) 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) + }) }) From 30b6b8681f749b27cac087d5b4893410ab95b8d9 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:52:02 -0700 Subject: [PATCH 12/22] stock: a count names its location without a branch context too, refusals speak in words An absolute count that names no location is refused whenever the caller has no branch context and where the product sits in several locations in reach, read through the caller's branch or grants. A delta the oldest row cannot take is a 422 insufficient_stock, a count under the allocated figure a 422 below_allocated. The alert grouping drops a term that changed nothing. Item 130, N20, review round 2 P2-1 and P3s. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/fragments/inventory.yaml | 10 +++++---- core/internal/dashboard/repository.go | 2 +- core/internal/inventory/handler.go | 12 +++++++++++ core/internal/inventory/service.go | 30 +++++++++++++++++++++++---- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/core/api/fragments/inventory.yaml b/core/api/fragments/inventory.yaml index a828b40f1..044363e6a 100644 --- a/core/api/fragments/inventory.yaml +++ b/core/api/fragments/inventory.yaml @@ -74,10 +74,12 @@ paths: - inventory summary: Adjust inventory (cycle count) description: >- - A result that would leave negative stock answers 500. An absolute count - (is_delta false) that names no location, for a product held in more than - one location in the caller's reach, answers 422 with the code - location_required: a count is for one location. + A result that would leave negative stock answers 422 insufficient_stock. + An absolute count (is_delta false) that names no location answers 422 + location_required where the caller has no branch context, or 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/internal/dashboard/repository.go b/core/internal/dashboard/repository.go index 8c62cc92a..f53c32a24 100644 --- a/core/internal/dashboard/repository.go +++ b/core/internal/dashboard/repository.go @@ -188,7 +188,7 @@ func (r *PostgresRepository) GetInventoryAlerts(ctx context.Context, branchID *u LEFT JOIN locations l ON l.id = i.location_id 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, COALESCE(l.branch_id, i.location_id) + 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 diff --git a/core/internal/inventory/handler.go b/core/internal/inventory/handler.go index b606cb928..2d830cac7 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" @@ -99,6 +100,17 @@ func (h *Handler) AdjustStock(w http.ResponseWriter, r *http.Request) { Message: "This product is held in more than one location here. Count each location and name the one you counted."}) 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 f6d29c4e7..ba7ae9db6 100644 --- a/core/internal/inventory/service.go +++ b/core/internal/inventory/service.go @@ -32,6 +32,19 @@ var ErrLocationOutsideBranch = errors.New("inventory: the named location is not // 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") +// 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 @@ -51,10 +64,16 @@ 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 can only be a guess when the product sits in several, so it is - // refused in words, never written to the oldest row. + // 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 && !req.IsDelta { - rows, err := s.repo.ListInventoryByProductAndBranch(ctx, req.ProductID, branchctx.IDForQuery(ctx)) + if branchctx.IDForQuery(ctx) == nil { + return ErrCountNeedsLocation + } + rows, err := s.repo.ListInventoryByProduct(ctx, req.ProductID) if err != nil { return err } @@ -88,6 +107,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 } @@ -96,7 +118,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) From 621f154376f014289942d182d738531cd41b5e64 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:52:02 -0700 Subject: [PATCH 13/22] desk: a scan goes where the person chose, found elsewhere offers the yard, under allocated is said Item 130, N20, review round 2 P3-2, P3-3, P3-5. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/apps/desk/src/pages/yard/CycleCount.ts | 94 +++++++++++++++---- .../desk/src/services/InventoryService.ts | 5 +- 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/web/apps/desk/src/pages/yard/CycleCount.ts b/web/apps/desk/src/pages/yard/CycleCount.ts index 2cc019209..b8004ca3a 100644 --- a/web/apps/desk/src/pages/yard/CycleCount.ts +++ b/web/apps/desk/src/pages/yard/CycleCount.ts @@ -9,6 +9,7 @@ import { ClipboardCheck, MapPin, ChevronRight, Check, AlertTriangle, Loader2, Sc 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'; @@ -19,6 +20,8 @@ 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; } @@ -26,6 +29,8 @@ interface CountLocation { 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; } @@ -71,18 +76,21 @@ export class CycleCount extends LitElement { // 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: 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), - counted: '', - })), + locations, + chosen: locations.length === 1 ? locations[0].location_id : null, }; })); } catch { @@ -104,11 +112,15 @@ export class CycleCount extends LitElement { ToastService.show(`${item.product.description} has no location here: add the one you found it in`, 'error'); return; } - // A scan adds one to the first location listed; the person moves it with the count field. - const first = item.locations[0]; - const currentCount = parseFloat(first.counted); + // 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; - this._setCount(index, 0, newCount.toString()); + 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'); @@ -123,6 +135,30 @@ export class CycleCount extends LitElement { 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) { @@ -142,7 +178,7 @@ export class CycleCount extends LitElement { 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, counted: '' }]; + 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; @@ -151,6 +187,7 @@ export class CycleCount extends LitElement { private async _handleSubmit() { this.submitting = true; let failCount = 0; + let firstFailure: string | null = null; for (const item of this.items) { for (const loc of item.locations) { const counted = parseFloat(loc.counted); @@ -165,20 +202,28 @@ export class CycleCount extends LitElement { reason: `Cycle count - Zone ${this.selectedZone}`, is_delta: false, }); - } catch { + } catch (err) { failCount++; + firstFailure ??= apiErrorMessage(err, 'Failed to adjust stock'); console.error('Failed to adjust stock'); } } } if (failCount > 0) { - ToastService.show(`${failCount} adjustment(s) failed to save`, 'error'); + ToastService.show(`${failCount} adjustment(s) failed to save: ${firstFailure}`, 'error'); } this.submitting = false; this.submitted = true; } 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._allLocations.filter(l => { @@ -303,7 +348,13 @@ export class CycleCount extends LitElement { const hasDiscrep = !isNaN(counted) && counted !== loc.expected; return html`
-
${loc.name}
+
Expected
${loc.expected}
@@ -324,11 +375,16 @@ export class CycleCount extends LitElement { }" />
+ ${!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.yardLocations.filter(l => !item.locations.some(x => x.location_id === l.id)).map(l => html` + ${this._offeredLocations(item).map(l => html`
+ ${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) => html`