From a7e8f2e1226dc90f9ca55fc1aab21f513ce10afb Mon Sep 17 00:00:00 2001 From: arigatoexpress <95630102+arigatoexpress@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:47:03 -0600 Subject: [PATCH 01/10] feat(inventory): seed in-app Firestore inventory from the legacy snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment 1 of moving THO inventory in-app. The 2026 domain cutover repointed the legacy scrape URL to the new app, freezing public inventory at the 2026-05-11 snapshot (see #192). This adds an idempotent seeder that maps snapshot homes into the Firestore `inventory` collection THODatabase already reads, so staff inherit the current homes to manage instead of starting empty. - tools/inventory_seed.py: snapshot_home_to_inventory_doc() — the inverse of inventory_tools._load_inventory_from_firestore, so a seeded home renders identically through the public Firestore read path (display-preserving). Idempotent seeder (legacy id = Firestore doc id) + dry-run-default CLI. - database/firestore_client.py: upsert_inventory() (set merge=True, preserves staff edits) + delete_inventory(). - tests/test_inventory_seed.py: mapper shape, half-baths, pre-owned, dry-run, idempotency, no-id skip (6 tests, ruff clean). Purely additive — no serving change yet. Next: read-flip /api/marketing/inventory-context to prefer Firestore (legacy snapshot as fallback), then admin CRUD endpoints + a staff Inventory Manager page. Co-Authored-By: Claude Opus 4.8 (1M context) --- database/firestore_client.py | 16 +++ tests/test_inventory_seed.py | 130 +++++++++++++++++++++++ tools/inventory_seed.py | 198 +++++++++++++++++++++++++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 tests/test_inventory_seed.py create mode 100644 tools/inventory_seed.py diff --git a/database/firestore_client.py b/database/firestore_client.py index 292b9d0..abc0dc7 100644 --- a/database/firestore_client.py +++ b/database/firestore_client.py @@ -294,6 +294,22 @@ def update_inventory(self, inventory_id: str, data: dict) -> bool: self.db.collection("inventory").document(inventory_id).update(data) return True + def upsert_inventory(self, inventory_id: str, data: dict) -> str: + """Create-or-merge an inventory item at an explicit doc id (idempotent). + + Uses ``set(merge=True)`` so re-seeding a known id updates fields in place + instead of duplicating, while preserving any staff-edited fields not in + ``data``. Mirrors ``create_customer``'s explicit-doc-id pattern. + """ + doc_ref = self.db.collection("inventory").document(inventory_id) + doc_ref.set(data, merge=True) + return inventory_id + + def delete_inventory(self, inventory_id: str) -> bool: + """Delete an inventory item (hard delete). Prefer status changes for retire.""" + self.db.collection("inventory").document(inventory_id).delete() + return True + # ============ PROPERTIES ============ def get_property(self, property_id: str) -> dict | None: diff --git a/tests/test_inventory_seed.py b/tests/test_inventory_seed.py new file mode 100644 index 0000000..8655346 --- /dev/null +++ b/tests/test_inventory_seed.py @@ -0,0 +1,130 @@ +"""Tests for the legacy-snapshot -> Firestore inventory seeder. + +The seeder is the first step of moving THO inventory in-app: it populates the +Firestore ``inventory`` collection from the frozen legacy snapshot so the public +site can serve staff-managed homes instead of a dead scrape. The mapping is the +inverse of ``tools.inventory_tools._load_inventory_from_firestore``, so these +tests pin that the seed survives the round trip and is idempotent. +""" + +from tools import inventory_seed + + +def _snapshot_home(**overrides): + home = { + "id": "43372", + "legacy_inventory_id": "43372", + "model_name": "Premier / Creole 3256H32447", + "manufacturer": "Champion Homes", + "classification": "Double Wide", + "status": "Available", + "marketing_tags": ["Manufactured", "Lot Model", "New"], + "features": ["Manufactured", "Lot Model", "Island Kitchen"], + "price_value": 0, + "display_price": "Call for Price", + "specs": {"beds": 3, "baths": 2.0, "sq_ft": 1699, "width": 30, "length": 56}, + "image_url": "https://cdn.example.com/43372-ext-1.jpg", + "hero_image": "https://cdn.example.com/43372-ext-1.jpg", + "gallery_images": [ + "https://cdn.example.com/43372-ext-1.jpg", + "https://cdn.example.com/43372-ext-2.jpg", + ], + "real_photos": [ + "https://cdn.example.com/43372-ext-1.jpg", + "https://cdn.example.com/43372-ext-2.jpg", + "https://cdn.example.com/43372-int-1.jpg", + ], + "floorplan_url": "https://cdn.example.com/43372-floorplan.jpg", + "floor_plan_url": "https://cdn.example.com/43372-floorplan.jpg", + "matterport_id": "mTvc6YoSRTx", + "image_categories": {"exterior": ["https://cdn.example.com/43372-ext-1.jpg"]}, + "description": "The Creole is a 3 bed, 2 bath home.", + "detail_url": "https://www.texashomeoutlet.com/inventory-detail/43372/...", + } + home.update(overrides) + return home + + +class FakeDatabase: + """Records upsert_inventory calls so we can assert idempotent doc-id writes.""" + + def __init__(self): + self.docs: dict[str, dict] = {} + + def upsert_inventory(self, inventory_id: str, data: dict) -> str: + self.docs[inventory_id] = data + return inventory_id + + +def test_mapper_produces_firestore_read_shape(): + doc = inventory_seed.snapshot_home_to_inventory_doc(_snapshot_home()) + + # Keys the public Firestore read path (_load_inventory_from_firestore) needs. + assert doc["legacy_inventory_id"] == "43372" + assert doc["model_name"] == "Premier / Creole 3256H32447" + assert doc["manufacturer"] == "Champion Homes" + assert doc["classification"] == "Double Wide" + assert doc["status"] == "AVAILABLE" # uppercase enum search_inventory filters on + assert doc["is_new"] is True + assert doc["bedrooms"] == 3 + assert doc["bathrooms"] == 2 # 2.0 -> 2 (int), halves preserved elsewhere + assert doc["sqft"] == 1699 + assert doc["width"] == 30 + assert doc["length"] == 56 + assert doc["sale_price"] == 0 and doc["msrp"] == 0 # "Call for Price" + assert doc["floorplan_url"] == "https://cdn.example.com/43372-floorplan.jpg" + assert doc["matterport_id"] == "mTvc6YoSRTx" + assert doc["photos"] == doc["gallery_images"] + assert doc["photos"][0] == "https://cdn.example.com/43372-ext-1.jpg" + assert doc["source"] == inventory_seed.SEED_SOURCE + + +def test_mapper_preserves_half_baths(): + doc = inventory_seed.snapshot_home_to_inventory_doc(_snapshot_home(specs={"beds": 4, "baths": 2.5})) + assert doc["bathrooms"] == 2.5 + + +def test_mapper_marks_preowned_homes(): + doc = inventory_seed.snapshot_home_to_inventory_doc( + _snapshot_home(status="Pre-Owned", marketing_tags=["Pre-Owned"]) + ) + assert doc["is_new"] is False + + +def test_seed_dry_run_writes_nothing_but_plans_everything(): + db = FakeDatabase() + homes = [_snapshot_home(), _snapshot_home(id="44490", legacy_inventory_id="44490", model_name="Big Blue")] + + stats = inventory_seed.seed_inventory_from_snapshot(db, homes, dry_run=True) + + assert stats["total"] == 2 + assert stats["written"] == 0 + assert db.docs == {} # no writes on a dry run + assert ("43372", "Premier / Creole 3256H32447") in stats["planned"] + assert ("44490", "Big Blue") in stats["planned"] + + +def test_seed_apply_upserts_by_legacy_id_and_is_idempotent(): + db = FakeDatabase() + homes = [_snapshot_home()] + + first = inventory_seed.seed_inventory_from_snapshot(db, homes, dry_run=False) + assert first["written"] == 1 + assert set(db.docs) == {"43372"} # legacy id is the doc id + + # Re-running must not duplicate — same doc id is overwritten in place. + second = inventory_seed.seed_inventory_from_snapshot(db, homes, dry_run=False) + assert second["written"] == 1 + assert set(db.docs) == {"43372"} + + +def test_seed_skips_homes_with_no_id(): + db = FakeDatabase() + homes = [_snapshot_home(id="", legacy_inventory_id="")] + + stats = inventory_seed.seed_inventory_from_snapshot(db, homes, dry_run=False) + + assert stats["total"] == 1 + assert stats["written"] == 0 + assert stats["skipped_no_id"] == 1 + assert db.docs == {} diff --git a/tools/inventory_seed.py b/tools/inventory_seed.py new file mode 100644 index 0000000..7b02965 --- /dev/null +++ b/tools/inventory_seed.py @@ -0,0 +1,198 @@ +"""Seed the in-app (Firestore) inventory store from the legacy snapshot. + +THO's public inventory page historically rendered the *legacy snapshot* +(``data/legacy_site/legacy_inventory_context.json``), captured by scraping +``texashomeoutlet.com/inventory``. The 2026 domain cutover repointed that URL +to the new app, so the live refresh can no longer reach a real source and the +snapshot is frozen (see ``tools/legacy_site_crawler.py`` + PR #192). + +The fix is to make inventory *staff-managed in-app*: homes live in the Firestore +``inventory`` collection (which ``THODatabase`` already reads via +``search_inventory``) and the public endpoint serves them. This module seeds +that collection from the existing snapshot so staff inherit the current 279 +homes to edit/retire rather than starting from an empty store. + +The mapping here is the deliberate inverse of +``tools.inventory_tools._load_inventory_from_firestore`` — a home seeded by this +module renders identically when read back through the public Firestore path, so +seeding is display-preserving. + +Idempotent: the legacy id is used as the Firestore document id, so re-running +updates in place instead of duplicating. Dry-run by default; pass ``--apply`` +(or ``dry_run=False``) to write. +""" + +from __future__ import annotations + +import argparse +import logging +from collections.abc import Callable +from typing import Any + +log = logging.getLogger(__name__) + +# Marks docs created by this seeder so they can be told apart from homes a +# staff member created directly in-app. +SEED_SOURCE = "legacy_snapshot_seed" + + +def _to_int(value: Any) -> int | None: + """Coerce to a positive int, or None for missing/zero/non-numeric.""" + if value in (None, ""): + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + if number <= 0: + return None + return int(number) + + +def _to_number(value: Any) -> float | int | None: + """Coerce to a positive number (keeps halves like 2.5 baths), else None.""" + if value in (None, ""): + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + if number <= 0: + return None + return int(number) if number.is_integer() else number + + +def _is_new(home: dict) -> bool: + """Infer new vs pre-owned from the snapshot's status / marketing tags. + + Defaults to new unless the home is explicitly flagged pre-owned, mirroring + how the read path derives "Pre-Owned" status. + """ + status = str(home.get("status") or "").lower() + if "pre-owned" in status or "preowned" in status or "used" in status: + return False + tags = [str(t).lower() for t in (home.get("marketing_tags") or [])] + if any("pre-owned" in t or "preowned" in t or "used" in t for t in tags): + return False + return True + + +def _legacy_id(home: dict) -> str: + """Stable id for the home (used as the Firestore doc id for idempotency).""" + return str(home.get("legacy_inventory_id") or home.get("id") or "").strip() + + +def snapshot_home_to_inventory_doc(home: dict) -> dict: + """Map one legacy-snapshot home to a Firestore ``inventory`` document. + + The output keys match what ``_load_inventory_from_firestore`` reads + (``bedrooms``/``bathrooms``/``sqft``/``width``/``length``/``sale_price``/ + ``photos``/``floorplan_url``/...), so seeded homes survive the round trip to + the public API unchanged. + """ + specs = home.get("specs") or {} + pricing = home.get("pricing") or {} + price_value = _to_int(home.get("price_value") or pricing.get("price_value")) or 0 + photos = home.get("real_photos") or home.get("gallery_images") or home.get("photos") or [] + + return { + "legacy_inventory_id": _legacy_id(home), + "serial_number": str(home.get("serial_number") or ""), + "manufacturer": home.get("manufacturer") or "", + "model_name": home.get("model_name") or "", + "classification": home.get("classification") or "", + # Firestore inventory uses an uppercase status enum; search_inventory + # filters status == "AVAILABLE". + "status": "AVAILABLE", + "is_new": _is_new(home), + "bedrooms": _to_int(specs.get("beds") or specs.get("bedrooms")), + "bathrooms": _to_number(specs.get("baths") or specs.get("bathrooms")), + "sqft": _to_int(specs.get("sq_ft") or specs.get("sqft")), + "width": _to_int(specs.get("width")), + "length": _to_int(specs.get("length")), + "sale_price": price_value, + "msrp": price_value, + "features": list(home.get("features") or []), + "marketing_tags": list(home.get("marketing_tags") or []), + "image_url": home.get("image_url") or home.get("hero_image") or "", + "gallery_images": list(photos), + "photos": list(photos), + "image_categories": dict(home.get("image_categories") or {}), + "floorplan_url": home.get("floorplan_url") or home.get("floor_plan_url"), + "matterport_id": home.get("matterport_id"), + "description": home.get("description") or "", + "detail_url": home.get("detail_url") or "", + "source": SEED_SOURCE, + } + + +def load_snapshot_homes(loader: Callable[..., dict] | None = None, *, limit: int = 1000) -> list[dict]: + """Load homes from the legacy snapshot via the crawler's cached loader.""" + if loader is None: + from tools.legacy_site_crawler import load_legacy_inventory_context + + loader = load_legacy_inventory_context + context = loader(limit=limit) or {} + return list(context.get("homes") or []) + + +def seed_inventory_from_snapshot( + db: Any, + homes: list[dict], + *, + dry_run: bool = True, + limit: int | None = None, +) -> dict: + """Upsert snapshot homes into the Firestore ``inventory`` collection. + + Uses the legacy id as the doc id (idempotent). ``db`` must provide + ``upsert_inventory(doc_id, data)``. Returns a stats dict; on ``dry_run`` no + writes happen and ``planned`` lists the (id, model_name) that would be + written. + """ + stats: dict[str, Any] = {"total": 0, "written": 0, "skipped_no_id": 0, "planned": []} + for home in homes[: limit if limit else None]: + stats["total"] += 1 + doc_id = _legacy_id(home) + if not doc_id: + stats["skipped_no_id"] += 1 + log.warning("Skipping snapshot home with no legacy id: model=%s", home.get("model_name")) + continue + doc = snapshot_home_to_inventory_doc(home) + stats["planned"].append((doc_id, doc["model_name"])) + if dry_run: + continue + db.upsert_inventory(doc_id, doc) + stats["written"] += 1 + return stats + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Seed Firestore inventory from the legacy snapshot.") + parser.add_argument("--apply", action="store_true", help="Write to Firestore (default: dry run).") + parser.add_argument("--limit", type=int, default=None, help="Only process the first N homes.") + args = parser.parse_args(argv) + logging.basicConfig(level=logging.INFO) + + homes = load_snapshot_homes() + db = None + if args.apply: + from database.firestore_client import get_database + + db = get_database() + + stats = seed_inventory_from_snapshot(db, homes, dry_run=not args.apply, limit=args.limit) + mode = "APPLIED" if args.apply else "DRY-RUN" + print(f"[{mode}] snapshot homes: {stats['total']} | written: {stats['written']} " + f"| skipped (no id): {stats['skipped_no_id']}") + if not args.apply: + for doc_id, model in stats["planned"][:25]: + print(f" would upsert {doc_id}: {model}") + if len(stats["planned"]) > 25: + print(f" ... and {len(stats['planned']) - 25} more") + print("\nRe-run with --apply to write to Firestore.") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) From fbfe129c4e16c86d3e550cc7a185db5f0380b75c Mon Sep 17 00:00:00 2001 From: arigatoexpress <95630102+arigatoexpress@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:55:19 -0600 Subject: [PATCH 02/10] feat(inventory): serve staff-managed Firestore inventory via INVENTORY_SOURCE Increment 2: read-flip for /api/marketing/inventory-context. Extracts the legacy-snapshot and Firestore branches into _legacy_inventory_context() and _firestore_inventory_context(), and adds INVENTORY_SOURCE (legacy|firestore| auto; default legacy) to choose between them. Default is legacy -> ZERO behavior change on merge. Set INVENTORY_SOURCE= firestore (or auto) AFTER seeding to serve in-app inventory, with the legacy snapshot as the safety-net fallback so the page is never empty. Instant revert via INVENTORY_SOURCE=legacy (no redeploy). Health-checks the RAW Firestore home count (not the post-fallback website-asset homes), so auto never regresses to the small asset catalog when Firestore is empty. Tests: 4 precedence tests + existing inventory-context tests unchanged. 72 green across api_v1 + inventory suites; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 260 +++++++++++++++++++++++++++---------------- tests/test_api_v1.py | 72 ++++++++++++ 2 files changed, 233 insertions(+), 99 deletions(-) diff --git a/main.py b/main.py index 38f520a..80f76d1 100644 --- a/main.py +++ b/main.py @@ -4200,115 +4200,177 @@ def _overlay_staff_photos(homes: list[dict]) -> None: home["has_staff_photos"] = True -@app.get("/api/marketing/inventory-context") -@limiter.limit("30/minute") -async def api_inventory_context(request: Request): - """Get inventory highlights for ad creation and the public browse page.""" +def _inventory_source_pref() -> str: + """How the public inventory endpoint chooses its source. + + - ``legacy`` (default): legacy snapshot first, Firestore as fallback — the + original behavior, safe to ship before the in-app store is seeded. + - ``firestore``: serve staff-managed Firestore inventory; snapshot is used + only when Firestore has no homes. + - ``auto``: serve Firestore when it has >= INVENTORY_FIRESTORE_MIN_HOMES + homes, else the snapshot. + + Flip with the ``INVENTORY_SOURCE`` env var (no redeploy needed); revert + instantly with ``INVENTORY_SOURCE=legacy``. This is the rollout switch for + moving inventory in-app: ship legacy-default, seed Firestore, verify, flip. + """ + value = (os.getenv("INVENTORY_SOURCE", "legacy") or "legacy").strip().lower() + return value if value in ("legacy", "firestore", "auto") else "legacy" + + +def _inventory_firestore_min_homes() -> int: + """Minimum real Firestore homes before ``auto`` prefers Firestore.""" try: - # Public browse/Ad Studio should mirror the customer's active legacy - # inventory, not stale Firestore/static seed rows. The crawler is - # cached and Firestore-free; if the public site is unavailable, the - # old Firestore/asset path below remains a graceful fallback. - legacy_result = load_legacy_inventory_context(limit=100) - if legacy_result.get("success") and legacy_result.get("homes"): - floorplan_result = load_legacy_floorplan_catalog_context(limit=500) - merged = merge_orderable_floorplan_catalog( - legacy_result, - assets=PROPERTY_ASSETS, - floorplan_context=floorplan_result - if floorplan_result.get("success") and floorplan_result.get("homes") - else None, - ) - _overlay_staff_photos(merged.get("homes", [])) - return merged - if legacy_result.get("error"): - struct_logger.warning( - "Legacy inventory context unavailable", - error=legacy_result.get("error"), - ) + return max(1, int(os.getenv("INVENTORY_FIRESTORE_MIN_HOMES", "1"))) + except ValueError: + return 1 - from tools.asset_scraper import get_assets_for_home - - # Fallback for local/offline operation and legacy admin data. This path - # should not win when the public legacy site can be read successfully. - result = get_inventory_for_ads(limit=100) - firestore_homes = result.get("homes", []) - - # Enrich Firestore homes with asset catalog images only when they lack - # non-floorplan photos. Floorplan-only listings used to pass this gate - # because `real_photos` was non-empty; the classifier now makes the - # distinction explicit before and after enrichment. - for home in firestore_homes: - apply_classifier_to_home(home) - if not has_real_photo(home): - asset = get_assets_for_home(home.get("model_name", "")) - if asset: - asset_images = asset.get("images") or [] - if asset_images: - existing = home.get("real_photos") or home.get("gallery_images") or [] - home["real_photos"] = [*existing, *asset_images] - home["gallery_images"] = [*existing, *asset_images][:3] - if asset.get("image_categories"): - home["image_categories"] = asset.get("image_categories", {}) - home["floor_plan_url"] = home.get("floor_plan_url") or asset.get("floor_plan") - if asset.get("matterport_id") and not home.get("matterport_id"): - home["matterport_id"] = asset["matterport_id"] - home["matterport_url"] = get_matterport_url(asset["matterport_id"]) - apply_classifier_to_home(home) - website_homes = [] - if not firestore_homes: - for slug, asset in PROPERTY_ASSETS.items(): - home_data = { - "id": slug, - "source_catalog_slug": slug, - "model_name": asset["name"], - "manufacturer": asset.get("manufacturer", "New Vision Manufacturing"), - "classification": "Manufactured Home", - "status": "Available" if asset.get("is_new") else "Pre-Owned", - "inventory_kind": "orderable_floorplan" if asset.get("is_new") else "pre_owned", - "display_price": "Call for Price", - "price_value": 0, - "specs": { - "beds": asset.get("beds"), - "baths": asset.get("baths"), - "sq_ft": asset.get("sqft"), - "dimensions": asset.get("dims"), - }, - "features": [], - "image_url": (asset.get("images") or [""])[0], - "gallery_images": asset.get("images", [])[:3], - "real_photos": asset.get("images", []), - "image_categories": asset.get("image_categories", {}), - "floor_plan_url": asset.get("floor_plan"), - "matterport_id": asset.get("matterport_id"), - "matterport_url": get_matterport_url(asset["matterport_id"]) - if asset.get("matterport_id") - else None, - } - website_homes.append(home_data) - result["homes"] = website_homes - result["total_inventory"] = len(website_homes) - else: - result["homes"] = firestore_homes - result["total_inventory"] = result.get("total_inventory", len(firestore_homes)) - # Apply URL-based floorplan classifier to every home before responding. - # This is intentionally repeated after enrichment/fallback construction - # so the public API never counts floorplans as usable listing photos. - for home in result.get("homes", []): - apply_classifier_to_home(home) +def _legacy_inventory_context() -> dict | None: + """Public inventory response from the legacy snapshot, or None if it's empty. + Returns the floorplan-merged, staff-photo-overlaid response when the + snapshot has homes; None when it has none (so the caller can fall back). + """ + legacy_result = load_legacy_inventory_context(limit=100) + if legacy_result.get("success") and legacy_result.get("homes"): floorplan_result = load_legacy_floorplan_catalog_context(limit=500) - result = merge_orderable_floorplan_catalog( - result, + merged = merge_orderable_floorplan_catalog( + legacy_result, assets=PROPERTY_ASSETS, floorplan_context=floorplan_result if floorplan_result.get("success") and floorplan_result.get("homes") else None, ) - result["website_homes"] = len(website_homes) - _overlay_staff_photos(result.get("homes", [])) - return result + _overlay_staff_photos(merged.get("homes", [])) + return merged + if legacy_result.get("error"): + struct_logger.warning( + "Legacy inventory context unavailable", + error=legacy_result.get("error"), + ) + return None + + +def _firestore_inventory_context(preloaded: dict | None = None) -> dict: + """Public inventory response from staff-managed (Firestore) inventory. + + Reuses ``preloaded`` (a prior ``get_inventory_for_ads`` result) when given, + so the caller can health-check the raw Firestore homes without querying + twice. When Firestore has no homes it falls back to the website asset + catalog, exactly as the original endpoint did for local/offline operation. + """ + from tools.asset_scraper import get_assets_for_home + + result = preloaded if preloaded is not None else get_inventory_for_ads(limit=100) + firestore_homes = result.get("homes", []) + + # Enrich Firestore homes with asset catalog images only when they lack + # non-floorplan photos. Floorplan-only listings used to pass this gate + # because `real_photos` was non-empty; the classifier now makes the + # distinction explicit before and after enrichment. + for home in firestore_homes: + apply_classifier_to_home(home) + if not has_real_photo(home): + asset = get_assets_for_home(home.get("model_name", "")) + if asset: + asset_images = asset.get("images") or [] + if asset_images: + existing = home.get("real_photos") or home.get("gallery_images") or [] + home["real_photos"] = [*existing, *asset_images] + home["gallery_images"] = [*existing, *asset_images][:3] + if asset.get("image_categories"): + home["image_categories"] = asset.get("image_categories", {}) + home["floor_plan_url"] = home.get("floor_plan_url") or asset.get("floor_plan") + if asset.get("matterport_id") and not home.get("matterport_id"): + home["matterport_id"] = asset["matterport_id"] + home["matterport_url"] = get_matterport_url(asset["matterport_id"]) + apply_classifier_to_home(home) + website_homes = [] + if not firestore_homes: + for slug, asset in PROPERTY_ASSETS.items(): + home_data = { + "id": slug, + "source_catalog_slug": slug, + "model_name": asset["name"], + "manufacturer": asset.get("manufacturer", "New Vision Manufacturing"), + "classification": "Manufactured Home", + "status": "Available" if asset.get("is_new") else "Pre-Owned", + "inventory_kind": "orderable_floorplan" if asset.get("is_new") else "pre_owned", + "display_price": "Call for Price", + "price_value": 0, + "specs": { + "beds": asset.get("beds"), + "baths": asset.get("baths"), + "sq_ft": asset.get("sqft"), + "dimensions": asset.get("dims"), + }, + "features": [], + "image_url": (asset.get("images") or [""])[0], + "gallery_images": asset.get("images", [])[:3], + "real_photos": asset.get("images", []), + "image_categories": asset.get("image_categories", {}), + "floor_plan_url": asset.get("floor_plan"), + "matterport_id": asset.get("matterport_id"), + "matterport_url": get_matterport_url(asset["matterport_id"]) + if asset.get("matterport_id") + else None, + } + website_homes.append(home_data) + result["homes"] = website_homes + result["total_inventory"] = len(website_homes) + else: + result["homes"] = firestore_homes + result["total_inventory"] = result.get("total_inventory", len(firestore_homes)) + + # Apply URL-based floorplan classifier to every home before responding. + # This is intentionally repeated after enrichment/fallback construction + # so the public API never counts floorplans as usable listing photos. + for home in result.get("homes", []): + apply_classifier_to_home(home) + + floorplan_result = load_legacy_floorplan_catalog_context(limit=500) + result = merge_orderable_floorplan_catalog( + result, + assets=PROPERTY_ASSETS, + floorplan_context=floorplan_result + if floorplan_result.get("success") and floorplan_result.get("homes") + else None, + ) + result["website_homes"] = len(website_homes) + _overlay_staff_photos(result.get("homes", [])) + return result + + +@app.get("/api/marketing/inventory-context") +@limiter.limit("30/minute") +async def api_inventory_context(request: Request): + """Get inventory highlights for ad creation and the public browse page. + + Inventory is moving in-app: staff-managed homes live in the Firestore + ``inventory`` collection. ``INVENTORY_SOURCE`` selects the source (default + ``legacy`` = the snapshot, with Firestore as fallback). When set to + ``firestore``/``auto`` the staff-managed store wins, with the legacy + snapshot as the safety net so the page is never empty. + """ + try: + prefer = _inventory_source_pref() + raw = None + if prefer != "legacy": + raw = get_inventory_for_ads(limit=100) + raw_homes = raw.get("homes") or [] + if prefer == "firestore" or ( + raw.get("success") and len(raw_homes) >= _inventory_firestore_min_homes() + ): + return _firestore_inventory_context(raw) + + legacy = _legacy_inventory_context() + if legacy is not None: + return legacy + + # Legacy snapshot unavailable — fall back to the Firestore/asset path + # (reusing the raw query if we already ran it above). + return _firestore_inventory_context(raw) except Exception as e: struct_logger.error("Inventory context failed", error=str(e)) return {"success": False, "error": "Failed to load inventory context. Please try again."} diff --git a/tests/test_api_v1.py b/tests/test_api_v1.py index 9c7f543..0180c18 100644 --- a/tests/test_api_v1.py +++ b/tests/test_api_v1.py @@ -843,6 +843,78 @@ def test_marketing_inventory_context_appends_orderable_catalog_to_live_inventory assert data["homes"][1]["is_orderable"] is True +def _isolate_inventory_merge(monkeypatch, main): + """Passthrough the floorplan merge / photo overlay so inventory-context + source-selection tests assert which SOURCE wins, not merge internals.""" + monkeypatch.setattr(main, "merge_orderable_floorplan_catalog", lambda result, **k: result) + monkeypatch.setattr(main, "_overlay_staff_photos", lambda homes: None) + monkeypatch.setattr( + main, "load_legacy_floorplan_catalog_context", lambda **k: {"success": False, "homes": []} + ) + monkeypatch.setattr(main, "PROPERTY_ASSETS", {}) + + +_LEGACY_CTX = { + "success": True, + "source": "legacy_site_live", + "homes": [{"id": "legacy-1", "model_name": "Legacy Home", "real_photos": ["https://x/a.jpg"]}], + "total_inventory": 1, +} +_FS_CTX = { + "success": True, + "homes": [{"id": "fs-1", "model_name": "FS Home", "real_photos": ["https://x/b.jpg"]}], + "total_inventory": 1, +} + + +def test_inventory_context_defaults_to_legacy_source(monkeypatch): + """No INVENTORY_SOURCE set -> legacy snapshot wins (behavior unchanged).""" + client, main, _db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") + monkeypatch.delenv("INVENTORY_SOURCE", raising=False) + _isolate_inventory_merge(monkeypatch, main) + monkeypatch.setattr(main, "load_legacy_inventory_context", lambda **k: dict(_LEGACY_CTX)) + monkeypatch.setattr(main, "get_inventory_for_ads", lambda **k: dict(_FS_CTX)) + + data = client.get("/api/marketing/inventory-context").json() + assert [h["id"] for h in data["homes"]] == ["legacy-1"] + + +def test_inventory_context_firestore_source_serves_firestore(monkeypatch): + """INVENTORY_SOURCE=firestore -> staff-managed Firestore inventory wins.""" + client, main, _db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") + monkeypatch.setenv("INVENTORY_SOURCE", "firestore") + _isolate_inventory_merge(monkeypatch, main) + monkeypatch.setattr(main, "load_legacy_inventory_context", lambda **k: dict(_LEGACY_CTX)) + monkeypatch.setattr(main, "get_inventory_for_ads", lambda **k: dict(_FS_CTX)) + + data = client.get("/api/marketing/inventory-context").json() + assert [h["id"] for h in data["homes"]] == ["fs-1"] + + +def test_inventory_context_auto_falls_back_to_legacy_when_firestore_empty(monkeypatch): + """auto + empty Firestore -> legacy snapshot, NOT the website-asset catalog.""" + client, main, _db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") + monkeypatch.setenv("INVENTORY_SOURCE", "auto") + _isolate_inventory_merge(monkeypatch, main) + monkeypatch.setattr(main, "load_legacy_inventory_context", lambda **k: dict(_LEGACY_CTX)) + monkeypatch.setattr(main, "get_inventory_for_ads", lambda **k: {"success": True, "homes": []}) + + data = client.get("/api/marketing/inventory-context").json() + assert [h["id"] for h in data["homes"]] == ["legacy-1"] + + +def test_inventory_context_auto_prefers_firestore_when_populated(monkeypatch): + """auto + >= min Firestore homes -> Firestore wins (the unfreeze).""" + client, main, _db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") + monkeypatch.setenv("INVENTORY_SOURCE", "auto") + _isolate_inventory_merge(monkeypatch, main) + monkeypatch.setattr(main, "load_legacy_inventory_context", lambda **k: dict(_LEGACY_CTX)) + monkeypatch.setattr(main, "get_inventory_for_ads", lambda **k: dict(_FS_CTX)) + + data = client.get("/api/marketing/inventory-context").json() + assert [h["id"] for h in data["homes"]] == ["fs-1"] + + def test_marketing_readiness_routes_are_admin_protected(monkeypatch): client, main, _db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") token = main._create_admin_token() From 0a8733e03d153ccfffa58ffcae2f1a34089d7a42 Mon Sep 17 00:00:00 2001 From: arigatoexpress <95630102+arigatoexpress@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:03:17 -0600 Subject: [PATCH 03/10] feat(inventory): admin CRUD endpoints for staff-managed in-app inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment 3: staff can create/update/retire homes in the Firestore inventory store (served by increment 2 when INVENTORY_SOURCE=firestore/auto). - POST /api/inventory create (require_admin; model_name required) - PUT /api/inventory/{id} partial-merge update - DELETE /api/inventory/{id} soft-retire (status=RETIRED); ?hard=true deletes - database/models.py: InventoryWrite — permissive (extra=allow) write schema normalizing the core typed fields while passing staff extras through. - All writes audited via log_admin_action. Tests: 6 endpoint tests (auth, create, missing-name, merge-update, soft-retire, hard-delete) + FakeTHODatabase write methods + exposed InventoryWrite on the harness's synthetic database.models mock. 61 green; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- database/models.py | 34 ++++++++++++++++++ main.py | 84 ++++++++++++++++++++++++++++++++++++++++++- tests/test_api_v1.py | 86 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 1 deletion(-) diff --git a/database/models.py b/database/models.py index d141adc..038364f 100644 --- a/database/models.py +++ b/database/models.py @@ -220,6 +220,40 @@ class DealStatus(str, Enum): ARCHIVED = "archived" +class InventoryWrite(BaseModel): + """Permissive validation for admin inventory create/update payloads. + + Inventory is a flexible staff-managed home record, so this accepts and + passes through extra fields (``extra="allow"``) while normalizing the core + typed fields the public read path relies on. All fields are optional: the + create route requires ``model_name``; update is a partial merge. + """ + + model_config = ConfigDict(extra="allow") + + model_name: str | None = None + manufacturer: str | None = None + classification: str | None = None + status: str | None = None + is_new: bool | None = None + serial_number: str | None = None + bedrooms: int | None = Field(default=None, ge=0, le=10) + bathrooms: float | None = Field(default=None, ge=0, le=10) + sqft: int | None = Field(default=None, ge=0) + width: int | None = Field(default=None, ge=0) + length: int | None = Field(default=None, ge=0) + sale_price: float | None = Field(default=None, ge=0) + msrp: float | None = Field(default=None, ge=0) + features: list[str] | None = None + marketing_tags: list[str] | None = None + photos: list[str] | None = None + gallery_images: list[str] | None = None + image_url: str | None = None + floorplan_url: str | None = None + matterport_id: str | None = None + description: str | None = None + + class Deal(BaseModel): """ Customer application/deal record — replaces fastcontractdocs.com. diff --git a/main.py b/main.py index 80f76d1..f660192 100644 --- a/main.py +++ b/main.py @@ -2625,7 +2625,7 @@ async def download_document(filename: str): # ─── Inventory API ─── from database.deal_validation import validate_for_documents from database.firestore_client import get_database -from database.models import Deal, DealStatus, Inventory +from database.models import Deal, DealStatus, Inventory, InventoryWrite _db = get_database() app.state.db = _db @@ -2963,6 +2963,88 @@ async def list_inventory(status: str = "AVAILABLE", limit: int = 100, is_new: bo return {"success": False, "error": "Failed to load inventory. Please try again."} +@app.post("/api/inventory", dependencies=[Depends(require_admin)]) +async def create_inventory_item(request: Request): + """Create a staff-managed inventory home (in-app inventory). + + Part of moving inventory in-app: writes go to the Firestore `inventory` + collection the public read path serves when INVENTORY_SOURCE=firestore/auto. + """ + try: + data = await request.json() + payload = InventoryWrite(**data).model_dump(exclude_none=True) + if not payload.get("model_name"): + return JSONResponse({"success": False, "error": "model_name is required."}, status_code=400) + payload.setdefault("status", "AVAILABLE") + payload.setdefault("source", "staff_created") + inventory_id = _db.create_inventory(payload) + log_admin_action( + actor=_audit_actor(request), + action="inventory.create", + target_type="inventory", + target_id=str(inventory_id), + details={"model_name": payload.get("model_name")}, + request=request, + ) + return {"success": True, "id": inventory_id, "inventory": {**payload, "id": inventory_id}} + except Exception as e: + struct_logger.error("Inventory create failed", error=str(e)) + return JSONResponse( + {"success": False, "error": "Failed to create inventory item."}, status_code=400 + ) + + +@app.put("/api/inventory/{inventory_id}", dependencies=[Depends(require_admin)]) +async def update_inventory_item(inventory_id: str, request: Request): + """Update a staff-managed inventory home (partial merge).""" + try: + data = await request.json() + payload = InventoryWrite(**data).model_dump(exclude_none=True) + if not payload: + return JSONResponse({"success": False, "error": "No fields to update."}, status_code=400) + _db.update_inventory(inventory_id, payload) + log_admin_action( + actor=_audit_actor(request), + action="inventory.update", + target_type="inventory", + target_id=str(inventory_id), + details={"fields": sorted(payload.keys())}, + request=request, + ) + return {"success": True, "id": inventory_id} + except Exception as e: + struct_logger.error("Inventory update failed", error=str(e)) + return JSONResponse( + {"success": False, "error": "Failed to update inventory item."}, status_code=400 + ) + + +@app.delete("/api/inventory/{inventory_id}", dependencies=[Depends(require_admin)]) +async def retire_inventory_item(inventory_id: str, request: Request, hard: bool = False): + """Retire an inventory home. Soft by default (status=RETIRED so it drops off + the public AVAILABLE list but the record is kept); ``?hard=true`` deletes it. + """ + try: + if hard: + _db.delete_inventory(inventory_id) + else: + _db.update_inventory(inventory_id, {"status": "RETIRED"}) + log_admin_action( + actor=_audit_actor(request), + action="inventory.delete" if hard else "inventory.retire", + target_type="inventory", + target_id=str(inventory_id), + details={"hard": hard}, + request=request, + ) + return {"success": True, "id": inventory_id, "retired": not hard, "deleted": hard} + except Exception as e: + struct_logger.error("Inventory retire failed", error=str(e)) + return JSONResponse( + {"success": False, "error": "Failed to retire inventory item."}, status_code=400 + ) + + @app.get("/api/admin/inventory/photo-audit", dependencies=[Depends(require_admin)]) async def admin_inventory_photo_audit(limit: int = 5000): """ diff --git a/tests/test_api_v1.py b/tests/test_api_v1.py index 0180c18..d57cc12 100644 --- a/tests/test_api_v1.py +++ b/tests/test_api_v1.py @@ -345,6 +345,20 @@ def get_inventory_by_id(self, inventory_id: str): item = self.collections["inventory"].get(inventory_id) return dict(item) if item else None + def create_inventory(self, data: dict) -> str: + new_id = data.get("id") or f"inv-{len(self.collections['inventory']) + 1}" + self.collections["inventory"][new_id] = {**data, "id": new_id} + return new_id + + def update_inventory(self, inventory_id: str, data: dict) -> bool: + existing = self.collections["inventory"].get(inventory_id, {}) + self.collections["inventory"][inventory_id] = {**existing, **data, "id": inventory_id} + return True + + def delete_inventory(self, inventory_id: str) -> bool: + self.collections["inventory"].pop(inventory_id, None) + return True + def count_customers(self): by_status: dict[str, int] = {} for customer in self.collections["customers"].values(): @@ -541,6 +555,7 @@ def load_app(monkeypatch, tho_api_key: str | None = "tho-secret", rate_limit_rpm sys.modules.pop("database.models", None) from database.models import Inventory as RealInventory + from database.models import InventoryWrite as RealInventoryWrite fake_logger = FakeStructuredLogger() fake_db = FakeTHODatabase() @@ -614,6 +629,7 @@ def load_app(monkeypatch, tho_api_key: str | None = "tho-secret", rate_limit_rpm database_models_module.Deal = FakeDeal database_models_module.DealStatus = FakeDealStatus database_models_module.Inventory = RealInventory + database_models_module.InventoryWrite = RealInventoryWrite monkeypatch.setitem(sys.modules, "database.models", database_models_module) document_schemas_module = types.ModuleType("schemas.document_schemas") @@ -915,6 +931,76 @@ def test_inventory_context_auto_prefers_firestore_when_populated(monkeypatch): assert [h["id"] for h in data["homes"]] == ["fs-1"] +def test_create_inventory_requires_admin(monkeypatch): + client, _main, _db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") + resp = client.post("/api/inventory", json={"model_name": "The Nassau"}) + assert resp.status_code == 401 + + +def test_create_inventory_item(monkeypatch): + client, main, fake_db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") + token = main._create_admin_token() + resp = client.post( + "/api/inventory", + json={"model_name": "The Nassau", "manufacturer": "Jessup", "bedrooms": 3, "bathrooms": 2.0}, + headers={"X-Admin-Token": token}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + stored = fake_db.collections["inventory"][body["id"]] + assert stored["model_name"] == "The Nassau" + assert stored["status"] == "AVAILABLE" # defaulted + assert stored["source"] == "staff_created" + + +def test_create_inventory_rejects_missing_model_name(monkeypatch): + client, main, _db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") + token = main._create_admin_token() + resp = client.post( + "/api/inventory", json={"manufacturer": "Jessup"}, headers={"X-Admin-Token": token} + ) + assert resp.status_code == 400 + assert resp.json()["success"] is False + + +def test_update_inventory_item_merges(monkeypatch): + client, main, fake_db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") + fake_db.collections["inventory"]["inv-1"] = {"id": "inv-1", "model_name": "Old", "status": "AVAILABLE"} + token = main._create_admin_token() + resp = client.put( + "/api/inventory/inv-1", + json={"sale_price": 89900, "model_name": "New Name"}, + headers={"X-Admin-Token": token}, + ) + assert resp.status_code == 200 + stored = fake_db.collections["inventory"]["inv-1"] + assert stored["model_name"] == "New Name" + assert stored["sale_price"] == 89900 + assert stored["status"] == "AVAILABLE" # untouched field preserved + + +def test_retire_inventory_item_soft(monkeypatch): + client, main, fake_db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") + fake_db.collections["inventory"]["inv-1"] = {"id": "inv-1", "model_name": "X", "status": "AVAILABLE"} + token = main._create_admin_token() + resp = client.delete("/api/inventory/inv-1", headers={"X-Admin-Token": token}) + assert resp.status_code == 200 + assert resp.json()["retired"] is True + # Soft retire keeps the record but drops it off the AVAILABLE list. + assert fake_db.collections["inventory"]["inv-1"]["status"] == "RETIRED" + + +def test_delete_inventory_item_hard(monkeypatch): + client, main, fake_db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") + fake_db.collections["inventory"]["inv-1"] = {"id": "inv-1", "model_name": "X"} + token = main._create_admin_token() + resp = client.delete("/api/inventory/inv-1?hard=true", headers={"X-Admin-Token": token}) + assert resp.status_code == 200 + assert resp.json()["deleted"] is True + assert "inv-1" not in fake_db.collections["inventory"] + + def test_marketing_readiness_routes_are_admin_protected(monkeypatch): client, main, _db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") token = main._create_admin_token() From 29e3a4c2bd52709da1f7c8fa086851908020640f Mon Sep 17 00:00:00 2001 From: arigatoexpress <95630102+arigatoexpress@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:41:14 -0600 Subject: [PATCH 04/10] feat(inventory): sync House Orders sheet -> Firestore inventory (PII/cost-safe) Integration increment 1 (the live unfreeze). House Orders.xlsx (the operator's daily-maintained source of truth) -> Firestore inventory, so the public site reflects real stock instead of the frozen 2026-05-11 scrape. - tools/house_orders_sync.py: parse_house_orders() (sheet 'Everybody Else', header=1) + a pure, allow-listed mapper. PII contract: the Customer column is read ONLY to set availability (blank=AVAILABLE, filled=SOLD) and the buyer NAME is never persisted; invoice cost / MSRP / price are never written (site keeps Call-for-Price); records are emitted by an ALLOW-LIST so no new sheet column can leak. Upserts via upsert_inventory keyed on serial# = doc id (idempotent). Dry-run default; --apply writes. - tests/test_house_orders_sync.py: 11 tests led by the PII guards (name never persisted, cost/price never in the doc, emitted keys subset of the allow-list) + mapping, header/section/total filtering, idempotency. ruff clean. Column names verified against the live sheet header + convert_inventory.py. Activation (gated): place House Orders.xlsx in data/, dry-run to eyeball, --apply once, then INVENTORY_SOURCE=firestore. Automated daily Drive pull deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_house_orders_sync.py | 146 +++++++++++++++++++++ tools/house_orders_sync.py | 224 ++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 tests/test_house_orders_sync.py create mode 100644 tools/house_orders_sync.py diff --git a/tests/test_house_orders_sync.py b/tests/test_house_orders_sync.py new file mode 100644 index 0000000..baaea49 --- /dev/null +++ b/tests/test_house_orders_sync.py @@ -0,0 +1,146 @@ +"""Tests for the House Orders -> Firestore inventory sync. + +The sync is the live-inventory unfreeze: it reflects the operator's House Orders +sheet into the in-app store. Because the sheet holds buyer names and dealer cost, +the most important tests here are the PII/cost-leak guards — a regression that +persists a customer name or invoice cost would push private data onto the public +website. +""" + +from tools import house_orders_sync as hos + + +def _row(**overrides): + row = { + "serial": "15677", + "serial_2": "10508", + "model": "Oak 28x56 (Marvel 4) 3/2", + "manufacturer": "Tru Belton", + "customer": None, # blank = available + } + row.update(overrides) + return row + + +class FakeDatabase: + def __init__(self): + self.docs: dict[str, dict] = {} + + def upsert_inventory(self, inventory_id: str, data: dict) -> str: + self.docs[inventory_id] = data + return inventory_id + + +# --- PII / cost leak guards (the critical ones) -------------------------------- + +def test_customer_name_sets_status_but_is_never_persisted(): + doc = hos.house_order_row_to_inventory_doc(_row(customer="Antonio Martinez")) + assert doc["status"] == "SOLD" + # The buyer's name must appear NOWHERE in the persisted record. + blob = repr(doc).lower() + assert "antonio" not in blob and "martinez" not in blob + assert "customer" not in doc + + +def test_blank_customer_is_available(): + assert hos.house_order_row_to_inventory_doc(_row(customer=None))["status"] == "AVAILABLE" + assert hos.house_order_row_to_inventory_doc(_row(customer=" "))["status"] == "AVAILABLE" + + +def test_invoice_cost_and_price_never_in_doc(): + # Even if the raw row carries cost/price-ish junk, the allow-list drops it. + doc = hos.house_order_row_to_inventory_doc( + {**_row(), "invoice_amount": 60187.0, "msrp": 97846.15, "salesman": "Adriana"} + ) + for forbidden in ("invoice_amount", "msrp", "sale_price", "price", "salesman", "customer"): + assert forbidden not in doc + + +def test_emitted_keys_are_within_the_allow_list(): + doc = hos.house_order_row_to_inventory_doc(_row(customer="Jane Buyer")) + assert set(doc).issubset(hos._ALLOWED_KEYS) + + +# --- mapping correctness ------------------------------------------------------- + +def test_maps_specs_and_classification_from_model(): + doc = hos.house_order_row_to_inventory_doc(_row(model="Oak 28x56 3/2")) + assert doc["serial_number"] == "15677" + assert doc["model_name"] == "Oak 28x56 3/2" + assert doc["manufacturer"] == "Tru Belton" + assert doc["width"] == 28 and doc["length"] == 56 + assert doc["sqft"] == 28 * 56 + assert doc["bedrooms"] == 3 and doc["bathrooms"] == 2 + assert doc["classification"] == "Double Wide" # width >= 28 + assert doc["is_new"] is True + assert doc["source"] == hos.SYNC_SOURCE + + +def test_single_wide_classification(): + doc = hos.house_order_row_to_inventory_doc(_row(model="Glory Smart 14x76")) + assert doc["classification"] == "Single Wide" # width < 28 + + +def test_iter_home_rows_skips_headers_sections_and_totals(): + records = [ + {"Serial #": "Serial #", "Model": "Model"}, # header echo + {"Serial #": "On Approval", "Model": ""}, # section label + {"Serial #": "total", "Model": "", "MSRP": 851620}, # totals row + {"Serial #": "15677", "Model": "Oak 28x56", "Manufacturing Plant": "Tru Belton"}, # real + {"Serial #": "nan", "Model": "nan"}, # empty + ] + rows = list(hos._iter_home_rows(records)) + assert [r["serial"] for r in rows] == ["15677"] + + +def test_full_transform_from_sheet_records(): + records = [ + {"Serial #": "15677", "Model": "Oak 28x56 3/2", "Manufacturing Plant": "Tru Belton", "Customer": None}, + {"Serial #": "10543", "Model": "Jackson 16x76", "Manufacturing Plant": "Jessup", "Customer": "Gabriel Mercado"}, + {"Serial #": "Serial #", "Model": "Model"}, # dropped + ] + docs = hos.house_orders_to_inventory_docs(records) + assert {d["serial_number"] for d in docs} == {"15677", "10543"} + by_serial = {d["serial_number"]: d for d in docs} + assert by_serial["15677"]["status"] == "AVAILABLE" + assert by_serial["10543"]["status"] == "SOLD" + assert "mercado" not in repr(docs).lower() # no buyer name anywhere + + +# --- sync / idempotency -------------------------------------------------------- + +def test_sync_dry_run_writes_nothing(): + db = FakeDatabase() + docs = hos.house_orders_to_inventory_docs([_sheet_rec("15677"), _sheet_rec("10543")]) + stats = hos.sync_house_orders_to_inventory(db, docs, dry_run=True) + assert stats["total"] == 2 and stats["written"] == 0 + assert db.docs == {} + + +def test_sync_apply_upserts_by_serial_idempotently(): + db = FakeDatabase() + docs = hos.house_orders_to_inventory_docs([_sheet_rec("15677")]) + first = hos.sync_house_orders_to_inventory(db, docs, dry_run=False) + assert first["written"] == 1 and set(db.docs) == {"15677"} + # Re-run: same serial doc id, no duplicate. + second = hos.sync_house_orders_to_inventory(db, docs, dry_run=False) + assert second["written"] == 1 and set(db.docs) == {"15677"} + + +def test_sync_counts_available_vs_sold(): + db = FakeDatabase() + docs = hos.house_orders_to_inventory_docs([ + _sheet_rec("15677", customer=None), + _sheet_rec("10543", customer="Some Buyer"), + ]) + stats = hos.sync_house_orders_to_inventory(db, docs, dry_run=False) + assert stats["available"] == 1 and stats["sold"] == 1 + + +def _sheet_rec(serial, customer=None): + return { + "Serial #": serial, + "Model": "Oak 28x56 3/2", + "Manufacturing Plant": "Tru Belton", + "Customer": customer, + } diff --git a/tools/house_orders_sync.py b/tools/house_orders_sync.py new file mode 100644 index 0000000..280435f --- /dev/null +++ b/tools/house_orders_sync.py @@ -0,0 +1,224 @@ +"""Sync the live "House Orders" sheet into the in-app Firestore inventory. + +House Orders.xlsx (Drive, owned by the THO operator, updated daily) is the real +source of truth for the dealer's stock — see the Command Center. The public +website was reading a frozen 2026-05-11 scrape instead; this sync makes the +in-app inventory store (served when ``INVENTORY_SOURCE=firestore``) reflect the +sheet. + +Relationship to the existing tools: +- ``tools/convert_inventory.py`` already parses House Orders → inventory.json, + but it carries ``invoice_amount`` (dealer COST) and hardcodes status=Available. + This module is the PII/cost-safe path for the *public* store. + +PII / sensitivity contract (the reason this is a separate module): +- The sheet's ``Customer`` column (a buyer NAME) is read ONLY to derive + availability (blank = available stock, filled = sold/committed) — the name is + **never** carried into a record or persisted. +- ``Invoice Amount`` (dealer cost) and ``MSRP`` (retail price) are **not** + written: THO sells "Call for Price", and cost must never reach a public surface. +- Records are built by an explicit ALLOW-LIST (emit only known-safe keys), so a + new sheet column can never silently leak into the public store. + +Idempotent: upserts via ``firestore_client.upsert_inventory`` keyed on +``serial_number`` = Firestore doc id, like ``tools/inventory_seed.py``. Dry-run +by default; ``--apply`` (or ``dry_run=False``) writes. +""" + +from __future__ import annotations + +import argparse +import logging +import re +from collections.abc import Iterable, Iterator +from typing import Any + +log = logging.getLogger(__name__) + +SYNC_SOURCE = "house_orders_sync" + +# The "Customer" column is read only to set availability. Anything here means +# the home is committed to a buyer and must NOT appear on the public site. +_SOLD_STATUS = "SOLD" +_AVAILABLE_STATUS = "AVAILABLE" + +# Keys we will ever persist. An allow-list (not a deny-list) so an unexpected +# sheet column can never leak. Note the absence of customer/invoice/salesman/price. +_ALLOWED_KEYS = frozenset({ + "serial_number", "serial_number_2", "model_name", "manufacturer", + "classification", "status", "is_new", + "bedrooms", "bathrooms", "sqft", "width", "length", "source", +}) + +# Sheet header / section labels that are not real inventory rows. +_NON_HOME_SERIAL_TOKENS = ("serial", "section", "approval", "days out", "wks", "nan", "total", "stock") + + +def _clean(value: Any) -> str: + if value is None: + return "" + text = str(value).strip() + return "" if text.lower() == "nan" else text + + +def _pick(record: dict, *names: str) -> Any: + """Case/space-insensitive column lookup across a few candidate names.""" + norm = {re.sub(r"[^a-z0-9]", "", k.lower()): v for k, v in record.items() if isinstance(k, str)} + for name in names: + key = re.sub(r"[^a-z0-9]", "", name.lower()) + if key in norm: + return norm[key] + return None + + +def _is_home_serial(serial: str) -> bool: + """A real home row has a serial that contains a digit and isn't a label.""" + if not serial: + return False + low = serial.lower() + if any(tok in low for tok in _NON_HOME_SERIAL_TOKENS): + return False + return any(ch.isdigit() for ch in serial) + + +def _status_from_customer(customer: Any) -> str: + """Blank customer => available stock; any name => sold/committed (name dropped).""" + return _AVAILABLE_STATUS if not _clean(customer) else _SOLD_STATUS + + +def _parse_model_specs(model: str) -> tuple[int | None, int | None, int | None, int | None]: + """Pull (width, length, beds, baths) out of a model string like 'Oak 28x56 3/2'.""" + width = length = beds = baths = None + dim = re.search(r"(\d{1,2})\s*[xX]\s*(\d{2,3})", model) + if dim: + width, length = int(dim.group(1)), int(dim.group(2)) + bb = re.search(r"(\d)\s*/\s*(\d)", model) + if bb: + beds, baths = int(bb.group(1)), int(bb.group(2)) + return width, length, beds, baths + + +def _iter_home_rows(records: Iterable[dict]) -> Iterator[dict]: + """Normalize raw sheet records to ``{serial, serial_2, model, manufacturer, customer}``. + + Filters out header/section/total rows and anything without a real serial or + model. ``customer`` is kept ONLY so the caller can derive status; it is never + emitted downstream. + """ + for rec in records: + serial = _clean(_pick(rec, "Serial #", "Serial #1", "Serial", "Serial Number")) + if not _is_home_serial(serial): + continue + model = _clean(_pick(rec, "Model", "Model Name")) + if not model or model.lower() in {"model", "nan"}: + continue + yield { + "serial": serial, + "serial_2": _clean(_pick(rec, "Serial #2", "Serial #1.1", "Serial 2")), + "model": model, + "manufacturer": _clean(_pick(rec, "Manufacturing Plant", "Manufacturer", "Plant")), + "customer": _pick(rec, "Customer"), # status only — never persisted + } + + +def house_order_row_to_inventory_doc(row: dict) -> dict | None: + """Map one normalized House Orders row to a PII/cost-free Firestore doc. + + Emits ONLY allow-listed keys. No customer name, no invoice cost, no price — + the public site keeps "Call for Price". Returns None for non-home rows. + """ + serial = _clean(row.get("serial")) + model = _clean(row.get("model")) + if not _is_home_serial(serial) or not model: + return None + width, length, beds, baths = _parse_model_specs(model) + doc = { + "serial_number": serial, + "serial_number_2": _clean(row.get("serial_2")), + "model_name": model, + "manufacturer": _clean(row.get("manufacturer")) or "Unknown", + "classification": "Double Wide" if (width or 0) >= 28 else "Single Wide", + "status": _status_from_customer(row.get("customer")), + "is_new": True, # House Orders = new factory stock; repos live in the 21st Repo DB + "bedrooms": beds, + "bathrooms": baths, + "sqft": (width * length) if (width and length) else None, + "width": width, + "length": length, + "source": SYNC_SOURCE, + } + # ALLOW-LIST emit: drop empty values AND anything not explicitly permitted. + return {k: v for k, v in doc.items() if k in _ALLOWED_KEYS and v not in (None, "")} + + +def house_orders_to_inventory_docs(records: Iterable[dict]) -> list[dict]: + """Full transform: raw sheet records -> list of safe Firestore inventory docs.""" + docs = [] + for row in _iter_home_rows(records): + doc = house_order_row_to_inventory_doc(row) + if doc: + docs.append(doc) + return docs + + +def parse_house_orders(path: str) -> list[dict]: + """Read House Orders.xlsx (sheet 'Everybody Else') into safe inventory docs.""" + import pandas as pd # local: pandas only needed for the real-file path + + df = pd.read_excel(path, sheet_name="Everybody Else", header=1) + records = df.to_dict("records") + return house_orders_to_inventory_docs(records) + + +def sync_house_orders_to_inventory( + db: Any, docs: list[dict], *, dry_run: bool = True, limit: int | None = None +) -> dict: + """Upsert inventory docs into Firestore, keyed on serial_number = doc id. + + Idempotent. ``db`` must provide ``upsert_inventory(doc_id, data)``. Returns a + stats dict; on ``dry_run`` nothing is written. + """ + stats: dict[str, Any] = {"total": 0, "written": 0, "available": 0, "sold": 0, "planned": []} + for doc in docs[: limit if limit else None]: + stats["total"] += 1 + if doc["status"] == _AVAILABLE_STATUS: + stats["available"] += 1 + else: + stats["sold"] += 1 + stats["planned"].append((doc["serial_number"], doc["model_name"], doc["status"])) + if dry_run: + continue + db.upsert_inventory(doc["serial_number"], doc) + stats["written"] += 1 + return stats + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Sync House Orders.xlsx -> Firestore inventory.") + parser.add_argument("--path", default="data/House Orders.xlsx", help="Path to House Orders.xlsx") + parser.add_argument("--apply", action="store_true", help="Write to Firestore (default: dry run).") + parser.add_argument("--limit", type=int, default=None) + args = parser.parse_args(argv) + logging.basicConfig(level=logging.INFO) + + docs = parse_house_orders(args.path) + db = None + if args.apply: + from database.firestore_client import get_database + + db = get_database() + stats = sync_house_orders_to_inventory(db, docs, dry_run=not args.apply, limit=args.limit) + mode = "APPLIED" if args.apply else "DRY-RUN" + print(f"[{mode}] homes: {stats['total']} | available: {stats['available']} | sold: {stats['sold']} " + f"| written: {stats['written']}") + if not args.apply: + for serial, model, status in stats["planned"][:25]: + print(f" {status:9} {serial}: {model}") + if len(stats["planned"]) > 25: + print(f" ... and {len(stats['planned']) - 25} more") + print("\nRe-run with --apply to write. Then flip INVENTORY_SOURCE=firestore to serve.") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) From ac82abfc9a13cf7995f4eb12ab777300d95a2043 Mon Sep 17 00:00:00 2001 From: arigatoexpress <95630102+arigatoexpress@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:23:27 -0600 Subject: [PATCH 05/10] feat(inventory): GCS loader for House Orders (enables scheduled refresh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_house_orders_from_gcs() downloads House Orders.xlsx from a GCS bucket to a temp file, parses it (PII/cost-safe path), and cleans up — so a daily Cloud Scheduler job or a staff 'refresh' action can keep inventory current from the operator's sheet without a manual local run. +1 test (mocked storage). ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_house_orders_sync.py | 40 +++++++++++++++++++++++++++++++++ tools/house_orders_sync.py | 26 +++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/tests/test_house_orders_sync.py b/tests/test_house_orders_sync.py index baaea49..b713688 100644 --- a/tests/test_house_orders_sync.py +++ b/tests/test_house_orders_sync.py @@ -7,6 +7,9 @@ website. """ +import os +import sys + from tools import house_orders_sync as hos @@ -144,3 +147,40 @@ def _sheet_rec(serial, customer=None): "Manufacturing Plant": "Tru Belton", "Customer": customer, } + + +def test_load_house_orders_from_gcs_downloads_parses_and_cleans_up(monkeypatch, tmp_path): + """The GCS loader downloads the xlsx, parses it, and removes the temp file.""" + import types + captured = {} + + class _FakeBlob: + def download_to_filename(self, path): + captured["downloaded_to"] = path + with open(path, "wb") as fh: + fh.write(b"fake-xlsx-bytes") + + class _FakeBucket: + def blob(self, name): + captured["blob"] = name + return _FakeBlob() + + class _FakeClient: + def bucket(self, name): + captured["bucket"] = name + return _FakeBucket() + + fake_storage = types.SimpleNamespace(Client=lambda: _FakeClient()) + monkeypatch.setitem(sys.modules, "google.cloud.storage", fake_storage) + # parse the downloaded file via a stub (avoids needing a real xlsx) + monkeypatch.setattr( + hos, "parse_house_orders", + lambda path: [{"serial_number": "X", "model_name": "M", "status": "AVAILABLE"}], + ) + + docs = hos.load_house_orders_from_gcs("tho-inventory-assets", "house-orders/House Orders.xlsx") + + assert captured["bucket"] == "tho-inventory-assets" + assert captured["blob"] == "house-orders/House Orders.xlsx" + assert docs == [{"serial_number": "X", "model_name": "M", "status": "AVAILABLE"}] + assert not os.path.exists(captured["downloaded_to"]) # temp file cleaned up diff --git a/tools/house_orders_sync.py b/tools/house_orders_sync.py index 280435f..4bb89d9 100644 --- a/tools/house_orders_sync.py +++ b/tools/house_orders_sync.py @@ -170,6 +170,32 @@ def parse_house_orders(path: str) -> list[dict]: return house_orders_to_inventory_docs(records) +def load_house_orders_from_gcs(bucket_name: str, blob_name: str) -> list[dict]: + """Download House Orders.xlsx from GCS to a temp file and parse it. + + Lets a daily Cloud Scheduler job (or a staff "refresh" action) keep the + in-app inventory current from the operator's sheet without a manual local + run. The run-as SA reads the bucket; nothing is persisted to disk beyond a + short-lived temp file. + """ + import os + import tempfile + + from google.cloud import storage + + blob = storage.Client().bucket(bucket_name).blob(blob_name) + fd, path = tempfile.mkstemp(suffix=".xlsx") + os.close(fd) + try: + blob.download_to_filename(path) + return parse_house_orders(path) + finally: + try: + os.unlink(path) + except OSError: + pass + + def sync_house_orders_to_inventory( db: Any, docs: list[dict], *, dry_run: bool = True, limit: int | None = None ) -> dict: From ad1ccb74fdb8a4ac6d59be686a533b65678a95db Mon Sep 17 00:00:00 2001 From: arigatoexpress <95630102+arigatoexpress@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:35:14 -0600 Subject: [PATCH 06/10] fix(inventory): never surface or persist dealer COST (cost-fence hardening) All-hands audit flagged a latent dealer-cost path the in-app branch makes reachable (no default-config leak, but a flag-flip + staff write or the agent's search_inventory() full-dict return could expose invoice_amount). - tools/inventory_tools.py: stop copying invoice_amount into the public read / agent pricing dict (the leak at the source). - main.py POST/PUT /api/inventory: strip {invoice_amount, invoice_date, cost} before upsert, so the permissive InventoryWrite(extra=allow) can't persist dealer cost into the public-served Firestore collection. - tests: assert cost POSTed to /api/inventory is not stored; firestore loader test no longer expects invoice_amount. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.py | 8 ++++++++ tests/test_api_v1.py | 17 +++++++++++++++++ tests/test_inventory_tools_firestore.py | 2 +- tools/inventory_tools.py | 5 ++++- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index f660192..b8e6765 100644 --- a/main.py +++ b/main.py @@ -2973,6 +2973,10 @@ async def create_inventory_item(request: Request): try: data = await request.json() payload = InventoryWrite(**data).model_dump(exclude_none=True) + # Never persist dealer COST into the public-served inventory collection, + # even though InventoryWrite is permissive (extra="allow"). + for _cost_field in ("invoice_amount", "invoice_date", "cost"): + payload.pop(_cost_field, None) if not payload.get("model_name"): return JSONResponse({"success": False, "error": "model_name is required."}, status_code=400) payload.setdefault("status", "AVAILABLE") @@ -3000,6 +3004,10 @@ async def update_inventory_item(inventory_id: str, request: Request): try: data = await request.json() payload = InventoryWrite(**data).model_dump(exclude_none=True) + # Never persist dealer COST into the public-served inventory collection, + # even though InventoryWrite is permissive (extra="allow"). + for _cost_field in ("invoice_amount", "invoice_date", "cost"): + payload.pop(_cost_field, None) if not payload: return JSONResponse({"success": False, "error": "No fields to update."}, status_code=400) _db.update_inventory(inventory_id, payload) diff --git a/tests/test_api_v1.py b/tests/test_api_v1.py index d57cc12..42897b8 100644 --- a/tests/test_api_v1.py +++ b/tests/test_api_v1.py @@ -2442,3 +2442,20 @@ def test_mira_update_lead_triage_not_found(monkeypatch): data = response.json() assert data["status"] == "error" assert "not found" in data["error"].lower() + + +def test_create_inventory_strips_dealer_cost(monkeypatch): + """Dealer COST must never be persisted into the public-served inventory store.""" + client, main, fake_db, _logger = create_client(monkeypatch, tho_api_key="tho-secret") + token = main._create_admin_token() + resp = client.post( + "/api/inventory", + json={"model_name": "The Nassau", "invoice_amount": 60187.0, + "invoice_date": "2026-01-01", "cost": 50000}, + headers={"X-Admin-Token": token}, + ) + assert resp.status_code == 200 + stored = fake_db.collections["inventory"][resp.json()["id"]] + for forbidden in ("invoice_amount", "invoice_date", "cost"): + assert forbidden not in stored + assert stored["model_name"] == "The Nassau" diff --git a/tests/test_inventory_tools_firestore.py b/tests/test_inventory_tools_firestore.py index bcc871e..7905466 100644 --- a/tests/test_inventory_tools_firestore.py +++ b/tests/test_inventory_tools_firestore.py @@ -52,7 +52,7 @@ def search_inventory(self, status, limit): "price_value": 0, "display_price": "Call for Price", "price_tier": "Under $50k", - "invoice_amount": None, + # invoice_amount (dealer cost) is intentionally NOT surfaced here. }, "features": [], "marketing_tags": [], diff --git a/tools/inventory_tools.py b/tools/inventory_tools.py index 44a42c3..914c4ef 100644 --- a/tools/inventory_tools.py +++ b/tools/inventory_tools.py @@ -141,7 +141,10 @@ def _load_inventory_from_firestore(): if price_value > 0 else "Call for Price", "price_tier": price_tier, - "invoice_amount": item.get("invoice_amount"), + # Dealer COST (invoice_amount) is deliberately NOT surfaced: + # this dict feeds the public inventory read path and the + # agent's search_inventory(); cost must never reach a + # public/customer-facing surface. }, "features": item.get("features", []), "marketing_tags": item.get("marketing_tags", []), From c8df9484dddd7e2dc1d600e8216a9ab13799b738 Mon Sep 17 00:00:00 2001 From: arigatoexpress <95630102+arigatoexpress@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:36:54 -0600 Subject: [PATCH 07/10] fix(inventory): harden House Orders dims/beds-baths parsing All-hands audit found _parse_model_specs misparsed dates as beds/baths ('Model 6/12/2024 28x56' -> beds=6), accepted 3-digit widths as 0, and took transposed 'LxW' dims verbatim. Now: dims anchored to non-digits + sanity-bounded to 8-40 + de-transposed; beds/baths require single digits not inside a longer run (dates ignored). +4 assertions. ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_house_orders_sync.py | 10 ++++++++++ tools/house_orders_sync.py | 18 ++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/tests/test_house_orders_sync.py b/tests/test_house_orders_sync.py index b713688..9c011e1 100644 --- a/tests/test_house_orders_sync.py +++ b/tests/test_house_orders_sync.py @@ -184,3 +184,13 @@ def bucket(self, name): assert captured["blob"] == "house-orders/House Orders.xlsx" assert docs == [{"serial_number": "X", "model_name": "M", "status": "AVAILABLE"}] assert not os.path.exists(captured["downloaded_to"]) # temp file cleaned up + + +def test_parse_model_specs_ignores_dates_and_bad_dims(): + # A date in the model must NOT be read as beds/baths, and the real dims still parse. + w, length, beds, baths = hos._parse_model_specs("Model 6/12/2024 28x56") + assert (w, length) == (28, 56) + assert beds is None and baths is None + assert hos._parse_model_specs("Oak 28x56 3/2")[2:] == (3, 2) # clean beds/baths + assert hos._parse_model_specs("Bigfoot 100x200")[:2] == (None, None) # 3-digit width rejected + assert hos._parse_model_specs("Sunshine 76x14")[:2] == (14, 76) # transposed LxW de-transposed diff --git a/tools/house_orders_sync.py b/tools/house_orders_sync.py index 4bb89d9..6d4a6d0 100644 --- a/tools/house_orders_sync.py +++ b/tools/house_orders_sync.py @@ -87,12 +87,22 @@ def _status_from_customer(customer: Any) -> str: def _parse_model_specs(model: str) -> tuple[int | None, int | None, int | None, int | None]: - """Pull (width, length, beds, baths) out of a model string like 'Oak 28x56 3/2'.""" + """Pull (width, length, beds, baths) out of a model string like 'Oak 28x56 3/2'. + + Hardened against junk: the dims regex is anchored to non-digits so a 3-digit + run ('100x200') or a date doesn't slip in; width is sanity-bounded (8-40, a + real section width) and de-transposed ('76x14' -> 14x76); beds/baths require + single digits NOT inside a longer run so a date ('6/12/2024') is ignored. + """ width = length = beds = baths = None - dim = re.search(r"(\d{1,2})\s*[xX]\s*(\d{2,3})", model) + dim = re.search(r"(? 40 and 8 <= length_val <= 40: # written 'LxW' -> swap to WxL + w, length_val = length_val, w + if 8 <= w <= 40: + width, length = w, length_val + bb = re.search(r"(? Date: Tue, 16 Jun 2026 13:07:26 -0600 Subject: [PATCH 08/10] feat(inventory): staff Inventory Manager UI wired into admin area Adds the in-app inventory management page (create/edit/retire homes in the Firestore store the public site serves when INVENTORY_SOURCE=firestore) and wires it into App.jsx state routing as admin page 'manage-inventory': lazy import, ADMIN_PAGE_KEYS, NavBar admin item, pageFromPath, render block. Cost fields are never shown or sent (public read path hides dealer cost). Completes the in-app inventory unfreeze end-to-end (consumes the admin CRUD endpoints POST/PUT/DELETE /api/inventory already on this branch). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/App.jsx | 19 ++- frontend/src/pages/InventoryManager.jsx | 212 ++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/InventoryManager.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d6de240..08fb2ef 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -36,8 +36,9 @@ const FAQ = lazy(() => import('./pages/FAQ')); const Warranty = lazy(() => import('./pages/Warranty')); const Delivery = lazy(() => import('./pages/Delivery')); const PhotoManager = lazy(() => import('./pages/PhotoManager')); +const InventoryManager = lazy(() => import('./pages/InventoryManager')); const ADMIN_PIN_LENGTH = 8; -const ADMIN_PAGE_KEYS = new Set(['analytics', 'crm', 'chat-history', 'documents', 'adstudio', 'system', 'getting-started', 'photos']); +const ADMIN_PAGE_KEYS = new Set(['analytics', 'crm', 'chat-history', 'documents', 'adstudio', 'system', 'getting-started', 'photos', 'manage-inventory']); // Page loading fallback with skeleton const PageLoader = () => ( @@ -73,6 +74,7 @@ function NavBar({ const adminItems = adminAuthed ? [ { key: 'copilot', label: 'Ops Copilot', icon: Sparkles }, { key: 'documents', label: 'Documents', icon: FileText }, + { key: 'manage-inventory', label: 'Inventory', icon: Home }, { key: 'photos', label: 'Photos', icon: Camera }, { key: 'crm', label: 'CRM', icon: Users }, { key: 'system', label: 'System Hub', icon: Activity }, @@ -416,6 +418,7 @@ function App() { if (p.startsWith('/warranty')) return 'warranty'; if (p.startsWith('/delivery')) return 'delivery'; if (p.startsWith('/chat-history')) return 'chat-history'; + if (p.startsWith('/manage-inventory')) return 'manage-inventory'; if (p.startsWith('/copilot') || p.startsWith('/ops-copilot')) return 'copilot'; if (p.startsWith('/chat')) return 'chat'; if (p.startsWith('/inventory')) return 'inventory'; @@ -1218,6 +1221,20 @@ function App() { ); } + if (activePage === 'manage-inventory' && adminAuthed) { + return ( +
+ {appModals} + + + }> + navigateTo('inventory')} onNavigate={navigateTo} /> + + +
+ ); + } + if (activePage === 'chat-history' && adminAuthed) { return (
diff --git a/frontend/src/pages/InventoryManager.jsx b/frontend/src/pages/InventoryManager.jsx new file mode 100644 index 0000000..e1a2259 --- /dev/null +++ b/frontend/src/pages/InventoryManager.jsx @@ -0,0 +1,212 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Home, Plus, Pencil, Archive, Loader2, X, Camera } from 'lucide-react'; +import adminFetch from '../adminFetch'; + +// Staff Inventory Manager — create / edit / retire homes in the in-app +// (Firestore) inventory store the public site serves when INVENTORY_SOURCE= +// firestore. Wired to the admin CRUD endpoints (POST/PUT/DELETE /api/inventory). +// Cost fields are never shown or sent — the public read path hides dealer cost. + +const STATUSES = ['AVAILABLE', 'PENDING', 'RESERVED', 'SOLD', 'RETIRED']; +const CLASSIFICATIONS = ['Single Wide', 'Double Wide']; + +const EMPTY = { + model_name: '', manufacturer: '', classification: 'Single Wide', status: 'AVAILABLE', + serial_number: '', bedrooms: '', bathrooms: '', sqft: '', width: '', length: '', + is_new: true, features: '', +}; + +function toPayload(form) { + const num = (v) => (v === '' || v === null ? undefined : Number(v)); + const payload = { + model_name: form.model_name.trim(), + manufacturer: form.manufacturer.trim() || undefined, + classification: form.classification || undefined, + status: form.status || undefined, + serial_number: form.serial_number.trim() || undefined, + bedrooms: num(form.bedrooms), bathrooms: num(form.bathrooms), sqft: num(form.sqft), + width: num(form.width), length: num(form.length), is_new: !!form.is_new, + features: form.features ? form.features.split(',').map((s) => s.trim()).filter(Boolean) : undefined, + }; + Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]); + return payload; +} + +export default function InventoryManager({ onBack, onNavigate }) { + const [homes, setHomes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [message, setMessage] = useState(null); // {type, text} + const [editing, setEditing] = useState(null); // null | 'new' | home id + const [form, setForm] = useState(EMPTY); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await adminFetch('/api/inventory?status=&limit=500'); + const data = await res.json(); + if (data.success) setHomes(data.inventory || []); + else setError(data.error || 'Failed to load inventory.'); + } catch { + setError('Failed to load inventory.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const startNew = () => { setForm(EMPTY); setEditing('new'); setMessage(null); }; + const startEdit = (home) => { + setForm({ + ...EMPTY, ...home, + bedrooms: home.beds ?? home.bedrooms ?? '', bathrooms: home.baths ?? home.bathrooms ?? '', + sqft: home.sqft ?? '', features: (home.features || []).join(', '), + }); + setEditing(home.id); + setMessage(null); + }; + + const save = async () => { + if (!form.model_name.trim()) { setMessage({ type: 'error', text: 'Model name is required.' }); return; } + setSaving(true); + setMessage(null); + try { + const isNew = editing === 'new'; + const res = await adminFetch(isNew ? '/api/inventory' : `/api/inventory/${encodeURIComponent(editing)}`, { + method: isNew ? 'POST' : 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(toPayload(form)), + }); + const data = await res.json(); + if (data.success) { + setMessage({ type: 'ok', text: isNew ? 'Home added.' : 'Home updated.' }); + setEditing(null); + await load(); + } else { + setMessage({ type: 'error', text: data.error || 'Save failed.' }); + } + } catch { + setMessage({ type: 'error', text: 'Save failed.' }); + } finally { + setSaving(false); + } + }; + + const retire = async (home) => { + if (!window.confirm(`Retire "${home.model_name}"? It will drop off the public site but is kept.`)) return; + try { + const res = await adminFetch(`/api/inventory/${encodeURIComponent(home.id)}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { setMessage({ type: 'ok', text: 'Home retired.' }); await load(); } + else setMessage({ type: 'error', text: data.error || 'Retire failed.' }); + } catch { + setMessage({ type: 'error', text: 'Retire failed.' }); + } + }; + + const field = (label, key, props = {}) => ( + + ); + + return ( +
+
+ +

Manage Inventory

+ +
+ + {message && ( +
+ {message.text} +
+ )} + + {editing && ( +
+
+

{editing === 'new' ? 'Add a home' : 'Edit home'}

+ +
+
+ {field('Model name *', 'model_name')} + {field('Manufacturer', 'manufacturer')} + + + {field('Serial #', 'serial_number')} + {field('Bedrooms', 'bedrooms', { type: 'number', min: 0 })} + {field('Bathrooms', 'bathrooms', { type: 'number', min: 0, step: '0.5' })} + {field('Sq ft', 'sqft', { type: 'number', min: 0 })} + {field('Width', 'width', { type: 'number', min: 0 })} + {field('Length', 'length', { type: 'number', min: 0 })} + {field('Features (comma-separated)', 'features')} +
+
+ + +
+

Dealer cost is never shown or stored on the public listing. Photos are managed in the Photos tab.

+
+ )} + + {loading ? ( +
Loading inventory…
+ ) : error ? ( +
{error}
+ ) : homes.length === 0 ? ( +
No homes yet. Click “Add Home” to create one.
+ ) : ( +
+ + + + + + {homes.map((h) => ( + + + + + + + + ))} + +
ModelManufacturerStatusBeds/BathsActions
{h.model_name || '—'}{h.manufacturer || '—'}{h.status || '—'}{(h.beds ?? h.bedrooms ?? '—')}/{(h.baths ?? h.bathrooms ?? '—')} +
+ + {onNavigate && } + +
+
+
+ )} +
+ ); +} From eb75379db3862fc76f4bbbd42c81d8898b4b745e Mon Sep 17 00:00:00 2001 From: arigatoexpress <95630102+arigatoexpress@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:20:47 -0600 Subject: [PATCH 09/10] test(inventory): make GCS loader test hermetic (no ADC needed in CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loader does `from google.cloud import storage`; faking it via sys.modules is bypassed once the real submodule is imported earlier in the full suite, so CI's credential-free job hit DefaultCredentialsError. Patch Client on the real module instead — order-independent, no GCS creds. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_house_orders_sync.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_house_orders_sync.py b/tests/test_house_orders_sync.py index 9c011e1..7f65240 100644 --- a/tests/test_house_orders_sync.py +++ b/tests/test_house_orders_sync.py @@ -151,7 +151,6 @@ def _sheet_rec(serial, customer=None): def test_load_house_orders_from_gcs_downloads_parses_and_cleans_up(monkeypatch, tmp_path): """The GCS loader downloads the xlsx, parses it, and removes the temp file.""" - import types captured = {} class _FakeBlob: @@ -170,8 +169,12 @@ def bucket(self, name): captured["bucket"] = name return _FakeBucket() - fake_storage = types.SimpleNamespace(Client=lambda: _FakeClient()) - monkeypatch.setitem(sys.modules, "google.cloud.storage", fake_storage) + # Patch Client on the REAL module: the loader does `from google.cloud import + # storage`, and a sys.modules swap is bypassed once the real submodule is + # imported earlier in the full suite (import-order fragility). Patching the + # attribute is order-independent and needs no GCS credentials. + from google.cloud import storage as _gcs + monkeypatch.setattr(_gcs, "Client", lambda: _FakeClient()) # parse the downloaded file via a stub (avoids needing a real xlsx) monkeypatch.setattr( hos, "parse_house_orders", From 18ceb2c9f2e81a7ded01cbec5b79d082ef526f07 Mon Sep 17 00:00:00 2001 From: arigatoexpress <95630102+arigatoexpress@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:21:21 -0600 Subject: [PATCH 10/10] style(test): drop now-unused sys import in house_orders_sync test Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_house_orders_sync.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_house_orders_sync.py b/tests/test_house_orders_sync.py index 7f65240..a719a5e 100644 --- a/tests/test_house_orders_sync.py +++ b/tests/test_house_orders_sync.py @@ -8,7 +8,6 @@ """ import os -import sys from tools import house_orders_sync as hos