@curless/hotel-storefront 0.52.1 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aggregate/hotels.d.ts.map +1 -1
- package/dist/aggregate/hotels.js +156 -37
- package/dist/aggregate/hotels.js.map +1 -1
- package/dist/backend.d.ts +28 -2
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +21 -0
- package/dist/backend.js.map +1 -1
- package/dist/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +41 -12
- package/dist/demo-hotels.js.map +1 -1
- package/dist/huazhu/catalog.d.ts.map +1 -1
- package/dist/huazhu/catalog.js +16 -0
- package/dist/huazhu/catalog.js.map +1 -1
- package/dist/huazhu/drift.d.ts.map +1 -1
- package/dist/huazhu/drift.js +3 -0
- package/dist/huazhu/drift.js.map +1 -1
- package/dist/huazhu/sandbox.d.ts.map +1 -1
- package/dist/huazhu/sandbox.js +6 -1
- package/dist/huazhu/sandbox.js.map +1 -1
- package/dist/huazhu/wire.d.ts +7 -0
- package/dist/huazhu/wire.d.ts.map +1 -1
- package/dist/huazhu/wire.js.map +1 -1
- package/dist/money.d.ts.map +1 -1
- package/dist/money.js +3 -0
- package/dist/money.js.map +1 -1
- package/dist/present.d.ts +14 -2
- package/dist/present.d.ts.map +1 -1
- package/dist/present.js +98 -7
- package/dist/present.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +386 -75
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +30 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, unitWords, } from './backend.js';
|
|
1
|
+
import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, notPricedForNoAnswer, unansweredBy, unitWords, } from './backend.js';
|
|
2
2
|
import { MAX_GUESTS, addDays, isDay, nights as nightsBetween } from './dates.js';
|
|
3
3
|
import { FACILITIES } from './facilities.js';
|
|
4
4
|
import { DESCRIPTION_MAX, POLICY_MAX, hotelName, hotelText } from './fence.js';
|
|
@@ -1288,6 +1288,8 @@ const BOOKING_SCHEMA = {
|
|
|
1288
1288
|
again: OBJ,
|
|
1289
1289
|
/** Of `bookings`, the ids asked for minutes before, when the others are new (`BookingResult.reused`). */
|
|
1290
1290
|
repeated: STRS,
|
|
1291
|
+
/** The coupon asked for was not used: the booking returned was made before, without it (`couponNotUsedOf`). */
|
|
1292
|
+
couponNotUsed: OBJ,
|
|
1291
1293
|
/** `trip`, drawn again: see `stampOf`. */
|
|
1292
1294
|
stamp: STR,
|
|
1293
1295
|
/** The trip changed after the card that sent the form was drawn, so nothing was booked (`stampOf`). */
|
|
@@ -1730,6 +1732,11 @@ const holderOf = (deps, supplier) => {
|
|
|
1730
1732
|
*/
|
|
1731
1733
|
const bookingForCard = (b) => ({
|
|
1732
1734
|
...b,
|
|
1735
|
+
// A booking cancelled before the reason was kept: a coupon not returned could
|
|
1736
|
+
// then only have run out (C10) - said so, as the text says it.
|
|
1737
|
+
...(b.couponReturned === false && !b.couponNotReturnedBecause
|
|
1738
|
+
? { couponNotReturnedBecause: 'expired' }
|
|
1739
|
+
: {}),
|
|
1733
1740
|
quote: quoteForCard(b.quote),
|
|
1734
1741
|
// What a cancelled booking was charged to cancel, as text like every amount the
|
|
1735
1742
|
// card is given: "Cancelled" in My bookings says it beside the struck-out total
|
|
@@ -1908,10 +1915,33 @@ const heldLine = (deps, b) => {
|
|
|
1908
1915
|
: `${b.holdUntil?.slice(0, 16).replace('T', ' ')} UTC`;
|
|
1909
1916
|
// ⚠️ NOT "PRESENTS A CARD AT CHECK-IN" (2026-10-05 audit, P2): a demo
|
|
1910
1917
|
// booking has no check-in, and saying so sent a guest towards a real desk.
|
|
1918
|
+
//
|
|
1919
|
+
// ⚠️ AND NOT A CLOCK TIME (2026-11-03 review, T2). "lapses on its own at Tue 3
|
|
1920
|
+
// Nov, 11:39" was told as 「今天 11:41 自动失效」 - the booking void today, when
|
|
1921
|
+
// the stay is weeks away. A demonstration hold lapses by itself and means
|
|
1922
|
+
// nothing for a real stay; said as how long it lasts, and that it is only the
|
|
1923
|
+
// demonstration.
|
|
1911
1924
|
if (h.demo)
|
|
1912
|
-
return `${total} all in. **${demoBookingWords(h)}** Nothing has been charged and no card is needed.
|
|
1925
|
+
return `${total} all in. **${demoBookingWords(h)}** Nothing has been charged and no card is needed. ${demoHoldWords(b, deps.trips.now())}`;
|
|
1913
1926
|
return `${total} all in. **Nothing has been charged** - the rooms are held until ${lapses}, and the guest presents a card at check-in.`;
|
|
1914
1927
|
};
|
|
1928
|
+
/**
|
|
1929
|
+
* How long a demonstration hold lasts, as "in N minutes" from now - the hold
|
|
1930
|
+
* lapses by itself, and says nothing about the stay (T2, `heldLine`).
|
|
1931
|
+
*/
|
|
1932
|
+
const demoHoldWords = (b, now) => {
|
|
1933
|
+
const minutes = b.holdUntil ? Math.ceil((Date.parse(b.holdUntil) - now) / 60_000) : Number.NaN;
|
|
1934
|
+
if (Number.isNaN(minutes))
|
|
1935
|
+
return '';
|
|
1936
|
+
const lasting = minutes <= 1
|
|
1937
|
+
? 'in a minute'
|
|
1938
|
+
: minutes < 90
|
|
1939
|
+
? `in ${minutes} minutes`
|
|
1940
|
+
: minutes < 48 * 60
|
|
1941
|
+
? `in about ${Math.round(minutes / 60)} hours`
|
|
1942
|
+
: `in about ${Math.round(minutes / (24 * 60))} days`;
|
|
1943
|
+
return `This is a demonstration: it lapses by itself ${lasting} (demo only - no real room is held; the stay's own dates are not affected).`;
|
|
1944
|
+
};
|
|
1915
1945
|
/** What a demonstration booking is, in one sentence - see `demoBookingNote`. */
|
|
1916
1946
|
const demoBookingWords = (h) => `This is a demonstration booking: ${h.name} has not received it and is not holding any room.`;
|
|
1917
1947
|
/**
|
|
@@ -2133,10 +2163,29 @@ const couponTerms = (c, brand) => [
|
|
|
2133
2163
|
? `usable from ${c.validFrom} to ${c.validUntil}`
|
|
2134
2164
|
: `until ${c.validUntil}`,
|
|
2135
2165
|
].join(' · ');
|
|
2136
|
-
/**
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2166
|
+
/**
|
|
2167
|
+
* What cancelling does to the coupon a booking used - and why.
|
|
2168
|
+
*
|
|
2169
|
+
* ⚠️ BACK ONLY FOR A FREE CANCELLATION IN THE COUPON'S DATES (robin,
|
|
2170
|
+
* 2026-11-03, C10). A cancellation that charges a fee counts the coupon as
|
|
2171
|
+
* used: it already took money off a stay that is being paid for. The reason is
|
|
2172
|
+
* said in the words (`fee` / `expired`) and as the same code in the structure
|
|
2173
|
+
* (`notReturnedBecause`), so a host that draws no card has it too. `fee` is the
|
|
2174
|
+
* charge as text, for the sentence.
|
|
2175
|
+
*/
|
|
2176
|
+
const couponBackWords = (c, fee,
|
|
2177
|
+
/** Said after the cancellation, not before it. */
|
|
2178
|
+
done = false) => c.returned
|
|
2179
|
+
? done
|
|
2180
|
+
? `The coupon ${couponName(c)} is back with the member, usable until ${c.validUntil}: the cancellation was free.`
|
|
2181
|
+
: `The coupon ${couponName(c)} it used comes back to the member on cancelling, because the cancellation is free and the coupon is usable until ${c.validUntil}.`
|
|
2182
|
+
: c.notReturnedBecause === 'fee'
|
|
2183
|
+
? done
|
|
2184
|
+
? `The coupon ${couponName(c)} is NOT given back: this cancellation was charged a fee${fee ? ` (${fee})` : ''}, and a cancellation that is charged counts the coupon as used.`
|
|
2185
|
+
: `The coupon ${couponName(c)} it used does NOT come back: cancelling costs a fee${fee ? ` (${fee})` : ''}, and a cancellation that is charged counts the coupon as used.`
|
|
2186
|
+
: done
|
|
2187
|
+
? `The coupon ${couponName(c)} ran out on ${c.validUntil}, so it is not given back.`
|
|
2188
|
+
: `The coupon ${couponName(c)} it used ran out on ${c.validUntil}, so cancelling does not give it back.`;
|
|
2140
2189
|
/**
|
|
2141
2190
|
* Said wherever points are: they are shown and earned, never spent here -
|
|
2142
2191
|
* nobody has published what a point is worth (hotel-connectors.md §5).
|
|
@@ -2174,9 +2223,6 @@ const memberTripLines = (q) => {
|
|
|
2174
2223
|
`${m.programme} ${m.tier} member rate: public rate ${formatMoney(m.publicTotal)} → member rate ${formatMoney(memberTotal)} (${formatMoney(m.saved)} less)${q.coupon
|
|
2175
2224
|
? ` → coupon ${couponName(q.coupon)} −${formatMoney(q.coupon.off)}${stays > 1 ? ` (on the stay at ${q.coupon.hotelName})` : ''}`
|
|
2176
2225
|
: ''} → **${atHotel ? 'to pay at the hotel' : 'to pay'} ${formatMoney(q.total)}**. Earns about ${m.points.toLocaleString('en-GB')} points${q.coupon ? ', on the price after the coupon' : ''}.`,
|
|
2177
|
-
q.couponRefused
|
|
2178
|
-
? `The coupon ${q.couponRefused.name ? couponName({ name: q.couponRefused.name, names: q.couponRefused.names }) : q.couponRefused.id} was NOT taken off: ${q.couponRefused.why} The total is without it.`
|
|
2179
|
-
: '',
|
|
2180
2226
|
...(q.coupons?.length
|
|
2181
2227
|
? [
|
|
2182
2228
|
`The member's coupons for this trip - ${COUPON_RULE}`,
|
|
@@ -2189,7 +2235,26 @@ const memberTripLines = (q) => {
|
|
|
2189
2235
|
: []),
|
|
2190
2236
|
].filter(Boolean);
|
|
2191
2237
|
};
|
|
2238
|
+
/**
|
|
2239
|
+
* The coupon asked for that was not taken off, and why.
|
|
2240
|
+
*
|
|
2241
|
+
* ⚠️ SAID WHENEVER THERE IS ONE, SIGNED IN OR NOT (2026-11-03 review, C2). The
|
|
2242
|
+
* sentence lived in `memberTripLines`, which says nothing without a member - so
|
|
2243
|
+
* a coupon refused because nobody is signed in (`signed-out`) was in
|
|
2244
|
+
* `structuredContent.couponRefused` and not in the words, and a host that
|
|
2245
|
+
* hands its model the text read "Total ¥1,200" and could say it was the price
|
|
2246
|
+
* with the coupon.
|
|
2247
|
+
*/
|
|
2248
|
+
const couponRefusedLine = (q) => q.couponRefused
|
|
2249
|
+
? `The coupon ${q.couponRefused.name ? couponName({ name: q.couponRefused.name, names: q.couponRefused.names }) : q.couponRefused.id} was NOT taken off: ${q.couponRefused.why} The total is without it.`
|
|
2250
|
+
: '';
|
|
2192
2251
|
const tripText = (quote, demo, noCards) => {
|
|
2252
|
+
// ⚠️ NOT "EMPTY" WHEN SOMETHING WAS LEFT OUT: a trip whose only group did not
|
|
2253
|
+
// answer is not an empty trip (2026-11-03 review, C5).
|
|
2254
|
+
if (quote.lines.length === 0 && quote.dropped?.length)
|
|
2255
|
+
return [...quote.dropped.map((d) => `Left out: ${d.title} — ${d.why}`), demo ? DEMO_NOTE : '']
|
|
2256
|
+
.filter(Boolean)
|
|
2257
|
+
.join('\n');
|
|
2193
2258
|
if (quote.lines.length === 0)
|
|
2194
2259
|
return 'The trip is empty.';
|
|
2195
2260
|
const lines = quote.lines.map((l) => l.kind === 'room'
|
|
@@ -2198,6 +2263,7 @@ const tripText = (quote, demo, noCards) => {
|
|
|
2198
2263
|
return [
|
|
2199
2264
|
...lines,
|
|
2200
2265
|
`Taxes and fees ${quote.taxesIncluded ? 'included in the prices' : formatMoney(quote.taxesAndFees)}${quote.coupon ? ` · coupon −${formatMoney(quote.coupon.off)}` : ''} · **Total ${formatMoney(quote.total)}**`,
|
|
2266
|
+
couponRefusedLine(quote),
|
|
2201
2267
|
...memberTripLines(quote),
|
|
2202
2268
|
...(quote.dropped ?? []).map((d) => `Left out: ${d.title} — ${d.why}`),
|
|
2203
2269
|
demo ? DEMO_NOTE : '',
|
|
@@ -2645,7 +2711,9 @@ const bookingLine = (b, noCards) => {
|
|
|
2645
2711
|
? ` · coupon ${couponName(b.quote.coupon)} −${formatMoney(b.quote.coupon.off)}${b.couponReturned === true
|
|
2646
2712
|
? ' (given back)'
|
|
2647
2713
|
: b.couponReturned === false
|
|
2648
|
-
?
|
|
2714
|
+
? b.couponNotReturnedBecause === 'fee'
|
|
2715
|
+
? ' (not given back - the cancellation was charged a fee)'
|
|
2716
|
+
: ' (ran out - not given back)'
|
|
2649
2717
|
: ''}`
|
|
2650
2718
|
: '',
|
|
2651
2719
|
b.payment
|
|
@@ -2851,12 +2919,14 @@ const listAnd = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} and ${xs.
|
|
|
2851
2919
|
* hotel. The sentence says the first and denies the second, in the text and in
|
|
2852
2920
|
* `structuredContent` alike - a host with no card has only the words.
|
|
2853
2921
|
*/
|
|
2854
|
-
const degradedOf = (deps, answer
|
|
2922
|
+
const degradedOf = (deps, answer,
|
|
2923
|
+
/** What is missing from the list: the hotels of a search, the bookings of "my bookings". */
|
|
2924
|
+
noun = deps.vocab.nounPlural) => {
|
|
2855
2925
|
const down = answer.degraded ?? [];
|
|
2856
2926
|
if (down.length === 0)
|
|
2857
2927
|
return { structured: {}, said: '' };
|
|
2858
2928
|
const one = down.length === 1;
|
|
2859
|
-
const said = `${listAnd(down.map((d) => d.name))} did not answer in time, so none of ${one ? 'its' : 'their'} ${
|
|
2929
|
+
const said = `${listAnd(down.map((d) => d.name))} did not answer in time, so none of ${one ? 'its' : 'their'} ${noun} is in this list - this does not mean ${one ? 'it has' : 'they have'} none here; asking again in a moment may bring them.`;
|
|
2860
2930
|
return {
|
|
2861
2931
|
structured: {
|
|
2862
2932
|
degraded: down.map((d) => ({ supplier: d.supplier, name: d.name })),
|
|
@@ -2865,6 +2935,37 @@ const degradedOf = (deps, answer) => {
|
|
|
2865
2935
|
said,
|
|
2866
2936
|
};
|
|
2867
2937
|
};
|
|
2938
|
+
/**
|
|
2939
|
+
* What is said when a read could not be had because hotel groups did not answer
|
|
2940
|
+
* in time - one group, or several, or every one.
|
|
2941
|
+
*
|
|
2942
|
+
* ⚠️ NEVER JUST THE FIRST GROUP, NEVER "NONE HERE" (2026-11-03 review, C5/C8).
|
|
2943
|
+
* Where every group of a market is silent it says so ("no group answered this
|
|
2944
|
+
* time"), not the name of whichever was asked first; and in every case it
|
|
2945
|
+
* denies the reading a guest would make of silence, that there is nothing.
|
|
2946
|
+
*/
|
|
2947
|
+
const notAnswered = (deps, key, down) => {
|
|
2948
|
+
const groups = deps.backend.suppliers?.().length ?? 0;
|
|
2949
|
+
const names = listAnd(down.map((d) => d.name));
|
|
2950
|
+
const one = down.length === 1;
|
|
2951
|
+
const said = groups > 1 && down.length >= groups
|
|
2952
|
+
? `No hotel group answered this time: ${names} did not answer in time - this does not mean there is nothing here; asking again in a moment may bring them.`
|
|
2953
|
+
: `${names} did not answer in time, so nothing from ${one ? 'it' : 'them'} could be read just now - this does not mean ${one ? 'it has' : 'they have'} none here; asking again in a moment may bring ${one ? 'it' : 'them'}.`;
|
|
2954
|
+
return {
|
|
2955
|
+
structured: {
|
|
2956
|
+
view: 'error',
|
|
2957
|
+
...common(deps, key),
|
|
2958
|
+
error: 'supplier-timeout',
|
|
2959
|
+
detail: said,
|
|
2960
|
+
degraded: down.map((d) => ({ supplier: d.supplier, name: d.name })),
|
|
2961
|
+
degradedSaid: said,
|
|
2962
|
+
},
|
|
2963
|
+
text: said,
|
|
2964
|
+
// Like `bad-count`: the card still draws it, and a host that checks a
|
|
2965
|
+
// result against the tool's output schema does not hold an error to it.
|
|
2966
|
+
isError: true,
|
|
2967
|
+
};
|
|
2968
|
+
};
|
|
2868
2969
|
/** The counts a call can carry, and what each may be. */
|
|
2869
2970
|
const COUNTS = ['guests', 'rooms', 'quantity'];
|
|
2870
2971
|
/** "2" is 2: a host that sends a count as a string has not asked for something else. */
|
|
@@ -2916,6 +3017,13 @@ const countProblem = (t, name, args) => {
|
|
|
2916
3017
|
*/
|
|
2917
3018
|
const guestWords = (err, fault = 'That could not be done just now - nothing was changed. Try again in a moment.') => {
|
|
2918
3019
|
const e = err;
|
|
3020
|
+
// ⚠️ A READ THAT TIMED OUT IS NOT A REFUSAL (2026-11-03 review, C5). After a
|
|
3021
|
+
// booking was made, "Atour did not answer within 8000 ms" would be read
|
|
3022
|
+
// back as the booking's own answer - and sound like it was not made.
|
|
3023
|
+
if (unansweredBy(err)) {
|
|
3024
|
+
console.error('[tools] a read timed out:', e.message);
|
|
3025
|
+
return fault;
|
|
3026
|
+
}
|
|
2919
3027
|
if (e instanceof Error && (typeof e.code === 'string' || e.constructor === Error))
|
|
2920
3028
|
return e.message;
|
|
2921
3029
|
console.error('[tools] failed:', err);
|
|
@@ -2969,14 +3077,42 @@ const guarded = async (deps, ctx, who, code, run) => {
|
|
|
2969
3077
|
*/
|
|
2970
3078
|
const quoteParts = async (deps, lines, who, coupon) => {
|
|
2971
3079
|
const byCurrency = new Map();
|
|
3080
|
+
// ⚠️ A HOTEL WHOSE GROUP DID NOT ANSWER IS LEFT OUT OF THE PRICE, SAID (2026-11-03
|
|
3081
|
+
// review, C5): its currency cannot be read, so it cannot be priced with the
|
|
3082
|
+
// rest - and it is not an empty trip, nor a trip without it.
|
|
3083
|
+
const unread = new Map();
|
|
2972
3084
|
for (const l of lines) {
|
|
2973
|
-
|
|
2974
|
-
|
|
3085
|
+
try {
|
|
3086
|
+
const c = (await deps.backend.getHotel(l.hotelId))?.hotel.currency ?? '';
|
|
3087
|
+
byCurrency.set(c, [...(byCurrency.get(c) ?? []), l]);
|
|
3088
|
+
}
|
|
3089
|
+
catch (err) {
|
|
3090
|
+
const down = unansweredBy(err);
|
|
3091
|
+
if (!down)
|
|
3092
|
+
throw err;
|
|
3093
|
+
for (const g of down)
|
|
3094
|
+
unread.set(g.supplier, g);
|
|
3095
|
+
}
|
|
2975
3096
|
}
|
|
2976
3097
|
const asked = coupon ? { coupon } : {};
|
|
3098
|
+
if (byCurrency.size === 0 && unread.size) {
|
|
3099
|
+
throw Object.assign(new Error(`${listAnd([...unread.values()].map((g) => g.name))} did not answer`), { code: 'supplier-timeout', unanswered: [...unread.values()] });
|
|
3100
|
+
}
|
|
2977
3101
|
if (byCurrency.size === 0)
|
|
2978
3102
|
return [await deps.backend.quote({ lines: [], ...asked }, who)];
|
|
2979
|
-
const
|
|
3103
|
+
const priced = await Promise.all([...byCurrency.values()].map((g) => deps.backend.quote({ lines: g, ...asked }, who)));
|
|
3104
|
+
const parts = unread.size && priced[0]
|
|
3105
|
+
? [
|
|
3106
|
+
{
|
|
3107
|
+
...priced[0],
|
|
3108
|
+
dropped: [
|
|
3109
|
+
...(priced[0].dropped ?? []),
|
|
3110
|
+
...[...unread.values()].map(notPricedForNoAnswer),
|
|
3111
|
+
],
|
|
3112
|
+
},
|
|
3113
|
+
...priced.slice(1),
|
|
3114
|
+
]
|
|
3115
|
+
: priced;
|
|
2980
3116
|
// A coupon goes on one part of a trip in two currencies: the others need
|
|
2981
3117
|
// not say it is in the wrong one.
|
|
2982
3118
|
return parts.some((p) => p.coupon) ? parts.map(({ couponRefused: _no, ...p }) => p) : parts;
|
|
@@ -3434,7 +3570,17 @@ export const tripGone = (deps) => {
|
|
|
3434
3570
|
};
|
|
3435
3571
|
export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
3436
3572
|
const { result, written } = await keepingAmounts(async () => {
|
|
3437
|
-
|
|
3573
|
+
// ⚠️ A GROUP THAT DID NOT ANSWER A READ IS AN ANSWER, NOT A FAULT (2026-11-03
|
|
3574
|
+
// review, C5/C8): the hotel page, the add-ons, the dates, a booking looked
|
|
3575
|
+
// up. Thrown by the market past its time limit, it reached the host as "the
|
|
3576
|
+
// connector could not answer that" - or, for a search where every group was
|
|
3577
|
+
// silent, as the first group's name alone. Said here, naming every group.
|
|
3578
|
+
const answer = await answerTool(deps, name, rawArgs, ctx).catch((err) => {
|
|
3579
|
+
const down = unansweredBy(err);
|
|
3580
|
+
if (!down)
|
|
3581
|
+
throw err;
|
|
3582
|
+
return notAnswered(deps, ctx.tripKey ?? tripKey(ctx.session, deps.vocab.brand), down);
|
|
3583
|
+
});
|
|
3438
3584
|
const suggestions = await suggestionsFor(deps, answer.structured).catch(() => []);
|
|
3439
3585
|
return { answer, suggestions };
|
|
3440
3586
|
});
|
|
@@ -3530,7 +3676,13 @@ const listedInWords = (h) => {
|
|
|
3530
3676
|
const price = f
|
|
3531
3677
|
? `${formatMoney(f.total)} for the stay incl. taxes and fees, ${withZh(f.room, f.roomNames, true)}${f.rooms > 1 ? ` × ${f.rooms}` : ''}${terms.length ? ` [${terms.join(', ')}]` : ''}`
|
|
3532
3678
|
: h.noRoomsForStay
|
|
3533
|
-
?
|
|
3679
|
+
? // ⚠️ NOT SOLD HERE IS NOT SOLD OUT (2026-11-03 review, U11): a hotel with no
|
|
3680
|
+
// rooms on sale through this connector was told as "no room for those
|
|
3681
|
+
// nights", and the model said "no vacancy tonight" - as if another night
|
|
3682
|
+
// would do. It says what the hotel's own page says.
|
|
3683
|
+
h.noRoomsForStayCode === 'none-on-sale'
|
|
3684
|
+
? h.noRoomsForStay
|
|
3685
|
+
: 'no room for those nights'
|
|
3534
3686
|
: h.fromNightly
|
|
3535
3687
|
? `from ${formatMoney(h.fromNightly)} ${nightWords(true)}`
|
|
3536
3688
|
: '';
|
|
@@ -3763,6 +3915,57 @@ const LIVE_STATUSES = new Set([
|
|
|
3763
3915
|
'unconfirmed',
|
|
3764
3916
|
'awaiting-payment',
|
|
3765
3917
|
]);
|
|
3918
|
+
/**
|
|
3919
|
+
* A coupon asked for on a booking that was already made, without it - or
|
|
3920
|
+
* nothing.
|
|
3921
|
+
*
|
|
3922
|
+
* ⚠️ NEVER SILENT THAT THE COUPON WAS NOT USED (2026-11-03 review, C3). The
|
|
3923
|
+
* same stay asked for again within minutes is answered with the first
|
|
3924
|
+
* booking (`repeat`); a member who first booked without a coupon and then said
|
|
3925
|
+
* "use the 满500减50" was handed the first booking at its first price, with
|
|
3926
|
+
* nothing said of the coupon they had just agreed to. The coupon is NOT put in
|
|
3927
|
+
* the repeat key - that would book the stay twice - so the answer says it:
|
|
3928
|
+
* the booking stands as it was, the coupon was not spent, and what it takes to
|
|
3929
|
+
* use it is cancelling this one (free until when, or what it costs) and
|
|
3930
|
+
* booking again. In the structure as `couponNotUsed` and in the same words.
|
|
3931
|
+
*/
|
|
3932
|
+
const couponNotUsedOf = (deps, key, asked,
|
|
3933
|
+
/** The bookings that were asked for before and returned as they were. */
|
|
3934
|
+
reusedBookings,
|
|
3935
|
+
/** Every booking in the answer: the coupon may have gone on one made now. */
|
|
3936
|
+
answered) => {
|
|
3937
|
+
const want = asked?.trim().toUpperCase();
|
|
3938
|
+
if (!want || reusedBookings.length === 0)
|
|
3939
|
+
return undefined;
|
|
3940
|
+
if (answered.some((b) => b.quote.coupon?.id.toUpperCase() === want))
|
|
3941
|
+
return undefined;
|
|
3942
|
+
const t = deps.tools;
|
|
3943
|
+
const held = deps.members.read(key)?.coupons?.find((c) => c.id.toUpperCase() === want);
|
|
3944
|
+
const label = held ? couponName(held) : (asked ?? '');
|
|
3945
|
+
const codes = reusedBookings.map((b) => b.confirmationCode).filter(Boolean);
|
|
3946
|
+
const names = codes.length ? codes.join(', ') : 'it';
|
|
3947
|
+
const now = deps.trips.now();
|
|
3948
|
+
// Free to cancel still: every room line has a window that has not closed.
|
|
3949
|
+
const rooms = reusedBookings.flatMap((b) => b.quote.lines.filter((l) => l.kind === 'room'));
|
|
3950
|
+
const open = rooms.filter((l) => l.freeCancellationUntil && Date.parse(l.freeCancellationUntil) > now);
|
|
3951
|
+
const first = open
|
|
3952
|
+
.slice()
|
|
3953
|
+
.sort((a, b) => (a.freeCancellationUntil ?? '').localeCompare(b.freeCancellationUntil ?? ''))[0];
|
|
3954
|
+
const freeUntil = rooms.length > 0 && open.length === rooms.length && first ? freeUntilText(first) : undefined;
|
|
3955
|
+
const total = reusedBookings.map((b) => formatMoney(b.quote.total)).join(' + ');
|
|
3956
|
+
const said = `THE COUPON WAS NOT USED: ${reusedBookings.length > 1 ? 'these bookings were' : 'this booking was'} made a few minutes ago without the coupon ${label}, and a booking already made is not changed to take one - ${reusedBookings.length > 1 ? 'they stand' : 'it stands'} at ${total} all in, and the coupon is still the member's. To use it, cancel ${names} (${freeUntil ? `free to cancel until ${freeUntil}` : `${t.previewCancellation} says what cancelling costs`}) and book again with the coupon - only if the guest wants that.`;
|
|
3957
|
+
return {
|
|
3958
|
+
structured: {
|
|
3959
|
+
id: asked,
|
|
3960
|
+
...(held ? { name: held.name, ...(held.names ? { names: held.names } : {}) } : {}),
|
|
3961
|
+
bookings: codes,
|
|
3962
|
+
cancelFree: !!freeUntil,
|
|
3963
|
+
...(freeUntil ? { freeUntil } : {}),
|
|
3964
|
+
said,
|
|
3965
|
+
},
|
|
3966
|
+
said,
|
|
3967
|
+
};
|
|
3968
|
+
};
|
|
3766
3969
|
/**
|
|
3767
3970
|
* The booking this very form made a few minutes ago, when the slot is empty
|
|
3768
3971
|
* because of it - or undefined.
|
|
@@ -3803,6 +4006,7 @@ const bookedFromThisForm = async (deps, ctx, key, args) => {
|
|
|
3803
4006
|
x.quote.lines.some((l) => l.kind === 'room' && l.roomRef === room && (l.quantity ?? 1) === rooms));
|
|
3804
4007
|
if (!b)
|
|
3805
4008
|
return undefined;
|
|
4009
|
+
const notUsed = couponNotUsedOf(deps, key, couponOf(deps, args), [b], [b]);
|
|
3806
4010
|
return {
|
|
3807
4011
|
structured: {
|
|
3808
4012
|
view: 'booking',
|
|
@@ -3812,9 +4016,11 @@ const bookedFromThisForm = async (deps, ctx, key, args) => {
|
|
|
3812
4016
|
holdUntil: b.holdUntil,
|
|
3813
4017
|
total: formatMoney(b.quote.total),
|
|
3814
4018
|
repeat: true,
|
|
4019
|
+
...(notUsed ? { couponNotUsed: notUsed.structured } : {}),
|
|
3815
4020
|
},
|
|
3816
4021
|
text: [
|
|
3817
4022
|
'NOTHING NEW WAS BOOKED: this booking form already booked this room a few minutes ago, and that answer did not reach the card - this is the booking made then.',
|
|
4023
|
+
notUsed?.said ?? '',
|
|
3818
4024
|
`**${confirming(deps, b) ? 'Being confirmed' : b.confirmationCode}** · ${b.hotelName} · ${stayWords(b.stay, b.hourly)}`,
|
|
3819
4025
|
bookedLine(deps, b) ?? '',
|
|
3820
4026
|
deps.vocab.demo ? noteFor(deps.vocab) : '',
|
|
@@ -3869,6 +4075,47 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
|
|
|
3869
4075
|
return currentTrip(deps, key, who, store, couponOf(deps, args), ctx);
|
|
3870
4076
|
}
|
|
3871
4077
|
}
|
|
4078
|
+
// ⚠️ LISTED, NOT BOOKED HERE (robin, 2026-10-30): a rate paid in advance
|
|
4079
|
+
// where this deployment takes no payment goes in no trip and no pick. The
|
|
4080
|
+
// press on its Reserve - or the assistant asking for it - is answered with
|
|
4081
|
+
// what is true and what is left: nothing booked, nothing charged; the rates
|
|
4082
|
+
// here paid at the hotel, a press each; other dates; or the group itself.
|
|
4083
|
+
// Never a review that fails at Book, after the guest typed their name.
|
|
4084
|
+
// ONE ANSWER FOR A ROOM BY THE NIGHT AND A ROOM BY THE HOUR (2026-11-03
|
|
4085
|
+
// review, C6): the hourly branch put such a room in the trip before this was
|
|
4086
|
+
// asked, and the guest met the refusal at "Book".
|
|
4087
|
+
const prepaidNotHere = (room, open, stay, when) => {
|
|
4088
|
+
const group = groupNameOf(deps, hotelId);
|
|
4089
|
+
const said = [
|
|
4090
|
+
`Nothing was booked or charged: ${room.name} is paid in advance, and a stay paid in advance cannot be booked here yet - this connector books rates paid at the hotel.`,
|
|
4091
|
+
open.length
|
|
4092
|
+
? `Paid at the hotel at ${detail.hotel.name} ${when}: ${open.map((r) => `${r.name} (\`${r.id}\`)`).join(', ')}.`
|
|
4093
|
+
: `No rate at ${detail.hotel.name} ${when} is paid at the hotel.`,
|
|
4094
|
+
`Other dates, or booking this rate with ${group} directly, are the other ways on.`,
|
|
4095
|
+
].join(' ');
|
|
4096
|
+
return {
|
|
4097
|
+
structured: {
|
|
4098
|
+
view: 'error',
|
|
4099
|
+
...common(deps, key),
|
|
4100
|
+
error: 'prepaid-not-here',
|
|
4101
|
+
room: { id: room.id, name: room.name, ...(room.names ? { names: room.names } : {}) },
|
|
4102
|
+
rooms: open.map((r) => ({
|
|
4103
|
+
id: r.id,
|
|
4104
|
+
name: r.name,
|
|
4105
|
+
...(r.names ? { names: r.names } : {}),
|
|
4106
|
+
})),
|
|
4107
|
+
hotel: {
|
|
4108
|
+
id: detail.hotel.id,
|
|
4109
|
+
name: detail.hotel.name,
|
|
4110
|
+
...(detail.hotel.names ? { names: detail.hotel.names } : {}),
|
|
4111
|
+
},
|
|
4112
|
+
stay,
|
|
4113
|
+
group,
|
|
4114
|
+
said,
|
|
4115
|
+
},
|
|
4116
|
+
text: said,
|
|
4117
|
+
};
|
|
4118
|
+
};
|
|
3872
4119
|
const today = (startTime || !asStay(args)
|
|
3873
4120
|
? deps.backend.hourlyRooms?.(hotelId, guestsOf(args), who)
|
|
3874
4121
|
: undefined);
|
|
@@ -3897,6 +4144,15 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
|
|
|
3897
4144
|
: ((await today)?.noRooms ?? `${detail.hotel.name} has no rooms by the hour today.`),
|
|
3898
4145
|
};
|
|
3899
4146
|
}
|
|
4147
|
+
if (hourlyRoom.notSoldHere) {
|
|
4148
|
+
const wantedHere = Math.max(1, clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE));
|
|
4149
|
+
const open = ((await today)?.rooms ?? []).filter((r) => r.hourly && !r.notSoldHere && r.roomsLeft >= wantedHere);
|
|
4150
|
+
return prepaidNotHere(hourlyRoom, open, {
|
|
4151
|
+
checkIn: hourlyRoom.hourly.date,
|
|
4152
|
+
checkOut: hourlyRoom.hourly.date,
|
|
4153
|
+
guests: guestsOf(args),
|
|
4154
|
+
}, 'by the hour today');
|
|
4155
|
+
}
|
|
3900
4156
|
const bad = startProblem(hourlyRoom.hourly, startTime);
|
|
3901
4157
|
if (bad) {
|
|
3902
4158
|
return {
|
|
@@ -4026,44 +4282,10 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
|
|
|
4026
4282
|
text: `${type ? `${detail.hotel.name} has no ${type.name} that sleeps ${stay.guests} and has ${wanted === 1 ? 'a room' : `${wanted} rooms`} left for those nights.` : `${detail.hotel.name} has no room type by that name for those nights.`} It has: ${dated.rooms.map((r) => `${r.name} (${han && r.names?.zh ? `${r.names.zh}, ` : ''}\`${r.id}\`)`).join(', ')}. Call again with the id.`,
|
|
4027
4283
|
};
|
|
4028
4284
|
}
|
|
4029
|
-
//
|
|
4030
|
-
// where this deployment takes no payment goes in no trip and no pick. The
|
|
4031
|
-
// press on its Reserve - or the assistant asking for it - is answered with
|
|
4032
|
-
// what is true and what is left: nothing booked, nothing charged; the rates
|
|
4033
|
-
// here paid at the hotel, a press each; other dates; or the group itself.
|
|
4034
|
-
// Never a review that fails at Book, after the guest typed their name.
|
|
4285
|
+
// Listed, not booked here - see `prepaidNotHere`.
|
|
4035
4286
|
if (room.notSoldHere) {
|
|
4036
4287
|
const open = dated.rooms.filter((r) => !r.notSoldHere && r.sleeps >= stay.guests && r.roomsLeft >= wanted);
|
|
4037
|
-
|
|
4038
|
-
const said = [
|
|
4039
|
-
`Nothing was booked or charged: ${room.name} is paid in advance, and a stay paid in advance cannot be booked here yet - this connector books rates paid at the hotel.`,
|
|
4040
|
-
open.length
|
|
4041
|
-
? `Paid at the hotel at ${detail.hotel.name} for those nights: ${open.map((r) => `${r.name} (\`${r.id}\`)`).join(', ')}.`
|
|
4042
|
-
: `No rate at ${detail.hotel.name} for those nights is paid at the hotel.`,
|
|
4043
|
-
`Other dates, or booking this rate with ${group} directly, are the other ways on.`,
|
|
4044
|
-
].join(' ');
|
|
4045
|
-
return {
|
|
4046
|
-
structured: {
|
|
4047
|
-
view: 'error',
|
|
4048
|
-
...common(deps, key),
|
|
4049
|
-
error: 'prepaid-not-here',
|
|
4050
|
-
room: { id: room.id, name: room.name, ...(room.names ? { names: room.names } : {}) },
|
|
4051
|
-
rooms: open.map((r) => ({
|
|
4052
|
-
id: r.id,
|
|
4053
|
-
name: r.name,
|
|
4054
|
-
...(r.names ? { names: r.names } : {}),
|
|
4055
|
-
})),
|
|
4056
|
-
hotel: {
|
|
4057
|
-
id: detail.hotel.id,
|
|
4058
|
-
name: detail.hotel.name,
|
|
4059
|
-
...(detail.hotel.names ? { names: detail.hotel.names } : {}),
|
|
4060
|
-
},
|
|
4061
|
-
stay,
|
|
4062
|
-
group,
|
|
4063
|
-
said,
|
|
4064
|
-
},
|
|
4065
|
-
text: said,
|
|
4066
|
-
};
|
|
4288
|
+
return prepaidNotHere(room, open, stay, 'for those nights');
|
|
4067
4289
|
}
|
|
4068
4290
|
deps.trips.set(store, {
|
|
4069
4291
|
kind: 'room',
|
|
@@ -4103,7 +4325,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4103
4325
|
// the trip as it is now (no stamp); a card's form books the one it showed.
|
|
4104
4326
|
const drawn = str(args.stamp);
|
|
4105
4327
|
if (drawn && drawn !== stampOf(inTrip, deps.trips.shown(store))) {
|
|
4106
|
-
const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
4328
|
+
const again = await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
|
|
4107
4329
|
return {
|
|
4108
4330
|
...again,
|
|
4109
4331
|
structured: { ...again.structured, tripChanged: true },
|
|
@@ -4196,7 +4418,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4196
4418
|
// booking is held to (ShownQuote).
|
|
4197
4419
|
const rest = lines.length === inTrip.length && !failed.length
|
|
4198
4420
|
? undefined
|
|
4199
|
-
: await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
4421
|
+
: await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
|
|
4200
4422
|
const others = rest && lines.length !== inTrip.length;
|
|
4201
4423
|
// What could be booked instead of each, found now (robin, 2026-10-14).
|
|
4202
4424
|
const instead = await Promise.all(failed.map((f, i) => i < MAX_FAILED_WITH_ALTERNATIVES
|
|
@@ -4243,7 +4465,9 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4243
4465
|
// at 15:00, and a generic "check-in is in the afternoon" is not an answer.
|
|
4244
4466
|
// Per booking: each is its own hotel, with its own hour and its own desk.
|
|
4245
4467
|
const booked = await Promise.all(made.map(async (booking, i) => {
|
|
4246
|
-
|
|
4468
|
+
// ⚠️ THE BOOKING IS MADE: a read after it that fails (a group slow to
|
|
4469
|
+
// answer) loses the check-in hour, never the answer.
|
|
4470
|
+
const hotel = await deps.backend.getHotel(booking.hotelId).catch(() => null);
|
|
4247
4471
|
return {
|
|
4248
4472
|
booking,
|
|
4249
4473
|
// The requests that could not both be asked for are said once.
|
|
@@ -4254,6 +4478,10 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4254
4478
|
const several = booked.length > 1;
|
|
4255
4479
|
// The stays asked for minutes before, beside new ones - see `reused`.
|
|
4256
4480
|
const earlier = booked.filter(({ booking }) => reused?.includes(booking.id));
|
|
4481
|
+
// The coupon asked for, when the stays asked for before did not carry it
|
|
4482
|
+
// and nothing new did either - see `couponNotUsedOf`.
|
|
4483
|
+
const askedCoupon = couponOf(deps, args);
|
|
4484
|
+
const notUsed = couponNotUsedOf(deps, key, askedCoupon, repeat ? made : earlier.map(({ booking }) => booking), made);
|
|
4257
4485
|
const waiting = booked.filter(({ booking }) => confirming(deps, booking)).length;
|
|
4258
4486
|
const notBooked = failed.map((f, i) => ({
|
|
4259
4487
|
hotelId: f.hotelId,
|
|
@@ -4348,6 +4576,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4348
4576
|
...(notBooked.length ? { failed: notBooked } : {}),
|
|
4349
4577
|
...(repeat ? { repeat: true } : {}),
|
|
4350
4578
|
...(earlier.length ? { repeated: earlier.map(({ booking }) => booking.id) } : {}),
|
|
4579
|
+
...(notUsed ? { couponNotUsed: notUsed.structured } : {}),
|
|
4351
4580
|
},
|
|
4352
4581
|
text: [
|
|
4353
4582
|
// ⚠️ THE SAME BOOKING ASKED FOR AGAIN IS SAID TO BE THAT, never a new
|
|
@@ -4357,6 +4586,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4357
4586
|
: earlier.length
|
|
4358
4587
|
? `NOT ALL NEW: ${listAnd(earlier.map(({ booking }) => booking.confirmationCode || booking.hotelName))} ${earlier.length > 1 ? 'were' : 'was'} asked for a few minutes ago for the same guest and ${earlier.length > 1 ? 'are the bookings' : 'is the booking'} made then; the rest were booked now.`
|
|
4359
4588
|
: '',
|
|
4589
|
+
notUsed?.said ?? '',
|
|
4360
4590
|
// ⚠️ BOOKED IS CONFIRMED: a stay still being confirmed is said as
|
|
4361
4591
|
// that, and not counted (总线程, 2026-10-14).
|
|
4362
4592
|
notBooked.length || (booked.length > 1 && waiting)
|
|
@@ -4386,13 +4616,16 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4386
4616
|
// and the trip is shown again at today's - which is then what the guest
|
|
4387
4617
|
// has seen, and what a second ask books.
|
|
4388
4618
|
if (err.code === 'price-changed') {
|
|
4389
|
-
const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
4619
|
+
const again = await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
|
|
4390
4620
|
return { ...again, text: [words, again.text].join('\n\n') };
|
|
4391
4621
|
}
|
|
4392
4622
|
// The coupon asked for cannot be used: nothing was booked, and the trip
|
|
4393
4623
|
// is shown again with it refused and why - the guest decides what next.
|
|
4394
4624
|
if (err.code === 'coupon') {
|
|
4395
|
-
|
|
4625
|
+
// ⚠️ WITH THE CONTEXT, like every other answer of the trip (2026-11-03
|
|
4626
|
+
// review, T1): without it the restated trip lost the Chinese room and
|
|
4627
|
+
// hotel names a host with no card is given beside the plain ones.
|
|
4628
|
+
const again = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args), ctx);
|
|
4396
4629
|
return {
|
|
4397
4630
|
...again,
|
|
4398
4631
|
structured: { ...again.structured, notBooked: 'coupon' },
|
|
@@ -4400,7 +4633,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4400
4633
|
};
|
|
4401
4634
|
}
|
|
4402
4635
|
if (err.code === 'mixed-supplier') {
|
|
4403
|
-
const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
4636
|
+
const again = await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
|
|
4404
4637
|
return {
|
|
4405
4638
|
...again,
|
|
4406
4639
|
text: [
|
|
@@ -4500,7 +4733,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4500
4733
|
// FAILED either, for the same reason as the ceiling above: the place
|
|
4501
4734
|
// matched. What matched the place and the words, before either:
|
|
4502
4735
|
const placedCount = (a) => a.beforeFilters ?? a.beforeCeiling ?? a.total;
|
|
4503
|
-
|
|
4736
|
+
// ⚠️ AND NOT WHEN A GROUP DID NOT ANSWER (2026-11-03 review, C4). The place
|
|
4737
|
+
// "matched nothing" among the groups that answered; the one that did not
|
|
4738
|
+
// may have it. A wider search then found something else, took over as the
|
|
4739
|
+
// answer, and the first search's "X did not answer" went with it - "nothing
|
|
4740
|
+
// matched Hong Kong, so this is Shenzhen", with Hong Kong's group silent.
|
|
4741
|
+
// It also cost another time limit. The empty answer is given as it is, with
|
|
4742
|
+
// the sentence that the group did not answer (`degradedOf`).
|
|
4743
|
+
if (placedCount(answer) === 0 && !answer.degraded?.length) {
|
|
4504
4744
|
const wider = widen(ask);
|
|
4505
4745
|
if (wider) {
|
|
4506
4746
|
const second = await deps.backend.searchHotels(wider.ask, who);
|
|
@@ -4666,8 +4906,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4666
4906
|
guests: guestsOf(args),
|
|
4667
4907
|
};
|
|
4668
4908
|
const priced = await answerTool(deps, t.listHotels, { ...args, ...tonight }, ctx);
|
|
4909
|
+
// ⚠️ THE ROOMS ASKED FOR, not "one room" (2026-11-03 review, C12): `rooms: 2`
|
|
4910
|
+
// rides along into the priced list, and every price in it is two rooms'.
|
|
4911
|
+
const roomCount = ask.rooms ?? 1;
|
|
4669
4912
|
// In both, in the same words: the model reads the structure.
|
|
4670
|
-
const stayDefault = `No dates were asked for, so this opens on tonight by default: ${tonight.checkIn} to ${tonight.checkOut} (one night, on the hotels' own date), ${tonight.guests} ${tonight.guests === 1 ? 'guest' : 'guests'}, one room - every price here is for that night. Say so: the prices shown are tonight's, and the guest can ${ctx.noCards ? 'name other dates' : 'change the dates on the card or name others'}. Do not ask for dates before showing them.`;
|
|
4913
|
+
const stayDefault = `No dates were asked for, so this opens on tonight by default: ${tonight.checkIn} to ${tonight.checkOut} (one night, on the hotels' own date), ${tonight.guests} ${tonight.guests === 1 ? 'guest' : 'guests'}, ${roomCount === 1 ? 'one room' : `${roomCount} rooms`} - every price here is for that night. Say so: the prices shown are tonight's, and the guest can ${ctx.noCards ? 'name other dates' : 'change the dates on the card or name others'}. Do not ask for dates before showing them.`;
|
|
4671
4914
|
// Right after what is here - "every one, and no others" stays the first thing said.
|
|
4672
4915
|
const inAll = String(priced.structured.inAll ?? '');
|
|
4673
4916
|
return {
|
|
@@ -4910,8 +5153,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4910
5153
|
ask.filters?.hourly && hotels.some((h) => h.forStay?.hours)
|
|
4911
5154
|
? `By the hour, today: each price is that ${deps.vocab.noun}'s cheapest room by the hour, for its hours, taxes included.`
|
|
4912
5155
|
: ask.stay
|
|
4913
|
-
? `Priced for ${ask.stay.checkIn} to ${ask.stay.checkOut}${ask.rooms && ask.rooms > 1 ? `, ${ask.rooms} rooms` : ''}: each price is that ${deps.vocab.noun}'s cheapest room for the whole stay, taxes included${hotels.some((h) => h.noRoomsForStay)
|
|
4914
|
-
? `; ${hotels.filter((h) => h.noRoomsForStay).length} in this answer have no room for those nights`
|
|
5156
|
+
? `Priced for ${ask.stay.checkIn} to ${ask.stay.checkOut}${ask.rooms && ask.rooms > 1 ? `, ${ask.rooms} rooms` : ''}: each price is that ${deps.vocab.noun}'s cheapest room for the whole stay, taxes included${hotels.some((h) => h.noRoomsForStay && h.noRoomsForStayCode !== 'none-on-sale')
|
|
5157
|
+
? `; ${hotels.filter((h) => h.noRoomsForStay && h.noRoomsForStayCode !== 'none-on-sale').length} in this answer have no room for those nights`
|
|
5158
|
+
: ''}${
|
|
5159
|
+
// The other kind, said as its own (U11): other nights will not help.
|
|
5160
|
+
hotels.some((h) => h.noRoomsForStayCode === 'none-on-sale')
|
|
5161
|
+
? `; ${hotels.filter((h) => h.noRoomsForStayCode === 'none-on-sale').length} in this answer ${hotels.filter((h) => h.noRoomsForStayCode === 'none-on-sale').length === 1 ? 'has' : 'have'} no rooms on sale through this connector - not sold out, and other nights make no difference`
|
|
4915
5162
|
: ''}.`
|
|
4916
5163
|
: '',
|
|
4917
5164
|
].filter(Boolean);
|
|
@@ -5445,7 +5692,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5445
5692
|
...(isDay(str(args.day)) ? { day: str(args.day) } : {}),
|
|
5446
5693
|
};
|
|
5447
5694
|
deps.trips.set(key, line);
|
|
5448
|
-
|
|
5695
|
+
// ⚠️ THE COUPON STAYS ON, like on a room (2026-11-03 review, C11): its
|
|
5696
|
+
// schema says it keeps the new price, and `undefined` here dropped it.
|
|
5697
|
+
return currentTrip(deps, key, identity(deps, ctx, key), key, couponOf(deps, args), ctx);
|
|
5449
5698
|
}
|
|
5450
5699
|
return putRoomLine(deps, t, ctx, key, key, args, detail);
|
|
5451
5700
|
}
|
|
@@ -5492,6 +5741,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5492
5741
|
if (name === t.listBookings) {
|
|
5493
5742
|
const who = identity(deps, ctx, key, args);
|
|
5494
5743
|
const here = await deps.backend.listBookings(who, typeof args.limit === 'number' ? args.limit : undefined);
|
|
5744
|
+
// Read here, before a filter or a spread drops it: the groups whose
|
|
5745
|
+
// bookings are not in `here` (2026-11-03 review, C5).
|
|
5746
|
+
const missing = degradedOf(deps, { degraded: here.degraded }, 'bookings');
|
|
5495
5747
|
// Elsewhere, only with the code AND the email - see `listBookings`.
|
|
5496
5748
|
const code = str(args.booking);
|
|
5497
5749
|
const { found: there, tooMany } = code && str(args.email)
|
|
@@ -5517,6 +5769,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5517
5769
|
// than "nothing in this conversation".
|
|
5518
5770
|
...(missed ? { notFound: code, notFoundLine: missLine } : {}),
|
|
5519
5771
|
...(tooMany ? { tooMany: true } : {}),
|
|
5772
|
+
...missing.structured,
|
|
5520
5773
|
},
|
|
5521
5774
|
text: [
|
|
5522
5775
|
missed ? missLine : '',
|
|
@@ -5524,7 +5777,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5524
5777
|
? bookings.map((b) => bookingLine(b, ctx.noCards)).join('\n')
|
|
5525
5778
|
: code
|
|
5526
5779
|
? ''
|
|
5527
|
-
:
|
|
5780
|
+
: // ⚠️ NOT "NO BOOKINGS" when a group did not answer: it may hold them.
|
|
5781
|
+
missing.said
|
|
5782
|
+
? ''
|
|
5783
|
+
: 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
|
|
5784
|
+
missing.said,
|
|
5528
5785
|
]
|
|
5529
5786
|
.filter(Boolean)
|
|
5530
5787
|
.join('\n'),
|
|
@@ -5881,13 +6138,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5881
6138
|
toolToConfirm: t.cancelBooking,
|
|
5882
6139
|
// Whether cancelling hands the coupon back - the same sentence as the text.
|
|
5883
6140
|
...(preview.coupon
|
|
5884
|
-
? {
|
|
6141
|
+
? {
|
|
6142
|
+
coupon: {
|
|
6143
|
+
...preview.coupon,
|
|
6144
|
+
said: couponBackWords(preview.coupon, formatMoney(preview.fee)),
|
|
6145
|
+
},
|
|
6146
|
+
}
|
|
5885
6147
|
: {}),
|
|
5886
6148
|
},
|
|
5887
6149
|
text: [
|
|
5888
6150
|
`Cancelling ${preview.confirmationCode} would cost ${formatMoney(preview.fee)}${preview.fee.minor === 0 ? ' - nothing' : ''}.`,
|
|
5889
6151
|
preview.rule,
|
|
5890
|
-
preview.coupon ? couponBackWords(preview.coupon) : '',
|
|
6152
|
+
preview.coupon ? couponBackWords(preview.coupon, formatMoney(preview.fee)) : '',
|
|
5891
6153
|
preview.cancellable
|
|
5892
6154
|
? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
|
|
5893
6155
|
: '',
|
|
@@ -5930,6 +6192,28 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5930
6192
|
// will be charged.
|
|
5931
6193
|
const charged = cancelled.cancellationFee ?? preview.fee;
|
|
5932
6194
|
const h = holderOf(deps, cancelled.supplier);
|
|
6195
|
+
// What became of the coupon the booking used, said once and handed over in
|
|
6196
|
+
// both: the structure's `coupon` and the text's sentence are this one string.
|
|
6197
|
+
const couponNow = cancelled.quote.coupon && cancelled.couponReturned !== undefined
|
|
6198
|
+
? (() => {
|
|
6199
|
+
const returned = cancelled.couponReturned === true;
|
|
6200
|
+
const c = {
|
|
6201
|
+
id: cancelled.quote.coupon.id,
|
|
6202
|
+
name: cancelled.quote.coupon.name,
|
|
6203
|
+
...(cancelled.quote.coupon.names ? { names: cancelled.quote.coupon.names } : {}),
|
|
6204
|
+
validUntil: cancelled.quote.coupon.validUntil,
|
|
6205
|
+
returned,
|
|
6206
|
+
// A booking cancelled before the reason was kept: a coupon not
|
|
6207
|
+
// returned could then only have run out.
|
|
6208
|
+
...(returned
|
|
6209
|
+
? {}
|
|
6210
|
+
: {
|
|
6211
|
+
notReturnedBecause: cancelled.couponNotReturnedBecause ?? 'expired',
|
|
6212
|
+
}),
|
|
6213
|
+
};
|
|
6214
|
+
return { ...c, said: couponBackWords(c, formatMoney(charged), true) };
|
|
6215
|
+
})()
|
|
6216
|
+
: undefined;
|
|
5933
6217
|
return {
|
|
5934
6218
|
structured: {
|
|
5935
6219
|
view: 'cancelled',
|
|
@@ -5940,6 +6224,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5940
6224
|
fee: formatMoney(charged),
|
|
5941
6225
|
rule: preview.rule,
|
|
5942
6226
|
cancellable: false,
|
|
6227
|
+
...(couponNow ? { coupon: couponNow } : {}),
|
|
5943
6228
|
},
|
|
5944
6229
|
text: [
|
|
5945
6230
|
cancelled.payment
|
|
@@ -5954,11 +6239,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5954
6239
|
? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
|
|
5955
6240
|
`${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this is a demonstration, and nothing is charged.`}`
|
|
5956
6241
|
: `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
|
|
5957
|
-
|
|
5958
|
-
? cancelled.couponReturned
|
|
5959
|
-
? `The coupon ${couponName(cancelled.quote.coupon)} is back with the member, usable until ${cancelled.quote.coupon.validUntil}.`
|
|
5960
|
-
: `The coupon ${couponName(cancelled.quote.coupon)} ran out on ${cancelled.quote.coupon.validUntil}, so it is not given back.`
|
|
5961
|
-
: '',
|
|
6242
|
+
couponNow?.said ?? '',
|
|
5962
6243
|
demoBookingNote(deps, cancelled.supplier),
|
|
5963
6244
|
deps.vocab.demo ? noteFor(deps.vocab) : '',
|
|
5964
6245
|
]
|
|
@@ -5982,7 +6263,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5982
6263
|
who: identity(deps, ctx, key),
|
|
5983
6264
|
});
|
|
5984
6265
|
if (comparison.columns.length === 0) {
|
|
5985
|
-
|
|
6266
|
+
// A group that did not answer is not "not ours" (2026-11-03 review, C5):
|
|
6267
|
+
// its hotels are said as left out for that.
|
|
6268
|
+
const silent = comparison.dropped.filter((d) => d.code === 'supplier-timeout');
|
|
6269
|
+
const unknown = comparison.dropped.filter((d) => !d.candidates?.length && d.code !== 'supplier-timeout');
|
|
5986
6270
|
const fitsMany = fitsManyOf(deps, comparison.dropped);
|
|
5987
6271
|
return {
|
|
5988
6272
|
structured: {
|
|
@@ -5992,12 +6276,13 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5992
6276
|
},
|
|
5993
6277
|
// A name that fits several IS one of ours - only the rest are "none".
|
|
5994
6278
|
text: [
|
|
5995
|
-
...(unknown.length || !fitsMany.length
|
|
6279
|
+
...(unknown.length || (!fitsMany.length && !silent.length)
|
|
5996
6280
|
? [
|
|
5997
6281
|
`None of those are ${catalogue(deps.vocab)}: ${unknown.map((d) => d.name).join(', ')}.`,
|
|
5998
6282
|
]
|
|
5999
6283
|
: []),
|
|
6000
6284
|
...fitsMany,
|
|
6285
|
+
...silent.map((d) => `Left out: ${d.name} — ${d.why}`),
|
|
6001
6286
|
].join('\n'),
|
|
6002
6287
|
};
|
|
6003
6288
|
}
|
|
@@ -6048,6 +6333,25 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
6048
6333
|
}
|
|
6049
6334
|
if (name === t.itinerary) {
|
|
6050
6335
|
const days = Array.isArray(args.days) ? args.days : [];
|
|
6336
|
+
// ⚠️ WHICH STAYS ARE BOOKED ALREADY marks the plan; a group that did not
|
|
6337
|
+
// answer cannot be checked, and the plan says so rather than showing its
|
|
6338
|
+
// stays as not booked (2026-11-03 review, C5).
|
|
6339
|
+
let planBookings = [];
|
|
6340
|
+
let unchecked = [];
|
|
6341
|
+
try {
|
|
6342
|
+
const mine = await deps.backend.listBookings(identity(deps, ctx, key, args), 50);
|
|
6343
|
+
planBookings = mine;
|
|
6344
|
+
unchecked = mine.degraded ?? [];
|
|
6345
|
+
}
|
|
6346
|
+
catch (err) {
|
|
6347
|
+
const down = unansweredBy(err);
|
|
6348
|
+
if (!down)
|
|
6349
|
+
throw err;
|
|
6350
|
+
unchecked = down;
|
|
6351
|
+
}
|
|
6352
|
+
const uncheckedSaid = unchecked.length
|
|
6353
|
+
? `${listAnd(unchecked.map((d) => d.name))} did not answer in time, so ${unchecked.length === 1 ? 'its' : 'their'} bookings were not checked against this plan - a stay there may already be booked without the plan saying so; asking again in a moment may check them.`
|
|
6354
|
+
: '';
|
|
6051
6355
|
const plan = await buildItinerary(deps.backend, days.map((d) => ({
|
|
6052
6356
|
label: str(d.label),
|
|
6053
6357
|
note: typeof d.note === 'string' ? d.note : undefined,
|
|
@@ -6058,7 +6362,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
6058
6362
|
title: str(args.title),
|
|
6059
6363
|
stay: asStay(args),
|
|
6060
6364
|
trip: deps.trips.read(key),
|
|
6061
|
-
bookings:
|
|
6365
|
+
bookings: planBookings,
|
|
6062
6366
|
who: identity(deps, ctx, key),
|
|
6063
6367
|
});
|
|
6064
6368
|
if (plan.days.length === 0) {
|
|
@@ -6120,6 +6424,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
6120
6424
|
gap,
|
|
6121
6425
|
...(nudges.length ? { nudges } : {}),
|
|
6122
6426
|
...(overlaps.length ? { overlaps } : {}),
|
|
6427
|
+
...(uncheckedSaid
|
|
6428
|
+
? {
|
|
6429
|
+
degraded: unchecked.map((d) => ({ supplier: d.supplier, name: d.name })),
|
|
6430
|
+
degradedSaid: uncheckedSaid,
|
|
6431
|
+
}
|
|
6432
|
+
: {}),
|
|
6123
6433
|
// In both, like the nudges: a host with no card may hand its model
|
|
6124
6434
|
// this rather than the text.
|
|
6125
6435
|
...(ctx.noCards ? { notDrawn: PLAN_NOT_DRAWN } : {}),
|
|
@@ -6139,6 +6449,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
6139
6449
|
: []),
|
|
6140
6450
|
...plan.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
|
|
6141
6451
|
...fitsManyOf(deps, plan.dropped),
|
|
6452
|
+
uncheckedSaid,
|
|
6142
6453
|
ctx.noCards ? PLAN_NOT_DRAWN : PLAN_DRAWN_ONCE,
|
|
6143
6454
|
deps.vocab.demo ? noteFor(deps.vocab) : '',
|
|
6144
6455
|
]
|