@pisell/pisellos 2.3.143 → 2.3.145

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.
Files changed (33) hide show
  1. package/dist/modules/BookingContext/utils/productExtend.d.ts +7 -0
  2. package/dist/modules/BookingContext/utils/productExtend.js +62 -18
  3. package/dist/modules/Order/index.d.ts +2 -1
  4. package/dist/modules/Order/index.js +380 -198
  5. package/dist/modules/Order/types.d.ts +9 -0
  6. package/dist/modules/Order/utils.d.ts +5 -3
  7. package/dist/modules/Order/utils.js +32 -11
  8. package/dist/modules/ResourcePlanner/planner.d.ts +2 -0
  9. package/dist/modules/ResourcePlanner/planner.js +327 -22
  10. package/dist/modules/ResourcePlanner/types.d.ts +29 -0
  11. package/dist/solution/BookingByStep/index.d.ts +1 -1
  12. package/dist/solution/BookingTicket/utils/addProductDecision.d.ts +0 -1
  13. package/dist/solution/BookingTicket/utils/addProductDecision.js +1 -1
  14. package/dist/solution/UnifiedBookingSales/index.d.ts +3 -0
  15. package/dist/solution/UnifiedBookingSales/index.js +432 -163
  16. package/dist/solution/UnifiedBookingSales/types.d.ts +14 -2
  17. package/lib/modules/BookingContext/utils/productExtend.d.ts +7 -0
  18. package/lib/modules/BookingContext/utils/productExtend.js +45 -2
  19. package/lib/modules/Order/index.d.ts +2 -1
  20. package/lib/modules/Order/index.js +92 -10
  21. package/lib/modules/Order/types.d.ts +9 -0
  22. package/lib/modules/Order/utils.d.ts +5 -3
  23. package/lib/modules/Order/utils.js +29 -10
  24. package/lib/modules/ResourcePlanner/planner.d.ts +2 -0
  25. package/lib/modules/ResourcePlanner/planner.js +247 -7
  26. package/lib/modules/ResourcePlanner/types.d.ts +29 -0
  27. package/lib/solution/BookingByStep/index.d.ts +1 -1
  28. package/lib/solution/BookingTicket/utils/addProductDecision.d.ts +0 -1
  29. package/lib/solution/BookingTicket/utils/addProductDecision.js +1 -1
  30. package/lib/solution/UnifiedBookingSales/index.d.ts +3 -0
  31. package/lib/solution/UnifiedBookingSales/index.js +220 -27
  32. package/lib/solution/UnifiedBookingSales/types.d.ts +14 -2
  33. package/package.json +1 -1
@@ -72,6 +72,11 @@ export interface UnifiedBookingSalesPrepareAvailabilityProjectionParams {
72
72
  }
73
73
  export interface AvailabilityContextRef {
74
74
  contextId: string;
75
+ /** POS/Kiosk duration protocol capability and business-day default. */
76
+ durationTimeSelection?: {
77
+ version: 1;
78
+ currentBusinessDate: string;
79
+ };
75
80
  }
