MM TicketOps docs
System specification · Generated from repo evidence

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.

16Primary flows
7Actor roles
19Data sheets / entities
~12kTickets per event baseline

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

RoleApp constantScope
Buying / Procurementoperator, sr_operator, ops_managerInventory purchase entry, vendor credential use
Salesoperator, sr_operator, ops_managerSale entry, credit check override (mgr)
Allocator / Ops Mgrsr_operator, ops_managerDistribution, allocation preview, policies
Adminsuper_admin, event_admin *Org settings, users, audit, reconciliation
Staff (dispatch)staffAssigned 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.

AreaChangeSection
ScopeSystem must handle Olympics-class events (~200+ sessions, 50 sports). New 3-level hierarchy: Olympics → Sub-event (sport) → Game/Session§1, §18
ScheduleBulk 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 mapsNew capability: upload stadium layouts (Block × Row × Seat), map seats to categories, visual allocation by seat proximity§1b
Sale form UXClient 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 relationshipsOne 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 portalAdvanced filters/sort: match · category · delivery status · block · row · ticket-holder fields (large clients have thousands of tickets)§7
Client portalAccept / Confirm Receipt action — per ticket or bulk — writes Delivery=ACCEPTED back to Distribution and supplier view§7
Client portalColumn 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 portalFEW-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 portalData-flow sequence clarified: Client fills → Distribution → Supplier. Supplier only ever sees allocation-driven client detail§9
Supplier portalPrivacy: Column N and Company column redacted in supplier view (observed value Company = MIRRA across every row). Vendor column also hidden§9
Data modelOlympics 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 directionDashboard is moving to an "Inventory & Distribution Command Center" UX — modern, minimal, action-first (Action Center · Match Summary Widgets · Stock Overview · Open Distribution CTA)§11
Implementationv2 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.

Actorsadminops_manager
ScreensEventsPage · EventDetailPage · EventSwitcherModal
EntityEventDefMatchDef[]SubGameDef[]SubGameCategory[]
SourceTDS §Events · AppContext INITIAL_EVENTS
1

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 codes M01M104. 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
2

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.

3

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.

4

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.

5

Open procurement: BUYING

Purchase entries allowed; sales still blocked.

6

Open sales: SELLING

Sales entries allowed. Credit checks enforced against client.creditLimit.

7

Start allocation: ALLOCATING

Distribution + Allocator available. Concurrent selling typically continues.

8

Start dispatch: DISPATCHING

Staff Queue + Supplier Portal generated. Client portals active.

9

Close event: CLOSEDARCHIVED

Final audit; outstanding dispatches flagged; retention policy kicks in at auditRetentionDays=365.

App gap: the React app has the full status enum on 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.

Actorsadmin event_admin
Event statusDRAFTPLANNING
ScreensEventDetailPage → "Import schedule" modal · MatchEditor (for manual edits)
Entity writtenMatchDef[] (per event) · SubGameDef[]
File formatAccepts .xlsx (Olympics 2024.xlsx layout) · .csv · FIFA schedule layout (Schedule WC2026 sheet, 10 populated columns)

1a.1 Import

1

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
2

System: parse & normalise

  • Match code normalisation: M1/M01/M001M01 (FIFA); preserve sport-prefixed codes verbatim for Olympics (ARC01, ATH15, BK301 — note: 3x3 basketball uses BK3NN)
  • Parse S.Desc for teams/stage — extract {gender} - {teamA} vs {teamB}, Group N, Semi-finals, Bronze, Final, Round of 16
  • Map Medal column → 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 CODE across categories into one MatchDef with N SubGameCategory[]
?

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.

3

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 formExampleResolution trigger
TBD vs TBDPre-draw friendliesManual edit when draw announced
Qualifier N vs Qualifier MQualifier 1 vs Qualifier 2Manual edit when qualifiers confirmed
Winner {match-ref}Winner QF1 vs Winner QF2, Winner Group A vs Runner-up Group BAuto-resolve when source match's result is entered
Sport-specificAFC2 vs Spain (Olympics S.Desc)Already resolved — treat as static
1

Admin enters match result

On EventDetailPage → match row → "Enter result". Captures winner + runner-up. Valid only for matches with status SCHEDULED / LIVE.

2

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.

3

Cascade

If resolving team B in M57 now enables M61 (semi-final) to resolve, repeat. Log each resolution to the audit trail.

4

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.

Sales on placeholder matches are fully supported. A client can buy "Semi-final 1" tickets 12 months ahead; the match resolves closer to the date without any change to the sale record. Only the display name updates.
FIFA 2026 stage mapping — derived from match number: 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.

Actorsadmin allocator
ScreensVenueDetailPage → Seat Map tab · DistributionPage → visual allocator overlay
Entity writtenSeatMap (new) · SeatCell[] · CategoryZone[]

