Allocation Engine
The allocation engine turns an approved demand into a concrete assignment of groups → hotels with explicit, auditable reasoning for every decision.
How allocation runs
When demand is approved, a job lands on nexa-allocation:
1. Preprocess groups
- Validate tier consistency
- Split mixed-tier groups → MANUAL_REVIEW
2. For each tier:
2a. Search providers in parallel (Amadeus, Hotelbeds, Contracts)
2b. Deduplicate across providers (3-layer strategy)
2c. Filter by policy (stars, distance, price, amenities)
2d. Score candidates (cost dominant + distance + consolidation)
2e. Assign groups respecting integrity + capacity
3. Persist an AllocationWave
- All candidates, scores, reasons
- Final assignments + total cost
- Savings vs market for direct contracts
4. Enqueue booking
Ranking inputs
The scorer is deterministic and transparent. It weights three objectives:
| Objective | Default weight | Direction |
|---|---|---|
| Cost (nightly rate × rooms × nights) | 0.60 | Lower is better |
| Distance from airport | 0.25 | Lower is better |
| Consolidation (groups placed in same hotel) | 0.15 | Higher is better |
Amenities and stars act as gates (policy constraints), not as scoring inputs. Once a hotel passes the gate it competes purely on cost-distance-consolidation.
You can tune weights per policy with allocationWeights: { cost, distance, consolidation }.
Deduplication across providers
A hotel can surface from Amadeus, Hotelbeds, and as a direct contract simultaneously. Nexa picks one offer per hotel using:
- Provider-id match: if two offers share a resolved provider-independent chain/hotel ID.
- Geo+stars match: coordinates within 100m and same star rating.
- Name+address match: exact string match as fallback.
Once deduplicated, Nexa picks the cheapest offer — with a tiebreaker that prefers direct contracts (both for rate certainty and for the inventory bump you get back on confirmation).
Group integrity
Groups never split across hotels by default. If an assignment cannot fit a group at any policy-compliant hotel, the group is held — not broken — and lands in MANUAL_REVIEW with category ALLOCATION_FAILED.
Operators can explicitly override with a split decision (see Cases).
Tier separation
Tiers are scored independently and inventory is tracked between them:
Pass 1 (First / Business) → consumes inventory at 4★+ hotels within 15 km
Pass 2 (Premium Economy) → sees post-Pass-1 availability, its own policy
Pass 3 (Economy) → sees post-Pass-2 availability, its own policy
Pass 4 (Crew) → per crew policy, usually pre-contracted
This matters because the 4★ hotel near the airport may sell out to Business before Economy even runs. It also prevents the system from sending a Business passenger to a 2★ property just because Economy claimed inventory first.
Overriding an allocation
Operators with OPS_SUPERVISOR or ADMIN can override an allocation:
curl -X POST https://us-central1.api.nexastudio.io/allocation/wave/$WAVE_URN/override \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"groupId": "GRP-4",
"hotelUrn": "urn:nexa:hotel:abc",
"reason": "passenger requested family-friendly hotel"
}'
The override:
- Is recorded in audit with the actor, reason, and before/after state.
- Triggers re-booking if a reservation already exists elsewhere.
- Does not affect other groups' allocations.
Failure modes
| Symptom | Likely cause | Next step |
|---|---|---|
ALLOCATION_FAILED immediately | No hotels in the search radius | Broaden the policy radius temporarily or unblock contract inventory |
| All candidates filtered out | Amenities too strict, stars too high | Relax policy (versioned) or escalate via the exception agent |
Wave completes but some groups HELD | Capacity constraints, mixed tier groups | Split or manually override; use POST /allocation/wave/:urn/rerun after |
| Huge delta between estimated and actual cost | Cached offers stale | Force refresh: POST /hotels/cache/invalidate?airport=XXX then rerun |
Inspecting a wave
curl https://us-central1.api.nexastudio.io/allocation/wave/$WAVE_URN \
-H "Authorization: Bearer $TOKEN" | jq '.rankingInputs, .allocations'
Every rankingInput entry captures the candidate that was evaluated, the raw scores, the final weighted score, and the reason it was selected or discarded. This is the audit trail the ops team hands to finance when explaining "why did we book this hotel and not the cheaper one?"