@pisell/pisellos 2.2.292 → 2.2.293
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/core/index.d.ts +7 -0
- package/dist/core/index.js +108 -65
- package/dist/model/strategy/adapter/itemRule/adapter.d.ts +4 -1
- package/dist/model/strategy/adapter/itemRule/adapter.js +135 -35
- package/dist/model/strategy/adapter/itemRule/evaluator.d.ts +8 -1
- package/dist/model/strategy/adapter/itemRule/evaluator.js +25 -1
- package/dist/model/strategy/adapter/itemRule/type.d.ts +44 -0
- package/dist/model/strategy/adapter/itemRule/type.js +19 -1
- package/dist/modules/BookingContext/index.d.ts +3 -0
- package/dist/modules/BookingContext/index.js +42 -13
- package/dist/modules/BookingContext/types.d.ts +4 -4
- package/dist/modules/BookingContext/types.js +3 -3
- package/dist/modules/OpenData/types.d.ts +1 -0
- package/dist/modules/Order/index.d.ts +19 -4
- package/dist/modules/Order/index.js +885 -485
- package/dist/modules/Order/salesNotes.d.ts +31 -0
- package/dist/modules/Order/salesNotes.js +492 -0
- package/dist/modules/Order/types.d.ts +93 -5
- package/dist/modules/Order/types.js +5 -0
- package/dist/modules/Order/utils.d.ts +12 -0
- package/dist/modules/Order/utils.js +96 -31
- package/dist/modules/Product/types.d.ts +2 -0
- package/dist/modules/ProductList/clientDataVariants.d.ts +50 -0
- package/dist/modules/ProductList/clientDataVariants.js +216 -0
- package/dist/modules/ProductList/index.d.ts +18 -3
- package/dist/modules/ProductList/index.js +220 -38
- package/dist/modules/ProductList/types.d.ts +10 -0
- package/dist/modules/Quotation/index.d.ts +22 -0
- package/dist/modules/Quotation/index.js +293 -28
- package/dist/modules/ResourcePlanner/index.d.ts +0 -22
- package/dist/modules/ResourcePlanner/index.js +1 -180
- package/dist/modules/ResourcePlanner/localClock.d.ts +33 -0
- package/dist/modules/ResourcePlanner/localClock.js +183 -0
- package/dist/modules/ResourcePlanner/planner.d.ts +5 -14
- package/dist/modules/ResourcePlanner/planner.js +2111 -958
- package/dist/modules/ResourcePlanner/types.d.ts +359 -187
- package/dist/modules/ResourcePlanner/types.js +33 -1
- package/dist/plugins/request.d.ts +1 -0
- package/dist/server/index.d.ts +17 -2
- package/dist/server/index.js +1134 -932
- package/dist/server/modules/floor-plan/index.d.ts +4 -0
- package/dist/server/modules/floor-plan/index.js +240 -98
- package/dist/server/modules/menu/index.js +48 -23
- package/dist/server/modules/order/index.d.ts +17 -7
- package/dist/server/modules/order/index.js +419 -207
- package/dist/server/modules/products/index.d.ts +10 -4
- package/dist/server/modules/products/index.js +177 -84
- package/dist/server/modules/resource/index.js +21 -13
- package/dist/server/utils/product.d.ts +1 -1
- package/dist/server/utils/product.js +186 -22
- package/dist/server/utils/small-ticket.d.ts +9 -1
- package/dist/server/utils/small-ticket.js +136 -41
- package/dist/solution/BaseSales/index.d.ts +26 -3
- package/dist/solution/BaseSales/index.js +1384 -905
- package/dist/solution/BaseSales/types.d.ts +2 -0
- package/dist/solution/BaseSales/utils/transformBaseProductToOrderProduct.d.ts +3 -0
- package/dist/solution/BaseSales/utils/transformBaseProductToOrderProduct.js +10 -3
- package/dist/solution/BaseSales/utils.js +6 -3
- package/dist/solution/BookingByStep/index.d.ts +4 -4
- package/dist/solution/BookingTicket/index.d.ts +1 -1
- package/dist/solution/BookingTicket/index.js +4 -2
- package/dist/solution/BookingTicket/utils/addProductDecision.d.ts +2 -1
- package/dist/solution/BookingTicket/utils/addProductDecision.js +6 -3
- package/dist/solution/BookingTicket/utils/orderCustomer.js +3 -2
- package/dist/solution/RegisterAndLogin/config.d.ts +9 -3
- package/dist/solution/RegisterAndLogin/config.js +95 -40
- package/dist/solution/RegisterAndLogin/index.js +4 -1
- package/dist/solution/ScanOrder/index.d.ts +1 -0
- package/dist/solution/ScanOrder/index.js +31 -27
- package/dist/solution/ScanOrder/types.d.ts +2 -0
- package/dist/solution/ScanOrder/utils.d.ts +1 -0
- package/dist/solution/ScanOrder/utils.js +8 -3
- package/dist/solution/UnifiedBookingSales/index.d.ts +128 -74
- package/dist/solution/UnifiedBookingSales/index.js +3877 -963
- package/dist/solution/UnifiedBookingSales/remoteOccupancyCache.d.ts +49 -0
- package/dist/solution/UnifiedBookingSales/remoteOccupancyCache.js +430 -0
- package/dist/solution/UnifiedBookingSales/types.d.ts +161 -77
- package/dist/solution/UnifiedBookingSales/types.js +4 -9
- package/dist/solution/VenueBooking/index.d.ts +3 -0
- package/dist/solution/VenueBooking/index.js +549 -497
- package/dist/solution/VenueBooking/utils/dateSummary.d.ts +1 -0
- package/dist/solution/VenueBooking/utils/dateSummary.js +3 -1
- package/dist/solution/VenueBooking/utils/slotMerge.d.ts +2 -0
- package/dist/solution/VenueBooking/utils/slotMerge.js +3 -1
- package/dist/utils/platform.d.ts +8 -0
- package/dist/utils/platform.js +15 -0
- package/lib/core/index.d.ts +7 -0
- package/lib/core/index.js +20 -2
- package/lib/model/strategy/adapter/itemRule/adapter.d.ts +4 -1
- package/lib/model/strategy/adapter/itemRule/adapter.js +73 -4
- package/lib/model/strategy/adapter/itemRule/evaluator.d.ts +8 -1
- package/lib/model/strategy/adapter/itemRule/evaluator.js +23 -1
- package/lib/model/strategy/adapter/itemRule/type.d.ts +44 -0
- package/lib/model/strategy/adapter/itemRule/type.js +19 -1
- package/lib/modules/BookingContext/index.d.ts +3 -0
- package/lib/modules/BookingContext/index.js +35 -8
- package/lib/modules/BookingContext/types.d.ts +4 -4
- package/lib/modules/BookingContext/types.js +3 -3
- package/lib/modules/OpenData/types.d.ts +1 -0
- package/lib/modules/Order/index.d.ts +19 -4
- package/lib/modules/Order/index.js +325 -28
- package/lib/modules/Order/salesNotes.d.ts +31 -0
- package/lib/modules/Order/salesNotes.js +382 -0
- package/lib/modules/Order/types.d.ts +93 -5
- package/lib/modules/Order/types.js +4 -0
- package/lib/modules/Order/utils.d.ts +12 -0
- package/lib/modules/Order/utils.js +85 -16
- package/lib/modules/Product/types.d.ts +2 -0
- package/lib/modules/ProductList/clientDataVariants.d.ts +50 -0
- package/lib/modules/ProductList/clientDataVariants.js +204 -0
- package/lib/modules/ProductList/index.d.ts +18 -3
- package/lib/modules/ProductList/index.js +137 -8
- package/lib/modules/ProductList/types.d.ts +10 -0
- package/lib/modules/Quotation/index.d.ts +22 -0
- package/lib/modules/Quotation/index.js +158 -11
- package/lib/modules/ResourcePlanner/index.d.ts +0 -22
- package/lib/modules/ResourcePlanner/index.js +9 -129
- package/lib/modules/ResourcePlanner/localClock.d.ts +33 -0
- package/lib/modules/ResourcePlanner/localClock.js +205 -0
- package/lib/modules/ResourcePlanner/planner.d.ts +5 -14
- package/lib/modules/ResourcePlanner/planner.js +1807 -783
- package/lib/modules/ResourcePlanner/types.d.ts +359 -187
- package/lib/modules/ResourcePlanner/types.js +13 -1
- package/lib/plugins/request.d.ts +1 -0
- package/lib/server/index.d.ts +17 -2
- package/lib/server/index.js +149 -21
- package/lib/server/modules/floor-plan/index.d.ts +4 -0
- package/lib/server/modules/floor-plan/index.js +60 -7
- package/lib/server/modules/menu/index.js +26 -4
- package/lib/server/modules/order/index.d.ts +17 -7
- package/lib/server/modules/order/index.js +194 -57
- package/lib/server/modules/products/index.d.ts +10 -4
- package/lib/server/modules/products/index.js +93 -23
- package/lib/server/modules/resource/index.js +12 -2
- package/lib/server/utils/product.d.ts +1 -1
- package/lib/server/utils/product.js +163 -3
- package/lib/server/utils/small-ticket.d.ts +9 -1
- package/lib/server/utils/small-ticket.js +126 -33
- package/lib/solution/BaseSales/index.d.ts +26 -3
- package/lib/solution/BaseSales/index.js +396 -70
- package/lib/solution/BaseSales/types.d.ts +2 -0
- package/lib/solution/BaseSales/utils/transformBaseProductToOrderProduct.d.ts +3 -0
- package/lib/solution/BaseSales/utils/transformBaseProductToOrderProduct.js +9 -2
- package/lib/solution/BaseSales/utils.js +3 -0
- package/lib/solution/BookingByStep/index.d.ts +4 -4
- package/lib/solution/BookingTicket/index.d.ts +1 -1
- package/lib/solution/BookingTicket/index.js +2 -1
- package/lib/solution/BookingTicket/utils/addProductDecision.d.ts +2 -1
- package/lib/solution/BookingTicket/utils/addProductDecision.js +5 -2
- package/lib/solution/BookingTicket/utils/orderCustomer.js +2 -1
- package/lib/solution/RegisterAndLogin/config.d.ts +9 -3
- package/lib/solution/RegisterAndLogin/config.js +49 -8
- package/lib/solution/RegisterAndLogin/index.js +4 -1
- package/lib/solution/ScanOrder/index.d.ts +1 -0
- package/lib/solution/ScanOrder/index.js +5 -1
- package/lib/solution/ScanOrder/types.d.ts +2 -0
- package/lib/solution/ScanOrder/utils.d.ts +1 -0
- package/lib/solution/ScanOrder/utils.js +4 -0
- package/lib/solution/UnifiedBookingSales/index.d.ts +128 -74
- package/lib/solution/UnifiedBookingSales/index.js +2592 -424
- package/lib/solution/UnifiedBookingSales/remoteOccupancyCache.d.ts +49 -0
- package/lib/solution/UnifiedBookingSales/remoteOccupancyCache.js +316 -0
- package/lib/solution/UnifiedBookingSales/types.d.ts +161 -77
- package/lib/solution/UnifiedBookingSales/types.js +4 -6
- package/lib/solution/VenueBooking/index.d.ts +3 -0
- package/lib/solution/VenueBooking/index.js +33 -9
- package/lib/solution/VenueBooking/utils/dateSummary.d.ts +1 -0
- package/lib/solution/VenueBooking/utils/dateSummary.js +3 -1
- package/lib/solution/VenueBooking/utils/slotMerge.d.ts +2 -0
- package/lib/solution/VenueBooking/utils/slotMerge.js +3 -1
- package/lib/utils/platform.d.ts +8 -0
- package/lib/utils/platform.js +22 -0
- package/package.json +1 -1
|
@@ -5,26 +5,36 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
});
|
|
6
6
|
var _exportNames = {
|
|
7
7
|
buildPlannerDiscountResult: true,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
normalizeProductForAvailability: true,
|
|
9
|
+
buildAvailabilityResourcesFromV2: true,
|
|
10
|
+
collectAvailabilityResourceIds: true,
|
|
11
|
+
buildAvailabilityLineIdentity: true,
|
|
12
|
+
buildBookingFromAvailabilityAssignment: true,
|
|
13
|
+
buildBookingFromAvailabilityAssignments: true,
|
|
14
14
|
UnifiedBookingSalesImpl: true,
|
|
15
|
-
UnifiedBookingSales: true
|
|
15
|
+
UnifiedBookingSales: true,
|
|
16
|
+
AvailabilityError: true
|
|
16
17
|
};
|
|
18
|
+
Object.defineProperty(exports, "AvailabilityError", {
|
|
19
|
+
enumerable: true,
|
|
20
|
+
get: function () {
|
|
21
|
+
return _ResourcePlanner.AvailabilityError;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
17
24
|
exports.UnifiedBookingSalesImpl = exports.UnifiedBookingSales = void 0;
|
|
18
|
-
exports.
|
|
19
|
-
exports.
|
|
25
|
+
exports.buildAvailabilityLineIdentity = buildAvailabilityLineIdentity;
|
|
26
|
+
exports.buildAvailabilityResourcesFromV2 = buildAvailabilityResourcesFromV2;
|
|
27
|
+
exports.buildBookingFromAvailabilityAssignment = buildBookingFromAvailabilityAssignment;
|
|
28
|
+
exports.buildBookingFromAvailabilityAssignments = buildBookingFromAvailabilityAssignments;
|
|
20
29
|
exports.buildPlannerDiscountResult = buildPlannerDiscountResult;
|
|
21
|
-
exports.
|
|
22
|
-
exports.
|
|
23
|
-
exports.collectPlannerResourceIds = collectPlannerResourceIds;
|
|
24
|
-
exports.normalizeProductForPlanner = normalizeProductForPlanner;
|
|
30
|
+
exports.collectAvailabilityResourceIds = collectAvailabilityResourceIds;
|
|
31
|
+
exports.normalizeProductForAvailability = normalizeProductForAvailability;
|
|
25
32
|
var _dayjs = _interopRequireDefault(require("dayjs"));
|
|
33
|
+
var _lodashEs = require("lodash-es");
|
|
26
34
|
var _modules = require("../../modules");
|
|
35
|
+
var _utils = require("../../modules/BookingContext/utils");
|
|
27
36
|
var _getDateIsInSchedule = require("../../modules/Schedule/getDateIsInSchedule");
|
|
37
|
+
var _ResourcePlanner = require("../../modules/ResourcePlanner");
|
|
28
38
|
var _types = require("./types");
|
|
29
39
|
Object.keys(_types).forEach(function (key) {
|
|
30
40
|
if (key === "default" || key === "__esModule") return;
|
|
@@ -37,12 +47,59 @@ Object.keys(_types).forEach(function (key) {
|
|
|
37
47
|
}
|
|
38
48
|
});
|
|
39
49
|
});
|
|
50
|
+
var _remoteOccupancyCache = require("./remoteOccupancyCache");
|
|
40
51
|
var _BookingTicket = require("../BookingTicket");
|
|
52
|
+
var _localClock = require("../../modules/ResourcePlanner/localClock");
|
|
53
|
+
var _platform = require("../../utils/platform");
|
|
54
|
+
var _itemRule = require("../../model/strategy/adapter/itemRule");
|
|
41
55
|
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const OPEN_DATA_SECTION_CODES = ['sale', 'reservation', 'fulfillment', 'menu', 'workflow'];
|
|
56
|
+
const OPEN_DATA_SECTION_CODES = ['sale', 'reservation', 'fulfillment', 'menu', 'workflow', 'basic', 'checkout', 'availability'];
|
|
45
57
|
const OPEN_DATA_CACHE_TTL = 5 * 60 * 1000;
|
|
58
|
+
function normalizePositiveInteger(value) {
|
|
59
|
+
const normalized = Number(value);
|
|
60
|
+
if (!Number.isInteger(normalized) || normalized <= 0) return undefined;
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
function getCandidateDurationMinutes(candidate) {
|
|
64
|
+
const startAt = candidate.bookingStartAt || candidate.startAt;
|
|
65
|
+
const endAt = candidate.bookingEndAt || candidate.endAt;
|
|
66
|
+
try {
|
|
67
|
+
const start = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(startAt));
|
|
68
|
+
const end = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(endAt));
|
|
69
|
+
const minutes = Math.round((end - start) / 60000);
|
|
70
|
+
return Number.isInteger(minutes) && minutes > 0 ? minutes : undefined;
|
|
71
|
+
} catch (_error) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function getCandidateStartTime(candidate) {
|
|
76
|
+
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(candidate.startTime || '')) {
|
|
77
|
+
return candidate.startTime;
|
|
78
|
+
}
|
|
79
|
+
const startAt = candidate.bookingStartAt || candidate.startAt;
|
|
80
|
+
try {
|
|
81
|
+
const start = (0, _localClock.parseLocalDateTime)(startAt);
|
|
82
|
+
return `${String(start.hour).padStart(2, '0')}:${String(start.minute).padStart(2, '0')}`;
|
|
83
|
+
} catch (_error) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function normalizePositiveIntegerList(value) {
|
|
88
|
+
if (!Array.isArray(value)) return [];
|
|
89
|
+
return Array.from(new Set(value.map(item => normalizePositiveInteger(item)).filter(item => item !== undefined)));
|
|
90
|
+
}
|
|
91
|
+
function toConfiguredBoolean(value) {
|
|
92
|
+
if (typeof value === 'boolean') return value;
|
|
93
|
+
if (typeof value === 'number') return value !== 0;
|
|
94
|
+
if (typeof value === 'string') {
|
|
95
|
+
return ['1', 'true', 'yes', 'y', 'on'].includes(value.trim().toLowerCase());
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
function normalizeItemRuleStrategyConfigs(value) {
|
|
100
|
+
if (!Array.isArray(value)) return [];
|
|
101
|
+
return value.filter(config => Boolean(config && typeof config === 'object' && config.metadata?.type === 'item_rule'));
|
|
102
|
+
}
|
|
46
103
|
function isRecord(value) {
|
|
47
104
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
48
105
|
}
|
|
@@ -149,82 +206,477 @@ function filterCustomerBoundDiscounts(discountList) {
|
|
|
149
206
|
if (!Array.isArray(discountList)) return [];
|
|
150
207
|
return discountList.filter(discount => !isCustomerBoundDiscount(discount));
|
|
151
208
|
}
|
|
152
|
-
|
|
153
|
-
|
|
209
|
+
const DEFAULT_AVAILABILITY_SLOT_STEP_MINUTES = 30;
|
|
210
|
+
const DEFAULT_AVAILABILITY_BUSINESS_HOURS = {
|
|
211
|
+
// Same start/end is interpreted as an overnight range, i.e. 24 hours.
|
|
212
|
+
startTime: '00:00',
|
|
213
|
+
endTime: '00:00'
|
|
214
|
+
};
|
|
215
|
+
const MAX_AVAILABILITY_RANGE_DAYS = 90;
|
|
216
|
+
const MAX_AVAILABILITY_CONTEXT_REGISTRY = 128;
|
|
217
|
+
const MAX_AVAILABILITY_PROJECTION_POOL = 16;
|
|
218
|
+
const MAX_REMOTE_OCCUPANCY_RESOURCE_DATE_CELLS = 4096;
|
|
219
|
+
function isValidClockTime(value) {
|
|
220
|
+
const match = /^(\d{2}):(\d{2})$/.exec(value);
|
|
221
|
+
if (!match) return false;
|
|
222
|
+
return Number(match[1]) < 24 && Number(match[2]) < 60;
|
|
223
|
+
}
|
|
224
|
+
function assertValidAvailabilityTimezone(timezoneName) {
|
|
225
|
+
if (!String(timezoneName || '').trim()) {
|
|
226
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'timezone label 不能为空', {
|
|
227
|
+
timezone: timezoneName
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function normalizeAvailabilityLocalDateTime(value, fixedOffsetMinutes) {
|
|
232
|
+
if (value == null || value === '') return null;
|
|
233
|
+
try {
|
|
234
|
+
const localText = typeof value?.format === 'function' ? value.format('YYYY-MM-DDTHH:mm:ss') : String(value);
|
|
235
|
+
return (0, _localClock.formatLocalDateTime)((0, _localClock.parseLocalDateTime)(localText), fixedOffsetMinutes);
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function normalizeAvailabilityLocalDateTimeRange(params) {
|
|
241
|
+
const startText = normalizeAvailabilityLocalDateTime(params.startAt, params.fixedOffsetMinutes);
|
|
242
|
+
const endText = normalizeAvailabilityLocalDateTime(params.endAt, params.fixedOffsetMinutes);
|
|
243
|
+
if (!startText || !endText) return null;
|
|
244
|
+
const startScalar = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(startText));
|
|
245
|
+
let endScalar = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(endText));
|
|
246
|
+
if (endScalar <= startScalar && params.rollOverEnd) {
|
|
247
|
+
endScalar = (0, _localClock.addLocalCalendarDays)(endScalar, 1);
|
|
248
|
+
}
|
|
249
|
+
if (endScalar <= startScalar) return null;
|
|
250
|
+
return {
|
|
251
|
+
startAt: startText,
|
|
252
|
+
endAt: (0, _localClock.formatLocalDateTime)(endScalar, params.fixedOffsetMinutes),
|
|
253
|
+
startScalar,
|
|
254
|
+
endScalar
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function sameAvailabilityId(left, right) {
|
|
258
|
+
return left !== undefined && left !== null && right !== undefined && right !== null && String(left) === String(right);
|
|
259
|
+
}
|
|
260
|
+
function uniqueAvailabilityIds(values) {
|
|
261
|
+
const result = new Map();
|
|
262
|
+
values.forEach(value => {
|
|
263
|
+
const id = toNumberId(value);
|
|
264
|
+
if (id !== undefined) result.set(String(id), id);
|
|
265
|
+
});
|
|
266
|
+
return Array.from(result.values());
|
|
267
|
+
}
|
|
268
|
+
function assertAvailabilityCatalogProductIds(productIds, context) {
|
|
269
|
+
if (productIds == null) return;
|
|
270
|
+
if (!Array.isArray(productIds)) {
|
|
271
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', `${context} 必须是 number[]`, {
|
|
272
|
+
context,
|
|
273
|
+
productIds
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const invalidIds = productIds.filter(id => typeof id !== 'number' || !Number.isFinite(id));
|
|
277
|
+
if (invalidIds.length > 0) {
|
|
278
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', `${context} 只能包含有限数值商品 ID`, {
|
|
279
|
+
context,
|
|
280
|
+
invalidIds,
|
|
281
|
+
productIds
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function normalizeResourceType(_value) {
|
|
286
|
+
// 当前业务只支持每个资源组选择一个具体资源。
|
|
287
|
+
// 保留公共联合类型以兼容已有调用方,但暂不启用 multiple/capacity 分支。
|
|
288
|
+
return 'single';
|
|
289
|
+
}
|
|
154
290
|
function resolveProductKind(product) {
|
|
155
|
-
if (product.duration) return 'duration';
|
|
291
|
+
if (product.duration || product.service_duration) return 'duration';
|
|
292
|
+
if (product.extension_type === 'venue_slot') return 'venue_slot';
|
|
156
293
|
if (['session_product', 'session_ticket'].includes(product.extension_type)) return 'session';
|
|
157
|
-
if (product.cartDetailValue?.session || product._extend?.other?.session
|
|
158
|
-
|
|
294
|
+
if (product.cartDetailValue?.session || product._extend?.other?.session || product.session) {
|
|
295
|
+
return 'session';
|
|
296
|
+
}
|
|
297
|
+
const scheduleIds = product['schedule.ids'] || product.schedule_ids;
|
|
298
|
+
if (Array.isArray(scheduleIds) && scheduleIds.length > 0) return 'session';
|
|
159
299
|
return 'normal';
|
|
160
300
|
}
|
|
161
|
-
function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
301
|
+
function expandScheduleDateRanges(values) {
|
|
302
|
+
if (!Array.isArray(values)) return [];
|
|
303
|
+
const dates = new Set();
|
|
304
|
+
values.forEach(value => {
|
|
305
|
+
if (typeof value === 'string') {
|
|
306
|
+
try {
|
|
307
|
+
dates.add((0, _localClock.formatLocalDate)((0, _localClock.parseLocalDate)(value.slice(0, 10))));
|
|
308
|
+
} catch {
|
|
309
|
+
// Ignore invalid configuration entries.
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const startValue = value?.start || value?.start_date || value?.date;
|
|
314
|
+
const endValue = value?.end || value?.end_date || startValue;
|
|
315
|
+
let start;
|
|
316
|
+
let end;
|
|
317
|
+
try {
|
|
318
|
+
start = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(String(startValue).slice(0, 10)));
|
|
319
|
+
end = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(String(endValue).slice(0, 10)));
|
|
320
|
+
} catch {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (end < start) return;
|
|
324
|
+
let cursor = start;
|
|
325
|
+
let guard = 0;
|
|
326
|
+
while (cursor <= end && guard < MAX_AVAILABILITY_RANGE_DAYS) {
|
|
327
|
+
dates.add((0, _localClock.formatLocalDate)(cursor));
|
|
328
|
+
cursor = (0, _localClock.addLocalCalendarDays)(cursor, 1);
|
|
329
|
+
guard += 1;
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
return Array.from(dates).sort();
|
|
177
333
|
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Preserves form ownership while flattening default/optional resource ids.
|
|
181
|
-
* `formId` must survive because the order API requires it under booking.resources[].
|
|
182
|
-
*/
|
|
183
|
-
function normalizeRequirements(product) {
|
|
334
|
+
function normalizeRequirementGroups(product) {
|
|
184
335
|
const resources = product.product_resource?.resources;
|
|
185
336
|
if (!Array.isArray(resources)) return [];
|
|
186
|
-
return resources.filter(resource => resource?.status !== 0).map(resource =>
|
|
187
|
-
formId
|
|
188
|
-
|
|
189
|
-
resourceType
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
337
|
+
return resources.filter(resource => resource?.status !== 0).map((resource, index) => {
|
|
338
|
+
const formId = toNumberId(resource.id ?? resource.form_id);
|
|
339
|
+
const required = resource.required ?? resource.status === 1;
|
|
340
|
+
const resourceType = normalizeResourceType(resource.type);
|
|
341
|
+
const combinedIds = resource.combined_resource?.status === 1 ? resource.combined_resource?.resource_ids : [];
|
|
342
|
+
const resourceIds = uniqueAvailabilityIds([...(Array.isArray(resource.default_resource) ? resource.default_resource : []), ...(Array.isArray(resource.optional_resource) ? resource.optional_resource : []), ...(Array.isArray(combinedIds) ? combinedIds : [])]);
|
|
343
|
+
const min = required ? 1 : 0;
|
|
344
|
+
const max = 1;
|
|
345
|
+
return {
|
|
346
|
+
id: String(formId ?? resource.code ?? `requirement-${index}`),
|
|
347
|
+
formId,
|
|
348
|
+
title: pickLocalizedText(resource.title || resource.name || resource.main_field),
|
|
349
|
+
code: resource.code,
|
|
350
|
+
resourceType,
|
|
351
|
+
required: Boolean(required),
|
|
352
|
+
min,
|
|
353
|
+
max,
|
|
354
|
+
resourceIds,
|
|
355
|
+
capacityRequired: Math.max(1, Number(resource.capacity_required ?? product.capacity?.default ?? product.capacity ?? 1) || 1),
|
|
356
|
+
raw: resource
|
|
357
|
+
};
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
function normalizeScheduleRepeatType(value) {
|
|
361
|
+
if (value === 'daily' || value === 1 || value === '1') return 'daily';
|
|
362
|
+
if (value === 'weekly' || value === 2 || value === '2') return 'weekly';
|
|
363
|
+
return 'none';
|
|
364
|
+
}
|
|
365
|
+
function normalizeAbsoluteSessionRange(params) {
|
|
366
|
+
const fixedOffsetMinutes = params.fixedOffsetMinutes ?? (0, _localClock.captureRuntimeOffsetMinutes)();
|
|
367
|
+
const range = normalizeAvailabilityLocalDateTimeRange({
|
|
368
|
+
startAt: params.startAt,
|
|
369
|
+
endAt: params.endAt,
|
|
370
|
+
fixedOffsetMinutes,
|
|
371
|
+
rollOverEnd: true
|
|
372
|
+
});
|
|
373
|
+
if (!range) return null;
|
|
374
|
+
return {
|
|
375
|
+
startAt: range.startAt,
|
|
376
|
+
endAt: range.endAt,
|
|
377
|
+
scheduleRefs: [{
|
|
378
|
+
scheduleId: params.scheduleId,
|
|
379
|
+
timeSlotId: params.timeSlotId,
|
|
380
|
+
source: params.source
|
|
381
|
+
}]
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function normalizeScheduleItem(schedule, fixedOffsetMinutes) {
|
|
385
|
+
const rules = [];
|
|
386
|
+
const ranges = [];
|
|
387
|
+
const scheduleId = toNumberId(schedule.id ?? schedule.schedule_id);
|
|
388
|
+
const repeatType = normalizeScheduleRepeatType(schedule.repeat_type);
|
|
389
|
+
const repeatRule = schedule.repeat_rule || {};
|
|
390
|
+
const startAt = schedule.start_at || schedule.start || schedule.start_time;
|
|
391
|
+
const endAt = schedule.end_at || schedule.end || schedule.end_time;
|
|
392
|
+
const startDate = startAt ? String(startAt).slice(0, 10) : undefined;
|
|
393
|
+
const configuredEndDate = repeatRule.end?.type === 'date' ? repeatRule.end?.end_date : undefined;
|
|
394
|
+
const endDate = configuredEndDate ? String(configuredEndDate).slice(0, 10) : undefined;
|
|
395
|
+
const includedDates = expandScheduleDateRanges(repeatRule.included_date);
|
|
396
|
+
const excludedDates = expandScheduleDateRanges(repeatRule.excluded_date);
|
|
397
|
+
const addRule = params => {
|
|
398
|
+
const mode = repeatType === 'weekly' ? 'weekly' : 'daily';
|
|
399
|
+
rules.push({
|
|
400
|
+
id: `${String(scheduleId ?? 'schedule')}:${params.idSuffix}`,
|
|
401
|
+
mode,
|
|
402
|
+
startTime: params.startTime.slice(0, 5),
|
|
403
|
+
endTime: params.endTime.slice(0, 5),
|
|
404
|
+
startDate,
|
|
405
|
+
endDate,
|
|
406
|
+
frequency: Math.max(1, Number(repeatRule.frequency || 1)),
|
|
407
|
+
weekdays: repeatType === 'weekly' ? (repeatRule.frequency_date || []).map(Number).filter(Number.isFinite) : undefined,
|
|
408
|
+
includeDates: includedDates,
|
|
409
|
+
excludeDates: excludedDates,
|
|
410
|
+
scheduleRef: {
|
|
411
|
+
scheduleId,
|
|
412
|
+
timeSlotId: params.timeSlotId,
|
|
413
|
+
source: `schedule_${schedule.type || 'standard'}`
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
};
|
|
417
|
+
if (schedule.type === 'designation') {
|
|
418
|
+
const designations = Array.isArray(schedule.designation) ? schedule.designation : schedule.designation ? [schedule.designation] : [];
|
|
419
|
+
designations.forEach(designation => {
|
|
420
|
+
const range = normalizeAbsoluteSessionRange({
|
|
421
|
+
startAt: `${designation.start_date} ${designation.start_time}`,
|
|
422
|
+
endAt: `${designation.end_date || designation.start_date} ${designation.end_time}`,
|
|
423
|
+
scheduleId,
|
|
424
|
+
source: 'schedule_designation',
|
|
425
|
+
fixedOffsetMinutes
|
|
426
|
+
});
|
|
427
|
+
if (range) ranges.push(range);
|
|
428
|
+
});
|
|
429
|
+
return {
|
|
430
|
+
rules,
|
|
431
|
+
ranges
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (schedule.type === 'time-slots' && Array.isArray(schedule.time_slot)) {
|
|
435
|
+
schedule.time_slot.forEach((slot, index) => {
|
|
436
|
+
if (!slot.start_time || !slot.end_time) return;
|
|
437
|
+
if (repeatType === 'none' && startDate) {
|
|
438
|
+
const range = normalizeAbsoluteSessionRange({
|
|
439
|
+
startAt: `${startDate} ${slot.start_time}`,
|
|
440
|
+
endAt: `${startDate} ${slot.end_time}`,
|
|
441
|
+
scheduleId,
|
|
442
|
+
timeSlotId: toNumberId(slot.id),
|
|
443
|
+
source: 'schedule_time_slot',
|
|
444
|
+
fixedOffsetMinutes
|
|
445
|
+
});
|
|
446
|
+
if (range) ranges.push(range);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
addRule({
|
|
450
|
+
idSuffix: `slot-${String(slot.id ?? index)}`,
|
|
451
|
+
startTime: slot.start_time,
|
|
452
|
+
endTime: slot.end_time,
|
|
453
|
+
timeSlotId: toNumberId(slot.id)
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
return {
|
|
457
|
+
rules,
|
|
458
|
+
ranges
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
if (startAt && endAt) {
|
|
462
|
+
if (repeatType === 'none') {
|
|
463
|
+
const range = normalizeAbsoluteSessionRange({
|
|
464
|
+
startAt,
|
|
465
|
+
endAt,
|
|
466
|
+
scheduleId,
|
|
467
|
+
source: 'schedule_standard',
|
|
468
|
+
fixedOffsetMinutes
|
|
469
|
+
});
|
|
470
|
+
if (range) ranges.push(range);
|
|
471
|
+
} else {
|
|
472
|
+
try {
|
|
473
|
+
addRule({
|
|
474
|
+
idSuffix: 'standard',
|
|
475
|
+
startTime: (0, _localClock.formatLocalTime)(String(startAt), false),
|
|
476
|
+
endTime: (0, _localClock.formatLocalTime)(String(endAt), false)
|
|
477
|
+
});
|
|
478
|
+
} catch {
|
|
479
|
+
// Invalid schedule boundaries do not produce a recurring rule.
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
rules,
|
|
485
|
+
ranges
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function resolveProductScheduleItems(product, scheduleList) {
|
|
489
|
+
const scheduleMap = new Map(scheduleList.map(schedule => [String(schedule.id), schedule]));
|
|
490
|
+
const rawSchedules = [product.schedules, product.schedule, product._schedule].flatMap(value => Array.isArray(value) ? value : value ? [value] : []);
|
|
491
|
+
const explicitIds = uniqueAvailabilityIds([...(Array.isArray(product['schedule.ids']) ? product['schedule.ids'] : []), ...(Array.isArray(product.schedule_ids) ? product.schedule_ids : []), ...rawSchedules.filter(item => typeof item !== 'object')]);
|
|
492
|
+
const result = new Map();
|
|
493
|
+
rawSchedules.filter(item => item && typeof item === 'object').forEach(schedule => {
|
|
494
|
+
result.set(String(schedule.id ?? JSON.stringify(schedule)), schedule);
|
|
495
|
+
});
|
|
496
|
+
explicitIds.forEach(id => {
|
|
497
|
+
const schedule = scheduleMap.get(String(id));
|
|
498
|
+
if (schedule) result.set(String(id), schedule);
|
|
499
|
+
});
|
|
500
|
+
return Array.from(result.values());
|
|
193
501
|
}
|
|
194
502
|
|
|
195
|
-
/** Converts
|
|
196
|
-
function
|
|
503
|
+
/** Converts ProductList and schedule records into the pure Availability vocabulary. */
|
|
504
|
+
function normalizeProductForAvailability(product, scheduleList = [], fixedOffsetMinutes = (0, _localClock.captureRuntimeOffsetMinutes)()) {
|
|
197
505
|
const raw = product;
|
|
198
506
|
const kind = resolveProductKind(raw);
|
|
507
|
+
const isFlexibleDuration = kind === 'duration' && (0, _utils.isProductFlexibleDuration)(raw.duration);
|
|
508
|
+
const sessionRules = [];
|
|
509
|
+
const sessionRanges = [];
|
|
510
|
+
const explicitSession = raw.cartDetailValue?.session || raw._extend?.other?.session || raw.session;
|
|
511
|
+
const explicitRange = normalizeAbsoluteSessionRange({
|
|
512
|
+
startAt: explicitSession?.start_at,
|
|
513
|
+
endAt: explicitSession?.end_at,
|
|
514
|
+
scheduleId: toNumberId(explicitSession?.schedule_id),
|
|
515
|
+
timeSlotId: toNumberId(explicitSession?.time_slot_id),
|
|
516
|
+
source: 'product_session',
|
|
517
|
+
fixedOffsetMinutes
|
|
518
|
+
});
|
|
519
|
+
if (explicitRange) sessionRanges.push(explicitRange);
|
|
520
|
+
resolveProductScheduleItems(raw, scheduleList).forEach(schedule => {
|
|
521
|
+
const normalized = normalizeScheduleItem(schedule, fixedOffsetMinutes);
|
|
522
|
+
sessionRules.push(...normalized.rules);
|
|
523
|
+
sessionRanges.push(...normalized.ranges);
|
|
524
|
+
});
|
|
199
525
|
return {
|
|
200
526
|
id: raw.id ?? raw.product_id,
|
|
201
527
|
title: pickLocalizedText(raw.title || raw.name || raw.product_title),
|
|
202
528
|
kind,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
529
|
+
isFlexibleDuration: isFlexibleDuration || undefined,
|
|
530
|
+
durationMinutes: kind === 'duration' && !isFlexibleDuration ? Math.max(1, Number(raw.duration?.value || raw.duration || raw.service_duration || 30) || 30) : undefined,
|
|
531
|
+
requirementGroups: normalizeRequirementGroups(raw),
|
|
532
|
+
sessionRules,
|
|
533
|
+
sessionRanges,
|
|
534
|
+
capacityRequired: Math.max(1, Number(raw.capacity?.default || raw.capacity || 1) || 1),
|
|
535
|
+
quantity: Math.max(1, Number(raw.num || raw.quantity || 1) || 1),
|
|
536
|
+
cutOffPolicy: normalizeAvailabilityCutOffPolicy(raw.cut_off_time),
|
|
209
537
|
raw
|
|
210
538
|
};
|
|
211
539
|
}
|
|
212
|
-
function
|
|
213
|
-
if (value
|
|
214
|
-
|
|
540
|
+
function normalizeAvailabilityCutOffNumber(value, context) {
|
|
541
|
+
if (value == null) {
|
|
542
|
+
return {
|
|
543
|
+
reason: `${context} 缺失`
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
if (typeof value === 'string' && !value.trim()) {
|
|
547
|
+
return {
|
|
548
|
+
reason: `${context} 缺失`
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
const normalized = Number(value);
|
|
552
|
+
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
553
|
+
return {
|
|
554
|
+
reason: `${context} 必须是非负有限数`
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
value: normalized
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function normalizeAvailabilityCutOffInteger(value, context) {
|
|
562
|
+
const numeric = normalizeAvailabilityCutOffNumber(value, context);
|
|
563
|
+
if (numeric.reason) return numeric;
|
|
564
|
+
if (!Number.isInteger(numeric.value)) {
|
|
565
|
+
return {
|
|
566
|
+
reason: `${context} 必须是非负整数`
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
value: numeric.value
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
function invalidAvailabilityCutOffPolicy(reason) {
|
|
574
|
+
return {
|
|
575
|
+
type: 'invalid',
|
|
576
|
+
reason
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function normalizeAvailabilityFutureDay(raw) {
|
|
580
|
+
if (!Object.prototype.hasOwnProperty.call(raw, 'future_day')) return {};
|
|
581
|
+
const futureDay = normalizeAvailabilityCutOffInteger(raw.future_day, 'cut_off_time.future_day');
|
|
582
|
+
return futureDay.reason ? {
|
|
583
|
+
reason: futureDay.reason
|
|
584
|
+
} : {
|
|
585
|
+
futureDay: futureDay.value
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function normalizeAvailabilityCutOffPolicy(raw) {
|
|
589
|
+
if (raw == null || raw === '') return undefined;
|
|
590
|
+
if (!isRecord(raw)) {
|
|
591
|
+
return invalidAvailabilityCutOffPolicy('cut_off_time 必须是对象');
|
|
592
|
+
}
|
|
593
|
+
const futureDay = normalizeAvailabilityFutureDay(raw);
|
|
594
|
+
if (futureDay.reason) {
|
|
595
|
+
return invalidAvailabilityCutOffPolicy(futureDay.reason);
|
|
596
|
+
}
|
|
597
|
+
const type = String(raw.type ?? '').trim();
|
|
598
|
+
if (!type) {
|
|
599
|
+
return invalidAvailabilityCutOffPolicy('cut_off_time.type 缺失');
|
|
600
|
+
}
|
|
601
|
+
if (type === 'ongoing') {
|
|
602
|
+
if (!isRecord(raw.ongoing)) {
|
|
603
|
+
return invalidAvailabilityCutOffPolicy('cut_off_time.ongoing 缺失');
|
|
604
|
+
}
|
|
605
|
+
const ongoingType = String(raw.ongoing.type ?? '').trim();
|
|
606
|
+
if (ongoingType === 'before_end') {
|
|
607
|
+
return {
|
|
608
|
+
type: 'ongoing',
|
|
609
|
+
mode: 'before_end',
|
|
610
|
+
...(futureDay.futureDay === undefined ? {} : {
|
|
611
|
+
futureDay: futureDay.futureDay
|
|
612
|
+
})
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
if (ongoingType === 'after_start') {
|
|
616
|
+
const graceMinutes = normalizeAvailabilityCutOffNumber(raw.ongoing.unit, 'cut_off_time.ongoing.unit');
|
|
617
|
+
return graceMinutes.reason ? invalidAvailabilityCutOffPolicy(graceMinutes.reason) : {
|
|
618
|
+
type: 'ongoing',
|
|
619
|
+
mode: 'after_start',
|
|
620
|
+
graceMinutes: graceMinutes.value,
|
|
621
|
+
...(futureDay.futureDay === undefined ? {} : {
|
|
622
|
+
futureDay: futureDay.futureDay
|
|
623
|
+
})
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
return invalidAvailabilityCutOffPolicy(`未知的 cut_off_time.ongoing.type: ${ongoingType}`);
|
|
627
|
+
}
|
|
628
|
+
if (type === 'before_start') {
|
|
629
|
+
return {
|
|
630
|
+
type: 'before_start',
|
|
631
|
+
...(futureDay.futureDay === undefined ? {} : {
|
|
632
|
+
futureDay: futureDay.futureDay
|
|
633
|
+
})
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
if (type === 'advance') {
|
|
637
|
+
const unit = normalizeAvailabilityCutOffNumber(raw.unit, 'cut_off_time.unit');
|
|
638
|
+
if (unit.reason) return invalidAvailabilityCutOffPolicy(unit.reason);
|
|
639
|
+
const unitType = String(raw.unit_type ?? '').trim();
|
|
640
|
+
if (!['days', 'hours', 'minutes'].includes(unitType)) {
|
|
641
|
+
return invalidAvailabilityCutOffPolicy('cut_off_time.unit_type 必须是 days|hours|minutes');
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
type: 'advance',
|
|
645
|
+
unit: unit.value,
|
|
646
|
+
unitType: unitType,
|
|
647
|
+
...(futureDay.futureDay === undefined ? {} : {
|
|
648
|
+
futureDay: futureDay.futureDay
|
|
649
|
+
})
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return invalidAvailabilityCutOffPolicy(`未知的 cut_off_time.type: ${type}`);
|
|
215
653
|
}
|
|
216
|
-
function normalizeResource(raw) {
|
|
654
|
+
function normalizeResource(raw, fixedOffsetMinutes = (0, _localClock.captureRuntimeOffsetMinutes)()) {
|
|
217
655
|
const id = raw.resourceId ?? raw.resource_id ?? raw.id;
|
|
218
656
|
if (id === undefined || id === null || id === '') return null;
|
|
219
|
-
const
|
|
657
|
+
const rawWindows = raw.windows || raw.times || [];
|
|
220
658
|
return {
|
|
221
659
|
id,
|
|
222
660
|
formId: raw.formId ?? raw.form_id ?? raw.resource_form_id,
|
|
223
661
|
name: raw.resourceName || raw.main_field || raw.name || raw.title,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
662
|
+
code: raw.code,
|
|
663
|
+
resourceType: normalizeResourceType(raw.type || raw.resourceType),
|
|
664
|
+
capacity: Math.max(1, Number(raw.capacity || raw.max_capacity || 1) || 1),
|
|
665
|
+
windows: (Array.isArray(rawWindows) ? rawWindows : []).flatMap(window => {
|
|
666
|
+
const range = normalizeAvailabilityLocalDateTimeRange({
|
|
667
|
+
startAt: window.startAt || window.start_at || window.start,
|
|
668
|
+
endAt: window.endAt || window.end_at || window.end,
|
|
669
|
+
fixedOffsetMinutes,
|
|
670
|
+
rollOverEnd: true
|
|
671
|
+
});
|
|
672
|
+
if (!range) return [];
|
|
673
|
+
return [{
|
|
674
|
+
startAt: range.startAt,
|
|
675
|
+
endAt: range.endAt,
|
|
676
|
+
source: window.source,
|
|
677
|
+
raw: window
|
|
678
|
+
}];
|
|
679
|
+
}),
|
|
228
680
|
raw
|
|
229
681
|
};
|
|
230
682
|
}
|
|
@@ -232,79 +684,62 @@ function normalizeScheduleIds(raw) {
|
|
|
232
684
|
const ids = raw.schedule ?? raw.schedule_ids ?? raw.scheduleIds ?? [];
|
|
233
685
|
return (Array.isArray(ids) ? ids : [ids]).map(id => toNumberId(id)).filter(id => id !== undefined);
|
|
234
686
|
}
|
|
235
|
-
function collectRawScheduleIds(rawResources) {
|
|
236
|
-
const ids = new Map();
|
|
237
|
-
(rawResources || []).forEach(resource => {
|
|
238
|
-
normalizeScheduleIds(resource).forEach(id => {
|
|
239
|
-
ids.set(String(id), id);
|
|
240
|
-
});
|
|
241
|
-
});
|
|
242
|
-
return Array.from(ids.values());
|
|
243
|
-
}
|
|
244
|
-
function scheduleListHasIds(scheduleList, scheduleIds) {
|
|
245
|
-
if (!scheduleIds.length) return true;
|
|
246
|
-
const existingIds = new Set((scheduleList || []).map(schedule => String(schedule.id)));
|
|
247
|
-
return scheduleIds.every(id => existingIds.has(String(id)));
|
|
248
|
-
}
|
|
249
687
|
function dateRangeDays(dateRange) {
|
|
250
|
-
if (!dateRange?.
|
|
688
|
+
if (!dateRange?.startDate || !dateRange?.endDate) return [];
|
|
251
689
|
const dates = [];
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
dates.push(
|
|
257
|
-
cursor = cursor.add(1, 'day');
|
|
690
|
+
const startScalar = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(dateRange.startDate));
|
|
691
|
+
const endScalar = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(dateRange.endDate));
|
|
692
|
+
if (endScalar < startScalar) return dates;
|
|
693
|
+
for (let cursor = startScalar; cursor <= endScalar; cursor = (0, _localClock.addLocalCalendarDays)(cursor, 1)) {
|
|
694
|
+
dates.push((0, _localClock.formatLocalDate)(cursor));
|
|
258
695
|
}
|
|
259
696
|
return dates;
|
|
260
697
|
}
|
|
261
|
-
function
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
698
|
+
function normalizeResourceOccupancy(rawEvent, resourceId, source = 'remote', fixedOffsetMinutes = (0, _localClock.captureRuntimeOffsetMinutes)()) {
|
|
699
|
+
const range = normalizeAvailabilityLocalDateTimeRange({
|
|
700
|
+
startAt: rawEvent.start_at ?? rawEvent.startAt ?? rawEvent.start_time ?? rawEvent.startTime,
|
|
701
|
+
endAt: rawEvent.end_at ?? rawEvent.endAt ?? rawEvent.end_time ?? rawEvent.endTime,
|
|
702
|
+
fixedOffsetMinutes,
|
|
703
|
+
rollOverEnd: false
|
|
704
|
+
});
|
|
705
|
+
if (!range) return null;
|
|
267
706
|
return {
|
|
268
|
-
|
|
707
|
+
id: rawEvent.id == null ? undefined : String(rawEvent.id),
|
|
269
708
|
resourceId,
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
end_at: endAt,
|
|
709
|
+
startAt: range.startAt,
|
|
710
|
+
endAt: range.endAt,
|
|
273
711
|
pax: Number(rawEvent.pax ?? rawEvent.number ?? rawEvent.capacity ?? 1) || 1,
|
|
274
|
-
source
|
|
712
|
+
source: rawEvent.source === 'cart' || rawEvent.source === 'selection' ? rawEvent.source : source,
|
|
713
|
+
scheduleEventId: toNumberId(rawEvent.schedule_event_id ?? rawEvent.scheduleEventId),
|
|
714
|
+
serverEventId: toNumberId(rawEvent.schedule_event_id ?? rawEvent.server_event_id ?? rawEvent.id),
|
|
715
|
+
bookingUid: rawEvent.booking_uid ?? rawEvent.bookingUid ?? rawEvent.metadata?.unique_identification_number,
|
|
716
|
+
productUid: rawEvent.product_uid ?? rawEvent.productUid ?? rawEvent.metadata?.product_uid,
|
|
717
|
+
raw: rawEvent
|
|
275
718
|
};
|
|
276
719
|
}
|
|
277
|
-
function normalizeResourceV2(raw) {
|
|
278
|
-
const normalized = normalizeResource({
|
|
279
|
-
...raw,
|
|
280
|
-
times: []
|
|
281
|
-
});
|
|
282
|
-
if (!normalized) return null;
|
|
283
|
-
return normalized;
|
|
284
|
-
}
|
|
285
720
|
function buildResourceWindowFallbackTimes(params) {
|
|
286
721
|
const {
|
|
287
722
|
raw,
|
|
288
723
|
dates,
|
|
289
724
|
scheduleIds
|
|
290
725
|
} = params;
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
726
|
+
const fixedOffsetMinutes = params.fixedOffsetMinutes ?? (0, _localClock.captureRuntimeOffsetMinutes)();
|
|
727
|
+
const boundary = normalizeAvailabilityLocalDateTimeRange({
|
|
728
|
+
startAt: raw.start_time || raw.start_at || raw.startTime,
|
|
729
|
+
endAt: raw.end_time || raw.end_at || raw.endTime,
|
|
730
|
+
fixedOffsetMinutes,
|
|
731
|
+
rollOverEnd: true
|
|
732
|
+
});
|
|
733
|
+
if (!boundary) return [];
|
|
299
734
|
return dates.flatMap(date => {
|
|
300
|
-
const dayStart = (0,
|
|
301
|
-
const dayEnd = (0,
|
|
302
|
-
const start =
|
|
303
|
-
const end =
|
|
304
|
-
if (
|
|
735
|
+
const dayStart = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(`${date}T00:00:00`));
|
|
736
|
+
const dayEnd = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(`${date}T23:59:59`));
|
|
737
|
+
const start = Math.max(boundary.startScalar, dayStart);
|
|
738
|
+
const end = Math.min(boundary.endScalar, dayEnd);
|
|
739
|
+
if (end <= start) return [];
|
|
305
740
|
return [{
|
|
306
|
-
start_at:
|
|
307
|
-
end_at:
|
|
741
|
+
start_at: (0, _localClock.formatLocalDateTime)(start, fixedOffsetMinutes),
|
|
742
|
+
end_at: (0, _localClock.formatLocalDateTime)(end, fixedOffsetMinutes),
|
|
308
743
|
schedule_ids: scheduleIds,
|
|
309
744
|
source: 'resource_window_fallback'
|
|
310
745
|
}];
|
|
@@ -313,16 +748,18 @@ function buildResourceWindowFallbackTimes(params) {
|
|
|
313
748
|
|
|
314
749
|
/**
|
|
315
750
|
* Materializes resource work windows locally from v2's schedule ids, then keeps its flat
|
|
316
|
-
* event_list as
|
|
751
|
+
* event_list as resource occupancy records. This deliberately avoids the older API's times -> event_list
|
|
317
752
|
* response nesting and lets one resource response serve any requested date range.
|
|
318
753
|
*/
|
|
319
|
-
function
|
|
754
|
+
function buildAvailabilityResourcesFromV2(params) {
|
|
320
755
|
const scheduleMap = new Map((params.scheduleList || []).map(schedule => [String(schedule.id), schedule]));
|
|
321
756
|
const dates = dateRangeDays(params.dateRange);
|
|
322
|
-
const
|
|
757
|
+
const resourceOccupancies = [];
|
|
758
|
+
const fixedOffsetMinutes = params.fixedOffsetMinutes ?? (0, _localClock.captureRuntimeOffsetMinutes)();
|
|
323
759
|
const resources = (params.rawResources || []).map(raw => {
|
|
324
|
-
const resource =
|
|
760
|
+
const resource = normalizeResource(raw, fixedOffsetMinutes);
|
|
325
761
|
if (!resource) return null;
|
|
762
|
+
const explicitWindows = [...resource.windows];
|
|
326
763
|
const scheduleIds = normalizeScheduleIds(raw);
|
|
327
764
|
const schedules = scheduleIds.map(id => scheduleMap.get(String(id))).filter(Boolean);
|
|
328
765
|
const matchedScheduleIds = new Set(schedules.map(schedule => String(schedule.id)));
|
|
@@ -330,36 +767,55 @@ function buildPlannerResourcesFromV2(params) {
|
|
|
330
767
|
if (missingScheduleIds.length > 0) {
|
|
331
768
|
resource.raw = {
|
|
332
769
|
...(resource.raw || raw),
|
|
333
|
-
|
|
770
|
+
_availabilityMissingScheduleIds: missingScheduleIds
|
|
334
771
|
};
|
|
335
772
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
773
|
+
const materializedWindows = dates.flatMap(date => schedules.flatMap(schedule => (0, _getDateIsInSchedule.getScheduleStartEndTimePoints)(date, [schedule]).flatMap(slot => {
|
|
774
|
+
const range = normalizeAvailabilityLocalDateTimeRange({
|
|
775
|
+
startAt: slot.start_at,
|
|
776
|
+
endAt: slot.end_at,
|
|
777
|
+
fixedOffsetMinutes,
|
|
778
|
+
rollOverEnd: true
|
|
779
|
+
});
|
|
780
|
+
if (!range) return [];
|
|
781
|
+
return [{
|
|
782
|
+
startAt: range.startAt,
|
|
783
|
+
endAt: range.endAt,
|
|
784
|
+
source: `resource_schedule:${schedule.id}`,
|
|
785
|
+
raw: {
|
|
786
|
+
scheduleId: schedule.id
|
|
787
|
+
}
|
|
788
|
+
}];
|
|
340
789
|
})));
|
|
341
|
-
|
|
342
|
-
|
|
790
|
+
resource.windows = materializedWindows.length > 0 ? materializedWindows : explicitWindows;
|
|
791
|
+
if (resource.windows.length === 0 && missingScheduleIds.length > 0) {
|
|
792
|
+
resource.windows = buildResourceWindowFallbackTimes({
|
|
343
793
|
raw,
|
|
344
794
|
dates,
|
|
345
|
-
scheduleIds
|
|
346
|
-
|
|
795
|
+
scheduleIds,
|
|
796
|
+
fixedOffsetMinutes
|
|
797
|
+
}).map(window => ({
|
|
798
|
+
startAt: window.start_at,
|
|
799
|
+
endAt: window.end_at,
|
|
800
|
+
source: window.source,
|
|
801
|
+
raw: window
|
|
802
|
+
}));
|
|
347
803
|
}
|
|
348
804
|
const eventList = Array.isArray(raw.event_list) ? raw.event_list : [];
|
|
349
805
|
eventList.forEach(event => {
|
|
350
|
-
const
|
|
351
|
-
if (
|
|
806
|
+
const occupancy = normalizeResourceOccupancy(event, resource.id, 'remote', fixedOffsetMinutes);
|
|
807
|
+
if (occupancy) resourceOccupancies.push(occupancy);
|
|
352
808
|
});
|
|
353
809
|
return resource;
|
|
354
810
|
}).filter(resource => resource !== null);
|
|
355
811
|
return {
|
|
356
812
|
resources,
|
|
357
|
-
|
|
813
|
+
resourceOccupancies
|
|
358
814
|
};
|
|
359
815
|
}
|
|
360
816
|
|
|
361
817
|
/** Combines URL-locked resources with every resource referenced by the selected products. */
|
|
362
|
-
function
|
|
818
|
+
function collectAvailabilityResourceIds(products, explicitResourceIds = []) {
|
|
363
819
|
const ids = new Map();
|
|
364
820
|
const add = value => {
|
|
365
821
|
if (value === undefined || value === null || value === '') return;
|
|
@@ -367,80 +823,340 @@ function collectPlannerResourceIds(products, explicitResourceIds = []) {
|
|
|
367
823
|
};
|
|
368
824
|
explicitResourceIds.forEach(add);
|
|
369
825
|
products.forEach(product => {
|
|
370
|
-
product.
|
|
826
|
+
product.requirementGroups?.forEach(requirement => {
|
|
371
827
|
requirement.resourceIds?.forEach(add);
|
|
372
828
|
});
|
|
373
829
|
});
|
|
374
830
|
return Array.from(ids.values());
|
|
375
831
|
}
|
|
376
|
-
function
|
|
377
|
-
|
|
378
|
-
return String(left) === String(right);
|
|
832
|
+
function getProductLineUid(product) {
|
|
833
|
+
return String(product?.metadata?.unique_identification_number ?? product?.unique_identification_number ?? '');
|
|
379
834
|
}
|
|
380
|
-
function
|
|
381
|
-
|
|
382
|
-
|
|
835
|
+
function buildAvailabilityLineIdentity(product) {
|
|
836
|
+
return {
|
|
837
|
+
product_id: product.product_id,
|
|
838
|
+
product_variant_id: product.product_variant_id ?? 0,
|
|
839
|
+
unique_identification_number: getProductLineUid(product),
|
|
840
|
+
product_sku: product.product_sku,
|
|
841
|
+
product_bundle: product.product_bundle
|
|
842
|
+
};
|
|
383
843
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
return buildBookingFromAssignments({
|
|
844
|
+
function buildBookingFromAvailabilityAssignment(params) {
|
|
845
|
+
return buildBookingFromAvailabilityAssignments({
|
|
846
|
+
product: params.product,
|
|
388
847
|
assignments: [params.assignment],
|
|
848
|
+
timezone: params.timezone,
|
|
849
|
+
fixedOffsetMinutes: params.fixedOffsetMinutes
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
function buildBookingFromAvailabilityAssignments(params) {
|
|
853
|
+
const normalizedAssignments = (params.assignments || []).map(assignment => {
|
|
854
|
+
const resourceId = assignment.resourceId ?? assignment.resource_id;
|
|
855
|
+
const matchedGroup = (params.product.requirementGroups || []).find(group => group.resourceIds?.some(id => sameAvailabilityId(id, resourceId)));
|
|
856
|
+
return {
|
|
857
|
+
productId: assignment.productId ?? assignment.product_id,
|
|
858
|
+
resourceId,
|
|
859
|
+
formId: assignment.formId ?? assignment.form_id ?? matchedGroup?.formId,
|
|
860
|
+
startAt: assignment.startAt ?? assignment.start_at,
|
|
861
|
+
endAt: assignment.endAt ?? assignment.end_at,
|
|
862
|
+
capacityRequired: assignment.capacityRequired ?? assignment.capacity_required ?? matchedGroup?.capacityRequired,
|
|
863
|
+
scheduleId: assignment.scheduleId ?? assignment.schedule_id,
|
|
864
|
+
timeSlotId: assignment.timeSlotId ?? assignment.time_slot_id
|
|
865
|
+
};
|
|
866
|
+
});
|
|
867
|
+
const first = normalizedAssignments[0];
|
|
868
|
+
const fixedOffsetMinutes = params.fixedOffsetMinutes ?? (0, _localClock.captureRuntimeOffsetMinutes)();
|
|
869
|
+
const range = normalizeAvailabilityLocalDateTimeRange({
|
|
870
|
+
startAt: first.startAt,
|
|
871
|
+
endAt: first.endAt,
|
|
872
|
+
fixedOffsetMinutes,
|
|
873
|
+
rollOverEnd: false
|
|
874
|
+
});
|
|
875
|
+
if (!range) {
|
|
876
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'assignment 时间范围非法', {
|
|
877
|
+
assignments: params.assignments
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
const candidate = {
|
|
881
|
+
key: `${String(first.productId)}:${range.startAt}:${range.endAt}`,
|
|
882
|
+
productId: first.productId,
|
|
883
|
+
source: 'duration_window',
|
|
884
|
+
startAt: range.startAt,
|
|
885
|
+
endAt: range.endAt,
|
|
886
|
+
bookingStartAt: range.startAt,
|
|
887
|
+
bookingEndAt: range.endAt,
|
|
888
|
+
date: range.startAt.slice(0, 10),
|
|
889
|
+
startTime: (0, _localClock.formatLocalTime)(range.startAt, false),
|
|
890
|
+
endTime: (0, _localClock.formatLocalTime)(range.endAt, false),
|
|
891
|
+
timezone: String(params.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'runtime'),
|
|
892
|
+
scheduleRefs: [],
|
|
893
|
+
primaryScheduleRef: undefined
|
|
894
|
+
};
|
|
895
|
+
return buildBookingFromAvailabilitySelection({
|
|
896
|
+
assignments: normalizedAssignments,
|
|
897
|
+
candidate,
|
|
389
898
|
product: params.product
|
|
390
899
|
});
|
|
391
900
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
901
|
+
function getBookingUid(booking) {
|
|
902
|
+
return String(booking?.metadata?.unique_identification_number ?? booking?.booking_uid ?? '');
|
|
903
|
+
}
|
|
904
|
+
function stableValue(value) {
|
|
905
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
906
|
+
if (!value || typeof value !== 'object') return value;
|
|
907
|
+
return Object.keys(value).sort().reduce((result, key) => {
|
|
908
|
+
if (value[key] !== undefined && typeof value[key] !== 'function') {
|
|
909
|
+
result[key] = stableValue(value[key]);
|
|
910
|
+
}
|
|
911
|
+
return result;
|
|
912
|
+
}, {});
|
|
913
|
+
}
|
|
914
|
+
function hashText(value) {
|
|
915
|
+
let hash = 2166136261;
|
|
916
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
917
|
+
hash ^= value.charCodeAt(index);
|
|
918
|
+
hash = Math.imul(hash, 16777619);
|
|
919
|
+
}
|
|
920
|
+
return (hash >>> 0).toString(36);
|
|
921
|
+
}
|
|
922
|
+
function resolveOrderRevision(tempOrder) {
|
|
923
|
+
if (!tempOrder) return 'order:none';
|
|
924
|
+
const products = (Array.isArray(tempOrder.products) ? tempOrder.products : []).map(product => ({
|
|
925
|
+
uid: getProductLineUid(product),
|
|
926
|
+
productId: product.product_id ?? product.id,
|
|
927
|
+
variantId: product.product_variant_id ?? 0,
|
|
928
|
+
num: product.num ?? product.quantity ?? 1,
|
|
929
|
+
bookingUid: product.booking_uid ?? product.metadata?.booking_uid,
|
|
930
|
+
productSku: product.product_sku,
|
|
931
|
+
productBundle: product.product_bundle
|
|
932
|
+
})).sort((left, right) => left.uid.localeCompare(right.uid));
|
|
933
|
+
const bookings = (Array.isArray(tempOrder.bookings) ? tempOrder.bookings : []).map(booking => ({
|
|
934
|
+
uid: getBookingUid(booking),
|
|
935
|
+
scheduleEventId: booking.schedule_event_id,
|
|
936
|
+
productUid: booking.product_uid,
|
|
937
|
+
scheduleId: booking.schedule_id,
|
|
938
|
+
startDate: booking.start_date,
|
|
939
|
+
startTime: booking.start_time,
|
|
940
|
+
endDate: booking.end_date,
|
|
941
|
+
endTime: booking.end_time,
|
|
942
|
+
resources: booking.resources
|
|
943
|
+
})).sort((left, right) => left.uid.localeCompare(right.uid));
|
|
944
|
+
return `order:${hashText(JSON.stringify(stableValue({
|
|
945
|
+
products,
|
|
946
|
+
bookings
|
|
947
|
+
})))}`;
|
|
948
|
+
}
|
|
949
|
+
function buildBookingFromAvailabilitySelection(params) {
|
|
399
950
|
const {
|
|
400
951
|
assignments,
|
|
401
|
-
|
|
952
|
+
candidate,
|
|
953
|
+
product,
|
|
954
|
+
resources = [],
|
|
955
|
+
bookingUid,
|
|
956
|
+
holder,
|
|
957
|
+
holderIds
|
|
402
958
|
} = params;
|
|
403
|
-
const
|
|
404
|
-
const
|
|
405
|
-
const
|
|
959
|
+
const bookingStartAt = candidate.bookingStartAt || candidate.startAt;
|
|
960
|
+
const bookingEndAt = candidate.bookingEndAt || candidate.endAt;
|
|
961
|
+
const startScalar = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(bookingStartAt));
|
|
962
|
+
const endScalar = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(bookingEndAt));
|
|
963
|
+
const scheduleId = candidate.primaryScheduleRef?.scheduleId ?? assignments.find(assignment => assignment.scheduleId != null)?.scheduleId;
|
|
964
|
+
const timeSlotId = candidate.primaryScheduleRef?.timeSlotId ?? assignments.find(assignment => assignment.timeSlotId != null)?.timeSlotId;
|
|
965
|
+
if (product.kind === 'session' && scheduleId == null) {
|
|
966
|
+
throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', 'session 候选缺少真实 schedule identity', {
|
|
967
|
+
productId: product.id,
|
|
968
|
+
candidateKey: candidate.key,
|
|
969
|
+
scheduleRefs: candidate.scheduleRefs
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
const normalizedHolder = holder && typeof holder === 'object' ? (0, _lodashEs.cloneDeep)(holder) : undefined;
|
|
973
|
+
const normalizedHolderIds = Array.isArray(holderIds) ? (0, _lodashEs.cloneDeep)(holderIds) : holderIds;
|
|
406
974
|
return {
|
|
407
|
-
start_date:
|
|
408
|
-
start_time:
|
|
409
|
-
end_date:
|
|
410
|
-
end_time:
|
|
411
|
-
duration: Math.max(1,
|
|
975
|
+
start_date: bookingStartAt.slice(0, 10),
|
|
976
|
+
start_time: bookingStartAt.slice(11, 16),
|
|
977
|
+
end_date: bookingEndAt.slice(0, 10),
|
|
978
|
+
end_time: bookingEndAt.slice(11, 16),
|
|
979
|
+
duration: Math.max(1, Math.trunc((endScalar - startScalar) / (60 * 1000))),
|
|
412
980
|
like_status: 'common',
|
|
413
|
-
schedule_id: 0,
|
|
981
|
+
schedule_id: scheduleId ?? 0,
|
|
982
|
+
...(normalizedHolder ? {
|
|
983
|
+
holder: normalizedHolder
|
|
984
|
+
} : {}),
|
|
414
985
|
resources: assignments.map(assignment => {
|
|
415
|
-
|
|
986
|
+
// requirementSelections 的提交协议只携带资源 ID;购物车展示则需要名称。
|
|
987
|
+
// commit 此时仍持有本次不可变 projection,因此直接按 ID 取回名称并
|
|
988
|
+
// 固化到 booking,避免 UI 再依赖 legacy BookingContext resourcesOriginMap。
|
|
989
|
+
const matchedResource = resources.find(resource => sameAvailabilityId(resource.id, assignment.resourceId));
|
|
990
|
+
const resourceName = String(matchedResource?.name || '').trim();
|
|
416
991
|
return {
|
|
417
992
|
id: assignment.resourceId,
|
|
418
993
|
resource_id: assignment.resourceId,
|
|
419
|
-
form_id:
|
|
994
|
+
form_id: assignment.formId,
|
|
420
995
|
relation_type: 'form',
|
|
421
996
|
relation_id: assignment.resourceId,
|
|
422
997
|
like_status: 'common',
|
|
423
|
-
capacity: assignment.capacityRequired ||
|
|
998
|
+
capacity: assignment.capacityRequired || 1,
|
|
999
|
+
...(resourceName ? {
|
|
1000
|
+
metadata: {
|
|
1001
|
+
resource_name: resourceName
|
|
1002
|
+
}
|
|
1003
|
+
} : {})
|
|
424
1004
|
};
|
|
425
1005
|
}),
|
|
426
1006
|
metadata: {
|
|
1007
|
+
...(bookingUid ? {
|
|
1008
|
+
unique_identification_number: bookingUid
|
|
1009
|
+
} : {}),
|
|
427
1010
|
unified_booking_sales: true,
|
|
428
|
-
|
|
1011
|
+
availability_candidate_key: candidate.key,
|
|
1012
|
+
availability_source: candidate.source,
|
|
1013
|
+
availability_slice_start_at: candidate.startAt,
|
|
1014
|
+
availability_slice_end_at: candidate.endAt,
|
|
1015
|
+
availability_schedule_refs: candidate.scheduleRefs,
|
|
1016
|
+
...(timeSlotId == null ? {} : {
|
|
1017
|
+
time_slot_id: timeSlotId
|
|
1018
|
+
}),
|
|
1019
|
+
product_kind: product.kind,
|
|
1020
|
+
...(normalizedHolderIds === undefined ? {} : {
|
|
1021
|
+
holder_id: normalizedHolderIds
|
|
1022
|
+
}),
|
|
1023
|
+
...(normalizedHolder ? {
|
|
1024
|
+
holder: (0, _lodashEs.cloneDeep)(normalizedHolder)
|
|
1025
|
+
} : {})
|
|
429
1026
|
}
|
|
430
1027
|
};
|
|
431
1028
|
}
|
|
432
|
-
|
|
433
|
-
|
|
1029
|
+
const POS_OVERRIDABLE_AVAILABILITY_CONFLICT_CODES = new Set(['past', 'booking_cutoff', 'resource_full', 'resource_unavailable', 'resource_capacity_exceeded',
|
|
1030
|
+
// A required group whose selected resource is outside its window is surfaced
|
|
1031
|
+
// as missing_resource at group level. Structural validation below still
|
|
1032
|
+
// rejects a genuinely missing selection.
|
|
1033
|
+
'missing_resource']);
|
|
1034
|
+
const POS_NO_RESOURCE_ID = 0;
|
|
1035
|
+
function matchesForcedSelectionGroup(selection, group) {
|
|
1036
|
+
const hasGroupId = typeof selection.requirementGroupId === 'string' && selection.requirementGroupId.trim().length > 0;
|
|
1037
|
+
const hasFormId = selection.formId !== undefined && selection.formId !== null;
|
|
1038
|
+
if (!hasGroupId && !hasFormId) return false;
|
|
1039
|
+
if (hasGroupId && selection.requirementGroupId !== group.id) return false;
|
|
1040
|
+
if (hasFormId && !sameAvailabilityId(selection.formId, group.formId)) return false;
|
|
1041
|
+
return true;
|
|
434
1042
|
}
|
|
435
1043
|
|
|
436
|
-
/**
|
|
437
|
-
|
|
1044
|
+
/**
|
|
1045
|
+
* POS may intentionally overbook a configured time/resource. Rebuild the
|
|
1046
|
+
* assignments from the submitted, structurally valid resource identities so
|
|
1047
|
+
* a full/unavailable option is not silently dropped by normal availability
|
|
1048
|
+
* evaluation. This deliberately ignores capacity, occupancy and resource
|
|
1049
|
+
* windows, while retaining product/group/resource identity and selection
|
|
1050
|
+
* cardinality checks.
|
|
1051
|
+
*/
|
|
1052
|
+
function buildPosForcedAvailabilityAssignments(params) {
|
|
1053
|
+
const {
|
|
1054
|
+
product,
|
|
1055
|
+
resources,
|
|
1056
|
+
candidate,
|
|
1057
|
+
requirementSelections,
|
|
1058
|
+
partySize
|
|
1059
|
+
} = params;
|
|
1060
|
+
const groups = product.requirementGroups || [];
|
|
1061
|
+
const conflicts = [];
|
|
1062
|
+
const assignments = [];
|
|
1063
|
+
const selectionsByGroup = new Map();
|
|
1064
|
+
const addConflict = (code, message, details = {}) => conflicts.push({
|
|
1065
|
+
code,
|
|
1066
|
+
message,
|
|
1067
|
+
productId: candidate.productId,
|
|
1068
|
+
candidateKey: candidate.key,
|
|
1069
|
+
...details
|
|
1070
|
+
});
|
|
1071
|
+
requirementSelections.forEach(selection => {
|
|
1072
|
+
const matchedGroups = groups.filter(group => matchesForcedSelectionGroup(selection, group));
|
|
1073
|
+
if (matchedGroups.length !== 1) {
|
|
1074
|
+
addConflict('invalid_requirement_group', '选择的资源组不属于当前商品', {
|
|
1075
|
+
requirementGroupId: selection.requirementGroupId,
|
|
1076
|
+
formId: selection.formId
|
|
1077
|
+
});
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
const group = matchedGroups[0];
|
|
1081
|
+
if (selectionsByGroup.has(group.id)) {
|
|
1082
|
+
addConflict('invalid_selection_count', `资源组 ${group.id} 被重复提交`, {
|
|
1083
|
+
requirementGroupId: group.id,
|
|
1084
|
+
formId: group.formId
|
|
1085
|
+
});
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
selectionsByGroup.set(group.id, selection);
|
|
1089
|
+
if (!Array.isArray(selection.resourceIds)) {
|
|
1090
|
+
addConflict('invalid_selection_count', `资源组 ${group.id} 的资源选择必须是数组`, {
|
|
1091
|
+
requirementGroupId: group.id,
|
|
1092
|
+
formId: group.formId
|
|
1093
|
+
});
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
const selectedResourceIds = Array.from(new Map(selection.resourceIds.map(resourceId => [String(resourceId), resourceId])).values());
|
|
1097
|
+
const required = group.required !== false;
|
|
1098
|
+
const min = Math.max(required ? 1 : 0, Number(group.min ?? (required ? 1 : 0)) || 0);
|
|
1099
|
+
const max = Math.max(min, Number(group.max ?? Math.max(min, 1)) || Math.max(min, 1));
|
|
1100
|
+
if (selectedResourceIds.length < min) {
|
|
1101
|
+
addConflict('missing_resource', `资源组 ${group.id} 缺少已选资源`, {
|
|
1102
|
+
requirementGroupId: group.id,
|
|
1103
|
+
formId: group.formId
|
|
1104
|
+
});
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (selectedResourceIds.length > max) {
|
|
1108
|
+
addConflict('invalid_selection_count', `资源组 ${group.id} 已选资源数量超出上限`, {
|
|
1109
|
+
requirementGroupId: group.id,
|
|
1110
|
+
formId: group.formId
|
|
1111
|
+
});
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
selectedResourceIds.forEach(resourceId => {
|
|
1115
|
+
if (sameAvailabilityId(resourceId, POS_NO_RESOURCE_ID)) {
|
|
1116
|
+
assignments.push({
|
|
1117
|
+
productId: product.id,
|
|
1118
|
+
resourceId: POS_NO_RESOURCE_ID,
|
|
1119
|
+
formId: group.formId ?? selection.formId,
|
|
1120
|
+
startAt: candidate.bookingStartAt || candidate.startAt,
|
|
1121
|
+
endAt: candidate.bookingEndAt || candidate.endAt,
|
|
1122
|
+
capacityRequired: Math.max(1, partySize),
|
|
1123
|
+
scheduleId: candidate.primaryScheduleRef?.scheduleId,
|
|
1124
|
+
timeSlotId: candidate.primaryScheduleRef?.timeSlotId
|
|
1125
|
+
});
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
const resource = resources.find(item => sameAvailabilityId(item.id, resourceId));
|
|
1129
|
+
const belongsToGroup = Boolean(resource && !(group.formId !== undefined && resource.formId !== undefined && !sameAvailabilityId(group.formId, resource.formId)) && !(group.resourceIds?.length && !group.resourceIds.some(id => sameAvailabilityId(id, resourceId))));
|
|
1130
|
+
if (!resource || !belongsToGroup) {
|
|
1131
|
+
addConflict('resource_unavailable', `资源 ${String(resourceId)} 不属于当前商品资源组`, {
|
|
1132
|
+
requirementGroupId: group.id,
|
|
1133
|
+
formId: group.formId,
|
|
1134
|
+
resourceId
|
|
1135
|
+
});
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
assignments.push({
|
|
1139
|
+
productId: product.id,
|
|
1140
|
+
resourceId,
|
|
1141
|
+
formId: group.formId ?? resource.formId,
|
|
1142
|
+
startAt: candidate.bookingStartAt || candidate.startAt,
|
|
1143
|
+
endAt: candidate.bookingEndAt || candidate.endAt,
|
|
1144
|
+
capacityRequired: Math.max(1, partySize),
|
|
1145
|
+
scheduleId: candidate.primaryScheduleRef?.scheduleId,
|
|
1146
|
+
timeSlotId: candidate.primaryScheduleRef?.timeSlotId
|
|
1147
|
+
});
|
|
1148
|
+
});
|
|
1149
|
+
});
|
|
1150
|
+
groups.filter(group => group.required !== false).forEach(group => {
|
|
1151
|
+
if (selectionsByGroup.has(group.id)) return;
|
|
1152
|
+
addConflict('missing_resource', `资源组 ${group.id} 缺少已选资源`, {
|
|
1153
|
+
requirementGroupId: group.id,
|
|
1154
|
+
formId: group.formId
|
|
1155
|
+
});
|
|
1156
|
+
});
|
|
438
1157
|
return {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
unique_identification_number: product.metadata?.unique_identification_number || product.unique_identification_number,
|
|
442
|
-
product_sku: product.product_sku,
|
|
443
|
-
product_bundle: product.product_bundle
|
|
1158
|
+
assignments,
|
|
1159
|
+
conflicts
|
|
444
1160
|
};
|
|
445
1161
|
}
|
|
446
1162
|
class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
|
|
@@ -451,55 +1167,375 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
|
|
|
451
1167
|
openDataTarget = null;
|
|
452
1168
|
loadOpenDataInFlight = null;
|
|
453
1169
|
loadOpenDataInFlightTarget = null;
|
|
1170
|
+
availabilityRuntimeOffsetMinutes = null;
|
|
1171
|
+
availabilityScheduleCatalog = null;
|
|
1172
|
+
availabilityProjectionSequence = 0;
|
|
1173
|
+
availabilityContextSequence = 0;
|
|
1174
|
+
availabilityLifecycleGeneration = 0;
|
|
1175
|
+
availabilityContextRegistry = new Map();
|
|
1176
|
+
availabilityProjectionPool = new Map();
|
|
1177
|
+
availabilityPrepareInFlight = new Map();
|
|
1178
|
+
availabilityProjectionRefreshInFlight = new Map();
|
|
1179
|
+
availabilityRemoteOccupancyCache = new _remoteOccupancyCache.AvailabilityRemoteOccupancyCache(MAX_REMOTE_OCCUPANCY_RESOURCE_DATE_CELLS);
|
|
1180
|
+
availabilityRemoteResourceQueryInFlight = new Map();
|
|
1181
|
+
availabilityRemoteResourceQuerySequence = 0;
|
|
1182
|
+
availabilityRemoteResourceEpoch = 0;
|
|
1183
|
+
availabilityCommitLocks = new Set();
|
|
1184
|
+
allergyItemRuleEvaluator = new _itemRule.ItemRuleEvaluator();
|
|
1185
|
+
allergyItemRuleConfigs = [];
|
|
1186
|
+
allergyItemRuleConfigsPromise = null;
|
|
1187
|
+
async initialize(core, options = {}) {
|
|
1188
|
+
this.availabilityScheduleCatalog = null;
|
|
1189
|
+
this.openDataTarget = null;
|
|
1190
|
+
this.availabilityRuntimeOffsetMinutes = (0, _localClock.captureRuntimeOffsetMinutes)();
|
|
1191
|
+
this.clearAvailabilityRemoteResourceState();
|
|
1192
|
+
this.resetAllergyItemRuleState();
|
|
1193
|
+
await super.initialize(core, options);
|
|
1194
|
+
const openDataBusinessCode = String(this.otherParams?.businessCode ?? this.otherParams?.business_code ?? '').trim();
|
|
1195
|
+
const openDataChannel = String(this.otherParams?.channel ?? '').trim();
|
|
1196
|
+
if (this.otherParams?.openData && openDataBusinessCode && openDataChannel) {
|
|
1197
|
+
this.openDataTarget = `${openDataBusinessCode}+${openDataChannel}`;
|
|
1198
|
+
}
|
|
1199
|
+
this.captureAvailabilityScheduleCatalog();
|
|
1200
|
+
}
|
|
454
1201
|
getSubmitOrderSalesChannel() {
|
|
455
1202
|
const channel = String(this.otherParams?.channel ?? '').trim();
|
|
456
1203
|
return channel || super.getSubmitOrderSalesChannel();
|
|
457
1204
|
}
|
|
458
|
-
|
|
459
|
-
return
|
|
1205
|
+
createAvailabilityFacadeError(code, message, details) {
|
|
1206
|
+
return new _ResourcePlanner.AvailabilityError(code, message, details);
|
|
460
1207
|
}
|
|
461
|
-
|
|
462
|
-
return
|
|
1208
|
+
getAvailabilityScopeDescriptor(otherParams = this.otherParams || {}) {
|
|
1209
|
+
return {
|
|
1210
|
+
cacheId: this.cacheId || '',
|
|
1211
|
+
businessCode: String(otherParams.businessCode ?? otherParams.business_code ?? ''),
|
|
1212
|
+
channel: String(otherParams.channel ?? ''),
|
|
1213
|
+
platform: String(otherParams.platform ?? ''),
|
|
1214
|
+
businessTimezone: String(otherParams.businessTimezone ?? '')
|
|
1215
|
+
};
|
|
463
1216
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const cacheData = this.readSessionCacheData();
|
|
472
|
-
const solutionCache = cacheData?.[this.name];
|
|
473
|
-
if (!isRecord(solutionCache) || !isRecord(solutionCache.sessionState)) {
|
|
474
|
-
return undefined;
|
|
475
|
-
}
|
|
476
|
-
return solutionCache.sessionState[key];
|
|
1217
|
+
normalizeAvailabilityIdList(values) {
|
|
1218
|
+
if (!Array.isArray(values) || values.length === 0) return undefined;
|
|
1219
|
+
const uniqueMap = new Map();
|
|
1220
|
+
values.forEach(value => {
|
|
1221
|
+
uniqueMap.set(String(value), value);
|
|
1222
|
+
});
|
|
1223
|
+
return Array.from(uniqueMap.entries()).sort(([left], [right]) => left.localeCompare(right)).map(([, value]) => value);
|
|
477
1224
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const
|
|
487
|
-
const
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
nextSessionState[key] = value;
|
|
1225
|
+
normalizeAvailabilityStringList(values) {
|
|
1226
|
+
if (!Array.isArray(values) || values.length === 0) return undefined;
|
|
1227
|
+
return Array.from(new Set(values.map(String))).sort((left, right) => left.localeCompare(right));
|
|
1228
|
+
}
|
|
1229
|
+
normalizeAvailabilityPrepareDescriptor(params) {
|
|
1230
|
+
this.assertAvailabilityPrepareParams(params);
|
|
1231
|
+
const timezone = String(params.timezone || this.otherParams?.businessTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai');
|
|
1232
|
+
assertValidAvailabilityTimezone(timezone);
|
|
1233
|
+
const dateRange = this.normalizeAvailabilityDateRange(params, timezone);
|
|
1234
|
+
const slotStepMinutes = params.slotStepMinutes || DEFAULT_AVAILABILITY_SLOT_STEP_MINUTES;
|
|
1235
|
+
const businessHours = params.businessHours || DEFAULT_AVAILABILITY_BUSINESS_HOURS;
|
|
1236
|
+
const mode = params.mode || 'appointment';
|
|
1237
|
+
if (!isValidClockTime(businessHours.startTime) || !isValidClockTime(businessHours.endTime)) {
|
|
1238
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'businessHours 必须使用 HH:mm', {
|
|
1239
|
+
businessHours
|
|
1240
|
+
});
|
|
495
1241
|
}
|
|
496
|
-
this.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
1242
|
+
return this.cloneAvailabilityPrepareParams({
|
|
1243
|
+
...params,
|
|
1244
|
+
productIds: this.normalizeAvailabilityIdList(params.productIds),
|
|
1245
|
+
resourceIds: this.normalizeAvailabilityIdList(params.resourceIds),
|
|
1246
|
+
editingProductLineUids: this.normalizeAvailabilityStringList(params.editingProductLineUids),
|
|
1247
|
+
dateRange,
|
|
1248
|
+
timezone,
|
|
1249
|
+
slotStepMinutes,
|
|
1250
|
+
businessHours: {
|
|
1251
|
+
...businessHours
|
|
501
1252
|
},
|
|
502
|
-
|
|
1253
|
+
_availabilityOperatingHours: params._availabilityOperatingHours ? {
|
|
1254
|
+
source: params._availabilityOperatingHours.source,
|
|
1255
|
+
scheduleIds: [...params._availabilityOperatingHours.scheduleIds]
|
|
1256
|
+
} : undefined,
|
|
1257
|
+
mode
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
async resolveAvailabilityPrepareDefaults(params) {
|
|
1261
|
+
const needsOperatingHours = params.businessHours === undefined;
|
|
1262
|
+
const needsTimeSlice = params.slotStepMinutes === undefined;
|
|
1263
|
+
if (!needsOperatingHours && !needsTimeSlice) {
|
|
1264
|
+
return {
|
|
1265
|
+
...params,
|
|
1266
|
+
_availabilityOperatingHours: {
|
|
1267
|
+
source: 'prepare_business_hours',
|
|
1268
|
+
scheduleIds: []
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
const businessCode = String(this.otherParams?.businessCode ?? this.otherParams?.business_code ?? '').trim();
|
|
1273
|
+
const channel = String(this.otherParams?.channel ?? '').trim();
|
|
1274
|
+
const currentTarget = businessCode && channel ? `${businessCode}+${channel}` : null;
|
|
1275
|
+
let openData = !currentTarget || this.openDataTarget === currentTarget ? this.otherParams?.openData : null;
|
|
1276
|
+
if (!openData && this.store.openData) {
|
|
1277
|
+
try {
|
|
1278
|
+
openData = await this.getOpenData();
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
if (needsOperatingHours) {
|
|
1281
|
+
throw this.createAvailabilityFacadeError('OPERATING_HOURS_RESOLVE_FAILED', '无法读取店铺营业时间配置,请重试', {
|
|
1282
|
+
businessCode,
|
|
1283
|
+
channel,
|
|
1284
|
+
cause: error instanceof Error ? error.message : String(error)
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
console.warn('[UnifiedBookingSales] OpenData 时间切片配置加载失败,回退为默认值', error);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
const operatingScheduleIds = this.normalizeAvailabilityIdList(Array.isArray(openData?.['availability.operating_hours']) ? openData?.['availability.operating_hours'] : undefined) || [];
|
|
1291
|
+
const configuredTimeSlice = needsTimeSlice ? normalizePositiveInteger(openData?.['reservation.time_slice']) : undefined;
|
|
1292
|
+
return {
|
|
1293
|
+
...params,
|
|
1294
|
+
...(configuredTimeSlice ? {
|
|
1295
|
+
slotStepMinutes: configuredTimeSlice
|
|
1296
|
+
} : {}),
|
|
1297
|
+
_availabilityOperatingHours: needsOperatingHours ? {
|
|
1298
|
+
source: operatingScheduleIds.length > 0 ? 'open_data_schedule' : 'default_business_hours',
|
|
1299
|
+
scheduleIds: operatingScheduleIds
|
|
1300
|
+
} : {
|
|
1301
|
+
source: 'prepare_business_hours',
|
|
1302
|
+
scheduleIds: []
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
buildAvailabilityPrepareKey(params) {
|
|
1307
|
+
const normalizedParams = this.normalizeAvailabilityPrepareDescriptor(params);
|
|
1308
|
+
return JSON.stringify(stableValue({
|
|
1309
|
+
scope: this.getAvailabilityScopeDescriptor(),
|
|
1310
|
+
prepare: normalizedParams
|
|
1311
|
+
}));
|
|
1312
|
+
}
|
|
1313
|
+
clearAvailabilityRemoteResourceState() {
|
|
1314
|
+
this.availabilityRemoteResourceEpoch += 1;
|
|
1315
|
+
this.availabilityRemoteOccupancyCache.clear();
|
|
1316
|
+
this.availabilityRemoteResourceQueryInFlight.clear();
|
|
1317
|
+
this.availabilityRemoteResourceQuerySequence = 0;
|
|
1318
|
+
}
|
|
1319
|
+
buildAvailabilityRemoteResourceScopeKey(timezoneName) {
|
|
1320
|
+
return JSON.stringify(stableValue({
|
|
1321
|
+
scope: this.getAvailabilityScopeDescriptor(),
|
|
1322
|
+
timezone: timezoneName
|
|
1323
|
+
}));
|
|
1324
|
+
}
|
|
1325
|
+
buildAvailabilityRemoteResourceQueryKey(params) {
|
|
1326
|
+
return JSON.stringify(stableValue({
|
|
1327
|
+
scope: this.getAvailabilityScopeDescriptor(),
|
|
1328
|
+
timezone: params.timezone,
|
|
1329
|
+
dateRange: params.dateRange,
|
|
1330
|
+
resourceIds: this.normalizeAvailabilityIdList(params.resourceIds) || []
|
|
1331
|
+
}));
|
|
1332
|
+
}
|
|
1333
|
+
async setOtherParams(params, {
|
|
1334
|
+
cover = false
|
|
1335
|
+
} = {}) {
|
|
1336
|
+
const previousScope = this.getAvailabilityScopeDescriptor(this.otherParams || {});
|
|
1337
|
+
const previousItemRuleConfig = this.otherParams?.itemRule;
|
|
1338
|
+
const explicitlySetsOpenData = Object.prototype.hasOwnProperty.call(params, 'openData');
|
|
1339
|
+
await super.setOtherParams(params, {
|
|
1340
|
+
cover
|
|
1341
|
+
});
|
|
1342
|
+
const nextScope = this.getAvailabilityScopeDescriptor(this.otherParams || {});
|
|
1343
|
+
if (explicitlySetsOpenData) {
|
|
1344
|
+
this.openDataTarget = this.otherParams?.openData && nextScope.businessCode && nextScope.channel ? `${nextScope.businessCode}+${nextScope.channel}` : null;
|
|
1345
|
+
}
|
|
1346
|
+
const shouldResetAvailabilityState = previousScope.cacheId !== nextScope.cacheId || previousScope.businessCode !== nextScope.businessCode || previousScope.channel !== nextScope.channel || previousScope.platform !== nextScope.platform || previousScope.businessTimezone !== nextScope.businessTimezone;
|
|
1347
|
+
if (shouldResetAvailabilityState) {
|
|
1348
|
+
await this.resetAvailabilityProjectionState('set_other_params_reset');
|
|
1349
|
+
}
|
|
1350
|
+
if (shouldResetAvailabilityState || explicitlySetsOpenData || previousItemRuleConfig !== this.otherParams?.itemRule) {
|
|
1351
|
+
this.resetAllergyItemRuleState();
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
async destroy() {
|
|
1355
|
+
this.availabilityScheduleCatalog = null;
|
|
1356
|
+
this.availabilityRuntimeOffsetMinutes = null;
|
|
1357
|
+
await this.resetAvailabilityProjectionState('destroy');
|
|
1358
|
+
this.resetAllergyItemRuleState();
|
|
1359
|
+
await super.destroy();
|
|
1360
|
+
}
|
|
1361
|
+
createAvailabilityContextId() {
|
|
1362
|
+
this.availabilityContextSequence += 1;
|
|
1363
|
+
return `${this.name}:availability:${Date.now()}:${this.availabilityContextSequence}`;
|
|
1364
|
+
}
|
|
1365
|
+
buildAvailabilityProjectionKey(contextId, version) {
|
|
1366
|
+
return `${contextId}:${version}`;
|
|
1367
|
+
}
|
|
1368
|
+
getAvailabilityRuntimeOffsetMinutes() {
|
|
1369
|
+
if (this.availabilityRuntimeOffsetMinutes === null) {
|
|
1370
|
+
this.availabilityRuntimeOffsetMinutes = (0, _localClock.captureRuntimeOffsetMinutes)();
|
|
1371
|
+
}
|
|
1372
|
+
return this.availabilityRuntimeOffsetMinutes;
|
|
1373
|
+
}
|
|
1374
|
+
getAvailabilityRuntimeDateTime(value = new Date()) {
|
|
1375
|
+
return (0, _localClock.formatLocalDateTime)((0, _localClock.localDateToScalar)(value), this.getAvailabilityRuntimeOffsetMinutes());
|
|
1376
|
+
}
|
|
1377
|
+
cloneAvailabilityPrepareParams(params) {
|
|
1378
|
+
return (0, _lodashEs.cloneDeep)({
|
|
1379
|
+
...params,
|
|
1380
|
+
productIds: params.productIds ? [...params.productIds] : undefined,
|
|
1381
|
+
resourceIds: params.resourceIds ? [...params.resourceIds] : undefined,
|
|
1382
|
+
rawProducts: params.rawProducts ? (0, _lodashEs.cloneDeep)(params.rawProducts) : undefined,
|
|
1383
|
+
rawResources: params.rawResources ? (0, _lodashEs.cloneDeep)(params.rawResources) : undefined,
|
|
1384
|
+
rawOccupancies: params.rawOccupancies ? (0, _lodashEs.cloneDeep)(params.rawOccupancies) : undefined,
|
|
1385
|
+
editingProductLineUids: params.editingProductLineUids ? [...params.editingProductLineUids] : undefined,
|
|
1386
|
+
dateRange: params.dateRange ? {
|
|
1387
|
+
...params.dateRange
|
|
1388
|
+
} : undefined,
|
|
1389
|
+
businessHours: params.businessHours ? {
|
|
1390
|
+
...params.businessHours
|
|
1391
|
+
} : undefined,
|
|
1392
|
+
_availabilityOperatingHours: params._availabilityOperatingHours ? {
|
|
1393
|
+
source: params._availabilityOperatingHours.source,
|
|
1394
|
+
scheduleIds: [...params._availabilityOperatingHours.scheduleIds]
|
|
1395
|
+
} : undefined,
|
|
1396
|
+
catalogScope: params.catalogScope?.type === 'schedule' ? {
|
|
1397
|
+
...params.catalogScope
|
|
1398
|
+
} : params.catalogScope?.type === 'productList' ? {
|
|
1399
|
+
type: 'productList',
|
|
1400
|
+
params: {
|
|
1401
|
+
...params.catalogScope.params
|
|
1402
|
+
}
|
|
1403
|
+
} : undefined
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
getAvailabilityDurationCandidateAnchorAt(projection) {
|
|
1407
|
+
const value = projection?.meta.durationCandidateAnchorAt;
|
|
1408
|
+
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
1409
|
+
}
|
|
1410
|
+
createAvailabilityContextRef(context) {
|
|
1411
|
+
return {
|
|
1412
|
+
contextId: context.contextId
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
async emitAvailabilityDiagnostic(payload) {
|
|
1416
|
+
await this.core?.effects?.emit?.(`${this.name}:${_types.UnifiedBookingSalesHooks.onAvailabilityProjectionDiagnostic}`, {
|
|
1417
|
+
...payload,
|
|
1418
|
+
registrySize: this.availabilityContextRegistry.size,
|
|
1419
|
+
projectionPoolSize: this.availabilityProjectionPool.size
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
touchAvailabilityContext(context) {
|
|
1423
|
+
context.lastAccessedAt = new Date().toISOString();
|
|
1424
|
+
this.availabilityContextRegistry.delete(context.contextId);
|
|
1425
|
+
this.availabilityContextRegistry.set(context.contextId, context);
|
|
1426
|
+
}
|
|
1427
|
+
touchAvailabilityProjection(entry) {
|
|
1428
|
+
this.availabilityProjectionPool.delete(entry.projectionKey);
|
|
1429
|
+
this.availabilityProjectionPool.set(entry.projectionKey, entry);
|
|
1430
|
+
return entry;
|
|
1431
|
+
}
|
|
1432
|
+
removeAvailabilityProjection(projectionKey) {
|
|
1433
|
+
if (!projectionKey) return;
|
|
1434
|
+
this.availabilityProjectionPool.delete(projectionKey);
|
|
1435
|
+
}
|
|
1436
|
+
removeAvailabilityContext(contextId) {
|
|
1437
|
+
const context = this.availabilityContextRegistry.get(contextId);
|
|
1438
|
+
if (!context) return;
|
|
1439
|
+
this.removeAvailabilityProjection(context.activeProjectionKey);
|
|
1440
|
+
this.availabilityProjectionRefreshInFlight.delete(contextId);
|
|
1441
|
+
this.availabilityCommitLocks.delete(contextId);
|
|
1442
|
+
this.availabilityContextRegistry.delete(contextId);
|
|
1443
|
+
}
|
|
1444
|
+
evictAvailabilityProjectionPool() {
|
|
1445
|
+
while (this.availabilityProjectionPool.size > MAX_AVAILABILITY_PROJECTION_POOL) {
|
|
1446
|
+
const oldestEntry = this.availabilityProjectionPool.entries().next().value;
|
|
1447
|
+
if (!oldestEntry) break;
|
|
1448
|
+
const [projectionKey, projectionEntry] = oldestEntry;
|
|
1449
|
+
this.availabilityProjectionPool.delete(projectionKey);
|
|
1450
|
+
const context = this.availabilityContextRegistry.get(projectionEntry.contextId);
|
|
1451
|
+
if (context?.activeProjectionKey === projectionKey) {
|
|
1452
|
+
context.activeProjectionKey = null;
|
|
1453
|
+
context.refreshNonce += 1;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
evictAvailabilityContextRegistry() {
|
|
1458
|
+
while (this.availabilityContextRegistry.size > MAX_AVAILABILITY_CONTEXT_REGISTRY) {
|
|
1459
|
+
const oldestContext = this.availabilityContextRegistry.entries().next().value;
|
|
1460
|
+
if (!oldestContext) break;
|
|
1461
|
+
this.removeAvailabilityContext(oldestContext[0]);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
invalidateAvailabilityContextsAfterCommit() {
|
|
1465
|
+
this.availabilityLifecycleGeneration += 1;
|
|
1466
|
+
const nextOrderRevision = this.getCurrentOrderRevision();
|
|
1467
|
+
this.availabilityProjectionPool.clear();
|
|
1468
|
+
this.availabilityPrepareInFlight.clear();
|
|
1469
|
+
this.availabilityProjectionRefreshInFlight.clear();
|
|
1470
|
+
this.availabilityContextRegistry.forEach(context => {
|
|
1471
|
+
context.activeVersion += 1;
|
|
1472
|
+
context.activeProjectionKey = null;
|
|
1473
|
+
context.lastKnownOrderRevision = nextOrderRevision;
|
|
1474
|
+
context.refreshNonce += 1;
|
|
1475
|
+
context.lifecycleGeneration = this.availabilityLifecycleGeneration;
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
async resetAvailabilityProjectionState(action) {
|
|
1479
|
+
this.availabilityLifecycleGeneration += 1;
|
|
1480
|
+
this.availabilityContextRegistry.clear();
|
|
1481
|
+
this.availabilityProjectionPool.clear();
|
|
1482
|
+
this.availabilityPrepareInFlight.clear();
|
|
1483
|
+
this.availabilityProjectionRefreshInFlight.clear();
|
|
1484
|
+
this.availabilityCommitLocks.clear();
|
|
1485
|
+
this.clearAvailabilityRemoteResourceState();
|
|
1486
|
+
await this.emitAvailabilityDiagnostic({
|
|
1487
|
+
action,
|
|
1488
|
+
contextId: null,
|
|
1489
|
+
contextVersion: null,
|
|
1490
|
+
reason: action,
|
|
1491
|
+
projection: null
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
isSessionStoragePersistEnabled() {
|
|
1495
|
+
return Boolean(this.cacheId && this.otherParams?.enableSessionStoragePersist === true);
|
|
1496
|
+
}
|
|
1497
|
+
shouldUseSessionStorageForSubModule(moduleName) {
|
|
1498
|
+
return this.isSessionStoragePersistEnabled() && moduleName === 'order';
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* 读取与当前 cacheId 同分桶的 Solution 会话状态。
|
|
1503
|
+
* 该状态独立于 otherParams,适合页面皮肤保存短生命周期 UI 状态。
|
|
1504
|
+
*/
|
|
1505
|
+
getSessionStateValue(key) {
|
|
1506
|
+
if (!key || !this.isSessionStoragePersistEnabled()) return undefined;
|
|
1507
|
+
const cacheData = this.readSessionCacheData();
|
|
1508
|
+
const solutionCache = cacheData?.[this.name];
|
|
1509
|
+
if (!isRecord(solutionCache) || !isRecord(solutionCache.sessionState)) {
|
|
1510
|
+
return undefined;
|
|
1511
|
+
}
|
|
1512
|
+
return solutionCache.sessionState[key];
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* 写入与当前 cacheId 同分桶的 Solution 会话状态。
|
|
1517
|
+
* value 为 undefined 时删除该 key;存储失败不阻断业务流程。
|
|
1518
|
+
*/
|
|
1519
|
+
setSessionStateValue(key, value) {
|
|
1520
|
+
if (!key || !this.isSessionStoragePersistEnabled()) return;
|
|
1521
|
+
const cacheData = this.readSessionCacheData();
|
|
1522
|
+
const solutionCache = cacheData?.[this.name];
|
|
1523
|
+
const currentSessionState = isRecord(solutionCache) && isRecord(solutionCache.sessionState) ? solutionCache.sessionState : {};
|
|
1524
|
+
const nextSessionState = {
|
|
1525
|
+
...currentSessionState
|
|
1526
|
+
};
|
|
1527
|
+
if (value === undefined) {
|
|
1528
|
+
delete nextSessionState[key];
|
|
1529
|
+
} else {
|
|
1530
|
+
nextSessionState[key] = value;
|
|
1531
|
+
}
|
|
1532
|
+
this.checkSaveCache({
|
|
1533
|
+
cacheId: this.cacheId,
|
|
1534
|
+
fatherModule: this.name,
|
|
1535
|
+
store: {
|
|
1536
|
+
sessionState: nextSessionState
|
|
1537
|
+
},
|
|
1538
|
+
cacheKey: ['sessionState']
|
|
503
1539
|
});
|
|
504
1540
|
}
|
|
505
1541
|
|
|
@@ -525,12 +1561,9 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
|
|
|
525
1561
|
return 1;
|
|
526
1562
|
}
|
|
527
1563
|
getRegisteredModuleNames() {
|
|
528
|
-
return [...super.getRegisteredModuleNames(), '
|
|
1564
|
+
return [...super.getRegisteredModuleNames(), 'openData'];
|
|
529
1565
|
}
|
|
530
1566
|
createSubModule(moduleName) {
|
|
531
|
-
if (moduleName === 'resourcePlanner') {
|
|
532
|
-
return new _modules.ResourcePlannerModule(`${this.name}_resourcePlanner`);
|
|
533
|
-
}
|
|
534
1567
|
if (moduleName === 'openData') {
|
|
535
1568
|
return new _modules.OpenDataModule(`${this.name}_openData`);
|
|
536
1569
|
}
|
|
@@ -582,8 +1615,7 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
|
|
|
582
1615
|
return true;
|
|
583
1616
|
}
|
|
584
1617
|
isAuthenticatedCustomerUserPlatform() {
|
|
585
|
-
|
|
586
|
-
return platform === 'pc' || platform === 'h5';
|
|
1618
|
+
return (0, _platform.isCustomerUserPlatform)(this.otherParams?.platform);
|
|
587
1619
|
}
|
|
588
1620
|
resolveDiscountOrderIdentity(tempOrder) {
|
|
589
1621
|
return tempOrder?.order_id || this.store.order?.getOrderIdentity?.()?.orderId || null;
|
|
@@ -675,6 +1707,113 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
|
|
|
675
1707
|
channel
|
|
676
1708
|
});
|
|
677
1709
|
}
|
|
1710
|
+
resetAllergyItemRuleState() {
|
|
1711
|
+
this.allergyItemRuleConfigs = [];
|
|
1712
|
+
this.allergyItemRuleConfigsPromise = null;
|
|
1713
|
+
this.allergyItemRuleEvaluator.setStrategyConfigs([]);
|
|
1714
|
+
}
|
|
1715
|
+
async fetchAllergyItemRuleConfigs(strategyModelIds) {
|
|
1716
|
+
if (!strategyModelIds.length) return [];
|
|
1717
|
+
try {
|
|
1718
|
+
const response = await this.request.get('/promotion', {
|
|
1719
|
+
skip: 1,
|
|
1720
|
+
num: 99,
|
|
1721
|
+
status: 'active',
|
|
1722
|
+
ids: strategyModelIds
|
|
1723
|
+
});
|
|
1724
|
+
if (response?.code !== 200 || !Array.isArray(response?.data?.list)) {
|
|
1725
|
+
throw new Error('[UnifiedBookingSales] 商品过敏策略加载失败');
|
|
1726
|
+
}
|
|
1727
|
+
const list = response.data.list;
|
|
1728
|
+
return normalizeItemRuleStrategyConfigs(list.map(item => item?.metadata));
|
|
1729
|
+
} catch (error) {
|
|
1730
|
+
// this.logMethodError('fetchAllergyItemRuleConfigs', error, {
|
|
1731
|
+
// strategyModelIds,
|
|
1732
|
+
// });
|
|
1733
|
+
throw error;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
async ensureAllergyItemRuleConfigsLoaded() {
|
|
1737
|
+
if (this.allergyItemRuleConfigs.length > 0) {
|
|
1738
|
+
return this.allergyItemRuleConfigs;
|
|
1739
|
+
}
|
|
1740
|
+
if (this.allergyItemRuleConfigsPromise) {
|
|
1741
|
+
return this.allergyItemRuleConfigsPromise;
|
|
1742
|
+
}
|
|
1743
|
+
this.allergyItemRuleConfigsPromise = (async () => {
|
|
1744
|
+
const staticConfigs = normalizeItemRuleStrategyConfigs(this.otherParams?.itemRule?.strategyConfigs);
|
|
1745
|
+
if (staticConfigs.length > 0) {
|
|
1746
|
+
this.allergyItemRuleConfigs = staticConfigs;
|
|
1747
|
+
this.allergyItemRuleEvaluator.setStrategyConfigs(staticConfigs);
|
|
1748
|
+
return staticConfigs;
|
|
1749
|
+
}
|
|
1750
|
+
const openData = await this.getOpenData();
|
|
1751
|
+
const enabled = toConfiguredBoolean(openData?.['sale.enable_item_rules']);
|
|
1752
|
+
const strategyModelIds = normalizePositiveIntegerList(openData?.['sale.enabled_item_rules']);
|
|
1753
|
+
if (!enabled || !strategyModelIds.length) {
|
|
1754
|
+
this.allergyItemRuleEvaluator.setStrategyConfigs([]);
|
|
1755
|
+
return [];
|
|
1756
|
+
}
|
|
1757
|
+
const loaded = await this.fetchAllergyItemRuleConfigs(strategyModelIds);
|
|
1758
|
+
this.allergyItemRuleConfigs = loaded;
|
|
1759
|
+
this.allergyItemRuleEvaluator.setStrategyConfigs(loaded);
|
|
1760
|
+
return loaded;
|
|
1761
|
+
})();
|
|
1762
|
+
return this.allergyItemRuleConfigsPromise;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/**
|
|
1766
|
+
* 评估当前渠道与商品是否命中 ALLERGY_CHECK。
|
|
1767
|
+
* 只返回优先级最高 Action 中当前商品 Target 的 dataSource,不跨商品或 Action 合并。
|
|
1768
|
+
*/
|
|
1769
|
+
async resolveProductAllergyRequirement(params) {
|
|
1770
|
+
const productId = normalizePositiveInteger(params.productId);
|
|
1771
|
+
if (!productId) return null;
|
|
1772
|
+
const strategyConfigs = await this.ensureAllergyItemRuleConfigsLoaded();
|
|
1773
|
+
if (!strategyConfigs.length) return null;
|
|
1774
|
+
const productVariantId = normalizePositiveInteger(params.productVariantId);
|
|
1775
|
+
const runtimeConfig = this.otherParams?.itemRule || {};
|
|
1776
|
+
const runtimePax = runtimeConfig.pax || {};
|
|
1777
|
+
const cartItems = (this.getOrderProducts?.() || []).map(item => ({
|
|
1778
|
+
product_id: Number(item?.product_id ?? item?.id),
|
|
1779
|
+
product_variant_id: Number(item?.product_variant_id ?? 0) || undefined,
|
|
1780
|
+
quantity: Number(item?.num ?? item?.quantity ?? 0)
|
|
1781
|
+
})).filter(item => Number.isFinite(item.product_id) && item.product_id > 0 && Number.isFinite(item.quantity) && item.quantity > 0);
|
|
1782
|
+
return this.allergyItemRuleEvaluator.getAllergyRequirement({
|
|
1783
|
+
pax: {
|
|
1784
|
+
total: Number(runtimePax.total ?? 0) || 0,
|
|
1785
|
+
adult: Number(runtimePax.adult ?? 0) || 0,
|
|
1786
|
+
child: Number(runtimePax.child ?? 0) || 0
|
|
1787
|
+
},
|
|
1788
|
+
cartItems,
|
|
1789
|
+
serviceType: String(runtimeConfig.serviceType ?? this.otherParams?.serviceType ?? ''),
|
|
1790
|
+
channel: String(params.channel ?? this.otherParams?.channel ?? this.otherParams?.platform ?? ''),
|
|
1791
|
+
currentProduct: {
|
|
1792
|
+
product_id: productId,
|
|
1793
|
+
...(productVariantId ? {
|
|
1794
|
+
product_variant_id: productVariantId
|
|
1795
|
+
} : {})
|
|
1796
|
+
},
|
|
1797
|
+
submissionIndex: Number(runtimeConfig.submissionIndex ?? 0) || 0,
|
|
1798
|
+
strategyConfigs,
|
|
1799
|
+
custom: runtimeConfig.custom
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Load a resource policy without depending on the legacy appointmentBooking solution.
|
|
1805
|
+
*/
|
|
1806
|
+
async getProtocol(policyId) {
|
|
1807
|
+
const normalizedPolicyId = String(policyId ?? '').trim();
|
|
1808
|
+
if (!normalizedPolicyId) {
|
|
1809
|
+
throw new Error('[UnifiedBookingSales] getProtocol 需要 policyId');
|
|
1810
|
+
}
|
|
1811
|
+
let policyUrl = 'shop-policy';
|
|
1812
|
+
if (this.otherParams.platform === 'kiosk' || this.otherParams.platform === 'pos' || this.otherParams.platform === 'shop') {
|
|
1813
|
+
policyUrl = 'policy';
|
|
1814
|
+
}
|
|
1815
|
+
return this.request.get(`/${policyUrl}/${encodeURIComponent(normalizedPolicyId)}`);
|
|
1816
|
+
}
|
|
678
1817
|
|
|
679
1818
|
/**
|
|
680
1819
|
* Loads the product catalog used by both UI and Planner. Schedule data is requested with each
|
|
@@ -696,6 +1835,10 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
|
|
|
696
1835
|
await this.core.effects.emit(`${this.name}:onProductsLoaded`, result);
|
|
697
1836
|
return result;
|
|
698
1837
|
}
|
|
1838
|
+
async queryProducts(params = {}) {
|
|
1839
|
+
if (!this.store.products) throw new Error('[UnifiedBookingSales] products 模块未初始化');
|
|
1840
|
+
return this.store.products.queryProducts(params);
|
|
1841
|
+
}
|
|
699
1842
|
|
|
700
1843
|
/** Date-first entry point. It delegates protocol details and cache ownership to ScheduleModule. */
|
|
701
1844
|
async loadScheduleAvailableDate(params) {
|
|
@@ -863,23 +2006,17 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
|
|
|
863
2006
|
preferredDiscountFound: preferredDiscountId === undefined || preferredDiscountId === null ? undefined : Boolean(preferredDiscount)
|
|
864
2007
|
});
|
|
865
2008
|
}
|
|
866
|
-
|
|
867
|
-
/** ResourcePlanner is a required child module; fail early instead of producing partial availability. */
|
|
868
|
-
getPlanner() {
|
|
869
|
-
if (!this.store.resourcePlanner) {
|
|
870
|
-
throw new Error('[UnifiedBookingSales] resourcePlanner 模块未初始化');
|
|
871
|
-
}
|
|
872
|
-
return this.store.resourcePlanner;
|
|
873
|
-
}
|
|
874
2009
|
buildUnifiedOrderProduct(sourceProduct, ownershipKey, product) {
|
|
2010
|
+
const normalizedProductRaw = product?.raw || {};
|
|
2011
|
+
const quotationShelfId = sourceProduct._extend?.quotation_shelf_id ?? sourceProduct.quotation_shelf_id ?? sourceProduct.metadata?.quotation_shelf_id;
|
|
875
2012
|
const orderProduct = this.transformBaseProductToOrderProduct({
|
|
876
2013
|
payload: {
|
|
877
2014
|
...sourceProduct,
|
|
878
2015
|
id: sourceProduct.id ?? product?.id,
|
|
879
2016
|
product_id: sourceProduct.product_id ?? sourceProduct.id ?? product?.id,
|
|
880
2017
|
quantity: sourceProduct.quantity ?? sourceProduct.num ?? product?.quantity ?? 1,
|
|
881
|
-
price: sourceProduct.price ??
|
|
882
|
-
selling_price: sourceProduct.selling_price ?? sourceProduct.price ??
|
|
2018
|
+
price: sourceProduct.price ?? normalizedProductRaw.price,
|
|
2019
|
+
selling_price: sourceProduct.selling_price ?? sourceProduct.price ?? normalizedProductRaw.selling_price ?? normalizedProductRaw.price,
|
|
883
2020
|
metadata: {
|
|
884
2021
|
...(sourceProduct.metadata || {}),
|
|
885
2022
|
[ownershipKey]: true
|
|
@@ -888,11 +2025,14 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
|
|
|
888
2025
|
sourceProduct
|
|
889
2026
|
});
|
|
890
2027
|
if (orderProduct && typeof orderProduct === 'object') {
|
|
891
|
-
// BaseSales may rebuild metadata from its price source. Stamp ownership
|
|
892
|
-
//
|
|
2028
|
+
// BaseSales may rebuild metadata from its price source. Stamp ownership and the selected
|
|
2029
|
+
// quotation shelf after that transform so neither booking adds nor edits lose quote identity.
|
|
893
2030
|
orderProduct.metadata = {
|
|
894
2031
|
...(orderProduct.metadata || {}),
|
|
895
|
-
[ownershipKey]: true
|
|
2032
|
+
[ownershipKey]: true,
|
|
2033
|
+
...(quotationShelfId !== undefined ? {
|
|
2034
|
+
quotation_shelf_id: quotationShelfId
|
|
2035
|
+
} : {})
|
|
896
2036
|
};
|
|
897
2037
|
}
|
|
898
2038
|
return orderProduct;
|
|
@@ -902,7 +2042,7 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
|
|
|
902
2042
|
async removeExistingStagedLines(ownershipKey) {
|
|
903
2043
|
const existingLines = (this.getTempOrder()?.products || []).filter(product => product?.metadata?.[ownershipKey] === true);
|
|
904
2044
|
if (existingLines.length > 0) {
|
|
905
|
-
await this.removeProductsFromOrder(existingLines.map(
|
|
2045
|
+
await this.removeProductsFromOrder(existingLines.map(buildAvailabilityLineIdentity));
|
|
906
2046
|
}
|
|
907
2047
|
}
|
|
908
2048
|
async stageProducts(params, ownershipKey) {
|
|
@@ -930,14 +2070,6 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
|
|
|
930
2070
|
return this.getTempOrder();
|
|
931
2071
|
}
|
|
932
2072
|
|
|
933
|
-
/**
|
|
934
|
-
* Adds the current product choice to an otherwise clean planner cart without creating bookings.
|
|
935
|
-
* This separates "what the customer wants" from the later time/resource confirmation step.
|
|
936
|
-
*/
|
|
937
|
-
async stagePlannerProducts(params) {
|
|
938
|
-
return this.stageProducts(params, 'unified_booking_sales');
|
|
939
|
-
}
|
|
940
|
-
|
|
941
2073
|
/**
|
|
942
2074
|
* Adds ordinary retail products without asking ResourcePlanner for booking facts.
|
|
943
2075
|
* Its staged lines have separate ownership so planner and retail skins can coexist safely.
|
|
@@ -980,59 +2112,194 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
|
|
|
980
2112
|
}
|
|
981
2113
|
return this.addProductToOrder(orderProduct);
|
|
982
2114
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
2115
|
+
normalizeAvailabilityDateRange(params, timezone) {
|
|
2116
|
+
const today = (0, _localClock.formatLocalDate)((0, _localClock.localDateToScalar)(new Date()));
|
|
2117
|
+
const startDate = params.dateRange?.startDate || today;
|
|
2118
|
+
const endDate = params.dateRange?.endDate || (0, _localClock.formatLocalDate)((0, _localClock.addLocalCalendarDays)((0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(startDate)), 31));
|
|
2119
|
+
let startScalar;
|
|
2120
|
+
let endScalar;
|
|
2121
|
+
try {
|
|
2122
|
+
startScalar = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(startDate));
|
|
2123
|
+
endScalar = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(endDate));
|
|
2124
|
+
} catch (error) {
|
|
2125
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_DATE_RANGE', '日期范围必须是有效的 YYYY-MM-DD 且结束日期不早于开始日期', {
|
|
2126
|
+
dateRange: {
|
|
2127
|
+
startDate,
|
|
2128
|
+
endDate
|
|
2129
|
+
},
|
|
2130
|
+
timezone,
|
|
2131
|
+
cause: error instanceof Error ? error.message : String(error)
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
if (endScalar < startScalar) {
|
|
2135
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_DATE_RANGE', '日期范围必须是有效的 YYYY-MM-DD 且结束日期不早于开始日期', {
|
|
2136
|
+
dateRange: {
|
|
2137
|
+
startDate,
|
|
2138
|
+
endDate
|
|
2139
|
+
},
|
|
2140
|
+
timezone
|
|
996
2141
|
});
|
|
997
2142
|
}
|
|
998
|
-
|
|
2143
|
+
const inclusiveDays = Math.trunc((endScalar - startScalar) / (24 * 60 * 60 * 1000)) + 1;
|
|
2144
|
+
if (inclusiveDays > MAX_AVAILABILITY_RANGE_DAYS) {
|
|
2145
|
+
throw new _ResourcePlanner.AvailabilityError('RANGE_TOO_LARGE', '日期范围最多包含 90 个自然日', {
|
|
2146
|
+
dateRange: {
|
|
2147
|
+
startDate,
|
|
2148
|
+
endDate
|
|
2149
|
+
},
|
|
2150
|
+
inclusiveDays
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
return {
|
|
2154
|
+
startDate,
|
|
2155
|
+
endDate
|
|
2156
|
+
};
|
|
999
2157
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
const
|
|
1005
|
-
if (!
|
|
1006
|
-
|
|
2158
|
+
assertAvailabilityPrepareParams(params) {
|
|
2159
|
+
assertAvailabilityCatalogProductIds(params.productIds, 'prepare.productIds');
|
|
2160
|
+
const hasProductAnchor = Array.isArray(params.productIds) && params.productIds.length > 0;
|
|
2161
|
+
const hasResourceAnchor = Array.isArray(params.resourceIds) && params.resourceIds.length > 0;
|
|
2162
|
+
const hasDateAnchor = Boolean(params.dateRange?.startDate || params.dateRange?.endDate);
|
|
2163
|
+
if (!hasProductAnchor && !hasResourceAnchor && !hasDateAnchor) {
|
|
2164
|
+
throw new _ResourcePlanner.AvailabilityError('MISSING_ANCHOR', 'prepare 至少需要 productIds、resourceIds 或 dateRange 之一', {
|
|
2165
|
+
params
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
if (!hasProductAnchor && !params.catalogScope) {
|
|
2169
|
+
throw new _ResourcePlanner.AvailabilityError('MISSING_CATALOG_SCOPE', '缺少 productIds 时必须提供 catalogScope', {
|
|
2170
|
+
params
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
if (params.slotStepMinutes !== undefined) {
|
|
2174
|
+
const step = Number(params.slotStepMinutes);
|
|
2175
|
+
if (!Number.isInteger(step) || step <= 0) {
|
|
2176
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'slotStepMinutes 必须是正整数', {
|
|
2177
|
+
slotStepMinutes: params.slotStepMinutes
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
1007
2180
|
}
|
|
1008
|
-
|
|
2181
|
+
}
|
|
2182
|
+
captureAvailabilityScheduleCatalog() {
|
|
2183
|
+
if (!this.store.schedule) {
|
|
2184
|
+
throw new Error('[UnifiedBookingSales] schedule 模块未初始化');
|
|
2185
|
+
}
|
|
2186
|
+
this.availabilityScheduleCatalog = (0, _lodashEs.cloneDeep)(this.store.schedule.getScheduleList?.() || []);
|
|
1009
2187
|
}
|
|
1010
2188
|
|
|
1011
|
-
/**
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
2189
|
+
/** Returns the solution-lifetime schedule snapshot captured after initialization. */
|
|
2190
|
+
getAvailabilityScheduleCatalog() {
|
|
2191
|
+
if (this.availabilityScheduleCatalog === null) {
|
|
2192
|
+
throw new Error('[UnifiedBookingSales] schedule 快照未初始化');
|
|
2193
|
+
}
|
|
2194
|
+
return this.availabilityScheduleCatalog;
|
|
2195
|
+
}
|
|
2196
|
+
buildAvailabilityOperatingHoursSnapshot(params) {
|
|
2197
|
+
const descriptor = params.prepare._availabilityOperatingHours || {
|
|
2198
|
+
source: params.prepare.businessHours ? 'prepare_business_hours' : 'default_business_hours',
|
|
2199
|
+
scheduleIds: []
|
|
2200
|
+
};
|
|
2201
|
+
if (descriptor.source !== 'open_data_schedule') {
|
|
2202
|
+
const windows = dateRangeDays(params.dateRange).flatMap(date => {
|
|
2203
|
+
const range = normalizeAvailabilityLocalDateTimeRange({
|
|
2204
|
+
startAt: `${date}T${params.businessHours.startTime}:00`,
|
|
2205
|
+
endAt: `${date}T${params.businessHours.endTime}:00`,
|
|
2206
|
+
fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
|
|
2207
|
+
rollOverEnd: true
|
|
2208
|
+
});
|
|
2209
|
+
return range ? [{
|
|
2210
|
+
startAt: range.startAt,
|
|
2211
|
+
endAt: range.endAt,
|
|
2212
|
+
source: descriptor.source
|
|
2213
|
+
}] : [];
|
|
2214
|
+
});
|
|
2215
|
+
return {
|
|
2216
|
+
source: descriptor.source,
|
|
2217
|
+
scheduleIds: [],
|
|
2218
|
+
missingScheduleIds: [],
|
|
2219
|
+
windows
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
const scheduleMap = new Map(params.scheduleList.map(schedule => [String(schedule.id), schedule]));
|
|
2223
|
+
const schedules = descriptor.scheduleIds.map(scheduleId => scheduleMap.get(String(scheduleId))).filter(schedule => Boolean(schedule));
|
|
2224
|
+
const matchedScheduleIds = new Set(schedules.map(schedule => String(schedule.id)));
|
|
2225
|
+
const missingScheduleIds = descriptor.scheduleIds.filter(scheduleId => !matchedScheduleIds.has(String(scheduleId)));
|
|
2226
|
+
const windows = dateRangeDays(params.dateRange).flatMap(date => (0, _getDateIsInSchedule.getScheduleStartEndTimePoints)(date, schedules).flatMap(slot => {
|
|
2227
|
+
const range = normalizeAvailabilityLocalDateTimeRange({
|
|
2228
|
+
startAt: slot.start_at,
|
|
2229
|
+
endAt: slot.end_at,
|
|
2230
|
+
fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
|
|
2231
|
+
rollOverEnd: true
|
|
2232
|
+
});
|
|
2233
|
+
return range ? [{
|
|
2234
|
+
startAt: range.startAt,
|
|
2235
|
+
endAt: range.endAt,
|
|
2236
|
+
source: `operating_schedule:${descriptor.scheduleIds.map(String).join(',')}`
|
|
2237
|
+
}] : [];
|
|
2238
|
+
}));
|
|
2239
|
+
return {
|
|
2240
|
+
source: descriptor.source,
|
|
2241
|
+
scheduleIds: [...descriptor.scheduleIds],
|
|
2242
|
+
missingScheduleIds,
|
|
2243
|
+
windows
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
async loadAvailabilityProducts(params) {
|
|
2247
|
+
const {
|
|
2248
|
+
prepare,
|
|
2249
|
+
dateRange
|
|
2250
|
+
} = params;
|
|
2251
|
+
const explicitProductIds = prepare.productIds || [];
|
|
2252
|
+
let rawProducts = [];
|
|
2253
|
+
if (Array.isArray(prepare.rawProducts)) {
|
|
2254
|
+
rawProducts = prepare.rawProducts;
|
|
2255
|
+
this.unifiedProductCatalog = [...rawProducts];
|
|
2256
|
+
} else if (explicitProductIds.length > 0) {
|
|
2257
|
+
rawProducts = await this.loadProducts({
|
|
2258
|
+
product_ids: explicitProductIds,
|
|
2259
|
+
schedule_date: dateRange.startDate
|
|
2260
|
+
});
|
|
2261
|
+
} else if (prepare.catalogScope?.type === 'schedule') {
|
|
2262
|
+
await this.loadScheduleAvailableDate({
|
|
2263
|
+
startDate: dateRange.startDate,
|
|
2264
|
+
endDate: dateRange.endDate,
|
|
2265
|
+
custom_page_id: Number(prepare.catalogScope.customPageId) || undefined,
|
|
2266
|
+
channel: prepare.catalogScope.channel
|
|
2267
|
+
});
|
|
2268
|
+
const dateSet = new Set(dateRangeDays(dateRange));
|
|
2269
|
+
const scheduleProductIds = (this.store.schedule.getAvailabilityScheduleDateList?.() || []).filter(item => dateSet.has(item.date)).flatMap(item => item.product_ids || []);
|
|
2270
|
+
const productIds = uniqueAvailabilityIds([...scheduleProductIds, ...(this.store.schedule.getOtherProductsIds?.() || [])]);
|
|
2271
|
+
if (productIds.length > 0) {
|
|
2272
|
+
assertAvailabilityCatalogProductIds(productIds, 'prepare.catalogScope.scheduleProductIds');
|
|
2273
|
+
rawProducts = await this.loadProducts({
|
|
2274
|
+
product_ids: productIds,
|
|
2275
|
+
schedule_date: dateRange.startDate
|
|
2276
|
+
});
|
|
2277
|
+
} else {
|
|
2278
|
+
this.unifiedProductCatalog = [];
|
|
2279
|
+
}
|
|
2280
|
+
} else if (prepare.catalogScope?.type === 'productList') {
|
|
2281
|
+
rawProducts = await this.loadProducts({
|
|
2282
|
+
...prepare.catalogScope.params,
|
|
2283
|
+
schedule_date: prepare.catalogScope.params.schedule_date || dateRange.startDate
|
|
1021
2284
|
});
|
|
1022
|
-
scheduleList = this.store.schedule.getScheduleList?.() || [];
|
|
1023
2285
|
}
|
|
1024
|
-
|
|
2286
|
+
const normalized = rawProducts.map(product => normalizeProductForAvailability(product, params.scheduleList, this.getAvailabilityRuntimeOffsetMinutes()));
|
|
2287
|
+
if (!explicitProductIds.length && prepare.resourceIds?.length) {
|
|
2288
|
+
return normalized.filter(product => product.requirementGroups?.some(group => group.resourceIds?.some(resourceId => prepare.resourceIds.some(id => sameAvailabilityId(id, resourceId)))));
|
|
2289
|
+
}
|
|
2290
|
+
const productIdSet = explicitProductIds.length ? new Set(explicitProductIds.map(String)) : null;
|
|
2291
|
+
return productIdSet ? normalized.filter(product => productIdSet.has(String(product.id))) : normalized;
|
|
1025
2292
|
}
|
|
1026
2293
|
|
|
1027
2294
|
/**
|
|
1028
2295
|
* Fetches resource metadata plus flat occupied events. Work windows are intentionally derived
|
|
1029
|
-
* locally in
|
|
2296
|
+
* locally in buildAvailabilityResourcesFromV2 from /schedule, rather than requested from the legacy
|
|
1030
2297
|
* resource dates endpoint.
|
|
1031
2298
|
*/
|
|
1032
|
-
async
|
|
2299
|
+
async fetchAvailabilityResourcesV2(params) {
|
|
1033
2300
|
const response = await this.request.get('/schedule/resource/list/v2', {
|
|
1034
|
-
start_date: params.dateRange.
|
|
1035
|
-
end_date: params.dateRange.
|
|
2301
|
+
start_date: params.dateRange.startDate,
|
|
2302
|
+
end_date: params.dateRange.endDate,
|
|
1036
2303
|
resource_ids: params.resourceIds,
|
|
1037
2304
|
front_end_cache_id: false
|
|
1038
2305
|
}, {
|
|
@@ -1043,180 +2310,1081 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
|
|
|
1043
2310
|
if (Array.isArray(data?.list)) return data.list;
|
|
1044
2311
|
return [];
|
|
1045
2312
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
2313
|
+
async fetchAndPublishAvailabilityResourcesV2(params) {
|
|
2314
|
+
const normalizedResourceIds = this.normalizeAvailabilityIdList(params.resourceIds) || [];
|
|
2315
|
+
if (normalizedResourceIds.length === 0) return [];
|
|
2316
|
+
const scopeKey = this.buildAvailabilityRemoteResourceScopeKey(params.timezone);
|
|
2317
|
+
const cacheParams = {
|
|
2318
|
+
scopeKey,
|
|
2319
|
+
resourceIds: normalizedResourceIds,
|
|
2320
|
+
dateRange: params.dateRange
|
|
2321
|
+
};
|
|
2322
|
+
const cachedRawResources = this.availabilityRemoteOccupancyCache.read(cacheParams);
|
|
2323
|
+
if (cachedRawResources) {
|
|
2324
|
+
if (params.useCacheOnHit) {
|
|
2325
|
+
void this.refreshAvailabilityRemoteResourceQuery({
|
|
2326
|
+
timezone: params.timezone,
|
|
2327
|
+
dateRange: params.dateRange,
|
|
2328
|
+
resourceIds: normalizedResourceIds
|
|
2329
|
+
}).catch(error => {
|
|
2330
|
+
console.warn('[UnifiedBookingSales] Remote occupancy background refresh failed', error);
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
return cachedRawResources;
|
|
2334
|
+
}
|
|
2335
|
+
return await this.refreshAvailabilityRemoteResourceQuery({
|
|
2336
|
+
timezone: params.timezone,
|
|
2337
|
+
dateRange: params.dateRange,
|
|
2338
|
+
resourceIds: normalizedResourceIds
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
async refreshAvailabilityRemoteResourceQuery(params) {
|
|
2342
|
+
const normalizedResourceIds = this.normalizeAvailabilityIdList(params.resourceIds) || [];
|
|
2343
|
+
if (normalizedResourceIds.length === 0) return [];
|
|
2344
|
+
const queryKey = this.buildAvailabilityRemoteResourceQueryKey({
|
|
2345
|
+
timezone: params.timezone,
|
|
2346
|
+
dateRange: params.dateRange,
|
|
2347
|
+
resourceIds: normalizedResourceIds
|
|
2348
|
+
});
|
|
2349
|
+
const epoch = this.availabilityRemoteResourceEpoch;
|
|
2350
|
+
const existing = this.availabilityRemoteResourceQueryInFlight.get(queryKey);
|
|
2351
|
+
if (existing?.epoch === epoch) {
|
|
2352
|
+
return await existing.promise;
|
|
2353
|
+
}
|
|
2354
|
+
this.availabilityRemoteResourceQuerySequence += 1;
|
|
2355
|
+
const writeVersion = this.availabilityRemoteResourceQuerySequence;
|
|
2356
|
+
const promise = this.fetchAvailabilityResourcesV2({
|
|
2357
|
+
dateRange: params.dateRange,
|
|
2358
|
+
resourceIds: normalizedResourceIds
|
|
2359
|
+
});
|
|
2360
|
+
this.availabilityRemoteResourceQueryInFlight.set(queryKey, {
|
|
2361
|
+
epoch,
|
|
2362
|
+
promise
|
|
2363
|
+
});
|
|
2364
|
+
try {
|
|
2365
|
+
const rawResources = await promise;
|
|
2366
|
+
if (epoch === this.availabilityRemoteResourceEpoch) {
|
|
2367
|
+
this.availabilityRemoteOccupancyCache.publish({
|
|
2368
|
+
scopeKey: this.buildAvailabilityRemoteResourceScopeKey(params.timezone),
|
|
2369
|
+
timezoneName: params.timezone,
|
|
2370
|
+
resourceIds: normalizedResourceIds,
|
|
2371
|
+
dateRange: params.dateRange,
|
|
2372
|
+
rawResources,
|
|
2373
|
+
version: writeVersion
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
return rawResources;
|
|
2377
|
+
} finally {
|
|
2378
|
+
const current = this.availabilityRemoteResourceQueryInFlight.get(queryKey);
|
|
2379
|
+
if (current?.promise === promise) {
|
|
2380
|
+
this.availabilityRemoteResourceQueryInFlight.delete(queryKey);
|
|
2381
|
+
}
|
|
1057
2382
|
}
|
|
1058
|
-
|
|
2383
|
+
}
|
|
2384
|
+
async loadAvailabilityResources(params) {
|
|
2385
|
+
const resourceIds = collectAvailabilityResourceIds(params.products, params.resourceIds || []);
|
|
2386
|
+
if (resourceIds.length === 0) {
|
|
1059
2387
|
return {
|
|
1060
|
-
resources:
|
|
1061
|
-
|
|
2388
|
+
resources: [],
|
|
2389
|
+
resourceOccupancies: []
|
|
1062
2390
|
};
|
|
1063
2391
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
2392
|
+
if (Array.isArray(params.rawResources)) {
|
|
2393
|
+
return buildAvailabilityResourcesFromV2({
|
|
2394
|
+
rawResources: params.rawResources,
|
|
2395
|
+
scheduleList: params.scheduleList,
|
|
2396
|
+
dateRange: params.dateRange,
|
|
2397
|
+
fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes()
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
if (params.fetchPolicy === 'cache_only') {
|
|
2401
|
+
const cachedRawResources = this.availabilityRemoteOccupancyCache.read({
|
|
2402
|
+
scopeKey: this.buildAvailabilityRemoteResourceScopeKey(params.timezone),
|
|
2403
|
+
resourceIds,
|
|
2404
|
+
dateRange: params.dateRange
|
|
2405
|
+
});
|
|
2406
|
+
if (cachedRawResources) {
|
|
2407
|
+
return buildAvailabilityResourcesFromV2({
|
|
2408
|
+
rawResources: cachedRawResources,
|
|
2409
|
+
scheduleList: params.scheduleList,
|
|
2410
|
+
dateRange: params.dateRange,
|
|
2411
|
+
fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes()
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
return params.fallbackResources ? (0, _lodashEs.cloneDeep)(params.fallbackResources) : {
|
|
1067
2415
|
resources: [],
|
|
1068
|
-
|
|
2416
|
+
resourceOccupancies: []
|
|
1069
2417
|
};
|
|
1070
2418
|
}
|
|
1071
|
-
const rawResources = await this.
|
|
2419
|
+
const rawResources = await this.fetchAndPublishAvailabilityResourcesV2({
|
|
2420
|
+
timezone: params.timezone,
|
|
1072
2421
|
dateRange: params.dateRange,
|
|
1073
|
-
resourceIds
|
|
2422
|
+
resourceIds,
|
|
2423
|
+
useCacheOnHit: true
|
|
1074
2424
|
});
|
|
1075
|
-
|
|
1076
|
-
const materialized = buildPlannerResourcesFromV2({
|
|
2425
|
+
return buildAvailabilityResourcesFromV2({
|
|
1077
2426
|
rawResources,
|
|
1078
|
-
scheduleList,
|
|
1079
|
-
dateRange: params.dateRange
|
|
2427
|
+
scheduleList: params.scheduleList,
|
|
2428
|
+
dateRange: params.dateRange,
|
|
2429
|
+
fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes()
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
resolveEditingOccupancyExclusions(tempOrder, productLineUids = []) {
|
|
2433
|
+
const productUids = new Set();
|
|
2434
|
+
const bookingUids = new Set();
|
|
2435
|
+
const scheduleEventIds = new Set();
|
|
2436
|
+
if (!productLineUids.length) return {
|
|
2437
|
+
productUids,
|
|
2438
|
+
bookingUids,
|
|
2439
|
+
scheduleEventIds
|
|
2440
|
+
};
|
|
2441
|
+
const products = Array.isArray(tempOrder?.products) ? tempOrder.products : [];
|
|
2442
|
+
const bookings = Array.isArray(tempOrder?.bookings) ? tempOrder.bookings : [];
|
|
2443
|
+
productLineUids.forEach(requestedUid => {
|
|
2444
|
+
const product = products.find(line => getProductLineUid(line) === String(requestedUid));
|
|
2445
|
+
if (!product) {
|
|
2446
|
+
throw new _ResourcePlanner.AvailabilityError('SELF_EXCLUSION_RESOLVE_FAILED', '无法解析编辑商品行,不能安全排除自身占用', {
|
|
2447
|
+
productLineUid: requestedUid
|
|
2448
|
+
});
|
|
2449
|
+
}
|
|
2450
|
+
const productUid = getProductLineUid(product);
|
|
2451
|
+
const linkedBookingUid = String(product.booking_uid ?? product.metadata?.booking_uid ?? '');
|
|
2452
|
+
const linkedBookings = bookings.filter(booking => linkedBookingUid && getBookingUid(booking) === linkedBookingUid || productUid && String(booking.product_uid ?? '') === productUid);
|
|
2453
|
+
if (!productUid || !linkedBookings.length) {
|
|
2454
|
+
throw new _ResourcePlanner.AvailabilityError('SELF_EXCLUSION_RESOLVE_FAILED', '编辑商品行缺少可验证的 booking 关联', {
|
|
2455
|
+
productLineUid: requestedUid,
|
|
2456
|
+
linkedBookingUid
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
productUids.add(productUid);
|
|
2460
|
+
linkedBookings.forEach(booking => {
|
|
2461
|
+
const bookingUid = getBookingUid(booking);
|
|
2462
|
+
if (bookingUid) bookingUids.add(bookingUid);
|
|
2463
|
+
if (booking.schedule_event_id != null) {
|
|
2464
|
+
scheduleEventIds.add(String(booking.schedule_event_id));
|
|
2465
|
+
}
|
|
2466
|
+
});
|
|
1080
2467
|
});
|
|
1081
2468
|
return {
|
|
1082
|
-
|
|
1083
|
-
|
|
2469
|
+
productUids,
|
|
2470
|
+
bookingUids,
|
|
2471
|
+
scheduleEventIds
|
|
1084
2472
|
};
|
|
1085
2473
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
2474
|
+
occupancyIsExcluded(occupancy, exclusions) {
|
|
2475
|
+
if (occupancy.scheduleEventId != null) {
|
|
2476
|
+
return exclusions.scheduleEventIds.has(String(occupancy.scheduleEventId));
|
|
2477
|
+
}
|
|
2478
|
+
if (occupancy.bookingUid != null) {
|
|
2479
|
+
return exclusions.bookingUids.has(String(occupancy.bookingUid));
|
|
2480
|
+
}
|
|
2481
|
+
if (occupancy.productUid != null) {
|
|
2482
|
+
return exclusions.productUids.has(String(occupancy.productUid));
|
|
2483
|
+
}
|
|
2484
|
+
return false;
|
|
2485
|
+
}
|
|
2486
|
+
collectCartResourceOccupancies(tempOrder, exclusions) {
|
|
2487
|
+
const bookings = Array.isArray(tempOrder?.bookings) ? tempOrder.bookings : [];
|
|
2488
|
+
return bookings.flatMap(booking => {
|
|
2489
|
+
const bookingUid = getBookingUid(booking);
|
|
2490
|
+
const productUid = String(booking.product_uid ?? booking.metadata?.product_uid ?? '');
|
|
2491
|
+
const scheduleEventId = toNumberId(booking.schedule_event_id);
|
|
2492
|
+
const startAt = booking.start_at || `${booking.start_date || ''} ${booking.start_time || ''}`.trim();
|
|
2493
|
+
const endAt = booking.end_at || `${booking.end_date || booking.start_date || ''} ${booking.end_time || ''}`.trim();
|
|
2494
|
+
const resources = Array.isArray(booking.resources) ? booking.resources : [];
|
|
2495
|
+
return resources.flatMap(resource => {
|
|
2496
|
+
const resourceId = resource.resource_id ?? resource.relation_id ?? resource.id;
|
|
2497
|
+
if (resourceId == null || !startAt || !endAt) return [];
|
|
2498
|
+
const range = normalizeAvailabilityLocalDateTimeRange({
|
|
2499
|
+
startAt,
|
|
2500
|
+
endAt,
|
|
2501
|
+
fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
|
|
2502
|
+
rollOverEnd: false
|
|
2503
|
+
});
|
|
2504
|
+
if (!range) return [];
|
|
2505
|
+
const occupancy = {
|
|
2506
|
+
resourceId,
|
|
2507
|
+
startAt: range.startAt,
|
|
2508
|
+
endAt: range.endAt,
|
|
2509
|
+
pax: Math.max(1, Number(resource.capacity ?? booking.capacity ?? 1) || 1),
|
|
2510
|
+
source: 'cart',
|
|
2511
|
+
scheduleEventId,
|
|
2512
|
+
bookingUid: bookingUid || undefined,
|
|
2513
|
+
productUid: productUid || undefined,
|
|
2514
|
+
raw: {
|
|
2515
|
+
booking,
|
|
2516
|
+
resource
|
|
2517
|
+
}
|
|
2518
|
+
};
|
|
2519
|
+
return this.occupancyIsExcluded(occupancy, exclusions) ? [] : [occupancy];
|
|
2520
|
+
});
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
getCurrentOrderRevision() {
|
|
2524
|
+
return resolveOrderRevision(this.getTempOrder());
|
|
2525
|
+
}
|
|
2526
|
+
getAvailabilityContextEntry(contextId) {
|
|
2527
|
+
const context = this.availabilityContextRegistry.get(contextId);
|
|
2528
|
+
if (!context) {
|
|
2529
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_CONTEXT_NOT_FOUND', 'availability context 不存在或已失效,请重新 prepare', {
|
|
2530
|
+
contextId,
|
|
2531
|
+
lifecycleGeneration: this.availabilityLifecycleGeneration
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
this.touchAvailabilityContext(context);
|
|
2535
|
+
return context;
|
|
2536
|
+
}
|
|
2537
|
+
getAvailabilityProjectionByKey(projectionKey) {
|
|
2538
|
+
if (!projectionKey) return null;
|
|
2539
|
+
return this.availabilityProjectionPool.get(projectionKey)?.projection || null;
|
|
2540
|
+
}
|
|
2541
|
+
getActiveAvailabilityProjectionEntry(context) {
|
|
2542
|
+
if (!context.activeProjectionKey) return null;
|
|
2543
|
+
const entry = this.availabilityProjectionPool.get(context.activeProjectionKey);
|
|
2544
|
+
if (!entry) return null;
|
|
2545
|
+
return this.touchAvailabilityProjection(entry);
|
|
2546
|
+
}
|
|
2547
|
+
decorateAvailabilityCandidate(contextId, contextVersion, candidate) {
|
|
2548
|
+
return {
|
|
2549
|
+
...candidate,
|
|
2550
|
+
availabilityContextId: contextId,
|
|
2551
|
+
availabilityContextVersion: contextVersion
|
|
2552
|
+
};
|
|
2553
|
+
}
|
|
2554
|
+
assertAvailabilityCandidateFreshness(context, candidate) {
|
|
2555
|
+
if (candidate.availabilityContextId !== context.contextId) {
|
|
2556
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '候选与 availability context 不匹配,请重新获取候选', {
|
|
2557
|
+
candidateContextId: candidate.availabilityContextId,
|
|
2558
|
+
contextId: context.contextId
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
const currentContextVersion = context.activeVersion;
|
|
2562
|
+
if (candidate.availabilityContextVersion !== currentContextVersion) {
|
|
2563
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '候选已过期,请重新获取最新时间段', {
|
|
2564
|
+
contextId: context.contextId,
|
|
2565
|
+
candidateContextVersion: candidate.availabilityContextVersion,
|
|
2566
|
+
currentContextVersion,
|
|
2567
|
+
snapshotId: context.lastSnapshotId
|
|
2568
|
+
});
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
assertContextualQueryFreshness(context, request) {
|
|
2572
|
+
if (request.target === 'time') {
|
|
2573
|
+
request.candidates.forEach(candidate => {
|
|
2574
|
+
this.assertAvailabilityCandidateFreshness(context, candidate);
|
|
2575
|
+
});
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
if (request.target === 'resource') {
|
|
2579
|
+
request.selectedRanges.forEach(candidate => {
|
|
2580
|
+
this.assertAvailabilityCandidateFreshness(context, candidate);
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
storeAvailabilityProjectionEntry(params) {
|
|
2585
|
+
const previousContext = this.availabilityContextRegistry.get(params.contextId);
|
|
2586
|
+
const contextVersion = previousContext ? previousContext.activeVersion + (params.incrementVersion ? 1 : 0) : 1;
|
|
2587
|
+
const projectionKey = this.buildAvailabilityProjectionKey(params.contextId, contextVersion);
|
|
2588
|
+
const entry = {
|
|
2589
|
+
contextId: params.contextId,
|
|
2590
|
+
contextVersion,
|
|
2591
|
+
projectionKey,
|
|
2592
|
+
projection: params.projection,
|
|
2593
|
+
orderRevision: params.orderRevision,
|
|
2594
|
+
preparedAt: params.projection.meta.preparedAt,
|
|
2595
|
+
snapshotId: params.projection.meta.snapshotId
|
|
2596
|
+
};
|
|
2597
|
+
if (previousContext?.activeProjectionKey && previousContext.activeProjectionKey !== projectionKey) {
|
|
2598
|
+
this.removeAvailabilityProjection(previousContext.activeProjectionKey);
|
|
2599
|
+
}
|
|
2600
|
+
this.availabilityProjectionPool.set(projectionKey, entry);
|
|
2601
|
+
this.evictAvailabilityProjectionPool();
|
|
2602
|
+
const nextContext = {
|
|
2603
|
+
contextId: params.contextId,
|
|
2604
|
+
prepareParams: this.cloneAvailabilityPrepareParams(params.prepareParams),
|
|
2605
|
+
prepareKey: params.prepareKey,
|
|
2606
|
+
activeVersion: contextVersion,
|
|
2607
|
+
activeProjectionKey: projectionKey,
|
|
2608
|
+
createdAt: previousContext?.createdAt ?? params.projection.meta.preparedAt,
|
|
2609
|
+
lastAccessedAt: new Date().toISOString(),
|
|
2610
|
+
lastKnownOrderRevision: params.orderRevision,
|
|
2611
|
+
lastPreparedAt: params.projection.meta.preparedAt,
|
|
2612
|
+
lastSnapshotId: params.projection.meta.snapshotId,
|
|
2613
|
+
durationCandidateAnchorAt: previousContext?.durationCandidateAnchorAt ?? this.getAvailabilityDurationCandidateAnchorAt(params.projection),
|
|
2614
|
+
refreshNonce: previousContext?.refreshNonce ?? 0,
|
|
2615
|
+
lifecycleGeneration: this.availabilityLifecycleGeneration
|
|
2616
|
+
};
|
|
2617
|
+
this.touchAvailabilityContext(nextContext);
|
|
2618
|
+
this.evictAvailabilityContextRegistry();
|
|
2619
|
+
return this.touchAvailabilityProjection(entry);
|
|
2620
|
+
}
|
|
2621
|
+
async buildAvailabilityProjectionSnapshot(params, options = {}) {
|
|
2622
|
+
const normalizedPrepareParams = this.normalizeAvailabilityPrepareDescriptor(params);
|
|
2623
|
+
const timezoneLabel = String(normalizedPrepareParams.timezone);
|
|
2624
|
+
const dateRange = normalizedPrepareParams.dateRange;
|
|
2625
|
+
const slotStepMinutes = normalizedPrepareParams.slotStepMinutes || DEFAULT_AVAILABILITY_SLOT_STEP_MINUTES;
|
|
2626
|
+
const businessHours = normalizedPrepareParams.businessHours || DEFAULT_AVAILABILITY_BUSINESS_HOURS;
|
|
2627
|
+
const mode = normalizedPrepareParams.mode || 'appointment';
|
|
2628
|
+
const scheduleList = this.getAvailabilityScheduleCatalog();
|
|
2629
|
+
const operatingHours = this.buildAvailabilityOperatingHoursSnapshot({
|
|
2630
|
+
prepare: normalizedPrepareParams,
|
|
2631
|
+
dateRange,
|
|
2632
|
+
scheduleList,
|
|
2633
|
+
businessHours
|
|
2634
|
+
});
|
|
2635
|
+
const products = await this.loadAvailabilityProducts({
|
|
2636
|
+
prepare: normalizedPrepareParams,
|
|
2637
|
+
dateRange,
|
|
2638
|
+
scheduleList
|
|
2639
|
+
});
|
|
2640
|
+
const loadedResources = await this.loadAvailabilityResources({
|
|
2641
|
+
timezone: timezoneLabel,
|
|
2642
|
+
dateRange,
|
|
2643
|
+
resourceIds: normalizedPrepareParams.resourceIds,
|
|
1096
2644
|
products,
|
|
1097
|
-
|
|
1098
|
-
|
|
2645
|
+
rawResources: normalizedPrepareParams.rawResources,
|
|
2646
|
+
scheduleList,
|
|
2647
|
+
fetchPolicy: options.resourceFetchPolicy || 'default',
|
|
2648
|
+
fallbackResources: options.fallbackResources
|
|
2649
|
+
});
|
|
2650
|
+
const tempOrder = this.getTempOrder();
|
|
2651
|
+
const exclusions = this.resolveEditingOccupancyExclusions(tempOrder, normalizedPrepareParams.editingProductLineUids);
|
|
2652
|
+
const injectedRawOccupancies = (normalizedPrepareParams.rawOccupancies || []).flatMap(occupancy => {
|
|
2653
|
+
const range = normalizeAvailabilityLocalDateTimeRange({
|
|
2654
|
+
startAt: occupancy.startAt,
|
|
2655
|
+
endAt: occupancy.endAt,
|
|
2656
|
+
fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
|
|
2657
|
+
rollOverEnd: false
|
|
2658
|
+
});
|
|
2659
|
+
if (!range) return [];
|
|
2660
|
+
return [{
|
|
2661
|
+
...occupancy,
|
|
2662
|
+
startAt: range.startAt,
|
|
2663
|
+
endAt: range.endAt
|
|
2664
|
+
}];
|
|
2665
|
+
});
|
|
2666
|
+
const remoteOccupancies = [...loadedResources.resourceOccupancies, ...(loadedResources.includesInjectedRawOccupancies ? [] : injectedRawOccupancies)].filter(occupancy => !this.occupancyIsExcluded(occupancy, exclusions));
|
|
2667
|
+
const cartOccupancies = this.collectCartResourceOccupancies(tempOrder, exclusions);
|
|
2668
|
+
const preparedAt = this.getAvailabilityRuntimeDateTime();
|
|
2669
|
+
const durationCandidateAnchorAt = options.durationCandidateAnchorAt || preparedAt;
|
|
2670
|
+
this.availabilityProjectionSequence += 1;
|
|
2671
|
+
const orderRevision = this.getCurrentOrderRevision();
|
|
2672
|
+
const projection = (0, _ResourcePlanner.createAvailabilityProjection)({
|
|
2673
|
+
products,
|
|
2674
|
+
resources: loadedResources.resources,
|
|
2675
|
+
resourceOccupancies: [...remoteOccupancies, ...cartOccupancies],
|
|
2676
|
+
mode,
|
|
2677
|
+
timezone: timezoneLabel,
|
|
2678
|
+
coverage: dateRange,
|
|
2679
|
+
slotStepMinutes,
|
|
2680
|
+
businessHours,
|
|
2681
|
+
operatingHours,
|
|
2682
|
+
preparedAt,
|
|
2683
|
+
now: preparedAt,
|
|
2684
|
+
orderRevision,
|
|
2685
|
+
snapshotId: `${this.name}:${Date.now()}:${this.availabilityProjectionSequence}`,
|
|
2686
|
+
anchors: {
|
|
2687
|
+
productIds: normalizedPrepareParams.productIds,
|
|
2688
|
+
resourceIds: normalizedPrepareParams.resourceIds,
|
|
2689
|
+
dateRange
|
|
2690
|
+
},
|
|
2691
|
+
prepareParams: normalizedPrepareParams,
|
|
2692
|
+
durationCandidateAnchorAt
|
|
2693
|
+
});
|
|
2694
|
+
return {
|
|
2695
|
+
projection,
|
|
2696
|
+
prepareParams: normalizedPrepareParams,
|
|
2697
|
+
orderRevision
|
|
1099
2698
|
};
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
2699
|
+
}
|
|
2700
|
+
async getSharedPreparedAvailabilitySnapshot(params) {
|
|
2701
|
+
const lifecycleGeneration = this.availabilityLifecycleGeneration;
|
|
2702
|
+
const normalizedParams = this.normalizeAvailabilityPrepareDescriptor(params);
|
|
2703
|
+
const hasRawInputs = params.rawProducts !== undefined || params.rawResources !== undefined || params.rawOccupancies !== undefined;
|
|
2704
|
+
const prepareKey = hasRawInputs ? JSON.stringify(stableValue({
|
|
2705
|
+
scope: this.getAvailabilityScopeDescriptor(),
|
|
2706
|
+
prepare: {
|
|
2707
|
+
...normalizedParams,
|
|
2708
|
+
rawProducts: undefined,
|
|
2709
|
+
rawResources: undefined,
|
|
2710
|
+
rawOccupancies: undefined
|
|
2711
|
+
},
|
|
2712
|
+
rawInputs: {
|
|
2713
|
+
products: params.rawProducts !== undefined,
|
|
2714
|
+
resources: params.rawResources !== undefined,
|
|
2715
|
+
occupancies: params.rawOccupancies !== undefined
|
|
2716
|
+
}
|
|
2717
|
+
})) : this.buildAvailabilityPrepareKey(normalizedParams);
|
|
2718
|
+
if (hasRawInputs) {
|
|
2719
|
+
const snapshot = await this.buildAvailabilityProjectionSnapshot(normalizedParams);
|
|
2720
|
+
if (lifecycleGeneration !== this.availabilityLifecycleGeneration) {
|
|
2721
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_CONTEXT_NOT_FOUND', 'prepare 期间业务作用域或实例生命周期已变化,请重新 prepare', {
|
|
2722
|
+
prepareKey,
|
|
2723
|
+
expectedLifecycleGeneration: lifecycleGeneration,
|
|
2724
|
+
currentLifecycleGeneration: this.availabilityLifecycleGeneration
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
return {
|
|
2728
|
+
prepareKey,
|
|
2729
|
+
snapshot
|
|
2730
|
+
};
|
|
2731
|
+
}
|
|
2732
|
+
const existing = this.availabilityPrepareInFlight.get(prepareKey);
|
|
2733
|
+
const promise = existing?.lifecycleGeneration === lifecycleGeneration ? existing.promise : this.buildAvailabilityProjectionSnapshot(normalizedParams);
|
|
2734
|
+
if (!existing || existing.lifecycleGeneration !== lifecycleGeneration) {
|
|
2735
|
+
this.availabilityPrepareInFlight.set(prepareKey, {
|
|
2736
|
+
lifecycleGeneration,
|
|
2737
|
+
prepareKey,
|
|
2738
|
+
promise
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2741
|
+
try {
|
|
2742
|
+
const snapshot = await promise;
|
|
2743
|
+
if (lifecycleGeneration !== this.availabilityLifecycleGeneration) {
|
|
2744
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_CONTEXT_NOT_FOUND', 'prepare 期间业务作用域或实例生命周期已变化,请重新 prepare', {
|
|
2745
|
+
prepareKey,
|
|
2746
|
+
expectedLifecycleGeneration: lifecycleGeneration,
|
|
2747
|
+
currentLifecycleGeneration: this.availabilityLifecycleGeneration
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
return {
|
|
2751
|
+
prepareKey,
|
|
2752
|
+
snapshot
|
|
2753
|
+
};
|
|
2754
|
+
} finally {
|
|
2755
|
+
const current = this.availabilityPrepareInFlight.get(prepareKey);
|
|
2756
|
+
if (current?.promise === promise) {
|
|
2757
|
+
this.availabilityPrepareInFlight.delete(prepareKey);
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
async refreshAvailabilityContextProjection(params) {
|
|
2762
|
+
const existingInFlight = this.availabilityProjectionRefreshInFlight.get(params.contextId);
|
|
2763
|
+
if (existingInFlight) return await existingInFlight;
|
|
2764
|
+
const context = this.getAvailabilityContextEntry(params.contextId);
|
|
2765
|
+
const expectedVersion = context.activeVersion;
|
|
2766
|
+
const expectedRefreshNonce = context.refreshNonce;
|
|
2767
|
+
const expectedLifecycleGeneration = this.availabilityLifecycleGeneration;
|
|
2768
|
+
const refreshPromise = (async () => {
|
|
2769
|
+
const snapshot = await this.buildAvailabilityProjectionSnapshot(this.cloneAvailabilityPrepareParams(context.prepareParams), {
|
|
2770
|
+
durationCandidateAnchorAt: context.durationCandidateAnchorAt
|
|
2771
|
+
});
|
|
2772
|
+
const currentContext = this.availabilityContextRegistry.get(params.contextId);
|
|
2773
|
+
if (!currentContext) return null;
|
|
2774
|
+
if (expectedLifecycleGeneration !== this.availabilityLifecycleGeneration || currentContext.activeVersion !== expectedVersion || currentContext.refreshNonce !== expectedRefreshNonce || currentContext.lifecycleGeneration !== expectedLifecycleGeneration) {
|
|
2775
|
+
return null;
|
|
2776
|
+
}
|
|
2777
|
+
const projectionEntry = this.storeAvailabilityProjectionEntry({
|
|
2778
|
+
contextId: currentContext.contextId,
|
|
2779
|
+
prepareParams: snapshot.prepareParams,
|
|
2780
|
+
prepareKey: currentContext.prepareKey,
|
|
2781
|
+
projection: snapshot.projection,
|
|
2782
|
+
orderRevision: snapshot.orderRevision,
|
|
2783
|
+
incrementVersion: true
|
|
2784
|
+
});
|
|
2785
|
+
await this.emitAvailabilityDiagnostic({
|
|
2786
|
+
action: 'refresh',
|
|
2787
|
+
contextId: currentContext.contextId,
|
|
2788
|
+
contextVersion: projectionEntry.contextVersion,
|
|
2789
|
+
projection: projectionEntry.projection,
|
|
2790
|
+
snapshotId: projectionEntry.snapshotId,
|
|
2791
|
+
orderRevision: projectionEntry.orderRevision,
|
|
2792
|
+
previousOrderRevision: params.previousOrderRevision,
|
|
2793
|
+
reason: params.reason
|
|
2794
|
+
});
|
|
2795
|
+
return projectionEntry;
|
|
2796
|
+
})();
|
|
2797
|
+
this.availabilityProjectionRefreshInFlight.set(params.contextId, refreshPromise);
|
|
2798
|
+
try {
|
|
2799
|
+
return await refreshPromise;
|
|
2800
|
+
} finally {
|
|
2801
|
+
const current = this.availabilityProjectionRefreshInFlight.get(params.contextId);
|
|
2802
|
+
if (current === refreshPromise) {
|
|
2803
|
+
this.availabilityProjectionRefreshInFlight.delete(params.contextId);
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
async ensureAvailabilityContextProjection(contextId, reason) {
|
|
2808
|
+
let retries = 0;
|
|
2809
|
+
while (retries < 3) {
|
|
2810
|
+
const context = this.getAvailabilityContextEntry(contextId);
|
|
2811
|
+
const currentOrderRevision = this.getCurrentOrderRevision();
|
|
2812
|
+
const projectionEntry = this.getActiveAvailabilityProjectionEntry(context);
|
|
2813
|
+
if (projectionEntry && projectionEntry.orderRevision === currentOrderRevision) {
|
|
2814
|
+
return {
|
|
2815
|
+
context,
|
|
2816
|
+
projectionEntry,
|
|
2817
|
+
refreshed: false
|
|
2818
|
+
};
|
|
2819
|
+
}
|
|
2820
|
+
const refreshedProjectionEntry = await this.refreshAvailabilityContextProjection({
|
|
2821
|
+
contextId,
|
|
2822
|
+
reason: projectionEntry ? reason : `${reason}:projection_cache_miss`,
|
|
2823
|
+
previousOrderRevision: context.lastKnownOrderRevision
|
|
2824
|
+
});
|
|
2825
|
+
if (!refreshedProjectionEntry) {
|
|
2826
|
+
retries += 1;
|
|
2827
|
+
continue;
|
|
2828
|
+
}
|
|
2829
|
+
if (refreshedProjectionEntry.orderRevision !== this.getCurrentOrderRevision()) {
|
|
2830
|
+
retries += 1;
|
|
2831
|
+
continue;
|
|
2832
|
+
}
|
|
2833
|
+
return {
|
|
2834
|
+
context: this.getAvailabilityContextEntry(contextId),
|
|
2835
|
+
projectionEntry: refreshedProjectionEntry,
|
|
2836
|
+
refreshed: true
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
throw new _ResourcePlanner.AvailabilityError('STALE_PROJECTION', 'availability projection 刷新期间购物车持续变化,请重新 prepare', {
|
|
2840
|
+
contextId,
|
|
2841
|
+
reason
|
|
1108
2842
|
});
|
|
1109
|
-
await this.core.effects.emit(`${this.name}:${_types.UnifiedBookingSalesHooks.onPlannerContextLoaded}`, snapshot);
|
|
1110
|
-
return this.getPlanner().getSnapshot();
|
|
1111
2843
|
}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
2844
|
+
async tryAcquireAvailabilityCommitLock(contextId) {
|
|
2845
|
+
if (this.availabilityCommitLocks.has(contextId)) {
|
|
2846
|
+
const waitingContext = this.availabilityContextRegistry.get(contextId);
|
|
2847
|
+
await this.emitAvailabilityDiagnostic({
|
|
2848
|
+
action: 'commit_lock_wait',
|
|
2849
|
+
contextId,
|
|
2850
|
+
contextVersion: waitingContext?.activeVersion ?? null,
|
|
2851
|
+
projection: this.getAvailabilityProjectionByKey(waitingContext?.activeProjectionKey),
|
|
2852
|
+
snapshotId: waitingContext?.lastSnapshotId,
|
|
2853
|
+
orderRevision: waitingContext?.lastKnownOrderRevision,
|
|
2854
|
+
reason: 'AVAILABILITY_COMMIT_IN_PROGRESS'
|
|
2855
|
+
});
|
|
2856
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_COMMIT_IN_PROGRESS', '同一 availability context 已有 commit 正在进行中', {
|
|
2857
|
+
contextId
|
|
2858
|
+
});
|
|
2859
|
+
}
|
|
2860
|
+
this.availabilityCommitLocks.add(contextId);
|
|
2861
|
+
const currentContext = this.availabilityContextRegistry.get(contextId);
|
|
2862
|
+
await this.emitAvailabilityDiagnostic({
|
|
2863
|
+
action: 'commit_lock_acquired',
|
|
2864
|
+
contextId,
|
|
2865
|
+
contextVersion: currentContext?.activeVersion ?? null,
|
|
2866
|
+
projection: this.getAvailabilityProjectionByKey(currentContext?.activeProjectionKey),
|
|
2867
|
+
snapshotId: currentContext?.lastSnapshotId,
|
|
2868
|
+
orderRevision: currentContext?.lastKnownOrderRevision,
|
|
2869
|
+
reason: 'commit_start'
|
|
2870
|
+
});
|
|
2871
|
+
}
|
|
2872
|
+
async releaseAvailabilityCommitLock(contextId) {
|
|
2873
|
+
this.availabilityCommitLocks.delete(contextId);
|
|
2874
|
+
const releasedContext = this.availabilityContextRegistry.get(contextId);
|
|
2875
|
+
await this.emitAvailabilityDiagnostic({
|
|
2876
|
+
action: 'commit_lock_released',
|
|
2877
|
+
contextId,
|
|
2878
|
+
contextVersion: releasedContext?.activeVersion ?? null,
|
|
2879
|
+
projection: this.getAvailabilityProjectionByKey(releasedContext?.activeProjectionKey),
|
|
2880
|
+
snapshotId: releasedContext?.lastSnapshotId,
|
|
2881
|
+
orderRevision: releasedContext?.lastKnownOrderRevision,
|
|
2882
|
+
reason: 'commit_end'
|
|
2883
|
+
});
|
|
1116
2884
|
}
|
|
1117
2885
|
|
|
1118
|
-
/**
|
|
1119
|
-
|
|
1120
|
-
|
|
2886
|
+
/** IO boundary: loads catalog, schedules, resources, occupancies, and registers one context. */
|
|
2887
|
+
async prepareAvailabilityProjection(params) {
|
|
2888
|
+
this.assertAvailabilityPrepareParams(params);
|
|
2889
|
+
const paramsWithDefaults = await this.resolveAvailabilityPrepareDefaults(params);
|
|
2890
|
+
const {
|
|
2891
|
+
prepareKey,
|
|
2892
|
+
snapshot
|
|
2893
|
+
} = await this.getSharedPreparedAvailabilitySnapshot(paramsWithDefaults);
|
|
2894
|
+
const contextId = this.createAvailabilityContextId();
|
|
2895
|
+
const projectionEntry = this.storeAvailabilityProjectionEntry({
|
|
2896
|
+
contextId,
|
|
2897
|
+
prepareParams: snapshot.prepareParams,
|
|
2898
|
+
prepareKey,
|
|
2899
|
+
projection: snapshot.projection,
|
|
2900
|
+
orderRevision: snapshot.orderRevision,
|
|
2901
|
+
incrementVersion: false
|
|
2902
|
+
});
|
|
2903
|
+
const context = this.getAvailabilityContextEntry(contextId);
|
|
2904
|
+
const handle = this.createAvailabilityContextRef(context);
|
|
2905
|
+
const payload = {
|
|
2906
|
+
context: handle,
|
|
2907
|
+
contextVersion: context.activeVersion,
|
|
2908
|
+
projection: projectionEntry.projection,
|
|
2909
|
+
refreshed: false,
|
|
2910
|
+
reason: 'prepare'
|
|
2911
|
+
};
|
|
2912
|
+
await this.emitAvailabilityDiagnostic({
|
|
2913
|
+
action: 'prepare',
|
|
2914
|
+
contextId,
|
|
2915
|
+
contextVersion: context.activeVersion,
|
|
2916
|
+
projection: projectionEntry.projection,
|
|
2917
|
+
snapshotId: context.lastSnapshotId,
|
|
2918
|
+
orderRevision: context.lastKnownOrderRevision,
|
|
2919
|
+
reason: 'prepare'
|
|
2920
|
+
});
|
|
2921
|
+
await this.core.effects.emit(`${this.name}:${_types.UnifiedBookingSalesHooks.onAvailabilityProjectionPrepared}`, payload);
|
|
2922
|
+
return handle;
|
|
1121
2923
|
}
|
|
1122
2924
|
|
|
1123
|
-
/**
|
|
1124
|
-
|
|
1125
|
-
|
|
2925
|
+
/** Candidate calculation over the current context projection and runtime minute. */
|
|
2926
|
+
async getProductTimeRanges(contextId, request = {}) {
|
|
2927
|
+
const {
|
|
2928
|
+
context,
|
|
2929
|
+
projectionEntry
|
|
2930
|
+
} = await this.ensureAvailabilityContextProjection(contextId, 'get_product_time_ranges');
|
|
2931
|
+
const contextVersion = context.activeVersion;
|
|
2932
|
+
const allowOutsideOperatingHoursForRequestedStartTimes = String(this.otherParams?.platform ?? '').trim().toLowerCase() === 'pos' && Object.values(request.durationStartTimesByProductId || {}).some(startTimes => Array.isArray(startTimes) && startTimes.length > 0);
|
|
2933
|
+
return (0, _ResourcePlanner.getProductTimeRanges)(projectionEntry.projection, {
|
|
2934
|
+
...request,
|
|
2935
|
+
...(allowOutsideOperatingHoursForRequestedStartTimes ? {
|
|
2936
|
+
allowOutsideOperatingHoursForRequestedStartTimes: true
|
|
2937
|
+
} : {})
|
|
2938
|
+
}, {
|
|
2939
|
+
evaluatedAt: this.getAvailabilityRuntimeDateTime()
|
|
2940
|
+
}).map(group => ({
|
|
2941
|
+
...group,
|
|
2942
|
+
ranges: group.ranges.map(candidate => this.decorateAvailabilityCandidate(context.contextId, contextVersion, candidate))
|
|
2943
|
+
}));
|
|
1126
2944
|
}
|
|
1127
2945
|
|
|
1128
|
-
/**
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
2946
|
+
/** Thin facade: auto-refresh on cart drift, then delegate to the pure projection query. */
|
|
2947
|
+
async queryBookingAvailability(contextId, request) {
|
|
2948
|
+
const {
|
|
2949
|
+
context,
|
|
2950
|
+
projectionEntry,
|
|
2951
|
+
refreshed
|
|
2952
|
+
} = await this.ensureAvailabilityContextProjection(contextId, 'query_booking_availability');
|
|
2953
|
+
this.assertContextualQueryFreshness(context, request);
|
|
2954
|
+
const result = (0, _ResourcePlanner.queryAvailabilityProjection)(projectionEntry.projection, request);
|
|
2955
|
+
const contextVersion = context.activeVersion;
|
|
2956
|
+
const contextualResult = result.target === 'time' ? {
|
|
2957
|
+
...result,
|
|
2958
|
+
data: result.data.map(group => ({
|
|
2959
|
+
...group,
|
|
2960
|
+
times: group.times.map(item => ({
|
|
2961
|
+
...item,
|
|
2962
|
+
availabilityContextId: context.contextId,
|
|
2963
|
+
availabilityContextVersion: contextVersion
|
|
2964
|
+
}))
|
|
2965
|
+
}))
|
|
2966
|
+
} : result.target === 'resource' ? {
|
|
2967
|
+
...result,
|
|
2968
|
+
data: result.data.map(group => ({
|
|
2969
|
+
...group,
|
|
2970
|
+
ranges: group.ranges.map(rangeGroup => ({
|
|
2971
|
+
...rangeGroup,
|
|
2972
|
+
range: {
|
|
2973
|
+
...rangeGroup.range,
|
|
2974
|
+
availabilityContextId: context.contextId,
|
|
2975
|
+
availabilityContextVersion: contextVersion
|
|
2976
|
+
}
|
|
2977
|
+
}))
|
|
2978
|
+
}))
|
|
2979
|
+
} : result;
|
|
2980
|
+
return {
|
|
2981
|
+
...contextualResult,
|
|
2982
|
+
contextId: context.contextId,
|
|
2983
|
+
contextVersion,
|
|
2984
|
+
refreshed
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
releaseAvailabilityContext(contextId) {
|
|
2988
|
+
const context = this.availabilityContextRegistry.get(contextId);
|
|
2989
|
+
if (!context) return;
|
|
2990
|
+
this.removeAvailabilityContext(contextId);
|
|
2991
|
+
void this.emitAvailabilityDiagnostic({
|
|
2992
|
+
action: 'release',
|
|
2993
|
+
contextId,
|
|
2994
|
+
contextVersion: context.activeVersion,
|
|
2995
|
+
projection: this.getActiveAvailabilityProjectionEntry(context)?.projection || null,
|
|
2996
|
+
snapshotId: context.lastSnapshotId,
|
|
2997
|
+
orderRevision: context.lastKnownOrderRevision,
|
|
2998
|
+
reason: 'explicit_release'
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
assertAvailabilityCommitWriteCAS(params) {
|
|
3002
|
+
const context = this.availabilityContextRegistry.get(params.contextId);
|
|
3003
|
+
if (!context) {
|
|
3004
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_CONTEXT_NOT_FOUND', 'availability context 不存在或已失效,请重新 prepare', {
|
|
3005
|
+
contextId: params.contextId
|
|
3006
|
+
});
|
|
1136
3007
|
}
|
|
1137
|
-
|
|
3008
|
+
if (this.availabilityLifecycleGeneration !== params.expectedLifecycleGeneration || context.lifecycleGeneration !== params.expectedLifecycleGeneration || context.activeVersion !== params.expectedVersion || context.refreshNonce !== params.expectedRefreshNonce || this.getCurrentOrderRevision() !== params.expectedOrderRevision) {
|
|
3009
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'availability context 已变化,当前选择已失效,请重新获取候选', {
|
|
3010
|
+
contextId: params.contextId,
|
|
3011
|
+
expectedVersion: params.expectedVersion,
|
|
3012
|
+
currentVersion: context.activeVersion,
|
|
3013
|
+
expectedRefreshNonce: params.expectedRefreshNonce,
|
|
3014
|
+
currentRefreshNonce: context.refreshNonce,
|
|
3015
|
+
expectedLifecycleGeneration: params.expectedLifecycleGeneration,
|
|
3016
|
+
currentLifecycleGeneration: this.availabilityLifecycleGeneration,
|
|
3017
|
+
expectedOrderRevision: params.expectedOrderRevision,
|
|
3018
|
+
currentOrderRevision: this.getCurrentOrderRevision()
|
|
3019
|
+
});
|
|
3020
|
+
}
|
|
3021
|
+
return context;
|
|
1138
3022
|
}
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
validatePlannerSelection() {
|
|
1142
|
-
return this.getPlanner().validateSelection();
|
|
3023
|
+
findProductLineByUid(productLineUid) {
|
|
3024
|
+
return (this.getTempOrder()?.products || []).find(product => getProductLineUid(product) === String(productLineUid));
|
|
1143
3025
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
return this.
|
|
3026
|
+
findLinkedBooking(product) {
|
|
3027
|
+
const productUid = getProductLineUid(product);
|
|
3028
|
+
const bookingUid = String(product.booking_uid ?? product.metadata?.booking_uid ?? '');
|
|
3029
|
+
return (this.getTempOrder()?.bookings || []).find(booking => bookingUid && getBookingUid(booking) === bookingUid || productUid && String(booking.product_uid ?? '') === productUid);
|
|
1148
3030
|
}
|
|
1149
3031
|
|
|
1150
|
-
/**
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
this.setPlannerSelection(params.selection);
|
|
1157
|
-
}
|
|
1158
|
-
if (params.assignments) {
|
|
1159
|
-
this.setPlannerSelection({
|
|
1160
|
-
assignments: params.assignments
|
|
3032
|
+
/** Revalidates against the latest landed occupancy cache, then mutates exactly one product line. */
|
|
3033
|
+
async commitAvailabilitySelection(contextId, params) {
|
|
3034
|
+
const normalizedContextId = String(contextId || '').trim();
|
|
3035
|
+
if (!normalizedContextId) {
|
|
3036
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'commitAvailabilitySelection 缺少 contextId', {
|
|
3037
|
+
candidate: params.candidate
|
|
1161
3038
|
});
|
|
1162
3039
|
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
3040
|
+
await this.tryAcquireAvailabilityCommitLock(normalizedContextId);
|
|
3041
|
+
try {
|
|
3042
|
+
const bookingCount = Number(params.bookingCount ?? 1);
|
|
3043
|
+
if (!Number.isInteger(bookingCount) || bookingCount <= 0) {
|
|
3044
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_RESERVATION_QUANTITY', 'bookingCount 必须是正整数', {
|
|
3045
|
+
bookingCount: params.bookingCount
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
3048
|
+
if (params.mode === 'edit' && bookingCount !== 1) {
|
|
3049
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_RESERVATION_QUANTITY', '编辑预约商品时 bookingCount 必须为 1', {
|
|
3050
|
+
bookingCount
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
const orderProductQuantity = params.orderProduct?.num ?? params.orderProduct?.quantity ?? params.orderProduct?._extend?.quantity;
|
|
3054
|
+
if (orderProductQuantity !== undefined && orderProductQuantity !== null && Number(orderProductQuantity) !== bookingCount) {
|
|
3055
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_RESERVATION_QUANTITY', 'orderProduct 数量与 bookingCount 不一致', {
|
|
3056
|
+
bookingCount,
|
|
3057
|
+
orderProductQuantity
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3060
|
+
const partySize = Number(params.partySize);
|
|
3061
|
+
if (!Number.isInteger(partySize) || partySize <= 0) {
|
|
3062
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_RESERVATION_QUANTITY', 'partySize 必须是正整数', {
|
|
3063
|
+
partySize: params.partySize
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
3066
|
+
const {
|
|
3067
|
+
context,
|
|
3068
|
+
projectionEntry,
|
|
3069
|
+
refreshed
|
|
3070
|
+
} = await this.ensureAvailabilityContextProjection(normalizedContextId, 'commit_precheck');
|
|
3071
|
+
if (refreshed) {
|
|
3072
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'availability context 已刷新,请重新获取候选', {
|
|
3073
|
+
contextId: normalizedContextId,
|
|
3074
|
+
currentContextVersion: context.activeVersion,
|
|
3075
|
+
candidateContextVersion: params.candidate.availabilityContextVersion
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
const boundEditingUids = context.prepareParams.editingProductLineUids || [];
|
|
3079
|
+
if (params.mode === 'edit') {
|
|
3080
|
+
if (!params.productLineUid || !boundEditingUids.includes(String(params.productLineUid))) {
|
|
3081
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'edit context 与当前商品行不匹配,请重新 prepare 编辑上下文', {
|
|
3082
|
+
contextId: normalizedContextId,
|
|
3083
|
+
productLineUid: params.productLineUid,
|
|
3084
|
+
editingProductLineUids: boundEditingUids
|
|
3085
|
+
});
|
|
1175
3086
|
}
|
|
3087
|
+
} else if (boundEditingUids.length > 0) {
|
|
3088
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'edit context 不能用于 add,请重新 prepare 新增上下文', {
|
|
3089
|
+
contextId: normalizedContextId,
|
|
3090
|
+
editingProductLineUids: boundEditingUids
|
|
3091
|
+
});
|
|
3092
|
+
}
|
|
3093
|
+
if (projectionEntry.contextVersion !== context.activeVersion) {
|
|
3094
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'availability context 已刷新,请重新获取候选', {
|
|
3095
|
+
contextId: normalizedContextId,
|
|
3096
|
+
currentContextVersion: context.activeVersion,
|
|
3097
|
+
projectionContextVersion: projectionEntry.contextVersion
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
3100
|
+
if (projectionEntry.orderRevision !== this.getCurrentOrderRevision()) {
|
|
3101
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '购物车已变化,请重新获取候选', {
|
|
3102
|
+
contextId: normalizedContextId,
|
|
3103
|
+
currentContextVersion: context.activeVersion,
|
|
3104
|
+
projectionOrderRevision: projectionEntry.orderRevision,
|
|
3105
|
+
currentOrderRevision: this.getCurrentOrderRevision()
|
|
3106
|
+
});
|
|
3107
|
+
}
|
|
3108
|
+
if (projectionEntry.contextVersion !== params.candidate.availabilityContextVersion) {
|
|
3109
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '候选已过期,请重新获取最新时间段', {
|
|
3110
|
+
contextId: normalizedContextId,
|
|
3111
|
+
candidateContextVersion: params.candidate.availabilityContextVersion,
|
|
3112
|
+
currentContextVersion: context.activeVersion
|
|
3113
|
+
});
|
|
3114
|
+
}
|
|
3115
|
+
this.assertAvailabilityCandidateFreshness(context, params.candidate);
|
|
3116
|
+
const orderProductId = params.orderProduct?.product_id ?? params.orderProduct?.id;
|
|
3117
|
+
if (!sameAvailabilityId(orderProductId, params.candidate.productId)) {
|
|
3118
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'orderProduct 与 candidate 商品不一致', {
|
|
3119
|
+
orderProductId,
|
|
3120
|
+
candidateProductId: params.candidate.productId
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
if (params.mode === 'edit' && !params.productLineUid) {
|
|
3124
|
+
throw new _ResourcePlanner.AvailabilityError('PRODUCT_LINE_NOT_FOUND', 'edit 模式必须提供 productLineUid');
|
|
3125
|
+
}
|
|
3126
|
+
const editingLine = params.mode === 'edit' ? this.findProductLineByUid(String(params.productLineUid)) : undefined;
|
|
3127
|
+
if (params.mode === 'edit' && !editingLine) {
|
|
3128
|
+
throw new _ResourcePlanner.AvailabilityError('PRODUCT_LINE_NOT_FOUND', '找不到要编辑的预约商品行', {
|
|
3129
|
+
productLineUid: params.productLineUid
|
|
3130
|
+
});
|
|
3131
|
+
}
|
|
3132
|
+
if (editingLine && !sameAvailabilityId(editingLine.product_id ?? editingLine.id, params.candidate.productId)) {
|
|
3133
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', '编辑商品行与 candidate 商品不一致', {
|
|
3134
|
+
productLineUid: params.productLineUid,
|
|
3135
|
+
productLineProductId: editingLine.product_id ?? editingLine.id,
|
|
3136
|
+
candidateProductId: params.candidate.productId
|
|
3137
|
+
});
|
|
3138
|
+
}
|
|
3139
|
+
if (!Array.isArray(params.requirementSelections)) {
|
|
3140
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'requirementSelections 必须是数组', {
|
|
3141
|
+
requirementSelections: params.requirementSelections
|
|
3142
|
+
});
|
|
3143
|
+
}
|
|
3144
|
+
const baseProjection = projectionEntry.projection;
|
|
3145
|
+
let commitWriteGuard = {
|
|
3146
|
+
contextId: normalizedContextId,
|
|
3147
|
+
expectedVersion: context.activeVersion,
|
|
3148
|
+
expectedRefreshNonce: context.refreshNonce,
|
|
3149
|
+
expectedLifecycleGeneration: this.availabilityLifecycleGeneration,
|
|
3150
|
+
expectedOrderRevision: String(baseProjection.meta.orderRevision ?? '')
|
|
1176
3151
|
};
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
3152
|
+
const prepareParams = baseProjection.meta.prepareParams;
|
|
3153
|
+
if (!prepareParams) {
|
|
3154
|
+
throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', 'projection 缺少 prepareParams,无法执行缓存重校验');
|
|
3155
|
+
}
|
|
3156
|
+
const fallbackResources = {
|
|
3157
|
+
resources: (0, _lodashEs.cloneDeep)([...(baseProjection.resources || [])]),
|
|
3158
|
+
resourceOccupancies: (0, _lodashEs.cloneDeep)([...(baseProjection.resourceOccupancies || [])].filter(occupancy => occupancy.source !== 'cart')),
|
|
3159
|
+
includesInjectedRawOccupancies: true
|
|
1184
3160
|
};
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
3161
|
+
const commitSnapshot = await this.buildAvailabilityProjectionSnapshot({
|
|
3162
|
+
...this.cloneAvailabilityPrepareParams(prepareParams),
|
|
3163
|
+
editingProductLineUids: params.mode === 'edit' && params.productLineUid ? [params.productLineUid] : []
|
|
3164
|
+
}, {
|
|
3165
|
+
resourceFetchPolicy: 'cache_only',
|
|
3166
|
+
fallbackResources,
|
|
3167
|
+
durationCandidateAnchorAt: this.getAvailabilityDurationCandidateAnchorAt(baseProjection) || context.durationCandidateAnchorAt
|
|
3168
|
+
});
|
|
3169
|
+
const refreshedProjection = commitSnapshot.projection;
|
|
3170
|
+
if (String(refreshedProjection.meta.orderRevision ?? '') !== String(baseProjection.meta.orderRevision ?? '')) {
|
|
3171
|
+
await this.refreshAvailabilityContextProjection({
|
|
3172
|
+
contextId: normalizedContextId,
|
|
3173
|
+
reason: 'commit_order_revision_changed',
|
|
3174
|
+
previousOrderRevision: String(baseProjection.meta.orderRevision ?? '')
|
|
3175
|
+
});
|
|
3176
|
+
const latestContext = this.getAvailabilityContextEntry(normalizedContextId);
|
|
3177
|
+
throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '最终缓存重校验期间购物车已变化,请重新获取候选', {
|
|
3178
|
+
contextId: normalizedContextId,
|
|
3179
|
+
candidateContextVersion: params.candidate.availabilityContextVersion,
|
|
3180
|
+
currentContextVersion: latestContext.activeVersion,
|
|
3181
|
+
projectionOrderRevision: baseProjection.meta.orderRevision,
|
|
3182
|
+
refreshedOrderRevision: refreshedProjection.meta.orderRevision,
|
|
3183
|
+
snapshotId: baseProjection.meta.snapshotId,
|
|
3184
|
+
refreshedSnapshotId: refreshedProjection.meta.snapshotId
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
this.assertAvailabilityCommitWriteCAS(commitWriteGuard);
|
|
3188
|
+
const candidateDurationMinutes = getCandidateDurationMinutes(params.candidate);
|
|
3189
|
+
const candidateStartTime = getCandidateStartTime(params.candidate);
|
|
3190
|
+
const allowPosAvailabilityOverride = String(this.otherParams?.platform ?? '').trim().toLowerCase() === 'pos';
|
|
3191
|
+
const refreshedRanges = (0, _ResourcePlanner.getProductTimeRanges)(refreshedProjection, {
|
|
3192
|
+
productIds: [params.candidate.productId],
|
|
3193
|
+
dateRange: {
|
|
3194
|
+
startDate: params.candidate.startAt.slice(0, 10),
|
|
3195
|
+
endDate: params.candidate.startAt.slice(0, 10)
|
|
3196
|
+
},
|
|
3197
|
+
...(candidateDurationMinutes ? {
|
|
3198
|
+
durationMinutesByProductId: {
|
|
3199
|
+
[String(params.candidate.productId)]: candidateDurationMinutes
|
|
3200
|
+
},
|
|
3201
|
+
...(candidateStartTime ? {
|
|
3202
|
+
durationStartTimesByProductId: {
|
|
3203
|
+
[String(params.candidate.productId)]: [candidateStartTime]
|
|
3204
|
+
},
|
|
3205
|
+
...(allowPosAvailabilityOverride ? {
|
|
3206
|
+
allowOutsideOperatingHoursForRequestedStartTimes: true
|
|
3207
|
+
} : {})
|
|
3208
|
+
} : {})
|
|
3209
|
+
} : {})
|
|
3210
|
+
}).flatMap(group => group.ranges);
|
|
3211
|
+
const refreshedCandidateBase = refreshedRanges.find(candidate => candidate.startAt === params.candidate.startAt && candidate.endAt === params.candidate.endAt && candidate.bookingStartAt === params.candidate.bookingStartAt && candidate.bookingEndAt === params.candidate.bookingEndAt);
|
|
3212
|
+
if (!refreshedCandidateBase) {
|
|
3213
|
+
throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', '缓存重校验后候选时间已不在商品配置中', {
|
|
3214
|
+
contextId: normalizedContextId,
|
|
3215
|
+
candidate: params.candidate,
|
|
3216
|
+
refreshedSnapshotId: refreshedProjection.meta.snapshotId
|
|
3217
|
+
});
|
|
3218
|
+
}
|
|
3219
|
+
const product = refreshedProjection.products.find(item => sameAvailabilityId(item.id, refreshedCandidateBase.productId));
|
|
3220
|
+
if (!product) {
|
|
3221
|
+
throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', '缓存重校验后商品不在 projection 中', {
|
|
3222
|
+
productId: refreshedCandidateBase.productId
|
|
3223
|
+
});
|
|
3224
|
+
}
|
|
3225
|
+
let validation = (0, _ResourcePlanner.validateAvailabilitySelection)(refreshedProjection, {
|
|
3226
|
+
candidate: refreshedCandidateBase,
|
|
3227
|
+
requirementSelections: params.requirementSelections,
|
|
3228
|
+
partySize,
|
|
3229
|
+
bookingCount
|
|
3230
|
+
});
|
|
3231
|
+
if (allowPosAvailabilityOverride && !validation.ok && validation.conflicts.every(conflict => POS_OVERRIDABLE_AVAILABILITY_CONFLICT_CODES.has(conflict.code))) {
|
|
3232
|
+
const forced = buildPosForcedAvailabilityAssignments({
|
|
3233
|
+
product,
|
|
3234
|
+
resources: refreshedProjection.resources,
|
|
3235
|
+
candidate: refreshedCandidateBase,
|
|
3236
|
+
requirementSelections: params.requirementSelections,
|
|
3237
|
+
partySize
|
|
3238
|
+
});
|
|
3239
|
+
validation = forced.conflicts.length > 0 ? {
|
|
3240
|
+
...validation,
|
|
3241
|
+
assignments: [],
|
|
3242
|
+
conflicts: forced.conflicts
|
|
3243
|
+
} : {
|
|
3244
|
+
...validation,
|
|
3245
|
+
ok: true,
|
|
3246
|
+
status: validation.availability.status,
|
|
3247
|
+
assignments: forced.assignments,
|
|
3248
|
+
conflicts: []
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
if (!validation.ok) {
|
|
3252
|
+
const capacityConflict = validation.conflicts.find(conflict => conflict.code === 'resource_capacity_exceeded');
|
|
3253
|
+
throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', capacityConflict?.message || '缓存重校验后资源已不可提交', {
|
|
3254
|
+
validation,
|
|
3255
|
+
refreshedSnapshotId: refreshedProjection.meta.snapshotId
|
|
3256
|
+
});
|
|
3257
|
+
}
|
|
3258
|
+
// Revalidation failure must leave the caller's candidate reusable. Only publish the
|
|
3259
|
+
// refreshed projection/version after candidate and resource validation has passed;
|
|
3260
|
+
// otherwise a second Confirm from the still-open modal would incorrectly become stale.
|
|
3261
|
+
const revalidationEntry = this.storeAvailabilityProjectionEntry({
|
|
3262
|
+
contextId: normalizedContextId,
|
|
3263
|
+
prepareParams: commitSnapshot.prepareParams,
|
|
3264
|
+
prepareKey: context.prepareKey,
|
|
3265
|
+
projection: refreshedProjection,
|
|
3266
|
+
orderRevision: commitSnapshot.orderRevision,
|
|
3267
|
+
incrementVersion: true
|
|
3268
|
+
});
|
|
3269
|
+
const revalidatedContext = this.getAvailabilityContextEntry(normalizedContextId);
|
|
3270
|
+
await this.emitAvailabilityDiagnostic({
|
|
3271
|
+
action: 'refresh',
|
|
3272
|
+
contextId: normalizedContextId,
|
|
3273
|
+
contextVersion: revalidatedContext.activeVersion,
|
|
3274
|
+
projection: refreshedProjection,
|
|
3275
|
+
snapshotId: revalidationEntry.snapshotId,
|
|
3276
|
+
orderRevision: revalidationEntry.orderRevision,
|
|
3277
|
+
previousOrderRevision: baseProjection.meta.orderRevision,
|
|
3278
|
+
reason: 'commit_cached_occupancy_revalidation'
|
|
3279
|
+
});
|
|
3280
|
+
commitWriteGuard = {
|
|
3281
|
+
contextId: normalizedContextId,
|
|
3282
|
+
expectedVersion: revalidatedContext.activeVersion,
|
|
3283
|
+
expectedRefreshNonce: revalidatedContext.refreshNonce,
|
|
3284
|
+
expectedLifecycleGeneration: this.availabilityLifecycleGeneration,
|
|
3285
|
+
expectedOrderRevision: revalidationEntry.orderRevision
|
|
1200
3286
|
};
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
3287
|
+
const refreshedCandidate = this.decorateAvailabilityCandidate(normalizedContextId, revalidatedContext.activeVersion, refreshedCandidateBase);
|
|
3288
|
+
const existingBooking = editingLine ? this.findLinkedBooking(editingLine) : undefined;
|
|
3289
|
+
const existingBookingUid = existingBooking ? getBookingUid(existingBooking) : '';
|
|
3290
|
+
if (params.mode === 'edit' && !existingBookingUid) {
|
|
3291
|
+
throw new _ResourcePlanner.AvailabilityError('PRODUCT_LINE_NOT_FOUND', '编辑商品行缺少关联 booking', {
|
|
3292
|
+
productLineUid: params.productLineUid
|
|
3293
|
+
});
|
|
3294
|
+
}
|
|
3295
|
+
this.assertAvailabilityCommitWriteCAS(commitWriteGuard);
|
|
3296
|
+
const booking = buildBookingFromAvailabilitySelection({
|
|
3297
|
+
assignments: validation.assignments,
|
|
3298
|
+
candidate: refreshedCandidate,
|
|
3299
|
+
product,
|
|
3300
|
+
resources: refreshedProjection.resources,
|
|
3301
|
+
bookingUid: existingBookingUid || undefined,
|
|
3302
|
+
holder: params.orderProduct?._extend?.holder ?? existingBooking?.holder ?? existingBooking?.metadata?.holder,
|
|
3303
|
+
holderIds: params.orderProduct?._extend?.holder_id ?? params.orderProduct?._extend?.holder?.form_record ?? existingBooking?.metadata?.holder_id ?? existingBooking?.holder?.form_record
|
|
3304
|
+
});
|
|
3305
|
+
const transformed = this.buildUnifiedOrderProduct({
|
|
3306
|
+
...params.orderProduct,
|
|
3307
|
+
num: bookingCount,
|
|
3308
|
+
quantity: bookingCount
|
|
3309
|
+
}, 'unified_booking_sales', product);
|
|
3310
|
+
if (!transformed) {
|
|
3311
|
+
throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'orderProduct 无法转换为订单行', {
|
|
3312
|
+
orderProduct: params.orderProduct
|
|
3313
|
+
});
|
|
3314
|
+
}
|
|
3315
|
+
transformed.num = bookingCount;
|
|
3316
|
+
transformed.metadata = {
|
|
3317
|
+
...(transformed.metadata || {}),
|
|
3318
|
+
unified_booking_sales: true,
|
|
3319
|
+
availability_candidate_key: refreshedCandidate.key,
|
|
3320
|
+
availability_context_id: normalizedContextId,
|
|
3321
|
+
availability_context_version: refreshedCandidate.availabilityContextVersion
|
|
3322
|
+
};
|
|
3323
|
+
let productLineUid = '';
|
|
3324
|
+
let bookingUid = '';
|
|
3325
|
+
let productLineUids = [];
|
|
3326
|
+
let bookingUids = [];
|
|
3327
|
+
if (params.mode === 'edit' && editingLine && params.productLineUid) {
|
|
3328
|
+
transformed.product_sku = transformed.product_sku ?? editingLine.product_sku;
|
|
3329
|
+
transformed.product_bundle = transformed.product_bundle ?? editingLine.product_bundle;
|
|
3330
|
+
transformed.booking_uid = existingBookingUid;
|
|
3331
|
+
transformed.metadata = {
|
|
3332
|
+
...(editingLine.metadata || {}),
|
|
3333
|
+
...(transformed.metadata || {}),
|
|
3334
|
+
unique_identification_number: params.productLineUid,
|
|
3335
|
+
booking_uid: existingBookingUid
|
|
3336
|
+
};
|
|
3337
|
+
await this.updateOrderProduct({
|
|
3338
|
+
product_id: transformed.product_id ?? editingLine.product_id,
|
|
3339
|
+
product_variant_id: transformed.product_variant_id ?? editingLine.product_variant_id ?? 0,
|
|
3340
|
+
unique_identification_number: params.productLineUid,
|
|
3341
|
+
updates: transformed,
|
|
3342
|
+
booking,
|
|
3343
|
+
...(params.allowBookingReprice !== undefined ? {
|
|
3344
|
+
allow_booking_reprice: params.allowBookingReprice
|
|
3345
|
+
} : {}),
|
|
3346
|
+
...(params.allowBookingDiscountReapply !== undefined ? {
|
|
3347
|
+
allow_booking_discount_reapply: params.allowBookingDiscountReapply
|
|
3348
|
+
} : {})
|
|
3349
|
+
});
|
|
3350
|
+
productLineUid = params.productLineUid;
|
|
3351
|
+
bookingUid = existingBookingUid;
|
|
3352
|
+
productLineUids = [productLineUid];
|
|
3353
|
+
bookingUids = [bookingUid];
|
|
3354
|
+
} else {
|
|
3355
|
+
const beforeProductLineUids = new Set((this.getTempOrder()?.products || []).map(line => getProductLineUid(line)).filter(Boolean));
|
|
3356
|
+
const beforeBookingUids = new Set((this.getTempOrder()?.bookings || []).map(item => getBookingUid(item)).filter(Boolean));
|
|
3357
|
+
const products = await this.addProductToOrder(transformed, booking, params.salesNotes?.length ? {
|
|
3358
|
+
salesNotes: params.salesNotes
|
|
3359
|
+
} : undefined);
|
|
3360
|
+
const addedLines = products.filter(line => sameAvailabilityId(line.product_id ?? line.id, refreshedCandidate.productId) && line.metadata?.availability_candidate_key === refreshedCandidate.key && !beforeProductLineUids.has(getProductLineUid(line)));
|
|
3361
|
+
productLineUids = addedLines.map(line => getProductLineUid(line)).filter(Boolean);
|
|
3362
|
+
const addedBookings = (this.getTempOrder()?.bookings || []).filter(item => item.metadata?.availability_candidate_key === refreshedCandidate.key && !beforeBookingUids.has(getBookingUid(item)));
|
|
3363
|
+
bookingUids = addedBookings.map(item => getBookingUid(item)).filter(Boolean);
|
|
3364
|
+
if (bookingUids.length === 0) {
|
|
3365
|
+
bookingUids = addedLines.map(line => String(line.booking_uid ?? line.metadata?.booking_uid ?? '')).filter(Boolean);
|
|
3366
|
+
}
|
|
3367
|
+
productLineUid = productLineUids[productLineUids.length - 1] || '';
|
|
3368
|
+
bookingUid = bookingUids[bookingUids.length - 1] || '';
|
|
3369
|
+
}
|
|
3370
|
+
this.invalidateAvailabilityContextsAfterCommit();
|
|
3371
|
+
const nextContext = this.getAvailabilityContextEntry(normalizedContextId);
|
|
3372
|
+
const result = {
|
|
3373
|
+
contextId: normalizedContextId,
|
|
3374
|
+
contextVersion: nextContext.activeVersion,
|
|
3375
|
+
tempOrder: this.getTempOrder(),
|
|
3376
|
+
validation,
|
|
3377
|
+
candidate: refreshedCandidate,
|
|
3378
|
+
productLineUid,
|
|
3379
|
+
bookingUid,
|
|
3380
|
+
productLineUids,
|
|
3381
|
+
bookingUids
|
|
3382
|
+
};
|
|
3383
|
+
await this.core.effects.emit(`${this.name}:${_types.UnifiedBookingSalesHooks.onAvailabilitySelectionCommitted}`, result);
|
|
3384
|
+
return result;
|
|
3385
|
+
} finally {
|
|
3386
|
+
await this.releaseAvailabilityCommitLock(normalizedContextId);
|
|
1211
3387
|
}
|
|
1212
|
-
const result = {
|
|
1213
|
-
tempOrder: this.getTempOrder(),
|
|
1214
|
-
assignments,
|
|
1215
|
-
validation,
|
|
1216
|
-
submitResult
|
|
1217
|
-
};
|
|
1218
|
-
await this.core.effects.emit(`${this.name}:${_types.UnifiedBookingSalesHooks.onPlannerCommitted}`, result);
|
|
1219
|
-
return result;
|
|
1220
3388
|
}
|
|
1221
3389
|
}
|
|
1222
3390
|
exports.UnifiedBookingSalesImpl = UnifiedBookingSalesImpl;
|