Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
460e6fb
test: a yard's inventory alert sums its own row and its zones (red)
futurebuildai Oct 14, 2026
24686c5
stock: the dashboard's inventory alerts sum a yard's own row and its …
futurebuildai Oct 14, 2026
7fd2456
test: a count naming no location on a multi location yard is refused …
futurebuildai Oct 14, 2026
d7072f9
test: alerts keep yards apart, a legacy row stays its own, each names…
futurebuildai Oct 14, 2026
2aa0893
test: the cycle count lists each location and writes each count to it…
futurebuildai Oct 14, 2026
6e4905c
test: the inventory alerts widget names the yard on each row (red)
futurebuildai Oct 14, 2026
1a7ace2
stock: a count names its location, or is refused in words
futurebuildai Oct 14, 2026
6e75de5
stock: each alert row names its yard, a legacy row stays its own
futurebuildai Oct 14, 2026
f4a59b7
desk: the cycle count counts by location and each alert shows its yard
futurebuildai Oct 14, 2026
79cb54a
Merge refactor/v1 into refactor/g1-yard-stock
futurebuildai Oct 14, 2026
cdf1a44
test: a count with no location is refused without a branch context, a…
futurebuildai Oct 14, 2026
abb71b6
test: a scan goes where the person chose, found elsewhere offers the …
futurebuildai Oct 14, 2026
30b6b86
stock: a count names its location without a branch context too, refus…
futurebuildai Oct 14, 2026
621f154
desk: a scan goes where the person chose, found elsewhere offers the …
futurebuildai Oct 14, 2026
1b760cf
contract: the adjust route's refusals, generated types, the round's row
futurebuildai Oct 14, 2026
e0df1ed
test: a delta with no location is refused without a branch context (red)
futurebuildai Oct 14, 2026
b4888bc
stock: a delta with no location is refused without a branch context too
futurebuildai Oct 14, 2026
91b2d48
characterization: stock seeded by a named location, two goldens follow
futurebuildai Oct 14, 2026
822a703
contract: the adjust route's delta refusal, the round's row
futurebuildai Oct 14, 2026
a1ec671
Merge refactor/v1 into refactor/g1-yard-stock
futurebuildai Oct 15, 2026
5c1a01d
test: a bound caller with no row in their yard is refused, not writte…
futurebuildai Oct 15, 2026
458e280
inventory: the write never creates a row with no location
futurebuildai Oct 15, 2026
15e155c
test: Cycle count keeps refused locations on the page and shows done …
futurebuildai Oct 15, 2026
0b522a5
desk: Cycle count shows done only when every count was written
futurebuildai Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions core/api/fragments/dashboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,13 @@ paths:
summary: Products at or below their reorder point
description: >-
Roles admin, owner, finance, branch scoped. At most 10 rows, lowest
stock first. A product with no inventory row counts as zero stock. A
reorder point of NULL counts as 10.
stock first, one row per product and yard. A yard's stock is its own
row plus every zone and bin under it, and that sum is what is
compared with the reorder point and reported. A product with no
inventory row counts as zero stock (an administrator with no branch
header sees that; a caller bound to a yard sees only products that
yard holds a row of, so a product held only at another yard gives no
alert here). A reorder point of NULL counts as 10.
parameters:
- $ref: "#/components/parameters/XBranchId"
responses:
Expand Down Expand Up @@ -181,7 +186,10 @@ components:
location_id:
type: string
format: uuid
description: Absent when the product has no inventory row.
description: The yard (the branch) the stock sum is for. Absent when the product has no inventory row.
yard_name:
type: string
description: The yard's name (its code when it has none). Absent with no yard.
DashboardTopCustomer:
type: object
description: dashboard.TopCustomer. Money is int64 cents.
Expand Down
9 changes: 8 additions & 1 deletion core/api/fragments/inventory.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,14 @@ paths:
tags:
- inventory
summary: Adjust inventory (cycle count)
description: A result that would leave negative stock answers 500.
description: >-
A result that would leave negative stock answers 422 insufficient_stock.
An adjustment that names no location (a count or a delta) answers 422
location_required where the caller has no branch context; an absolute
count (is_delta false) that names none answers it too where the
product is held in more than one location in the caller's reach: a
count is for one location. An absolute count below the figure already
allocated to orders at that location answers 422 below_allocated.
parameters:
- $ref: '#/components/parameters/XBranchId'
- $ref: '#/components/parameters/IdempotencyKey'
Expand Down
10 changes: 7 additions & 3 deletions core/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3117,7 +3117,7 @@ paths:
tags: [dashboard]
summary: Products at or below their reorder point
description: >-
Roles admin, owner, finance, branch scoped. At most 10 rows, lowest stock first. A product with no inventory row counts as zero stock. A reorder point of NULL counts as 10.
Roles admin, owner, finance, branch scoped. At most 10 rows, lowest stock first, one row per product and yard. A yard's stock is its own row plus every zone and bin under it, and that sum is what is compared with the reorder point and reported. A product with no inventory row counts as zero stock (an administrator with no branch header sees that; a caller bound to a yard sees only products that yard holds a row of, so a product held only at another yard gives no alert here). A reorder point of NULL counts as 10.
parameters:
- $ref: "#/components/parameters/XBranchId"
responses:
Expand Down Expand Up @@ -6787,7 +6787,8 @@ paths:
tags:
- inventory
summary: Adjust inventory (cycle count)
description: A result that would leave negative stock answers 500.
description: >-
A result that would leave negative stock answers 422 insufficient_stock. An adjustment that names no location (a count or a delta) answers 422 location_required where the caller has no branch context; an absolute count (is_delta false) that names none answers it too where the product is held in more than one location in the caller's reach: a count is for one location. An absolute count below the figure already allocated to orders at that location answers 422 below_allocated.
parameters:
- $ref: '#/components/parameters/XBranchId'
- $ref: '#/components/parameters/IdempotencyKey'
Expand Down Expand Up @@ -18233,7 +18234,10 @@ components:
location_id:
type: string
format: uuid
description: Absent when the product has no inventory row.
description: The yard (the branch) the stock sum is for. Absent when the product has no inventory row.
yard_name:
type: string
description: The yard's name (its code when it has none). Absent with no yard.
DashboardTopCustomer:
type: object
description: dashboard.TopCustomer. Money is int64 cents.
Expand Down
8 changes: 5 additions & 3 deletions core/internal/app/serve/wire_noheader_round2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,8 +597,10 @@ func noHeaderDashboardLeg(t *testing.T) {
if trendTotal != 500 {
t.Errorf("no header revenue trend sums %d cents, want A's 500", trendTotal)
}
// The inventory alerts arm: A's yard row only, through the stocking
// location's branch.
// The inventory alerts arm: A's yard only, through the stocking
// location's branch; an alert is one row per product and yard, and its
// location is the yard (the branch), the sum of the yard's own row and
// every zone and bin under it (item 130, N20).
status, buf = f.userAs(t, "finance", "GET", "/api/v1/dashboard/inventory-alerts", "", nil)
if status != http.StatusOK {
t.Fatalf("no header inventory alerts: %d %s", status, buf)
Expand All @@ -615,7 +617,7 @@ func noHeaderDashboardLeg(t *testing.T) {
for _, a := range alerts {
if a.ProductID == f.posProd.String() {
rows++
if a.LocationID != f.yardA.String() {
if a.LocationID != f.branchA.String() {
t.Errorf("no header inventory alert for the product sits at %s, want A's yard", a.LocationID)
}
}
Expand Down
6 changes: 3 additions & 3 deletions core/internal/characterization/scenarios_r1b_c_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,11 +502,11 @@ func r1bCPOSGroups() []groupDef {
name: "pos.till.current.c", method: "GET", path: "/api/v1/pos/till/current?register_id=REG-01",
extract: map[string]string{"c_till": "/id"},
},
// Sales deduct stock at the unassigned location, so seed some there.
// Seed the sales stock at the yard: an adjustment names its location.
{name: "pos.setup.stock.product", method: "POST", path: "/api/v1/inventory/adjust",
body: map[string]any{"product_id": "{product}", "quantity": 50, "reason": "r1b c pos setup", "is_delta": true}},
body: map[string]any{"product_id": "{product}", "location_id": "{branch}", "quantity": 50, "reason": "r1b c pos setup", "is_delta": true}},
{name: "pos.setup.stock.sheet", method: "POST", path: "/api/v1/inventory/adjust",
body: map[string]any{"product_id": "{productSheet}", "quantity": 10, "reason": "r1b c pos setup", "is_delta": true}},
body: map[string]any{"product_id": "{productSheet}", "location_id": "{branch}", "quantity": 10, "reason": "r1b c pos setup", "is_delta": true}},
{name: "pos.till.open.c_duplicate", method: "POST", path: "/api/v1/pos/till/open",
body: map[string]any{"register_id": "REG-01", "opening_float_cents": 5000}},
{name: "pos.till.open.bad_body", method: "POST", path: "/api/v1/pos/till/open", body: "not an object"},
Expand Down
2 changes: 1 addition & 1 deletion core/internal/characterization/scenarios_sales_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,7 @@ func inventoryGroups() []groupDef {
method: "POST",
path: "/api/v1/inventory/adjust",
body: map[string]any{
"product_id": "{productSheet}", "quantity": 25,
"product_id": "{productSheet}", "location_id": "{branch}", "quantity": 25,
"reason": "golden characterisation", "is_delta": true,
},
},
Expand Down
27 changes: 18 additions & 9 deletions core/internal/characterization/testdata/goldens/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"name": "Flashing J 6 X 6 X 10",
"product_id": "<id-3>",
"reorder_qty": 20,
"sku": "CORN2006"
"sku": "CORN2006",
"yard_name": "Lake Country Outpost"
},
{
"alert_type": "LOW_STOCK",
Expand All @@ -33,7 +34,8 @@
"name": "Sheathing 1/8 X 4 X 9 (Green) NSP Weather Barrier",
"product_id": "<id-4>",
"reorder_qty": 40,
"sku": "CORNSHTGR49"
"sku": "CORNSHTGR49",
"yard_name": "Lake Country Outpost"
},
{
"alert_type": "LOW_STOCK",
Expand All @@ -42,7 +44,8 @@
"name": "Face Mount Joist Hanger 2x8",
"product_id": "<id-5>",
"reorder_qty": 150,
"sku": "HANGER-28FM"
"sku": "HANGER-28FM",
"yard_name": "Lake Country Outpost"
},
{
"alert_type": "LOW_STOCK",
Expand All @@ -51,7 +54,8 @@
"name": "1x6x6 Cedar Fence Board",
"product_id": "<id-6>",
"reorder_qty": 200,
"sku": "LUM-166-WRC"
"sku": "LUM-166-WRC",
"yard_name": "Lake Country Outpost"
},
{
"alert_type": "LOW_STOCK",
Expand All @@ -60,7 +64,8 @@
"name": "2x6x12 SPF Premium",
"product_id": "<id-7>",
"reorder_qty": 100,
"sku": "LUM-2612-PREM"
"sku": "LUM-2612-PREM",
"yard_name": "Lake Country Outpost"
},
{
"alert_type": "LOW_STOCK",
Expand All @@ -69,7 +74,8 @@
"name": "Roof Edge Drip 10'",
"product_id": "<id-8>",
"reorder_qty": 60,
"sku": "RF-DRIP-10"
"sku": "RF-DRIP-10",
"yard_name": "Lake Country Outpost"
},
{
"alert_type": "LOW_STOCK",
Expand All @@ -78,7 +84,8 @@
"name": "Vented Soffit Fibre Cement Textured 1/4 X 24 X 8",
"product_id": "<id-9>",
"reorder_qty": 20,
"sku": "CORNSFT2408V"
"sku": "CORNSFT2408V",
"yard_name": "Lake Country Outpost"
},
{
"alert_type": "LOW_STOCK",
Expand All @@ -87,7 +94,8 @@
"name": "2x4x8 SPF Premium",
"product_id": "<id-10>",
"reorder_qty": 200,
"sku": "LUM-248-PREM"
"sku": "LUM-248-PREM",
"yard_name": "Lake Country Outpost"
},
{
"alert_type": "LOW_STOCK",
Expand All @@ -96,7 +104,8 @@
"name": "Ice and Water Barrier 65'",
"product_id": "<id-11>",
"reorder_qty": 20,
"sku": "RF-ICE-65"
"sku": "RF-ICE-65",
"yard_name": "Lake Country Outpost"
}
]
}
Expand Down
27 changes: 14 additions & 13 deletions core/internal/characterization/testdata/goldens/inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"path": "/api/v1/inventory/adjust",
"body": {
"is_delta": true,
"product_id": "<id-2>",
"location_id": "<id-2>",
"product_id": "<id-3>",
"quantity": 25,
"reason": "golden characterisation"
}
Expand All @@ -46,17 +47,6 @@
"content_type": "application/json",
"body": {
"items": [
{
"allocated": "0",
"available": "25",
"id": "<id-3>",
"location_id": null,
"location_name": "",
"product_id": "<id-2>",
"quantity": "25",
"uom": "PCS",
"updated_at": "<ts+0d>"
},
{
"allocated": "0",
"available": "67",
Expand All @@ -67,11 +57,22 @@
"quantity": "67",
"uom": "LF",
"updated_at": "<ts+0d>"
},
{
"allocated": "0",
"available": "51",
"id": "<id-7>",
"location_id": "<id-8>",
"location_name": "WK",
"product_id": "<id-6>",
"quantity": "51",
"uom": "LF",
"updated_at": "<ts+0d>"
}
],
"limit": 2,
"next_cursor": "<cursor>",
"total": 193
"total": 192
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"path": "/api/v1/inventory/adjust",
"body": {
"is_delta": true,
"location_id": "<id-1>",
"product_id": "<id-4>",
"quantity": 50,
"reason": "r1b c pos setup"
Expand All @@ -52,6 +53,7 @@
"path": "/api/v1/inventory/adjust",
"body": {
"is_delta": true,
"location_id": "<id-1>",
"product_id": "<id-5>",
"quantity": 10,
"reason": "r1b c pos setup"
Expand Down
80 changes: 80 additions & 0 deletions core/internal/dashboard/inventory_alerts_pg_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading