mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
23b7f6d71c
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
132 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|