Suppliers Service
Manages land service providers (DMCs), their hotel inventory, and tour packages with independent per-hotel pricing.
When to Use
Section titled “When to Use”- Managing external companies that provide ground services (hotels, tours, transfers)
- Creating tour packages (the primary entry point for product creation)
- Publishing tours to markets with AI translation
- Setting up per-hotel pricing with rate periods and room types
- Setting up pricing periods with weekday restrictions and blackout dates
- Configuring multi-tenant access for supplier managers
Data Architecture
Section titled “Data Architecture”Supplier (land service provider, has source_locale for content language)├── SupplierHotel[] (hotel inventory)│ ├── SupplierHotelTranslation[] (per-locale content)│ └── SupplierService (1:1 - per-hotel pricing)│ ├── SupplierServiceTranslation[] (per-locale content)│ └── SupplierServiceRate[] (rate periods)│ └── SupplierServiceRatePrice[] (room type prices)├── SupplierActivity[] (excursions, day tours)│ ├── SupplierActivityTranslation[] (per-locale content)│ └── SupplierService (1:1 - per-activity pricing)│ └── SupplierServiceRate[] → SupplierServiceRatePrice[]├── SupplierTransfer[] (airport/city transfers)│ ├── SupplierTransferTranslation[] (per-locale content)│ └── SupplierService (1:1 - per-transfer pricing)│ └── SupplierServiceRate[] → SupplierServiceRatePrice[]├── SupplierContract[] (formal agreements)│ └── SupplierContractService[] (line items with price snapshots)│ ├── → SupplierService + SupplierServiceRate (service-based)│ └── → SupplierTourRate (tour-rate-based)└── SupplierTour[] (tour packages — M:N with suppliers via pivot) ├── supplier_supplier_tour (pivot: supplier_id, supplier_tour_id) ├── ProductTemplate (1:1 - defines itinerary structure) ├── SupplierTourItinerary[] (assignments per day) │ ├── selection_hotel_id, luxury_hotel_id, grand_luxury_hotel_id │ ├── supplier_tour_itinerary_activities (pivot) │ │ ├── type='included' → base tier activities (in package price) │ │ ├── type='extra' → optional add-on activities │ │ └── type='substitution' → upgrade replacement activities │ └── supplier_tour_itinerary_transfers (pivot) │ ├── type='selection' → base tier transfers │ ├── type='luxury' → luxury upgrade transfers │ └── type='grand_luxury' → premium upgrade transfers ├── package_service_supplier_tour (pivot: supplier_service_id, supplier_tour_id, alternative_group) └── SupplierTourRate[] (pricing periods - legacy)Key Concepts
Section titled “Key Concepts”Service Tiers (Hotels & Transfers)
Section titled “Service Tiers (Hotels & Transfers)”Hotels and transfers use a 3-tier luxury categorization system (ServiceTier):
| Tier | Enum Value | Label | Description |
|---|---|---|---|
| Selection | selection |
5 Star Selection | Base tier, included in package price |
| Luxury | luxury |
5 Star Exclusive | Upgrade tier, shown as optional upgrades |
| Grand Luxury | grand_luxury |
5 Star Grand Luxury | Premium upgrade tier |
Source: backend/app/Enums/ServiceTier.php
Activity Tiers
Section titled “Activity Tiers”Activities use a dedicated ActivityTier enum, separate from the hotel/transfer tiers:
| Tier | Enum Value | Label | Description |
|---|---|---|---|
| Included | included |
Included | Base activities included in package price |
| Extra | extra |
Extra | Optional add-on activities (shown as “Añadir experiencia”) |
| Substitution | substitution |
Substitution | Upgrade replacements for included activities (shown as “Mejorar experiencia” with gem badge) |
Source: backend/app/Enums/ActivityTier.php
Tier Relationships
Section titled “Tier Relationships”Each tour itinerary day can have hotels and transfers at each service tier, and activities at each activity tier:
// Hotel relationships (ServiceTier)$itinerary->selectionHotel; // Base tier hotel$itinerary->luxuryHotel; // Upgrade tier hotel$itinerary->grandLuxuryHotel; // Premium tier hotel
// Activity relationships (ActivityTier, many-to-many)$itinerary->includedActivities; // Base activities in package price$itinerary->extraActivities; // Optional add-on activities$itinerary->substitutionActivities; // Upgrade replacement activities
// Transfer relationships (ServiceTier, many-to-many)$itinerary->selectionTransfers; // Base tier transfers$itinerary->luxuryTransfers; // Upgrade tier transfers$itinerary->grandLuxuryTransfers; // Premium tier transfersCheckout Flow
Section titled “Checkout Flow”In checkout, Selection tier hotels/transfers and Included tier activities are included in the base price. Luxury and Grand Luxury hotels and Extra/Substitution activities appear as optional upgrades with prices calculated server-side. Extra and Substitution activities with a minimum_pax value are filtered out when the offer’s pax count is below the threshold; Included activities are never filtered. The frontend hotel selector uses a tabbed 3-tier UI where users can browse Selection (included), Luxury, and Grand Luxury hotels per time period, with mutual exclusivity per period (only one upgrade at a time).
Source: backend/app/Services/Checkout/CheckoutHotelService.php, backend/app/Services/Checkout/CheckoutActivityService.php, backend/app/Services/Checkout/CheckoutTransferService.php
SupplierService Architecture
Section titled “SupplierService Architecture”SupplierService provides independent, per-hotel pricing separate from the legacy tour-based rates. Each hotel can have its own service with flexible pricing models.
Source: backend/app/Models/SupplierService.php
Supplement Pricing Mode
Section titled “Supplement Pricing Mode”Hotel services have an is_supplement_pricing flag (default false). When enabled, stored prices are treated as per-person/night supplements instead of full room rates. The calculator multiplies the total by pax count derived from the room type.
| Mode | is_supplement_pricing |
Calculation | Example (2A, 150€/night, 3 nights) |
|---|---|---|---|
| Room | false |
rate × nights |
150 × 3 = 450€ |
| Supplement | true |
rate × nights × pax |
150 × 3 × 2 = 900€ |
When to use: Package tours where the base hotel is bundled in a closed price and extra hotel nights are priced as per-person supplements.
Pax count is parsed from room type via SupplierServiceRatePrice::getPaxCountFromRoomType() (e.g., 2A = 2, 2A+1CH = 3, 2A+1B = 3).
Admin toggle: Visible only for Hotel-type services in the service form.
Source: backend/app/Services/Checkout/HotelPriceCalculatorService.php, backend/app/Filament/Resources/Suppliers/SupplierServices/Schemas/SupplierServiceForm.php
Service Types
Section titled “Service Types”| Type | Description | Use Case | FK Link |
|---|---|---|---|
| Hotel | Accommodation pricing | Per-hotel rates | supplier_hotel_id |
| Activity | Day tours, excursions | Per-person pricing | supplier_activity_id |
| Transfer | Airport/city transfers | Per-trip pricing | supplier_transfer_id |
Source: backend/app/Enums/SupplierServiceType.php
SupplierActivity
Section titled “SupplierActivity”Activities represent excursions, tours, and experiences that can be assigned to tour itineraries.
Source: backend/app/Models/SupplierActivity.php
Activity Fields
Section titled “Activity Fields”| Field | Description |
|---|---|
name |
Activity name (e.g., “Great Wall Day Tour”) |
city |
Location in CitySelect format |
start_time |
Optional start time (HH:MM). Authoritative source for time_slot. |
duration_hours |
Optional duration in hours (decimal). Used to detect full-day activities. |
time_slot |
When activity occurs (morning, afternoon, full_day, lunch, dinner). Auto-inferred from start_time/duration_hours on save — see below. |
description |
Detailed description |
images |
Photo gallery |
minimum_pax |
Optional minimum passenger count. When set, Extra and Substitution activities are hidden in checkout if the offer’s pax count is below this value. Included activities are exempt. |
Time Slots
Section titled “Time Slots”Activities can specify when they occur during the day:
| Slot | Description |
|---|---|
morning |
Morning activity |
afternoon |
Afternoon activity |
full_day |
All-day activity |
lunch |
Lunch experience |
dinner |
Dinner experience |
Source: backend/app/Enums/ActivityTimeSlot.php
Time Slot Inference
Section titled “Time Slot Inference”time_slot is a derived field. Whenever an activity is saved with a start_time set
(admin form, spreadsheet import, or any write), the model re-infers time_slot from the
schedule — start_time is authoritative. The rule (ActivityTimeSlot::inferFromSchedule()):
- Starts before
13:00and ends at/after16:00→full_day - Starts before
13:00→morning - Otherwise →
afternoon
lunch and dinner are never inferred; they remain manual-only. When start_time is empty,
any manually-selected time_slot is left untouched. The Filament form previews this rule live,
and backfill migrations applied it to existing rows.
Source: backend/app/Models/SupplierActivity.php (saving hook), backend/app/Enums/ActivityTimeSlot.php
Edit Lock
Section titled “Edit Lock”Activities used in supplier tours cannot be edited. The edit form is disabled and the save button hidden. Check $activity->isUsedInTours() to determine if locked.
Activity → Service Link
Section titled “Activity → Service Link”Each activity has ONE linked SupplierService for pricing:
// Activity has one service$activity->service; // SupplierService with pricing
// Service belongs to one activity$service->activity; // SupplierActivityWhen creating a service for an activity, the pricing model auto-switches to PerPerson.
SupplierTransfer
Section titled “SupplierTransfer”Transfers represent transportation services (airport pickups, city transfers) that can be assigned to tour itineraries.
Source: backend/app/Models/SupplierTransfer.php
Transfer Fields
Section titled “Transfer Fields”| Field | Description |
|---|---|
name |
Transfer name (e.g., “Airport to Hotel Transfer”) |
city |
Location in CitySelect format |
vehicle_type |
Vehicle description (e.g., “Private Car”, “Minivan”) |
duration_minutes |
Estimated duration |
description |
Detailed description |
images |
Photo gallery |
Transfer → Service Link
Section titled “Transfer → Service Link”Each transfer has ONE linked SupplierService for pricing:
// Transfer has one service$transfer->service; // SupplierService with pricing
// Service belongs to one transfer$service->transfer; // SupplierTransferActivity and Transfer Assignment Types
Section titled “Activity and Transfer Assignment Types”Transfers use ServiceTier (see Service Tiers). Activities use ActivityTier (see Activity Tiers):
Transfer tiers:
| Tier | Pivot Type | Description | In Offer Price? |
|---|---|---|---|
| Selection | selection |
Base package transfers | Yes |
| Luxury | luxury |
Upgrade transfers | No |
| Grand Luxury | grand_luxury |
Premium transfers | No |
Activity tiers:
| Tier | Pivot Type | Description | In Offer Price? |
|---|---|---|---|
| Included | included |
Base package activities | Yes |
| Extra | extra |
Optional add-on activities | No |
| Substitution | substitution |
Upgrade replacement activities | No |
// Assign activity to tour itinerary (Included tier)$itinerary->includedActivities()->attach($activity->id, [ 'sort_order' => 0, 'type' => 'included']);
// Assign transfer to tour itinerary (Luxury tier)$itinerary->luxuryTransfers()->attach($transfer->id, [ 'sort_order' => 0, 'type' => 'luxury']);The same activity/transfer CAN be assigned to multiple tiers on the same day (unique constraint is on itinerary_id, item_id, type).
Source: backend/app/Models/SupplierTourItinerary.php
Supplier Entity Translations
Section titled “Supplier Entity Translations”Supplier entities support per-locale translations so content displays in the market’s language during checkout and on the product detail page. Each translatable entity has a dedicated translation table and model.
All four entities implement HasSupplierTranslations and use the HasTranslations trait, which provides:
getTranslation(string $locale): ?Model– find translation record for a localetranslated(string $field, ?string $locale): mixed– return translated value with fallback to the source attribute when locale is null or no translation exists
Contract: backend/app/Contracts/HasSupplierTranslations.php
Trait: backend/app/Traits/HasTranslations.php
Translatable fields per entity:
| Entity | Translated Fields | Not Translated |
|---|---|---|
| SupplierActivity | name, description, inclusions, warnings, notes, reasons (JSON), amenities (JSON) | city, time_slot, images |
| SupplierHotel | description, reasons (JSON), amenities (JSON) | hotel_name, address (proper nouns) |
| SupplierTransfer | name, description, vehicle_type | city, duration_minutes, images |
| SupplierService | name, description | pricing fields |
Translation tables: supplier_activity_translations, supplier_hotel_translations, supplier_transfer_translations, supplier_service_translations. Each has a FK with cascade delete, a locale column (string 10), and a unique constraint on (entity_FK, locale).
Locale resolution: The ResolveMarket middleware sets locale from the URL path (/api/{market}/{lang}/...) into request()->attributes->get('locale'). Checkout services accept ?string $locale = null and call $entity->translated('field', $locale). When locale is null (e.g., admin context), the source attribute is returned directly.
N+1 prevention: All checkout and product API queries eager-load .translations on supplier entities (e.g., selectionHotel.translations, extraActivities.translations). The trait reads from the already-loaded collection.
Admin UI: Each entity’s Filament edit page includes a “Manage Translations” header action (via HandlesSupplierTranslation trait). Modal with locale picker (from active Markets’ supported_locales), source content reference, and updateOrCreate save. Amenity icons are disabled (preserved from source) – only label and description are editable.
AI auto-translation: The locale picker exposes an “Auto-translate with AI” hint action. When clicked, SupplierTranslationService sends the record’s translatable source fields to SupplierTranslationAgent (Laravel AI structured output, OpenRouter). The agent uses a dynamic schema built from translatableFields() so only the entity’s actual fields are requested and all are required() — this prevents the provider from skipping fields. Amenity icons are stripped before the AI call and re-merged from the source by index afterwards (with a Log::warning when the returned item count diverges). The result pre-populates the modal form so the admin can review and edit before saving.
Source: backend/app/Filament/Concerns/HandlesSupplierTranslation.php, backend/app/Services/SupplierTranslationService.php, backend/app/Ai/Agents/SupplierTranslationAgent.php
Used by: EditSupplierActivity, EditSupplierHotel, EditSupplierTransfer, EditSupplierService
Pricing Models
Section titled “Pricing Models”| Model | Calculation | Example |
|---|---|---|
| PerNight | price × nights |
Hotel accommodation |
| Package | Fixed price | All-inclusive tour |
| PerPerson | price × travelers |
Group activities |
| PerTrip | Fixed price | Airport transfer |
Source: backend/app/Enums/ServicePricingModel.php
Rate Periods (SupplierServiceRate)
Section titled “Rate Periods (SupplierServiceRate)”Each service has rate periods defining when prices apply:
- Date range:
start_datetoend_date - Weekdays: Array of allowed days (e.g.,
["mon", "thu", "sat"]) - Blackout Dates: Date ranges when service is unavailable
- Allotment: Available inventory slots.
AllotmentConsumptionrecords track consumed slots per(rate, booking, service_date)— one row per calendar day the service is delivered. Hotels emit one row per stay night, activities and transfers one row on their itinerary day (departure_date + (day - 1)), and the package service one row ondeparture_date. Multi-night stays that spill across a rate-period boundary use the rate coveringdeparture_datefor every night (matches the one-rate-per-offer pricing assumption).AllotmentServiceenforces availability at two points: (1) pre-configurator batch check to hide sold-out offers from the PDP, and (2) at payment time with pessimistic locking to prevent concurrent overbooking. When a(rate, service_date)hits zero during consumption,AllotmentExhaustedNotificationmails the current Volāre entity (one aggregated email per booking, dispatched viaDB::afterCommit).
Methods:
isDateAvailable($date)- Checks period, weekday, and blackout datesisDateExcluded($date)- Checks blackout ranges onlygetPriceForRoomType($roomType)- Returns price record
Source: backend/app/Models/SupplierServiceRate.php, backend/app/Services/Allotment/AllotmentService.php
Blackouts are scoped to their own rate period
Section titled “Blackouts are scoped to their own rate period”A rate’s excluded_date_ranges close dates inside that rate’s own season. SupplierTourBlackoutResolver clips every range to the carrying rate’s start_date–end_date: a range overlapping the season partially is kept for the overlap, and one falling entirely outside it closes nothing. SupplierServiceRate::isDateAvailable() has always gated on the rate period first, so this aligns the search-window, calendar and diagnostics layers with what pricing actually does. Reading ranges rate-blind is what closed Konnichiwa’s whole Christmas 2026 calendar from a closure filed on a March–April 2027 rate.
The resolver is the single source for every consumer — the flight-search date pickers, the departure-rule expander, the cache-window filter, the offer generator’s skip reason and the diagnostics panel. Two further alignments with pricing: it reads only active package services (a retired package’s closures no longer remove sellable dates), and it includes the tour’s own SupplierTourRate::excluded_date_ranges (travel-window closures), which isDateAvailable() already refuses.
Two query shapes exist, deliberately: hits($departure, $blackouts) tests the departure day and departure+1, covering overnight international flights (MAD 23:45 → LIM 04:25+1); describeHit($tour, $date) tests only the day given, because its caller already knows which date failed and wants the cause for that date.
Because an out-of-season closure is now silently ignored instead of harmlessly over-blocking, the admin forms bound the entry: BlackoutDateBounds sets minDate/maxDate on the Blackout Dates pickers in SupplierServiceForm, SupplierTourRateForm and the tour wizard in SupplierTourForm to the carrying rate’s own season. Filament turns those into after_or_equal/before_or_equal rules, so the bound holds on save, not just in the picker. Two deliberate behaviours: a row still holding a range the rate already has persisted keeps that range saveable (45 pre-existing out-of-season blackouts, plus seasons narrowed after the fact, would otherwise block every unrelated edit) — widened per row, never for the whole repeater; and when the rate boundary cannot be read the picker is left unbounded (fail open — the old behaviour, rather than guessing a bound that rejects a legitimate date). The importer is not bounded, so a spreadsheet can still file a closure out of season, where it closes nothing.
Source: backend/app/Services/Offers/SupplierTourBlackoutResolver.php, backend/app/Filament/Support/BlackoutDateBounds.php
Room Type Prices (SupplierServiceRatePrice)
Section titled “Room Type Prices (SupplierServiceRatePrice)”Prices per room configuration within a rate period:
| Code | Description |
|---|---|
| 1A | 1 Adult |
| 2A | 2 Adults |
| 3A | 3 Adults |
| 4A | 4 Adults |
| 1A+1CH | 1 Adult + 1 Child |
| 1A+2CH | 1 Adult + 2 Children |
| 1A+3CH | 1 Adult + 3 Children |
| 2A+1CH | 2 Adults + 1 Child |
| 2A+2CH | 2 Adults + 2 Children |
| 2A+1B | 2 Adults + 1 Baby |
| 2A+1CH+1B | 2 Adults + 1 Child + 1 Baby |
| 3A+1CH | 3 Adults + 1 Child |
| per_person | Per Person (Activity) |
| per_trip | Per Trip (Transfer) |
Special room types:
per_person- Used for activity pricing, multiplied by number of travelersper_trip- Used for transfer pricing, fixed price regardless of travelers
Currency auto-assignment: When creating prices without a currency, the system automatically assigns the supplier’s default currency.
No zero prices. A price row is the supplier’s cost for that occupancy, so a 0 has always meant “not quoted yet” — and pricing now treats it as an unfilled cell that makes the land price unknown rather than a free component. Six package price rows were sitting at 0 when this shipped, and the itemized hotel/activity rates carry more, so the rule applies to every service type rather than packages alone. Every price field in the rate-price repeater on the Supplier Service form therefore has a minimum of 0.01: the room-type/package Price, the activity / per-person-transfer Price, and the per-trip transfer Price per Trip. If a room type is not quoted, leave the row out instead of filing it as free.
This is a form-level rule. The Excel template import still accepts 0 (its price columns validate >= 0), so a spreadsheet can introduce one. What that costs depends on which branch prices the tour:
- Package-priced tours (effectively every live product): a
0on a package price row stops offers generating for the affected dates and blocks activation, and the guard names the package, the rate id and the room type. - Itemized tours: a
0on a hotel or activity rate has the same blocking effect, but the activation refusal is generic — “the itemized hotel/activity services have no price for this date and room type”, with no rate named. - On a package-priced tour, itemized hotel/activity prices are ignored entirely, so a
0there has no pricing effect at all.
Correct such rows in the admin panel.
Source: backend/app/Models/SupplierServiceRatePrice.php, backend/app/Filament/Resources/Suppliers/SupplierServices/Schemas/SupplierServiceForm.php
Changing Supplier Currency
Section titled “Changing Supplier Currency”Each supplier has a currency_id that drives the default currency for every SupplierServiceRatePrice row owned by that supplier (across hotels, activities, transfers, and packages). When an operator edits the currency on the supplier record, SupplierObserver::updated() cascades the new currency_id to:
supplier_service_rate_prices– bulk-updated for every price row whose rate belongs to a service owned by this supplier (live pricing for all service types).supplier_tour_rate_room_prices– legacy tour room prices, kept defensively so any reintroduction of that path stays consistent.
Important: only the currency label is changed, never the numeric value. A row priced 100 USD becomes 100 ZAR after changing the supplier from USD to ZAR – same number, different real value. The cascade does not perform any FX conversion. The admin is responsible for verifying and correcting the numeric values to reflect the new currency.
Source: backend/app/Observers/SupplierObserver.php
Inline Warning (Supplier Edit Form)
Section titled “Inline Warning (Supplier Edit Form)”The Payment section on the supplier edit form carries a permanent description above the currency Select that calls out the implication of changing it: the cascade only updates labels, not values, and the admin must verify that every related price reflects amounts in the new currency. The warning is always visible whenever the operator is on the edit page, so a deliberate currency change is always paired with the reminder.
Source: backend/app/Filament/Resources/Suppliers/Schemas/SupplierForm.php – the Section::make('Payment')->description(...) block.
Alignment Migration
Section titled “Alignment Migration”Before this cascade existed, only the legacy table was kept in sync, so production rows on supplier_service_rate_prices could drift from their owning supplier’s currency. A one-time migration (2026_05_06_122024_align_supplier_service_rate_prices_currency) walks every supplier and rewrites any drifted currency_id to match suppliers.currency_id. It is idempotent and the down step is a no-op (the original mismatched values are not recoverable, and the post-state matches the new system invariant).
Source: backend/database/migrations/2026_05_06_122024_align_supplier_service_rate_prices_currency.php
Impact on Existing Offers
Section titled “Impact on Existing Offers”Activated offers store final_price (and other prices) as a frozen EUR snapshot at creation time. Standard 2-pax checkouts use that frozen value, so a supplier currency change cannot retroactively alter what the customer pays.
Non-standard pax checkouts (1 traveler, 3 travelers, custom multi-room) call AutoOfferGeneratorService::calculateLandPrice() live against the current supplier_service_rate_prices data. Until the operator finishes verifying numeric values against the new currency, a supplier currency change can therefore affect what those checkouts compute. See Offers — Variable Passenger Count (Checkout).
SupplierTour and ProductTemplate Relationship
Section titled “SupplierTour and ProductTemplate Relationship”Tours are linked 1:1 with ProductTemplates. The Tour wizard creates both the SupplierTour and its ProductTemplate in a single flow. The ProductTemplate defines the itinerary structure (cities, nights, titles), while SupplierTour adds supplier-specific data (hotels, rates, guide info).
ProductTemplate is no longer managed as a standalone resource – it is created and edited through Tours. See Admin Panel for the full workflow.
Source: backend/app/Models/SupplierTour.php
Multi-Supplier Tours
Section titled “Multi-Supplier Tours”A SupplierTour can have multiple suppliers via a many-to-many relationship. This enables multi-country tours where different suppliers manage different regions (e.g., India + Thailand).
Pivot table: supplier_supplier_tour (supplier_id, supplier_tour_id)
Relationship: SupplierTour::suppliers() (belongsToMany)
Helpers:
$tour->hasSupplier($supplierId)– checks pivot membership (uses loaded relation when available)$tour->supplier_ids– accessor returningarray<int>of all associated supplier IDs
Admin form: Multi-select supplier field replaces the old single-supplier dropdown.
Service assignment scoping: Hotels, activities, and transfers in the tour form are filtered to show items from ALL associated suppliers, not just one.
Supplier manager visibility: A supplier manager sees all tours that include their supplier in the pivot.
Policy: SupplierTourPolicy checks pivot-based membership via hasSupplier() instead of the old direct FK.
Source: backend/app/Models/SupplierTour.php, backend/app/Policies/SupplierTourPolicy.php
Multi-Package Services
Section titled “Multi-Package Services”A SupplierTour can have multiple package services (flat base pricing) via a many-to-many relationship. This allows combining packages from different suppliers into one tour price.
Pivot table: package_service_supplier_tour (supplier_service_id, supplier_tour_id, alternative_group)
Relationship: SupplierTour::packageServices() (belongsToMany to SupplierService, withPivot('alternative_group'))
Helpers:
$tour->hasPackageServices()– returns true if any package services are linked
Admin form: Multi-select package field shows packages from all associated suppliers.
Components vs alternatives (alternative_group)
Section titled “Components vs alternatives (alternative_group)”Attached packages are not all additive. One tour’s list mixes real cost components (the tour itself, a domestic-flight package, an extension) with mutually exclusive alternatives: season successors (a 2026 package beside its 2027 re-issue), operating-day variants, and season price tiers — Konnichiwa’s tour is eight packages, one per date slice.
The distinction is recorded on the pivot:
alternative_group |
Meaning |
|---|---|
NULL |
Mandatory component. A leg of every trip; it must price for a departure or no offer may exist on that date. |
a label (e.g. alt-54-1) |
One slot of alternatives. All packages sharing the label are versions of the same thing, and a departure needs exactly one of them to apply and price. |
Pricing: land price is the sum of the tour’s slots, pricing exactly one member per alternative slot (never summing overlapping tier windows), and is null — no offer — when any slot fails to produce a positively-priced applicable rate. The classification is stored business data and is deliberately not inferred from rate overlap at pricing time. See Offers — Land Price Components for the full rule and offers:classify-package-alternatives for seeding the column.
Source: backend/app/Models/SupplierTour.php, backend/app/Services/Offers/AutoOfferGeneratorService.php, backend/.ai/rules/offers.md
TourStatus
Section titled “TourStatus”Tours have an auto-derived status (TourStatus enum) based on 6 completion steps:
| Status | Value | Description |
|---|---|---|
| Draft | draft |
One or more required steps incomplete |
| Complete | complete |
All 6 steps pass – ready to publish to market |
Status is computed by SupplierTourService::determineStatusFromFormData() on every save. There is no manual status toggle.
Source: backend/app/Enums/TourStatus.php
SupplierTourService
Section titled “SupplierTourService”Centralized business logic for tour operations:
- Status derivation –
determineStatusFromFormData()/getCompletionProgressForTour()compute the 6-step completion progress - Hotel assignments –
generateHotelAssignments(),syncHotelAssignments(),saveHotelAssignments(),loadGroupedHotelAssignments(). On save, days no longer covered by any stop (e.g. after reducing a stop’s nights) get their hotel tiers cleared, so the public page never renders a stale hotel the admin no longer shows - Activity assignments –
saveActivityAssignments(),loadActivityAssignments(). On save, activities on days no longer covered by any real stop (e.g. a former night that became a 0-night transit/return day, which the editor strips) are cleared, so the public page never renders a stale activity the admin no longer shows – the same treatment as hotels. Transfers are intentionally exempt because they legitimately live on 0-night transit days (airport/connection transfers) - Transfer assignments –
saveTransferAssignments(),loadTransferAssignments() - Rate periods –
saveRatePeriods(),syncRatePeriods(),loadRatePeriods()
Source: backend/app/Services/SupplierTourService.php
CLI: tours:clear-orphan-assignments
Section titled “CLI: tours:clear-orphan-assignments”Data-repair twin of the save-time hotel/activity auto-clear in SupplierTourService. Locked tours (with an active ProductByMarket) skip all save-time syncing, so hotel/activity assignments stranded on 0-night transit/return days never clear on a re-save. This command clears them, reusing the same covered-days source of truth (ItineraryCalculator) so it targets only genuine orphans. It nulls orphan hotel columns and deletes orphan activity pivots — it never deletes the itinerary row, and never touches ProductByMarket translations, offers, or publish state. Transfers are intentionally exempt (they legitimately live on transit days).
# Preview across all tours (no writes)./vendor/bin/sail artisan tours:clear-orphan-assignments --dry-run
# Limit to specific supplier tour IDs (repeatable)./vendor/bin/sail artisan tours:clear-orphan-assignments --tour=42 --tour=57Source: backend/app/Console/Commands/ClearOrphanTourAssignmentsCommand.php (#2201, #2197, #2193)
Rate Date Validation
Section titled “Rate Date Validation”Tour rates have specific validity rules checked by isDateAvailable():
- Date within
start_date-end_daterange - Weekday in allowed
weekdaysarray (e.g.,["mon", "thu"]) - Date not in
excluded_date_ranges(blackout periods)
A tour rate’s own blackouts are travel-window closures, and they are scoped to that rate’s season like any other — the resolver now includes them, so the search window and calendar stop offering dates pricing would refuse.
Source: backend/app/Models/SupplierTourRate.php
Room Type Codes
Section titled “Room Type Codes”Standardized room type codes used across the system:
| Code | Description |
|---|---|
| 1A | 1 Adult |
| 2A | 2 Adults |
| 3A | 3 Adults |
| 4A | 4 Adults |
| 1A+1CH | 1 Adult + 1 Child |
| 1A+2CH | 1 Adult + 2 Children |
| 1A+3CH | 1 Adult + 3 Children |
| 2A+1CH | 2 Adults + 1 Child |
| 2A+2CH | 2 Adults + 2 Children |
| 2A+1B | 2 Adults + 1 Baby |
| 2A+1CH+1B | 2 Adults + 1 Child + 1 Baby |
| 3A+1CH | 3 Adults + 1 Child |
Source: backend/app/Models/SupplierTourRateRoomPrice.php (roomTypeOptions())
SupplierHotel Fields
Section titled “SupplierHotel Fields”| Field | Description |
|---|---|
hotel_name |
Hotel name |
city |
Location in CitySelect format (“City, Country”) |
address |
Street address |
category |
ServiceTier enum (selection, luxury, grand_luxury) |
meal_plan |
HotelMealPlan enum |
room_types |
Available room configurations (array) |
room_type |
Legacy single room type field |
images |
Photo gallery |
description |
Optional text description, displayed in PDP hotel detail modal and checkout hotel cards |
reasons |
Array of selling points (e.g., “Beachfront location”) |
amenities |
Array of amenity objects with icon, label, and description |
Source: backend/app/Models/SupplierHotel.php
SupplierHotel Room Types
Section titled “SupplierHotel Room Types”Hotels use a different room type configuration defined as model constants:
| Code | Description |
|---|---|
1 pax |
1 Pax (Single) |
2 pax |
2 Pax (Double) - Required |
3 pax |
3 Pax (Triple) |
2 pax 1 baby |
2 Pax + 1 Baby |
2 pax 1 child |
2 Pax + 1 Child |
2 pax 2 children |
2 Pax + 2 Children |
4 pax |
4 Pax (Quad) |
family |
Family Room |
suite |
Suite |
All hotels must include the “2 pax” room type.
Source: backend/app/Models/SupplierHotel.php (ROOM_TYPES, REQUIRED_ROOM_TYPE)
Connection to Offers
Section titled “Connection to Offers”Offers use SupplierService pricing from hotels AND included activities in the tour itinerary:
ProductByMarket → ProductTemplate ← SupplierTour → SupplierTourItinerary[] ↓ ┌─────────────┴─────────────┐ ↓ ↓ SupplierHotel[] SupplierActivity[] (included) ↓ ↓ SupplierService[] SupplierService[] ↓ ↓ Rate → Prices Rate → Prices ↓ ↓ └───────────┬───────────────┘ ↓ Offer (land_base_price)Offer Creation Flow
Section titled “Offer Creation Flow”- Select ProductByMarket (includes ProductTemplate and linked SupplierTour)
- System extracts all hotels from the tour’s itinerary
- System extracts all included activities (not upsells)
- System finds SupplierService for each hotel and activity
- Room type dropdown shows types available at ALL hotels (intersection)
- Price displayed is the TOTAL across all services for selected room type
- Offer stores the combined
land_base_price
Price Calculation
Section titled “Price Calculation”The land price sums hotel and included activity services, converting each to the market currency individually:
Land Price = Σ(convert(service_price, service_currency, market_currency))
Hotel Service Price = rate_price x nights (per_night model) With supplement pricing: rate_price x nights x pax_from_room_typeActivity Service Price = rate_price x travelers (per_person model)Room type lookup:
- Hotels: Use selected room type (e.g.,
2A,2A+1CH) - Activities: Always use
per_personroom type
Currency conversion: When a tour combines packages or services in different currencies (e.g., JPY + THB for a EUR market), each price is converted to the market currency before summing. See Offers - Mixed-Currency Conversion for details.
Source: backend/app/Services/Offers/AutoOfferGeneratorService.php (calculateLandPrice())
See Offers documentation for combined pricing details.
Multi-Tenant Access Control
Section titled “Multi-Tenant Access Control”SupplierManager Role
Section titled “SupplierManager Role”Users with SupplierManager role are automatically scoped to their assigned supplier. The Filament resources filter queries via the supplier_supplier_tour pivot – a manager sees all tours containing their supplier.
Source: backend/app/Filament/Resources/Suppliers/SupplierTourResource.php
Permissions
Section titled “Permissions”| Permission | Description |
|---|---|
| supplier.view_any | List suppliers |
| supplier.view | View supplier details |
| supplier.create | Create suppliers (Admin only) |
| supplier.update | Update supplier |
| supplier.delete | Delete supplier (Admin only) |
| supplier_tour.* | Tour CRUD operations |
| supplier_tour_rate.* | Rate CRUD operations |
| supplier_hotel.* | Hotel CRUD operations |
Filament Admin Resources
Section titled “Filament Admin Resources”Navigation Group: “Suppliers”
Section titled “Navigation Group: “Suppliers””| Resource | Nav Label | Model | Description |
|---|---|---|---|
| Suppliers | Suppliers | Supplier | Company management |
| Hotels | Hotels | SupplierHotel | Hotel inventory |
| Activities | Activities | SupplierActivity | Excursions, day tours |
| Transfers | Transfers | SupplierTransfer | Airport/city transfers |
| Supplier Services | Supplier Services | SupplierService | Per-hotel/activity/transfer pricing |
| SupplierTourResource | Tours | SupplierTour | Tour packages (primary product entry point) |
| Supplier Contracts | Supplier Contracts | SupplierContract | Contract management with status workflow |
| Release Dates | Release Dates | SupplierTourRate | Tour rate periods (legacy) |
Source: backend/app/Filament/Resources/Suppliers/
Tour Creation Wizard
Section titled “Tour Creation Wizard”Creating a SupplierTour uses an embedded wizard that also creates the ProductTemplate:
- Product Template Step: AI parser, title, duration, itinerary (cities/nights)
- Tour Details Step: Guide info, meals, transport, images
- Services Assignments Step: Three tabs:
- Hotels Tab: Grouped by stop with Selection/Luxury/Grand Luxury hotel selections
- Activities Tab: Per-day activity assignments across Included/Extra/Substitution tiers
- Transfers Tab: Per-day transfer assignments across Selection/Luxury/Grand Luxury tiers
AI Trip Parser
Section titled “AI Trip Parser”Paste raw trip information to auto-generate all template fields. Appears as a collapsible section at the top of Step 1. Each generation is logged to activity_log with log name ai_tour_parser, recording the raw input, source locale, TCAI profile, generated title, and total stops.
Example input:
Asia India 9 nights - 2 Delhi - 1 Jodhpur - 2 Udaipur - 2 Jaipur - 1 Agra - 1 DelhiGenerates:
- Tour title and descriptions (short/long)
- Complete itinerary with POI locations resolved via Google Places
- Duration auto-calculated from total nights
- Hotel assignments regenerated from itinerary
Content is generated in the selected source language. Shows confirmation modal before replacing existing values.
Source: backend/app/Services/ProductTemplateAIService.php (generateFromRawInput())
Flight Route Validation (Create Wizard)
Section titled “Flight Route Validation (Create Wizard)”Step 1 includes a “Validate flight routes before continuing” checkbox (checked by default). When checked, clicking Next triggers FlightRouteValidationService to perform a test Aerticket API search against both international and domestic flight legs. If validation fails, a notification is shown and the wizard is halted – uncheck the checkbox to skip validation.
This only applies to the Create wizard. Edit and View pages use a header button instead (see below).
Flight Route Validation (Edit/View)
Section titled “Flight Route Validation (Edit/View)”Edit and View pages have a “Validate Flight Route” header button that performs a test flight search via the Aerticket API. The confirmation modal shows the route summary, duration, and test parameters (2 adults, sample date ~60 days out, origin MAD). Results appear as per-segment notifications: each international and domestic leg gets an individual success/failure notification.
The validation service separates international and domestic legs from the route config, validates them independently, and returns domestic_results alongside the main international results.
The button is only visible when the tour has an itinerary defined.
Source: backend/app/Filament/Resources/Suppliers/SupplierTours/Actions/ValidateFlightRouteAction.php
Service: backend/app/Services/Flights/FlightRouteValidationService.php, backend/app/Services/Flights/FlightRouteValidationNotifier.php
Publish to Market (Edit Page)
Section titled “Publish to Market (Edit Page)”The “Publish to Market” header action on the Tour edit page creates a ProductByMarket from the tour’s template. See Admin Panel - Publish to Market for details.
Source: backend/app/Filament/Resources/Suppliers/SupplierTours/Actions/PublishToMarketAction.php
Tour View Page
Section titled “Tour View Page”The view page shows the TourCompletionWidget (6-step progress), a “Published Markets” section listing linked market products, and three footer widgets with read-only service assignment tables (hotels, activities, transfers).
See Admin Panel - Tour View Page for full details.
Source: backend/app/Filament/Resources/Suppliers/SupplierTours/Pages/ViewSupplierTour.php
Hotel Selection Performance
Section titled “Hotel Selection Performance”Hotel dropdowns use lazy loading to prevent N+1 issues with large inventories:
- Hotels fetched on-demand when user searches (not preloaded)
- Search filtered by itinerary location
- Results limited to 50 per search
Tier filtering: The Selection (base/included) dropdown lists hotels of ANY ServiceTier category, so a Luxury or Grand Luxury hotel can be placed in the included slot for custom/tailor-made packages. The Luxury and Grand Luxury dropdowns stay filtered to their own category (ServiceTier::Luxury / ServiceTier::GrandLuxury).
- The
requireServicefallback is independent of category: when the tour has no package service, the Selection dropdown still only lists hotels with an active service/rate (that rate becomes the base land price). With a package present, the selection hotel is descriptive only. - For packaged tours the selection hotel’s own rate is never added to the price – the base land price comes from the package service (
AutoOfferGeneratorService::calculateLandPrice()package branch), so an upgrade-tier hotel in the Selection slot shows as “included” with no supplement.
Cascade behavior: Deleting SupplierTour also deletes its ProductTemplate. Deleting Supplier cascades to tours and hotels.
Source: backend/app/Filament/Resources/Suppliers/SupplierTours/Schemas/SupplierTourForm.php
Supplier Contracts
Section titled “Supplier Contracts”Manages formal agreements with suppliers, tracking negotiation status and locking in service prices and allotments. Contracts snapshot pricing from existing services/tours so agreed rates are preserved independently of future rate changes.
A contract is not necessarily a single document: rates renegotiated mid-season are agreed in addendums that hang off the signed contract, so what a rate costs is a property of the contract family rather than of one row — see which document governs a rate.
Use Cases
Section titled “Use Cases”- Formalizing agreed pricing with a supplier before a season
- Tracking contract negotiation and signing workflow
- Checking whether a supplier has an active contract covering a specific date
- Auto-generating a contract from an existing tour’s service assignments
- Renegotiating a price, an allotment or the withdrawal of a service inside a signed season
Status Lifecycle
Section titled “Status Lifecycle”Contracts follow a strict state machine with guarded transitions:
Draft → Sent → Negotiating → Signed → Active → Expired ↓ ↓ ↓ ↓ ↓ └───────┴─────────┴───────────┴────────┴──→ Terminated| Status | Editable | Can Add Services | Final |
|---|---|---|---|
| Draft | Yes | Yes | No |
| Sent | Yes | Yes | No |
| Negotiating | Yes | Yes | No |
| Signed | No | No | No |
| Active | No | No | No |
| Expired | No | No | Yes |
| Terminated | No | No | Yes |
Transitions set timestamps automatically: sent_at when moving to Sent, terminated_at when moving to Terminated. The signed_at and signed_by fields are captured automatically when signed via Signaturit e-signature, or manually via a modal form.
Signed and Active contracts accept no further lines, which is the point of the lock — changing them is what addendums are for. Addendums are supplier_contracts rows and run the same lifecycle, signature flow and status guards as a contract.
Source: backend/app/Enums/SupplierContractStatus.php
Reference Numbers
Section titled “Reference Numbers”Auto-generated sequential pattern: SC-{YYYY}-{0001}. The sequence resets each year, counts only contracts (addendum rows are excluded via scopeContractsOnly()), and does not reuse numbers from soft-deleted contracts (the lookup includes trashed rows so audit traceability is preserved).
An addendum derives its reference from its parent’s: {parent reference}-A{n}, e.g. SC-2026-0001-A1. The sequence is read from the highest existing numeric suffix rather than from a count, so deleting an addendum never hands its number to the next one, and it is assigned by the model’s creating hook at insert time — two operators drafting addendums against the same contract cannot both claim -A1.
Source: backend/app/Models/SupplierContract.php (generateReferenceNumber(), generateAddendumReferenceNumber())
Contract Services (Line Items)
Section titled “Contract Services (Line Items)”Each contract has line items (SupplierContractService) that snapshot a price and allotment from either a SupplierService + SupplierServiceRate pair or a SupplierTourRate. Price priority for snapshots: 2A room type, then per_person, then per_trip, then first available.
Every line also carries a change_type (SupplierContractChangeType: add / change / remove) saying what it does to the document it amends. A contract’s own lines are always add — every line of a first document is new — so the field is only surfaced on addendums. A change or remove line additionally stores previous_price / previous_allotment (what the family agreed when the addendum was drafted, so the signed PDF keeps printing the same before/after whatever later addendums do) and, for a withdrawal, effective_from. A remove line never carries a price: the model’s saving hook nulls price and allotment on it, so the invariant holds for the admin form, the factories and any future importer alike.
| Helper | Answers |
|---|---|
changesPrice() |
The line moves the price the amended document agreed — the PDF prints before/after only for the value that actually moved |
changesAllotment() |
The line moves the allotment the amended document agreed |
Source: backend/app/Models/SupplierContractService.php, backend/app/Enums/SupplierContractChangeType.php
SupplierContractService (Business Logic)
Section titled “SupplierContractService (Business Logic)”The service class provides:
transitionStatus()– validates transitions and sets timestampsgetServicesForQuotation()– returns contract line items applicable on a specific date (filters by rate date availability)isSupplierCovered()– checks if a supplier has an active contract valid on a datecollectServicesFromTour(SupplierTour $tour, int $supplierId)– returns the services (hotels, activities, transfers, package services) referenced by the tour and owned by$supplierId. Filters every query bysupplier_idso a multi-supplier tour never leaks another supplier’s services into the contract. Skips 0-night transit-day itinerary rows (viaItineraryCalculator::getTransitDayNumbers()) so orphan hotel/activity/transfer references on those rows don’t surface as phantom services on the contract – mirroring the form-side filter inSupplierTourService::stripTransitDays(). Includes both per-day transfer upgrades (itineraries.selectionTransfers,luxuryTransfers,grandLuxuryTransfers) and the trip-level luxury transfer (SupplierTour.luxury_transfer_id), so tours that assign a single transfer for the whole trip get their transfer service into the contract too.buildContractDataFromTour(SupplierTour $tour, int $supplierId)– composes repeater-compatible form data (title, currency, validity range, line items) for the given supplier. Line items are emitted only from supplier services (hotels, activities, transfers, package services); tour rates inform the contract’s validity range viacomputeValidityRange()but are no longer emitted as line items. ThrowsInvalidArgumentExceptionif the supplier isn’t attached to the tour.
Source: backend/app/Services/SupplierContractService.php
Addendums
Section titled “Addendums”A signed contract is locked, so renegotiating rates mid-season means a second document rather than an edit. An addendum is a supplier_contracts row whose parent_contract_id points at the contract it amends (NULL means the row is a contract in its own right). It carries only the lines that change plus a required reason, and never modifies the parent.
| Model API | Behaviour |
|---|---|
parentContract() / addendums() |
The self-referential pair on parent_contract_id (cascade on delete) |
isAddendum() |
parent_contract_id !== null |
canBeAmended() |
Only a Signed or Active contract, and never an addendum — an unsigned contract is still editable in place, and addendums are not nested |
awaitsSignature() |
Draft, Sent or Negotiating. Expired and Terminated documents are not pending: nobody is going to sign them, and their lines are not on sale either |
sellsWithoutSignature() |
An authorisation to sell its rates unsigned is on the record |
scopeContractsOnly() / scopeAddendumsOnly() |
Split the shared table |
An addendum inherits the parent’s supplier and currency. Because the parent arrives in the create page’s query string and is therefore user input, CreateSupplierContract re-checks canBeAmended() server-side (halting with a notification if it fails) and re-derives supplier_id from the parent whatever the form posted. valid_from defaults to today — or to the contract’s own start when its season has not opened yet — and valid_until to the parent’s end date.
Source: backend/app/Models/SupplierContract.php, backend/database/migrations/2026_09_02_091045_add_addendum_support_to_supplier_contracts.php
Which document governs a rate
Section titled “Which document governs a rate”Once a contract and its addendums can each carry a line for the same rate, the agreed price is no longer “the line on the contract”. SupplierContractRateResolver is the single answer, and the rule is that the most recently signed document of the family wins (Signed or Active; documents signed the same day break ties by id, so the ordering is total). Unsigned documents are deliberately invisible to it — until the supplier signs, their rates are not agreed. Lines whose contract has been soft-deleted are ignored (whereHas('contract') honours the SoftDeletes scope), because a deleted document agrees nothing.
| Method | Answers |
|---|---|
effectiveLine() |
The line currently governing a rate, withdrawals included — “withdrawn” is not the same as “never agreed” |
currentTerms() |
The price and allotment in force, as the addendum form’s Currently agreed prefill needs them |
agreedPrice() |
The price to bill, skipping priceless lines so a withdrawal does not erase the cost of a service the supplier already delivered |
withdrawnFrom() |
The date a rate stops being sellable per the newest signed removal (effective_from, falling back to that document’s signed_at); a removal that a later signed document re-adds is no longer a withdrawal |
unsellableRates() |
Per-rate, operator-facing reasons why a departure may not be sold |
overriddenLines() |
The contract’s own lines a signed addendum has since replaced, keyed by line id — what the Overridden by addendums section of the contract form renders |
agreedPrice() answers “what is agreed”, not “what was agreed on a given day”: an addendum signed after a booking moves the cost for that booking too. That matches the behaviour before addendums existed, where the contract’s own price was read whenever a bill was built.
unsellableRates() is scoped to the rate rather than to a contract family: the question “may this rate be sold” belongs to the rate, and the answer is whatever document last spoke about it, whichever contract that document hangs from. Two things block a sale — a signed removal effective on or before the departure date, and an addendum still awaiting signature that introduces or changes the rate. A contract still in draft never blocks: its rates have always been sellable, and the signature gate there is the offer’s own status. The first is a fact about the agreement and cannot be lifted; the second is lifted by an explicit authorisation recorded on that document. Consumers: offer activation and the checkout gate, and the vendor bill cost lookup in NetSuite.
Source: backend/app/Services/SupplierContractRateResolver.php
Selling before signature
Section titled “Selling before signature”Rates a supplier has not signed yet are not sellable, and that gate is what makes partners sign. Since authorised exceptions happen in practice, there is a recorded door rather than a rule people have to break: sell_without_signature_authorised_by / _at / _reason on the contract row, written by the Authorise Selling Without Signature action and gated on the authorise_sell_without_signature permission (see Roles and Permissions). It is offered only on an addendum that still awaitsSignature() — a contract’s rates never needed its signature to be sellable, so flagging one would record an exception to a rule that does not exist — the reason is mandatory, and the state is surfaced as an Unsigned sale badge plus a Selling without signature filter on the contracts table — which doubles as the report of how much is sold unsigned and with which partners. Revoke Selling Without Signature restores the requirement; anything already sold is untouched. A signed removal is never liftable this way.
Source: backend/app/Filament/Resources/Suppliers/SupplierContracts/Pages/EditSupplierContract.php, backend/database/migrations/2026_09_02_094426_add_sell_without_signature_to_supplier_contracts.php
Re-pricing after a signed addendum
Section titled “Re-pricing after a signed addendum”Offers freeze their cost at generation time, so a signed addendum changes what a supplier’s trips cost without moving any published price. SupplierContract::booted() watches for a status change into Signed and, when the document is an addendum, dispatches RecalculateSupplierOfferPricesJob. The job delegates to offers:recalculate-prices (--apply --force --supplier={id}, statuses draft, approved_by_supplier, active) instead of repeating its arithmetic, which keeps one re-pricing mechanism in the codebase — the same one an operator runs by hand — and logs the exit code and output. That command skips offers with a committed booking, which is the agreed rule: a confirmed booking keeps the cost it was closed with. See Recalculating Prices.
Source: backend/app/Jobs/RecalculateSupplierOfferPricesJob.php, backend/app/Models/SupplierContract.php (booted())
Admin UI (Filament)
Section titled “Admin UI (Filament)”Create Page
Section titled “Create Page”The “Generate from Tour” dropdown (visible after selecting a supplier) auto-fills the contract title, validity dates, and the service line items from the tour’s itinerary assignments. Only services owned by the selected supplier are pulled in — on a multi-supplier tour, services from other suppliers stay on their own contracts.
Currency is inherited from the supplier and not editable on the contract. The currency Select is rendered disabled and shows the supplier’s currency for context. On save, both mutateFormDataBeforeCreate and mutateFormDataBeforeSave derive currency_id from the contract’s supplier_id, so a tampered or stale form value cannot diverge from the supplier’s currency.
The same page doubles as the addendum form when it is opened with ?parent={id}. A hidden parent_contract_id carries the parent across Livewire requests (which do not repeat the query string), so only the mount-time field defaults read ?parent; everything else resolves the parent from form state. In that mode: supplier and currency are inherited and disabled, the reference is derived and read-only, Generate from Tour is hidden, reason becomes required, an Amends contract placeholder names the parent and its signature date, and the services repeater starts empty so only the changed lines are entered. Each repeater row gains a Change toggle (change_type), a Currently agreed placeholder fed by SupplierContractRateResolver::currentTerms(), and an Effective From date; price and allotment — read-only on a plain contract, where they mirror the rate — become editable, and are cleared and disabled on a withdrawal.
Edit Page Workflow Actions
Section titled “Edit Page Workflow Actions”Header actions enforce the status lifecycle:
| Action | Visible When | Modal |
|---|---|---|
| Create Addendum | canBeAmended() (Signed or Active contract, never an addendum) and the user can create contracts |
No modal — links to the create page with ?parent={id} |
| Send for Signature | Draft/Sent, no pending signature | Generates PDF, sends to Signaturit |
| Resend Email | Pending signature exists | Triggers a Signaturit reminder for the same signaturit_signature_id; updates signaturit_sent_at |
| Sync from Signaturit | Pending signature exists | Webhook-bypass for stuck contracts: pulls envelope state from Signaturit and finalizes the contract (downloads signed PDF, transitions to Signed) when both parties have completed. See Signaturit / Manual Sync |
| Cancel Signature | Pending signature exists | Cancels via Signaturit API |
| Download Contract PDF | contract_pdf_path is set |
Downloads generated PDF |
| Download Signed PDF | signed_pdf_path is set |
Downloads Signaturit-signed PDF |
| Mark as Sent | Draft | Status-only flag (no email, no PDF, no Signaturit call) – use when the contract was delivered outside the Signaturit flow |
| Mark as Signed | Sent or Negotiating | Prompts for signed_at and signed_by |
| Activate | Signed | Confirmation only |
| Authorise Selling Without Signature | awaitsSignature(), not already authorised, user holds authorise_sell_without_signature |
Requires a reason; records authoriser, timestamp and reason (Selling before signature) |
| Revoke Selling Without Signature | An authorisation exists, same permission | Confirmation only; clears the three fields |
| Terminate | Any non-final status | Prompts for termination_reason |
Actions are ordered as the work happens — amend, get it signed, move the status — with the exceptional ones (selling unsigned, terminating, deleting) last. Create Addendum also appears on the contract’s View page and on the Addendums relation manager.
See Signaturit E-Signature for the full digital signature flow.
Addendums Relation Manager
Section titled “Addendums Relation Manager”AddendumsRelationManager lists a contract’s addendums as its children (reference, reason, status, signature date, line count) with a Create Addendum header action. It is read-only — an addendum has the same lifecycle as a contract, so it is created and edited through the resource’s own pages, whose links the row actions point at — and it is hidden on an addendum (canViewForRecord()), which never nests another.
Contracts Table
Section titled “Contracts Table”Contracts and addendums share one list. An addendum’s Reference cell carries an Addendum to {reference} description, a Reason column (toggleable) shows why a document was amended, and an Unsigned sale badge flags an authorisation to sell without signature (reason on hover). Two filters join the Status one: Document (contracts only / addendums only) and Selling without signature.
Source: backend/app/Filament/Resources/Suppliers/SupplierContracts/
CLI: suppliers:contracts:generate
Section titled “CLI: suppliers:contracts:generate”Auto-generate a contract from a tour’s service assignments via Artisan.
# Interactive (prompts for tour selection)./vendor/bin/sail artisan suppliers:contracts:generate
# Direct with tour ID./vendor/bin/sail artisan suppliers:contracts:generate 42
# Preview without creating./vendor/bin/sail artisan suppliers:contracts:generate 42 --dry-runResolves the target supplier (auto-picks if the tour has a single supplier, otherwise prompts), collects only that supplier’s services from the tour itinerary, previews line items in a table, and creates the contract with status Draft (and the supplier’s currency) inside a DB transaction.
Source: backend/app/Console/Commands/GenerateSupplierContractCommand.php
Bulk Template Import/Export
Section titled “Bulk Template Import/Export”The Supplier Services list page provides Excel template export/import for bulk-creating and updating hotels, activities, transfers or packages with their full entity chain.
Location
Section titled “Location”Header actions on Supplier Services list page (/admin/supplier-services):
- Export Template — a dropdown with two options sharing one modal:
- Export blank template — headers and validations only
- Export with current data — the same template pre-filled with the supplier’s existing services, one row per rate period, ready to edit and re-import as updates
- Import from Template — uploads a filled XLSX, creating records that don’t exist and updating those that do
All actions prompt for a Supplier and a Type (Hotel, Activity, Package or Transfer).
Access: the export dropdown and the import action are visible to admins only (a hidden
Filament action cannot be mounted). Both actions additionally authorize the resolved supplier
server-side against SupplierPolicy — view for exports, update for imports — so the boundary
does not rest on the form’s filtered supplier dropdown.
The with-data export writes values in the exact vocabulary the import parsers accept (enum
labels, 2 pax-style room keys, Mon,Wed weekday codes, native Excel dates), leaves blank
cells for NULL fields, and emits one row per blackout range for rates holding several —
so re-importing an unchanged export is a no-op. Filenames carry a -with-data suffix.
Source: backend/app/Filament/Resources/Suppliers/SupplierServices/Actions/ExportTemplateAction.php, ImportTemplateAction.php
Hotels Template
Section titled “Hotels Template”Columns across entity fields, rate period, pricing type, and prices:
| Column | Field | Required | Notes |
|---|---|---|---|
| A | Hotel Name | Yes | Creates SupplierHotel |
| B | City | Yes | |
| C | Address | Yes | |
| D-G | Category, Room Config, Room Type, Meal Plan | No | See cell comments for format |
| H | Rate Start Date | Yes | DD/MM/YYYY |
| I | Rate End Date | Yes | DD/MM/YYYY |
| J | Rooms/Day | Yes | Allotment, minimum 1 |
| K | Release Days | No | |
| L | Operating Days | No | Comma-separated 3-letter codes |
| M | Blackout Start | No | DD/MM/YYYY |
| N | Blackout End | No | DD/MM/YYYY |
| O | Pricing Type | No | Dropdown: “Room” or “Supplement”. Accepts old values (“Room Price”, “Supplement per Person”) on import. |
| P-AE | Price columns | Min 1 | 1A–8A, 2A+1CH, 2A+2CH, 2A+1B, 1A+1CH, 1A+2CH, 1A+3CH, 2A+1CH+1B, 3A+1CH (16 total) |
One row creates: SupplierHotel → SupplierService (type=Hotel, pricing=PerNight) → SupplierServiceRate → SupplierServiceRatePrice (one per filled price column).
HOTEL_PRICE_COLUMNS must cover every configuration in SupplierServiceRatePrice::roomTypeOptions() — a configuration with no column cannot be represented in the file. The “template covers every room configuration” test guards against drift.
Pricing Type validation: All rows for the same hotel must use the same Pricing Type. A blank cell is not a conflict — it means “no opinion”. Conflicting stated values produce a validation error.
Activities Template
Section titled “Activities Template”18 columns:
| Column | Field | Required | Notes |
|---|---|---|---|
| A | Name | Yes | Creates SupplierActivity |
| B | Description | No | |
| C | Location | Yes | |
| D | Start Time | No | HH:MM (24h). Part of the identity — see below |
| E | Duration (hours) | No | 0.25 increments, 0.25–24 |
| F | Minimum Pax | No | Whole number ≥ 1 |
| G-I | Inclusions, Warnings, Additional Notes | No | Free text |
| J | Rate Start Date | Yes | DD/MM/YYYY |
| K | Rate End Date | Yes | DD/MM/YYYY |
| L | Operating Days | No | Comma-separated 3-letter codes |
| M | Blackout Start | No | DD/MM/YYYY |
| N | Blackout End | No | DD/MM/YYYY |
| O | Release Days | No | |
| P | Allotment | No | Whole number ≥ 1. Defaults to 999 on create |
| Q | Price per Adult | Yes | In supplier currency |
| R | Price per Child | No | Cannot be the only price — see below |
One row creates: SupplierActivity → SupplierService (type=Activity, pricing=PerPerson) → SupplierServiceRate → SupplierServiceRatePrice.
time_slot is not a column — the model derives it from Start Time on save. A legacy Time Slot column and legacy 2A/3A/4A price columns are still parsed for backward compatibility, but a sheet using the legacy price layout is rejected because those room types cannot be resolved at checkout.
Price per Child alone is rejected. ActivityPriceCalculatorService reads only the per-person price, so a child-only row would import cleanly and then be priced at 0.00. The same guard applies to transfers.
Transfers Template
Section titled “Transfers Template”17 columns: Transfer Name, Transfer Category, Description, City/Location, Vehicle Type, Duration (minutes), Rate Start/End Date, Operating Days, Blackout Start/End, Release Days, Pricing Model, Allotment, Price per Trip, Price per Adult, Price per Child.
Transfer Name and City/Location are required, plus at least one price that isn’t Price per Child. When Pricing Model is blank on create, it is derived from Transfer Category: Luxury Transfer → Per Person, anything else → Per Trip.
Package Template
Section titled “Package Template”26 columns: Service Name, Description, Pricing Model, Rate Start/End Date, Operating Days, Blackout Start/End, Allotment, Release Days, then the same 16 price columns as Hotels.
Service Name, Rate Start/End Date and Allotment are required. Rows sharing a Service Name are grouped into one package with several rate periods; a blank Service Name continues the previous group. Pricing Model accepts “Package (Fixed)” or “Per Person”, defaulting to Package (Fixed) on create.
Import Semantics
Section titled “Import Semantics”Records are matched on a stable identity and updated in place:
| Type | Identity |
|---|---|
| Hotel | supplier + name + category |
| Activity | supplier + name, narrowed by start time only when the file states one |
| Transfer | supplier + name |
| Package | supplier + name, matched case- and whitespace-insensitively |
City and address are ordinary editable attributes, not part of any identity, so correcting them updates the record instead of creating a second one. Changing an identity column creates a new record — renaming is an admin-panel action.
Three rules govern updates:
- A blank optional cell preserves the stored value. Defaults apply only when creating. A blank Operating Days cell means all days on a new rate and leaves an existing rate’s days untouched.
- An import never deletes, except blackout ranges on an identified period. Rate periods, prices
and services are only added or updated; removing them is done in the admin panel. Blackout ranges
are the one exception, because accumulating them made every edit impossible: the dedup key is
from/to, so extending a range produced a second one beside the original, editing one did the same, and clearing the cells did nothing. When a row carries aRate IDand the sheet has the Blackout columns, the file’s rows for that period are its complete list and replace what is stored — an empty set clears them. Both conditions are gates: without the id there is no way to know the file describes the whole period, and without the columns a file predating them would wipe every range. With either closed, ranges accumulate and are de-duplicated onfrom/toas before. SeeresolveExcludedDateRanges(). - A rate period is identified by its
Rate ID, not by its dates. The with-data export writes each period’s stored id into a trailing, shadedRate IDcolumn; when a row carries one, the importer updates that period and treats the dates as an ordinary edit, so a changed date moves the period instead of leaving the original behind. A blank cell creates a period, which is how rows are added. Rows repeating an id within one period are expected — a period with several blackout ranges spans several rows, and the merge folds them back before the checks run. - A row matching more than one record is skipped and reported, not applied to one of them at random.
An id the file has no business writing to is a file-level error: the import is refused whole rather
than half-applied. Two cases qualify — an id that is unknown, belongs to another supplier or to a
service of a different type, and the same id stated on two different periods, which is what a copied
row looks like when its Rate ID was not cleared and would otherwise overwrite the period the first
row just moved. Files predating the column have no such header and fall back to matching on dates.
rejectPeriodsDescribedTwice() refuses a third shape: one period described twice, once by its id
and once by a row that states the same dates without one. Rows group by id when they state one and by
dates when they do not, so those two rows split into separate groups that both resolve to the same
stored rate — and since one replaces its blackout ranges while the other adds to them, what survived
depended on which was written last. A dateless row is compared against both the dates an identified
row states and the ones currently stored, because that row may be moving the period. Adding a period
by clearing a copied row’s id stays legitimate: the check only fires on a collision, not whenever a
file mixes rows with and without ids.
This matters beyond tidiness: nothing validates that rate periods do not overlap, and
SupplierService::getRateForDate() resolves a price by taking the first match ordered on
start_date alone. Two periods sharing a start date leave the winner to the database, so a
duplicate created by a date edit would quietly keep the old price in force.
Template Features
Section titled “Template Features”- Date validation cells (DD/MM/YYYY format)
- Number validation for allotment and price columns
- Cell comments on headers with format hints, and an identity marker on identity columns
- Instructions sheet in each template, including an “Updating existing services” section
- Empty rows are skipped automatically
- All validation errors reported with row numbers; any error rejects the whole file so nothing is half-applied
Source: backend/app/Services/SupplierTemplateService.php (export), backend/app/Services/SupplierTemplateImportService.php (import)
Test Data Generation
Section titled “Test Data Generation”Use these commands to populate your local environment with test data. Run them in order: hotels → activities → services.
suppliers:hotels:generate
Section titled “suppliers:hotels:generate”Generate test supplier hotels with realistic luxury hotel data.
# Interactive mode with prompts./vendor/bin/sail artisan suppliers:hotels:generate
# Non-interactive with options./vendor/bin/sail artisan suppliers:hotels:generate \ --suppliers=1 --suppliers=2 \ --countries=Spain --countries=France \ --hotels-per-city=3 \ --use-aiOptions:
| Option | Default | Description |
|---|---|---|
--suppliers=* |
interactive | Supplier IDs (multiple values allowed) |
--countries=* |
interactive | Country names from airports table |
--hotels-per-city |
2 |
Hotels per city (minimum 2) |
--use-ai |
off | Use OpenRouter API for realistic data |
- Cities sourced from airports table (valid destinations only)
- AI mode generates realistic luxury hotel names via OpenRouter
- Fallback mode uses hotel brand names (Four Seasons, Ritz-Carlton, etc.)
- Room types always include required “2 pax” plus random optional types
Source: backend/app/Console/Commands/GenerateTestSupplierHotels.php, backend/app/Services/SupplierHotelGeneratorService.php
suppliers:activities:generate
Section titled “suppliers:activities:generate”Generate test supplier activities (tours, excursions, experiences) by city.
# Interactive mode./vendor/bin/sail artisan suppliers:activities:generate
# Non-interactive./vendor/bin/sail artisan suppliers:activities:generate \ --suppliers=1 \ --countries=Spain \ --activities-per-city=2Options:
| Option | Default | Description |
|---|---|---|
--suppliers=* |
interactive | Supplier IDs (multiple values allowed) |
--countries=* |
interactive | Country names from airports table |
--activities-per-city |
3 |
Activities per city (minimum 1) |
Creates activities for each supplier. Use suppliers:services:generate --type=activity afterward to create linked services with per_person pricing.
Source: backend/app/Console/Commands/GenerateTestSupplierActivities.php
suppliers:services:generate
Section titled “suppliers:services:generate”Generate supplier services with rates and pricing. Links to hotels/activities created in previous steps.
# Interactive mode (prompts for type and supplier)./vendor/bin/sail artisan suppliers:services:generate
# Non-interactive./vendor/bin/sail artisan suppliers:services:generate \ --type=hotel --suppliers=1 --count=10Options:
| Option | Default | Description |
|---|---|---|
--type |
interactive | Service type: hotel, activity, or transfer |
--suppliers=* |
interactive | Supplier IDs (multiple values allowed) |
--count |
20 |
Number of services to create per supplier |
Pricing models are assigned automatically by type: per_night for hotels, per_person for activities, per_trip for transfers.
Source: backend/app/Console/Commands/GenerateTestSupplierServices.php
Database Schema
Section titled “Database Schema”Core Tables
Section titled “Core Tables”| Table | Purpose |
|---|---|
suppliers |
Land service provider companies |
supplier_hotels |
Hotel inventory per supplier |
supplier_activities |
Excursion/tour inventory per supplier |
supplier_transfers |
Transfer inventory per supplier |
supplier_services |
Purchasable services (hotel, activity, transfer) |
supplier_service_rates |
Rate periods with date/weekday constraints |
supplier_service_rate_prices |
Room type prices per rate period |
Translation Tables
Section titled “Translation Tables”| Table | Purpose |
|---|---|
supplier_activity_translations |
Per-locale content for activities |
supplier_hotel_translations |
Per-locale content for hotels |
supplier_transfer_translations |
Per-locale content for transfers |
supplier_service_translations |
Per-locale content for services |
Tour Tables
Section titled “Tour Tables”| Table | Purpose |
|---|---|
supplier_tours |
Tour packages linked to ProductTemplates |
supplier_supplier_tour |
Pivot: many-to-many suppliers per tour |
package_service_supplier_tour |
Pivot: many-to-many package services per tour, plus alternative_group (NULL = mandatory cost component) |
supplier_tour_itineraries |
Hotel assignments per tour day (selection/luxury/grand_luxury) |
supplier_tour_itinerary_activities |
Activity pivot (included/extra/substitution per day) |
supplier_tour_itinerary_transfers |
Transfer pivot (selection/luxury/grand_luxury per day) |
supplier_tour_rates |
Tour rate periods (legacy) |
supplier_tour_rate_room_prices |
Tour room prices (legacy) |
Contract Tables
Section titled “Contract Tables”| Table | Purpose |
|---|---|
supplier_contracts |
Contract header with status, validity, signing info. Also holds addendums: parent_contract_id (self FK, indexed, cascade on delete; NULL = a contract in its own right), the amendment reason, and the sell_without_signature_authorised_by / _at / _reason trio |
supplier_contract_services |
Line items linking contracts to services/rates with price snapshots, plus change_type (add default / change / remove), effective_from (the withdrawal date) and the previous_price / previous_allotment snapshot of what the amended document agreed |
Key Constraints
Section titled “Key Constraints”supplier_services.supplier_hotel_idis unique (one service per hotel)supplier_services.supplier_activity_idis unique (one service per activity)supplier_services.supplier_transfer_idis unique (one service per transfer)supplier_tour_itinerary_activitiesunique on(itinerary_id, activity_id, type)supplier_tour_itinerary_transfersunique on(itinerary_id, transfer_id, type)- Deleting a supplier cascades to services, hotels, activities, and transfers
- Deleting a service rate cascades to its prices
- Deleting a contract cascades to its addendums (
supplier_contracts.parent_contract_id)
Source: backend/database/migrations/ (search for supplier)
Files:
- Models:
backend/app/Models/Supplier*.php - Translation Models:
backend/app/Models/SupplierActivityTranslation.php,SupplierHotelTranslation.php,SupplierTransferTranslation.php,SupplierServiceTranslation.php - Translation Trait:
backend/app/Traits/HasTranslations.php - Translation Contract:
backend/app/Contracts/HasSupplierTranslations.php - Translation Admin Trait:
backend/app/Filament/Concerns/HandlesSupplierTranslation.php - Enums:
backend/app/Enums/SupplierServiceType.php,backend/app/Enums/ServicePricingModel.php,backend/app/Enums/ActivityTimeSlot.php,backend/app/Enums/ServiceTier.php,backend/app/Enums/ActivityTier.php,backend/app/Enums/SupplierContractStatus.php,backend/app/Enums/SupplierContractChangeType.php,backend/app/Enums/TourStatus.php - Resources:
backend/app/Filament/Resources/Suppliers/ - Policies:
backend/app/Policies/Supplier*.php - Transfer Resource:
backend/app/Filament/Resources/Suppliers/SupplierTransfers/ - Activity Resource:
backend/app/Filament/Resources/Suppliers/SupplierActivities/ - Template Export:
backend/app/Services/SupplierTemplateService.php - Template Import:
backend/app/Services/SupplierTemplateImportService.php - Tour Service:
backend/app/Services/SupplierTourService.php - Contract Service:
backend/app/Services/SupplierContractService.php - Contract Resource:
backend/app/Filament/Resources/Suppliers/SupplierContracts/ - Contract CLI:
backend/app/Console/Commands/GenerateSupplierContractCommand.php - Checkout Services:
backend/app/Services/Checkout/CheckoutHotelService.php,CheckoutActivityService.php,CheckoutTransferService.php - Preview DTO:
backend/app/Filament/Resources/Offers/Schemas/OfferPreviewData.php