1b.1 Upload & categorise

1

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 with data-block, data-row, data-seat
  • JSON spec: hierarchical blocks → rows → seats
2

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.

3

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.

4

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

1

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).

2

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.

3

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).

Not in current app. Neither 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.
Most vendor platforms (Viagogo etc.) don't expose seat coordinates directly — seat numbers come in as plain strings via the purchase intake. A coordinate-less map (block + row + seat + categoryId) still delivers 80% of the value (filter, search, "all same block").

2 Master data management

Cross-event reference data edited by admins/managers. Each entity has its own CRUD page using the reusable MasterPage component.

EntityKey fieldsRolesScreen
ClientscompanyName · code · type (CORPORATE/AGENCY/INDIVIDUAL) · creditLimit · taxId · paymentTerms · addressMASTER_ROLESClientsPage · ClientDetailPage
Vendorsname · code · type (MARKETPLACE/DIRECT/AGENCY) · website · country · primaryContact*MASTER_ROLESVendorsPage · VendorDetailPage
VendorEventBridgevendorId × eventId · platformUrl · loginEmail · credentialHint · primaryContactForEventMASTER_ROLESVendorDetailPage
ContractscontractRef · contractType (PURCHASE/SALE) · partyId · eventId · validFrom/To · maxValue · currency · status (ACTIVE/EXPIRED/TERMINATED)MASTER_ROLESContractsPage
Venuesname · city · country · capacity · address · timezone · mapUrlMASTER_ROLESVenuesPage
Currenciescode · name · symbol · exchangeRateToAed · isActiveADMIN_ROLESCurrenciesPage
Vendor credentialsvendorId · eventId (null = global) · loginId · email · passwordHash · activeFULFILMENT_ROLESVendorCredentialsPage
Notification templatescode · name · channels · subject · bodyMarkdown · variablesADMIN_ROLESNotificationTemplatesPage
Usersname · email · role · vendorGroups (for suppliers)ADMIN_ROLESUsersPage
Smart fallback: getBestCredential(vendorId, eventId) prefers an event-specific credential; falls back to a global one (eventId=null) if none exists. Keep this.
Known bug: credential history auto-logs on 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.

Actorsbuying team (operator · sr_operator · ops_manager)
Event statusBUYING+
ScreensNewPurchasePage · PurchasesPage · detail drawer
Entities writtenPurchases · _PURCHASE_UNITS · _PURCHASE_META · _PURCHASE_UNITS_AUDIT
Apps ScriptExpandPurchases.gs :: expandPurchasesToUnits()
1

Open "New Purchase"

Header: match · vendor (autofills contract) · contract · purchaseDate · currencyCode · fxRate · deliveryChannel (e-ticket / mobile transfer / physical / link / RFID) · prepaid · depositAmount · notes · attachments.

2

Add line items

Per line: subGame · category · qty · unitPrice. Multi-line per purchase supported. Bulk tab accepts CSV paste.

PurchaseLineItem
3

System: save purchase

Generate PurchaseID = P_<uuid>. Stamp Date, User, CreatedAt (immutable).

4

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..N
  • Status = AVAILABLE, AllocatedToSalesID = ""
  • Copy Match · Game · Vendor · Contract · Category from parent
  • Block/Row/Seat filled when vendor provides them (else blank, operator can fill later via allocator seat tools)
5

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.

6

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.

7

Success screen

"Purchase created · 43 units generated: P00001–P00043".

ID formats enforced: UnitID 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.

Actorssales team · ops_manager (credit override)
Event statusSELLING+
ScreensNewSalePage · SalesPage · DistributionPage
Entities writtenSales · Distribution · _IMPORT_META · _SALES_PARENT_RECON
Apps ScriptImportSales.gs :: importSalesToDistribution()
1

Open "New Sale" — header block (NEW order)

Form order (revised per screenshot #1):

  • Clienttypable 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 / EUR only (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.

2

Add line items (Match moves here)

Per line — revised order:

  • Match / Sessiontypable 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 + enter creditOverrideReason
?

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)
3

System: save sale

Generate SalesID = S<seq> (S1, S2, …), SourceSaleID = S_<uuid> (immutable dedup key), RunID (import batch). Stamp Date/User.

4

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)
5

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.

6

Success screen

"Sale S147 created · 4 distribution rows: S147-1, S147-2, S147-3, S147-4"

App gap (P0): the React app currently tracks allocation at sale-LINE level. It must materialise one DistRow per ticket at sale creation, keyed by S<id>-<n>.

5 Allocation

Match each unallocated child DistRow to a specific UnitID in _PURCHASE_UNITS based on Match + Category + SetID availability.

