Files
TREK/server/tests/unit/nest/llm-parse/booking-total.test.ts
T
Maurice 23b7f6d71c fix(bookings): count a multi-segment rail ticket's fare once
A rail, bus or ferry ticket with changes arrived as one booking per
segment, each with a linked cost for the full fare, so the trip total
was the fare times the number of segments.

Import: the KItinerary mapper now groups consecutive train segments
under one booking reference that connect (same station by name or
within 500 m, no return, within a day) into one train booking with
stops and metadata.legs, the way connecting flights already were. The
connection rule is shared between flights and ground transport, and a
stop carries the onward departure for flights as for trains, as the
planner form and the MCP tools write it. The three copied ground
mappers are folded into one.

Within one such booking a fare repeated on every segment is booked
once, and fares that differ per segment are added in whole cents. That
summing now applies to connecting flights too, which used to keep the
first leg's price only.

Segments of one order that do not join into one booking (an outward
and a return trip, a gap, a change of station named two ways without
coordinates, bus and boat tickets, Rail&Fly) are compared across the
document: when every priced transport booking of a reference carries
the same amount in the same currency, the first to depart keeps it and
the others lose it. Amounts that differ stay. This runs on what
KItinerary extracted only.

AI import: the repeated-total safety net from #2651 now covers train,
bus and ferry tickets and any mix of them with flights, still only
when the document prints that amount on a total line.

MCP and assistant: create_transport says that a ticket with several
segments is one booking with endpoints[] and legs[], and the price
fields say they hold the whole booking's cost. When a new booking
repeats the price an earlier booking with the same confirmation number
already carries in the trip's currency (its own currency, or the
trip's when it names none), create_transport and create_reservation
still save it and return a warning naming the earlier booking. REST is
unchanged.

Existing duplicated costs are left alone: they cannot be told apart
from two tickets that really cost the same, and deleting expenses
automatically would change settled balances. They can be removed in
the Costs tab.

Closes #2895
2026-10-29 20:51:28 +01:00

132 lines
6.2 KiB
TypeScript

