@pisell/pisellos 2.2.299 → 2.2.301

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.
@@ -166,9 +166,65 @@ function addLeadWindow(base, unit, unitType, _timezoneName) {
166
166
  if (unitType === 'hours') return (0, _localClock.addLocalHours)(base, unit);
167
167
  return (0, _localClock.addLocalMinutes)(base, unit);
168
168
  }
169
+ function normalizedWindowContainsRange(windows, candidateRange, timezoneName) {
170
+ return windows.some(window => {
171
+ const normalized = normalizeAbsoluteRange(window, timezoneName);
172
+ return Boolean(normalized && contains(normalized, candidateRange));
173
+ });
174
+ }
175
+ function normalizedWindowsContainDate(windows, date, timezoneName) {
176
+ return windows.some(window => {
177
+ const normalized = normalizeAbsoluteRange(window, timezoneName);
178
+ return Boolean(normalized && formatDate(normalized.start, timezoneName) === date);
179
+ });
180
+ }
181
+ function isRangeFromExcludedWindows(params) {
182
+ const candidateDate = formatDate(params.candidateRange.start, params.timezoneName);
183
+ const hasNormalWindowOnDate = normalizedWindowsContainDate(params.normalWindows, candidateDate, params.timezoneName);
184
+ const hasExcludedWindowOnDate = normalizedWindowsContainDate(params.excludedWindows, candidateDate, params.timezoneName);
185
+ if (!hasNormalWindowOnDate && hasExcludedWindowOnDate) return true;
186
+ return !normalizedWindowContainsRange(params.normalWindows, params.candidateRange, params.timezoneName) && normalizedWindowContainsRange(params.excludedWindows, params.candidateRange, params.timezoneName);
187
+ }
188
+ function isCandidateFromExcludedScheduleDate(params) {
189
+ const {
190
+ projection,
191
+ product,
192
+ candidate,
193
+ candidateRange
194
+ } = params;
195
+ const operatingHours = projection.meta.defaults.operatingHours;
196
+ if (isRangeFromExcludedWindows({
197
+ normalWindows: operatingHours.windows,
198
+ excludedWindows: operatingHours.excludedWindows || [],
199
+ candidateRange,
200
+ timezoneName: projection.meta.timezone
201
+ })) {
202
+ return true;
203
+ }
204
+ if (product.kind !== 'session' && product.kind !== 'venue_slot') {
205
+ return false;
206
+ }
207
+ if (candidate.source !== 'session_schedule') {
208
+ const hasNormalRule = (product.sessionRules || []).some(rule => matchesScheduleRuleDate(rule, candidate.date, projection.meta.timezone));
209
+ const hasExcludedOverrideRule = (product.sessionRules || []).some(rule => !matchesScheduleRuleDate(rule, candidate.date, projection.meta.timezone) && matchesScheduleRuleDate(rule, candidate.date, projection.meta.timezone, true));
210
+ return !hasNormalRule && hasExcludedOverrideRule;
211
+ }
212
+ const candidateDateRange = {
213
+ startDate: candidate.date,
214
+ endDate: candidate.date
215
+ };
216
+ const candidateIdentity = getProductTimeRangeIdentity(candidate);
217
+ const normalRanges = getSessionRangesForProduct(projection, product, candidateDateRange);
218
+ if (normalRanges.some(range => getProductTimeRangeIdentity(range) === candidateIdentity)) {
219
+ return false;
220
+ }
221
+ return getSessionRangesForProduct(projection, product, candidateDateRange, true).some(range => getProductTimeRangeIdentity(range) === candidateIdentity);
222
+ }
169
223
  function evaluateCandidateTimeGate(params) {
170
224
  const {
225
+ projection,
171
226
  product,
227
+ candidate,
172
228
  candidateRange,
173
229
  evaluationContext,
174
230
  timezoneName
@@ -181,6 +237,18 @@ function evaluateCandidateTimeGate(params) {
181
237
  conflictCode: 'past'
182
238
  };
183
239
  }
240
+ if (evaluationContext.allowExcludedScheduleDateOverride && isCandidateFromExcludedScheduleDate({
241
+ projection,
242
+ product,
243
+ candidate,
244
+ candidateRange
245
+ })) {
246
+ return {
247
+ status: 'unavailable',
248
+ reasonCodes: ['schedule_excluded_date'],
249
+ conflictCode: 'schedule_excluded_date'
250
+ };
251
+ }
184
252
  const policy = product.cutOffPolicy;
185
253
  if (!policy) return null;
186
254
  if (resolveCutOffPolicyIssue(policy)) {
@@ -213,10 +281,12 @@ function evaluateCandidateTimeGate(params) {
213
281
  };
214
282
  }
215
283
  if (policy.type === 'before_start') {
216
- // Flexible-duration candidates start at minute precision. Keep the current
217
- // minute selectable instead of treating the freshly generated candidate as
218
- // already started because the query clock still contains seconds.
219
- const hasNotReachedStart = product.isFlexibleDuration ? floorToMinute(now) <= candidateRange.start : now < candidateRange.start;
284
+ // Flexible-duration candidates and POS fixed-duration anchors start at
285
+ // minute precision. Keep that current minute selectable instead of treating
286
+ // the freshly generated candidate as already started because the query
287
+ // clock still contains seconds.
288
+ const usesMinutePrecision = product.isFlexibleDuration || evaluationContext.useExactDurationCandidateMinute && product.kind === 'duration';
289
+ const hasNotReachedStart = usesMinutePrecision ? floorToMinute(now) <= candidateRange.start : now < candidateRange.start;
220
290
  if (hasNotReachedStart) return null;
221
291
  return {
222
292
  status: 'unavailable',
@@ -324,8 +394,8 @@ function normalizeCoverage(input) {
324
394
  }
325
395
  };
326
396
  }
327
- function normalizeResourceWindow(resource, coverage, timezoneName) {
328
- return mergeRanges((resource.windows || []).map(window => normalizeAbsoluteRange(window, timezoneName)).filter(window => Boolean(window)).map(window => clipRange(window, coverage)).filter(window => Boolean(window)));
397
+ function normalizeResourceWindow(resource, coverage, timezoneName, includeExcludedWindows = false) {
398
+ return mergeRanges([...(resource.windows || []), ...(includeExcludedWindows ? resource.excludedWindows || [] : [])].map(window => normalizeAbsoluteRange(window, timezoneName)).filter(window => Boolean(window)).map(window => clipRange(window, coverage)).filter(window => Boolean(window)));
329
399
  }
330
400
  function normalizeOccupancy(occupancy, coverage, timezoneName, fixedOffsetMinutes) {
331
401
  const normalized = normalizeAbsoluteRange(occupancy, timezoneName);
@@ -389,6 +459,9 @@ function normalizeResources(resources) {
389
459
  capacity: Math.max(1, Number(resource.capacity || 1) || 1),
390
460
  windows: (resource.windows || []).map(window => ({
391
461
  ...window
462
+ })),
463
+ excludedWindows: (resource.excludedWindows || []).map(window => ({
464
+ ...window
392
465
  }))
393
466
  }));
394
467
  }