Actorsallocator (sr_operator · ops_manager)
Event statusALLOCATING+
ScreensDistributionPage · AllocationPreviewPage · AutoAllocatePage · AllocationPoliciesPage
Entities writtenDistribution · _PURCHASE_UNITS · _ALLOC_LOG · _ALLOCATION_PREVIEW · AllocationRun · AllocationRunItem
Apps ScriptAllocatorEngine.gs · SidebarBackend.gs · sidebar.html

5a. Preview (suggest)

1

Operator selects unallocated DistRow (parent child)

Must be row ≥ 3 (data rows), Sets ≥ 1. Click opens Allocator side-panel / AllocationPreviewPage.

2

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.

effectiveCategory = AllocFromCategory (or legacy typo alias AlloFromCategory) || Category. This enables operator-driven category upgrades.
3

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.

4

Score each plan

score = 0.35·exactness + 0.25·margin + 0.15·sla + 0.10·continuity + 0.05·freshness
    − 0.15·(upgrade?1:0) − 0.10·(multi-vendor?1:0) − 0.10·split-factor

Compute marginDelta = (saleUnitPrice − avgCost) × total.

5

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.

6

Display plans to operator

Ranked list: plan badge (exact / greedy / rotated) · chunks (SetID + qty + vendor) · score · margin% · marginDelta · policy violations (if any).

5b. Commit

7

Operator picks plan

Options: pick the top plan · pick lower-ranked (override) · "Auto allocate" (policy SEMI_AUTO/FULLY_AUTO) · cancel.

8

Acquire lock · validate

20-second script lock (prevents concurrent commits). Re-verify availability. If insufficient → WAITING_STOCK (see exceptions).

9

Write Distribution

Status = ALLOCATED VendorEmailPasswordCont. No. Tix = 1ACC Tix = effectiveCategory AllocatedUnitIDsAllocatedCount = 1 Notes += " | Allocated N from SetID" AllocNote = R<date>-<seq> RunID (lazy, shared across block)
Matthias masking: if the unit's Vendor is an email in _MATTHIAS_EMAILS (266 entries), write Vendor = "Matthias" and the real email to Email.
10

Write _PURCHASE_UNITS

Status: AVAILABLE → ALLOCATEDAllocatedToSalesID = <child SalesID>AllocatedAt = now
11

Append to _ALLOC_LOG

Row: Timestamp · User · Action=ALLOCATE · SaleID · SetID · UnitsAffected · ClearSeats=false · Reason.

12

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.

13

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.

Actorsallocator
Apps ScriptSidebarBackend.gs :: INV_*
Audit actionsUNDO_SALE · UNDO_SET · RESET_SEATS · SET_ALLOC_FROM_CATEGORY · CLEAR_ALLOC_FROM_CATEGORY
OperationEffectFunction
Unallocate entire saleAll child rows of sale: clear Vendor/Email/Password/AllocatedUnitIDs/Status. Units flipped back to AVAILABLE.INV_unallocateSelectedSale
Unallocate by SetIDOnly units from a specific SetID are released (useful if one set was mis-allocated).INV_unallocateBySetId
Reset seats by SetIDClears Block/Row/Seat but keeps allocation intact (fixing wrong seat assignment).INV_resetSeatsBySetId
Set AllocFromCategoryOverride category for future allocation (e.g. upgrade Cat 2 sale to pull from Cat 1).INV_setAllocFromCategoryForSelectedSale
Clear AllocFromCategoryRevert to base Category matching.INV_clearAllocFromCategoryForSelectedSale
App gap (P0): the React app has no "Special Handling" drawer. Add to 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.

Actorsclient (token-access) · allocator (generates portal)
ScreensClientPortalPage (/client-portal/:token)
EntitiesDistribution (source) · _CLIENT_INPUTS (vault) · _CFG (group key)
Apps ScriptClientPortal.gs (52KB — biggest single file)
1

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.

2

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):

ColHeaderSourceEditable by client
ASalesID (hidden)Distribution.ID · used for round-trip lookup
JSales ID (visible copy)Distribution.ID
KM-No.Distribution
LGameDistribution · resolves placeholders live
MCompanyDistribution · always == client's own
NINV-NODistribution
OCategoryDistribution
PTotal SETS / Purchased qtyParent sale qty (Distribution.Sets) — how many tickets the client bought
QAllc / Allocated qtyWritten 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)
RQty (per row)Distribution · always 1 for child rows
SBlockDistribution (written at allocation)
TRowDistribution
USeatDistribution
ABClients notesClient input (yellow)
ACFirst nameClient input (yellow)required
ADLast NameClient input (yellow)required
AEClient EmailClient input (yellow) — digital-ticket destinationrequired, validated
AFCommentsClient input (yellow)
AGStatus / DeliveryReads from Distribution.Delivery (NOT_SENT / PENDING / SENT / ACCEPTED / ISSUE / UPLOADED)— (but see Accept action below)
3

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").

4

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.

