Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
import json
|
|
|
|
|
from http import HTTPStatus
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from modules.inventory import catalog as catalog_module
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_catalog_defaults():
|
|
|
|
|
"""The shipped catalog_defaults.yaml loads and is non-trivial."""
|
|
|
|
|
defaults = catalog_module.load_catalog_defaults()
|
|
|
|
|
assert len(defaults) > 30
|
|
|
|
|
rice = next(d for d in defaults if d['name'] == 'Long Grain White Rice')
|
|
|
|
|
assert rice['category'] == 'grains'
|
|
|
|
|
assert rice['calories'] == '8040'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_seed_catalog_defaults_and_tombstone(test_catalog_config):
|
|
|
|
|
config = test_catalog_config
|
|
|
|
|
assert config.items == []
|
|
|
|
|
|
|
|
|
|
# Seeding loads all shipped defaults.
|
|
|
|
|
catalog_module.seed_catalog_defaults(config)
|
|
|
|
|
seeded = len(config.items)
|
|
|
|
|
assert seeded == len(catalog_module.load_catalog_defaults())
|
|
|
|
|
assert sorted(config.merged_default_ids) == sorted(d['id'] for d in catalog_module.load_catalog_defaults())
|
|
|
|
|
|
|
|
|
|
# Seeding again is idempotent (nothing re-added).
|
|
|
|
|
catalog_module.seed_catalog_defaults(config)
|
|
|
|
|
assert len(config.items) == seeded
|
|
|
|
|
|
|
|
|
|
# A user-deleted default is NOT re-added on the next seed (tombstoned via merged_default_ids).
|
Add a recommended Food Storage example, a Ration tab, and a supply planner
Build on the rebuilt config-only Inventory with three related additions.
Recommended example inventory:
- A fresh install now seeds the "Food Storage" example with the recommended
one-adult / one-year (~3,000 kcal/day) emergency food supply (60 items,
~1.12M kcal), instead of an empty inventory.
- The counts are layered on top of the shipped food catalog (single source of
per-package nutrition) via RECOMMENDED_ONE_YEAR_FOOD_COUNTS, joined at seed
time by recommended_food_storage_items().
- Genericized the two remaining brand names in the catalog defaults
(Velveeta -> Processed Cheese, Spam -> Canned Lunch Meat) so both the catalog
and the seeded inventory stay brand-free.
Ration tab (split out of Summary):
- Moved the Ration Estimate (People / Daily Calories / estimate table) out of
the increasingly long Summary tab into its own "Ration" tab, shown only for
inventories that have a calories field.
Supply planner ("Plan More Supply"):
- A slider picks a target duration longer than the current estimate; the whole
inventory is scaled up proportionally (every item by the same factor, mix
kept balanced) into a sortable shopping list of what to buy.
- Any split package rounds UP to a whole one (ceil), matching the "1.6 buckets
-> buy 1 more" rule.
- The shopping list is exportable as CSV (reusing toCSV) and PDF (reusing the
print-to-PDF path, scoped via body.printing-shopping so only the list prints
-- no inventory or summary).
Search filter:
- Moved the items search out of the top toolbar (which implied it filtered
every tab) into the Items tab, so it clearly narrows Items only;
Summary/Ration/Export/print always use the full inventory.
Aggregation/role helpers (findNameKey, planSupplyPurchase) live in summarize.js,
the single source of truth shared by Summary, export, and the ration views.
All new pure functions and UI flows are covered by tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:39:58 -06:00
|
|
|
remaining = [i for i in config.items if i['name'] != 'Canned Lunch Meat']
|
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
catalog_module.save_catalog_items(remaining)
|
|
|
|
|
catalog_module.seed_catalog_defaults(config)
|
Add a recommended Food Storage example, a Ration tab, and a supply planner
Build on the rebuilt config-only Inventory with three related additions.
Recommended example inventory:
- A fresh install now seeds the "Food Storage" example with the recommended
one-adult / one-year (~3,000 kcal/day) emergency food supply (60 items,
~1.12M kcal), instead of an empty inventory.
- The counts are layered on top of the shipped food catalog (single source of
per-package nutrition) via RECOMMENDED_ONE_YEAR_FOOD_COUNTS, joined at seed
time by recommended_food_storage_items().
- Genericized the two remaining brand names in the catalog defaults
(Velveeta -> Processed Cheese, Spam -> Canned Lunch Meat) so both the catalog
and the seeded inventory stay brand-free.
Ration tab (split out of Summary):
- Moved the Ration Estimate (People / Daily Calories / estimate table) out of
the increasingly long Summary tab into its own "Ration" tab, shown only for
inventories that have a calories field.
Supply planner ("Plan More Supply"):
- A slider picks a target duration longer than the current estimate; the whole
inventory is scaled up proportionally (every item by the same factor, mix
kept balanced) into a sortable shopping list of what to buy.
- Any split package rounds UP to a whole one (ceil), matching the "1.6 buckets
-> buy 1 more" rule.
- The shopping list is exportable as CSV (reusing toCSV) and PDF (reusing the
print-to-PDF path, scoped via body.printing-shopping so only the list prints
-- no inventory or summary).
Search filter:
- Moved the items search out of the top toolbar (which implied it filtered
every tab) into the Items tab, so it clearly narrows Items only;
Summary/Ration/Export/print always use the full inventory.
Aggregation/role helpers (findNameKey, planSupplyPurchase) live in summarize.js,
the single source of truth shared by Summary, export, and the ration views.
All new pure functions and UI flows are covered by tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:39:58 -06:00
|
|
|
assert not any(i['name'] == 'Canned Lunch Meat' for i in config.items)
|
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_catalog_file_is_not_loaded_as_an_inventory(test_inventory_configs, test_catalog_config):
|
|
|
|
|
"""catalog.yaml shares config/inventory/ but must never appear as an inventory."""
|
|
|
|
|
# Seed the catalog (writes config/inventory/catalog.yaml) and an inventory.
|
|
|
|
|
catalog_module.seed_catalog_defaults(test_catalog_config)
|
|
|
|
|
test_inventory_configs.create_inventory('Food Storage', 'food')
|
|
|
|
|
# Re-import the inventory directory; the catalog file must be excluded.
|
|
|
|
|
test_inventory_configs.import_all()
|
|
|
|
|
slugs = [i['slug'] for i in test_inventory_configs.all_inventories()]
|
|
|
|
|
assert 'catalog' not in slugs
|
|
|
|
|
assert slugs == ['food-storage']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inventory_named_catalog_does_not_collide(test_inventory_configs):
|
|
|
|
|
"""A user inventory named 'Catalog' must not claim the reserved catalog slug."""
|
|
|
|
|
inv = test_inventory_configs.create_inventory('Catalog', 'food')
|
|
|
|
|
assert inv['slug'] != 'catalog'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_catalog_items_assigns_ids(test_catalog_config):
|
|
|
|
|
saved = catalog_module.save_catalog_items([
|
|
|
|
|
{'name': 'Test Beans', 'category': 'legumes', 'subcategory': 'dry beans',
|
|
|
|
|
'item_size': '25', 'item_size_unit': 'lb', 'calories': '39200'},
|
|
|
|
|
{'name': 'No Id Item', 'category': 'grains'},
|
|
|
|
|
])
|
|
|
|
|
assert [i['name'] for i in saved] == ['Test Beans', 'No Id Item']
|
|
|
|
|
assert all(isinstance(i['id'], int) for i in saved)
|
|
|
|
|
assert len({i['id'] for i in saved}) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_catalog_api(test_catalog_config, async_client):
|
|
|
|
|
# Empty to start.
|
|
|
|
|
request, response = await async_client.get('/api/inventory/catalog')
|
|
|
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
assert response.json['catalog'] == []
|
|
|
|
|
|
|
|
|
|
items = [{'name': 'White Rice', 'category': 'grains', 'subcategory': 'rice',
|
|
|
|
|
'item_size': '5', 'item_size_unit': 'lb', 'calories': '8040'}]
|
|
|
|
|
request, response = await async_client.put('/api/inventory/catalog', content=json.dumps(dict(items=items)))
|
|
|
|
|
assert response.status_code == HTTPStatus.OK, response.status_code
|
|
|
|
|
assert response.json['catalog'][0]['name'] == 'White Rice'
|
|
|
|
|
assert isinstance(response.json['catalog'][0]['id'], int)
|
|
|
|
|
|
|
|
|
|
# The static /catalog route is not shadowed by the dynamic /<slug> route.
|
|
|
|
|
request, response = await async_client.get('/api/inventory/catalog')
|
|
|
|
|
assert len(response.json['catalog']) == 1
|