mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
feat(airtrail): import connecting flights as one multi-leg booking
AirTrail flights always imported as separate single-leg reservations, so a layover could not be expressed and the connection country ended up counted as visited in Atlas (#1535). The import picker now detects connection chains among the listed flights — each leg departing from the airport the previous one landed at, onward within 24 hours, and never back to the origin (an out-and-back is a return, not a connection) — and offers to import each chain as one flight with layover stops, on by default. The joined booking keeps per-leg airline, flight number, times and seat in metadata.legs, files every leg on its own trip day, and mirrors the first/last leg flat, exactly like the manual multi-leg form. With the connection stored as a stop endpoint, the existing Atlas role filter excludes the layover country on its own. AirTrail has no multi-leg flight a joined booking could round-trip to, so it imports detached from live sync, with every source flight id recorded in metadata.airtrail_ids — the picker and the server-side dedupe both treat those legs as imported, per leg, even across trip members. The server re-validates each requested chain and falls back to individual imports when it does not actually connect.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { airtrailImportSchema } from './airtrail.schema';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('airtrailImportSchema', () => {
|
||||
it('requires at least one flight id', () => {
|
||||
expect(airtrailImportSchema.safeParse({ flightIds: ['101'] }).success).toBe(true);
|
||||
expect(airtrailImportSchema.safeParse({ flightIds: [] }).success).toBe(false);
|
||||
expect(airtrailImportSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('carries connection chains through (#1535) — each with at least two legs', () => {
|
||||
const parsed = airtrailImportSchema.parse({ flightIds: ['101', '102'], connections: [['101', '102']] });
|
||||
expect(parsed.connections).toEqual([['101', '102']]);
|
||||
expect(airtrailImportSchema.parse({ flightIds: ['101'] }).connections).toBeUndefined();
|
||||
expect(airtrailImportSchema.safeParse({ flightIds: ['101'], connections: [['101']] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,13 @@ export type AirtrailFlight = z.infer<typeof airtrailFlightSchema>;
|
||||
|
||||
export const airtrailImportSchema = z.object({
|
||||
flightIds: z.array(z.string()).min(1, 'Select at least one flight'),
|
||||
/**
|
||||
* Chains of selected flight ids to import as ONE multi-leg booking each, with
|
||||
* the connection airports as layover stops (#1535). The server re-validates
|
||||
* that each chain actually connects; one that doesn't falls back to
|
||||
* individual imports.
|
||||
*/
|
||||
connections: z.array(z.array(z.string()).min(2)).optional(),
|
||||
});
|
||||
export type AirtrailImport = z.infer<typeof airtrailImportSchema>;
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'رحلات أخرى',
|
||||
'reservations.airtrail.empty': 'لم يتم العثور على أي رحلات في حساب AirTrail الخاص بك.',
|
||||
'reservations.airtrail.importCta': 'استيراد {count}',
|
||||
'reservations.airtrail.joinConnection': 'الاستيراد كرحلة واحدة مع توقف في {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Outros voos',
|
||||
'reservations.airtrail.empty': 'Nenhum voo encontrado na sua conta do AirTrail.',
|
||||
'reservations.airtrail.importCta': 'Importar {count}',
|
||||
'reservations.airtrail.joinConnection': 'Importar como um único voo com escala em {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Ostatní lety',
|
||||
'reservations.airtrail.empty': 'Ve vašem účtu AirTrail nebyly nalezeny žádné lety.',
|
||||
'reservations.airtrail.importCta': 'Importovat {count}',
|
||||
'reservations.airtrail.joinConnection': 'Importovat jako jeden let s přestupem v {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -165,6 +165,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Weitere Flüge',
|
||||
'reservations.airtrail.empty': 'Keine Flüge in Ihrem AirTrail-Konto gefunden.',
|
||||
'reservations.airtrail.importCta': '{count} importieren',
|
||||
'reservations.airtrail.joinConnection': 'Als einen Flug mit Zwischenstopp in {stops} importieren',
|
||||
'reservations.costsLabel': 'Kosten',
|
||||
'reservations.createExpense': 'Ausgabe erstellen',
|
||||
'reservations.createExpenseHint': 'Speichert die Buchung und öffnet dann den Kosten-Editor.',
|
||||
|
||||
@@ -161,6 +161,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Other flights',
|
||||
'reservations.airtrail.empty': 'No flights found in your AirTrail account.',
|
||||
'reservations.airtrail.importCta': 'Import {count}',
|
||||
'reservations.airtrail.joinConnection': 'Import as one flight with a layover in {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -163,6 +163,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Otros vuelos',
|
||||
'reservations.airtrail.empty': 'No se encontraron vuelos en tu cuenta de AirTrail.',
|
||||
'reservations.airtrail.importCta': 'Importar {count}',
|
||||
'reservations.airtrail.joinConnection': 'Importar como un solo vuelo con escala en {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -166,6 +166,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Autres vols',
|
||||
'reservations.airtrail.empty': 'Aucun vol trouvé dans votre compte AirTrail.',
|
||||
'reservations.airtrail.importCta': 'Importer {count}',
|
||||
'reservations.airtrail.joinConnection': 'Importer comme un seul vol avec escale à {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -164,6 +164,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Άλλες πτήσεις',
|
||||
'reservations.airtrail.empty': 'Δεν βρέθηκαν πτήσεις στον λογαριασμό σας στο AirTrail.',
|
||||
'reservations.airtrail.importCta': 'Εισαγωγή {count}',
|
||||
'reservations.airtrail.joinConnection': 'Εισαγωγή ως μία πτήση με ενδιάμεση στάση σε {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -164,6 +164,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Egyéb járatok',
|
||||
'reservations.airtrail.empty': 'Nem található járat az AirTrail-fiókodban.',
|
||||
'reservations.airtrail.importCta': '{count} importálása',
|
||||
'reservations.airtrail.joinConnection': 'Importálás egyetlen járatként, átszállással itt: {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Penerbangan lain',
|
||||
'reservations.airtrail.empty': 'Tidak ada penerbangan ditemukan di akun AirTrail-mu.',
|
||||
'reservations.airtrail.importCta': 'Impor {count}',
|
||||
'reservations.airtrail.joinConnection': 'Impor sebagai satu penerbangan dengan transit di {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -161,6 +161,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Altri voli',
|
||||
'reservations.airtrail.empty': 'Nessun volo trovato nel tuo account AirTrail.',
|
||||
'reservations.airtrail.importCta': 'Importa {count}',
|
||||
'reservations.airtrail.joinConnection': 'Importa come un unico volo con scalo a {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'その他のフライト',
|
||||
'reservations.airtrail.empty': 'AirTrail アカウントにフライトが見つかりませんでした。',
|
||||
'reservations.airtrail.importCta': '{count} 件をインポート',
|
||||
'reservations.airtrail.joinConnection': '{stops}での乗り継ぎを含む1つのフライトとしてインポート',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': '기타 항공편',
|
||||
'reservations.airtrail.empty': 'AirTrail 계정에서 항공편을 찾을 수 없습니다.',
|
||||
'reservations.airtrail.importCta': '{count}개 가져오기',
|
||||
'reservations.airtrail.joinConnection': '{stops} 경유 항공편 하나로 가져오기',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -162,6 +162,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Andere vluchten',
|
||||
'reservations.airtrail.empty': 'Geen vluchten gevonden in je AirTrail-account.',
|
||||
'reservations.airtrail.importCta': '{count} importeren',
|
||||
'reservations.airtrail.joinConnection': 'Importeren als één vlucht met een tussenstop in {stops}',
|
||||
'reservations.costsLabel': 'Kosten',
|
||||
'reservations.createExpense': 'Kostenpost aanmaken',
|
||||
'reservations.createExpenseHint': 'Boeking opslaan en daarna de Onkosteneditor openen.',
|
||||
|
||||
@@ -161,6 +161,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Inne loty',
|
||||
'reservations.airtrail.empty': 'Nie znaleziono lotów na Twoim koncie AirTrail.',
|
||||
'reservations.airtrail.importCta': 'Importuj {count}',
|
||||
'reservations.airtrail.joinConnection': 'Importuj jako jeden lot z przesiadką w {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Другие рейсы',
|
||||
'reservations.airtrail.empty': 'В вашей учётной записи AirTrail не найдено рейсов.',
|
||||
'reservations.airtrail.importCta': 'Импортировать {count}',
|
||||
'reservations.airtrail.joinConnection': 'Импортировать как один рейс с пересадкой в {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -161,6 +161,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Övriga flygningar',
|
||||
'reservations.airtrail.empty': 'Inga flygningar hittades i ditt AirTrail-konto.',
|
||||
'reservations.airtrail.importCta': 'Importera {count}',
|
||||
'reservations.airtrail.joinConnection': 'Importera som en flygning med mellanlandning i {stops}',
|
||||
'reservations.costsLabel': 'Kostnader',
|
||||
'reservations.createExpense': 'Skapa utgift',
|
||||
'reservations.createExpenseHint': 'Sparar bokningen och öppnar sedan kostnadsredigeraren.',
|
||||
|
||||
@@ -162,6 +162,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Diğer uçuşlar',
|
||||
'reservations.airtrail.empty': 'AirTrail hesabınızda uçuş bulunamadı.',
|
||||
'reservations.airtrail.importCta': '{count} içe aktar',
|
||||
'reservations.airtrail.joinConnection': '{stops} aktarmalı tek uçuş olarak içe aktar',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -160,6 +160,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Інші рейси',
|
||||
'reservations.airtrail.empty': 'У вашому акаунті AirTrail не знайдено рейсів.',
|
||||
'reservations.airtrail.importCta': 'Імпортувати {count}',
|
||||
'reservations.airtrail.joinConnection': 'Імпортувати як один рейс із пересадкою в {stops}',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -163,6 +163,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': 'Các chuyến bay khác',
|
||||
'reservations.airtrail.empty': 'Không tìm thấy chuyến bay nào trong tài khoản AirTrail của bạn.',
|
||||
'reservations.airtrail.importCta': 'Nhập {count}',
|
||||
'reservations.airtrail.joinConnection': 'Nhập thành một chuyến bay với điểm dừng tại {stops}',
|
||||
'reservations.costsLabel': 'Chi phí',
|
||||
'reservations.createExpense': 'Tạo chi phí',
|
||||
'reservations.createExpenseHint': 'Lưu đặt chỗ, sau đó mở trình chỉnh sửa Chi phí.',
|
||||
|
||||
@@ -159,6 +159,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': '其他航班',
|
||||
'reservations.airtrail.empty': '在你的 AirTrail 帳戶中找不到任何航班。',
|
||||
'reservations.airtrail.importCta': '匯入 {count}',
|
||||
'reservations.airtrail.joinConnection': '匯入為一個航班(在{stops}轉機)',
|
||||
'reservations.costsLabel': 'Costs',
|
||||
'reservations.createExpense': 'Create expense',
|
||||
'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.',
|
||||
|
||||
@@ -159,6 +159,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.airtrail.otherFlights': '其他航班',
|
||||
'reservations.airtrail.empty': '您的 AirTrail 账户中未找到航班。',
|
||||
'reservations.airtrail.importCta': '导入 {count}',
|
||||
'reservations.airtrail.joinConnection': '导入为一个航班(在{stops}中转)',
|
||||
'reservations.costsLabel': '费用',
|
||||
'reservations.createExpense': '创建支出',
|
||||
'reservations.createExpenseHint': '先保存预订,然后打开费用编辑器。',
|
||||
|
||||
Reference in New Issue
Block a user