5

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").

6

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:

Client AG = ACCEPTED→ Distribution.Delivery = ACCEPTED→ _STAFF_QUEUE.DispatchStatus = ACCEPTED→ Supplier portal row reflects
Triggers notification dispatch.accepted → sale.assigned_operator + role:ops_manager. Closes out the dispatch obligation for that ticket.
7

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).

8

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.

9

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.

Confidentiality: client never sees other clients' rows; sheet is filtered by their company group, and the SalesID lookup is in a hidden column. Accept/Receive actions are also scoped to that client's filter.
Delivery status state machine including ACCEPTED:
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.

Actorsstaff · manager (assigns)
ScreensStaffQueuePage · StaffTaskDetailPage · staff workbook _MY_TASKS
Entities_STAFF_QUEUE (24 cols) · Distribution · VendorCredential
Apps ScriptStaffQueue.gs · StaffPortal.gs · StaffPortal_AutoPush.gs · StaffPortal_SyncBack.gs
1

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.

2

Manager assigns staff

In _STAFF_QUEUE, edit AssignedTo (email) + Priority (Normal / High / Low). Default Priority = Normal, DispatchStatus = NOT_SENT.

3

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).

4

Staff opens task

Sees: TaskID · Match · Category · Qty · Company · INV_NO · Block/Row/Seat · VendorEmail · VendorPassword · ContractNo · ClientFirstName · ClientLastName · ClientEmail · Priority.

Password-reveal audit: when staff unmasks VendorPassword, fire PASSWORD_VIEWED entry into CredentialHistoryEntry. Auto-hide after 30 seconds. (Not yet wired in React app.)
5

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.

6

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.

7

Staff marks dispatched

Edit DispatchStatus: NOT_SENT → SENT (or PENDING, ACCEPTED, ISSUE for escalation). Enter DispatchedAt (auto or manual). Add StaffNotes if needed.

8

AutoPush sync cascade

  • Staff workbook onEdit → writes to _STAFF_QUEUE (DispatchStatus · StaffNotes · DispatchedAt)
  • _STAFF_QUEUE onEdit → writes to Distribution: Delivery ← DispatchStatus, DispatchNotes ← StaffNotes, stamp DispatchedAt
  • Distribution onEdit (Delivery col) → refreshes affected client workbook's AG (Status)
9

Notification fires

ticket_dispatched → client.primary_contact (email). sale.fully_dispatched when all DistRows of a sale reach SENT.

Manual sync option: if AutoPush triggers are disabled per staff workbook, ops can run 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.

Actorssupplier (token-access)
ScreensSupplierPortalPage (/supplier-portal/:token) · SupplierDashboardPage · SupplierMatchPage
Entities_SUPPLIER_QUEUE (40 cols) · Distribution
Apps ScriptSupplierPortal.gs (36KB)

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.

1

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.

2

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.

3

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)

The user-confirmed data-flow 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)
1

Client enters guest details in portal

First name · Last Name · Client Email · Comments · Clients notes (AB:AF). Covered in §7.

2

Sync to Distribution

OnEdit writes to Distribution.First name · Last Name · Client Email · Comment · Clients notes.

3

Allocation populates supplier row

When allocator commits a plan, the affected supplier rows get the relevant client detail copied over:

First nameLast NameClient EmailINV-NO (vendor listing ref)AllocatedUnitIDs

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:

FieldSupplier seesReason
Company (col M)Literal string MIRRA on every rowHides client identity. Confirmed in screenshot #4 — every row shows "MIRRA"
Vendor column (col B/C)Hidden / blankSupplier already knows who they are; prevents leaks to other supplier groups
Password columnHiddenVendor-login password is only for Staff path
Email (vendor email col)HiddenSame 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)VisibleRequired for delivery — they need where to send the ticket
Clients notes / CommentsVisibleDelivery instructions from the client (e.g. "send after 5pm")
Price / TotalHiddenOur margin is our business
Other clients' rowsFiltered out entirelyOne supplier cannot see another supplier's or other clients' tickets
Hardcoded rule: supplier-row writes always set 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

1

Supplier sends tickets via their own platform

Using their own login. Emails ticket to Client Email.

2

Supplier marks delivery

Edit Delivery column on their sheet: NOT_SENT → SENT / PENDING / ACCEPTED / ISSUE / UPLOADED. Add DispatchNotes.

