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/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/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 (
+
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.
+ ) : (
+
+
+
+ | Model | Manufacturer | Status | Beds/Baths | Actions |
+
+
+ {homes.map((h) => (
+
+ | {h.model_name || '—'} |
+ {h.manufacturer || '—'} |
+ {h.status || '—'} |
+ {(h.beds ?? h.bedrooms ?? '—')}/{(h.baths ?? h.bathrooms ?? '—')} |
+
+
+
+ {onNavigate && }
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/main.py b/main.py
index 38f520a..b8e6765 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,96 @@ 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)
+ # 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")
+ 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)
+ # 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)
+ 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):
"""
@@ -4200,115 +4290,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..42897b8 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")
@@ -843,6 +859,148 @@ 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_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()
@@ -2284,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_house_orders_sync.py b/tests/test_house_orders_sync.py
new file mode 100644
index 0000000..a719a5e
--- /dev/null
+++ b/tests/test_house_orders_sync.py
@@ -0,0 +1,198 @@
+"""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.
+"""
+
+import os
+
+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,
+ }
+
+
+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."""
+ 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()
+
+ # 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",
+ 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
+
+
+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/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/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/house_orders_sync.py b/tools/house_orders_sync.py
new file mode 100644
index 0000000..6d4a6d0
--- /dev/null
+++ b/tools/house_orders_sync.py
@@ -0,0 +1,260 @@
+"""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'.
+
+ 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"(? 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"(? 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 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:
+ """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())
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())
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", []),