@curless/hotel-storefront 0.52.1 → 0.54.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/about.d.ts +24 -0
- package/dist/about.d.ts.map +1 -0
- package/dist/about.js +28 -0
- package/dist/about.js.map +1 -0
- 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/aggregate/make.js +2 -2
- package/dist/aggregate/make.js.map +1 -1
- package/dist/backend.d.ts +34 -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/coupon-words.d.ts +12 -0
- package/dist/coupon-words.d.ts.map +1 -0
- package/dist/coupon-words.js +19 -0
- package/dist/coupon-words.js.map +1 -0
- package/dist/dates.js +1 -1
- package/dist/dates.js.map +1 -1
- package/dist/demo-hotels.d.ts +6 -0
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +80 -27
- package/dist/demo-hotels.js.map +1 -1
- package/dist/huazhu/backend.d.ts +6 -0
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +38 -30
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/huazhu/catalog.d.ts.map +1 -1
- package/dist/huazhu/catalog.js +43 -6
- package/dist/huazhu/catalog.js.map +1 -1
- package/dist/huazhu/client.js +1 -1
- package/dist/huazhu/client.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/make.d.ts.map +1 -1
- package/dist/huazhu/make.js +1 -0
- package/dist/huazhu/make.js.map +1 -1
- package/dist/huazhu/sandbox.d.ts.map +1 -1
- package/dist/huazhu/sandbox.js +8 -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/paid.d.ts.map +1 -1
- package/dist/paid.js +3 -1
- package/dist/paid.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/reference.d.ts.map +1 -1
- package/dist/reference.js +13 -6
- package/dist/reference.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +4 -4
- package/dist/server.js.map +1 -1
- package/dist/supplier.d.ts +6 -0
- package/dist/supplier.d.ts.map +1 -1
- package/dist/supplier.js +1 -0
- package/dist/supplier.js.map +1 -1
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +27 -18
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +11 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +480 -110
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +53 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/vocab.d.ts +15 -1
- package/dist/vocab.d.ts.map +1 -1
- package/dist/vocab.js +26 -2
- package/dist/vocab.js.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { aboutLines } from './about.js';
|
|
2
|
+
import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, notPricedForNoAnswer, unansweredBy, unitWords, } from './backend.js';
|
|
3
|
+
import { usedOnWords } from './coupon-words.js';
|
|
2
4
|
import { MAX_GUESTS, addDays, isDay, nights as nightsBetween } from './dates.js';
|
|
3
5
|
import { FACILITIES } from './facilities.js';
|
|
4
6
|
import { DESCRIPTION_MAX, POLICY_MAX, hotelName, hotelText } from './fence.js';
|
|
@@ -302,8 +304,8 @@ const forCapabilities = (tools, n, v, caps) => {
|
|
|
302
304
|
? 'A rate marked paid in advance is booked on its own, and not at once: the answer gives a card page in test mode and the time it is paid by, and the stay is booked once it is paid - no card is collected here. Every other rate is paid by nobody.'
|
|
303
305
|
: 'No payment is taken and no card is collected.'} All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
|
|
304
306
|
: v.sandbox
|
|
305
|
-
? `Makes a booking in a simulated copy of ${v.brand}'s booking system
|
|
306
|
-
? 'A rate marked paid in advance is booked on its own, and not at once: the answer gives a card page
|
|
307
|
+
? `Makes a simulated booking in a simulated copy of ${v.brand}'s booking system: each stay gets its own confirmation code there, and none of it is a ${v.brand} reservation or holds a real room. ${caps.prepaid
|
|
308
|
+
? 'A rate marked paid in advance is booked on its own, and not at once: the answer gives a card page where no real money moves and the time it is paid by, and the stay is booked once it is paid - no card is collected here. Every other rate is paid by nobody.'
|
|
307
309
|
: 'No payment is taken and no card is collected.'} All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
|
|
308
310
|
: `Books the trip's rooms with ${v.brand} for a named guest: each stay becomes the hotel's own reservation, with its own confirmation code, paid at the hotel. No payment is taken here and no card is collected. All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
|
|
309
311
|
};
|
|
@@ -592,9 +594,10 @@ const allTools = (n, v) => {
|
|
|
592
594
|
// could take the code to the real hotel.
|
|
593
595
|
description: v.market
|
|
594
596
|
? // ⚠️ THE GROUP HOLDS THE BOOKING, NOT THE MARKET - and what a booking
|
|
595
|
-
// makes is the group's: a hold, a
|
|
596
|
-
// at the hotel. The answer says which, stay by stay.
|
|
597
|
-
|
|
597
|
+
// makes is the group's: a hold, a booking that holds no room, or a
|
|
598
|
+
// reservation paid at the hotel. The answer says which, stay by stay.
|
|
599
|
+
// ⚠️ NO "DEMONSTRATION" / "TEST" / "SIMULATED" IN A MARKET (robin, 2026-11-03).
|
|
600
|
+
`Books the trip's rooms for a named guest with the hotel group each stay belongs to, one group at a time: a trip holding two groups' hotels is booked group by group. What a booking makes is that group's - a hold that lapses on its own and is not sent to the group, a booking that holds no room for anyone, or the hotel's own reservation paid at the hotel - and the answer says which for every stay. No payment is taken here and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
|
|
598
601
|
: v.demo
|
|
599
602
|
? `Makes a demonstration booking request for the trip's rooms, for a named guest: it returns a confirmation code and the moment the demonstration hold lapses on its own. ${v.brand} does not receive it and no real room is held. No payment is taken and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the request.`
|
|
600
603
|
: `Asks ${v.brand} to hold the trip's rooms for a named guest and returns a confirmation code and the moment the hold lapses. No payment is taken and no card is collected; the rooms are held, not paid for. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
|
|
@@ -1288,6 +1291,8 @@ const BOOKING_SCHEMA = {
|
|
|
1288
1291
|
again: OBJ,
|
|
1289
1292
|
/** Of `bookings`, the ids asked for minutes before, when the others are new (`BookingResult.reused`). */
|
|
1290
1293
|
repeated: STRS,
|
|
1294
|
+
/** The coupon asked for was not used: the booking returned was made before, without it (`couponNotUsedOf`). */
|
|
1295
|
+
couponNotUsed: OBJ,
|
|
1291
1296
|
/** `trip`, drawn again: see `stampOf`. */
|
|
1292
1297
|
stamp: STR,
|
|
1293
1298
|
/** The trip changed after the card that sent the form was drawn, so nothing was booked (`stampOf`). */
|
|
@@ -1704,6 +1709,7 @@ const holderOf = (deps, supplier) => {
|
|
|
1704
1709
|
demo: s.mode !== 'live',
|
|
1705
1710
|
sandbox: s.mode === 'sandbox',
|
|
1706
1711
|
test: s.mode === 'test',
|
|
1712
|
+
notReal: !!deps.vocab.market && s.mode !== 'live',
|
|
1707
1713
|
};
|
|
1708
1714
|
return {
|
|
1709
1715
|
name: deps.vocab.brand,
|
|
@@ -1711,6 +1717,7 @@ const holderOf = (deps, supplier) => {
|
|
|
1711
1717
|
demo: deps.vocab.demo,
|
|
1712
1718
|
sandbox: !!deps.vocab.sandbox,
|
|
1713
1719
|
test: !!deps.vocab.test,
|
|
1720
|
+
notReal: false,
|
|
1714
1721
|
};
|
|
1715
1722
|
};
|
|
1716
1723
|
/**
|
|
@@ -1730,6 +1737,11 @@ const holderOf = (deps, supplier) => {
|
|
|
1730
1737
|
*/
|
|
1731
1738
|
const bookingForCard = (b) => ({
|
|
1732
1739
|
...b,
|
|
1740
|
+
// A booking cancelled before the reason was kept: a coupon not returned could
|
|
1741
|
+
// then only have run out (C10) - said so, as the text says it.
|
|
1742
|
+
...(b.couponReturned === false && !b.couponNotReturnedBecause
|
|
1743
|
+
? { couponNotReturnedBecause: 'expired' }
|
|
1744
|
+
: {}),
|
|
1733
1745
|
quote: quoteForCard(b.quote),
|
|
1734
1746
|
// What a cancelled booking was charged to cancel, as text like every amount the
|
|
1735
1747
|
// card is given: "Cancelled" in My bookings says it beside the struck-out total
|
|
@@ -1784,11 +1796,17 @@ const paidLine = (deps, b, fee) => {
|
|
|
1784
1796
|
const back = formatMoney(p.refundAmount ?? p.amount);
|
|
1785
1797
|
const whole = !p.refundAmount || p.refundAmount.minor === p.amount.minor;
|
|
1786
1798
|
const unionpay = p.method === 'unionpay';
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1799
|
+
// ⚠️ ONE WORD PER ENVIRONMENT (0.54.0, M9): simulated on the simulated copy, test on a
|
|
1800
|
+
// group's test system - and in a market neither, only the fact (`notReal`). A
|
|
1801
|
+
// simulation names no "test card" or "UnionPay's test system": that is the payment
|
|
1802
|
+
// channel's own environment, not the simulation's word.
|
|
1803
|
+
const test = h.notReal
|
|
1804
|
+
? ` No real money moves, and this is not a real booking with ${h.name}.`
|
|
1805
|
+
: h.sandbox
|
|
1806
|
+
? ' This is simulated: no real money moves, and the booking is a simulated booking.'
|
|
1807
|
+
: h.test
|
|
1808
|
+
? ` This is a test: ${unionpay ? "UnionPay's test system" : 'the card page in test mode'}, no real money moves, and the booking is a test booking in ${h.name}'s test system.`
|
|
1809
|
+
: '';
|
|
1792
1810
|
const ended = b.status === 'cancelled' || b.status === 'expired' || b.status === 'failed';
|
|
1793
1811
|
const said = (() => {
|
|
1794
1812
|
// Its money as it stands once the stay has ended - never "if it does not take it".
|
|
@@ -1819,7 +1837,7 @@ const paidLine = (deps, b, fee) => {
|
|
|
1819
1837
|
case 'refunded':
|
|
1820
1838
|
return `${whole ? `All ${back}` : `${back} of the ${amount} paid`} has gone back to the card it was paid with${p.refundReason ? ` - ${p.refundReason}` : ''}.`;
|
|
1821
1839
|
case 'hotel-refunds':
|
|
1822
|
-
return `The money is with ${h.name}, who charged the one-time card UnionPay issued: what its terms give back${fee && fee.minor > 0 ? ` after its fee of ${formatMoney(fee)}` : ''}, it returns to that card -
|
|
1840
|
+
return `The money is with ${h.name}, who charged the one-time card UnionPay issued: what its terms give back${fee && fee.minor > 0 ? ` after its fee of ${formatMoney(fee)}` : ''}, it returns to that card - nothing is refunded from here${p.refundReason ? ` (${p.refundReason})` : ''}.`;
|
|
1823
1841
|
}
|
|
1824
1842
|
}
|
|
1825
1843
|
switch (p.status) {
|
|
@@ -1853,7 +1871,7 @@ const paidLine = (deps, b, fee) => {
|
|
|
1853
1871
|
case 'refunded':
|
|
1854
1872
|
return `${whole ? `All ${back}` : `${back} of the ${amount} paid`} has gone back to the card it was paid with${p.refundReason ? ` - ${p.refundReason}` : ''}.`;
|
|
1855
1873
|
case 'hotel-refunds':
|
|
1856
|
-
return `The money is with ${h.name}, who returns what its terms give back to the card it charged -
|
|
1874
|
+
return `The money is with ${h.name}, who returns what its terms give back to the card it charged - nothing is refunded from here.`;
|
|
1857
1875
|
}
|
|
1858
1876
|
})();
|
|
1859
1877
|
return `${said}${test}`;
|
|
@@ -1895,8 +1913,11 @@ const heldLine = (deps, b) => {
|
|
|
1895
1913
|
const total = formatMoney(b.quote.total);
|
|
1896
1914
|
const h = holderOf(deps, b.supplier);
|
|
1897
1915
|
if (h.caps.booking === 'pay-at-hotel') {
|
|
1916
|
+
// ⚠️ A MARKET'S GROUP: the fact, in no environment's word (robin, 2026-11-03).
|
|
1917
|
+
if (h.notReal)
|
|
1918
|
+
return `${total} all in. **This booking was not sent to ${h.name}** - no room is held for anyone and nothing is paid, now or at any hotel.`;
|
|
1898
1919
|
if (h.sandbox)
|
|
1899
|
-
return `${total} in the simulated system. **This is a
|
|
1920
|
+
return `${total} in the simulated system. **This is a simulated booking** - not a ${h.name} reservation: no room is held and nothing is paid, now or at any hotel.`;
|
|
1900
1921
|
if (h.test)
|
|
1901
1922
|
return `${total} in ${h.name}'s test system. **This is a test booking** - it holds no room for anyone, nothing is paid, now or at any hotel, and nobody needs to go there.`;
|
|
1902
1923
|
return b.status === 'requested'
|
|
@@ -1908,12 +1929,40 @@ const heldLine = (deps, b) => {
|
|
|
1908
1929
|
: `${b.holdUntil?.slice(0, 16).replace('T', ' ')} UTC`;
|
|
1909
1930
|
// ⚠️ NOT "PRESENTS A CARD AT CHECK-IN" (2026-10-05 audit, P2): a demo
|
|
1910
1931
|
// booking has no check-in, and saying so sent a guest towards a real desk.
|
|
1932
|
+
//
|
|
1933
|
+
// ⚠️ AND NOT A CLOCK TIME (2026-11-03 review, T2). "lapses on its own at Tue 3
|
|
1934
|
+
// Nov, 11:39" was told as 「今天 11:41 自动失效」 - the booking void today, when
|
|
1935
|
+
// the stay is weeks away. A demonstration hold lapses by itself and means
|
|
1936
|
+
// nothing for a real stay; said as how long it lasts, and that it is only the
|
|
1937
|
+
// demonstration.
|
|
1911
1938
|
if (h.demo)
|
|
1912
|
-
return `${total} all in. **${demoBookingWords(h)}** Nothing has been charged and no card is needed.
|
|
1939
|
+
return `${total} all in. **${demoBookingWords(h)}** Nothing has been charged and no card is needed. ${demoHoldWords(b, deps.trips.now(), h)}`;
|
|
1913
1940
|
return `${total} all in. **Nothing has been charged** - the rooms are held until ${lapses}, and the guest presents a card at check-in.`;
|
|
1914
1941
|
};
|
|
1942
|
+
/**
|
|
1943
|
+
* How long a demonstration hold lasts, as "in N minutes" from now - the hold
|
|
1944
|
+
* lapses by itself, and says nothing about the stay (T2, `heldLine`).
|
|
1945
|
+
*/
|
|
1946
|
+
const demoHoldWords = (b, now, h) => {
|
|
1947
|
+
const minutes = b.holdUntil ? Math.ceil((Date.parse(b.holdUntil) - now) / 60_000) : Number.NaN;
|
|
1948
|
+
if (Number.isNaN(minutes))
|
|
1949
|
+
return '';
|
|
1950
|
+
const lasting = minutes <= 1
|
|
1951
|
+
? 'in a minute'
|
|
1952
|
+
: minutes < 90
|
|
1953
|
+
? `in ${minutes} minutes`
|
|
1954
|
+
: minutes < 48 * 60
|
|
1955
|
+
? `in about ${Math.round(minutes / 60)} hours`
|
|
1956
|
+
: `in about ${Math.round(minutes / (24 * 60))} days`;
|
|
1957
|
+
// In a market, no "demonstration" (robin, 2026-11-03): only that it lapses and holds no room.
|
|
1958
|
+
if (h.notReal)
|
|
1959
|
+
return `It lapses by itself ${lasting} (no real room is held; the stay's own dates are not affected).`;
|
|
1960
|
+
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).`;
|
|
1961
|
+
};
|
|
1915
1962
|
/** What a demonstration booking is, in one sentence - see `demoBookingNote`. */
|
|
1916
|
-
const demoBookingWords = (h) =>
|
|
1963
|
+
const demoBookingWords = (h) => h.notReal
|
|
1964
|
+
? `This booking was not sent to ${h.name}, and ${h.name} is not holding any room.`
|
|
1965
|
+
: `This is a demonstration booking: ${h.name} has not received it and is not holding any room.`;
|
|
1917
1966
|
/**
|
|
1918
1967
|
* Said on every answer about a demonstration booking - made, changed or
|
|
1919
1968
|
* cancelled - and on nothing else: a hold of the demo's that a guest took for
|
|
@@ -1962,7 +2011,10 @@ const common = (deps, key) => {
|
|
|
1962
2011
|
? { market: { name: deps.vocab.brand, suppliers: deps.backend.suppliers() } }
|
|
1963
2012
|
: {}),
|
|
1964
2013
|
vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
|
|
1965
|
-
|
|
2014
|
+
// A market's note is empty (`noteFor`): it labels no environment (robin, 2026-11-03).
|
|
2015
|
+
...(deps.vocab.demo
|
|
2016
|
+
? { demo: true, ...(noteFor(deps.vocab) ? { demoNote: noteFor(deps.vocab) } : {}) }
|
|
2017
|
+
: { demo: false }),
|
|
1966
2018
|
...(key ? memberBadge(deps, key) : {}),
|
|
1967
2019
|
...(langOf(deps, key) ? { lang: langOf(deps, key) } : {}),
|
|
1968
2020
|
};
|
|
@@ -2088,6 +2140,11 @@ const quoteForCard = (quote, groupOf) => ({
|
|
|
2088
2140
|
// The review's own order - public, member, coupon, total - each
|
|
2089
2141
|
// figure the server's, so the card adds nothing up (10-30, ①).
|
|
2090
2142
|
publicTotal: formatMoney(quote.member.publicTotal),
|
|
2143
|
+
// The room charges at the public rate, the taxes a row of their own -
|
|
2144
|
+
// the receipt every guest gets: room, taxes, less the tier, less the
|
|
2145
|
+
// coupon, is the total (0.54.0, M1: a member's started from the
|
|
2146
|
+
// public rate taxes in, a guest's from the room before them).
|
|
2147
|
+
publicSubtotal: formatMoney(money(quote.member.publicTotal.minor - (quote.taxesIncluded ? 0 : quote.taxesAndFees.minor), quote.total.currency)),
|
|
2091
2148
|
memberTotal: formatMoney(money(quote.member.publicTotal.minor - quote.member.saved.minor, quote.total.currency)),
|
|
2092
2149
|
},
|
|
2093
2150
|
}
|
|
@@ -2128,15 +2185,34 @@ const couponTerms = (c, brand) => [
|
|
|
2128
2185
|
c.state === 'expired'
|
|
2129
2186
|
? `ran out on ${c.validUntil} - cannot be used`
|
|
2130
2187
|
: c.state === 'used'
|
|
2131
|
-
?
|
|
2188
|
+
? `already used ${usedOnWords(c.usedOn)}`
|
|
2132
2189
|
: c.state === 'not-yet'
|
|
2133
2190
|
? `usable from ${c.validFrom} to ${c.validUntil}`
|
|
2134
2191
|
: `until ${c.validUntil}`,
|
|
2135
2192
|
].join(' · ');
|
|
2136
|
-
/**
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2193
|
+
/**
|
|
2194
|
+
* What cancelling does to the coupon a booking used - and why.
|
|
2195
|
+
*
|
|
2196
|
+
* ⚠️ BACK ONLY FOR A FREE CANCELLATION IN THE COUPON'S DATES (robin,
|
|
2197
|
+
* 2026-11-03, C10). A cancellation that charges a fee counts the coupon as
|
|
2198
|
+
* used: it already took money off a stay that is being paid for. The reason is
|
|
2199
|
+
* said in the words (`fee` / `expired`) and as the same code in the structure
|
|
2200
|
+
* (`notReturnedBecause`), so a host that draws no card has it too. `fee` is the
|
|
2201
|
+
* charge as text, for the sentence.
|
|
2202
|
+
*/
|
|
2203
|
+
const couponBackWords = (c, fee,
|
|
2204
|
+
/** Said after the cancellation, not before it. */
|
|
2205
|
+
done = false) => c.returned
|
|
2206
|
+
? done
|
|
2207
|
+
? `The coupon ${couponName(c)} is back with the member, usable until ${c.validUntil}: the cancellation was free.`
|
|
2208
|
+
: `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}.`
|
|
2209
|
+
: c.notReturnedBecause === 'fee'
|
|
2210
|
+
? done
|
|
2211
|
+
? `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.`
|
|
2212
|
+
: `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.`
|
|
2213
|
+
: done
|
|
2214
|
+
? `The coupon ${couponName(c)} ran out on ${c.validUntil}, so it is not given back.`
|
|
2215
|
+
: `The coupon ${couponName(c)} it used ran out on ${c.validUntil}, so cancelling does not give it back.`;
|
|
2140
2216
|
/**
|
|
2141
2217
|
* Said wherever points are: they are shown and earned, never spent here -
|
|
2142
2218
|
* nobody has published what a point is worth (hotel-connectors.md §5).
|
|
@@ -2174,9 +2250,6 @@ const memberTripLines = (q) => {
|
|
|
2174
2250
|
`${m.programme} ${m.tier} member rate: public rate ${formatMoney(m.publicTotal)} → member rate ${formatMoney(memberTotal)} (${formatMoney(m.saved)} less)${q.coupon
|
|
2175
2251
|
? ` → coupon ${couponName(q.coupon)} −${formatMoney(q.coupon.off)}${stays > 1 ? ` (on the stay at ${q.coupon.hotelName})` : ''}`
|
|
2176
2252
|
: ''} → **${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
2253
|
...(q.coupons?.length
|
|
2181
2254
|
? [
|
|
2182
2255
|
`The member's coupons for this trip - ${COUPON_RULE}`,
|
|
@@ -2189,7 +2262,26 @@ const memberTripLines = (q) => {
|
|
|
2189
2262
|
: []),
|
|
2190
2263
|
].filter(Boolean);
|
|
2191
2264
|
};
|
|
2265
|
+
/**
|
|
2266
|
+
* The coupon asked for that was not taken off, and why.
|
|
2267
|
+
*
|
|
2268
|
+
* ⚠️ SAID WHENEVER THERE IS ONE, SIGNED IN OR NOT (2026-11-03 review, C2). The
|
|
2269
|
+
* sentence lived in `memberTripLines`, which says nothing without a member - so
|
|
2270
|
+
* a coupon refused because nobody is signed in (`signed-out`) was in
|
|
2271
|
+
* `structuredContent.couponRefused` and not in the words, and a host that
|
|
2272
|
+
* hands its model the text read "Total ¥1,200" and could say it was the price
|
|
2273
|
+
* with the coupon.
|
|
2274
|
+
*/
|
|
2275
|
+
const couponRefusedLine = (q) => q.couponRefused
|
|
2276
|
+
? `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.`
|
|
2277
|
+
: '';
|
|
2192
2278
|
const tripText = (quote, demo, noCards) => {
|
|
2279
|
+
// ⚠️ NOT "EMPTY" WHEN SOMETHING WAS LEFT OUT: a trip whose only group did not
|
|
2280
|
+
// answer is not an empty trip (2026-11-03 review, C5).
|
|
2281
|
+
if (quote.lines.length === 0 && quote.dropped?.length)
|
|
2282
|
+
return [...quote.dropped.map((d) => `Left out: ${d.title} — ${d.why}`), demo ? DEMO_NOTE : '']
|
|
2283
|
+
.filter(Boolean)
|
|
2284
|
+
.join('\n');
|
|
2193
2285
|
if (quote.lines.length === 0)
|
|
2194
2286
|
return 'The trip is empty.';
|
|
2195
2287
|
const lines = quote.lines.map((l) => l.kind === 'room'
|
|
@@ -2198,6 +2290,7 @@ const tripText = (quote, demo, noCards) => {
|
|
|
2198
2290
|
return [
|
|
2199
2291
|
...lines,
|
|
2200
2292
|
`Taxes and fees ${quote.taxesIncluded ? 'included in the prices' : formatMoney(quote.taxesAndFees)}${quote.coupon ? ` · coupon −${formatMoney(quote.coupon.off)}` : ''} · **Total ${formatMoney(quote.total)}**`,
|
|
2293
|
+
couponRefusedLine(quote),
|
|
2201
2294
|
...memberTripLines(quote),
|
|
2202
2295
|
...(quote.dropped ?? []).map((d) => `Left out: ${d.title} — ${d.why}`),
|
|
2203
2296
|
demo ? DEMO_NOTE : '',
|
|
@@ -2449,6 +2542,8 @@ const labelWords = (r) => {
|
|
|
2449
2542
|
];
|
|
2450
2543
|
return words.length ? ` [${words.join(', ')}]` : '';
|
|
2451
2544
|
};
|
|
2545
|
+
/** What a list and a page both say of a hotel with nothing on sale here: other nights would not help. */
|
|
2546
|
+
const NOT_SOLD_OUT = 'not sold out, and other nights make no difference';
|
|
2452
2547
|
/**
|
|
2453
2548
|
* What is said of rates listed but not booked here (`Room.notSoldHere`) - in
|
|
2454
2549
|
* the text and, word for word, in the structure (`notSoldHere`): the model reads
|
|
@@ -2456,7 +2551,18 @@ const labelWords = (r) => {
|
|
|
2456
2551
|
* card says no more than 预付 on them (robin, 2026-10-30), so the assistant is
|
|
2457
2552
|
* the one who knows - and says it when the guest wants one.
|
|
2458
2553
|
*/
|
|
2459
|
-
export const notSoldHereWords = (group) => `Rates marked paid in advance are listed as ${group} sells them, but a stay paid in advance cannot be booked here yet
|
|
2554
|
+
export const notSoldHereWords = (group) => `Rates marked paid in advance are listed as ${group} sells them, but a stay paid in advance cannot be booked here yet; only rates paid at the hotel can be. If the guest wants one, say so plainly - nothing is booked or charged - and offer a rate paid at the hotel, other dates, or booking it with ${group} directly.`;
|
|
2555
|
+
/**
|
|
2556
|
+
* A hotel none of whose rooms are on sale here (`none-on-sale`), said on its page
|
|
2557
|
+
* with the way out (2026-11-03 review, T5). The page's text had one sentence -
|
|
2558
|
+
* "This hotel has no rooms on sale here." - and the card said where to book it
|
|
2559
|
+
* instead, so a client that draws no card left the guest with a dead end. The
|
|
2560
|
+
* clause is the list's (see `NOT_SOLD_OUT`), so the list and the page say one
|
|
2561
|
+
* thing; the group is the deployment's own, or the hotel's in a market, never a
|
|
2562
|
+
* name written into the sentence. In the text and, word for word, in the
|
|
2563
|
+
* structure (`noRooms`): the model reads the structure.
|
|
2564
|
+
*/
|
|
2565
|
+
export const notOnSaleWords = (group, why) => `${why.replace(/[.\s]+$/, '')} - ${NOT_SOLD_OUT}. To book it, the guest can use ${group}'s own channels: its app, its website or by phone.`;
|
|
2460
2566
|
/** The group a hotel is with, by name - the deployment's own brand outside a market. */
|
|
2461
2567
|
const groupNameOf = (deps, hotelId) => holderOf(deps, deps.backend.supplierOf?.(hotelId)).name;
|
|
2462
2568
|
/** What a rate says about cancelling, as the text puts it. */
|
|
@@ -2645,7 +2751,9 @@ const bookingLine = (b, noCards) => {
|
|
|
2645
2751
|
? ` · coupon ${couponName(b.quote.coupon)} −${formatMoney(b.quote.coupon.off)}${b.couponReturned === true
|
|
2646
2752
|
? ' (given back)'
|
|
2647
2753
|
: b.couponReturned === false
|
|
2648
|
-
?
|
|
2754
|
+
? b.couponNotReturnedBecause === 'fee'
|
|
2755
|
+
? ' (not given back - the cancellation was charged a fee)'
|
|
2756
|
+
: ' (ran out - not given back)'
|
|
2649
2757
|
: ''}`
|
|
2650
2758
|
: '',
|
|
2651
2759
|
b.payment
|
|
@@ -2851,12 +2959,14 @@ const listAnd = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} and ${xs.
|
|
|
2851
2959
|
* hotel. The sentence says the first and denies the second, in the text and in
|
|
2852
2960
|
* `structuredContent` alike - a host with no card has only the words.
|
|
2853
2961
|
*/
|
|
2854
|
-
const degradedOf = (deps, answer
|
|
2962
|
+
const degradedOf = (deps, answer,
|
|
2963
|
+
/** What is missing from the list: the hotels of a search, the bookings of "my bookings". */
|
|
2964
|
+
noun = deps.vocab.nounPlural) => {
|
|
2855
2965
|
const down = answer.degraded ?? [];
|
|
2856
2966
|
if (down.length === 0)
|
|
2857
2967
|
return { structured: {}, said: '' };
|
|
2858
2968
|
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'} ${
|
|
2969
|
+
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
2970
|
return {
|
|
2861
2971
|
structured: {
|
|
2862
2972
|
degraded: down.map((d) => ({ supplier: d.supplier, name: d.name })),
|
|
@@ -2865,6 +2975,37 @@ const degradedOf = (deps, answer) => {
|
|
|
2865
2975
|
said,
|
|
2866
2976
|
};
|
|
2867
2977
|
};
|
|
2978
|
+
/**
|
|
2979
|
+
* What is said when a read could not be had because hotel groups did not answer
|
|
2980
|
+
* in time - one group, or several, or every one.
|
|
2981
|
+
*
|
|
2982
|
+
* ⚠️ NEVER JUST THE FIRST GROUP, NEVER "NONE HERE" (2026-11-03 review, C5/C8).
|
|
2983
|
+
* Where every group of a market is silent it says so ("no group answered this
|
|
2984
|
+
* time"), not the name of whichever was asked first; and in every case it
|
|
2985
|
+
* denies the reading a guest would make of silence, that there is nothing.
|
|
2986
|
+
*/
|
|
2987
|
+
const notAnswered = (deps, key, down) => {
|
|
2988
|
+
const groups = deps.backend.suppliers?.().length ?? 0;
|
|
2989
|
+
const names = listAnd(down.map((d) => d.name));
|
|
2990
|
+
const one = down.length === 1;
|
|
2991
|
+
const said = groups > 1 && down.length >= groups
|
|
2992
|
+
? `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.`
|
|
2993
|
+
: `${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'}.`;
|
|
2994
|
+
return {
|
|
2995
|
+
structured: {
|
|
2996
|
+
view: 'error',
|
|
2997
|
+
...common(deps, key),
|
|
2998
|
+
error: 'supplier-timeout',
|
|
2999
|
+
detail: said,
|
|
3000
|
+
degraded: down.map((d) => ({ supplier: d.supplier, name: d.name })),
|
|
3001
|
+
degradedSaid: said,
|
|
3002
|
+
},
|
|
3003
|
+
text: said,
|
|
3004
|
+
// Like `bad-count`: the card still draws it, and a host that checks a
|
|
3005
|
+
// result against the tool's output schema does not hold an error to it.
|
|
3006
|
+
isError: true,
|
|
3007
|
+
};
|
|
3008
|
+
};
|
|
2868
3009
|
/** The counts a call can carry, and what each may be. */
|
|
2869
3010
|
const COUNTS = ['guests', 'rooms', 'quantity'];
|
|
2870
3011
|
/** "2" is 2: a host that sends a count as a string has not asked for something else. */
|
|
@@ -2916,6 +3057,13 @@ const countProblem = (t, name, args) => {
|
|
|
2916
3057
|
*/
|
|
2917
3058
|
const guestWords = (err, fault = 'That could not be done just now - nothing was changed. Try again in a moment.') => {
|
|
2918
3059
|
const e = err;
|
|
3060
|
+
// ⚠️ A READ THAT TIMED OUT IS NOT A REFUSAL (2026-11-03 review, C5). After a
|
|
3061
|
+
// booking was made, "Atour did not answer within 8000 ms" would be read
|
|
3062
|
+
// back as the booking's own answer - and sound like it was not made.
|
|
3063
|
+
if (unansweredBy(err)) {
|
|
3064
|
+
console.error('[tools] a read timed out:', e.message);
|
|
3065
|
+
return fault;
|
|
3066
|
+
}
|
|
2919
3067
|
if (e instanceof Error && (typeof e.code === 'string' || e.constructor === Error))
|
|
2920
3068
|
return e.message;
|
|
2921
3069
|
console.error('[tools] failed:', err);
|
|
@@ -2969,14 +3117,42 @@ const guarded = async (deps, ctx, who, code, run) => {
|
|
|
2969
3117
|
*/
|
|
2970
3118
|
const quoteParts = async (deps, lines, who, coupon) => {
|
|
2971
3119
|
const byCurrency = new Map();
|
|
3120
|
+
// ⚠️ A HOTEL WHOSE GROUP DID NOT ANSWER IS LEFT OUT OF THE PRICE, SAID (2026-11-03
|
|
3121
|
+
// review, C5): its currency cannot be read, so it cannot be priced with the
|
|
3122
|
+
// rest - and it is not an empty trip, nor a trip without it.
|
|
3123
|
+
const unread = new Map();
|
|
2972
3124
|
for (const l of lines) {
|
|
2973
|
-
|
|
2974
|
-
|
|
3125
|
+
try {
|
|
3126
|
+
const c = (await deps.backend.getHotel(l.hotelId))?.hotel.currency ?? '';
|
|
3127
|
+
byCurrency.set(c, [...(byCurrency.get(c) ?? []), l]);
|
|
3128
|
+
}
|
|
3129
|
+
catch (err) {
|
|
3130
|
+
const down = unansweredBy(err);
|
|
3131
|
+
if (!down)
|
|
3132
|
+
throw err;
|
|
3133
|
+
for (const g of down)
|
|
3134
|
+
unread.set(g.supplier, g);
|
|
3135
|
+
}
|
|
2975
3136
|
}
|
|
2976
3137
|
const asked = coupon ? { coupon } : {};
|
|
3138
|
+
if (byCurrency.size === 0 && unread.size) {
|
|
3139
|
+
throw Object.assign(new Error(`${listAnd([...unread.values()].map((g) => g.name))} did not answer`), { code: 'supplier-timeout', unanswered: [...unread.values()] });
|
|
3140
|
+
}
|
|
2977
3141
|
if (byCurrency.size === 0)
|
|
2978
3142
|
return [await deps.backend.quote({ lines: [], ...asked }, who)];
|
|
2979
|
-
const
|
|
3143
|
+
const priced = await Promise.all([...byCurrency.values()].map((g) => deps.backend.quote({ lines: g, ...asked }, who)));
|
|
3144
|
+
const parts = unread.size && priced[0]
|
|
3145
|
+
? [
|
|
3146
|
+
{
|
|
3147
|
+
...priced[0],
|
|
3148
|
+
dropped: [
|
|
3149
|
+
...(priced[0].dropped ?? []),
|
|
3150
|
+
...[...unread.values()].map(notPricedForNoAnswer),
|
|
3151
|
+
],
|
|
3152
|
+
},
|
|
3153
|
+
...priced.slice(1),
|
|
3154
|
+
]
|
|
3155
|
+
: priced;
|
|
2980
3156
|
// A coupon goes on one part of a trip in two currencies: the others need
|
|
2981
3157
|
// not say it is in the wrong one.
|
|
2982
3158
|
return parts.some((p) => p.coupon) ? parts.map(({ couponRefused: _no, ...p }) => p) : parts;
|
|
@@ -3434,7 +3610,17 @@ export const tripGone = (deps) => {
|
|
|
3434
3610
|
};
|
|
3435
3611
|
export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
3436
3612
|
const { result, written } = await keepingAmounts(async () => {
|
|
3437
|
-
|
|
3613
|
+
// ⚠️ A GROUP THAT DID NOT ANSWER A READ IS AN ANSWER, NOT A FAULT (2026-11-03
|
|
3614
|
+
// review, C5/C8): the hotel page, the add-ons, the dates, a booking looked
|
|
3615
|
+
// up. Thrown by the market past its time limit, it reached the host as "the
|
|
3616
|
+
// connector could not answer that" - or, for a search where every group was
|
|
3617
|
+
// silent, as the first group's name alone. Said here, naming every group.
|
|
3618
|
+
const answer = await answerTool(deps, name, rawArgs, ctx).catch((err) => {
|
|
3619
|
+
const down = unansweredBy(err);
|
|
3620
|
+
if (!down)
|
|
3621
|
+
throw err;
|
|
3622
|
+
return notAnswered(deps, ctx.tripKey ?? tripKey(ctx.session, deps.vocab.brand), down);
|
|
3623
|
+
});
|
|
3438
3624
|
const suggestions = await suggestionsFor(deps, answer.structured).catch(() => []);
|
|
3439
3625
|
return { answer, suggestions };
|
|
3440
3626
|
});
|
|
@@ -3530,7 +3716,13 @@ const listedInWords = (h) => {
|
|
|
3530
3716
|
const price = f
|
|
3531
3717
|
? `${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
3718
|
: h.noRoomsForStay
|
|
3533
|
-
?
|
|
3719
|
+
? // ⚠️ NOT SOLD HERE IS NOT SOLD OUT (2026-11-03 review, U11): a hotel with no
|
|
3720
|
+
// rooms on sale through this connector was told as "no room for those
|
|
3721
|
+
// nights", and the model said "no vacancy tonight" - as if another night
|
|
3722
|
+
// would do. It says what the hotel's own page says.
|
|
3723
|
+
h.noRoomsForStayCode === 'none-on-sale'
|
|
3724
|
+
? h.noRoomsForStay
|
|
3725
|
+
: 'no room for those nights'
|
|
3534
3726
|
: h.fromNightly
|
|
3535
3727
|
? `from ${formatMoney(h.fromNightly)} ${nightWords(true)}`
|
|
3536
3728
|
: '';
|
|
@@ -3763,6 +3955,57 @@ const LIVE_STATUSES = new Set([
|
|
|
3763
3955
|
'unconfirmed',
|
|
3764
3956
|
'awaiting-payment',
|
|
3765
3957
|
]);
|
|
3958
|
+
/**
|
|
3959
|
+
* A coupon asked for on a booking that was already made, without it - or
|
|
3960
|
+
* nothing.
|
|
3961
|
+
*
|
|
3962
|
+
* ⚠️ NEVER SILENT THAT THE COUPON WAS NOT USED (2026-11-03 review, C3). The
|
|
3963
|
+
* same stay asked for again within minutes is answered with the first
|
|
3964
|
+
* booking (`repeat`); a member who first booked without a coupon and then said
|
|
3965
|
+
* "use the 满500减50" was handed the first booking at its first price, with
|
|
3966
|
+
* nothing said of the coupon they had just agreed to. The coupon is NOT put in
|
|
3967
|
+
* the repeat key - that would book the stay twice - so the answer says it:
|
|
3968
|
+
* the booking stands as it was, the coupon was not spent, and what it takes to
|
|
3969
|
+
* use it is cancelling this one (free until when, or what it costs) and
|
|
3970
|
+
* booking again. In the structure as `couponNotUsed` and in the same words.
|
|
3971
|
+
*/
|
|
3972
|
+
const couponNotUsedOf = (deps, key, asked,
|
|
3973
|
+
/** The bookings that were asked for before and returned as they were. */
|
|
3974
|
+
reusedBookings,
|
|
3975
|
+
/** Every booking in the answer: the coupon may have gone on one made now. */
|
|
3976
|
+
answered) => {
|
|
3977
|
+
const want = asked?.trim().toUpperCase();
|
|
3978
|
+
if (!want || reusedBookings.length === 0)
|
|
3979
|
+
return undefined;
|
|
3980
|
+
if (answered.some((b) => b.quote.coupon?.id.toUpperCase() === want))
|
|
3981
|
+
return undefined;
|
|
3982
|
+
const t = deps.tools;
|
|
3983
|
+
const held = deps.members.read(key)?.coupons?.find((c) => c.id.toUpperCase() === want);
|
|
3984
|
+
const label = held ? couponName(held) : (asked ?? '');
|
|
3985
|
+
const codes = reusedBookings.map((b) => b.confirmationCode).filter(Boolean);
|
|
3986
|
+
const names = codes.length ? codes.join(', ') : 'it';
|
|
3987
|
+
const now = deps.trips.now();
|
|
3988
|
+
// Free to cancel still: every room line has a window that has not closed.
|
|
3989
|
+
const rooms = reusedBookings.flatMap((b) => b.quote.lines.filter((l) => l.kind === 'room'));
|
|
3990
|
+
const open = rooms.filter((l) => l.freeCancellationUntil && Date.parse(l.freeCancellationUntil) > now);
|
|
3991
|
+
const first = open
|
|
3992
|
+
.slice()
|
|
3993
|
+
.sort((a, b) => (a.freeCancellationUntil ?? '').localeCompare(b.freeCancellationUntil ?? ''))[0];
|
|
3994
|
+
const freeUntil = rooms.length > 0 && open.length === rooms.length && first ? freeUntilText(first) : undefined;
|
|
3995
|
+
const total = reusedBookings.map((b) => formatMoney(b.quote.total)).join(' + ');
|
|
3996
|
+
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.`;
|
|
3997
|
+
return {
|
|
3998
|
+
structured: {
|
|
3999
|
+
id: asked,
|
|
4000
|
+
...(held ? { name: held.name, ...(held.names ? { names: held.names } : {}) } : {}),
|
|
4001
|
+
bookings: codes,
|
|
4002
|
+
cancelFree: !!freeUntil,
|
|
4003
|
+
...(freeUntil ? { freeUntil } : {}),
|
|
4004
|
+
said,
|
|
4005
|
+
},
|
|
4006
|
+
said,
|
|
4007
|
+
};
|
|
4008
|
+
};
|
|
3766
4009
|
/**
|
|
3767
4010
|
* The booking this very form made a few minutes ago, when the slot is empty
|
|
3768
4011
|
* because of it - or undefined.
|
|
@@ -3803,6 +4046,7 @@ const bookedFromThisForm = async (deps, ctx, key, args) => {
|
|
|
3803
4046
|
x.quote.lines.some((l) => l.kind === 'room' && l.roomRef === room && (l.quantity ?? 1) === rooms));
|
|
3804
4047
|
if (!b)
|
|
3805
4048
|
return undefined;
|
|
4049
|
+
const notUsed = couponNotUsedOf(deps, key, couponOf(deps, args), [b], [b]);
|
|
3806
4050
|
return {
|
|
3807
4051
|
structured: {
|
|
3808
4052
|
view: 'booking',
|
|
@@ -3812,9 +4056,11 @@ const bookedFromThisForm = async (deps, ctx, key, args) => {
|
|
|
3812
4056
|
holdUntil: b.holdUntil,
|
|
3813
4057
|
total: formatMoney(b.quote.total),
|
|
3814
4058
|
repeat: true,
|
|
4059
|
+
...(notUsed ? { couponNotUsed: notUsed.structured } : {}),
|
|
3815
4060
|
},
|
|
3816
4061
|
text: [
|
|
3817
4062
|
'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.',
|
|
4063
|
+
notUsed?.said ?? '',
|
|
3818
4064
|
`**${confirming(deps, b) ? 'Being confirmed' : b.confirmationCode}** · ${b.hotelName} · ${stayWords(b.stay, b.hourly)}`,
|
|
3819
4065
|
bookedLine(deps, b) ?? '',
|
|
3820
4066
|
deps.vocab.demo ? noteFor(deps.vocab) : '',
|
|
@@ -3869,6 +4115,47 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
|
|
|
3869
4115
|
return currentTrip(deps, key, who, store, couponOf(deps, args), ctx);
|
|
3870
4116
|
}
|
|
3871
4117
|
}
|
|
4118
|
+
// ⚠️ LISTED, NOT BOOKED HERE (robin, 2026-10-30): a rate paid in advance
|
|
4119
|
+
// where this deployment takes no payment goes in no trip and no pick. The
|
|
4120
|
+
// press on its Reserve - or the assistant asking for it - is answered with
|
|
4121
|
+
// what is true and what is left: nothing booked, nothing charged; the rates
|
|
4122
|
+
// here paid at the hotel, a press each; other dates; or the group itself.
|
|
4123
|
+
// Never a review that fails at Book, after the guest typed their name.
|
|
4124
|
+
// ONE ANSWER FOR A ROOM BY THE NIGHT AND A ROOM BY THE HOUR (2026-11-03
|
|
4125
|
+
// review, C6): the hourly branch put such a room in the trip before this was
|
|
4126
|
+
// asked, and the guest met the refusal at "Book".
|
|
4127
|
+
const prepaidNotHere = (room, open, stay, when) => {
|
|
4128
|
+
const group = groupNameOf(deps, hotelId);
|
|
4129
|
+
const said = [
|
|
4130
|
+
`Nothing was booked or charged: ${room.name} is paid in advance, and a stay paid in advance cannot be booked here yet - only rates paid at the hotel can be.`,
|
|
4131
|
+
open.length
|
|
4132
|
+
? `Paid at the hotel at ${detail.hotel.name} ${when}: ${open.map((r) => `${r.name} (\`${r.id}\`)`).join(', ')}.`
|
|
4133
|
+
: `No rate at ${detail.hotel.name} ${when} is paid at the hotel.`,
|
|
4134
|
+
`Other dates, or booking this rate with ${group} directly, are the other ways on.`,
|
|
4135
|
+
].join(' ');
|
|
4136
|
+
return {
|
|
4137
|
+
structured: {
|
|
4138
|
+
view: 'error',
|
|
4139
|
+
...common(deps, key),
|
|
4140
|
+
error: 'prepaid-not-here',
|
|
4141
|
+
room: { id: room.id, name: room.name, ...(room.names ? { names: room.names } : {}) },
|
|
4142
|
+
rooms: open.map((r) => ({
|
|
4143
|
+
id: r.id,
|
|
4144
|
+
name: r.name,
|
|
4145
|
+
...(r.names ? { names: r.names } : {}),
|
|
4146
|
+
})),
|
|
4147
|
+
hotel: {
|
|
4148
|
+
id: detail.hotel.id,
|
|
4149
|
+
name: detail.hotel.name,
|
|
4150
|
+
...(detail.hotel.names ? { names: detail.hotel.names } : {}),
|
|
4151
|
+
},
|
|
4152
|
+
stay,
|
|
4153
|
+
group,
|
|
4154
|
+
said,
|
|
4155
|
+
},
|
|
4156
|
+
text: said,
|
|
4157
|
+
};
|
|
4158
|
+
};
|
|
3872
4159
|
const today = (startTime || !asStay(args)
|
|
3873
4160
|
? deps.backend.hourlyRooms?.(hotelId, guestsOf(args), who)
|
|
3874
4161
|
: undefined);
|
|
@@ -3897,6 +4184,15 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
|
|
|
3897
4184
|
: ((await today)?.noRooms ?? `${detail.hotel.name} has no rooms by the hour today.`),
|
|
3898
4185
|
};
|
|
3899
4186
|
}
|
|
4187
|
+
if (hourlyRoom.notSoldHere) {
|
|
4188
|
+
const wantedHere = Math.max(1, clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE));
|
|
4189
|
+
const open = ((await today)?.rooms ?? []).filter((r) => r.hourly && !r.notSoldHere && r.roomsLeft >= wantedHere);
|
|
4190
|
+
return prepaidNotHere(hourlyRoom, open, {
|
|
4191
|
+
checkIn: hourlyRoom.hourly.date,
|
|
4192
|
+
checkOut: hourlyRoom.hourly.date,
|
|
4193
|
+
guests: guestsOf(args),
|
|
4194
|
+
}, 'by the hour today');
|
|
4195
|
+
}
|
|
3900
4196
|
const bad = startProblem(hourlyRoom.hourly, startTime);
|
|
3901
4197
|
if (bad) {
|
|
3902
4198
|
return {
|
|
@@ -4026,44 +4322,10 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
|
|
|
4026
4322
|
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
4323
|
};
|
|
4028
4324
|
}
|
|
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.
|
|
4325
|
+
// Listed, not booked here - see `prepaidNotHere`.
|
|
4035
4326
|
if (room.notSoldHere) {
|
|
4036
4327
|
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
|
-
};
|
|
4328
|
+
return prepaidNotHere(room, open, stay, 'for those nights');
|
|
4067
4329
|
}
|
|
4068
4330
|
deps.trips.set(store, {
|
|
4069
4331
|
kind: 'room',
|
|
@@ -4103,7 +4365,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4103
4365
|
// the trip as it is now (no stamp); a card's form books the one it showed.
|
|
4104
4366
|
const drawn = str(args.stamp);
|
|
4105
4367
|
if (drawn && drawn !== stampOf(inTrip, deps.trips.shown(store))) {
|
|
4106
|
-
const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
4368
|
+
const again = await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
|
|
4107
4369
|
return {
|
|
4108
4370
|
...again,
|
|
4109
4371
|
structured: { ...again.structured, tripChanged: true },
|
|
@@ -4196,7 +4458,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4196
4458
|
// booking is held to (ShownQuote).
|
|
4197
4459
|
const rest = lines.length === inTrip.length && !failed.length
|
|
4198
4460
|
? undefined
|
|
4199
|
-
: await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
4461
|
+
: await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
|
|
4200
4462
|
const others = rest && lines.length !== inTrip.length;
|
|
4201
4463
|
// What could be booked instead of each, found now (robin, 2026-10-14).
|
|
4202
4464
|
const instead = await Promise.all(failed.map((f, i) => i < MAX_FAILED_WITH_ALTERNATIVES
|
|
@@ -4243,7 +4505,9 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4243
4505
|
// at 15:00, and a generic "check-in is in the afternoon" is not an answer.
|
|
4244
4506
|
// Per booking: each is its own hotel, with its own hour and its own desk.
|
|
4245
4507
|
const booked = await Promise.all(made.map(async (booking, i) => {
|
|
4246
|
-
|
|
4508
|
+
// ⚠️ THE BOOKING IS MADE: a read after it that fails (a group slow to
|
|
4509
|
+
// answer) loses the check-in hour, never the answer.
|
|
4510
|
+
const hotel = await deps.backend.getHotel(booking.hotelId).catch(() => null);
|
|
4247
4511
|
return {
|
|
4248
4512
|
booking,
|
|
4249
4513
|
// The requests that could not both be asked for are said once.
|
|
@@ -4254,6 +4518,10 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4254
4518
|
const several = booked.length > 1;
|
|
4255
4519
|
// The stays asked for minutes before, beside new ones - see `reused`.
|
|
4256
4520
|
const earlier = booked.filter(({ booking }) => reused?.includes(booking.id));
|
|
4521
|
+
// The coupon asked for, when the stays asked for before did not carry it
|
|
4522
|
+
// and nothing new did either - see `couponNotUsedOf`.
|
|
4523
|
+
const askedCoupon = couponOf(deps, args);
|
|
4524
|
+
const notUsed = couponNotUsedOf(deps, key, askedCoupon, repeat ? made : earlier.map(({ booking }) => booking), made);
|
|
4257
4525
|
const waiting = booked.filter(({ booking }) => confirming(deps, booking)).length;
|
|
4258
4526
|
const notBooked = failed.map((f, i) => ({
|
|
4259
4527
|
hotelId: f.hotelId,
|
|
@@ -4348,6 +4616,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4348
4616
|
...(notBooked.length ? { failed: notBooked } : {}),
|
|
4349
4617
|
...(repeat ? { repeat: true } : {}),
|
|
4350
4618
|
...(earlier.length ? { repeated: earlier.map(({ booking }) => booking.id) } : {}),
|
|
4619
|
+
...(notUsed ? { couponNotUsed: notUsed.structured } : {}),
|
|
4351
4620
|
},
|
|
4352
4621
|
text: [
|
|
4353
4622
|
// ⚠️ THE SAME BOOKING ASKED FOR AGAIN IS SAID TO BE THAT, never a new
|
|
@@ -4357,6 +4626,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4357
4626
|
: earlier.length
|
|
4358
4627
|
? `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
4628
|
: '',
|
|
4629
|
+
notUsed?.said ?? '',
|
|
4360
4630
|
// ⚠️ BOOKED IS CONFIRMED: a stay still being confirmed is said as
|
|
4361
4631
|
// that, and not counted (总线程, 2026-10-14).
|
|
4362
4632
|
notBooked.length || (booked.length > 1 && waiting)
|
|
@@ -4386,13 +4656,16 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4386
4656
|
// and the trip is shown again at today's - which is then what the guest
|
|
4387
4657
|
// has seen, and what a second ask books.
|
|
4388
4658
|
if (err.code === 'price-changed') {
|
|
4389
|
-
const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
4659
|
+
const again = await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
|
|
4390
4660
|
return { ...again, text: [words, again.text].join('\n\n') };
|
|
4391
4661
|
}
|
|
4392
4662
|
// The coupon asked for cannot be used: nothing was booked, and the trip
|
|
4393
4663
|
// is shown again with it refused and why - the guest decides what next.
|
|
4394
4664
|
if (err.code === 'coupon') {
|
|
4395
|
-
|
|
4665
|
+
// ⚠️ WITH THE CONTEXT, like every other answer of the trip (2026-11-03
|
|
4666
|
+
// review, T1): without it the restated trip lost the Chinese room and
|
|
4667
|
+
// hotel names a host with no card is given beside the plain ones.
|
|
4668
|
+
const again = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args), ctx);
|
|
4396
4669
|
return {
|
|
4397
4670
|
...again,
|
|
4398
4671
|
structured: { ...again.structured, notBooked: 'coupon' },
|
|
@@ -4400,7 +4673,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
|
|
|
4400
4673
|
};
|
|
4401
4674
|
}
|
|
4402
4675
|
if (err.code === 'mixed-supplier') {
|
|
4403
|
-
const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
|
|
4676
|
+
const again = await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
|
|
4404
4677
|
return {
|
|
4405
4678
|
...again,
|
|
4406
4679
|
text: [
|
|
@@ -4500,7 +4773,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4500
4773
|
// FAILED either, for the same reason as the ceiling above: the place
|
|
4501
4774
|
// matched. What matched the place and the words, before either:
|
|
4502
4775
|
const placedCount = (a) => a.beforeFilters ?? a.beforeCeiling ?? a.total;
|
|
4503
|
-
|
|
4776
|
+
// ⚠️ AND NOT WHEN A GROUP DID NOT ANSWER (2026-11-03 review, C4). The place
|
|
4777
|
+
// "matched nothing" among the groups that answered; the one that did not
|
|
4778
|
+
// may have it. A wider search then found something else, took over as the
|
|
4779
|
+
// answer, and the first search's "X did not answer" went with it - "nothing
|
|
4780
|
+
// matched Hong Kong, so this is Shenzhen", with Hong Kong's group silent.
|
|
4781
|
+
// It also cost another time limit. The empty answer is given as it is, with
|
|
4782
|
+
// the sentence that the group did not answer (`degradedOf`).
|
|
4783
|
+
if (placedCount(answer) === 0 && !answer.degraded?.length) {
|
|
4504
4784
|
const wider = widen(ask);
|
|
4505
4785
|
if (wider) {
|
|
4506
4786
|
const second = await deps.backend.searchHotels(wider.ask, who);
|
|
@@ -4524,7 +4804,15 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4524
4804
|
// backend contract, but "the next backend honours it" is not something the
|
|
4525
4805
|
// card can be built on - and the cost of it being wrong is the whole group
|
|
4526
4806
|
// in the model's context.
|
|
4527
|
-
|
|
4807
|
+
// ⚠️ A HOTEL NOT ON SALE HERE SAYS WHERE IT IS (review r2, T5): its page said
|
|
4808
|
+
// the group's own channels, its tile on a list - the answer a model often
|
|
4809
|
+
// stops at - only that nothing was on sale. In the text and the structure.
|
|
4810
|
+
const hotels = answer.hotels.slice(0, MAX_PAGE).map((h) => h.noRoomsForStayCode === 'none-on-sale' && h.noRoomsForStay
|
|
4811
|
+
? {
|
|
4812
|
+
...h,
|
|
4813
|
+
noRoomsForStay: `${h.noRoomsForStay.replace(/[.\s]+$/, '')} - it can be booked through ${groupNameOf(deps, h.id)}'s own channels: its app, its website or by phone.`,
|
|
4814
|
+
}
|
|
4815
|
+
: h);
|
|
4528
4816
|
const offset = asked.offset ?? 0;
|
|
4529
4817
|
const total = Math.max(answer.total, offset + hotels.length);
|
|
4530
4818
|
const cities = [...new Set(hotels.map((h) => h.city))];
|
|
@@ -4666,8 +4954,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4666
4954
|
guests: guestsOf(args),
|
|
4667
4955
|
};
|
|
4668
4956
|
const priced = await answerTool(deps, t.listHotels, { ...args, ...tonight }, ctx);
|
|
4957
|
+
// ⚠️ THE ROOMS ASKED FOR, not "one room" (2026-11-03 review, C12): `rooms: 2`
|
|
4958
|
+
// rides along into the priced list, and every price in it is two rooms'.
|
|
4959
|
+
const roomCount = ask.rooms ?? 1;
|
|
4669
4960
|
// 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.`;
|
|
4961
|
+
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
4962
|
// Right after what is here - "every one, and no others" stays the first thing said.
|
|
4672
4963
|
const inAll = String(priced.structured.inAll ?? '');
|
|
4673
4964
|
return {
|
|
@@ -4693,7 +4984,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4693
4984
|
// assistant ask which city to list the rest in (2026-10-15 smoke run).
|
|
4694
4985
|
// And not "tell me another city": the second run said there was one
|
|
4695
4986
|
// hotel, then offered to look in another city (2026-10-15).
|
|
4696
|
-
const inAll = `
|
|
4987
|
+
const inAll = `There is one ${branded(deps.vocab, deps.vocab.noun)} here in all, ${sole.name}${sole.city ? ` in ${sole.city}` : ''}. There is no other hotel and no other city here - nothing else to search for or to offer.`;
|
|
4697
4988
|
return {
|
|
4698
4989
|
...page,
|
|
4699
4990
|
// In both, in the same words: a host may hand its model this rather
|
|
@@ -4910,8 +5201,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4910
5201
|
ask.filters?.hourly && hotels.some((h) => h.forStay?.hours)
|
|
4911
5202
|
? `By the hour, today: each price is that ${deps.vocab.noun}'s cheapest room by the hour, for its hours, taxes included.`
|
|
4912
5203
|
: 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`
|
|
5204
|
+
? `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')
|
|
5205
|
+
? `; ${hotels.filter((h) => h.noRoomsForStay && h.noRoomsForStayCode !== 'none-on-sale').length} in this answer have no room for those nights`
|
|
5206
|
+
: ''}${
|
|
5207
|
+
// The other kind, said as its own (U11): other nights will not help.
|
|
5208
|
+
hotels.some((h) => h.noRoomsForStayCode === 'none-on-sale')
|
|
5209
|
+
? `; ${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 here - ${NOT_SOLD_OUT}`
|
|
4915
5210
|
: ''}.`
|
|
4916
5211
|
: '',
|
|
4917
5212
|
].filter(Boolean);
|
|
@@ -4925,7 +5220,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
4925
5220
|
// only "2 hotels", the model asked for the list again to learn which.
|
|
4926
5221
|
const degraded = degradedOf(deps, answer);
|
|
4927
5222
|
const inAll = homeList
|
|
4928
|
-
? `
|
|
5223
|
+
? `There are ${total} ${branded(deps.vocab, deps.vocab.nounPlural)} here in all, and this answer lists every one: ${hotels
|
|
4929
5224
|
.map((h) => `${hotelName(h.name)}${h.city ? ` in ${h.city}` : ''}`)
|
|
4930
5225
|
.join('; ')}. There is no other ${deps.vocab.noun} and no other city here - nothing else to list, search for or offer.`
|
|
4931
5226
|
: '';
|
|
@@ -5029,7 +5324,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5029
5324
|
};
|
|
5030
5325
|
}
|
|
5031
5326
|
const h = detail.hotel;
|
|
5032
|
-
const description = hotelText('description', h.name,
|
|
5327
|
+
const description = hotelText('description', h.name,
|
|
5328
|
+
// Its 【…】 parts a line each, the words as they came (`aboutLines`).
|
|
5329
|
+
aboutLines(h.summary), DESCRIPTION_MAX, deps.vocab.demo);
|
|
5033
5330
|
const policies = await deps.backend.policies(h.id);
|
|
5034
5331
|
const extras = await deps.backend.extrasFor(h.id, stay);
|
|
5035
5332
|
const base = {
|
|
@@ -5076,17 +5373,23 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5076
5373
|
: [];
|
|
5077
5374
|
const flex = dateFlex(dates, stay);
|
|
5078
5375
|
if (detail.noRooms || !detail.rooms) {
|
|
5376
|
+
// ⚠️ NOTHING ON SALE HERE SAYS WHERE TO BOOK IT (T5): the same sentence
|
|
5377
|
+
// in the text and in `noRooms`, which the card does not draw for this code
|
|
5378
|
+
// (it says it in the guest's language from `noRoomsCode`).
|
|
5379
|
+
const said = detail.noRooms && detail.noRoomsCode === 'none-on-sale'
|
|
5380
|
+
? notOnSaleWords(groupNameOf(deps, h.id), detail.noRooms)
|
|
5381
|
+
: detail.noRooms;
|
|
5079
5382
|
return {
|
|
5080
5383
|
structured: {
|
|
5081
5384
|
view: 'no-rooms',
|
|
5082
5385
|
...base,
|
|
5083
5386
|
stay,
|
|
5084
|
-
noRooms:
|
|
5387
|
+
noRooms: said,
|
|
5085
5388
|
...(detail.noRoomsCode ? { noRoomsCode: detail.noRoomsCode } : {}),
|
|
5086
5389
|
...(detail.noRoomsArgs ? { noRoomsArgs: detail.noRoomsArgs } : {}),
|
|
5087
5390
|
...flex.structured,
|
|
5088
5391
|
},
|
|
5089
|
-
text: [
|
|
5392
|
+
text: [said ?? 'Nothing is available for those nights.', flex.text]
|
|
5090
5393
|
.filter(Boolean)
|
|
5091
5394
|
.join(' '),
|
|
5092
5395
|
};
|
|
@@ -5254,7 +5557,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5254
5557
|
continue;
|
|
5255
5558
|
const h = detail.hotel;
|
|
5256
5559
|
read.push({ id: h.id, name: h.name });
|
|
5257
|
-
const about = hotelText('description', h.name, [h.summary, h.amenities.length ? `Has: ${h.amenities.join(', ')}.` : '']
|
|
5560
|
+
const about = hotelText('description', h.name, [aboutLines(h.summary), h.amenities.length ? `Has: ${h.amenities.join(', ')}.` : '']
|
|
5258
5561
|
.filter(Boolean)
|
|
5259
5562
|
.join('\n'), DESCRIPTION_MAX, deps.vocab.demo);
|
|
5260
5563
|
const extras = await deps.backend.extrasFor(h.id, stay);
|
|
@@ -5409,7 +5712,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5409
5712
|
text: extras.length
|
|
5410
5713
|
? extras
|
|
5411
5714
|
.map((e) => `- ${e.name}${ctx.noCards ? ` (\`${e.id}\`)` : ''} — ${formatMoney(e.price)} ${unitWords(e.per)}${held[e.id] ? ` (${booked?.confirmationCode} holds ${held[e.id]})` : ''}`)
|
|
5412
|
-
.concat(deps.vocab.demo ? [noteFor(deps.vocab)] : [])
|
|
5715
|
+
.concat(deps.vocab.demo && noteFor(deps.vocab) ? [noteFor(deps.vocab)] : [])
|
|
5413
5716
|
.join('\n')
|
|
5414
5717
|
: `${detail.hotel.name} has no add-ons listed here.`,
|
|
5415
5718
|
};
|
|
@@ -5445,7 +5748,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5445
5748
|
...(isDay(str(args.day)) ? { day: str(args.day) } : {}),
|
|
5446
5749
|
};
|
|
5447
5750
|
deps.trips.set(key, line);
|
|
5448
|
-
|
|
5751
|
+
// ⚠️ THE COUPON STAYS ON, like on a room (2026-11-03 review, C11): its
|
|
5752
|
+
// schema says it keeps the new price, and `undefined` here dropped it.
|
|
5753
|
+
return currentTrip(deps, key, identity(deps, ctx, key), key, couponOf(deps, args), ctx);
|
|
5449
5754
|
}
|
|
5450
5755
|
return putRoomLine(deps, t, ctx, key, key, args, detail);
|
|
5451
5756
|
}
|
|
@@ -5492,6 +5797,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5492
5797
|
if (name === t.listBookings) {
|
|
5493
5798
|
const who = identity(deps, ctx, key, args);
|
|
5494
5799
|
const here = await deps.backend.listBookings(who, typeof args.limit === 'number' ? args.limit : undefined);
|
|
5800
|
+
// Read here, before a filter or a spread drops it: the groups whose
|
|
5801
|
+
// bookings are not in `here` (2026-11-03 review, C5).
|
|
5802
|
+
const missing = degradedOf(deps, { degraded: here.degraded }, 'bookings');
|
|
5495
5803
|
// Elsewhere, only with the code AND the email - see `listBookings`.
|
|
5496
5804
|
const code = str(args.booking);
|
|
5497
5805
|
const { found: there, tooMany } = code && str(args.email)
|
|
@@ -5517,6 +5825,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5517
5825
|
// than "nothing in this conversation".
|
|
5518
5826
|
...(missed ? { notFound: code, notFoundLine: missLine } : {}),
|
|
5519
5827
|
...(tooMany ? { tooMany: true } : {}),
|
|
5828
|
+
...missing.structured,
|
|
5520
5829
|
},
|
|
5521
5830
|
text: [
|
|
5522
5831
|
missed ? missLine : '',
|
|
@@ -5524,7 +5833,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5524
5833
|
? bookings.map((b) => bookingLine(b, ctx.noCards)).join('\n')
|
|
5525
5834
|
: code
|
|
5526
5835
|
? ''
|
|
5527
|
-
:
|
|
5836
|
+
: // ⚠️ NOT "NO BOOKINGS" when a group did not answer: it may hold them.
|
|
5837
|
+
missing.said
|
|
5838
|
+
? ''
|
|
5839
|
+
: 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
|
|
5840
|
+
missing.said,
|
|
5528
5841
|
]
|
|
5529
5842
|
.filter(Boolean)
|
|
5530
5843
|
.join('\n'),
|
|
@@ -5881,13 +6194,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5881
6194
|
toolToConfirm: t.cancelBooking,
|
|
5882
6195
|
// Whether cancelling hands the coupon back - the same sentence as the text.
|
|
5883
6196
|
...(preview.coupon
|
|
5884
|
-
? {
|
|
6197
|
+
? {
|
|
6198
|
+
coupon: {
|
|
6199
|
+
...preview.coupon,
|
|
6200
|
+
said: couponBackWords(preview.coupon, formatMoney(preview.fee)),
|
|
6201
|
+
},
|
|
6202
|
+
}
|
|
5885
6203
|
: {}),
|
|
5886
6204
|
},
|
|
5887
6205
|
text: [
|
|
5888
6206
|
`Cancelling ${preview.confirmationCode} would cost ${formatMoney(preview.fee)}${preview.fee.minor === 0 ? ' - nothing' : ''}.`,
|
|
5889
6207
|
preview.rule,
|
|
5890
|
-
preview.coupon ? couponBackWords(preview.coupon) : '',
|
|
6208
|
+
preview.coupon ? couponBackWords(preview.coupon, formatMoney(preview.fee)) : '',
|
|
5891
6209
|
preview.cancellable
|
|
5892
6210
|
? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
|
|
5893
6211
|
: '',
|
|
@@ -5930,6 +6248,28 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5930
6248
|
// will be charged.
|
|
5931
6249
|
const charged = cancelled.cancellationFee ?? preview.fee;
|
|
5932
6250
|
const h = holderOf(deps, cancelled.supplier);
|
|
6251
|
+
// What became of the coupon the booking used, said once and handed over in
|
|
6252
|
+
// both: the structure's `coupon` and the text's sentence are this one string.
|
|
6253
|
+
const couponNow = cancelled.quote.coupon && cancelled.couponReturned !== undefined
|
|
6254
|
+
? (() => {
|
|
6255
|
+
const returned = cancelled.couponReturned === true;
|
|
6256
|
+
const c = {
|
|
6257
|
+
id: cancelled.quote.coupon.id,
|
|
6258
|
+
name: cancelled.quote.coupon.name,
|
|
6259
|
+
...(cancelled.quote.coupon.names ? { names: cancelled.quote.coupon.names } : {}),
|
|
6260
|
+
validUntil: cancelled.quote.coupon.validUntil,
|
|
6261
|
+
returned,
|
|
6262
|
+
// A booking cancelled before the reason was kept: a coupon not
|
|
6263
|
+
// returned could then only have run out.
|
|
6264
|
+
...(returned
|
|
6265
|
+
? {}
|
|
6266
|
+
: {
|
|
6267
|
+
notReturnedBecause: cancelled.couponNotReturnedBecause ?? 'expired',
|
|
6268
|
+
}),
|
|
6269
|
+
};
|
|
6270
|
+
return { ...c, said: couponBackWords(c, formatMoney(charged), true) };
|
|
6271
|
+
})()
|
|
6272
|
+
: undefined;
|
|
5933
6273
|
return {
|
|
5934
6274
|
structured: {
|
|
5935
6275
|
view: 'cancelled',
|
|
@@ -5940,25 +6280,25 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5940
6280
|
fee: formatMoney(charged),
|
|
5941
6281
|
rule: preview.rule,
|
|
5942
6282
|
cancellable: false,
|
|
6283
|
+
...(couponNow ? { coupon: couponNow } : {}),
|
|
5943
6284
|
},
|
|
5944
6285
|
text: [
|
|
5945
6286
|
cancelled.payment
|
|
5946
6287
|
? // Paid here in advance: what becomes of the money is the answer.
|
|
5947
6288
|
`${cancelled.confirmationCode} is ${cancelled.status === 'expired' ? 'called off before it was paid' : 'cancelled'}. ${paidLine(deps, cancelled, charged)}`
|
|
5948
|
-
: h.
|
|
5949
|
-
? // A
|
|
5950
|
-
`${cancelled.confirmationCode} is cancelled
|
|
5951
|
-
: h.
|
|
5952
|
-
?
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
:
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
: '',
|
|
6289
|
+
: h.notReal
|
|
6290
|
+
? // A market's group: the fact, in no environment's word (robin, 2026-11-03).
|
|
6291
|
+
`${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this booking was never sent to ${h.name}, and nothing is charged.`}`
|
|
6292
|
+
: h.sandbox
|
|
6293
|
+
? // A simulated booking cancelled in the simulation: no hotel, no charge (2026-10-05 audit, P2).
|
|
6294
|
+
`${cancelled.confirmationCode} is cancelled in the simulated system. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this one is simulated, and nothing is charged.`}`
|
|
6295
|
+
: h.test
|
|
6296
|
+
? `${cancelled.confirmationCode} is cancelled in ${h.name}'s test system. ${charged.minor === 0 ? 'No fee.' : `A live booking on this rate would be charged ${formatMoney(charged)}; this one is a test, and nothing is charged.`}`
|
|
6297
|
+
: h.demo
|
|
6298
|
+
? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
|
|
6299
|
+
`${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.`}`
|
|
6300
|
+
: `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
|
|
6301
|
+
couponNow?.said ?? '',
|
|
5962
6302
|
demoBookingNote(deps, cancelled.supplier),
|
|
5963
6303
|
deps.vocab.demo ? noteFor(deps.vocab) : '',
|
|
5964
6304
|
]
|
|
@@ -5982,7 +6322,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5982
6322
|
who: identity(deps, ctx, key),
|
|
5983
6323
|
});
|
|
5984
6324
|
if (comparison.columns.length === 0) {
|
|
5985
|
-
|
|
6325
|
+
// A group that did not answer is not "not ours" (2026-11-03 review, C5):
|
|
6326
|
+
// its hotels are said as left out for that.
|
|
6327
|
+
const silent = comparison.dropped.filter((d) => d.code === 'supplier-timeout');
|
|
6328
|
+
const unknown = comparison.dropped.filter((d) => !d.candidates?.length && d.code !== 'supplier-timeout');
|
|
5986
6329
|
const fitsMany = fitsManyOf(deps, comparison.dropped);
|
|
5987
6330
|
return {
|
|
5988
6331
|
structured: {
|
|
@@ -5992,12 +6335,13 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
5992
6335
|
},
|
|
5993
6336
|
// A name that fits several IS one of ours - only the rest are "none".
|
|
5994
6337
|
text: [
|
|
5995
|
-
...(unknown.length || !fitsMany.length
|
|
6338
|
+
...(unknown.length || (!fitsMany.length && !silent.length)
|
|
5996
6339
|
? [
|
|
5997
6340
|
`None of those are ${catalogue(deps.vocab)}: ${unknown.map((d) => d.name).join(', ')}.`,
|
|
5998
6341
|
]
|
|
5999
6342
|
: []),
|
|
6000
6343
|
...fitsMany,
|
|
6344
|
+
...silent.map((d) => `Left out: ${d.name} — ${d.why}`),
|
|
6001
6345
|
].join('\n'),
|
|
6002
6346
|
};
|
|
6003
6347
|
}
|
|
@@ -6048,6 +6392,25 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
6048
6392
|
}
|
|
6049
6393
|
if (name === t.itinerary) {
|
|
6050
6394
|
const days = Array.isArray(args.days) ? args.days : [];
|
|
6395
|
+
// ⚠️ WHICH STAYS ARE BOOKED ALREADY marks the plan; a group that did not
|
|
6396
|
+
// answer cannot be checked, and the plan says so rather than showing its
|
|
6397
|
+
// stays as not booked (2026-11-03 review, C5).
|
|
6398
|
+
let planBookings = [];
|
|
6399
|
+
let unchecked = [];
|
|
6400
|
+
try {
|
|
6401
|
+
const mine = await deps.backend.listBookings(identity(deps, ctx, key, args), 50);
|
|
6402
|
+
planBookings = mine;
|
|
6403
|
+
unchecked = mine.degraded ?? [];
|
|
6404
|
+
}
|
|
6405
|
+
catch (err) {
|
|
6406
|
+
const down = unansweredBy(err);
|
|
6407
|
+
if (!down)
|
|
6408
|
+
throw err;
|
|
6409
|
+
unchecked = down;
|
|
6410
|
+
}
|
|
6411
|
+
const uncheckedSaid = unchecked.length
|
|
6412
|
+
? `${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.`
|
|
6413
|
+
: '';
|
|
6051
6414
|
const plan = await buildItinerary(deps.backend, days.map((d) => ({
|
|
6052
6415
|
label: str(d.label),
|
|
6053
6416
|
note: typeof d.note === 'string' ? d.note : undefined,
|
|
@@ -6058,7 +6421,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
6058
6421
|
title: str(args.title),
|
|
6059
6422
|
stay: asStay(args),
|
|
6060
6423
|
trip: deps.trips.read(key),
|
|
6061
|
-
bookings:
|
|
6424
|
+
bookings: planBookings,
|
|
6062
6425
|
who: identity(deps, ctx, key),
|
|
6063
6426
|
});
|
|
6064
6427
|
if (plan.days.length === 0) {
|
|
@@ -6120,6 +6483,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
6120
6483
|
gap,
|
|
6121
6484
|
...(nudges.length ? { nudges } : {}),
|
|
6122
6485
|
...(overlaps.length ? { overlaps } : {}),
|
|
6486
|
+
...(uncheckedSaid
|
|
6487
|
+
? {
|
|
6488
|
+
degraded: unchecked.map((d) => ({ supplier: d.supplier, name: d.name })),
|
|
6489
|
+
degradedSaid: uncheckedSaid,
|
|
6490
|
+
}
|
|
6491
|
+
: {}),
|
|
6123
6492
|
// In both, like the nudges: a host with no card may hand its model
|
|
6124
6493
|
// this rather than the text.
|
|
6125
6494
|
...(ctx.noCards ? { notDrawn: PLAN_NOT_DRAWN } : {}),
|
|
@@ -6139,6 +6508,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
6139
6508
|
: []),
|
|
6140
6509
|
...plan.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
|
|
6141
6510
|
...fitsManyOf(deps, plan.dropped),
|
|
6511
|
+
uncheckedSaid,
|
|
6142
6512
|
ctx.noCards ? PLAN_NOT_DRAWN : PLAN_DRAWN_ONCE,
|
|
6143
6513
|
deps.vocab.demo ? noteFor(deps.vocab) : '',
|
|
6144
6514
|
]
|
|
@@ -6177,7 +6547,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
|
6177
6547
|
// would find out at the desk.
|
|
6178
6548
|
return {
|
|
6179
6549
|
structured: { view: 'member-not-found', ...common(deps, key), query: number },
|
|
6180
|
-
text: `No ${deps.vocab.brand} membership matches that number and surname. Check both as they are printed; nothing else is needed -
|
|
6550
|
+
text: `No ${deps.vocab.brand} membership matches that number and surname. Check both as they are printed; nothing else is needed - a password is never asked for here.`,
|
|
6181
6551
|
};
|
|
6182
6552
|
}
|
|
6183
6553
|
deps.members.set(key, member);
|