Supplier Delivery has one extra value vs Staff: UPLOADED (used when supplier has uploaded the ticket to their own platform but client hasn't confirmed yet).
3

Auto-mirror + push back to Master

Supplier workbook onEditDispatchStatus ← Delivery (auto-mirrored in same sheet). Then SUPPLIER_pushDeliveryBackToDistribution() writes via SalesLineKey lookup into Distribution.Delivery / DispatchNotes / DispatchedAt.

4

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.

App gap (P0): React SupplierPortalPage is read-mostly. Needs editable Delivery + DispatchNotes + confidentiality banner ("You are viewing tickets from your inventory. Client company identities are redacted.").

10 Notifications

Templated, trigger-based notifications across email / WhatsApp / Slack / in-app toasts.

ScreensNotificationTemplatesPage
EntitiesNotificationTemplate · NotificationTrigger · NotificationLogEntry
1

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

2

Match against active triggers

Filter NotificationTrigger[] where eventType matches and conditions pass (e.g. sale.totalValue > 100000 or portal.hoursRemaining <= 72).

3

Resolve recipients

Expression-based: role:ops_manager (all users with that role) · sale.assigned_operator (dynamic) · client.primary_contact (entity-driven).

4

Render template

Replace {{variable}} placeholders in subject + bodyMarkdown using the event payload. Variables are metadata (name → description) on each template.

App gap: React app currently stores variables as metadata but has no substitution engine. Literal {{client.name}} would render verbatim. Wire a template renderer.
5

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.

6

Log

NotificationLogEntry: templateId · triggerId · eventType · payload · recipients · channel · sentAt · status (queued/sent/failed) · error · retryCount.

Seeded templates (10) & triggers (11)

Template codeTrigger eventDefault recipient
oversell_alertoversell.raisedrole:ops_manager
portal_generatedportal.generatedclient.primary_contact
portal_reminderportal.generated (hoursRemaining ≤ 72)client.primary_contact
ticket_dispatchedsale.fully_dispatchedclient.primary_contact
dispatch_overduedispatch.ticket_unsent_T_minus_65drole:ops_manager
allocation_committedallocation.committedsale.assigned_operator
sale_createdsale.createdrole:ops_manager
credential_updatedcredential.updatedrole:ops_manager
event_transitionevent.transitionrole:ops_manager
dispatch_issuedispatch.issue_raisedrole:sr_operator

11 Reports & dashboards

Live operational and financial views. Five role-based dashboards + a central Reports page.

ScreensDashboardPage (role-routed) · ReportsPage · EventDetailPage · _REPORT_POSITION

Per-role dashboards

RoleKPIs
super_adminTotal events · active users · pending approvals · system health · event snapshot cards · action center
ops_managerEvents in selling · allocations pending · dispatch urgency · event overview · dispatch checklist
sr_operatorAllocation queue · pending approvals · supplier status
operatorMy assignments · purchase orders · allocation progress
staffMy tasks · dispatch completion rate · personal performance

Reports tabs

TabContent
Event SummaryRevenue · cost · margin% · dispatch rate · revenue-by-client pie
Inventory HealthPurchased vs available vs allocated per match/subgame (stacked bars)
Allocation ProgressUnallocated vs allocated vs dispatched, per match (time series)
Dispatch StatusSent vs pending, per client
P&LGross 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)

App gap (P1): TDS specifies a per-match table: Category × Purchased (qty, amt) × Sold (qty, amt) × Remaining × Status × Alert ("Cat 1 oversold by 1"). Build on 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.

ScreensAuditLogPage · ReconciliationPage
Entities_ALLOC_LOG · _ALLOCATION_AUDIT · _ALLOCATION_REPAIR_LOG · _PURCHASE_UNITS_AUDIT · _SALES_PARENT_RECON · _SALES_RECON_AUDIT

12a. Allocation audit (integrity)

AllocationAudit.gs :: runAllocationAudit_() runs on demand or scheduled. Checks:

SeverityCheck
CRITICALMISSING_UNIT_IN_PURCHASE_UNITS — UnitID in Distribution not found in units table
CRITICALDUPLICATE_UNIT_IN_DISTRIBUTION — same UnitID referenced by multiple Distribution rows
CRITICALSALES_ID_MISMATCH — unit's AllocatedToSalesID ≠ Distribution.ID
CRITICALORPHAN_ALLOCATED_PURCHASE_UNIT — unit marked ALLOCATED but not referenced anywhere
CRITICALPURCHASE_UNIT_POINTS_TO_MISSING_SALES_ID
HIGHCOUNT_MISMATCH — AllocatedCount ≠ number of IDs in AllocatedUnitIDs
HIGHPURCHASE_UNIT_MISSING_ALLOCATED_SALES_ID — unit referenced but has no back-pointer
MEDIUMSTATUS_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:

ActionMeaning
KEEPCounts match; no change
DELETE_CANDIDATEDistribution has extra rows vs Sales — candidate for removal
MANUAL_REVIEWAmbiguous (e.g. multiple parents with same business key)
PROTECTED_EXCESSExtra row but already allocated — cannot delete
MISSING_IN_DISTSale 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:

Columns: Timestamp · User · Action · SaleID · SetID · UnitsAffected · ClearSeats · Reason
Actions: ALLOCATE · UNDO_SALE · UNDO_SET · RESET_SEATS · SET_ALLOC_FROM_CATEGORY · CLEAR_ALLOC_FROM_CATEGORY · CANCEL_SALE (new)

13 Event closure & archive

1

Event cutover

Admin transitions status: DISPATCHING → CLOSED. New sales/allocations blocked. Outstanding dispatches surfaced with red alerts.

2

Final audit run

Automatic runAllocationAudit_ + _SALES_RECON_AUDIT. Unresolved issues block archive.

3

Resolve exceptions

Admin clears each outstanding issue (or explicitly accepts residual).

4

Financial close

Export revenue report · cost report · margin reconciliation · vendor payment reconciliation · client invoice reconciliation. (Invoicing workflow is TBD — see §14 open items.)

5

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.

1

ops_manager opens OversellResolutionDrawer

Approve override
A

Line → ALLOCATED (requires sourcing extra inventory OR bearing the shortage risk). Reason logged.

Reject
B

Line removed from sale. Client notified. Refund flow (TBD).

2

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

1

System marks row

Distribution.Status = "WAITING_STOCK". Notes appended: "No available units for SetID...". Row remains in queue.

2

Procurement team sources more

New purchase entered → units expand → back to §5 Allocation.

App gap: 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.

1

Notification fires

dispatch.issue_raised → role:sr_operator.

2

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)

Not yet designed. Required flow: admin initiates cancel → if allocated, auto-unallocate all child DistRows (units back to AVAILABLE) → append 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

On every Distribution write during allocation: if the unit's Vendor is an email in the _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_adminops_managersr_operatoroperatorstaffclientsupplier
1 Event lifecycle
2 Master data
3 Procurement
4 Sales
5 Allocation
6 Override / unallocateR
7 Client portalRRR
8 Staff dispatchRassignassign
9 Supplier dispatchRRR
10 Notifications✓configreceivereceivereceivereceivereceivereceive
11 ReportsallallRRown
12 Audit & reconR
13 Event closureR
14 Exception flowsRraisenotifiednotified

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.

SheetRowsColsPurpose
Schedule WC20261,00027Match reference: Day · Date · M# · Game · Group · Time · City+Stadiums · Country · Region · Capacity
Purchases1,63111Source purchase orders (inventory-workbook mirror)
Sales1,52014Source sales orders with SourceSaleID dedup key
_PURCHASE_UNITS12,17924Unit-level inventory. Col Y header is corrupt (value 11910.0).
Distribution12,28741Main ledger. Headers are in row 2, not row 1.
_STAFF_QUEUE14,57924Staff dispatch queue
_SUPPLIER_QUEUE2,41340Supplier self-dispatch queue (Company=MIRRA redacted)
_VENDOR_CREDENTIALS37Vendor logins. Plaintext passwords observed.
_MATTHIAS_EMAILS2661Email allowlist for Matthias vendor masking
_ALLOC_LOG228Append-only allocation audit trail
_ALLOCATION_AUDIT2410Integrity health snapshot
_ALLOCATION_REPAIR_LOG810Repair operations log
_ALLOCATION_PREVIEW316Ephemeral preview (mostly empty)
_SALES_PARENT_RECON24,36920Sales reconciliation
_SALES_RECON_AUDIT24,36922Newer recon variant (post-TDS addition)
_PURCHASE_META23,0582Unit creation audit (unitKey · createdAt)
_IMPORT_META2,6982Distribution import audit (rowKey · importedAt)
_REPORT_POSITION54623Live 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 · Capacity

Purchases (11 cols · 1,631 rows)

Date · Match · Game · Vendor · Contract · Category · Number · Price · Total · Password behind the Email TAB · PurchaseID

Password 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 · SourceSaleID

Distribution (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
ColumnMeaning
Purchases/SalesRow-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
SalesSrcRowSource row number in the Sales tab that this Distribution child row was derived from. Used by reconciliation
SalesChildChild-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 · DistRow

AllocatedIDs 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)

Header cell: 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)

Reserved sheet for repair-operation history. Same shape as _ALLOC_LOG when populated.

_ALLOCATION_PREVIEW (10 cols · ephemeral)