76
81
  export interface ContextualProductTimeRange extends ProductTimeRange {
77
82
  availabilityContextId: string;
@@ -80,7 +85,7 @@ export interface ContextualProductTimeRange extends ProductTimeRange {
80
85
  export interface ContextualProductTimeRangeGroup extends Omit<ProductTimeRangeGroup, 'ranges'> {
81
86
  ranges: ContextualProductTimeRange[];
82
87
  }
83
- export type ContextualAvailabilityQuery = Omit<Extract<AvailabilityQuery, {
88
+ export type ContextualAvailabilityQuery = (Omit<Extract<AvailabilityQuery, {
84
89
  target: 'product';
85
90
  }>, never> | Omit<Extract<AvailabilityQuery, {
86
91
  target: 'date';
@@ -92,7 +97,10 @@ export type ContextualAvailabilityQuery = Omit<Extract<AvailabilityQuery, {
92
97
  target: 'resource';
93
98
  }>, 'selectedRanges'> & {
94
99
  selectedRanges: ContextualProductTimeRange[];
95
- });
100
+ })) & {
101
+ /** Automatic remembered selection must not inherit POS manual exceptions. */
102
+ selectionIntent?: 'remembered' | 'manual';
103
+ };
96
104
  export type ContextualTimeAvailabilityItem = Omit<TimeAvailabilityItem, keyof ProductTimeRange> & ContextualProductTimeRange;
97
105
  export interface ContextualTimeAvailabilityGroup extends Omit<TimeAvailabilityGroup, 'times'> {
98
106
  times: ContextualTimeAvailabilityItem[];
@@ -155,6 +163,10 @@ export interface UnifiedBookingSalesAddRetailProductParams {
155
163
  }
156
164
  export interface ContextualCommitAvailabilitySelectionParams {
157
165
  mode: 'add' | 'edit';
166
+ /** Only POS duration add supports automatic remembered-time commits. */
167
+ selectionIntent?: 'remembered' | 'manual';
168
+ /** Local UI intent guard, checked again immediately before the Order mutation. */
169
+ availabilityCommitGuard?: () => void;
158
170
  productLineUid?: string;
159
171
  orderProduct: Record<string, any>;
160
172
  candidate: ContextualProductTimeRange;
@@ -7,6 +7,13 @@ import { TimeSlicesResult, TimeSlice, DurationSlice } from './timeSlices';
7
7
  * 让 OS 这层完全可在 Node 单测下运行(dayjs 在 Node 也是纯函数)。
8
8
  */
9
9
  export interface BookingCalcContext {
10
+ /** Sales channel; remembered duration starts are accepted only by POS. */
11
+ channel?: string;
12
+ /** Start-only device preference for this decision; never reuse a cached end/resource. */
13
+ rememberedTime?: {
14
+ businessDate: string;
15
+ startAt: string;
16
+ };
10
17
  /** 来自 `/core/board/management/config` 的整套配置;常用路径见 sliceTimeSlices / appointmentTimingPreference。 */
11
18
  bookingConfig?: any;
12
19
  /** 资源 origin map(id → resource origin)。 */
@@ -28,6 +28,44 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de
28
28
  * 让 OS 这层完全可在 Node 单测下运行(dayjs 在 Node 也是纯函数)。
29
29
  */
30
30
 
31
+ /** Rebuild only the derived booking fields; SKU, quantity, notes and Holder stay intact. */
32
+ function seedRememberedBookingStart(cacheItem, ctx) {
33
+ const remembered = ctx.rememberedTime;
34
+ const invalid = () => Object.assign(new Error('Remembered time requires a valid POS duration start.'), {
35
+ code: 'INVALID_REMEMBERED_TIME'
36
+ });
37
+ const duration = cacheItem?.duration;
38
+ const durationType = typeof duration === 'object' && duration ? String(duration.type || '').toLowerCase() : '';
39
+ const durationValue = typeof duration === 'object' && duration ? duration.value : duration;
40
+ const isDuration = (0, _flexible.isProductFlexibleDuration)(duration) || ['', 'fixed', 'minute', 'minutes', 'hour', 'hours'].includes(durationType) && Number.isFinite(Number(durationValue)) && Number(durationValue) > 0;
41
+ const isCalendarDate = value => {
42
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
43
+ const date = new Date(`${value}T00:00:00.000Z`);
44
+ return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value;
45
+ };
46
+ const parts = typeof remembered?.startAt === 'string' ? /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(?:Z|[+-](\d{2}):(\d{2}))?$/.exec(remembered.startAt) : null;
47
+ if (String(ctx.channel || '').trim().toLowerCase() !== 'pos' || !isDuration || (0, _serviceTimes.isMultiDayProduct)(cacheItem) || (0, _serviceTimes.isSessionProduct)(cacheItem) || cacheItem?.cartDetailValue?.session || cacheItem?._extend?.other?.session || !isCalendarDate(remembered?.businessDate) || !parts || !isCalendarDate(parts[1]) || Number(parts[2]) > 23 || Number(parts[3]) > 59 || Number(parts[4] || 0) > 59 || Number(parts[5] || 0) > 23 || Number(parts[6] || 0) > 59) throw invalid();
48
+ // Parse the actual instant with the same runtime clock used by the legacy booking helpers.
49
+ // The business-day label must not replace the calendar date of an overnight start.
50
+ const start = (0, _dayjs.default)(remembered.startAt);
51
+ if (!start.isValid()) throw invalid();
52
+ const extend = {
53
+ ...(cacheItem?._extend || {})
54
+ };
55
+ for (const key of ['start_date', 'startDate', 'start_time', 'end_date', 'endDate', 'end_time', 'duration', 'resource', 'resources', 'capacity', 'booking', 'availabilitySelection']) delete extend[key];
56
+ return {
57
+ ...cacheItem,
58
+ new: 1,
59
+ autoClose: false,
60
+ _data: {},
61
+ _extend: {
62
+ ...extend,
63
+ start_date: start.format('YYYY-MM-DD'),
64
+ start_time: start.format(start.millisecond() ? 'HH:mm:ss.SSS' : start.second() ? 'HH:mm:ss' : 'HH:mm')
65
+ }
66
+ };
67
+ }
68
+
31
69
  /** 从 BookingContext.date 提取 HH:mm(仅当日期带有效时分时)。 */
32
70
  function derivePresetStartTimeFromDate(date) {
33
71
  if (date == null) return undefined;
@@ -195,10 +233,15 @@ function getServiceStartTimeAndEndTime(params) {
195
233
  */
196
234
  function getProductExtend(params) {
197
235
  const {
198
- cacheItem,
199
- ctx,
200
236
  isDisabledTime
201
237
  } = params;
238
+ const hasRememberedTime = params.ctx.rememberedTime !== undefined;
239
+ const cacheItem = hasRememberedTime ? seedRememberedBookingStart(params.cacheItem, params.ctx) : params.cacheItem;
240
+ const ctx = hasRememberedTime ? {
241
+ ...params.ctx,
242
+ previousService: undefined,
243
+ presetStartTime: undefined
244
+ } : params.ctx;
202
245
  const isNew = cacheItem.new || !cacheItem._extend?.endDate;
203
246
  const _cacheItem = {
204
247
  ...cacheItem
@@ -332,6 +332,7 @@ export declare class OrderModule extends BaseModule implements Module, OrderModu
332
332
  private applyOrderProductsDepositSummary;
333
333
  recalculateSummary(options?: {
334
334
  createIfMissing?: boolean;
335
+ isCurrentOrder?: () => boolean;
335
336
  }): Promise<OrderSummary | null>;
336
337
  getOrderSummary(): Promise<OrderSummary>;
337
338
  updateTempOrderNote(note: string): string;
@@ -592,7 +593,7 @@ export declare class OrderModule extends BaseModule implements Module, OrderModu
592
593
  generateIdempotencyToken(): string;
593
594
  syncPaymentsToOrder<T = any>(params: SyncPaymentsToOrderParams): Promise<SyncPaymentsToOrderResult<T>>;
594
595
  createOrder(params: CommitOrderParams['query']): {
595
- type: "virtual" | "appointment_booking";
596
+ type: "appointment_booking" | "virtual";
596
597
  platform: string;
597
598
  sales_channel: string;
598
599
  order_sales_channel: string;
@@ -913,7 +913,8 @@ class OrderModule extends _BaseModule.BaseModule {
913
913
  * - 不主动调用 applyDiscount / recalculateSummary,调用方负责跟进;
914
914
  * - 返回的 payload 只能在 applyDiscount(商品写路径还需 summary/persist)完成后发布。
915
915
  */
916
- async evaluatePromotion() {
916
+ async evaluatePromotion(options) {
917
+ if (options?.isCurrentOrder && !options.isCurrentOrder()) return null;
917
918
  if (this.isApplyingPromotion || this.isEmittingPromotionApplied) {
918
919
  return null;
919
920
  }
@@ -974,6 +975,10 @@ class OrderModule extends _BaseModule.BaseModule {
974
975
  continue;
975
976
  }
976
977
  }
978
+ const giftMutationBaseline = options?.isCurrentOrder ? (0, _lodashEs.cloneDeep)({
979
+ products: tempOrder.products,
980
+ bookings: tempOrder.bookings
981
+ }) : null;
977
982
  try {
978
983
  const giftResult = await this.giftSelectResolver({
979
984
  strategyId: addAction.strategyId,
@@ -982,6 +987,11 @@ class OrderModule extends _BaseModule.BaseModule {
982
987
  giftOptions: addAction.giftOptions,
983
988
  sourceProductIds: addAction.sourceProductIds
984
989
  });
990
+ if (options?.isCurrentOrder && !options.isCurrentOrder()) return null;
991
+ if (options?.isCurrentOrder && !(0, _lodashEs.isEqual)({
992
+ products: tempOrder.products,
993
+ bookings: tempOrder.bookings
994
+ }, giftMutationBaseline)) return null;
985
995
  const giftProducts = Array.isArray(giftResult) ? giftResult : giftResult?.products;
986
996
  const giftBookings = Array.isArray(giftResult) ? [] : giftResult?.bookings || [];
987
997
  if (Array.isArray(giftProducts) && giftProducts.length > 0) {
@@ -1002,12 +1012,18 @@ class OrderModule extends _BaseModule.BaseModule {
1002
1012
  }
1003
1013
  }
1004
1014
  } catch (error) {
1015
+ if (options?.isCurrentOrder && !options.isCurrentOrder()) return null;
1016
+ if (options?.isCurrentOrder && !(0, _lodashEs.isEqual)({
1017
+ products: tempOrder.products,
1018
+ bookings: tempOrder.bookings
1019
+ }, giftMutationBaseline)) return null;
1005
1020
  // 用户取消 / 选择失败 → 视为放弃本次新增
1006
1021
  console.warn('[OrderModule] giftSelectResolver rejected, skip add gift', addAction.strategyId, error);
1007
1022
  }
1008
1023
  }
1009
1024
  }
1010
1025
  }
1026
+ if (options?.isCurrentOrder && !options.isCurrentOrder()) return null;
1011
1027
  tempOrder.products = result.products;
1012
1028
  this.lastUnfulfilledPromotions = result.unfulfilledPromotions;
1013
1029
  this.lastGiftActions = result.giftActions;
@@ -2469,6 +2485,7 @@ class OrderModule extends _BaseModule.BaseModule {
2469
2485
  return shouldUseManualDepositOverride ? nextSummary : this.applyLastOrderDepositSnapshot(tempOrder, nextSummary);
2470
2486
  }
2471
2487
  async recalculateSummary(options) {
2488
+ if (options?.isCurrentOrder && !options.isCurrentOrder()) return null;
2472
2489
  const tempOrder = options?.createIfMissing ? this.ensureTempOrder() : this.store.tempOrder;
2473
2490
  if (!tempOrder) return null;
2474
2491
  this.sanitizeTempOrderProducts(tempOrder);
@@ -2478,7 +2495,7 @@ class OrderModule extends _BaseModule.BaseModule {
2478
2495
  const snapshotSummary = shouldReuseOriginalSnapshot ? this.restoreOriginalSnapshotIfProductsUnchanged(tempOrder, originalSnapshot) : null;
2479
2496
  if (snapshotSummary) return snapshotSummary;
2480
2497
  const shouldFullRecalculateFromSnapshot = Boolean(originalSnapshot);
2481
- const summaryProducts = shouldFullRecalculateFromSnapshot ? (0, _lodashEs.cloneDeep)(tempOrder.products || []) : tempOrder.products;
2498
+ const summaryProducts = shouldFullRecalculateFromSnapshot || options?.isCurrentOrder ? (0, _lodashEs.cloneDeep)(tempOrder.products || []) : tempOrder.products;
2482
2499
  if (shouldFullRecalculateFromSnapshot) {
2483
2500
  this.clearPersistedAmountFieldsForFullRecalc(summaryProducts || []);
2484
2501
  }
@@ -2516,6 +2533,10 @@ class OrderModule extends _BaseModule.BaseModule {
2516
2533
  });
2517
2534
  return this.store.summary;
2518
2535
  }
2536
+ const summaryMutationBaseline = options?.isCurrentOrder ? (0, _lodashEs.cloneDeep)({
2537
+ tempOrder,
2538
+ summary: this.store.summary
2539
+ }) : null;
2519
2540
  const salesSummaryResult = await salesSummary.getSummary({
2520
2541
  products: summaryProducts,
2521
2542
  isPriceIncludeTax: tempOrder.is_price_include_tax,
@@ -2525,7 +2546,12 @@ class OrderModule extends _BaseModule.BaseModule {
2525
2546
  channel: this.otherParams.channel
2526
2547
  } : {})
2527
2548
  });
2528
- if (shouldFullRecalculateFromSnapshot) {
2549
+ if (options?.isCurrentOrder && !options.isCurrentOrder()) return null;
2550
+ if (options?.isCurrentOrder && !(0, _lodashEs.isEqual)({
2551
+ tempOrder,
2552
+ summary: this.store.summary
2553
+ }, summaryMutationBaseline)) return null;
2554
+ if (shouldFullRecalculateFromSnapshot || options?.isCurrentOrder) {
2529
2555
  tempOrder.products = summaryProducts;
2530
2556
  }
2531
2557
  this.syncSummaryProductMetadata(tempOrder.products || []);
@@ -3454,20 +3480,30 @@ class OrderModule extends _BaseModule.BaseModule {
3454
3480
  * await this.finalizeProductOrderMutation(tempOrder);
3455
3481
  */
3456
3482
  async finalizeProductOrderMutation(tempOrder, options) {
3483
+ const isCurrentOrder = options?.isCurrentOrder;
3484
+ if (isCurrentOrder && !isCurrentOrder()) return;
3457
3485
  this.syncTempOrderHolderFromBookings(tempOrder);
3458
3486
  this.prepareVirtualSettlementTempOrder(tempOrder);
3459
3487
  const isVirtualSettlement = this.isActiveVirtualSettlementOrder(tempOrder);
3460
3488
  if (!options?.skipEditDiscountConfigRefresh && !isVirtualSettlement) {
3461
3489
  await this.ensureEditDiscountConfigForProductChange(tempOrder);
3462
3490
  }
3463
- const promotionPayload = !options?.skipDiscountRecalculation && !isVirtualSettlement ? await this.evaluatePromotion() : null;
3491
+ if (isCurrentOrder && !isCurrentOrder()) return;
3492
+ const promotionPayload = !options?.skipDiscountRecalculation && !isVirtualSettlement ? await this.evaluatePromotion(isCurrentOrder ? {
3493
+ isCurrentOrder
3494
+ } : undefined) : null;
3495
+ if (isCurrentOrder && !isCurrentOrder()) return;
3464
3496
  if (!options?.skipDiscountRecalculation && !isVirtualSettlement) {
3465
3497
  this.applyDiscount();
3466
3498
  }
3467
3499
  this.sanitizeTempOrderProducts(tempOrder);
3468
- await this.recalculateSummary({
3469
- createIfMissing: true
3500
+ const summary = await this.recalculateSummary({
3501
+ createIfMissing: true,
3502
+ ...(isCurrentOrder ? {
3503
+ isCurrentOrder
3504
+ } : {})
3470
3505
  });
3506
+ if (isCurrentOrder && (!isCurrentOrder() || !summary)) return;
3471
3507
  this.persistTempOrder();
3472
3508
  this.emitPromotionApplied(promotionPayload);
3473
3509
  }
@@ -3704,8 +3740,16 @@ class OrderModule extends _BaseModule.BaseModule {
3704
3740
  * // → tempOrder.products 新增 3 行,每行 num=1,bookings 3 条
3705
3741
  */
3706
3742
  async addProductToOrder(product, booking, options) {
3707
- const tempOrder = this.ensureTempOrder();
3708
- const beforeCommit = this.createCartIntentCommitGuard('add_product', tempOrder);
3743
+ const availabilityCommitGuard = options?.availabilityCommitGuard;
3744
+ availabilityCommitGuard?.();
3745
+ const liveTempOrder = this.ensureTempOrder();
3746
+ const beforeCommit = this.createCartIntentCommitGuard('add_product', liveTempOrder);
3747
+ // 自动预约在草稿上准备完整批次,避免拆票期间暴露半成品,或在失败时
3748
+ // 用整单快照回滚而抹掉其他入口的并发修改。人工路径保持原有写入行为。
3749
+ const tempOrder = availabilityCommitGuard ? (0, _lodashEs.cloneDeep)(liveTempOrder) : liveTempOrder;
3750
+ const guardedMutationFields = ['products', 'bookings', 'notes', '_extend', 'metadata', 'currency_code', 'currency_symbol', 'currency_format'];
3751
+ const readGuardedMutationFields = order => Object.fromEntries(guardedMutationFields.map(key => [key, order[key]]));
3752
+ const guardedMutationBaseline = availabilityCommitGuard ? (0, _lodashEs.cloneDeep)(readGuardedMutationFields(liveTempOrder)) : null;
3709
3753
  const salesNoteDrafts = (options?.salesNotes || []).map(draft => ({
3710
3754
  note_type: String(draft.note_type || '').trim(),
3711
3755
  text: {
@@ -3779,6 +3823,7 @@ class OrderModule extends _BaseModule.BaseModule {
3779
3823
  disableMerge: options?.disableMerge,
3780
3824
  beforeCommit
3781
3825
  });
3826
+ availabilityCommitGuard?.();
3782
3827
  attachSalesNoteDrafts(committedProduct);
3783
3828
  if (insertAtIndex !== undefined) insertAtIndex += 1;
3784
3829
  }
@@ -3788,6 +3833,7 @@ class OrderModule extends _BaseModule.BaseModule {
3788
3833
  disableMerge: options?.disableMerge,
3789
3834
  beforeCommit
3790
3835
  });
3836
+ availabilityCommitGuard?.();
3791
3837
  attachSalesNoteDrafts(committedProduct);
3792
3838
  }
3793
3839
  } else {
@@ -3796,18 +3842,54 @@ class OrderModule extends _BaseModule.BaseModule {
3796
3842
  disableMerge: options?.disableMerge,
3797
3843
  beforeCommit
3798
3844
  });
3845
+ availabilityCommitGuard?.();
3799
3846
  attachSalesNoteDrafts(committedProduct);
3800
3847
  }
3848
+ if (availabilityCommitGuard) {
3849
+ availabilityCommitGuard();
3850
+ beforeCommit();
3851
+ // 调用方的结构 revision 可能不包括备注、价格或结算元数据。
3852
+ // 本次会覆盖的字段也要保持原值,不能让草稿覆盖并发编辑。
3853
+ if (!(0, _lodashEs.isEqual)(readGuardedMutationFields(liveTempOrder), guardedMutationBaseline)) {
3854
+ throw Object.assign(new Error('预约加购期间订单已变化,请重新选择'), {
3855
+ code: 'AVAILABILITY_SELECTION_STALE'
3856
+ });
3857
+ }
3858
+ // 从最后一次门禁到真正新增之间不再 await。只发布本次新增会改写的
3859
+ // 字段;其余订单字段继续使用 live 对象,外部持有的订单引用也不变。
3860
+ Object.assign(liveTempOrder, readGuardedMutationFields(tempOrder));
3861
+ }
3801
3862
  } catch (error) {
3802
3863
  rollbackProductAndNotesMutation();
3803
3864
  throw error;
3804
3865
  }
3805
- await this.finalizeProductOrderMutation(tempOrder);
3866
+ // 发布后预约已加购;后续促销/summary 沿用既有收尾,不用自身新增后的
3867
+ // revision 再执行提交 CAS,也不会把已加购预约因收尾耗时而撤回。
3868
+ if (availabilityCommitGuard) {
3869
+ const committedIdentity = [liveTempOrder.order_id, liveTempOrder.external_sale_number];
3870
+ const isCurrentOrder = () => this.store.tempOrder === liveTempOrder && liveTempOrder.order_id === committedIdentity[0] && liveTempOrder.external_sale_number === committedIdentity[1] && (options?.availabilityCommitTargetGuard?.() ?? true);
3871
+ try {
3872
+ // 新增已发布,即使后续报价/促销收尾失败,也不能向调用方报告未加购。
3873
+ // 先保存已提交行;异步结果只允许写回仍属于本次提交的订单。
3874
+ if (isCurrentOrder()) this.persistTempOrder();
3875
+ await this.finalizeProductOrderMutation(liveTempOrder, {
3876
+ isCurrentOrder
3877
+ });
3878
+ } catch (error) {
3879
+ this.logWarning('addProductToOrder committed; finalization failed', {
3880
+ product_id: product.product_id,
3881
+ committed: true,
3882
+ error: error instanceof Error ? error.message : String(error)
3883
+ });
3884
+ }
3885
+ } else {
3886
+ await this.finalizeProductOrderMutation(liveTempOrder);
3887
+ }
3806
3888
  this.logInfo('addProductToOrder success', {
3807
3889
  product_id: product.product_id,
3808
3890
  with_booking: !!booking
3809
3891
  });
3810
- return tempOrder.products;
3892
+ return liveTempOrder.products;
3811
3893
  }
3812
3894
 
3813
3895
  /**
@@ -445,6 +445,15 @@ export interface AddProductToOrderOptions {
445
445
  disableMerge?: boolean;
446
446
  /** 商品行 UID 落定后,由 OrderModule 原子绑定到该行的 Sales Notes 草稿。 */
447
447
  salesNotes?: SalesNoteDraftInput[];
448
+ /**
449
+ * 自动预约加购的同步提交门禁;抛错时不发布本次新增。
450
+ * 带门禁时先在私有草稿中准备全部商品/booking,异步等待后及原子发布前重新调用。
451
+ * 门禁须校验当前订单身份/版本和真实时间;发布前不会因本次拆行改变订单版本。
452
+ * 不传时保留原有人工加购行为。
453
+ */
454
+ availabilityCommitGuard?: () => void;
455
+ /** 发布后的订单/业务归属检查;只用于阻止旧异步收尾写入新单,不再校验截止或 revision。 */
456
+ availabilityCommitTargetGuard?: () => boolean;
448
457
  }
449
458
  /**
450
459
  * 删除商品后的可选位置保持策略。
@@ -121,8 +121,9 @@ export declare function getBundleSellingMagnitude(bundle: Record<string, any> |
121
121
  * = `metadata.main_product_original_price` + Σ(bundle 原价 × num)。
122
122
  * - `metadata.source_product_price`:主商品/variant 基础价(已应用报价单),不含 option、
123
123
  * 不含折扣。**权威源**。
124
- * - `metadata.main_product_original_price` = source + Σ(option.price × option.num)(含 option、不含折扣)。
125
- * - `metadata.main_product_selling_price` = main_original − 主商品券 per-unit amount(含 option、含折扣)。
124
+ * - `product_sku.option[].add_price` 保留单规格折前单价;Rules 的 option.price 只用于本次计算。
125
+ * - `metadata.main_product_original_price` = source + Σ(option.add_price × option.num)(含 option、不含折扣)。
126
+ * - `metadata.main_product_selling_price` = main_original − 行内 per-unit amount(含主商品及 option 优惠)。
126
127
  * - `payment_price`:出站 payload 中为行 composite 已减整单折扣均摊价(无折扣时与 `selling_price` 同义)。
127
128
  *
128
129
  * Rules 钩子契约(hook_adapt 方案,Rules 内部保持不变):
@@ -131,8 +132,9 @@ export declare function getBundleSellingMagnitude(bundle: Record<string, any> |
131
132
  * `getProductOriginTotalPrice` 会自行叠加 option + bundle,喂 source 可避免 option 双加。
132
133
  * - `getProduct.total` = `selling_price × num`(行 composite × num),与 Rules 内部产出的
133
134
  * total(source + option + bundle)对齐。
135
+ * - `getProduct.options` 用单规格原价参与重算;主商品折后价从 main_selling 减去 option 折后合计反推。
134
136
  * - `setProduct` 反向流程:把 Rules 返回的 source-level `main_product_selling_price` / `price`
135
- * 加回 Σoptions,重新合成含 option 的 main_product_* 并派生 composite。
137
+ * 加回单规格原价合计,再减去 optionDiscountAmount;保留订单单规格原价供取消折扣及后续重算。
136
138
  * 优先级:
137
139
  * 1. `values.main_product_selling_price`(券应用分支,per-unit 券后 source-level 主价)
138
140
  * 2. `values.price`(还原分支 `restoredPrice`、good_pass 归零)
@@ -641,8 +641,9 @@ function getBundleSellingMagnitude(bundle) {
641
641
  * = `metadata.main_product_original_price` + Σ(bundle 原价 × num)。
642
642
  * - `metadata.source_product_price`:主商品/variant 基础价(已应用报价单),不含 option、
643
643
  * 不含折扣。**权威源**。
644
- * - `metadata.main_product_original_price` = source + Σ(option.price × option.num)(含 option、不含折扣)。
645
- * - `metadata.main_product_selling_price` = main_original − 主商品券 per-unit amount(含 option、含折扣)。
644
+ * - `product_sku.option[].add_price` 保留单规格折前单价;Rules 的 option.price 只用于本次计算。
645
+ * - `metadata.main_product_original_price` = source + Σ(option.add_price × option.num)(含 option、不含折扣)。
646
+ * - `metadata.main_product_selling_price` = main_original − 行内 per-unit amount(含主商品及 option 优惠)。
646
647
  * - `payment_price`:出站 payload 中为行 composite 已减整单折扣均摊价(无折扣时与 `selling_price` 同义)。
647
648
  *
648
649
  * Rules 钩子契约(hook_adapt 方案,Rules 内部保持不变):
@@ -651,8 +652,9 @@ function getBundleSellingMagnitude(bundle) {
651
652
  * `getProductOriginTotalPrice` 会自行叠加 option + bundle,喂 source 可避免 option 双加。
652
653
  * - `getProduct.total` = `selling_price × num`(行 composite × num),与 Rules 内部产出的
653
654
  * total(source + option + bundle)对齐。
655
+ * - `getProduct.options` 用单规格原价参与重算;主商品折后价从 main_selling 减去 option 折后合计反推。
654
656
  * - `setProduct` 反向流程:把 Rules 返回的 source-level `main_product_selling_price` / `price`
655
- * 加回 Σoptions,重新合成含 option 的 main_product_* 并派生 composite。
657
+ * 加回单规格原价合计,再减去 optionDiscountAmount;保留订单单规格原价供取消折扣及后续重算。
656
658
  * 优先级:
657
659
  * 1. `values.main_product_selling_price`(券应用分支,per-unit 券后 source-level 主价)
658
660
  * 2. `values.price`(还原分支 `restoredPrice`、good_pass 归零)
@@ -851,6 +853,14 @@ function createDefaultOrderRulesHooks(getBookings) {
851
853
  const effectiveNum = Number(num) > 0 ? Number(num) : 1;
852
854
  return new _decimal.default(Number(totalLike) || 0).dividedBy(effectiveNum).toDecimalPlaces(2).toString();
853
855
  };
856
+
857
+ // optionDiscountAmount 已是每件主商品内的单规格优惠合计,不再乘商品数量。
858
+ const getOptionDiscountAmount = discountList => {
859
+ return (discountList || []).reduce((total, discount) => {
860
+ if (discount?.metadata?.custom_product_bundle_map_id) return total;
861
+ return total.plus(Number(discount?.metadata?.optionDiscountAmount) || 0);
862
+ }, new _decimal.default(0));
863
+ };
854
864
  const getBundleIdentity = bundle => {
855
865
  return [bundle.bundle_id ?? bundle.id ?? '', bundle.bundle_product_id ?? bundle._bundle_product_id ?? bundle.product_id ?? '', bundle.bundle_variant_id ?? bundle.variant_id ?? bundle.product_variant_id ?? 0, bundle.bundle_group_id ?? bundle.group_id ?? ''].join('|');
856
866
  };
@@ -893,12 +903,14 @@ function createDefaultOrderRulesHooks(getBookings) {
893
903
  getProduct: product => {
894
904
  // source_product_price 是权威源;兜底链:source → mainSelling − options(反推)→ 行 selling_price
895
905
  const metadataAny = product.metadata || {};
896
- const optionSum = sumOptionUnitPrice(getProductSkuOptions(product));
906
+ const productOptions = getProductSkuOptions(product);
907
+ const optionSum = sumOptionUnitPrice(productOptions);
908
+ const optionSellingSum = optionSum.minus(getOptionDiscountAmount(product.discount_list));
897
909
  let sourcePrice;
898
910
  if (metadataAny.source_product_price !== undefined) {
899
911
  sourcePrice = String(metadataAny.source_product_price);
900
912
  } else if (metadataAny.main_product_selling_price !== undefined) {
901
- sourcePrice = new _decimal.default(Number(metadataAny.main_product_selling_price) || 0).minus(optionSum).toDecimalPlaces(2).toString();
913
+ sourcePrice = new _decimal.default(Number(metadataAny.main_product_selling_price) || 0).minus(optionSellingSum).toDecimalPlaces(2).toString();
902
914
  } else {
903
915
  sourcePrice = String(product.selling_price ?? 0);
904
916
  }
@@ -928,6 +940,11 @@ function createDefaultOrderRulesHooks(getBookings) {
928
940
  num: product.num || 1,
929
941
  discount_list: product.discount_list || [],
930
942
  bundle: product.product_bundle || [],
943
+ // 仅在 Rules 入参中映射 price,避免折后价污染订单的 add_price。
944
+ options: productOptions.map(option => ({
945
+ ...option,
946
+ price: option.add_price
947
+ })),
931
948
  booking_id: product.metadata?.booking_id || null,
932
949
  isClient: false,
933
950
  // 勿写死 false:否则 Rules 不会走 total≠origin_total 的手动价推断,
@@ -935,7 +952,7 @@ function createDefaultOrderRulesHooks(getBookings) {
935
952
  isManualDiscount: resolveRulesManualDiscountFlag(metadataAny),
936
953
  holder_id: product.metadata?.holder_id,
937
954
  startDate: getProductStartDate(product),
938
- main_product_selling_price: metadataAny.main_product_selling_price !== undefined ? new _decimal.default(Number(metadataAny.main_product_selling_price) || 0).minus(optionSum).toDecimalPlaces(2).toString() : undefined,
955
+ main_product_selling_price: metadataAny.main_product_selling_price !== undefined ? new _decimal.default(Number(metadataAny.main_product_selling_price) || 0).minus(optionSellingSum).toDecimalPlaces(2).toString() : undefined,
939
956
  inPromotion: metadataAny._promotion?.inPromotion === true,
940
957
  _promotion: metadataAny._promotion
941
958
  };
@@ -946,8 +963,10 @@ function createDefaultOrderRulesHooks(getBookings) {
946
963
  const existedSource = metadataAny.source_product_price ?? product.selling_price ?? '0';
947
964
  const nextOptions = getProductSkuOptions(product);
948
965
  const optionSum = sumOptionUnitPrice(nextOptions);
966
+ const nextDiscountList = values.discount_list ?? product.discount_list;
967
+ const optionSellingSum = optionSum.minus(getOptionDiscountAmount(nextDiscountList));
949
968
 
950
- // Rules 返回的 main_product_selling_price / price / total 均为 source-level。
969
+ // Rules 返回的 main_product_selling_price / price 为不含 option 的主商品价。
951
970
  // 优先级:main_product_selling_price(per-unit 券后 source)> price(还原/good_pass 分支)>
952
971
  // total/num(仅当 Rules 只给 total 时的兜底)> 保留当前 source。
953
972
  const nextSourceSellingPrice = values.main_product_selling_price !== undefined ? String(values.main_product_selling_price) : values.price !== undefined ? String(values.price) : values.total !== undefined ? toUnitPriceString(values.total, nextNum) : String(existedSource);
@@ -956,8 +975,8 @@ function createDefaultOrderRulesHooks(getBookings) {
956
975
  // 缺省时保留现有 source(券前 = 当前 source)。
957
976
  const nextSourceOriginalPrice = values.original_price !== undefined ? String(values.original_price) : String(existedSource);
958
977
 
959
- // 含 option 的 main_product_*
960
- const nextMainSellingPrice = new _decimal.default(Number(nextSourceSellingPrice) || 0).plus(optionSum).toDecimalPlaces(2).toFixed(2);
978
+ // 成交价只加回 option 折后合计;原价仍使用订单中保存的 option 原价。
979
+ const nextMainSellingPrice = new _decimal.default(Number(nextSourceSellingPrice) || 0).plus(optionSellingSum).toDecimalPlaces(2).toFixed(2);
961
980
  const nextMainOriginalPrice = new _decimal.default(Number(nextSourceOriginalPrice) || 0).plus(optionSum).toDecimalPlaces(2).toFixed(2);
962
981
  const nextBundle = values.bundle !== undefined ? mergeRulesBundlePrices(product.product_bundle, values.bundle) : product.product_bundle;
963
982
  const composedSellingPrice = composeLinePrice({
@@ -978,7 +997,7 @@ function createDefaultOrderRulesHooks(getBookings) {
978
997
  ...product,
979
998
  selling_price: composedSellingPrice,
980
999
  original_price: finalOriginalPrice,
981
- discount_list: values.discount_list ?? product.discount_list,
1000
+ discount_list: nextDiscountList,
982
1001
  num: values.quantity ?? product.num,
983
1002
  product_bundle: nextBundle,
984
1003
  metadata: {
@@ -1,5 +1,7 @@
1
1
  import type { AvailabilityProjection, AvailabilityProjectionInput, AvailabilityQuery, AvailabilityQueryResult, AvailabilitySelectionValidationParams, AvailabilitySelectionValidationResult, GetProductTimeRangesRequest, GetProductTimeRangesRuntimeOptions, ProductTimeRangeGroup } from './types';
2
2
  export declare function createAvailabilityProjection(input: AvailabilityProjectionInput): AvailabilityProjection;
3
+ /** Resolve the label without changing the real calendar timestamp. */
4
+ export declare function getDurationBusinessDate(projection: AvailabilityProjection, startAt: string): string;
3
5
  export declare function getProductTimeRanges(projection: AvailabilityProjection, request?: GetProductTimeRangesRequest, runtimeOptions?: GetProductTimeRangesRuntimeOptions): ProductTimeRangeGroup[];
4
6
  export declare function queryAvailabilityProjection(projection: AvailabilityProjection, request: AvailabilityQuery, runtimeOptions?: GetProductTimeRangesRuntimeOptions): AvailabilityQueryResult;
5
7
  export declare function validateAvailabilitySelection(projection: AvailabilityProjection, params: AvailabilitySelectionValidationParams, runtimeOptions?: GetProductTimeRangesRuntimeOptions): AvailabilitySelectionValidationResult;