@@ -408,15 +481,20 @@ function normalizeOperatingHours(params) {
408
481
  startAt: formatDateTime(range.start, params.timezoneName, params.fixedOffsetMinutes),
409
482
  endAt: formatDateTime(range.end, params.timezoneName, params.fixedOffsetMinutes)
410
483
  }));
411
- const windows = mergeRanges(sourceWindows.map(window => normalizeAbsoluteRange(window, params.timezoneName)).filter(window => Boolean(window)).map(window => clipRange(window, params.coverage)).filter(window => Boolean(window))).map(range => ({
484
+ const normalizeWindows = source => mergeRanges(source.map(window => normalizeAbsoluteRange(window, params.timezoneName)).filter(window => Boolean(window)).map(window => clipRange(window, params.coverage)).filter(window => Boolean(window))).map(range => ({
412
485
  startAt: formatDateTime(range.start, params.timezoneName, params.fixedOffsetMinutes),
413
486
  endAt: formatDateTime(range.end, params.timezoneName, params.fixedOffsetMinutes)
414
487
  }));
488
+ const windows = normalizeWindows(sourceWindows);
489
+ const excludedWindows = normalizeWindows(params.input?.excludedWindows || []);
415
490
  return {
416
491
  source: params.input?.source || params.fallbackSource,
417
492
  scheduleIds: uniqById(params.input?.scheduleIds || []),
418
493
  missingScheduleIds: uniqById(params.input?.missingScheduleIds || []),
419
- windows
494
+ windows,
495
+ ...(excludedWindows.length > 0 ? {
496
+ excludedWindows
497
+ } : {})
420
498
  };
421
499
  }
422
500
  function matchesRequirementGroup(requirementGroup, resource, product, partySize = 1) {
@@ -698,19 +776,19 @@ function resolveProjectionDateRange(projection, requestDateRange) {
698
776
  endDate
699
777
  };
700
778
  }
701
- function buildOperatingRanges(projection, dateRange) {
779
+ function buildOperatingRanges(projection, dateRange, includeExcludedWindows = false) {
702
780
  const coverage = {
703
781
  start: toDateStart(dateRange.startDate, projection.meta.timezone),
704
782
  end: toDateEnd(nextDate(dateRange.endDate), projection.meta.timezone)
705
783
  };
706
- return mergeRanges(projection.meta.defaults.operatingHours.windows.map(window => normalizeAbsoluteRange(window, projection.meta.timezone)).filter(window => Boolean(window)).map(window => clipRange(window, coverage)).filter(window => Boolean(window)));
784
+ return mergeRanges([...projection.meta.defaults.operatingHours.windows, ...(includeExcludedWindows ? projection.meta.defaults.operatingHours.excludedWindows || [] : [])].map(window => normalizeAbsoluteRange(window, projection.meta.timezone)).filter(window => Boolean(window)).map(window => clipRange(window, coverage)).filter(window => Boolean(window)));
707
785
  }
708
- function buildResourceWindowIndex(projection) {
786
+ function buildResourceWindowIndex(projection, includeExcludedWindows = false) {
709
787
  const coverage = {
710
788
  start: toDateTime(projection.meta.coverage.startAt, projection.meta.timezone),
711
789
  end: toDateTime(projection.meta.coverage.endAt, projection.meta.timezone)
712
790
  };
713
- return new Map(projection.resources.map(resource => [String(resource.id), normalizeResourceWindow(resource, coverage, projection.meta.timezone)]));
791
+ return new Map(projection.resources.map(resource => [String(resource.id), normalizeResourceWindow(resource, coverage, projection.meta.timezone, includeExcludedWindows)]));
714
792
  }
715
793
  function buildGroupWorkingRanges(params) {
716
794
  const {
@@ -727,7 +805,7 @@ function buildGroupWorkingRanges(params) {
727
805
  };
728
806
  const filteredResourceIdSet = resourceIds?.length ? new Set(resourceIds.map(String)) : null;
729
807
  const resourceWindows = projection.resources.filter(resource => matchesRequirementGroup(requirementGroup, resource, params.product, partySize)).filter(resource => !filteredResourceIdSet || filteredResourceIdSet.has(String(resource.id))).map(resource => {
730
- const windows = params.resourceWindowIndex?.get(String(resource.id)) || normalizeResourceWindow(resource, coverage, projection.meta.timezone);
808
+ const windows = params.resourceWindowIndex?.get(String(resource.id)) || normalizeResourceWindow(resource, coverage, projection.meta.timezone, params.includeExcludedWindows === true);
731
809
  return windows.map(window => clipRange(window, coverage)).filter(window => Boolean(window));
732
810
  });
733
811
  const requiredConcurrentResources = Math.max(requirementGroup.required === false ? 0 : 1, Number(requirementGroup.min ?? (requirementGroup.required === false ? 0 : 1)) || 0);
@@ -758,12 +836,12 @@ function buildGroupWorkingRanges(params) {
758
836
  }
759
837
  return mergeRanges(satisfiedRanges);
760
838
  }
761
- function materializeScheduleRules(rules, dateRange, timezoneName, fixedOffsetMinutes) {
839
+ function materializeScheduleRules(rules, dateRange, timezoneName, fixedOffsetMinutes, allowExcludedScheduleDates = false) {
762
840
  const dates = enumerateDates(dateRange.startDate, dateRange.endDate);
763
841
  const seen = new Map();
764
842
  rules.forEach(rule => {
765
843
  dates.forEach(date => {
766
- if (!matchesScheduleRuleDate(rule, date, timezoneName)) return;
844
+ if (!matchesScheduleRuleDate(rule, date, timezoneName, allowExcludedScheduleDates)) return;
767
845
  const start = toDateTimeInDate(date, rule.startTime, timezoneName);
768
846
  let end = toDateTimeInDate(date, rule.endTime, timezoneName);
769
847
  if (end <= start) end = toDateTimeInDate(nextDate(date, timezoneName), rule.endTime, timezoneName);
@@ -795,9 +873,9 @@ function materializeScheduleRules(rules, dateRange, timezoneName, fixedOffsetMin
795
873
  });
796
874
  return Array.from(seen.values());
797
875
  }
798
- function matchesScheduleRuleDate(rule, date, _timezoneName) {
876
+ function matchesScheduleRuleDate(rule, date, _timezoneName, allowExcludedScheduleDate = false) {
799
877
  if (rule.includeDates?.includes(date)) return true;
800
- if (rule.excludeDates?.includes(date)) return false;
878
+ if (!allowExcludedScheduleDate && rule.excludeDates?.includes(date)) return false;
801
879
  if (rule.startDate && date < rule.startDate) return false;
802
880
  if (rule.endDate && date > rule.endDate) return false;
803
881
  if (rule.mode === 'designation') {
@@ -841,7 +919,7 @@ function buildGroupResourceIndex(params) {
841
919
  });
842
920
  return index;
843
921
  }
844
- function getSessionRangesForProduct(projection, product, dateRange) {
922
+ function getSessionRangesForProduct(projection, product, dateRange, allowExcludedScheduleDates = false) {
845
923
  const fixedOffsetMinutes = (0, _localClock.extractFixedOffsetMinutes)(projection.meta.now) ?? (0, _localClock.captureRuntimeOffsetMinutes)();
846
924
  const ranges = (product.sessionRanges || []).map(range => {
847
925
  const normalized = normalizeAbsoluteRange(range, projection.meta.timezone);
@@ -866,7 +944,7 @@ function getSessionRangesForProduct(projection, product, dateRange) {
866
944
  primaryScheduleRef: range.scheduleRefs?.[0]
867
945
  };
868
946
  }).filter(Boolean);
869
- const ruleRanges = materializeScheduleRules(product.sessionRules || [], dateRange, projection.meta.timezone, fixedOffsetMinutes);
947
+ const ruleRanges = materializeScheduleRules(product.sessionRules || [], dateRange, projection.meta.timezone, fixedOffsetMinutes, allowExcludedScheduleDates);
870
948
  return dedupeProductRanges([...ranges, ...ruleRanges.map(range => ({
871
949
  ...range,
872
950
  key: `${String(product.id)}:${range.startAt}:${range.endAt}`,
@@ -916,7 +994,8 @@ function getProductTimeRanges(projection, request = {}, runtimeOptions = {}) {
916
994
  const dateRange = resolveProjectionDateRange(projection, request.dateRange);
917
995
  const slotStepMinutes = projection.meta.defaults.slotStepMinutes;
918
996
  const fixedOffsetMinutes = (0, _localClock.extractFixedOffsetMinutes)(projection.meta.now) ?? (0, _localClock.captureRuntimeOffsetMinutes)();
919
- const resourceWindowIndex = buildResourceWindowIndex(projection);
997
+ const allowExcludedScheduleDateOverride = runtimeOptions.allowExcludedScheduleDateOverride === true;
998
+ const resourceWindowIndex = buildResourceWindowIndex(projection, allowExcludedScheduleDateOverride);
920
999
  const filteredProductIdSet = request.productIds?.length ? new Set(request.productIds.map(String)) : null;
921
1000
  return projection.products.filter(product => !filteredProductIdSet || filteredProductIdSet.has(String(product.id))).map(product => {
922
1001
  if (product.kind === 'normal') {
@@ -929,7 +1008,7 @@ function getProductTimeRanges(projection, request = {}, runtimeOptions = {}) {
929
1008
  };
930
1009
  }
931
1010
  if (product.kind === 'session' || product.kind === 'venue_slot') {
932
- const fixedRanges = getSessionRangesForProduct(projection, product, dateRange);
1011
+ const fixedRanges = getSessionRangesForProduct(projection, product, dateRange, allowExcludedScheduleDateOverride);
933
1012
  const customRanges = [];
934
1013
  const customDurationMinutes = resolveRequestedSessionDurationMinutes(product, request, runtimeOptions.allowSessionCustomRanges === true);
935
1014
  const requestedStartTimes = resolveRequestedDurationStartTimes(product, request, runtimeOptions.allowSessionCustomRanges === true);
@@ -949,7 +1028,7 @@ function getProductTimeRanges(projection, request = {}, runtimeOptions = {}) {
949
1028
  };
950
1029
  }
951
1030
  const requiredGroups = (product.requirementGroups || []).filter(group => group.required !== false);
952
- const operatingRanges = buildOperatingRanges(projection, dateRange);
1031
+ const operatingRanges = buildOperatingRanges(projection, dateRange, allowExcludedScheduleDateOverride);
953
1032
  const operatingEndAt = dateRange.startDate === dateRange.endDate && operatingRanges.length > 0 ? formatDateTime(Math.max(...operatingRanges.map(range => range.end)), projection.meta.timezone, fixedOffsetMinutes) : undefined;
954
1033
  const resourceRanges = requiredGroups.length ? requiredGroups.map(group => buildGroupWorkingRanges({
955
1034
  projection,
@@ -958,7 +1037,8 @@ function getProductTimeRanges(projection, request = {}, runtimeOptions = {}) {
958
1037
  dateRange,
959
1038
  resourceIds: request.resourceIds,
960
1039
  partySize: normalizedPartySize,
961
- resourceWindowIndex
1040
+ resourceWindowIndex,
1041
+ includeExcludedWindows: allowExcludedScheduleDateOverride
962
1042
  })).reduce((current, ranges) => current === null ? ranges : intersectRangeLists(current, ranges), null) || [] : null;
963
1043
  const baseRanges = resourceRanges === null ? operatingRanges : intersectRangeLists(resourceRanges, operatingRanges);
964
1044
  const durationMinutes = resolveRequestedDurationMinutes(product, request);
@@ -972,7 +1052,8 @@ function getProductTimeRanges(projection, request = {}, runtimeOptions = {}) {
972
1052
  }
973
1053
  return floorToMinute(evaluatedAt);
974
1054
  })();
975
- const durationAnchor = ceilToTenMinutes(toDateTime(projection.meta.durationCandidateAnchorAt, projection.meta.timezone), projection.meta.timezone);
1055
+ const durationCandidateAnchor = toDateTime(projection.meta.durationCandidateAnchorAt, projection.meta.timezone);
1056
+ const durationAnchor = runtimeOptions.useExactDurationCandidateMinute ? floorToMinute(durationCandidateAnchor) : ceilToTenMinutes(durationCandidateAnchor, projection.meta.timezone);
976
1057
  const requestedStartTimes = resolveRequestedDurationStartTimes(product, request);
977
1058
  const ranges = [];
978
1059
  baseRanges.forEach(range => {
@@ -1177,7 +1258,9 @@ function buildEvaluationContext(params) {
1177
1258
  evaluatedAt,
1178
1259
  evaluatedAtMs: evaluatedAt,
1179
1260
  resourceIdSet: params.resourceIds?.length ? new Set(params.resourceIds.map(String)) : null,
1180
- selectionsByGroupKey
1261
+ selectionsByGroupKey,
1262
+ useExactDurationCandidateMinute: params.useExactDurationCandidateMinute === true,
1263
+ allowExcludedScheduleDateOverride: params.allowExcludedScheduleDateOverride === true
1181
1264
  };
1182
1265
  }
1183
1266
  function summarizeStatuses(statuses) {
@@ -1327,7 +1410,8 @@ function evaluateCandidateSummary(projection, candidate, request) {
1327
1410
  const evaluationContext = request.evaluationContext || buildEvaluationContext({
1328
1411
  projection,
1329
1412
  evaluatedAt: request.evaluatedAt,
1330
- resourceIds: request.resourceIds
1413
+ resourceIds: request.resourceIds,
1414
+ allowExcludedScheduleDateOverride: request.allowExcludedScheduleDateOverride
1331
1415
  });
1332
1416
  const requestedPartySize = normalizePartySize(request.partySize);
1333
1417
  const requiredGroups = (product.requirementGroups || []).filter(group => group.required !== false).map(requirementGroup => evaluateRequirementGroupSummary({
@@ -1341,7 +1425,9 @@ function evaluateCandidateSummary(projection, candidate, request) {
1341
1425
  groupResourceIndex: request.groupResourceIndex
1342
1426
  }));
1343
1427
  const gate = evaluateCandidateTimeGate({
1428
+ projection,
1344
1429
  product,
1430
+ candidate,
1345
1431
  candidateRange,
1346
1432
  evaluationContext,
1347
1433
  timezoneName: projection.meta.timezone
@@ -1383,7 +1469,8 @@ function evaluateCandidate(projection, candidate, request) {
1383
1469
  projection,
1384
1470
  evaluatedAt: request.evaluatedAt,
1385
1471
  resourceIds: request.resourceIds,
1386
- requirementSelections: request.requirementSelections
1472
+ requirementSelections: request.requirementSelections,
1473
+ allowExcludedScheduleDateOverride: request.allowExcludedScheduleDateOverride
1387
1474
  });
1388
1475
  const requestedPartySize = normalizePartySize(request.partySize);
1389
1476
  const partySize = requestedPartySize ?? 1;
@@ -1396,6 +1483,12 @@ function evaluateCandidate(projection, candidate, request) {
1396
1483
  const matchedResources = (request.groupResourceIndex?.get([String(product.id), requirementGroup.id].join(':')) || projection.resources.filter(resource => matchesRequirementGroup(requirementGroup, resource, product, partySize)).filter(resource => !evaluationContext.resourceIdSet || evaluationContext.resourceIdSet.has(String(resource.id)))).filter(resource => !selection || selection.resourceIds.some(resourceId => sameId(resourceId, resource.id)));
1397
1484
  const options = matchedResources.map(resource => {
1398
1485
  const matchedCell = aggregateMatchedCells(product.id, requirementGroup.id, resource.id, candidateRange, request.cellIndex?.get([String(product.id), requirementGroup.id, String(resource.id)].join(':')) || []);
1486
+ const isExcludedScheduleDate = evaluationContext.allowExcludedScheduleDateOverride && isRangeFromExcludedWindows({
1487
+ normalWindows: resource.windows || [],
1488
+ excludedWindows: resource.excludedWindows || [],
1489
+ candidateRange,
1490
+ timezoneName: projection.meta.timezone
1491
+ });
1399
1492
  if (!matchedCell) {
1400
1493
  const isPast = candidateRange.endMs <= evaluationContext.evaluatedAtMs;
1401
1494
  return {
@@ -1406,7 +1499,7 @@ function evaluateCandidate(projection, candidate, request) {
1406
1499
  resourceType: resource.resourceType,
1407
1500
  status: isPast ? 'past' : 'unavailable',
1408
1501
  maxPartySize: 0,
1409
- reasonCodes: isPast ? ['past'] : ['outside_resource_window']
1502
+ reasonCodes: isPast ? ['past'] : [...(isExcludedScheduleDate ? ['schedule_excluded_date'] : []), 'outside_resource_window']
1410
1503
  };
1411
1504
  }
1412
1505
  const remainingCapacity = matchedCell.remainingCapacity;
@@ -1414,6 +1507,7 @@ function evaluateCandidate(projection, candidate, request) {
1414
1507
  const isPast = candidateRange.endMs <= evaluationContext.evaluatedAtMs;
1415
1508
  const maxPartySize = isPast ? 0 : Math.max(0, remainingCapacity || 0);
1416
1509
  const requiredCapacity = resolveRequiredCapacity(requirementGroup, product, requestedPartySize);
1510
+ const reasonCodes = Array.from(new Set([...matchedCell.reasonCodes, ...(isExcludedScheduleDate ? ['schedule_excluded_date'] : [])]));
1417
1511
  const status = (() => {
1418
1512
  if (isPast) return 'past';
1419
1513
  if (matchedCell.status === 'unavailable') return 'unavailable';
@@ -1431,7 +1525,7 @@ function evaluateCandidate(projection, candidate, request) {
1431
1525
  remainingCapacity,
1432
1526
  maxCapacity,
1433
1527
  maxPartySize,
1434
- reasonCodes: isPast ? Array.from(new Set([...matchedCell.reasonCodes, 'past'])) : matchedCell.reasonCodes,
1528
+ reasonCodes: isPast ? Array.from(new Set([...reasonCodes, 'past'])) : reasonCodes,
1435
1529
  matchedCell
1436
1530
  };
1437
1531
  });
@@ -1471,7 +1565,9 @@ function evaluateCandidate(projection, candidate, request) {
1471
1565
  const requiredGroups = requirementGroups.filter(group => group.required);
1472
1566
  const requiredStatuses = requiredGroups.map(group => group.status);
1473
1567
  const gate = evaluateCandidateTimeGate({
1568
+ projection,
1474
1569
  product,
1570
+ candidate,
1475
1571
  candidateRange,
1476
1572
  evaluationContext,
1477
1573
  timezoneName: projection.meta.timezone
@@ -1547,7 +1643,7 @@ function assertRangesWithinProjection(projection, ranges) {
1547
1643
  }
1548
1644
  });
1549
1645
  }
1550
- function queryAvailabilityProjection(projection, request) {
1646
+ function queryAvailabilityProjection(projection, request, runtimeOptions = {}) {
1551
1647
  const normalizedPartySize = normalizePartySize(request.partySize);
1552
1648
  const evaluatedAt = formatDateTime((0, _localClock.localDateToScalar)(new Date()), projection.meta.timezone, (0, _localClock.extractFixedOffsetMinutes)(projection.meta.now) ?? (0, _localClock.captureRuntimeOffsetMinutes)());
1553
1649
  if (request.target === 'time') {
@@ -1570,7 +1666,9 @@ function queryAvailabilityProjection(projection, request) {
1570
1666
  const evaluationContext = buildEvaluationContext({
1571
1667
  projection,
1572
1668
  evaluatedAt,
1573
- resourceIds: request.resourceIds
1669
+ resourceIds: request.resourceIds,
1670
+ useExactDurationCandidateMinute: runtimeOptions.useExactDurationCandidateMinute,
1671
+ allowExcludedScheduleDateOverride: runtimeOptions.allowExcludedScheduleDateOverride
1574
1672
  });
1575
1673
  if (request.target === 'time') {
1576
1674
  const candidates = dedupeProductRanges(request.candidates);
@@ -1644,7 +1742,9 @@ function queryAvailabilityProjection(projection, request) {
1644
1742
  resourceIds: request.resourceIds,
1645
1743
  partySize: normalizedPartySize
1646
1744
  }, {
1647
- evaluatedAt
1745
+ evaluatedAt,
1746
+ useExactDurationCandidateMinute: runtimeOptions.useExactDurationCandidateMinute,
1747
+ allowExcludedScheduleDateOverride: runtimeOptions.allowExcludedScheduleDateOverride
1648
1748
  });
1649
1749
  const candidateGroupMap = new Map(candidateGroups.map(group => [String(group.productId), group]));
1650
1750
  if (request.target === 'date') {
@@ -1794,7 +1894,7 @@ function summarizeProductStatus(statuses) {
1794
1894
  if (statuses.every(status => status === 'past')) return 'past';
1795
1895
  return 'unavailable';
1796
1896
  }
1797
- function validateAvailabilitySelection(projection, params) {
1897
+ function validateAvailabilitySelection(projection, params, runtimeOptions = {}) {
1798
1898
  const normalizedPartySize = normalizePartySize(params.partySize);
1799
1899
  const normalizedBookingCount = normalizeBookingCount(params.bookingCount);
1800
1900
  assertRangesWithinProjection(projection, [params.candidate]);
@@ -1815,14 +1915,16 @@ function validateAvailabilitySelection(projection, params) {
1815
1915
  evaluationContext: buildEvaluationContext({
1816
1916
  projection,
1817
1917
  evaluatedAt,
1818
- requirementSelections: params.requirementSelections
1918
+ requirementSelections: params.requirementSelections,
1919
+ useExactDurationCandidateMinute: runtimeOptions.useExactDurationCandidateMinute,
1920
+ allowExcludedScheduleDateOverride: runtimeOptions.allowExcludedScheduleDateOverride
1819
1921
  })
1820
1922
  });
1821
1923
  const conflicts = [];
1822
1924
  if (evaluated.gate) {
1823
1925
  conflicts.push({
1824
1926
  code: evaluated.gate.conflictCode,
1825
- message: evaluated.gate.status === 'past' ? '候选时间已过期' : '候选时间不满足预订时间门禁',
1927
+ message: evaluated.gate.conflictCode === 'past' ? '候选时间已过期' : evaluated.gate.conflictCode === 'schedule_excluded_date' ? '候选日期已被日程排除' : '候选时间不满足预订时间门禁',
1826
1928
  reasonCodes: evaluated.gate.reasonCodes,
1827
1929
  productId: params.candidate.productId,
1828
1930
  candidateKey: params.candidate.key
@@ -101,6 +101,9 @@ export interface AvailabilityOperatingHoursSnapshot {
101
101
  scheduleIds: AvailabilityId[];
102
102
  missingScheduleIds: AvailabilityId[];
103
103
  windows: AvailabilityResourceWindow[];
104
+ /** Schedule windows omitted only because their date is excluded. POS may use
105
+ * these to surface an unavailable, manually selectable candidate. */
106
+ excludedWindows?: AvailabilityResourceWindow[];
104
107
  }
105
108
  export interface AvailabilityResource {
106
109
  id: AvailabilityId;
@@ -110,6 +113,8 @@ export interface AvailabilityResource {
110
113
  resourceType: AvailabilityResourceType;
111
114
  capacity: number;
112
115
  windows: AvailabilityResourceWindow[];
116
+ /** Resource work windows omitted only by an excluded schedule date. */
117
+ excludedWindows?: AvailabilityResourceWindow[];
113
118
  raw?: Record<string, any>;
114
119
  }
115
120
  export interface AvailabilityOccupancy extends AvailabilityAbsoluteRange {
@@ -245,6 +250,13 @@ export interface GetProductTimeRangesRequest {
245
250
  }
246
251
  export interface GetProductTimeRangesRuntimeOptions {
247
252
  evaluatedAt?: string;
253
+ /**
254
+ * Internal POS capability: fixed-duration candidates start from the stable
255
+ * context anchor's current minute instead of rounding up to ten minutes, and
256
+ * that minute remains selectable during before-start cutoff evaluation.
257
+ * Other callers keep the legacy alignment and strict second-level cutoff.
258
+ */
259
+ useExactDurationCandidateMinute?: boolean;
248
260
  /**
249
261
  * Internal POS capability: allow explicit duration/start overrides to append
250
262
  * a custom duration candidate for Session products. Fixed Session ranges are
@@ -252,6 +264,11 @@ export interface GetProductTimeRangesRuntimeOptions {
252
264
  * disabled.
253
265
  */
254
266
  allowSessionCustomRanges?: boolean;
267
+ /**
268
+ * Internal POS capability: materialize candidates whose schedule date was
269
+ * explicitly excluded, while availability evaluation keeps them unavailable.
270
+ */
271
+ allowExcludedScheduleDateOverride?: boolean;
255
272
  }
256
273
  export interface QueryAvailabilityBaseRequest {
257
274
  productIds?: AvailabilityId[];
@@ -638,6 +638,22 @@ function normalizeResource(raw, fixedOffsetMinutes = (0, _localClock.captureRunt
638
638
  const id = raw.resourceId ?? raw.resource_id ?? raw.id;
639
639
  if (id === undefined || id === null || id === '') return null;
640
640
  const rawWindows = raw.windows || raw.times || [];
641
+ const rawExcludedWindows = raw.excludedWindows || raw.excluded_windows || [];
642
+ const normalizeWindows = windows => (Array.isArray(windows) ? windows : []).flatMap(window => {
643
+ const range = normalizeAvailabilityLocalDateTimeRange({
644
+ startAt: window.startAt || window.start_at || window.start,
645
+ endAt: window.endAt || window.end_at || window.end,
646
+ fixedOffsetMinutes,
647
+ rollOverEnd: true
648
+ });
649
+ if (!range) return [];
650
+ return [{
651
+ startAt: range.startAt,
652
+ endAt: range.endAt,
653
+ source: window.source,
654
+ raw: window
655
+ }];
656
+ });
641
657
  return {
642
658
  id,
643
659
  formId: raw.formId ?? raw.form_id ?? raw.resource_form_id,
@@ -645,21 +661,8 @@ function normalizeResource(raw, fixedOffsetMinutes = (0, _localClock.captureRunt
645
661
  code: raw.code,
646
662
  resourceType: normalizeResourceType(raw.type || raw.resourceType),
647
663
  capacity: Math.max(1, Number(raw.capacity || raw.max_capacity || 1) || 1),
648
- windows: (Array.isArray(rawWindows) ? rawWindows : []).flatMap(window => {
649
- const range = normalizeAvailabilityLocalDateTimeRange({
650
- startAt: window.startAt || window.start_at || window.start,
651
- endAt: window.endAt || window.end_at || window.end,
652
- fixedOffsetMinutes,
653
- rollOverEnd: true
654
- });
655
- if (!range) return [];
656
- return [{
657
- startAt: range.startAt,
658
- endAt: range.endAt,
659
- source: window.source,
660
- raw: window
661
- }];
662
- }),
664
+ windows: normalizeWindows(rawWindows),
665
+ excludedWindows: normalizeWindows(rawExcludedWindows),
663
666
  raw
664
667
  };
665
668
  }
@@ -678,6 +681,17 @@ function dateRangeDays(dateRange) {
678
681
  }
679
682
  return dates;
680
683
  }
684
+ function getExcludedScheduleDateOverrideTimePoints(date, schedule) {
685
+ if (!schedule.repeat_rule?.excluded_date?.length) return [];
686
+ if ((0, _getDateIsInSchedule.getScheduleStartEndTimePoints)(date, [schedule]).length > 0) return [];
687
+ return (0, _getDateIsInSchedule.getScheduleStartEndTimePoints)(date, [{
688
+ ...schedule,
689
+ repeat_rule: {
690
+ ...schedule.repeat_rule,
691
+ excluded_date: []
692
+ }
693
+ }]);
694
+ }
681
695
  function normalizeResourceOccupancy(rawEvent, resourceId, source = 'remote', fixedOffsetMinutes = (0, _localClock.captureRuntimeOffsetMinutes)()) {
682
696
  const range = normalizeAvailabilityLocalDateTimeRange({
683
697
  startAt: rawEvent.start_at ?? rawEvent.startAt ?? rawEvent.start_time ?? rawEvent.startTime,
@@ -770,7 +784,26 @@ function buildAvailabilityResourcesFromV2(params) {
770
784
  }
771
785
  }];
772
786
  })));
787
+ const excludedWindows = dates.flatMap(date => schedules.flatMap(schedule => getExcludedScheduleDateOverrideTimePoints(date, schedule).flatMap(slot => {
788
+ const range = normalizeAvailabilityLocalDateTimeRange({
789
+ startAt: slot.start_at,
790
+ endAt: slot.end_at,
791
+ fixedOffsetMinutes,
792
+ rollOverEnd: true
793
+ });
794
+ if (!range) return [];
795
+ return [{
796
+ startAt: range.startAt,
797
+ endAt: range.endAt,
798
+ source: `resource_schedule_excluded:${schedule.id}`,
799
+ raw: {
800
+ scheduleId: schedule.id,
801
+ excludedDate: date
802
+ }
803
+ }];
804
+ })));
773
805
  resource.windows = materializedWindows.length > 0 ? materializedWindows : explicitWindows;
806
+ resource.excludedWindows = [...(resource.excludedWindows || []), ...excludedWindows];
774
807
  if (resource.windows.length === 0 && missingScheduleIds.length > 0) {
775
808
  resource.windows = buildResourceWindowFallbackTimes({
776
809
  raw,
@@ -1011,7 +1044,7 @@ function buildBookingFromAvailabilitySelection(params) {
1011
1044
  }
1012
1045
  };
1013
1046
  }
1014
- const POS_OVERRIDABLE_AVAILABILITY_CONFLICT_CODES = new Set(['past', 'booking_cutoff', 'resource_full', 'resource_unavailable', 'resource_capacity_exceeded',
1047
+ const POS_OVERRIDABLE_AVAILABILITY_CONFLICT_CODES = new Set(['past', 'booking_cutoff', 'schedule_excluded_date', 'resource_full', 'resource_unavailable', 'resource_capacity_exceeded',
1015
1048
  // A required group whose selected resource is outside its window is surfaced
1016
1049
  // as missing_resource at group level. Structural validation below still
1017
1050
  // rejects a genuinely missing selection.
@@ -2118,11 +2151,31 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
2118
2151
  source: `operating_schedule:${descriptor.scheduleIds.map(String).join(',')}`
2119
2152
  }] : [];
2120
2153
  }));
2154
+ const excludedWindows = dateRangeDays(params.dateRange).flatMap(date => schedules.flatMap(schedule => getExcludedScheduleDateOverrideTimePoints(date, schedule).flatMap(slot => {
2155
+ const range = normalizeAvailabilityLocalDateTimeRange({
2156
+ startAt: slot.start_at,
2157
+ endAt: slot.end_at,
2158
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
2159
+ rollOverEnd: true
2160
+ });
2161
+ return range ? [{
2162
+ startAt: range.startAt,
2163
+ endAt: range.endAt,
2164
+ source: `operating_schedule_excluded:${schedule.id}`,
2165
+ raw: {
2166
+ scheduleId: schedule.id,
2167
+ excludedDate: date
2168
+ }
2169
+ }] : [];
2170
+ })));
2121
2171
  return {
2122
2172
  source: descriptor.source,
2123
2173
  scheduleIds: [...descriptor.scheduleIds],
2124
2174
  missingScheduleIds,
2125
- windows
2175
+ windows,
2176
+ ...(excludedWindows.length > 0 ? {
2177
+ excludedWindows
2178
+ } : {})
2126
2179
  };
2127
2180
  }
2128
2181
  async loadAvailabilityProducts(params) {
@@ -2820,7 +2873,9 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
2820
2873
  } : {})
2821
2874
  }, {
2822
2875
  evaluatedAt: this.getAvailabilityRuntimeDateTime(),
2823
- allowSessionCustomRanges: isPosAvailabilityRequest
2876
+ useExactDurationCandidateMinute: isPosAvailabilityRequest,
2877
+ allowSessionCustomRanges: isPosAvailabilityRequest,
2878
+ allowExcludedScheduleDateOverride: isPosAvailabilityRequest
2824
2879
  }).map(group => ({
2825
2880
  ...group,
2826
2881
  ranges: group.ranges.map(candidate => this.decorateAvailabilityCandidate(context.contextId, contextVersion, candidate))
@@ -2835,7 +2890,11 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
2835
2890
  refreshed
2836
2891
  } = await this.ensureAvailabilityContextProjection(contextId, 'query_booking_availability');
2837
2892
  this.assertContextualQueryFreshness(context, request);
2838
- const result = (0, _ResourcePlanner.queryAvailabilityProjection)(projectionEntry.projection, request);
2893
+ const isPosAvailabilityRequest = String(this.otherParams?.platform ?? '').trim().toLowerCase() === 'pos';
2894
+ const result = (0, _ResourcePlanner.queryAvailabilityProjection)(projectionEntry.projection, request, {
2895
+ useExactDurationCandidateMinute: isPosAvailabilityRequest,
2896
+ allowExcludedScheduleDateOverride: isPosAvailabilityRequest
2897
+ });
2839
2898
  const contextVersion = context.activeVersion;
2840
2899
  const contextualResult = result.target === 'time' ? {
2841
2900
  ...result,
@@ -3092,7 +3151,9 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
3092
3151
  } : {})
3093
3152
  } : {})
3094
3153
  }, {
3095
- allowSessionCustomRanges: allowPosAvailabilityOverride
3154
+ useExactDurationCandidateMinute: allowPosAvailabilityOverride,
3155
+ allowSessionCustomRanges: allowPosAvailabilityOverride,
3156
+ allowExcludedScheduleDateOverride: allowPosAvailabilityOverride
3096
3157
  }).flatMap(group => group.ranges);
3097
3158
  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);
3098
3159
  if (!refreshedCandidateBase) {
@@ -3113,6 +3174,9 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
3113
3174
  requirementSelections: params.requirementSelections,
3114
3175
  partySize,
3115
3176
  bookingCount
3177
+ }, {
3178
+ useExactDurationCandidateMinute: allowPosAvailabilityOverride,
3179
+ allowExcludedScheduleDateOverride: allowPosAvailabilityOverride
3116
3180
  });
3117
3181
  if (allowPosAvailabilityOverride && !validation.ok && validation.conflicts.every(conflict => POS_OVERRIDABLE_AVAILABILITY_CONFLICT_CODES.has(conflict.code))) {
3118
3182
  const forced = buildPosForcedAvailabilityAssignments({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "private": false,
3
3
  "name": "@pisell/pisellos",
4
- "version": "2.2.299",
4
+ "version": "2.2.301",
5
5
  "description": "一个可扩展的前端模块化SDK框架,支持插件系统",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",