RunID · DistSheetRow · SalesID · Match · Category · Qty · OptionsSetIDs · ChosenSetID · ProposedUnitIDs · Warnings
ColumnMeaning
DistSheetRowPointer back to the Distribution row being previewed
OptionsSetIDsComma-separated list of candidate SetIDs w/ counts (e.g. PR0002-S01(4) · PR0002-S02(3))
ChosenSetIDOperator selection (single-set mode) — null until chosen
ProposedUnitIDsComma-separated UnitIDs the engine would commit
WarningsStatus 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
ColumnMeaning
Touched?Boolean — was this row modified during the recon run?
SalesChildChild-row sequence (1..N) within the parent sale
DistSalesImportKeyBusiness-key hash for the distribution sale (e.g. SK<hash>-<SalesID>)
DistSalesLineKeyLine-level key (<DistSalesImportKey>#<child>)
ExpectedCountFromSales / ExpectedQtyQty the Sales tab says should exist
ActualCountInDistributionQty actually present in Distribution (mismatch drives Action)
CurrentSalesRowRow number in the current Sales tab that owns this business key
CurrentSalesImportKeyBusiness-key of the current Sales row — compared vs Distribution's frozen copy to detect changes
CurrentSalesParentsWithSameBusinessKeyCount 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)

Row 1: title 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 · Medal

Column-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.

IdentifierFormatExampleSource
UnitIDP + 5-digit zero-paddedP00001 · P11456Sequential across all purchases
SetID (expanded)PR + 4-digit sourceRow + -S01PR0001-S01Derived from Purchases row #
SetSizeinteger43Parent purchase qty
SetPos1 → SetSize1, 2, …, 43Position in set
Parent SalesIDS + sequential intS147 · S148Generated in ImportSales
Child SalesIDParent + - + child#S147-1, S147-2, S147-3, S147-4Materialised in Distribution
SourceSaleIDS_ + UUID (from data-entry form) — operator-provided in Sales sheetS_91a7d4be-673f-480a-bcfc-5227e0446e7fImmutable dedup key
SalesLineKeySourceSaleID#<child#>S_91a7d4be…#1Composite unique key per ticket
PurchaseIDP_ + UUIDP_abc…From DataEntryLogic
FIFA Match codeM + 2-digitM01, M12 · M103=Bronze · M104=FinalNormalised on entry
FIFA stage (computed from M##)Derived from match-# rangeM01–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} + NNARC01, ATH15, FBL01, BKB10, BK301 (3x3 basketball), OOC01 (Opening Ceremony)From Olympics schedule sheet
Olympics Sport code (DISP)3–4 letter codeARC (Archery) · ATH (Athletics) · FBL (Football) · BKB (Basketball) · SWM (Swimming) · EQD (Equestrian Dressage) · 50 sports totalOlympics DISP column
FIFA Category (closed 5-value set)Exact strings, case-sensitiveTop Cat 1 · Cat 1 · Cat 2 · Cat 3 · Cat 4Dropdown-constrained
Olympics CategoryA / B / C / D / E / FirstFirst = premium (8 sports only) · AD = standard tiers across all 50 sports · E = ceremonies + select finals only (17 rows)Per-event scheme
Medal flag (Olympics)boolean Y / NY on 204 of 201 sessions (many have multiple category rows)Olympics Medal column
Contract No.free text, stored as @ (text format)2025-100129Text to preserve leading zeros / dashes
RunIDR + YYYYMMdd-HHmmssR20260417-143022Lazy-generated on first allocation of parent
AllocNoteR<date>-<seq> Allocated N SetIDR0260401-131186 Allocated 42 PR0014-S01Pipe-appended to Notes column
INV-NOvendor's internal listing number (free text)630679135 · 626005399-UP · 2025-3008 · 250112Carried from purchase → Distribution → StaffTask · Observed in screenshot #4
SupplierKeyUPPERCASE normalised groupPOVAMI · SALVI · MATTHIASFrom SUPPLIER_manualGroupMap_()
Placeholder team refMatch-reference stringTBD · 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 4217USD · EUR only (per latest rule — FX removed)Enum of 2

Canonical state sets

FieldValues
_PURCHASE_UNITS.StatusAVAILABLE ALLOCATED CANCELLED REPLACED SOLD
Distribution.Status (allocation)ALLOCATED WAITING_STOCK (null = unallocated)
Distribution.Delivery / supplierNOT_SENT SENT PENDING ACCEPTED ISSUE UPLOADED
_STAFF_QUEUE.DispatchStatusNOT_SENT SENT PENDING ACCEPTED ISSUE   (no UPLOADED)
_STAFF_QUEUE.PriorityNormal · High · Low
EventDef.statusDRAFT → PLANNING → BUYING → SELLING → ALLOCATING → DISPATCHING → CLOSED → ARCHIVED
SaleLineItem.statusUNALLOCATED · PENDING_APPROVAL · ALLOCATED · FULFILLED
StaffTask.statusNOT_SENT · SENT · PENDING · ACCEPTED · ISSUE
Contract.statusACTIVE · EXPIRED · TERMINATED
Vendor.typeMARKETPLACE · DIRECT · AGENCY
Client.typeCORPORATE · AGENCY · INDIVIDUAL
Contract.contractTypePURCHASE · SALE
AllocationPolicy.modeSUGGEST · SEMI_AUTO · FULLY_AUTO
AllocationPolicy.splitPolicySINGLE_SET_ONLY · ALLOW_MULTI_SET · REQUIRE_MIN_CHUNK_SIZE
Notification channelsemail · whatsapp · slack · in_app
_ALLOC_LOG.ActionALLOCATE · UNDO_SALE · UNDO_SET · RESET_SEATS · SET_ALLOC_FROM_CATEGORY · CLEAR_ALLOC_FROM_CATEGORY · CANCEL_SALE
Cross-reference: full gap analysis · field-by-field comparisons · bugs · P0/P1/P2 action list → see 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

Olympics (single event, e.g. "Paris 2024")
  → 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 rowsFirst · 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)ExampleNotes
