- collections_routes.py: /nav/collections CRUD over virtual_collection + /nav/locate (exact-then-rules)
- navigator_routes.py: /nav/reorg/{scan,apply,compress} (atomic re-file)
- disc_cache gets year/genre_ids/style_ids cols for rule matching (backfill TODO)
- /search: collections list + locator fallback shows the should-be-here zone for unfiled hits
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
191 lines
8.9 KiB
Python
191 lines
8.9 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import text
|
|
|
|
from .auth import require_token
|
|
from .db import get_db
|
|
|
|
# Store Navigator — the WowPlatter "Search & Inventory Management" drill-down, RecordGod-side.
|
|
# Store → Rack → Crate → Item, over the migrated virtual_* geometry. See RECORDGOD_NAVIGATOR_PLAN.md.
|
|
# Dims are METRES here (the 3D-store convention the data was migrated into), not WowPlatter's old cm.
|
|
router = APIRouter(prefix="/nav", tags=["navigator"])
|
|
|
|
|
|
@router.get("/spaces")
|
|
async def spaces(ident=Depends(require_token), db=Depends(get_db)):
|
|
rows = await db.execute(text("""
|
|
SELECT id, name, room_width::float AS room_width, room_depth::float AS room_depth,
|
|
(is_default = 'y') AS is_default
|
|
FROM virtual_space WHERE visible = 'y'
|
|
ORDER BY (is_default = 'y') DESC, id
|
|
"""))
|
|
return {"spaces": [dict(r) for r in rows.mappings()]}
|
|
|
|
|
|
@router.get("/store-layout")
|
|
async def store_layout(space_id: int | None = None, ident=Depends(require_token), db=Depends(get_db)):
|
|
"""All racks (position + facing + type dims + counts) for one space — the top-down store view."""
|
|
if space_id is None:
|
|
sp = (await db.execute(text(
|
|
"SELECT id FROM virtual_space WHERE visible='y' ORDER BY (is_default='y') DESC, id LIMIT 1"
|
|
))).first()
|
|
space_id = sp[0] if sp else None
|
|
space = (await db.execute(text("""
|
|
SELECT id, name, room_width::float AS room_width, room_depth::float AS room_depth
|
|
FROM virtual_space WHERE id = :s
|
|
"""), {"s": space_id})).mappings().first()
|
|
racks = [dict(r) for r in (await db.execute(text("""
|
|
SELECT r.id, r.name, r.pos_x::float AS x, r.pos_z::float AS z,
|
|
r.rotation_y::float AS rot, r.direction,
|
|
rt.type AS rack_type, rt.name AS type_name,
|
|
rt.width::float AS w, rt.depth::float AS d, coalesce(rt.levels,1)::int AS levels,
|
|
(SELECT count(*) FROM virtual_crate c WHERE c.rack_id = r.id AND c.visible = 'y') AS crates,
|
|
(SELECT count(*) FROM inventory i
|
|
WHERE i.crate_id IN (SELECT id FROM virtual_crate c WHERE c.rack_id = r.id)
|
|
AND i.in_stock) AS items
|
|
FROM virtual_rack r LEFT JOIN virtual_rack_type rt ON rt.id = r.rack_type_id
|
|
WHERE r.visible = 'y' AND r.space_id = :s
|
|
AND coalesce(rt.rack_purpose, 'stock') <> 'prop' -- functional racks only; props = 3D scenery
|
|
"""), {"s": space_id})).mappings()]
|
|
return {"ok": True, "space": dict(space) if space else None, "racks": racks}
|
|
|
|
|
|
@router.get("/rack/{rack_id}")
|
|
async def rack_detail(rack_id: int, ident=Depends(require_token), db=Depends(get_db)):
|
|
"""One rack: its type dims/levels + every crate (level, slot, facing, label, item count)."""
|
|
rack = (await db.execute(text("""
|
|
SELECT r.id, r.name, r.direction, r.pos_x::float AS x, r.pos_z::float AS z,
|
|
r.rotation_y::float AS rot, r.space_id,
|
|
rt.type AS rack_type, rt.name AS type_name,
|
|
rt.width::float AS w, rt.depth::float AS d,
|
|
coalesce(rt.levels,1)::int AS levels, coalesce(rt.slots_per_level,0)::int AS slots_per_level
|
|
FROM virtual_rack r LEFT JOIN virtual_rack_type rt ON rt.id = r.rack_type_id
|
|
WHERE r.id = :id
|
|
"""), {"id": rack_id})).mappings().first()
|
|
if not rack:
|
|
raise HTTPException(404, "rack not found")
|
|
crates = [dict(r) for r in (await db.execute(text("""
|
|
SELECT c.id, c.name, c.label_text, c.rack_level_index::int AS level, c.slot_number::int AS slot,
|
|
c.direction, c.pos_x::float AS x, c.pos_z::float AS z, c.crate_purpose,
|
|
(SELECT count(*) FROM inventory i WHERE i.crate_id = c.id AND i.in_stock) AS items
|
|
FROM virtual_crate c
|
|
WHERE c.rack_id = :id AND c.visible = 'y'
|
|
ORDER BY c.rack_level_index NULLS FIRST, c.slot_number NULLS LAST
|
|
"""), {"id": rack_id})).mappings()]
|
|
levels = [dict(r) for r in (await db.execute(text("""
|
|
SELECT l.level_index::int AS level, coalesce(l.slots,0)::int AS slots
|
|
FROM virtual_rack_type_level l
|
|
JOIN virtual_rack r ON r.rack_type_id = l.rack_type_id
|
|
WHERE r.id = :id ORDER BY l.level_index
|
|
"""), {"id": rack_id})).mappings()]
|
|
return {"ok": True, "rack": dict(rack), "crates": crates, "levels": levels}
|
|
|
|
|
|
@router.get("/crate/{crate_id}")
|
|
async def crate_contents(crate_id: int, ident=Depends(require_token), db=Depends(get_db)):
|
|
"""A crate's records, in slot order — the Crate Contents list."""
|
|
crate = (await db.execute(text("""
|
|
SELECT c.id, c.name, c.label_text, c.rack_id, c.rack_level_index::int AS level,
|
|
c.slot_number::int AS slot, c.direction, c.crate_purpose,
|
|
r.name AS rack_name
|
|
FROM virtual_crate c LEFT JOIN virtual_rack r ON r.id = c.rack_id
|
|
WHERE c.id = :id
|
|
"""), {"id": crate_id})).mappings().first()
|
|
if not crate:
|
|
raise HTTPException(404, "crate not found")
|
|
items = [dict(r) for r in (await db.execute(text("""
|
|
SELECT i.sku, i.slot_number::int AS slot, coalesce(i.title, dc.title) AS title,
|
|
dc.artist, i.price::float AS price, i.condition, i.in_stock
|
|
FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
|
|
WHERE i.crate_id = :id
|
|
ORDER BY i.slot_number NULLS LAST, i.sku
|
|
"""), {"id": crate_id})).mappings()]
|
|
return {"ok": True, "crate": dict(crate), "items": items}
|
|
|
|
|
|
# ── Reorganize — A-Z re-file planner (server scans + applies; client plans the moves) ──────────
|
|
class ScanIn(BaseModel):
|
|
crate_ids: list[int]
|
|
|
|
|
|
class Move(BaseModel):
|
|
sku: str
|
|
new_crate_id: int
|
|
new_slot: int
|
|
|
|
|
|
class Leftover(BaseModel):
|
|
sku: str
|
|
archive_location: str | None = None
|
|
|
|
|
|
class ApplyIn(BaseModel):
|
|
matched: list[Move] = []
|
|
leftovers: list[Leftover] = []
|
|
|
|
|
|
@router.post("/reorg/scan")
|
|
async def reorg_scan(body: ScanIn, ident=Depends(require_token), db=Depends(get_db)):
|
|
"""Every in-stock item across the source crates — the pool the client sorts + redistributes."""
|
|
ids = [c for c in body.crate_ids if c and c > 0]
|
|
if not ids:
|
|
raise HTTPException(400, "no crate ids")
|
|
rows = [dict(r) for r in (await db.execute(text("""
|
|
SELECT i.sku, i.release_id, i.crate_id, i.slot_number::int AS slot, i.price::float AS price,
|
|
i.condition, coalesce(i.title, dc.title) AS title, dc.artist, dc.year, dc.thumb,
|
|
c.label_text AS crate_label
|
|
FROM inventory i
|
|
LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
|
|
LEFT JOIN virtual_crate c ON c.id = i.crate_id
|
|
WHERE i.crate_id = ANY(:ids) AND i.in_stock
|
|
ORDER BY i.crate_id, i.slot_number NULLS LAST
|
|
"""), {"ids": ids})).mappings()]
|
|
return {"ok": True, "items": rows, "count": len(rows), "crate_ids": ids}
|
|
|
|
|
|
@router.post("/reorg/apply")
|
|
async def reorg_apply(body: ApplyIn, ident=Depends(require_token), db=Depends(get_db)):
|
|
"""Commit the plan in ONE transaction: matched → new crate+slot, leftovers → unfiled/archived."""
|
|
if not body.matched and not body.leftovers:
|
|
raise HTTPException(400, "nothing to apply")
|
|
moved = 0
|
|
for m in body.matched:
|
|
if not m.sku or m.new_crate_id < 1 or m.new_slot < 1:
|
|
continue
|
|
r = await db.execute(text("""
|
|
UPDATE inventory SET crate_id=:c, slot_number=:s, updated_at=now()
|
|
WHERE sku=:sku
|
|
"""), {"c": m.new_crate_id, "s": m.new_slot, "sku": m.sku})
|
|
moved += r.rowcount or 0
|
|
archived = 0
|
|
for lo in body.leftovers:
|
|
if not lo.sku:
|
|
continue
|
|
r = await db.execute(text("""
|
|
UPDATE inventory SET crate_id=NULL, slot_number=NULL, location=:loc, updated_at=now()
|
|
WHERE sku=:sku
|
|
"""), {"loc": lo.archive_location, "sku": lo.sku})
|
|
archived += r.rowcount or 0
|
|
if moved == 0 and body.matched:
|
|
# nothing matched a real SKU — roll back rather than half-apply
|
|
await db.rollback()
|
|
raise HTTPException(409, f"0 of {len(body.matched)} items matched an inventory SKU — nothing written")
|
|
await db.commit()
|
|
return {"ok": True, "matched_updated": moved, "leftovers_archived": archived}
|
|
|
|
|
|
@router.post("/reorg/compress/{crate_id}")
|
|
async def reorg_compress(crate_id: int, ident=Depends(require_token), db=Depends(get_db)):
|
|
"""Close gaps in a crate's slot numbering — renumber 1..N by current slot order."""
|
|
await db.execute(text("""
|
|
WITH ranked AS (
|
|
SELECT sku, row_number() OVER (ORDER BY slot_number NULLS LAST, sku) AS rn
|
|
FROM inventory WHERE crate_id = :c AND in_stock)
|
|
UPDATE inventory i SET slot_number = ranked.rn, updated_at = now()
|
|
FROM ranked WHERE i.sku = ranked.sku
|
|
"""), {"c": crate_id})
|
|
n = (await db.execute(text(
|
|
"SELECT count(*) FROM inventory WHERE crate_id=:c AND in_stock"), {"c": crate_id})).scalar()
|
|
await db.commit()
|
|
return {"ok": True, "slots": n}
|