Skip to content

Suppliers Service

Manages land service providers (DMCs), their hotel inventory, and tour packages with independent per-hotel pricing.

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

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

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

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 transfers

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

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

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

Activities represent excursions, tours, and experiences that can be assigned to tour itineraries.

Source: backend/app/Models/SupplierActivity.php

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.

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 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:00 and ends at/after 16:00full_day
  • Starts before 13:00morning
  • 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

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.

Each activity has ONE linked SupplierService for pricing:

// Activity has one service
$activity->service; // SupplierService with pricing
// Service belongs to one activity
$service->activity; // SupplierActivity

When creating a service for an activity, the pricing model auto-switches to PerPerson.

Transfers represent transportation services (airport pickups, city transfers) that can be assigned to tour itineraries.

Source: backend/app/Models/SupplierTransfer.php

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

Each transfer has ONE linked SupplierService for pricing:

// Transfer has one service
$transfer->service; // SupplierService with pricing
// Service belongs to one transfer
$service->transfer; // SupplierTransfer

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 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 locale
  • translated(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

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

Each service has rate periods defining when prices apply:

  • Date range: start_date to end_date
  • Weekdays: Array of allowed days (e.g., ["mon", "thu", "sat"])
  • Blackout Dates: Date ranges when service is unavailable
  • Allotment: Available inventory slots. AllotmentConsumption records 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 on departure_date. Multi-night stays that spill across a rate-period boundary use the rate covering departure_date for every night (matches the one-rate-per-offer pricing assumption). AllotmentService enforces 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, AllotmentExhaustedNotification mails the current Volāre entity (one aggregated email per booking, dispatched via DB::afterCommit).

Methods:

  • isDateAvailable($date) - Checks period, weekday, and blackout dates
  • isDateExcluded($date) - Checks blackout ranges only
  • getPriceForRoomType($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_dateend_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 travelers
  • per_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 0 on 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 0 on 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 0 there 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

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

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.

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

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

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 returning array<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

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

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

Centralized business logic for tour operations:

  • Status derivationdetermineStatusFromFormData() / getCompletionProgressForTour() compute the 6-step completion progress
  • Hotel assignmentsgenerateHotelAssignments(), 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 assignmentssaveActivityAssignments(), 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 assignmentssaveTransferAssignments(), loadTransferAssignments()
  • Rate periodssaveRatePeriods(), syncRatePeriods(), loadRatePeriods()

Source: backend/app/Services/SupplierTourService.php

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

Terminal window
# 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=57

Source: backend/app/Console/Commands/ClearOrphanTourAssignmentsCommand.php (#2201, #2197, #2193)

Tour rates have specific validity rules checked by isDateAvailable():

  1. Date within start_date - end_date range
  2. Weekday in allowed weekdays array (e.g., ["mon", "thu"])
  3. 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

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

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

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)

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)
  1. Select ProductByMarket (includes ProductTemplate and linked SupplierTour)
  2. System extracts all hotels from the tour’s itinerary
  3. System extracts all included activities (not upsells)
  4. System finds SupplierService for each hotel and activity
  5. Room type dropdown shows types available at ALL hotels (intersection)
  6. Price displayed is the TOTAL across all services for selected room type
  7. Offer stores the combined land_base_price

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_type
Activity 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_person room 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.

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

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
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/

Creating a SupplierTour uses an embedded wizard that also creates the ProductTemplate:

  1. Product Template Step: AI parser, title, duration, itinerary (cities/nights)
  2. Tour Details Step: Guide info, meals, transport, images
  3. 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

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 Delhi

Generates:

  • 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())

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

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

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

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 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 requireService fallback 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

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.

  • 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

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

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

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

The service class provides:

  • transitionStatus() – validates transitions and sets timestamps
  • getServicesForQuotation() – 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 date
  • collectServicesFromTour(SupplierTour $tour, int $supplierId) – returns the services (hotels, activities, transfers, package services) referenced by the tour and owned by $supplierId. Filters every query by supplier_id so a multi-supplier tour never leaks another supplier’s services into the contract. Skips 0-night transit-day itinerary rows (via ItineraryCalculator::getTransitDayNumbers()) so orphan hotel/activity/transfer references on those rows don’t surface as phantom services on the contract – mirroring the form-side filter in SupplierTourService::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 via computeValidityRange() but are no longer emitted as line items. Throws InvalidArgumentException if the supplier isn’t attached to the tour.

Source: backend/app/Services/SupplierContractService.php

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

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

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

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

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.

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.

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 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/

Auto-generate a contract from a tour’s service assignments via Artisan.

Terminal window
# 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-run

Resolves 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

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.

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 SupplierPolicyview 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

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: SupplierHotelSupplierService (type=Hotel, pricing=PerNight) → SupplierServiceRateSupplierServiceRatePrice (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.

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: SupplierActivitySupplierService (type=Activity, pricing=PerPerson) → SupplierServiceRateSupplierServiceRatePrice.

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.

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.

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.

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 a Rate ID and 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 on from/to as before. See resolveExcludedDateRanges().
  • 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, shaded Rate ID column; 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.

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

Use these commands to populate your local environment with test data. Run them in order: hotels → activities → services.

Generate test supplier hotels with realistic luxury hotel data.

Terminal window
# 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-ai

Options:

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

Generate test supplier activities (tours, excursions, experiences) by city.

Terminal window
# 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=2

Options:

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

Generate supplier services with rates and pricing. Links to hotels/activities created in previous steps.

Terminal window
# 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=10

Options:

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

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
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
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)
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
  • supplier_services.supplier_hotel_id is unique (one service per hotel)
  • supplier_services.supplier_activity_id is unique (one service per activity)
  • supplier_services.supplier_transfer_id is unique (one service per transfer)
  • supplier_tour_itinerary_activities unique on (itinerary_id, activity_id, type)
  • supplier_tour_itinerary_transfers unique 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