- 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>
201 lines
15 KiB
Markdown
201 lines
15 KiB
Markdown
# RecordGod — Search & Inventory Management (Navigator) port
|
||
|
||
Faithful port of WowPlatter's **Search & Inventory Management** workstation onto RecordGod's
|
||
Postgres + the already-migrated `virtual_*` geometry. This is the real thing — Store→Rack→Crate→Item
|
||
drill-down, per-level slot layouts with orientation, scan-to-slot, reorganize, returns, stock finder —
|
||
NOT the toy `/search` that ships today (that becomes Phase 0's store view).
|
||
|
||
Source studied: `wowplatter/admin/partials/search.php` (844 lines), `admin/js/search/main.js` (8,482 lines,
|
||
the live renderer), `admin/js/src/navigator/{diagram,scanner,views,state}.js`,
|
||
`includes/repositories/class-wowplatter-virtual-store-repository.php`, schema-manager virtual tables.
|
||
|
||
UI rule: **light/clean shop-admin theme** (see memory `recordgod-admin-light-ui`), not the dark dash.
|
||
|
||
---
|
||
|
||
## 1. The workstation shape
|
||
|
||
- **Top bar:** Space selector (`booth-room (default)` …), **view zoom** = `Store · Rack · Crate · Item` + **Auto**,
|
||
level controls `◀ Level 1 of 2 (BOTTOM) ▶`, rack-rotate, refresh.
|
||
- **5 tabs:** `Scanner · Returns · Reorganize · Collections · Stock Finder`.
|
||
- **Center:** the canvas diagram (changes with the zoom level).
|
||
- **Right:** Crate Contents list (items in the active crate) + collapsible side panels:
|
||
**Quick Insert · Release Info · Crate Info · Rack Info**.
|
||
|
||
The 4 zoom levels are ONE canvas that re-renders:
|
||
- **Store** — top-down room: racks at their positions, doors/portals, compass. Click a rack → Rack.
|
||
- **Rack** — one **level** of the selected rack (prev/next level), crates laid in **slots** with an
|
||
**orientation arrow** + genre label + slot number, BACK/FRONT/L/R around the rack. Click a crate → Crate.
|
||
- **Crate** — the crate's slot grid (rows×cols from crate_type) + Crate Contents (items by `slot_number`).
|
||
- **Item** — individual slot/record detail.
|
||
|
||
---
|
||
|
||
## 2. Data model — ALREADY in RecordGod (migrated verbatim by `migrate_virtual.py`)
|
||
|
||
Everything below is already in `recordgod-db`; the work is API + front-end, not schema.
|
||
|
||
| table | the bits that matter |
|
||
|---|---|
|
||
| `virtual_space` | `room_width, room_depth, is_default, visible` — the room |
|
||
| `virtual_rack_type` | `width, height, depth`, panel flags, material; **levels** via `virtual_rack_type_level` |
|
||
| `virtual_rack_type_level` | per-type level rows (level index, shelf height/thickness) — defines how many levels a rack has |
|
||
| `virtual_rack` | **`pos_x/pos_y/pos_z`**, `rotation_x/y/z`, `space_id`, `rack_type_id`, **`direction` (front/back/left/right)**, `genre_ids`, `style_ids`, `attach_wall`, `name` |
|
||
| `virtual_crate_type` | `width/height/depth`, **`record_rows`, `record_columns`** (the slot grid), material |
|
||
| `virtual_crate` | `rack_id`, **`rack_level_index`**, `crate_type_id`, **`pos_x/y/z` (relative to rack origin, m)**, **`direction` (forward/back/left/right) = the crate's facing**, `rotation_x/y/z` (all 0 in data — IGNORE for facing), `layer (upper/lower/floor)`, `name` |
|
||
| `inventory` | **`crate_id` + `slot_number`** — each record's home crate + its slot |
|
||
|
||
Hierarchy: **Space → Racks(positioned) → Levels(`rack_level_index`) → Crates(in slots, oriented) → Items(`slot_number`)**.
|
||
|
||
Genre/style: `virtual_rack.genre_ids`/`style_ids` are comma-sep id lists; crate-level tags via a tag table
|
||
(WowPlatter `wowplatter_get_crate_tags`). Confirm the migrated tag table name in P2.
|
||
|
||
**Props vs stock (important, every phase):** `virtual_rack_type.rack_purpose` ∈ `stock` (real record racks)
|
||
or `prop` (3D scenery — COLUMNs, AIRCON, STATION, DOOR, couch, trolley…). The functional navigator must
|
||
**filter `rack_purpose <> 'prop'`** everywhere (store-layout already does). `type` ∈ rack/bench/shelf/'' is the
|
||
geometry kind, NOT the stock/prop distinction — don't filter on `type`.
|
||
|
||
---
|
||
|
||
## 3. The geometry, decoded (the tricky x/y/z)
|
||
|
||
**Units.** Rack `width/depth` are **cm** (`/100` = metres, `/200` = half). Crate dims + crate `pos_*` are **metres**.
|
||
Rack `pos_x/pos_z` are **room metres**.
|
||
|
||
**Facing composes — the slippery bit (confirmed in data 2026-06-22).** There are TWO independent `direction`
|
||
columns and they STACK:
|
||
- `virtual_rack.direction` ∈ `front/back/left/right` — which way the whole rack faces in the room.
|
||
- `virtual_crate.direction` ∈ `forward/back/left/right` — which way THAT crate faces (data: forward 59 / back 57 /
|
||
left 114 / right 122). **This is the facing field — NOT `rotation_y` (it's 0.0 on every row).**
|
||
- **A crate's world facing = `rack.direction` ∘ `crate.direction`.** In the rack-level (front-on) view the
|
||
orientation arrow is the crate's OWN direction in the rack frame; in the store/3D world view you must compose
|
||
it with the rack's direction or the arrows point the wrong way. Never read one without the other.
|
||
- Vocab differs on purpose: crate says `forward`, rack says `front` — don't treat the strings as interchangeable.
|
||
|
||
**Render convention (copied from production `main.js`, do not reinvent):**
|
||
- `directionYaw(dir)` → radians: `forward/front`=`0`, `back`=`π`, `left`=`π/2`, `right`=`−π/2`. (north/east/south/west alias n=0/e=−π/2-ish via the same table.)
|
||
- **Crate placement in the rack-level view is by `slot_number`, not pos_x.** Grid: `cell=0.34m`,
|
||
`cols=floor(rackW/0.34)`, `rows=round(rackD/0.34)`, slot is **1-indexed row-major**;
|
||
`posX=-rackW/2 + cellX/2 + col*cellX`, `posZ=-rackD/2 + cellZ/2 + row*cellZ`. Fall back to `pos_x/pos_z` only if no slot_number.
|
||
- Crate rect yaw = `directionYaw(crate.direction) + crate.rotation_y` (rotation_y≈0, so direction drives it).
|
||
- **View-rotation** (the rack-rotate button, 0/90/180/270) composes on top: `rotatePoint` = `90:(-z,x) · 180:(-x,-z) · 270:(z,-x)`, effective W/D swap at 90/270.
|
||
- **Facing arrow** = `rotatedArrows[viewRot][crate.direction]`, where `rotatedArrows[0] = {forward:↓, back:↑, left:←, right:→}` and 90/180/270 cycle it. The arrow (not the square) is what the eye reads.
|
||
- Edge labels at viewRot 0: **BACK top, FRONT bottom, L left, R right** — rotate with the view.
|
||
- **World facing (store/3D view)** = `directionYaw(rack.direction) + directionYaw(crate.direction) + crate.rotation_y`. In the drilldown you're head-on to the rack, so only `crate.direction` + view-rotation apply.
|
||
|
||
**Store view** (`renderStoreView`, `diagram.js`):
|
||
1. bounds over all racks in the active space (`pos ± half`), `createWorldToCanvas(min,max,pad)` → fit+centre.
|
||
2. each rack = 4 corners `{±halfW,±halfD}` → `rotatePoint(yaw)` → `+pos` → toCanvas → filled rotated rect + name.
|
||
3. portals = door-span line + facing triangle at `(x/roomW·W, z/roomD·H)`, `facing_deg`; orange=paired, red=unpaired.
|
||
4. compass top-right. Hit-test by polygon (`pointInPolygon`) — crates first, then racks.
|
||
|
||
**Rack view** (`renderShelfFrontView` / `renderRackDrilldown`): show **one `rack_level_index` at a time**.
|
||
Crates on that level are ordered into **slots** along the rack width (`virtual_crate.pos_x` ascending);
|
||
each slot draws the crate square with: crate id, **slot number** (sequential), **genre label**, and an
|
||
**orientation arrow** derived from the crate's `direction`/`rotation_y` (← → ↑ ↓ = which way the spine faces).
|
||
BACK/FRONT and L/R labels come from the rack's `direction`. Level prev/next steps `rack_level_index`.
|
||
|
||
**Crate view** (`renderCrateLookupDiagram`): a `record_rows × record_columns` grid (from `crate_type`);
|
||
items placed by `slot_number`; empties dimmed. Crate Contents list = `inventory WHERE crate_id=?` ordered by `slot_number`.
|
||
|
||
---
|
||
|
||
## 4. API surface (RecordGod routes ← WowPlatter ajax actions)
|
||
|
||
All admin-gated, JSON, under `/nav/*` (new `navigator_routes.py`). Read side first, then writes.
|
||
|
||
| RecordGod route | replaces ajax | returns / does |
|
||
|---|---|---|
|
||
| `GET /nav/spaces` | get spaces | the room list for the selector |
|
||
| `GET /nav/store-layout?space_id=` | `get_store_layout` | racks (+pos/rot/type/dims) + portals for the store view |
|
||
| `GET /nav/rack/{id}` | `get_rack_details` | rack + its levels + crates-per-level (+pos/slot/orientation) |
|
||
| `GET /nav/crate/{id}` | `get_crate_details` + `get_crate_contents` | crate info (name/label/rack/level/last-scanned) + items by slot |
|
||
| `GET /nav/crate/{id}/breakdown` | `get_crate_content_breakdown`/`analytics` | genre/style mix, counts |
|
||
| `GET /nav/genres` `GET /nav/styles` | `get_all_genres/styles` | tag pickers |
|
||
| `POST /nav/crate/{id}` | save crate | rename / relabel / set genres+styles |
|
||
| `POST /nav/rack/{id}` | save rack | rename / set genres+styles |
|
||
| `POST /nav/scan` | scan-to-slot | assign Release IDs/SKUs to a crate's slots sequentially (mode: replace/append) |
|
||
| `POST /nav/crate/{id}/compress` | `compress_crate_slots` | close gaps in slot numbering |
|
||
| `POST /nav/compress-all` | `compress_all_crate_slots` | compress every crate |
|
||
| `POST /nav/reorg` | `apply_crate_reorganization` | move/merge crates between racks/levels; distribute items across slots/crates |
|
||
| `GET /nav/collections` | `get_collections` | collection boxes (ties into the Discogs collection-box pricing flow) |
|
||
| `GET /nav/stock-finder?q=` | stock finder | locate any item → its crate/slot (this is today's `/search` logic, kept) |
|
||
|
||
`inventory` writes (`crate_id`/`slot_number`) reuse the non-destructive UPSERT rule — never clobber sold/hand-edited rows.
|
||
|
||
---
|
||
|
||
## 5. Front-end
|
||
|
||
New `site/navigator.html` (light theme) — **replaces `/search`**, keeps the same `nav.js` top bar.
|
||
One `<canvas>` + `state.js`-style module: `{ view, spaceId, rackId, levelIndex, crateId }`.
|
||
Port the four renderers from `main.js`/`diagram.js` (store / rack-level-slots / crate-grid / item),
|
||
the polygon hit-test, level prev/next, rack-rotate, and the side panels (Crate Info / Rack Info editors,
|
||
Release Info, Quick Insert). Workflows: scanner scan-to-slot, returns, reorganize (move/merge/distribute),
|
||
compress, stock finder. `release_id` stays the universal key.
|
||
|
||
---
|
||
|
||
## 6. Phases (ship-thin, each usable)
|
||
|
||
- **P0 — store view.** `/nav/store-layout` + the top-down room render (racks at real pos/rot, click a rack).
|
||
Upgrades today's flat `/search` map. _Zero risk, immediate value._
|
||
- **P1 — rack drill-down + levels + slots.** `/nav/rack/{id}`; render one level, prev/next, slots with
|
||
orientation arrows + genre labels. **This is the tricky-geometry phase — do it carefully.**
|
||
- **P2 — crate view + info editors.** contents by slot, Crate/Rack Info panels (name/label/genre/style/last-scanned), compress slots.
|
||
- **P3 — Scanner.** active crate → scan Release IDs/SKUs → sequential slot assign (replace/append), Test/Process/Clear.
|
||
- **P4 — Reorganize + Returns.** move/merge crates, distribute items across slots; returns back to home crate.
|
||
- **P5 — Collections + Stock Finder + polish.** collection boxes, item view, rack-rotate, the "dope" pass.
|
||
|
||
Start P0→P1: that already gives a clickable store → rack → level → crate locate, which is the daily driver.
|
||
|
||
---
|
||
|
||
## 6b. Collections + the fallback location map (captured 2026-06-22)
|
||
|
||
**Data already migrated:** `virtual_collection` (14 rows). Model = a named **zone**:
|
||
`name, color, crate_ids[] (JSON, in click order), priority, sort_method` +
|
||
**filing rules** `genre_ids[] / style_ids[] / label_ids[] / format_descriptions[] / price_min / price_max /
|
||
year_min / year_max`, `is_active`. Comment in the source: *"grouping of crates with filing rules for smart
|
||
placement."* Real examples: `2000s HOUSE` = House style + price≥$15 + year 2000–2009 → 12 crates;
|
||
`OLD SCHOOL RAP` = year 1980–1986 → 1 crate; `BREAKS 15+` = 4 breaks styles + $15+ → 10 crates.
|
||
|
||
**Collections UI** (Collections tab): list (colour swatch + crate count) → editor where you **click crates on the
|
||
map and they're kept in click order** as removable chips, set name/colour/priority, `sort_method`
|
||
(alpha_artist/title, year, price, date_added), filing rules, and a live **match panel**
|
||
(`N matching · N located · N not located · N overlap`). Save → `virtual_collection`.
|
||
|
||
**THE fallback locator (John's headline ask).** `GET /nav/locate?release_id=` (or sku):
|
||
1. exact location first — if the record has `crate_id`+`slot_number`, return that.
|
||
2. else match the record's `genre/style/label/format/price/year` against **active collections, `priority` DESC**;
|
||
first hit → return its `crate_ids` as the **"should-be-here" zone** ("House $12–15 → these 4 crates").
|
||
The match panel's four numbers fall out of this: *matching* = records a collection's rules select, *located* = of
|
||
those, how many actually sit in its crates, *not located* = match-but-misfiled, *overlap* = matched by >1 collection.
|
||
|
||
**One data dependency to decide:** collections match on discogs `genre_ids/style_ids` + `year`, which RecordGod's
|
||
slim `disc_cache` doesn't carry (only title/artist/thumb/weight). Options: **(a)** enrich `disc_cache` with
|
||
`genre/style/year` (backfill from `discogs_full` on ultra) so the matcher is self-contained — recommended; or
|
||
**(b)** denormalise genre/style/year onto `inventory` at intake. Price is already on `inventory`.
|
||
|
||
## 6c. Reorganize — the A-Z re-file planner (P4)
|
||
|
||
Server contract (thin; planning is client-side, copy WowPlatter's `class-wowplatter-ajax-store-reorg.php`):
|
||
- `POST /nav/reorg/scan {crate_ids[]}` → all in-stock items (sku, release_id, crate_id, slot, price, title, artist,
|
||
year, thumb), ordered crate→slot. (Sources can be crate ids/ranges OR a collection's crates.)
|
||
- **Client plans:** apply filters (price≤ / label / year-range) → pick a **distribution target** (start crate +
|
||
count + slots/crate) → **sort** by `sort_method` → fill target crates slot-by-slot → produce a move list
|
||
`[{sku, from_crate/slot, to_crate/slot}]` + leftovers. Card-by-card **walkthrough modal** (prev/next, MERGE, Undo).
|
||
- `POST /nav/reorg/apply {matched[], leftovers[]}` → ONE transaction: per sku `UPDATE inventory SET crate_id,
|
||
slot_number, location_updated_*`; leftovers → `crate_id/slot=NULL, location=archive`. Guard SKU-miss → roll back.
|
||
- Slot housekeeping: `compress_crate_slots` (close gaps), `insert_with_shift` (insert at slot, push others down).
|
||
|
||
Build order: **Collections + fallback locator first** (location-map foundation, data already there, every locate
|
||
benefits), **then Reorganize** (heavier planner UI). Both reuse the §4 `/nav/*` router.
|
||
|
||
## 7. Open checks before building
|
||
1. Confirm `virtual_rack_type_level` + the crate-tag table actually came across in the migration (column names).
|
||
2. Confirm the slot-ordering rule WowPlatter uses (pos_x ascending vs an explicit `slot_number` on the crate).
|
||
3. ~~Crate facing field / composition~~ **RESOLVED 2026-06-22** — full render convention captured in §3
|
||
("Render convention"): `directionYaw` table, slot→grid placement, view-rotation matrix, the `rotatedArrows`
|
||
facing table, and the world-facing composition. P1 copies it verbatim.
|