1Session CODEMatchDef.codeARC01 · FBL58 · BK301 · OOC01Primary identifier. Duplicated across categories — group on load
2CATEGORYSubGameCategory.displayNameA · B · C · D · E · FirstClosed set of 6. Level mapping: First=1, A=2, B=3, C=4, D=5, E=6 (or match pricing)
3DISPMatchDef.sport / SubEvent.nameArchery · Athletics · Football · Basketball · Opening Ceremony · Closing CeremonyHuman-readable sport name. Also derives DISP → 3–4 letter code for the Session CODE prefix
4DateMatchDef.matchDate2024-07-25ISO date
5DayMatchDef.dayOfWeek (computed, but present for import fidelity)ThuAbbreviated weekday. Derivable from Date but kept for display
6VenueMatchDef.venueId (via fuzzy-match)Invalides · Stade de France · Marseille Marina · Château de Versailles46 unique venues. Import path creates Venues master records on the fly if fuzzy-match fails
7CapacityVenue.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
8StartMatchDef.matchTime09:30:00HH:MM:SS 24-hour
9EndSubGameDef.endTime / MatchDef.endTime12:30:00Used to compute durationMinutes
10S.Desc (Short description)MatchDef.teamsOrDescription + parsed into MatchDef.stageW - Individual ranking round · M - AFC2 vs Spain · M - Team - 1/4 Finals, Semi-finals, Bronze, FinalContains 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)
11Long DescMatchDef.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
12MedalMatchDef.isMedalSessionY / NBoolean. 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 keywordOccurrencesMaps to
Final593stage: 'FINAL'
Finals527stage: 'FINALS' (plural for sessions with multiple final rounds)
Round / Round 1 / Round of 16533stage: 'ROUND' with numeric suffix
Preliminary389stage: 'PRELIMINARY'
Semi-finals / Semi-final321 / 176stage: 'SEMI_FINAL'
Bronze249stage: 'BRONZE'
Group (M-Group, W-Group)114 / 45 ea.stage: 'GROUP' with gender flag
Qualification / Qualifications97 / 80stage: 'QUALIFICATION'
Eliminations / Elimination49 / 32stage: 'ELIMINATION'
Opening / Closing21 / nstage: 'CEREMONY'
Qualifier16stage: 'QUALIFIER' (placeholder-team source)

18d. Structural differences: Olympics vs FIFA

AspectOlympics 2024 sourceFIFA WC 2026 source
Sheet count1 flat sheet18 sheets (Schedule, Purchases, Sales, Distribution, + 14 audit/queue sheets)
Rows per session5–6 (one per category) — denormalised1 (categories modelled via separate SubGameCategory)
Sessions/matches201 unique sessions across 50 sports64 standard / 104 for WC 2026
Hierarchy depthOlympics → Sport → Session → Category (4 levels)Event → Match → (SubGame →) Category (3–4 levels)
TeamsEmbedded in S.Desc text ("M - AFC2 vs Spain") — requires parsingDedicated teamsOrDescription field
Dynamic teams (TBD / Qualifier / Winner-of-X)Rare in source — mostly resolved at publishCommon (knockout bracket references)
Category schemeFirst + AETop Cat 1 + Cat 1Cat 4
Medal flagYesMedal Y/NNot present (only Final / 3rd-place / knockout stage implied)
CeremoniesExplicit: OOC01 (Opening) · OCC01 (Closing) with their own category rowsNot modelled
CapacityPer-session override (0 possible for outdoor)Venue-level constant
Time detail44 unique start times · 88 unique end times (duration 20 min → 4+ hrs)Fixed kick-offs (~16 unique)

18e. Import pipeline (Olympics → app)

1

Admin selects event type OLYMPICS_MULTI_SPORT and uploads the schedule XLSX

2

Parser detects flat shape

Reads all 12 columns. Groups rows by Session CODE. One group = one MatchDef. Each row in group = one SubGameCategory.

3

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.

4

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).

5

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.

6

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".

App gap: neither mm-ticketops nor mm-ticketops-updated has the Olympics hierarchy or the flat-schedule importer yet. Implementation plan lives in §10 of REQUIREMENTS.md.