import { collapseRepeatedBookingTotal } from '../../../../src/nest/llm-parse/booking-total';
import { describe, expect, it } from 'vitest';
const leg = (
flightNumber: string,
departureTime: string,
price?: unknown,
{ currency = 'USD', ref = 'DEMO77', type = 'FlightReservation' } = {},
): Record<string, unknown> => ({
'@type': type,
reservationNumber: ref,
...(price === undefined ? {} : { price, priceCurrency: currency }),
reservationFor: { flightNumber, departureTime },
});
const prices = (nodes: Record<string, unknown>[]) => nodes.map((n) => n.price ?? null);
const TOTAL = 'Payment summary\nTotal paid: USD 200.00';
describe('collapseRepeatedBookingTotal (#2651)', () => {
it('keeps a total the flights repeat on the first to depart, whatever order the model gave', () => {
const nodes = [leg('EX101', '2031-02-08T07:00', 200), leg('EX100', '2031-02-05T12:00', 200)];
const out = collapseRepeatedBookingTotal(nodes, TOTAL);
expect(prices(out)).toEqual([null, 200]);
expect(out[0]).not.toHaveProperty('priceCurrency');
expect(out[1]).toHaveProperty('priceCurrency', 'USD');
});
it('leaves fares the document states per leg alone, even when they are equal', () => {
const text = 'Fare EX100 USD 200.00\nFare EX101 USD 200.00\nTotal paid: USD 400.00';
const nodes = [leg('EX100', '2031-02-05T12:00', 200), leg('EX101', '2031-02-08T07:00', 200)];
expect(prices(collapseRepeatedBookingTotal(nodes, text))).toEqual([200, 200]);
});
it('leaves a fare printed on every leg alone, even on a line labelled Amount', () => {
const text = 'Flight LH1 Amount 89.00 EUR\nFlight LH2 Amount 89.00 EUR\nTotal 178.00 EUR';
const nodes = [
leg('LH1', '2031-02-05T12:00', 89, { currency: 'EUR' }),
leg('LH2', '2031-02-08T07:00', 89, { currency: 'EUR' }),
];
expect(collapseRepeatedBookingTotal(nodes, text)).toBe(nodes);
});
it('leaves different amounts, references, currencies and mixed booking types alone', () => {
const cases = [
[leg('EX100', '2031-02-05T12:00', 120), leg('EX101', '2031-02-08T07:00', 80)],
[leg('EX100', '2031-02-05T12:00', 200, { ref: 'A1' }), leg('EX101', '2031-02-08T07:00', 200, { ref: 'B2' })],
[leg('EX100', '2031-02-05T12:00', 200), leg('EX101', '2031-02-08T07:00', 200, { currency: 'EUR' })],
[leg('EX100', '2031-02-05T12:00', 200), leg('Stay', '2031-02-05T15:00', 200, { type: 'LodgingReservation' })],
[leg('EX100', '2031-02-05T12:00', 200, { ref: '' }), leg('EX101', '2031-02-08T07:00', 200, { ref: '' })],
];
for (const nodes of cases) expect(collapseRepeatedBookingTotal(nodes, TOTAL)).toBe(nodes);
});
it('does nothing without document text to check, or when the text names no such total', () => {
const nodes = [leg('EX100', '2031-02-05T12:00', 200), leg('EX101', '2031-02-08T07:00', 200)];
expect(collapseRepeatedBookingTotal(nodes, undefined)).toBe(nodes);
expect(collapseRepeatedBookingTotal(nodes, 'Fare conditions apply.')).toBe(nodes);
});
it('compares in cents and reads a printed amount as one number', () => {
const nodes = [leg('EX100', '2031-02-05T12:00', '200.00'), leg('EX101', '2031-02-08T07:00', 200)];
// The date on the same line must not run into the amount.
expect(prices(collapseRepeatedBookingTotal(nodes, '2031-02-05 Total paid USD 200.00'))).toEqual(['200.00', null]);
const euro = [
leg('LH1', '2031-02-05T12:00', 1234.5, { currency: 'EUR' }),
leg('LH2', '2031-02-08T07:00', 1234.5, { currency: 'EUR' }),
];
expect(prices(collapseRepeatedBookingTotal(euro, 'Gesamtbetrag: 1.234,50 EUR'))).toEqual([1234.5, null]);
});
it('leaves an unpriced leg as it is and does not change the nodes it was given', () => {
const nodes = [
leg('EX100', '2031-02-05T12:00', 200),
leg('EX101', '2031-02-08T07:00', 200),
leg('EX102', '2031-02-09T07:00'),
];
const snapshot = JSON.parse(JSON.stringify(nodes));
const out = collapseRepeatedBookingTotal(nodes, TOTAL);
expect(prices(out)).toEqual([200, null, null]);
expect(out[2]).toBe(nodes[2]);
expect(nodes).toEqual(snapshot);
});
it('needs two priced flights to call it a repeat', () => {
const nodes = [leg('EX100', '2031-02-05T12:00', 200), leg('EX101', '2031-02-08T07:00')];
expect(collapseRepeatedBookingTotal(nodes, TOTAL)).toBe(nodes);
expect(collapseRepeatedBookingTotal([nodes[0]], TOTAL)).toEqual([nodes[0]]);
});
});
describe('collapseRepeatedBookingTotal on a ticket with changes (#2895)', () => {
const rail = (train: string, departureTime: string, price?: unknown, type = 'TrainReservation') =>
leg(train, departureTime, price, { currency: 'EUR', ref: '297199016704', type });
const TICKET = 'Ihre Fahrkarte\nGesamtpreis 71,67 EUR';
it('keeps the total a rail ticket repeats on every segment on the first train only', () => {
const nodes = [
rail('S1', '2026-07-04T06:12', 71.67),
rail('ICE 116', '2026-07-04T06:40', 71.67),
rail('S1', '2026-07-04T13:05', 71.67),
rail('STR 7', '2026-07-04T13:41', 71.67),
];
expect(prices(collapseRepeatedBookingTotal(nodes, TICKET))).toEqual([71.67, null, null, null]);
});
it('does the same for a bus or a ferry ticket', () => {
for (const type of ['BusReservation', 'BoatReservation']) {
const nodes = [rail('A', '2026-07-04T06:12', 71.67, type), rail('B', '2026-07-04T09:00', 71.67, type)];
expect(prices(collapseRepeatedBookingTotal(nodes, TICKET))).toEqual([71.67, null]);
}
});
it('books a total once across a ticket that mixes kinds of transport', () => {
const railAndFly = [
rail('LH 1', '2026-07-04T13:05', 71.67, 'FlightReservation'),
rail('ICE 116', '2026-07-04T06:40', 71.67),
];
expect(prices(collapseRepeatedBookingTotal(railAndFly, TICKET))).toEqual([null, 71.67]);
const replacementBus = [
rail('ICE 116', '2026-07-04T06:40', 71.67),
rail('SEV', '2026-07-04T13:05', 71.67, 'BusReservation'),
];
expect(prices(collapseRepeatedBookingTotal(replacementBus, TICKET))).toEqual([71.67, null]);
});
it('leaves fares that differ alone', () => {
const fares = [rail('S1', '2026-07-04T06:12', 10), rail('ICE 116', '2026-07-04T06:40', 61.67)];
expect(collapseRepeatedBookingTotal(fares, TICKET)).toBe(fares);
});
});