Every flow this project must support
A single reference that maps every workflow from event creation to archive, grounded in the 29 Apps Script sources, the 18-sheet distribution workbook (Dummy - MM_Dist_sheet.xlsx · 106,285 rows), the flat Olympics 2024 schedule (3,220 session-rows), the TDS HTML, 14 pages of PDFs, 4 walkthrough screenshots, and full audits of both the mm-ticketops and mm-ticketops-updated (v2 in progress) React apps. If a flow isn't in this document, it's out of scope.
0 Overview & legend
Every flow below follows the same template: a metadata strip (actors · modes · entities touched · source documents), a step-by-step diagram, and any exception branches. Diamonds mark decisions; red blocks mark exceptions; blue blocks mark system-automated steps.
The 7-stage backbone
Purchase logged
Buying team enters purchase in Inventory Workbook / New Purchase form.
Units serialised
System expands each purchase line into N _PURCHASE_UNITS rows with UnitIDs, SetID, SetSize, SetPos.
Sale logged
Sales team enters sale. SalesID auto-assigned, SourceSaleID (UUID) stamped as immutable dedup key.
Distribution expanded
System creates one child DistRow per ticket: S<id>-1, S<id>-2, …
Allocation
Allocator matches Match + Category across _PURCHASE_UNITS AVAILABLE, writes AllocatedUnitIDs, flips units to ALLOCATED, logs to _ALLOC_LOG.
Client portal collection
Token link to per-company workbook. Client fills AB:AF guest block. OnEdit syncs back.
Dispatch
Staff path (vendor creds → send via vendor platform) OR Supplier path (self-dispatch). Status cascades to Distribution and Client Portal live.
Actors
| Role | App constant | Scope |
|---|---|---|
| Buying / Procurement | operator, sr_operator, ops_manager | Inventory purchase entry, vendor credential use |
| Sales | operator, sr_operator, ops_manager | Sale entry, credit check override (mgr) |
| Allocator / Ops Mgr | sr_operator, ops_manager | Distribution, allocation preview, policies |
| Admin | super_admin, event_admin * | Org settings, users, audit, reconciliation |
| Staff (dispatch) | staff | Assigned staff queue only |
| Client (external) | client (token portal) | Own-company rows only. AB:AF editable |
| Supplier (external) | supplier (token portal) | Own-vendor rows. Company name redacted to MIRRA |
event_admin is referenced in App.tsx permission constants but missing from MOCK_USERS — resolve before production.0b What's new in this revision
New requirements from the latest briefing + the Olympics 2024 sample + 4 walkthrough screenshots. Everything below is additive / overrides the prior version where noted.
| Area | Change | Section |
|---|---|---|
| Scope | System must handle Olympics-class events (~200+ sessions, 50 sports). New 3-level hierarchy: Olympics → Sub-event (sport) → Game/Session | §1, §18 |
| Schedule | Bulk schedule import (schedules are purchased 1+ year ahead). Must accept placeholder teams (TBD vs TBD, Qualifier 1 vs Qualifier 2, Winner QF1 vs Winner QF2) that resolve as qualification progresses | §1a |
| Seat maps | New capability: upload stadium layouts (Block × Row × Seat), map seats to categories, visual allocation by seat proximity | §1b |
| Sale form UX | Client field typable with best-match autocomplete (not dropdown). Match field moves below Ticket Lines. Match field also typable with best-match. Currency restricted to USD & EUR only — FX field removed | §4 |
| Client relationships | One client → multiple matches in a tournament (not only multiple categories for one match). Sales form must support adding lines across different matches/sessions in one sale | §4 |
| Client portal | Advanced filters/sort: match · category · delivery status · block · row · ticket-holder fields (large clients have thousands of tickets) | §7 |
| Client portal | Accept / Confirm Receipt action — per ticket or bulk — writes Delivery=ACCEPTED back to Distribution and supplier view | §7 |
| Client portal | Column P = purchased qty, Column Q = allocated qty (populated from Distribution after allocation). Guest rows created per allocated ticket (4 tickets → 4 attendee rows) | §7 |
| Supplier portal | FEW-supplier pre-generation: generate the supplier sheet with all tickets purchased from that supplier before allocation starts. Allocated client details stream in as allocation happens | §9 |
| Supplier portal | Data-flow sequence clarified: Client fills → Distribution → Supplier. Supplier only ever sees allocation-driven client detail | §9 |
| Supplier portal | Privacy: Column N and Company column redacted in supplier view (observed value Company = MIRRA across every row). Vendor column also hidden | §9 |
| Data model | Olympics categories: A, B, C, D, E, First (premium). FIFA WC categories: Top Cat 1, Cat 1, Cat 2, Cat 3, Cat 4. Both schemes must coexist per-event | §17, §18 |
| Design direction | Dashboard is moving to an "Inventory & Distribution Command Center" UX — modern, minimal, action-first (Action Center · Match Summary Widgets · Stock Overview · Open Distribution CTA) | §11 |
| Implementation | v2 app exists: mm-ticketops-updated/ (React 19, no Tailwind, single 302 KB App.tsx, 81 KB hand-rolled CSS). Already has distributionTickets (child-row state), ACCEPTED delivery status, event_admin role populated, workflow tests | — |
1 Event lifecycle
Every flow downstream is scoped to an EventDef. Its status drives which flows are permissible.
Create event (DRAFT)
Admin opens EventsPage → "New event" modal. Enters: name, code, eventType, startDate/endDate, defaultCurrency, dispatchBufferHours, portalTokenExpiryDays, allowOversell, ownerUserId, logoUrl, bannerUrl.
eventType drives downstream structure:
SPORTS_TOURNAMENT— FIFA-style bracket, 64–104 matches. Match codesM01–M104. Stages derived from match number ranges (see §17)OLYMPICS_MULTI_SPORT— 200+ sessions across 50 sports. Session codes{SPORT}{NN}(see §18)RACING_SEASON/RACING_WEEKEND— F1-style, one weekend = multiple sub-sessions (FP1/Quali/Sprint/Race)CONCERT·CONFERENCE·EXPO·OTHER— generic 1-match-per-event
Transition to PLANNING
Admin populates schedule. For any non-trivial event the path is §1a Bulk schedule import (a year+ in advance), not one-by-one manual add. Manual add remains available for corrections and placeholder resolution.
Per match/session: code · teamsOrDescription · matchDate · matchTime · venueId · dispatchDeadline. Teams can start as placeholders (TBD / Qualifier N / Winner QF1) and resolve later. For multi-session events each match has SubGames with sessionType = MATCH / QUALIFYING / SPRINT / FP / RACE / SHOW / DAY / MEDAL / OTHER.
Configure categories per Match/SubGame
Each SubGame gets its category list. Two canonical schemes ship:
- FIFA scheme:
Top Cat 1(level 1, premium) ·Cat 1·Cat 2·Cat 3·Cat 4 - Olympics scheme:
First(premium, 8 sports only) ·A·B·C·D·E(ceremonies + select finals only) - F1 / custom: freeform per session (Paddock Club · Gold Hospitality · Grandstand A–F · General Admission, …)
Categories store level (1 = premium → 5 = cheapest) for rank-gap calculations on allocation upgrade/downgrade.
Assign venue + seat map
Select from Venues master (Lusail Stadium, Estadio Azteca, Stade de France, Marina Bay Street Circuit, …). Capacity shown for oversell context. If the venue has a seat map (see §1b), link it — enables visual allocation.
Open procurement: BUYING
Purchase entries allowed; sales still blocked.
Open sales: SELLING
Sales entries allowed. Credit checks enforced against client.creditLimit.
Start allocation: ALLOCATING
Distribution + Allocator available. Concurrent selling typically continues.
Start dispatch: DISPATCHING
Staff Queue + Supplier Portal generated. Client portals active.
Close event: CLOSED → ARCHIVED
Final audit; outstanding dispatches flagged; retention policy kicks in at auditRetentionDays=365.
EventDef but does NOT currently gate screens by status. Need mode-aware locks — e.g. hide New Sale while status < SELLING; disable Allocator while < ALLOCATING.1a Bulk schedule import & placeholder team resolution
Event schedules are typically acquired a year or more in advance. The system must accept the full schedule in one upload and tolerate placeholder teams that resolve progressively as qualification concludes.
1a.1 Import
Admin opens "Import schedule" for an event
Drag-drop or browse. Preview-first. Supported shapes:
- Olympics flat shape (1 row = 1 session × category):
Session CODE · CATEGORY · DISP (sport) · Date · Day · Venue · Capacity · Start · End · S.Desc · Long Desc · Medal - FIFA schedule shape (1 row = 1 match):
Day · Date · M# · Game · Group · Time · City+Stadiums · Country · Region · Capacity - Generic CSV: headers mapped via a column-mapper UI
System: parse & normalise
- Match code normalisation:
M1/M01/M001→M01(FIFA); preserve sport-prefixed codes verbatim for Olympics (ARC01,ATH15,BK301— note: 3x3 basketball usesBK3NN) - Parse
S.Descfor teams/stage — extract{gender} - {teamA} vs {teamB},Group N,Semi-finals,Bronze,Final,Round of 16 - Map
Medalcolumn →isMedalSession: boolean. Drive notification priority + dispatch deadline - Venue name → fuzzy-match against Venues master. If no match, prompt admin to create
- Group rows with the same
Session CODEacross categories into oneMatchDefwith NSubGameCategory[]
Preview + conflicts
Show: N new matches · M new venues needed · K placeholder teams detected · any row failing validation (missing date/venue). Admin confirms or adjusts mapping.
System: commit
Create/update MatchDef[] for the event. Each match's teamsOrDescription keeps the raw string for display; a parsed teams structure ({home, away, homeIsPlaceholder, awayIsPlaceholder}) is stored for resolution.
1a.2 Placeholder team resolution
Knockout bracket matches initially reference results of earlier matches. The system must accept them, allow sales to be placed against them, and let admins resolve them later.
| Placeholder form | Example | Resolution trigger |
|---|---|---|
TBD vs TBD | Pre-draw friendlies | Manual edit when draw announced |
Qualifier N vs Qualifier M | Qualifier 1 vs Qualifier 2 | Manual edit when qualifiers confirmed |
Winner {match-ref} | Winner QF1 vs Winner QF2, Winner Group A vs Runner-up Group B | Auto-resolve when source match's result is entered |
| Sport-specific | AFC2 vs Spain (Olympics S.Desc) | Already resolved — treat as static |
Admin enters match result
On EventDetailPage → match row → "Enter result". Captures winner + runner-up. Valid only for matches with status SCHEDULED / LIVE.
System: resolve dependents
Scan all matches whose teams.home.placeholder or teams.away.placeholder reference this match. Substitute the resolved team. Update teamsOrDescription display string.
Cascade
If resolving team B in M57 now enables M61 (semi-final) to resolve, repeat. Log each resolution to the audit trail.
Notify affected clients
Trigger match.teams_resolved notification to clients holding tickets for that match. Allows them to update guest details in their portal if needed.
M01–M48 Group Stage · M49–M56 Round of 16 · M57–M60 QF · M61–M62 SF · M63 3rd place · M64 Final. WC 2026 uniquely extends to 104 matches with expanded group format. Show the stage as a computed badge, not a stored field.1b Seat maps (stadium layout)
Upload a stadium layout (Blocks · Rows · Seats). Map seats to categories. Enables visual allocation by seat proximity — critical for "consecutive seats" requests in sales.
1b.1 Upload & categorise
Admin uploads layout
Per venue (optionally per match, since layouts change: football vs concerts in same stadium). Accepted formats:
- Structured CSV:
Block · Row · Seat · X · Y(coordinates optional but enable visual rendering) - SVG with
<rect>/<circle>elements tagged withdata-block,data-row,data-seat - JSON spec: hierarchical blocks → rows → seats
System: parse into SeatCell grid
One SeatCell per physical seat. Stores: {venueId, matchId?, block, row, seat, x, y, categoryId, status, restrictions}. Restrictions can be OBSTRUCTED_VIEW, ACCESSIBLE, COMPANION, MEDIA, HELD.
Admin paints category zones
Click-drag on rendered map to assign a block range to a category. Or bulk-import a category-zone CSV: Block-range · Row-range · CategoryID. Each seat gets a categoryId. Category coverage audit flags any uncategorised seat.
Capacity sanity-check
Sum of SeatCells per category should match the contracted inventory per category (or at least ≥ it). Admin is warned of mismatches.
1b.2 Visual allocation
Allocator opens sale in visual mode
On DistributionPage → select sale → "Visual allocate". Seat map overlay opens for the match. Shows: allocated seats (red) · available seats in this category (green) · available in other categories dimmed · restricted seats (yellow).
System suggests consecutive blocks
Given the sale qty and any "consecutive seats" special instruction, engine proposes the best contiguous run. Fallback: closest-proximity pairs. Same underlying scorer as §5 but with a distance-penalty added.
Operator selects / lassos
Rubber-band or click-to-add. Commit writes Block/Row/Seat to each child DistRow; flips those SeatCells to OCCUPIED; still runs the standard allocation path (SetID / UnitID).
mm-ticketops nor mm-ticketops-updated has seat-map UI yet. The Distribution / _STAFF_QUEUE sheets already carry Block · Row · Seat columns — data path is open. Visual overlay is pure UI work.2 Master data management
Cross-event reference data edited by admins/managers. Each entity has its own CRUD page using the reusable MasterPage component.
| Entity | Key fields | Roles | Screen |
|---|---|---|---|
| Clients | companyName · code · type (CORPORATE/AGENCY/INDIVIDUAL) · creditLimit · taxId · paymentTerms · address | MASTER_ROLES | ClientsPage · ClientDetailPage |
| Vendors | name · code · type (MARKETPLACE/DIRECT/AGENCY) · website · country · primaryContact* | MASTER_ROLES | VendorsPage · VendorDetailPage |
| VendorEventBridge | vendorId × eventId · platformUrl · loginEmail · credentialHint · primaryContactForEvent | MASTER_ROLES | VendorDetailPage |
| Contracts | contractRef · contractType (PURCHASE/SALE) · partyId · eventId · validFrom/To · maxValue · currency · status (ACTIVE/EXPIRED/TERMINATED) | MASTER_ROLES | ContractsPage |
| Venues | name · city · country · capacity · address · timezone · mapUrl | MASTER_ROLES | VenuesPage |
| Currencies | code · name · symbol · exchangeRateToAed · isActive | ADMIN_ROLES | CurrenciesPage |
| Vendor credentials | vendorId · eventId (null = global) · loginId · email · passwordHash · active | FULFILMENT_ROLES | VendorCredentialsPage |
| Notification templates | code · name · channels · subject · bodyMarkdown · variables | ADMIN_ROLES | NotificationTemplatesPage |
| Users | name · email · role · vendorGroups (for suppliers) | ADMIN_ROLES | UsersPage |
getBestCredential(vendorId, eventId) prefers an event-specific credential; falls back to a global one (eventId=null) if none exists. Keep this.CREATED only. UPDATED and DEACTIVATED mutations are silent. Fix before production.3 Procurement (purchase)
Records a purchase from a vendor and serialises it into unit-level inventory.
Open "New Purchase"
Header: match · vendor (autofills contract) · contract · purchaseDate · currencyCode · fxRate · deliveryChannel (e-ticket / mobile transfer / physical / link / RFID) · prepaid · depositAmount · notes · attachments.
Add line items
Per line: subGame · category · qty · unitPrice. Multi-line per purchase supported. Bulk tab accepts CSV paste.
System: save purchase
Generate PurchaseID = P_<uuid>. Stamp Date, User, CreatedAt (immutable).
System: expand to units
For each line with qty=N, create N rows in _PURCHASE_UNITS:
UnitID = P00001, P00002, …(sequential across all purchases)SetID = PR<4-digit sourceRow>-S01(e.g.PR0001-S01)SetSize = N,SetPos = 1..NStatus = AVAILABLE,AllocatedToSalesID = ""- Copy
Match · Game · Vendor · Contract · Categoryfrom parent - Block/Row/Seat filled when vendor provides them (else blank, operator can fill later via allocator seat tools)
System: assign vendor credentials (optional)
Operator may run "Assign Login to _PURCHASE_UNITS" (from VendorCredentialsSideBar) filtered by vendor + match + mode (BLANKS_ONLY or OVERWRITE). Writes Email / Password / LoginID onto units.
System: audit sync
On re-import: never shrink unit count below allocated count (finalTargetCount = max(qty, allocatedRows.length)). Orphan allocated units preserved forever. Audit row written to _PURCHASE_UNITS_AUDIT with issues: ALLOCATED_EXCEEDS_PURCHASES_QTY · EXTRA_AVAILABLE_ROWS_REMOVED · MISSING_ROWS_ADDED · ORPHAN_ALLOCATED_ROW_PRESERVED.
Success screen
"Purchase created · 43 units generated: P00001–P00043".
P + 5-digit zero-padded · SetID PR<4-digit source row>-S01 · PurchaseID P_<uuid>. The React app currently uses PR001-L1-S01 for SetID — needs normalisation.4 Sales (order entry)
Records a client order and expands it into one Distribution child row per ticket.
Open "New Sale" — header block (NEW order)
Form order (revised per screenshot #1):
- Client — typable with best-match autocomplete (NOT dropdown). Suggestions filtered to clients with active SALE contracts for the active event. Typing "mer" surfaces "Meridian Travel". Free-text entry also allowed → creates a new client draft
- Contract No. — auto-fills on client select; editable
- Sale Date — defaults today
- Currency — restricted to
USD/EURonly (per latest requirement). FX field removed — no AED conversion in this form - Client PO / External Ref
- Payment Status — PENDING / INVOICED / PAID
- Special Instructions (visible to allocator — "consecutive seats", "wheelchair access", etc.)
- Notes (internal)
Match field is NOT in the header. It moves into each line item (see step 2) because one client can buy tickets across multiple matches / sessions in a single sale.
Add line items (Match moves here)
Per line — revised order:
- Match / Session — typable with best-match autocomplete. Typing "M28" or "MEX" or "FBL01" surfaces matching matches. Olympics sessions resolve via
DISP(sport) +S.Desc+ Session CODE - SubGame / Session — shown only if selected match has > 1 subgame
- Category — dropdown, sourced from the selected match's category list (FIFA scheme OR Olympics scheme OR custom)
- Qty — integer
- Unit Price — in the sale's currency
Multiple lines across different matches supported. Example: Meridian Travel buys 2× Cat 2 for M06 (group stage) + 4× Top Cat 1 for M64 (Final) on one invoice.
Real-time inventory feedback per line: green enough stock · amber close to limit · red oversell.
Bulk-CSV tab accepts: match, category, qty, unit_price.
Credit check
Compute currentOutstanding + saleTotal vs client.creditLimit. If exceeded:
- Operator: blocked — requires manager override
- Manager: tick
creditOverridden+ entercreditOverrideReason
Oversell check
If any line qty > available inventory:
- Line
oversellFlag=true,status=PENDING_APPROVAL - Triggers notification
oversell.raised→ ops_manager - Line waits for resolution (see §14 Exception flows)
System: save sale
Generate SalesID = S<seq> (S1, S2, …), SourceSaleID = S_<uuid> (immutable dedup key), RunID (import batch). Stamp Date/User.
System: expand to Distribution
For each sale-line with qty=N, create N child DistRows:
ID = S<salesId>-<child#>(e.g. S147-1, S147-2, …)SalesLineKey = <SourceSaleID>#<child#>(dedup key for re-import)Sets= N on parent child only; 0 on others- Copy Date · Match · Game · Company · Category · Qty=1 · Price · Total
- Vendor/Email/Password/AllocatedUnitIDs blank (filled by allocator)
System: preserve existing manual data on re-import
If a row with same SalesLineKey already exists, its operator-edits (guest details, notes, dispatch fields) are preserved; only Sales-driven columns refresh.
Success screen
"Sale S147 created · 4 distribution rows: S147-1, S147-2, S147-3, S147-4"
S<id>-<n>.5 Allocation
Match each unallocated child DistRow to a specific UnitID in _PURCHASE_UNITS based on Match + Category + SetID availability.
5a. Preview (suggest)
Operator selects unallocated DistRow (parent child)
Must be row ≥ 3 (data rows), Sets ≥ 1. Click opens Allocator side-panel / AllocationPreviewPage.
Build candidate pool
Filter _PURCHASE_UNITS where Match = target.match AND Category = target.effectiveCategory AND Status = AVAILABLE AND AllocatedToSalesID = "". Group by SetID with available counts.
AllocFromCategory (or legacy typo alias AlloFromCategory) || Category. This enables operator-driven category upgrades.Generate candidate plans (4 strategies)
- EXACT — smallest single set with count ≥ qty (min waste)
- GREEDY_LARGEST — fill from largest sets downward (fewer chunks)
- GREEDY_SMALLEST — fill from smallest upward (tighter fit)
- ROTATED_DESC — up to 5 rotations of largest-first for vendor diversity
Dedup canonical signatures. Sort: closest-to-target → fewest chunks → largest total. Return top 6 plans.
Score each plan
− 0.15·(upgrade?1:0) − 0.10·(multi-vendor?1:0) − 0.10·split-factor
Compute marginDelta = (saleUnitPrice − avgCost) × total.
Evaluate against allocation policy
Check 11 constraints: vendor whitelist/blocklist · upgrade-allowed · upgrade rank gap · downgrade-allowed · downgrade rank gap · minMarginPct · maxAutoCommitValue (FULLY_AUTO only) · splitPolicy (SINGLE_SET_ONLY / ALLOW_MULTI_SET / REQUIRE_MIN_CHUNK_SIZE) · requireVendorDiversity · excludeVip. Plan marked passesPolicy.
Display plans to operator
Ranked list: plan badge (exact / greedy / rotated) · chunks (SetID + qty + vendor) · score · margin% · marginDelta · policy violations (if any).
5b. Commit
Operator picks plan
Options: pick the top plan · pick lower-ranked (override) · "Auto allocate" (policy SEMI_AUTO/FULLY_AUTO) · cancel.
Acquire lock · validate
20-second script lock (prevents concurrent commits). Re-verify availability. If insufficient → WAITING_STOCK (see exceptions).
Write Distribution
Vendor is an email in _MATTHIAS_EMAILS (266 entries), write Vendor = "Matthias" and the real email to Email.Write _PURCHASE_UNITS
Append to _ALLOC_LOG
Row: Timestamp · User · Action=ALLOCATE · SaleID · SetID · UnitsAffected · ClearSeats=false · Reason.
Uncheck Process_Now
Single-checkbox guard: when operator ticks a Process_Now, all others auto-uncheck (queue-of-1). Accepts column names Process_Now / Allocate_Now / Alloc_Now.
Notification fires
allocation.committed → sale.assigned_operator. Sale status may transition UNALLOCATED → PARTIAL_ALLOCATED → ALLOCATED.
6 Override / unallocate (Special Handling)
Reversible operations surfaced in the Allocator "Special Handling" sidebar. Every action requires confirm-text = UNDO + a reason string, and appends to _ALLOC_LOG.
| Operation | Effect | Function |
|---|---|---|
| Unallocate entire sale | All child rows of sale: clear Vendor/Email/Password/AllocatedUnitIDs/Status. Units flipped back to AVAILABLE. | INV_unallocateSelectedSale |
| Unallocate by SetID | Only units from a specific SetID are released (useful if one set was mis-allocated). | INV_unallocateBySetId |
| Reset seats by SetID | Clears Block/Row/Seat but keeps allocation intact (fixing wrong seat assignment). | INV_resetSeatsBySetId |
Set AllocFromCategory | Override category for future allocation (e.g. upgrade Cat 2 sale to pull from Cat 1). | INV_setAllocFromCategoryForSelectedSale |
Clear AllocFromCategory | Revert to base Category matching. | INV_clearAllocFromCategoryForSelectedSale |
DistributionPage with per-operation confirm + reason text. Wire to a proper _ALLOC_LOG append.7 Client portal collection
Per-client workbook generated from Distribution, filtered by Company. Client verifies allocated tickets, applies filters/sorts (large clients manage thousands at a time), fills guest details, and confirms receipt. All edits sync back to Master in real time.
Allocator generates portal
Distribution menu → "Create / Open client workbook". System normalises company name via clientGroupKey_() (strips VIP/Premium/Inc/Ltd suffixes, lowercases). Creates (or reuses) workbook named after group.
Filter & populate (exact column layout)
Filter Distribution rows where Company maps to this group. Write to client sheet with this exact column mapping (confirmed against screenshot #3):
| Col | Header | Source | Editable by client |
|---|---|---|---|
| A | SalesID (hidden) | Distribution.ID · used for round-trip lookup | — |
| J | Sales ID (visible copy) | Distribution.ID | — |
| K | M-No. | Distribution | — |
| L | Game | Distribution · resolves placeholders live | — |
| M | Company | Distribution · always == client's own | — |
| N | INV-NO | Distribution | — |
| O | Category | Distribution | — |
| P | Total SETS / Purchased qty | Parent sale qty (Distribution.Sets) — how many tickets the client bought | — |
| Q | Allc / Allocated qty | Written by allocator — how many have been allocated so far. E.g. sale of 10 tickets → Q=0 pre-alloc, Q=4 after partial, Q=10 fully allocated. The client uses this count to know how many guest rows to fill | ❌ (display only — write-back commented out) |
| R | Qty (per row) | Distribution · always 1 for child rows | — |
| S | Block | Distribution (written at allocation) | — |
| T | Row | Distribution | — |
| U | Seat | Distribution | — |
| AB | Clients notes | Client input (yellow) | ✅ |
| AC | First name | Client input (yellow) | ✅ required |
| AD | Last Name | Client input (yellow) | ✅ required |
| AE | Client Email | Client input (yellow) — digital-ticket destination | ✅ required, validated |
| AF | Comments | Client input (yellow) | ✅ |
| AG | Status / Delivery | Reads from Distribution.Delivery (NOT_SENT / PENDING / SENT / ACCEPTED / ISSUE / UPLOADED) | — (but see Accept action below) |
Client opens link · applies filters
Large clients manage thousands of tickets. Portal must offer server-side filter + sort on:
- Match / Session — multi-select (M01, M34, FBL01, …)
- Category — multi-select
- Delivery status — NOT_SENT · PENDING · SENT · ACCEPTED · ISSUE · UPLOADED
- Block / Row — range filter (e.g. "seats in Block C, rows 12–15")
- Ticket holder — text search over First name / Last Name / Client Email
- Allocation status — Allocated / Unallocated / Partially allocated (derived from P vs Q)
- Guest-detail completeness — Missing / Complete
Sort on any column. Filters persist in URL so the client can bookmark a view. Saved filters ("My family tickets", "Still to fill").
Client verifies allocation
Reads columns K:U to confirm category / qty / seat match their order. If allocation row is missing, client escalates via dispatch.issue_raised.
Client fills guest details (AB:AF)
One row per allocated ticket. Example: sale for 4 tickets (P=4) that's been allocated (Q=4) → client fills 4 attendee rows (e.g. a family of 4). If Q < P (partial allocation), only Q rows are editable; remaining rows greyed out until allocator finishes.
Copy-down button — copy the current row's guest block to the next N rows (common when all tickets in a set go to one booker or the same email).
Email is mandatory and RFC-validated. Missing-email rows flagged in a summary header ("12 rows still missing email").
Accept / Confirm Receipt action (NEW)
After a ticket has been dispatched (Delivery=SENT), client can acknowledge receipt:
- Per-ticket: "Accept" button on each dispatched row
- Bulk: multi-select rows → "Accept selected" (e.g. 50 tickets at once)
- Filtered-bulk: "Accept all SENT" on the current filter view
Each accept writes Delivery=ACCEPTED and stamps AcceptedAt + AcceptedBy (the client's portal token → user). Sync cascade same as dispatch:
dispatch.accepted → sale.assigned_operator + role:ops_manager. Closes out the dispatch obligation for that ticket.OnEdit syncs to _CLIENT_INPUTS vault
Hidden sheet keyed by SalesID preserves client edits across portal refreshes (so regenerating the portal doesn't blow away data).
Import-back to Master
Allocator triggers "Import client details back" → system reads AB:AF (+ acceptance status) from vault + client sheet and writes to Distribution columns First name · Last Name · Client Email · Comment · Clients notes · Delivery · AcceptedAt.
Live dispatch status
When Staff/Supplier later marks dispatch, Distribution.Delivery changes → onEdit fires on Distribution → affected client workbook's Status (AG) updates automatically. Conversely when client Accepts, cascade flows the other direction.
NOT_SENT → SENT (staff/supplier dispatches) → ACCEPTED (client confirms receipt) · OR ISSUE (escalation) · OR UPLOADED (supplier-initiated, specific vendor platforms).
8 Staff dispatch
Internal staff member logs into vendor platforms with supplied credentials and sends digital tickets to client emails.
Generate queue
For each Distribution SALES row with AllocatedUnitIDs + Client Email populated, create/update row in _STAFF_QUEUE. Inherit: VendorEmail · VendorPassword · ContractNo · Block · Row · Seat from _PURCHASE_UNITS. Inherit: ClientFirstName · ClientLastName · ClientEmail from Distribution. Preserved on rebuild: AssignedTo · Priority · DispatchStatus · DispatchedAt · StaffNotes.
Manager assigns staff
In _STAFF_QUEUE, edit AssignedTo (email) + Priority (Normal / High / Low). Default Priority = Normal, DispatchStatus = NOT_SENT.
Generate per-staff workbook
Per assigned email, create/refresh STAFF - Dispatch - <email> workbook with sheet _MY_TASKS. Filter: AssignedTo contains staff email (case-insensitive substring).
Staff opens task
Sees: TaskID · Match · Category · Qty · Company · INV_NO · Block/Row/Seat · VendorEmail · VendorPassword · ContractNo · ClientFirstName · ClientLastName · ClientEmail · Priority.
PASSWORD_VIEWED entry into CredentialHistoryEntry. Auto-hide after 30 seconds. (Not yet wired in React app.)Staff logs into vendor platform
External site (Viagogo / Ticketmaster / StubHub / etc.) — opens in browser, uses VendorEmail + Password. Searches by INV_NO to find the ticket listing.
Staff sends ticket to ClientEmail
Through the vendor's own UI — system is agnostic to which vendor. Staff captures proof (screenshot / confirmation URL) and pastes into proofUrl.
Staff marks dispatched
Edit DispatchStatus: NOT_SENT → SENT (or PENDING, ACCEPTED, ISSUE for escalation). Enter DispatchedAt (auto or manual). Add StaffNotes if needed.
AutoPush sync cascade
- Staff workbook
onEdit→ writes to_STAFF_QUEUE(DispatchStatus · StaffNotes · DispatchedAt) _STAFF_QUEUE onEdit→ writes toDistribution:Delivery ← DispatchStatus,DispatchNotes ← StaffNotes, stampDispatchedAtDistribution onEdit(Delivery col) → refreshes affected client workbook's AG (Status)
Notification fires
ticket_dispatched → client.primary_contact (email). sale.fully_dispatched when all DistRows of a sale reach SENT.
STAFFPORTAL_pullUpdatesFromAllStaff_ to batch-pull.9 Supplier self-dispatch
Alternative to Staff path — a few specific suppliers deliver tickets directly to clients. The supplier page is pre-generated from all purchases with that supplier; allocated client details stream in as allocation happens. Company identities are redacted throughout.
9a. Pre-generation (purchase-driven)
The supplier sheet is created as soon as purchases from that supplier are logged — before any sale allocation happens. It starts as a pure inventory listing so the supplier can verify what we bought from them, and then fills with client dispatch detail as allocations occur.
Detect the supplier group
Not every vendor runs self-dispatch — only a small set ("FEW") do. Detection uses SUPPLIER_manualGroupMap_() (100+ entries: POVAMI, SALVI, MATTHIAS, …) plus email-pattern matches (BDC/COMM/FED prefixes). Vendors not in this set are Staff-path only.
Seed the supplier sheet from Purchases
For every _PURCHASE_UNITS row whose vendor maps to this supplier key, write a row to the supplier workbook. Initial state: inventory-only — Match · Category · Block · Row · Seat · UnitID · Contract. No client data yet. Supplier uses this to verify purchases against their own records.
Generate supplier workbook
File named SUPPLIER - <SupplierKey> (UPPERCASE-normalised). Single sheet Supplier with 33 visible + 3 hidden columns. Hidden helpers: DistRow, SalesLineKey, SourceSaleID (used for round-trip sync, never shown to supplier).
9b. Data flow as allocations happen (the canonical sequence)
① Client fills guest details in Client portal (AB:AF) → sync to Master
② Master Distribution receives → central source of truth
③ Allocation → pushes client detail to Supplier sheet (only the allocated portions, only the fields the supplier needs to deliver)
Client enters guest details in portal
First name · Last Name · Client Email · Comments · Clients notes (AB:AF). Covered in §7.
Sync to Distribution
OnEdit writes to Distribution.First name · Last Name · Client Email · Comment · Clients notes.
Allocation populates supplier row
When allocator commits a plan, the affected supplier rows get the relevant client detail copied over:
AllocatedCount goes to Q-equivalent on supplier side (visible as the count of allocated units per set). The supplier can now see exactly which of their tickets go where.
9c. Confidentiality redaction (enforced in every row)
The supplier must NEVER see which client company the ticket is going to. Redactions enforced at write-time, not view-time:
| Field | Supplier sees | Reason |
|---|---|---|
| Company (col M) | Literal string MIRRA on every row | Hides client identity. Confirmed in screenshot #4 — every row shows "MIRRA" |
| Vendor column (col B/C) | Hidden / blank | Supplier already knows who they are; prevents leaks to other supplier groups |
| Password column | Hidden | Vendor-login password is only for Staff path |
| Email (vendor email col) | Hidden | Same reason |
| Column N (INV-NO) | Visible (their own invoice/listing number) | They need it to find the ticket in their own system |
| First name / Last Name / Client Email (AB–AE) | Visible | Required for delivery — they need where to send the ticket |
| Clients notes / Comments | Visible | Delivery instructions from the client (e.g. "send after 5pm") |
| Price / Total | Hidden | Our margin is our business |
| Other clients' rows | Filtered out entirely | One supplier cannot see another supplier's or other clients' tickets |
Company = "MIRRA" (constant SUPPLIER_PORTAL.COMPANY_REDACTED_VALUE). Value-substitution, not row-filter. Confirmed visually in screenshot #4 where every row's Company reads "MIRRA".9d. Dispatch & sync-back
Supplier sends tickets via their own platform
Using their own login. Emails ticket to Client Email.
Supplier marks delivery
Edit Delivery column on their sheet: NOT_SENT → SENT / PENDING / ACCEPTED / ISSUE / UPLOADED. Add DispatchNotes.
UPLOADED (used when supplier has uploaded the ticket to their own platform but client hasn't confirmed yet).Auto-mirror + push back to Master
Supplier workbook onEdit → DispatchStatus ← Delivery (auto-mirrored in same sheet). Then SUPPLIER_pushDeliveryBackToDistribution() writes via SalesLineKey lookup into Distribution.Delivery / DispatchNotes / DispatchedAt.
Cascade to Client portal
Distribution onEdit fires → affected client workbook refreshes Status (AG). Client can now see "SENT" live. Client's subsequent "Accept" (§7 step 6) flows back through the same pipeline and lands on both this supplier row and _STAFF_QUEUE.
10 Notifications
Templated, trigger-based notifications across email / WhatsApp / Slack / in-app toasts.
Event occurs in system
e.g. sale created · oversell raised · allocation committed · portal generated · dispatch overdue T-65d · ticket sent · credential updated · event transition · dispatch issue
Match against active triggers
Filter NotificationTrigger[] where eventType matches and conditions pass (e.g. sale.totalValue > 100000 or portal.hoursRemaining <= 72).
Resolve recipients
Expression-based: role:ops_manager (all users with that role) · sale.assigned_operator (dynamic) · client.primary_contact (entity-driven).
Render template
Replace {{variable}} placeholders in subject + bodyMarkdown using the event payload. Variables are metadata (name → description) on each template.
{{client.name}} would render verbatim. Wire a template renderer.Dispatch per channel
Email (SMTP / Resend / SendGrid) · WhatsApp (Twilio / Meta) · Slack (webhook) · in_app (toast via sonner). Retries on failure; status tracked on log entry.
Log
NotificationLogEntry: templateId · triggerId · eventType · payload · recipients · channel · sentAt · status (queued/sent/failed) · error · retryCount.
Seeded templates (10) & triggers (11)
| Template code | Trigger event | Default recipient |
|---|---|---|
| oversell_alert | oversell.raised | role:ops_manager |
| portal_generated | portal.generated | client.primary_contact |
| portal_reminder | portal.generated (hoursRemaining ≤ 72) | client.primary_contact |
| ticket_dispatched | sale.fully_dispatched | client.primary_contact |
| dispatch_overdue | dispatch.ticket_unsent_T_minus_65d | role:ops_manager |
| allocation_committed | allocation.committed | sale.assigned_operator |
| sale_created | sale.created | role:ops_manager |
| credential_updated | credential.updated | role:ops_manager |
| event_transition | event.transition | role:ops_manager |
| dispatch_issue | dispatch.issue_raised | role:sr_operator |
11 Reports & dashboards
Live operational and financial views. Five role-based dashboards + a central Reports page.
Per-role dashboards
| Role | KPIs |
|---|---|
| super_admin | Total events · active users · pending approvals · system health · event snapshot cards · action center |
| ops_manager | Events in selling · allocations pending · dispatch urgency · event overview · dispatch checklist |
| sr_operator | Allocation queue · pending approvals · supplier status |
| operator | My assignments · purchase orders · allocation progress |
| staff | My tasks · dispatch completion rate · personal performance |
Reports tabs
| Tab | Content |
|---|---|
| Event Summary | Revenue · cost · margin% · dispatch rate · revenue-by-client pie |
| Inventory Health | Purchased vs available vs allocated per match/subgame (stacked bars) |
| Allocation Progress | Unallocated vs allocated vs dispatched, per match (time series) |
| Dispatch Status | Sent vs pending, per client |
| P&L | Gross margin · sell-through by category · revenue timeline (line chart) |
Position Report (live refresh)
_REPORT_POSITION shows six KPIs refreshed on demand: Total Sold · Total Sent · Total Open · Available Inventory · Oversold Total · Surplus Total, plus Last Refresh timestamp. Observed real values: 11,799 / 8 / 11,791 / 11,174 / 4,275 / 3,658.Per-match dashboard (to build)
EventDetailPage. Source: Game Page screenshot in SCREENSHOTS - Structured Workflow.pdf p.2.12 Reconciliation & audit
Integrity checks and immutable audit logs that protect against data drift, re-import duplicates, and allocation errors.
12a. Allocation audit (integrity)
AllocationAudit.gs :: runAllocationAudit_() runs on demand or scheduled. Checks:
| Severity | Check |
|---|---|
| CRITICAL | MISSING_UNIT_IN_PURCHASE_UNITS — UnitID in Distribution not found in units table |
| CRITICAL | DUPLICATE_UNIT_IN_DISTRIBUTION — same UnitID referenced by multiple Distribution rows |
| CRITICAL | SALES_ID_MISMATCH — unit's AllocatedToSalesID ≠ Distribution.ID |
| CRITICAL | ORPHAN_ALLOCATED_PURCHASE_UNIT — unit marked ALLOCATED but not referenced anywhere |
| CRITICAL | PURCHASE_UNIT_POINTS_TO_MISSING_SALES_ID |
| HIGH | COUNT_MISMATCH — AllocatedCount ≠ number of IDs in AllocatedUnitIDs |
| HIGH | PURCHASE_UNIT_MISSING_ALLOCATED_SALES_ID — unit referenced but has no back-pointer |
| MEDIUM | STATUS_LOOKS_AVAILABLE_BUT_REFERENCED / STATUS_LOOKS_ALLOCATED_BUT_UNLINKED |
Summary row: Distribution rows count · Unique UnitIDs referenced · _PURCHASE_UNITS total · allocated count · issues found. Invariant: unique UnitIDs referenced = allocated units count.
12b. Sales reconciliation
After every sales re-import, _SALES_PARENT_RECON (and its successor _SALES_RECON_AUDIT with extra columns) compare expected sale-line counts vs actual Distribution child rows. Row-level Action:
| Action | Meaning |
|---|---|
KEEP | Counts match; no change |
DELETE_CANDIDATE | Distribution has extra rows vs Sales — candidate for removal |
MANUAL_REVIEW | Ambiguous (e.g. multiple parents with same business key) |
PROTECTED_EXCESS | Extra row but already allocated — cannot delete |
MISSING_IN_DIST | Sale expects N rows but Distribution has fewer |
12c. Immutable allocation log
_ALLOC_LOG is append-only. Every operator action that mutates allocation state must write:
Timestamp · User · Action · SaleID · SetID · UnitsAffected · ClearSeats · ReasonActions:
ALLOCATE · UNDO_SALE · UNDO_SET · RESET_SEATS · SET_ALLOC_FROM_CATEGORY · CLEAR_ALLOC_FROM_CATEGORY · CANCEL_SALE (new)13 Event closure & archive
Event cutover
Admin transitions status: DISPATCHING → CLOSED. New sales/allocations blocked. Outstanding dispatches surfaced with red alerts.
Final audit run
Automatic runAllocationAudit_ + _SALES_RECON_AUDIT. Unresolved issues block archive.
Resolve exceptions
Admin clears each outstanding issue (or explicitly accepts residual).
Financial close
Export revenue report · cost report · margin reconciliation · vendor payment reconciliation · client invoice reconciliation. (Invoicing workflow is TBD — see §14 open items.)
Archive
Status CLOSED → ARCHIVED. Portal tokens invalidated. Data retention clock starts (auditRetentionDays=365).
14 Exception flows
Every non-happy-path the system must explicitly handle.
14a. Oversell resolution
Trigger: sale line qty > available inventory
Line oversellFlag=true · status=PENDING_APPROVAL. Sale status → PARTIAL_PENDING. Match dashboard shows "Cat N oversold by M" banner.
ops_manager opens OversellResolutionDrawer
Line → ALLOCATED (requires sourcing extra inventory OR bearing the shortage risk). Reason logged.
Line removed from sale. Client notified. Refund flow (TBD).
Or: Upgrade to higher category
UpgradeModal proposes higher category with new unit price. If accepted: line AllocFromCategory set; allocator can pull from that pool. Margin delta recomputed.
14b. Insufficient stock mid-allocation (WAITING_STOCK)
Trigger: allocator finds no AVAILABLE units matching Match + Category
System marks row
Distribution.Status = "WAITING_STOCK". Notes appended: "No available units for SetID...". Row remains in queue.
Procurement team sources more
New purchase entered → units expand → back to §5 Allocation.
WAITING_STOCK is a fourth allocation state missing from the React app's Status enum.14c. Count mismatch after re-import
Covered in §12b. Each _SALES_RECON_AUDIT row has an action; admin ticks Process_Now = TRUE to re-sync.
14d. Dispatch issue escalation
Staff marks DispatchStatus = ISSUE
Can't deliver: wrong ticket, vendor platform down, client unreachable.
Notification fires
dispatch.issue_raised → role:sr_operator.
sr_operator investigates
Options: re-assign (change AssignedTo) · unallocate + re-allocate from different SetID · escalate to ops_manager · cancel line (refund flow).
14e. Sale cancellation (post-allocation)
CANCEL_SALE to _ALLOC_LOG with reason → notify client (sale.cancelled template — new) → trigger refund. Product decision needed — see §13.11 of REQUIREMENTS.md.14f. Matthias email masking
_MATTHIAS_EMAILS allowlist (266 entries), substitute Vendor = "Matthias" and keep the real email in Email. Preserve this as a global allocator-write filter.15 Actor × flow matrix
Who participates in which flow. ✓ = active participation · R = read-only/observes.
| Flow | super_admin | ops_manager | sr_operator | operator | staff | client | supplier |
|---|---|---|---|---|---|---|---|
| 1 Event lifecycle | ✓ | ✓ | — | — | — | — | — |
| 2 Master data | ✓ | ✓ | — | — | — | — | — |
| 3 Procurement | ✓ | ✓ | ✓ | ✓ | — | — | — |
| 4 Sales | ✓ | ✓ | ✓ | ✓ | — | — | — |
| 5 Allocation | ✓ | ✓ | ✓ | — | — | — | — |
| 6 Override / unallocate | ✓ | ✓ | R | — | — | — | — |
| 7 Client portal | R | R | R | — | — | ✓ | — |
| 8 Staff dispatch | R | assign | assign | — | ✓ | — | — |
| 9 Supplier dispatch | R | R | R | — | — | — | ✓ |
| 10 Notifications | ✓config | receive | receive | receive | receive | receive | receive |
| 11 Reports | all | all | R | R | own | — | — |
| 12 Audit & recon | ✓ | ✓ | R | — | — | — | — |
| 13 Event closure | ✓ | R | — | — | — | — | — |
| 14 Exception flows | ✓ | ✓ | ✓ | R | raise | notified | notified |
16 Entity & column index (from Excel workbook)
Every sheet in Dummy - MM_Dist_sheet.xlsx. 18 sheets · 106,285 rows total. Column counts are the real values from the XLSX; exact column headers captured in REQUIREMENTS.md §4 and §13.
| Sheet | Rows | Cols | Purpose |
|---|---|---|---|
Schedule WC2026 | 1,000 | 27 | Match reference: Day · Date · M# · Game · Group · Time · City+Stadiums · Country · Region · Capacity |
Purchases | 1,631 | 11 | Source purchase orders (inventory-workbook mirror) |
Sales | 1,520 | 14 | Source sales orders with SourceSaleID dedup key |
_PURCHASE_UNITS | 12,179 | 24 | Unit-level inventory. Col Y header is corrupt (value 11910.0). |
Distribution | 12,287 | 41 | Main ledger. Headers are in row 2, not row 1. |
_STAFF_QUEUE | 14,579 | 24 | Staff dispatch queue |
_SUPPLIER_QUEUE | 2,413 | 40 | Supplier self-dispatch queue (Company=MIRRA redacted) |
_VENDOR_CREDENTIALS | 3 | 7 | Vendor logins. Plaintext passwords observed. |
_MATTHIAS_EMAILS | 266 | 1 | Email allowlist for Matthias vendor masking |
_ALLOC_LOG | 22 | 8 | Append-only allocation audit trail |
_ALLOCATION_AUDIT | 24 | 10 | Integrity health snapshot |
_ALLOCATION_REPAIR_LOG | 8 | 10 | Repair operations log |
_ALLOCATION_PREVIEW | 3 | 16 | Ephemeral preview (mostly empty) |
_SALES_PARENT_RECON | 24,369 | 20 | Sales reconciliation |
_SALES_RECON_AUDIT | 24,369 | 22 | Newer recon variant (post-TDS addition) |
_PURCHASE_META | 23,058 | 2 | Unit creation audit (unitKey · createdAt) |
_IMPORT_META | 2,698 | 2 | Distribution import audit (rowKey · importedAt) |
_REPORT_POSITION | 546 | 23 | Live KPI report sheet |
16b Full column manifest (every sheet · every header)
Verbatim headers for every sheet in Dummy - MM_Dist_sheet.xlsx and Olympics 2024.xlsx. This is the authoritative list any importer / schema must match exactly.
Dummy - MM_Dist_sheet.xlsx
Schedule WC2026 (10 cols · 1,000 rows)
Day · Date · M# · Game · Group · Time · City+Stadiums · Country · Region · CapacityPurchases (11 cols · 1,631 rows)
Date · Match · Game · Vendor · Contract · Category · Number · Price · Total · Password behind the Email TAB · PurchaseIDPassword behind the Email TAB — legacy/undocumented field from an older Apps Script version. Typically null. Import path can drop it.
Sales (14 cols · 1,520 rows)
Date · Match · Game · Client · Contract · Category · Number · Price · Total · Status · RunID · SalesID · Notes · SourceSaleIDDistribution (41 cols · 12,287 rows · headers in row 2)
Device · Vendor · Email · Password · Cont. No. · Tix · ACC Tix · ID · Purchases/Sales · Date · M-No. · Game · Company · INV-NO · Category · Sets · Qty · Block · Row · Seat · Sold (Y/N) · Process_Now · Status · RunID · Notes · AllocatedUnitIDs · AllocatedCount · Clients notes · First name · Last Name · Client Email · Comment · Delivery · DispatchStatus · DispatchNotes · DispatchedAt · SalesSrcRow · SalesChild · AllocFromCategory · SourceSaleID · SalesLineKey| Column | Meaning |
|---|---|
Purchases/Sales | Row-type marker. Literal string "Sales" or "Purchases". Allocator filters sales rows only when building queues |
Sold (Y/N) | Legacy boolean — marks whether the row is sold/active. Usually False or null. Retained for sort compat |
SalesSrcRow | Source row number in the Sales tab that this Distribution child row was derived from. Used by reconciliation |
SalesChild | Child-index within parent sale (1, 2, 3, …). Pairs with SourceSaleID to form SalesLineKey |
_PURCHASE_UNITS (24 cols · 12,179 rows)
UnitID · Match · Game · Vendor · Contract · Category · SourceRow · CreatedAt · Status · AllocatedToSalesID · AllocatedAt · AllocNote · SetID · SetSize · SetPos · Password · Block · Row · Seat · PurchaseID · Email · Password · LoginID · [junk header value "11910.0"]Two duplicate Password columns (col 16 + col 22). Col 24 header is a stray numeric value 11910.0 — ignore on import.
_STAFF_QUEUE (24 cols · 14,579 rows)
TaskID · SalesID · Match · Category · Sets · Qty · Company · INV_NO · Block · Row · Seat · AllocatedIDs · VendorEmail · VendorPassword · ContractNo · ClientFirstName · ClientLastName · ClientEmail · AssignedTo · Priority · DispatchStatus · DispatchedAt · StaffNotes · DistRowAllocatedIDs is typically null on this queue; the authoritative UnitID pointer is on Distribution.AllocatedUnitIDs and joined via SalesID. Retained for future per-unit dispatch.
_SUPPLIER_QUEUE (40 cols · 2,413 rows)
SupplierKey · Device · Vendor · Email · Password · Cont. No. · Tix · ACC Tix · ID · Purchases/Sales · Date · M-No. · Game · Company · INV-NO · Category · Sets · Qty · Block · Row · Seat · Sold (Y/N) · Process_Now · Status · RunID · Notes · AllocatedUnitIDs · AllocatedCount · Clients notes · First name · Last Name · Client Email · Comment · Delivery · DispatchStatus · DispatchNotes · DispatchedAt · DistRow · SalesLineKey · SourceSaleID_VENDOR_CREDENTIALS (7 cols)
Vendor · LoginID · Email · Password · Active · Notes · UpdatedAt_MATTHIAS_EMAILS (1 col · 266 rows)
EMAILS — flat allowlist of vendor emails to anonymise under "Matthias"_ALLOC_LOG (8 cols · append-only)
Timestamp · User · Action · SaleID · SetID · UnitsAffected · ClearSeats · Reason_ALLOCATION_AUDIT (summary snapshot)
Allocation Audit · <timestamp> · then rows: Distribution rows · Unique UnitIDs referenced in Distribution · _PURCHASE_UNITS rows · Allocated _PURCHASE_UNITS rows · Issues found + issues table with columns Timestamp · Severity · Type · SalesID · Distribution Row · UnitID · _PURCHASE_UNITS Row · Match · Category · Message_ALLOCATION_REPAIR_LOG (0 cols observed — empty in sample)
_ALLOC_LOG when populated._ALLOCATION_PREVIEW (10 cols · ephemeral)
RunID · DistSheetRow · SalesID · Match · Category · Qty · OptionsSetIDs · ChosenSetID · ProposedUnitIDs · Warnings| Column | Meaning |
|---|---|
DistSheetRow | Pointer back to the Distribution row being previewed |
OptionsSetIDs | Comma-separated list of candidate SetIDs w/ counts (e.g. PR0002-S01(4) · PR0002-S02(3)) |
ChosenSetID | Operator selection (single-set mode) — null until chosen |
ProposedUnitIDs | Comma-separated UnitIDs the engine would commit |
Warnings | Status flags like WAITING_STOCK · INSUFFICIENT_STOCK · POLICY_BLOCK:<reason> |
_SALES_PARENT_RECON (20 cols · 24,369 rows)
Action · Reason · Touched? · DistRow · ID · Date · Match · Game · Company · INV-NO · Category · SalesChild · DistSalesImportKey · DistSalesLineKey · ExpectedCountFromSales · ActualCountInDistribution · Status · RunID · Notes · Process_Now_SALES_RECON_AUDIT (22 cols · 24,369 rows · newer variant)
Action · Reason · Touched? · DistRow · ID · Date · Match · Game · Company · INV-NO · Category · SalesChild · DistSalesImportKey · DistSalesLineKey · CurrentSalesRow · CurrentSalesImportKey · ExpectedQty · CurrentSalesParentsWithSameBusinessKey · Status · RunID · Notes · Process_Now| Column | Meaning |
|---|---|
Touched? | Boolean — was this row modified during the recon run? |
SalesChild | Child-row sequence (1..N) within the parent sale |
DistSalesImportKey | Business-key hash for the distribution sale (e.g. SK<hash>-<SalesID>) |
DistSalesLineKey | Line-level key (<DistSalesImportKey>#<child>) |
ExpectedCountFromSales / ExpectedQty | Qty the Sales tab says should exist |
ActualCountInDistribution | Qty actually present in Distribution (mismatch drives Action) |
CurrentSalesRow | Row number in the current Sales tab that owns this business key |
CurrentSalesImportKey | Business-key of the current Sales row — compared vs Distribution's frozen copy to detect changes |
CurrentSalesParentsWithSameBusinessKey | Count of Sales rows with the same key — > 1 triggers MANUAL_REVIEW |
_PURCHASE_META (2 cols · 23,058 rows)
unitKey · createdAt — audit pair: every unit created gets a row here (composite key Match|Game|Vendor|Contract|Category|#pos)_IMPORT_META (2 cols · 2,698 rows)
rowKey · importedAt — same shape, for Distribution-row imports_REPORT_POSITION (546 rows, dashboard sheet)
TICKETS POSITION REPORT + cell Last Refresh <timestamp>. Row 3 header: Total Sold · Total Sent · Total Open · Available Inventory · Oversold Total · Surplus Total. Row 4 values.Olympics 2024.xlsx · Sheet1 (12 cols · 3,220 rows)
Session CODE · CATEGORY · DISP · Date · Day · Venue · Capacity · Start · End · S.Desc · Long Desc · MedalColumn-by-column mapping to app entities is in §18b.
17 Identifier formats
Every identifier system with its canonical format (from AllocatorEngine.gs + ImportSales.gs + ExpandPurchases.gs). These override the TDS HTML wherever they disagree.
| Identifier | Format | Example | Source |
|---|---|---|---|
| UnitID | P + 5-digit zero-padded | P00001 · P11456 | Sequential across all purchases |
| SetID (expanded) | PR + 4-digit sourceRow + -S01 | PR0001-S01 | Derived from Purchases row # |
| SetSize | integer | 43 | Parent purchase qty |
| SetPos | 1 → SetSize | 1, 2, …, 43 | Position in set |
| Parent SalesID | S + sequential int | S147 · S148 | Generated in ImportSales |
| Child SalesID | Parent + - + child# | S147-1, S147-2, S147-3, S147-4 | Materialised in Distribution |
| SourceSaleID | S_ + UUID (from data-entry form) — operator-provided in Sales sheet | S_91a7d4be-673f-480a-bcfc-5227e0446e7f | Immutable dedup key |
| SalesLineKey | SourceSaleID#<child#> | S_91a7d4be…#1 | Composite unique key per ticket |
| PurchaseID | P_ + UUID | P_abc… | From DataEntryLogic |
| FIFA Match code | M + 2-digit | M01, M12 · M103=Bronze · M104=Final | Normalised on entry |
| FIFA stage (computed from M##) | Derived from match-# range | M01–M48 Group · M49–M56 R16 · M57–M60 QF · M61–M62 SF · M63 3rd place · M64 Final (WC 2026 uniquely extends to M104) | Computed, not stored |
| Olympics Session CODE | {SPORT-3/4} + NN | ARC01, ATH15, FBL01, BKB10, BK301 (3x3 basketball), OOC01 (Opening Ceremony) | From Olympics schedule sheet |
| Olympics Sport code (DISP) | 3–4 letter code | ARC (Archery) · ATH (Athletics) · FBL (Football) · BKB (Basketball) · SWM (Swimming) · EQD (Equestrian Dressage) · 50 sports total | Olympics DISP column |
| FIFA Category (closed 5-value set) | Exact strings, case-sensitive | Top Cat 1 · Cat 1 · Cat 2 · Cat 3 · Cat 4 | Dropdown-constrained |
| Olympics Category | A / B / C / D / E / First | First = premium (8 sports only) · A–D = standard tiers across all 50 sports · E = ceremonies + select finals only (17 rows) | Per-event scheme |
| Medal flag (Olympics) | boolean Y / N | Y on 204 of 201 sessions (many have multiple category rows) | Olympics Medal column |
| Contract No. | free text, stored as @ (text format) | 2025-100129 | Text to preserve leading zeros / dashes |
| RunID | R + YYYYMMdd-HHmmss | R20260417-143022 | Lazy-generated on first allocation of parent |
| AllocNote | R<date>-<seq> Allocated N SetID | R0260401-131186 Allocated 42 PR0014-S01 | Pipe-appended to Notes column |
| INV-NO | vendor's internal listing number (free text) | 630679135 · 626005399-UP · 2025-3008 · 250112 | Carried from purchase → Distribution → StaffTask · Observed in screenshot #4 |
| SupplierKey | UPPERCASE normalised group | POVAMI · SALVI · MATTHIAS | From SUPPLIER_manualGroupMap_() |
| Placeholder team ref | Match-reference string | TBD · Qualifier 1 · Winner QF1 · Winner Group A · Runner-up Group B · AFC2 (Asian Football Conf.) | Stored verbatim in teamsOrDescription; parsed flag on teams struct |
| Currency (restricted) | ISO 4217 | USD · EUR only (per latest rule — FX removed) | Enum of 2 |
Canonical state sets
| Field | Values |
|---|---|
_PURCHASE_UNITS.Status | AVAILABLE ALLOCATED CANCELLED REPLACED SOLD |
Distribution.Status (allocation) | ALLOCATED WAITING_STOCK (null = unallocated) |
Distribution.Delivery / supplier | NOT_SENT SENT PENDING ACCEPTED ISSUE UPLOADED |
_STAFF_QUEUE.DispatchStatus | NOT_SENT SENT PENDING ACCEPTED ISSUE (no UPLOADED) |
_STAFF_QUEUE.Priority | Normal · High · Low |
EventDef.status | DRAFT → PLANNING → BUYING → SELLING → ALLOCATING → DISPATCHING → CLOSED → ARCHIVED |
SaleLineItem.status | UNALLOCATED · PENDING_APPROVAL · ALLOCATED · FULFILLED |
StaffTask.status | NOT_SENT · SENT · PENDING · ACCEPTED · ISSUE |
Contract.status | ACTIVE · EXPIRED · TERMINATED |
Vendor.type | MARKETPLACE · DIRECT · AGENCY |
Client.type | CORPORATE · AGENCY · INDIVIDUAL |
Contract.contractType | PURCHASE · SALE |
AllocationPolicy.mode | SUGGEST · SEMI_AUTO · FULLY_AUTO |
AllocationPolicy.splitPolicy | SINGLE_SET_ONLY · ALLOW_MULTI_SET · REQUIRE_MIN_CHUNK_SIZE |
| Notification channels | email · whatsapp · slack · in_app |
_ALLOC_LOG.Action | ALLOCATE · UNDO_SALE · UNDO_SET · RESET_SEATS · SET_ALLOC_FROM_CATEGORY · CLEAR_ALLOC_FROM_CATEGORY · CANCEL_SALE |
REQUIREMENTS.md (654 lines, §§ 1–14) in the same directory.
18 Olympics data model (vs FIFA)
Olympics events are structurally richer than FIFA tournaments. Analysis below from Olympics 2024.xlsx (3,220 rows · 12 cols · 201 unique sessions · 50 sports · 46 venues · date range 2024-07-24 to 2024-08-11).
18a. Hierarchy
→ Sub-event (Sport) — 50 sports: ARC · ATH · ATM · ATW · BDM · BK · BKB · BKG · BMF · BMX · BOX · CLB · CRD · CSL · CSP · CTR · DIV · EQD · EQE · EQJ · FBL · FEN · GAR · GLF · GRY · GTR · HBL · HOC · JUD · MPN · MTB · OCC · OOC · OWS · ROW · RU · SAL · SHO · SKB · SWA · SWM · TEN · TKW · TRI · TTE · VBV · VVO · WLF · WPO · WRE
→ Game / Session — sport-prefixed code + sequence:
ARC01, ATH17, FBL58, BK301 (3×3 basketball uses BK3NN)→ Category rows —
First · A · B · C · D · E (one flat row per category per session in the source sheet)
18b. Olympics schedule sheet — every column mapped
The source sheet has one flat row per (Session × Category) combination. To load it, we group by Session CODE into MatchDef and collect the category rows into SubGameCategory[].
| Excel col # | Header (verbatim) | Maps to (app) | Example | Notes |
|---|---|---|---|---|
| 1 | Session CODE | MatchDef.code | ARC01 · FBL58 · BK301 · OOC01 | Primary identifier. Duplicated across categories — group on load |
| 2 | CATEGORY | SubGameCategory.displayName | A · B · C · D · E · First | Closed set of 6. Level mapping: First=1, A=2, B=3, C=4, D=5, E=6 (or match pricing) |
| 3 | DISP | MatchDef.sport / SubEvent.name | Archery · Athletics · Football · Basketball · Opening Ceremony · Closing Ceremony | Human-readable sport name. Also derives DISP → 3–4 letter code for the Session CODE prefix |
| 4 | Date | MatchDef.matchDate | 2024-07-25 | ISO date |
| 5 | Day | MatchDef.dayOfWeek (computed, but present for import fidelity) | Thu | Abbreviated weekday. Derivable from Date but kept for display |
| 6 | Venue | MatchDef.venueId (via fuzzy-match) | Invalides · Stade de France · Marseille Marina · Château de Versailles | 46 unique venues. Import path creates Venues master records on the fly if fuzzy-match fails |
| 7 | Capacity | Venue.capacityOverride (per-session) | 8000 · 77000 · 0 (outdoor) | Overrides base venue capacity for this session (e.g. concerts use different seating than football at same stadium). 0 = standing-room / outdoor / dynamic |
| 8 | Start | MatchDef.matchTime | 09:30:00 | HH:MM:SS 24-hour |
| 9 | End | SubGameDef.endTime / MatchDef.endTime | 12:30:00 | Used to compute durationMinutes |
| 10 | S.Desc (Short description) | MatchDef.teamsOrDescription + parsed into MatchDef.stage | W - Individual ranking round · M - AFC2 vs Spain · M - Team - 1/4 Finals, Semi-finals, Bronze, Final | Contains stage + teams in single string. Parser extracts: gender (M/W/Mixed), teams (if "X vs Y"), stage keywords (Final/Semi-finals/Bronze/Group N/Round/Qualifications/Preliminary) |
| 11 | Long Desc | MatchDef.longDescription | "Scheduled events (subject to change): Women's Team 1/8 Eliminations [Type of admission: Reserved seating for all categories]" | Free text. Contains admission info, category notes, event list. Stored verbatim |
| 12 | Medal | MatchDef.isMedalSession | Y / N | Boolean. Drives dispatch priority (medal sessions get earlier deadlines) and notification urgency |
18c. Stage keywords (S.Desc parsing)
The parser must recognise these to populate MatchDef.stage for search/filter. Frequency counts from 3,220-row sample:
| Stage keyword | Occurrences | Maps to |
|---|---|---|
Final | 593 | stage: 'FINAL' |
Finals | 527 | stage: 'FINALS' (plural for sessions with multiple final rounds) |
Round / Round 1 / Round of 16 | 533 | stage: 'ROUND' with numeric suffix |
Preliminary | 389 | stage: 'PRELIMINARY' |
Semi-finals / Semi-final | 321 / 176 | stage: 'SEMI_FINAL' |
Bronze | 249 | stage: 'BRONZE' |
Group (M-Group, W-Group) | 114 / 45 ea. | stage: 'GROUP' with gender flag |
Qualification / Qualifications | 97 / 80 | stage: 'QUALIFICATION' |
Eliminations / Elimination | 49 / 32 | stage: 'ELIMINATION' |
Opening / Closing | 21 / n | stage: 'CEREMONY' |
Qualifier | 16 | stage: 'QUALIFIER' (placeholder-team source) |
18d. Structural differences: Olympics vs FIFA
| Aspect | Olympics 2024 source | FIFA WC 2026 source |
|---|---|---|
| Sheet count | 1 flat sheet | 18 sheets (Schedule, Purchases, Sales, Distribution, + 14 audit/queue sheets) |
| Rows per session | 5–6 (one per category) — denormalised | 1 (categories modelled via separate SubGameCategory) |
| Sessions/matches | 201 unique sessions across 50 sports | 64 standard / 104 for WC 2026 |
| Hierarchy depth | Olympics → Sport → Session → Category (4 levels) | Event → Match → (SubGame →) Category (3–4 levels) |
| Teams | Embedded in S.Desc text ("M - AFC2 vs Spain") — requires parsing | Dedicated teamsOrDescription field |
| Dynamic teams (TBD / Qualifier / Winner-of-X) | Rare in source — mostly resolved at publish | Common (knockout bracket references) |
| Category scheme | First + A–E | Top Cat 1 + Cat 1–Cat 4 |
| Medal flag | Yes — Medal Y/N | Not present (only Final / 3rd-place / knockout stage implied) |
| Ceremonies | Explicit: OOC01 (Opening) · OCC01 (Closing) with their own category rows | Not modelled |
| Capacity | Per-session override (0 possible for outdoor) | Venue-level constant |
| Time detail | 44 unique start times · 88 unique end times (duration 20 min → 4+ hrs) | Fixed kick-offs (~16 unique) |
18e. Import pipeline (Olympics → app)
Admin selects event type OLYMPICS_MULTI_SPORT and uploads the schedule XLSX
Parser detects flat shape
Reads all 12 columns. Groups rows by Session CODE. One group = one MatchDef. Each row in group = one SubGameCategory.
Derive Sport (Sub-event)
Extract 3-letter prefix from Session CODE (handle BK3 special case). Cross-check against DISP column. Create/find SubEvent (sport) record.
Parse S.Desc
Apply stage keyword matcher. Extract gender. Extract team-vs-team pattern. Flag placeholders (AFC2 → "Asian qualifier 2" — unresolved; will resolve when qualification completes).
Fuzzy-match venues
Match venue name against Venues master. Create new Venue records for unknowns; admin prompted to confirm in a post-import review modal.
Commit
N matches created. Admin sees a summary: "Paris 2024 imported · 50 sports · 201 sessions · 204 medal sessions · 46 venues · 6 categories in play · 12 placeholder teams for post-qualification resolution".
mm-ticketops nor mm-ticketops-updated has the Olympics hierarchy or the flat-schedule importer yet. Implementation plan lives in §10 of REQUIREMENTS.md.